From 8bad4e93a89879a6ffea1e67d0148436957cfea6 Mon Sep 17 00:00:00 2001 From: lbgee Date: Sat, 18 Jul 2026 15:52:22 -0700 Subject: [PATCH 1/4] new calibration mode up to Zr using foil emission energies --- XSpect/analysis/xes.py | 132 ++++++++++++++++++ .../mfx102101026_aggregate_xes.ipynb | 34 ++++- 2 files changed, 162 insertions(+), 4 deletions(-) diff --git a/XSpect/analysis/xes.py b/XSpect/analysis/xes.py index 7b795e4..3a11a87 100644 --- a/XSpect/analysis/xes.py +++ b/XSpect/analysis/xes.py @@ -109,6 +109,138 @@ def make_energy_axis(run, **kwargs): ) +# Elemental emission line energies (eV). Source: standard X-ray data tables +# (e.g. Bearden / NIST). Used by calibrate_energy to fit the geometry against a +# known foil line. Add elements/lines as needed. +EMISSION_LINES = { + "Ti": {"Ka1": 4510.84, "Ka2": 4504.86, "Kb1": 4931.81}, + "V": {"Ka1": 4952.20, "Ka2": 4944.64, "Kb1": 5427.29}, + "Cr": {"Ka1": 5414.72, "Ka2": 5405.509, "Kb1": 5946.71}, + "Mn": {"Ka1": 5898.75, "Ka2": 5887.65, "Kb1": 6490.45}, + "Fe": {"Ka1": 6403.84, "Ka2": 6390.84, "Kb1": 7057.98}, + "Co": {"Ka1": 6930.32, "Ka2": 6915.30, "Kb1": 7649.43}, + "Ni": {"Ka1": 7478.15, "Ka2": 7460.89, "Kb1": 8264.66}, + "Cu": {"Ka1": 8047.78, "Ka2": 8027.83, "Kb1": 8905.29}, + "Zn": {"Ka1": 8638.86, "Ka2": 8615.78, "Kb1": 9572.0}, + "Ga": {"Ka1": 9251.74, "Ka2": 9224.82, "Kb1": 10264.2}, + "Ge": {"Ka1": 9886.42, "Ka2": 9855.32, "Kb1": 10982.1}, + "As": {"Ka1": 10543.72, "Ka2": 10507.99, "Kb1": 11726.2}, + "Se": {"Ka1": 11222.4, "Ka2": 11181.4, "Kb1": 12495.9}, + "Br": {"Ka1": 11924.2, "Ka2": 11877.6, "Kb1": 13291.4}, + "Kr": {"Ka1": 12649.0, "Ka2": 12598.0, "Kb1": 14112.0}, + "Rb": {"Ka1": 13395.3, "Ka2": 13335.8, "Kb1": 14961.3}, + "Sr": {"Ka1": 14165.0, "Ka2": 14097.9, "Kb1": 15835.7}, + "Y": {"Ka1": 14958.4, "Ka2": 14882.9, "Kb1": 16737.8}, + "Zr": {"Ka1": 15775.1, "Ka2": 15690.9, "Kb1": 17667.8}, +} + + +@register_step("calibrate_energy") +def calibrate_energy(run, **kwargs): + """Calibrate the vonHamos crystal_detector_distance (A) against a known line. + + The energy axis (make_energy_axis) is + ll = pixel*mm_per_pixel/2 - (max(gl)-min(gl))/4 + E(p) = hc / (2 d sin(arctan(R / (ll(p) + A)))) + Everything except A is fixed by the spectrometer. Given a foil emission line + of known energy E0 whose peak falls at pixel p0 in a measured spectrum, A is + solved in closed form: + s = hc / (2 d E0) + A = R * sqrt(1 - s^2) / s - ll(p0) + This step finds p0 as the argmax of the spectrum (optionally within a pixel + window), solves A, and rebuilds `{name}_energy` so the line sits exactly at + E0. The fitted A is stored as `{name}_calibrated_A` (and returned in the + result dict via run.results) so it can be reused for subsequent, non-foil + measurements on the same spectrometer. + + Parameters from YAML + -------------------- + on : spectrum key to locate the peak in (e.g. epix_reduced_ROI_1) + element : element symbol for the foil, e.g. "Mn" (uses EMISSION_LINES) + line : which line, "Ka1" (default), "Ka2", or "Kb1" + energy : explicit line energy in eV (overrides element/line lookup) + crystal_radius, d_spacing, mm_per_pixel : geometry (same as make_energy_axis) + n_pixels : pixel count (defaults to len of the `on` spectrum) + peak_window : optional [lo, hi] pixel range to search for the peak + name : output prefix (default "xes"); writes {name}_energy and + {name}_calibrated_A + """ + spec_key = kwargs.get("on") + element = kwargs.get("element") + line = kwargs.get("line", "Ka1") + energy = kwargs.get("energy", None) + R = kwargs.get("crystal_radius") + d = kwargs.get("d_spacing") + mm_per_pixel = kwargs.get("mm_per_pixel", 0.05) + peak_window = kwargs.get("peak_window", None) + name = kwargs.get("name", "xes") + + if spec_key is None or R is None or d is None: + run.update_status("calibrate_energy: 'on', crystal_radius, d_spacing required") + return + + # Resolve the reference energy + if energy is None: + if element is None or element not in EMISSION_LINES: + run.update_status( + f"calibrate_energy: provide 'energy' or a known 'element' " + f"(got element={element!r})" + ) + return + if line not in EMISSION_LINES[element]: + run.update_status( + f"calibrate_energy: line {line!r} not tabulated for {element}" + ) + return + energy = EMISSION_LINES[element][line] + + spec = getattr(run, spec_key, None) + if spec is None: + run.update_status(f"calibrate_energy: spectrum '{spec_key}' not found") + return + spec = np.asarray(spec, dtype=np.float64) + if spec.ndim > 1: + # collapse any leading axes; energy runs along the last (dispersion) axis + spec = spec.reshape(-1, spec.shape[-1]).sum(axis=0) + + n_pixels = kwargs.get("n_pixels", spec.shape[-1]) + + # Locate the peak pixel (optionally restricted to a window) + if peak_window is not None: + lo, hi = int(peak_window[0]), int(peak_window[1]) + p0 = lo + int(np.argmax(spec[lo:hi])) + else: + p0 = int(np.argmax(spec)) + + hc = 12398.42 # eV * Angstrom + gl = np.arange(n_pixels, dtype=np.float64) * mm_per_pixel + ll = gl / 2.0 - (np.amax(gl) - np.amin(gl)) / 4.0 + + # Closed-form solve for A so that E(p0) == energy + s = hc / (2.0 * d * energy) + if not (0.0 < s < 1.0): + run.update_status( + f"calibrate_energy: unphysical sin(theta)={s:.4f} for E={energy} " + f"(check d_spacing)" + ) + return + A = R * np.sqrt(1.0 - s * s) / s - ll[p0] + + energy_axis = hc / (2.0 * d * np.sin(np.arctan(R / (ll + A)))) + setattr(run, f"{name}_energy", energy_axis) + setattr(run, f"{name}_calibrated_A", float(A)) + # expose in results for reuse as the calibration on later measurements + if hasattr(run, "results") and isinstance(run.results, dict): + run.results[f"{name}_calibrated_A"] = float(A) + run.results[f"{name}_energy"] = energy_axis + + run.update_status( + f"calibrate_energy: {name} line {element or ''} {line}={energy:.2f} eV at " + f"pixel {p0} -> A={A:.4f} mm; axis {energy_axis.min():.1f}-" + f"{energy_axis.max():.1f} eV" + ) + + @register_step("patch_pixels") def patch_pixels(run, **kwargs): """Repair bad pixels using polynomial fitting from neighbors. diff --git a/experiments/mfx102101026/mfx102101026_aggregate_xes.ipynb b/experiments/mfx102101026/mfx102101026_aggregate_xes.ipynb index 6fd3253..55ea99e 100644 --- a/experiments/mfx102101026/mfx102101026_aggregate_xes.ipynb +++ b/experiments/mfx102101026/mfx102101026_aggregate_xes.ipynb @@ -26,10 +26,18 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "cell-setup", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Loading results from: /sdf/data/lcls/ds/mfx/mfx100895324/results/lbgee/XSpect/experiments/mfx102101026/results/run_17_foil_static_xes.h5\n" + ] + } + ], "source": [ "import os, csv\n", "import h5py\n", @@ -67,10 +75,28 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "cell-load", "metadata": {}, - "outputs": [], + "outputs": [ + { + "ename": "KeyError", + "evalue": "\"Unable to synchronously open object (object 'kbeta_energy' doesn't exist)\"", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mKeyError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[2], line 4\u001b[0m\n\u001b[1;32m 2\u001b[0m \u001b[38;5;28;01mwith\u001b[39;00m h5py\u001b[38;5;241m.\u001b[39mFile(H5_FILE, \u001b[38;5;124m'\u001b[39m\u001b[38;5;124mr\u001b[39m\u001b[38;5;124m'\u001b[39m) \u001b[38;5;28;01mas\u001b[39;00m fh:\n\u001b[1;32m 3\u001b[0m ka_energy \u001b[38;5;241m=\u001b[39m fh[\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mkalpha_energy\u001b[39m\u001b[38;5;124m'\u001b[39m][:] \u001b[38;5;66;03m# (704,) eV\u001b[39;00m\n\u001b[0;32m----> 4\u001b[0m kb_energy \u001b[38;5;241m=\u001b[39m \u001b[43mfh\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43mkbeta_energy\u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[43m]\u001b[49m[:] \u001b[38;5;66;03m# (704,) eV\u001b[39;00m\n\u001b[1;32m 6\u001b[0m run_nums \u001b[38;5;241m=\u001b[39m \u001b[38;5;28msorted\u001b[39m(\n\u001b[1;32m 7\u001b[0m \u001b[38;5;28mint\u001b[39m(k\u001b[38;5;241m.\u001b[39msplit(\u001b[38;5;124m'\u001b[39m\u001b[38;5;124m_\u001b[39m\u001b[38;5;124m'\u001b[39m)[\u001b[38;5;241m1\u001b[39m]) \u001b[38;5;28;01mfor\u001b[39;00m k \u001b[38;5;129;01min\u001b[39;00m fh\u001b[38;5;241m.\u001b[39mkeys() \u001b[38;5;28;01mif\u001b[39;00m k\u001b[38;5;241m.\u001b[39mstartswith(\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mrun_\u001b[39m\u001b[38;5;124m'\u001b[39m)\n\u001b[1;32m 8\u001b[0m )\n\u001b[1;32m 9\u001b[0m ka_raw \u001b[38;5;241m=\u001b[39m {} \u001b[38;5;66;03m# raw (unnormalized) summed spectra\u001b[39;00m\n", + "File \u001b[0;32mh5py/_objects.pyx:54\u001b[0m, in \u001b[0;36mh5py._objects.with_phil.wrapper\u001b[0;34m()\u001b[0m\n", + "File \u001b[0;32mh5py/_objects.pyx:55\u001b[0m, in \u001b[0;36mh5py._objects.with_phil.wrapper\u001b[0;34m()\u001b[0m\n", + "File \u001b[0;32m/sdf/group/lcls/ds/ana/sw/conda1/inst/envs/ana-4.0.68-py3/lib/python3.9/site-packages/h5py/_hl/group.py:357\u001b[0m, in \u001b[0;36mGroup.__getitem__\u001b[0;34m(self, name)\u001b[0m\n\u001b[1;32m 355\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mInvalid HDF5 object reference\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m 356\u001b[0m \u001b[38;5;28;01melif\u001b[39;00m \u001b[38;5;28misinstance\u001b[39m(name, (\u001b[38;5;28mbytes\u001b[39m, \u001b[38;5;28mstr\u001b[39m)):\n\u001b[0;32m--> 357\u001b[0m oid \u001b[38;5;241m=\u001b[39m \u001b[43mh5o\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mopen\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mid\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_e\u001b[49m\u001b[43m(\u001b[49m\u001b[43mname\u001b[49m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mlapl\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_lapl\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 358\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[1;32m 359\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mTypeError\u001b[39;00m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mAccessing a group is done with bytes or str, \u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 360\u001b[0m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mnot \u001b[39m\u001b[38;5;132;01m{}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;241m.\u001b[39mformat(\u001b[38;5;28mtype\u001b[39m(name)))\n", + "File \u001b[0;32mh5py/_objects.pyx:54\u001b[0m, in \u001b[0;36mh5py._objects.with_phil.wrapper\u001b[0;34m()\u001b[0m\n", + "File \u001b[0;32mh5py/_objects.pyx:55\u001b[0m, in \u001b[0;36mh5py._objects.with_phil.wrapper\u001b[0;34m()\u001b[0m\n", + "File \u001b[0;32mh5py/h5o.pyx:257\u001b[0m, in \u001b[0;36mh5py.h5o.open\u001b[0;34m()\u001b[0m\n", + "\u001b[0;31mKeyError\u001b[0m: \"Unable to synchronously open object (object 'kbeta_energy' doesn't exist)\"" + ] + } + ], "source": [ "# ── read HDF5 ─────────────────────────────────────────────────────────────────\n", "with h5py.File(H5_FILE, 'r') as fh:\n", From b6555b144fb340c31555d48144c9856fc009a21a Mon Sep 17 00:00:00 2001 From: lbgee Date: Mon, 20 Jul 2026 15:52:58 -0700 Subject: [PATCH 2/4] Add optional smalldata_dir override to experiment config Allow the YAML experiment section to specify an explicit smalldata_dir, overriding the default /sdf/data/lcls/ds/{hutch}/{exp}/hdf5/smalldata location search. Enables running the pipeline on locally reprocessed smalldata (e.g. custom pedestal/droplet params) without touching the shared experiment directory. --- XSpect/controller/config_parser.py | 4 ++++ XSpect/controller/pipeline.py | 7 ++++++- XSpect/model/experiment.py | 33 ++++++++++++++++++++++++------ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/XSpect/controller/config_parser.py b/XSpect/controller/config_parser.py index 2e66f59..2604cd3 100644 --- a/XSpect/controller/config_parser.py +++ b/XSpect/controller/config_parser.py @@ -28,6 +28,9 @@ class ExperimentConfig: hutch: str experiment_id: str lcls_run: int + smalldata_dir: "str | None" = None # Optional override for the smalldata + # directory. If set, XSpect looks for {exp}_Run{run:04d}.h5 here instead of + # the default /sdf/data/lcls/ds/{hutch}/{exp}/hdf5/smalldata search list. @dataclass(frozen=True) @@ -107,6 +110,7 @@ def _parse_experiment(raw: dict) -> ExperimentConfig: hutch=str(raw["hutch"]), experiment_id=str(raw["experiment_id"]), lcls_run=int(raw["lcls_run"]), + smalldata_dir=(str(raw["smalldata_dir"]) if raw.get("smalldata_dir") else None), ) diff --git a/XSpect/controller/pipeline.py b/XSpect/controller/pipeline.py index 18aa403..0ed2c37 100644 --- a/XSpect/controller/pipeline.py +++ b/XSpect/controller/pipeline.py @@ -281,7 +281,12 @@ def _create_experiment(self): """Create experiment object from config. Wraps the directory lookup failure gracefully.""" cfg = self.config.experiment try: - exp = spectroscopy_experiment(cfg.lcls_run, cfg.hutch, cfg.experiment_id) + exp = spectroscopy_experiment( + cfg.lcls_run, + cfg.hutch, + cfg.experiment_id, + smalldata_dir=cfg.smalldata_dir, + ) except Exception: exp = _MockExperiment(cfg.lcls_run, cfg.hutch, cfg.experiment_id) return exp diff --git a/XSpect/model/experiment.py b/XSpect/model/experiment.py index 56b2d1c..a8e7110 100644 --- a/XSpect/model/experiment.py +++ b/XSpect/model/experiment.py @@ -2,7 +2,7 @@ class experiment: - def __init__(self, lcls_run, hutch, experiment_id): + def __init__(self, lcls_run, hutch, experiment_id, smalldata_dir=None): """ Initializes an experiment instance. @@ -14,10 +14,16 @@ def __init__(self, lcls_run, hutch, experiment_id): Hutch name. Example: xcs experiment_id : str Experiment identifier. Example: xcsl1004021 + smalldata_dir : str, optional + Explicit path to the directory holding the {exp}_Run{run:04d}.h5 + smalldata files. If provided, this overrides the default location + search (useful for locally reprocessed data). Example: + /sdf/data/lcls/ds/mfx/mfx101592326/results/lbgee/hdf5/smalldata """ self.lcls_run = lcls_run self.hutch = hutch self.experiment_id = experiment_id + self.smalldata_dir = smalldata_dir self.get_experiment_directory() def get_experiment_directory(self): @@ -25,6 +31,9 @@ def get_experiment_directory(self): Determines and returns the directory of the experiment based on the hutch and experiment ID. It attempts the various paths LCLS has had over the years with recent S3DF paths being the first attempt. + If `smalldata_dir` was provided at construction, it takes priority over + the default location search. + Returns ------- str @@ -35,15 +44,26 @@ def get_experiment_directory(self): Exception If the directory cannot be found. """ + # Explicit override (e.g. locally reprocessed smalldata) takes priority. + if self.smalldata_dir: + if os.path.exists(self.smalldata_dir) and os.listdir(self.smalldata_dir): + self.experiment_directory = self.smalldata_dir + return self.smalldata_dir + raise Exception( + f"smalldata_dir '{self.smalldata_dir}' does not exist or is empty." + ) + experiment_directories = [ - '/sdf/data/lcls/ds/%s/%s/hdf5/smalldata', - '/reg/data/drpsrcf/%s/%s/scratch/hdf5/smalldata', - '/cds/data/drpsrcf/%s/%s/scratch/hdf5/smalldata', - '/reg/d/psdm/%s/%s/hdf5/smalldata' + "/sdf/data/lcls/ds/%s/%s/hdf5/smalldata", + "/reg/data/drpsrcf/%s/%s/scratch/hdf5/smalldata", + "/cds/data/drpsrcf/%s/%s/scratch/hdf5/smalldata", + "/reg/d/psdm/%s/%s/hdf5/smalldata", ] for directory in experiment_directories: experiment_directory = directory % (self.hutch, self.experiment_id) - if os.path.exists(experiment_directory) and os.listdir(experiment_directory): + if os.path.exists(experiment_directory) and os.listdir( + experiment_directory + ): self.experiment_directory = experiment_directory return experiment_directory raise Exception("Unable to find experiment directory.") @@ -53,6 +73,7 @@ class spectroscopy_experiment(experiment): """ A class to represent a spectroscopy experiment. """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) From 42b4f7689e735485b710c093500a689d6475cd08 Mon Sep 17 00:00:00 2001 From: lbgee Date: Mon, 20 Jul 2026 22:36:06 -0700 Subject: [PATCH 3/4] Add subtract_spatial_background pipeline step Estimate a per-line fluorescence background from side-band regions flanking a dispersed spectral streak (on the cross-dispersion axis) and subtract it from the signal band. Removes the isotropic fluorescence pedestal under a Von Hamos emission line while preserving the lineshape, before spatial reduction to a 1D spectrum. --- XSpect/analysis/spectroscopy.py | 77 +++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/XSpect/analysis/spectroscopy.py b/XSpect/analysis/spectroscopy.py index 5cf672f..9d9d261 100644 --- a/XSpect/analysis/spectroscopy.py +++ b/XSpect/analysis/spectroscopy.py @@ -478,6 +478,83 @@ def apply_roi(run, **kwargs): run.update_status(f"Applied ROI to {detector_key}") +@register_step("subtract_spatial_background") +def subtract_spatial_background(run, **kwargs): + """Subtract a per-line background estimated from flanking regions. + + Designed for a dispersed spectral streak sitting on a smooth, spatially + slowly-varying background (e.g. isotropic fluorescence under a Von Hamos + emission line). For each line along the dispersion axis, the background + level is estimated from one or two "side band" regions flanking the signal + (on the cross-dispersion axis) and subtracted from the whole line. + + Works on a 2D image (rows x cols). ``bkg_axis`` is the CROSS-DISPERSION + axis, i.e. the axis along which the side bands are taken and which is + reduced away by the subsequent reduce_detector_spatial step. The background + per line is: mean over the side-band pixels, scaled to the number of pixels + in the signal band, then subtracted from the signal band; side-band columns + themselves are zeroed so they do not contribute downstream. + + Parameters from YAML: + on: detector key (2D rows x cols) + signal: [start, end] of the signal band on bkg_axis + sidebands: list of [start, end] flanking regions on bkg_axis used to + estimate the background (1 or 2 regions typical) + bkg_axis: cross-dispersion axis (default 1 = columns) + new_key: output key (default: "_bkgsub"); the original is kept + estimator: "mean" or "median" over side-band pixels (default "median") + """ + detector_key = kwargs.get("on") + signal = kwargs.get("signal") + sidebands = kwargs.get("sidebands") + bkg_axis = kwargs.get("bkg_axis", 1) + new_key = kwargs.get("new_key", None) + estimator = kwargs.get("estimator", "median") + + if detector_key is None or signal is None or not sidebands: + run.update_status( + "subtract_spatial_background: 'on', 'signal' and 'sidebands' required" + ) + return + data = getattr(run, detector_key, None) + if data is None: + return + if data.ndim != 2: + run.update_status( + f"subtract_spatial_background: expected 2D image, got ndim={data.ndim}" + ) + return + + # Move the cross-dispersion axis to position 1 so we can index columns. + work = data if bkg_axis == 1 else data.T # (lines, cross) + est_fn = np.nanmedian if estimator == "median" else np.nanmean + + # Per-line background level (one value per dispersion line) from side bands. + side_pixels = [] + for lo, hi in sidebands: + side_pixels.append(work[:, lo:hi]) + side = np.concatenate(side_pixels, axis=1) + bkg_per_pixel = est_fn(side, axis=1, keepdims=True) # (lines, 1) + + out = work.astype(float).copy() + s0, s1 = signal + out[:, s0:s1] = out[:, s0:s1] - bkg_per_pixel # subtract per-pixel bkg + # zero everything outside the signal band so downstream reduction only + # integrates the background-subtracted signal columns + keep = np.zeros(work.shape[1], dtype=bool) + keep[s0:s1] = True + out[:, ~keep] = 0.0 + + result = out if bkg_axis == 1 else out.T + if new_key is None: + new_key = f"{detector_key}_bkgsub" + setattr(run, new_key, result) + run.update_status( + f"subtract_spatial_background: {detector_key} -> {new_key} " + f"signal={signal} sidebands={sidebands} estimator={estimator}" + ) + + @register_step("time_binning") def time_binning(run, **kwargs): """Create time delay bins from laser timing data. From 03bb2079d2e29b42d85b2490461e0c3f8f9ced37 Mon Sep 17 00:00:00 2001 From: lbgee Date: Fri, 24 Jul 2026 13:05:05 -0700 Subject: [PATCH 4/4] Add make_eventcode_mask pipeline step Extract one EVR/timing event-code column as a per-shot boolean mask so it can be consumed by union_shots/filter_shots. Needed to gate 30 Hz beam delivery (event code 198) inside a 120 Hz DAQ, which the smalldata xray flag does not distinguish. Used by the mfx101592326 Fe Ka droplet pipeline. --- XSpect/analysis/spectroscopy.py | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/XSpect/analysis/spectroscopy.py b/XSpect/analysis/spectroscopy.py index 9d9d261..39dfc3f 100644 --- a/XSpect/analysis/spectroscopy.py +++ b/XSpect/analysis/spectroscopy.py @@ -54,6 +54,52 @@ def get_run_shot_properties(run, **kwargs): run.get_run_shot_properties() +@register_step("make_eventcode_mask") +def make_eventcode_mask(run, **kwargs): + """Build a per-shot boolean mask from an EVR/timing event-code column. + + The smalldata timing group stores a per-shot event-code table (shape + n_shots x n_codes, e.g. timing/eventcodes with 288 columns). This step + extracts one code's column as a boolean shot mask, so it can be used by + union_shots / filter_shots like any other mask. + + Typical use: beam is delivered at 30 Hz inside a 120 Hz DAQ, tagged by + event code 198. Gate the analysis on code 198 to drop the 90 Hz of empty + frames (the smalldata `xray` flag does NOT distinguish these). + + Parameters from YAML: + code: event-code index (column) to extract, e.g. 198 + eventcodes: attribute holding the 2D code table (default "eventcodes") + new_key: output mask attribute name (default: "ec") + """ + code = kwargs.get("code") + ec_key = kwargs.get("eventcodes", "eventcodes") + new_key = kwargs.get("new_key", None) + if code is None: + run.update_status("make_eventcode_mask: 'code' is required") + return + table = getattr(run, ec_key, None) + if table is None: + run.update_status( + f"make_eventcode_mask: '{ec_key}' not loaded (add it to data.keys)" + ) + return + table = np.asarray(table) + if table.ndim != 2 or code >= table.shape[1]: + run.update_status( + f"make_eventcode_mask: '{ec_key}' shape {table.shape} cannot index code {code}" + ) + return + mask = table[:, code].astype(bool) + if new_key is None: + new_key = f"ec{code}" + setattr(run, new_key, mask) + run.update_status( + f"make_eventcode_mask: {ec_key}[:, {code}] -> {new_key} " + f"({int(mask.sum())}/{mask.size} shots = {100 * mask.mean():.1f}%)" + ) + + @register_step("filter_shots") def filter_shots(run, **kwargs): """Filter a shot mask by thresholding on another key.