Skip to content

Architecture overhaul: YAML-driven pipeline with MVC separation #82

Description

@lg345

Summary

Overhaul XSpect to use YAML-based configuration files as the single source of truth for analysis pipelines. Replace the current controller subclass hierarchy (6 classes with hardcoded analysis sequences) with a generic pipeline runner that dispatches steps from a YAML recipe.

Motivation

  • The controller has become a graveyard for bespoke analysis sequences. Every new experiment type requires a new subclass.
  • Configuration is scattered across object attributes set imperatively in notebooks.
  • No reproducibility artifact exists. You can't hand someone a file and say "this is exactly what I ran."
  • The controller does analysis work instead of mediating it.

Architecture

YAML as the complete recipe

One YAML file captures everything needed to reproduce an analysis from raw data:

description: "Mn Kbeta XES, time-resolved pump-probe, MFX LY69"

experiment:
  hutch: xcs
  experiment_id: xcsp23820
  lcls_run: 21

data:
  runs: [162-181, 183-190]
  keys:
    tt/ttCorr: time_tool_correction
    epics/lxt_ttc: lxt_ttc
    enc/lasDelay: encoder
    ipm4/sum: ipm
    tt/AMPL: time_tool_ampl
  detector_keys:
    epix_1/ROI_0_area:
      name: epix
      rois: [[0, -1]]
      combine_rois: true

pipeline:
  - step: filter_shots
    on: xray
    filter_key: ipm
    threshold: 1.0e4
  - step: union_shots
    on: epix_ROI_1
    filters: [simultaneous, laser]
  - step: separate_shots
    on: epix_ROI_1
    filters: [xray, laser]
  - step: reduce_detector_temporal
    on: epix_ROI_1.simultaneous_laser
    timing: timing_bin_indices.simultaneous_laser

reduction:
  - step: combine_runs
  - step: normalize_xes
    on: summed_laser_off

output:
  format: hdf5
  path: ./results/

Target module layout

XSpect/
├── __init__.py
├── model/
│   ├── experiment.py        # experiment, spectroscopy_experiment
│   ├── run.py               # spectroscopy_run (results dict interface)
│   └── von_hamos.py         # vonHamos crystal geometry
├── analysis/
│   ├── registry.py          # @register_step, @register_reduction, dispatch
│   ├── spectroscopy.py      # base operations (filter, union, separate, reduce)
│   ├── xes.py               # XES-specific steps (normalize, energy axis, combine)
│   └── xas.py               # XAS-specific steps (ccm axis, ccm binning)
├── controller/
│   ├── config_parser.py     # YAML parsing + validation
│   ├── pipeline_runner.py   # step dispatch loop + reduction orchestration
│   └── batch_manager.py     # shot chunking, multiprocessing, reconvergence
├── visualization/
├── diagnostics/
└── postprocessing/

Phase Structure (with dependencies)

Phase 1: MVP (Static XES end-to-end)

1A: Core Infrastructure (#84)

Dependencies: None (foundational)

Deliverable Description
model/run.py spectroscopy_run refactored with self.results = {} flat dict
model/experiment.py Move experiment, spectroscopy_experiment as-is
analysis/registry.py @register_step(name), @register_reduction(name), get_step(), get_reduction(), list_steps()
controller/config_parser.py YAML parse, validate required sections (experiment, data, pipeline, output), raise on unknown step names
controller/pipeline_runner.py Iterate pipeline section, look up + call steps, log execution
controller/batch_manager.py Split runs into shot ranges, multiprocessing.Pool, reconverge batch results
Pipeline class Pipeline.from_yaml(path), .run(cores, batch_size), .results

Acceptance criteria:

  • Pipeline.from_yaml("test.yaml").run(cores=4, batch_size=1000) executes on a minimal YAML with placeholder steps
  • Registry discovers and dispatches decorated methods
  • Batch manager splits and reconverges correctly

1B: Register Static XES Steps (#85)

Dependencies: 1A (registry must exist before steps can be registered)

Step Source Description
filter_shots SpectroscopyAnalysis.filter_shots Threshold filter on diagnostic key
union_shots SpectroscopyAnalysis.union_shots Combine masks (AND logic)
separate_shots SpectroscopyAnalysis.separate_shots Exclude masks (A AND NOT B)
filter_detector_adu SpectroscopyAnalysis.filter_detector_adu Zero pixels below ADU threshold
reduce_detector_shots SpectroscopyAnalysis.reduce_detector_shots Sum detector across shot dimension
reduce_detector_spatial SpectroscopyAnalysis.reduce_detector_spatial ROI reduction of spatial dimension
apply_roi SpectroscopyAnalysis.apply_roi Apply ROI mask
rotate wraps scipy.ndimage.rotate Rotate detector images
patch_pixels SpectroscopyAnalysis.patch_pixels Bad pixel interpolation
hit_finding HitFinding.basic_detect Event detection + filter

Key changes:

  • All steps become stateless: no self.pixels_to_patch, no self.adu_cutoff. Parameters from YAML args only.
  • Steps read/write run.results[key] instead of setattr(run, key, val).
  • Dot-separated key convention: union_shots on epix_ROI_1 with filters [simultaneous, laser] writes to run.results["epix_ROI_1.simultaneous_laser"].

Acceptance criteria:

  • Each step callable independently with mock run + YAML-style kwargs
  • Correct output keys following dot-separated naming
  • Status logs written for every step execution

1C: Static XES Integration (#86)

Dependencies: 1A + 1B (all steps registered, pipeline infrastructure runnable)

Deliverable Description
xcsp23820_static.yaml Complete config reproducing XSpect_P23820.py static workflow
model/von_hamos.py Move vonHamos class to model layer
Numerical validation Run old path + new path on same data, compare outputs
combine_runs reduction Basic cross-run summation (reduction lifecycle test)

Acceptance criteria:

  • Pipeline.from_yaml("xcsp23820_static.yaml").run(cores=16, batch_size=2000) produces results matching old XESBatchAnalysisRotation.primary_analysis_static
  • Execution log captures all step statuses
  • Results saved to HDF5 at specified path

Phase 2: Time-Resolved XES (#87)

Dependencies: Phase 1 complete (1C passes numerical validation)

New pipeline steps

Step Source Description
time_binning SpectroscopyAnalysis.time_binning Bin shots by delay stage + encoder + time tool
reduce_detector_temporal SpectroscopyAnalysis.reduce_detector_temporal Bin detector data into time bins
normalize_xes XESAnalysis.normalize_xes Area-normalize XES spectra
make_energy_axis XESAnalysis.make_energy_axis Energy axis from von Hamos geometry
droplet_reconstruction SpectroscopyAnalysis.droplet_reconstruction Sparse-to-dense detector reconstruction

New reduction steps

Step Source Description
combine_runs (full) XESAnalysis.combine_runs Sum across runs with uncertainty propagation
normalize_combined Extracted from combine_runs Normalize combined laser-on/off spectra
compute_difference Extracted from combine_runs Normalized difference spectra

Acceptance criteria:

  • Full time-resolved XES pipeline runs from YAML
  • Results match current XESBatchAnalysisRotation.primary_analysis output
  • Energy axis correctly generated from von Hamos parameters

Phase 3: XAS Pipelines (#88)

Dependencies: Phase 2 complete (time_binning shared between time-resolved XES and XAS)

New pipeline steps

Step Source Description
make_ccm_axis XASAnalysis.make_ccm_axis CCM energy bins from setpoint values
ccm_binning XASAnalysis.ccm_binning Digitize shots into CCM energy bins
reduce_detector_ccm XASAnalysis.reduce_detector_ccm 1D XAS (energy only)
reduce_detector_ccm_temporal XASAnalysis.reduce_detector_ccm_temporal 2D XAS (energy + time)
bin_uniques SpectroscopyAnalysis.bin_uniques Bin by arbitrary scan variable

Workflows replaced

Old class New YAML
XASBatchAnalysis 2D (energy + time) YAML
XASBatchAnalysis_1D_ccm Energy-only YAML
XASBatchAnalysis_1D_time Time-only YAML

Acceptance criteria:

  • All three XAS workflows (1D energy, 1D time, 2D energy+time) run from YAML
  • Results match current XAS subclass outputs
  • CCM axis handles both setpoint-derived and linspace-derived energy lists

Phase 4: Cleanup + Full Migration (#89)

Dependencies: Phases 1-3 complete (all analysis paths ported)

Scan analysis

  • reduce_det_scanvar: bin detector by arbitrary scan variable
  • Replaces: ScanAnalysis_1D, ScanAnalysis_1D_XES

PostProcessing integration

  • Port XSpect_PostProcessing.py (exponential fitting, kinetics extraction)
  • Determine: registered reduction steps vs. standalone post-hoc utilities

Visualization refactor

  • XSpect_Visualization.py and XSpect_Diagnostics.py read from run.results dict
  • No more getattr(run, key) pattern

Deletion

  • Remove XSpect_Controller.py entirely (all 6 subclasses dead)
  • Remove backwards-compat shims from init.py

Acceptance criteria:

  • All current analysis workflows expressible as YAML
  • Visualization works with new results format
  • No remaining imports from old controller subclasses
  • Old controller file deleted

Dependency Graph

1A --> 1B --> 1C --> Phase 2 --> Phase 3 --> Phase 4
                       |                       |
                       +-- shares time_binning -+

Critical path: registry.py (1A) -> step registration (1B) -> integration test (1C). Everything after Phase 1 is incremental step additions following the same pattern.

Inter-phase dependencies:

  • Phase 2 depends on Phase 1C (pipeline must be proven end-to-end before adding complexity)
  • Phase 3 depends on Phase 2 (time_binning step is shared; reduction patterns established in Phase 2 reused in Phase 3)
  • Phase 4 depends on Phases 1-3 (cannot delete old code until all paths are ported)

Within Phase 1:

  • 1B depends on 1A (registry must exist)
  • 1C depends on 1A + 1B (needs both infrastructure and registered steps)
  • 1A model/ refactoring is independent of analysis/registry.py, but both must land before 1B starts

Design Decisions

Decision Resolution
Pipeline model Flat ordered list, top-to-bottom execution, no conditionals
Step dispatch Registry via @register_step(name) decorator
Run state run.results flat dict with dot-separated keys
on: field semantics String passed to step; step interprets (mask name, detector key, or derived key)
Parallelism Transparent to user. batch_size/cores at runtime, not in YAML
Reduction lifecycle Pipeline.run() has two internal phases: batch-parallel pipeline, then serial reduction across all runs
Backwards compat Old imports via shim init.py through Phases 1-3; removed in Phase 4
Test data Small synthetic HDF5 fixture in tests/fixtures/ for unit tests
YAML validation Steps self-describe expected args; config_parser raises on unknown step names or missing required fields
Notebook interface Pipeline.from_yaml(path).run(cores=16, batch_size=2000)
Compute resources Out of YAML, passed at runtime (notebooks on HPC nodes)
Resumability Future feature, not in scope for Phases 1-4

What disappears

The following classes are replaced by different YAML files + one generic Pipeline class:

  • XESBatchAnalysis
  • XESBatchAnalysisRotation
  • XASBatchAnalysis
  • XASBatchAnalysis_1D_ccm
  • XASBatchAnalysis_1D_time
  • ScanAnalysis_1D
  • ScanAnalysis_1D_XES

Sub-issues

Execution order

#84 -> #85 -> #86 (MVP complete) -> #87 -> #88 -> #89

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions