Source code for ftmwpipeline.pipeline

"""Object-oriented Pipeline class for FTMW spectroscopy data processing.

This module provides the high-level object-oriented interface for the FTMW
pipeline, implementing the dual-interface architecture alongside functional
and CLI interfaces. Each Pipeline instance is bound to a specific .ftmw file.
"""

import logging
from pathlib import Path
from typing import (
    TYPE_CHECKING,
    Any,
    Dict,
    List,
    Optional,
    Sequence,
    Tuple,
    Union,
    cast,
)

if TYPE_CHECKING:
    from ._internal.tuning import (
        BatchItem,
        ExportResult,
        KnobSpec,
        SettingRow,
        SetResult,
        SweepResult,
    )

from ._internal.report_diff_impl import report_diff_impl
from ._internal.report_html_impl import report_run_impl
from ._internal.report_impl import report_table_impl
from ._internal.run_impl import run_pipeline_impl
from ._internal.shape_recommendation_impl import recommend_shape_impl
from ._internal.stage0_impl import import_data_impl, load_fid_from_pipeline_impl
from ._internal.stage1_impl import (
    compute_ft_impl,
    save_ft_parameters_impl,
    visualize_ft_impl,
)
from ._internal.stage2_impl import compute_noise_estimation_impl, visualize_noise_impl
from ._internal.stage2b_impl import (
    calibrate_tau_impl,
    load_tau_calibration_impl,
)
from ._internal.stage3_impl import (
    detect_peaks_impl,
    load_peaks_impl,
    visualize_peaks_impl,
)
from ._internal.stage4_impl import (
    assign_windows_impl,
    load_windows_impl,
    visualize_windows_impl,
)
from ._internal.stage5_impl import (
    fit_peaks_impl,
    fit_show_impl,
    load_fit_impl,
    visualize_fit_impl,
)
from ._internal.stage5_validation_impl import validate_stage5_shape_error_impl
from ._internal.stage6_impl import (
    DEFAULT_ATTENTION_CANDIDATE_EVIDENCE,
    DEFAULT_DISPLAY_BAR,
    CurationApplyResult,
    DecisionLogEntry,
    RankedWindow,
    RefitWindowResult,
    ReviewRunResult,
    UndoResult,
    apply_curation_impl,
    get_candidate_ledger_impl,
    get_final_products_impl,
    get_review_status_impl,
    merge_peaks_impl,
    rank_windows_impl,
    refit_window_impl,
    review_accept_impl,
    review_log_impl,
    review_run_impl,
    review_undo_impl,
    set_sigma_floor_impl,
    split_peak_impl,
)
from ._internal.start_detection_impl import detect_start_time_impl
from ._internal.timebase_impl import (
    calibrate_timebase_impl,
    load_timebase_calibration_impl,
)
from .core.data_structures import (
    FID,
    ComplexFT,
    FinalProducts,
    LedgerCandidate,
    Peak,
    SpectrumFit,
    Stage6Review,
    WindowPlan,
)
from .core.noise_settings import NoiseSettings
from .core.peak_detection_settings import PeakDetectionSettings
from .core.settings import FTSettings
from .core.stage_fit_settings import (
    ClockSource,
    StageFitSettings,
    coerce_clock_sources,
)
from .core.start_detection_settings import StartDetectionSettings
from .core.tau_calibration_settings import TauCalibrationSettings
from .core.window_planning_settings import WindowPlanningSettings
from .file_manager import (
    PipelineCorruptionError,
    PipelineExistsError,
    PipelineFileError,
    PipelineStageTracker,
    SourceMetadata,
    StageDependencyError,
    create_pipeline_file,
    open_pipeline_file,
    update_processing_parameters,
    validate_pipeline_file,
)
from .fitting.tau_calibration import ShapeRecommendation, TauCalibrationResult
from .fitting.timebase_calibration import TimebaseCalibrationResult
from .io.data_loaders import detect_format, load_fid, validate_source
from .io.stage_fit_settings_serialization import write_recommended_clock_sources
from .preprocessing.noise_estimation import NoiseResult
from .preprocessing.start_detection import StartDetectionResult


[docs] class Pipeline: """ Object-oriented pipeline interface for FTMW spectroscopy data processing. Each Pipeline instance is bound to a specific .ftmw pipeline file and provides high-level methods for data processing and analysis. This implements the object-oriented interface of the dual-interface architecture. Key Features: - File-bound design: each instance manages one .ftmw pipeline file - Safe re-execution: methods can be called multiple times - Consistent with CLI: methods behave identically to CLI commands - Error handling: clear error messages using custom exceptions Example Usage:: # Create new pipeline from raw data pipe = Pipeline.create("exp_2638.ftmw", source='examples/blackchirp_data/2638/') # Open existing pipeline for analysis pipe = Pipeline.open("exp_2638.ftmw") # Stage 1: FT Processing (canonical FT is unapodized, native-length) pipe.compute_ft(trim=(26500, 40000)) pipe.visualize_ft(save_params=True) # File info and validation pipe.info() # Show pipeline status and metadata pipe.validate() # Check file integrity """
[docs] def __init__( self, filepath: Union[str, Path], source_metadata: Optional[SourceMetadata] = None, stage_tracker: Optional[PipelineStageTracker] = None, ): """ Bind a Pipeline instance to a specific .ftmw file. Smart constructor: ``Pipeline(path)`` opens an existing pipeline file, raising ``FileNotFoundError`` with guidance if it does not exist. Use ``Pipeline.create()`` to make a new analysis from raw data. Parameters ---------- filepath : str or Path Path to the pipeline file. source_metadata : SourceMetadata, optional Source provenance. When omitted (the smart-constructor path) it is loaded from the file. stage_tracker : PipelineStageTracker, optional Stage completion tracker. When omitted it is loaded from the file. Notes ----- ``create()`` and ``open()`` supply both metadata arguments directly. Passing only a path performs the same load as ``open()``. """ self.logger = logging.getLogger(__name__) if source_metadata is None or stage_tracker is None: # Smart-constructor path: load state from the file (raises # FileNotFoundError with guidance if absent). filepath, source_metadata, stage_tracker = open_pipeline_file(filepath) # File binding self.filepath = Path(filepath) self.source_metadata = source_metadata self.stage_tracker = stage_tracker
[docs] @classmethod def create( cls, filepath: Union[str, Path], source: Union[str, Path], format_name: Optional[str] = None, fid_index: Optional[int] = None, force: bool = False, **loader_params: Any, ) -> "Pipeline": """ Create new pipeline from raw experimental data. Parameters ---------- filepath : str or Path Path for new pipeline file (should have .ftmw extension) source : str or Path Path to source data (file or directory) format_name : str, optional Data format name. If None, auto-detect format. fid_index : int, optional FID index for multi-FID formats (e.g., Blackchirp) force : bool, default False If True, overwrite existing file even with different source **loader_params Additional parameters for data loader Returns ------- Pipeline New Pipeline instance bound to the created file Raises ------ PipelineExistsError If file exists with different source and force=False FileNotFoundError If source data does not exist ValueError If format detection or validation fails RuntimeError If data loading or file creation fails """ source_path = Path(source) # Validate source exists if not source_path.exists(): raise FileNotFoundError(f"Source path does not exist: {source_path}") # Format detection if not specified if format_name is None: format_name = detect_format(source_path) if format_name is None: raise ValueError(f"Could not detect format for: {source_path}") # Validate source with format validation = validate_source(source_path, format_name) if not validation["valid"]: errors = "; ".join(validation["errors"]) raise ValueError(f"Source validation failed: {errors}") # Prepare loader parameters if format_name == "blackchirp" and fid_index is not None: loader_params["fid_index"] = fid_index # Load FID data try: fid = load_fid(source_path, format_name, **loader_params) except Exception as e: raise RuntimeError(f"Failed to load FID data: {e}") from e # Create source metadata source_metadata = SourceMetadata( source_path=source_path, format_name=format_name, loader_parameters=loader_params, ) # Create pipeline file created_filepath = create_pipeline_file( filepath, fid, source_metadata, force=force ) # Persist any instrument clock declaration extracted by the loader so # the Stage 5 resolver can surface it at the recommended layer. raw_clocks = fid.metadata.get("clock_sources") if raw_clocks is not None: try: clock_tuple = coerce_clock_sources(raw_clocks) write_recommended_clock_sources(str(created_filepath), clock_tuple) except Exception: # pragma: no cover pass # non-fatal: clock metadata is advisory # Chirp-window persistence + import-time start recommendation live # once in the stage 0 impl. from ._internal.stage0_impl import persist_chirp_window_metadata persist_chirp_window_metadata(str(created_filepath), fid) # Load file info for Pipeline instance filepath, source_metadata, stage_tracker = open_pipeline_file(created_filepath) return cls(filepath, source_metadata, stage_tracker)
[docs] @classmethod def open(cls, filepath: Union[str, Path]) -> "Pipeline": """ Open existing pipeline file. Parameters ---------- filepath : str or Path Path to existing pipeline file Returns ------- Pipeline Pipeline instance bound to the opened file Raises ------ FileNotFoundError If pipeline file doesn't exist PipelineCorruptionError If file appears to be corrupted ValueError If file format is invalid """ filepath, source_metadata, stage_tracker = open_pipeline_file(filepath) return cls(filepath, source_metadata, stage_tracker)
[docs] @classmethod def build( cls, source: Union[str, Path], *, trim: Optional[Tuple[float, float]] = None, output: Optional[Union[str, Path]] = None, **kwargs: Any, ) -> Dict[str, Any]: """Drive *source* through every stage end-to-end and return the result. Convenience classmethod over :func:`run_pipeline_impl`: imports the raw *source*, then runs FT -> noise -> tau -> peaks -> windows -> fit -> timebase -> review (and, with ``report=True``, the report), with live per-stage progress. *trim* (the active-band FT range, MHz) is required. ``output`` is the destination ``.ftmw`` (derived from *source* if omitted). Remaining keyword arguments are forwarded to :func:`run_pipeline_impl` (per-stage ``*_params`` override dicts, ``detect_start`` / ``calibrate`` / ``clocks``, ``report`` / ``report_output_dir``, ``sigma_floor_khz``, ``force``, ``progress``, …). Returns the structured run result (``pipeline_file``, ``status``, ``completed_stages``, ``failed_stage``, ``error``, ``timebase``, ``report``, ``elapsed_s``). Open the finished file with :meth:`Pipeline.open` (``result["pipeline_file"]``). """ return run_pipeline_impl(source, output, trim=trim, **kwargs)
[docs] def load_data(self) -> FID: """ Load FID data from the pipeline file. This method implements Stage 0 data loading, providing access to the raw FID data stored in the pipeline file. Returns ------- FID The loaded FID object with all metadata Raises ------ FileNotFoundError If pipeline file does not exist PipelineCorruptionError If file is corrupted or invalid RuntimeError If FID loading fails """ try: fid = load_fid_from_pipeline_impl(str(self.filepath)) self.logger.info( f"Loaded FID data: {fid.n_points:,} points, {fid.duration_us:.1f} μs" ) return fid except Exception as e: raise RuntimeError(f"Failed to load FID data: {e}") from e
[docs] def compute_ft( self, trim: Optional[Tuple[float, float]] = None, start_us: Optional[float] = None, end_us: Optional[float] = None, units_power: Optional[int] = None, from_saved_params: bool = False, ) -> ComplexFT: """Compute Fourier Transform (Stage 1, user-driven). Resolves settings through ``explicit > persisted > recommended`` and persists the resolved canonical settings to the ``.ftmw`` file. Can be called multiple times safely. The canonical FT is unconditionally unapodized, un-windowed, and native-length. Parameters ---------- trim : tuple of float, optional ``(min_mhz, max_mhz)`` frequency analysis range to keep. start_us : float, optional FID window start time in microseconds. end_us : float, optional FID window end time in microseconds. units_power : int, optional Spectrum scaling as power of 10. from_saved_params : bool, default False If ``True``, ignore the explicit kwargs above and use only the persisted / recommended settings (no explicit overrides). Returns ------- ComplexFT Computed frequency-domain data. Raises ------ StageDependencyError If Stage 0 (FID data) is not available. RuntimeError If FT computation fails. """ try: settings: Optional[FTSettings] = None if not from_saved_params: settings = FTSettings( start_us=start_us, end_us=end_us, units_power=units_power, trim=trim, ) result = compute_ft_impl( file_path=str(self.filepath), settings=settings, validate_only=False, persist=True, ) complex_ft: ComplexFT = result["complex_ft"] self.logger.info(f"FT computed: {complex_ft.n_points:,} frequency points") if trim: self.logger.info(f"Trimmed to {trim[0]:.1f}-{trim[1]:.1f} MHz") return complex_ft except Exception as e: raise RuntimeError(f"Failed to compute FT: {e}") from e
[docs] def visualize_ft( self, trim: Optional[Tuple[float, float]] = None, start_us: Optional[float] = None, end_us: Optional[float] = None, units_power: Optional[int] = None, save_params: bool = False, interactive: bool = True, output_file: Optional[Union[str, Path]] = None, show_fid_panels: bool = True, ) -> Any: """Create enhanced FT visualization with processing workflow display. Equivalent to the CLI ``ft show`` command. Never persists settings; exploration only. Pass ``save_params=True`` to write the explicitly provided kwargs to the canonical ``ft_processing`` record. The canonical FT is unconditionally unapodized, un-windowed, and native-length. Parameters ---------- trim : tuple of float, optional ``(min_mhz, max_mhz)`` frequency analysis range. start_us : float, optional FID window start time in microseconds. end_us : float, optional FID window end time in microseconds. units_power : int, optional Spectrum scaling as power of 10. save_params : bool, default False If ``True``, persist the explicitly provided settings. interactive : bool, default True Whether to show an interactive plot. output_file : str or Path, optional Path to save the plot image (non-interactive mode). show_fid_panels : bool, default True Whether to include FID processing panels. Returns ------- figure Matplotlib figure object. Raises ------ StageDependencyError If required dependencies are not available. RuntimeError If visualization fails. """ try: settings = FTSettings( start_us=start_us, end_us=end_us, units_power=units_power, trim=trim, ) fig = visualize_ft_impl( file_path=str(self.filepath), settings=settings, title=None, show_fid_panels=show_fid_panels, interactive=interactive, ) # Handle output if not interactive and output_file: fig.savefig(output_file, dpi=150, bbox_inches="tight") self.logger.info(f"Plot saved to: {output_file}") elif not interactive: default_name = f"{self.filepath.stem}_enhanced_spectrum.png" fig.savefig(default_name, dpi=150, bbox_inches="tight") self.logger.info(f"Plot saved to: {default_name}") elif interactive: import matplotlib.pyplot as plt plt.show() # Save parameters if requested if save_params: params: Dict[str, Any] = {} for key, value in ( ("start_us", start_us), ("end_us", end_us), ("units_power", units_power), ): if value is not None: params[key] = value if trim is not None: params["trim_min_mhz"] = trim[0] params["trim_max_mhz"] = trim[1] if params: save_ft_parameters_impl(str(self.filepath), params) self.logger.info(f"Saved {len(params)} processing parameters") else: self.logger.info("No custom parameters to save") self.logger.info("FT visualization completed") return fig except Exception as e: raise RuntimeError(f"Failed to create FT visualization: {e}") from e
[docs] def estimate_noise( self, *, settings: Optional[NoiseSettings] = None, preset: Optional[str] = None, ) -> NoiseResult: """ Estimate frequency-dependent noise with the scatter estimator. This method implements Stage 2 noise estimation, equivalent to the CLI ``noise run`` command. Requires Stage 1 (FT computation) to be completed. The scatter estimator is high-pass and region-aware: it is immune to the leakage pedestal on high-SNR, line-dense spectra. Parameters ---------- settings, preset Composable ways to drive the settings chain (a ``NoiseSettings`` bundle as the explicit override layer, a YAML preset's ``stage2:`` block as the preset layer beneath the persisted record). Individual knobs (``window_mhz`` / ``smoothing_mhz`` / ``smoothing_percentile`` / ``convolve_mhz`` / …) are set on the ``NoiseSettings`` instance; the explicit layer outranks the persisted record, which outranks the preset (per D11), so a no-arg call reproduces the persisted recipe. Returns ------- NoiseResult Container with RMS noise estimate, noise mask, and diagnostics Raises ------ StageDependencyError If Stage 1 (FT computation) has not been completed ValueError If noise estimation parameters are invalid RuntimeError If noise estimation computation fails """ try: # Compute noise estimation using shared implementation (handles dependency checking and storage) result = compute_noise_estimation_impl( file_path=str(self.filepath), settings=settings, preset=preset, ) # Storage and stage tracking handled by shared implementation self.logger.info("Stage 2: Noise estimation completed successfully") return cast(NoiseResult, result["noise_result"]) except StageDependencyError: # Re-raise dependency errors with clear message raise except Exception as e: raise RuntimeError(f"Failed to estimate noise: {e}") from e
[docs] def visualize_noise( self, y_max_factor: Optional[float] = None, figsize: Optional[tuple] = None, title: Optional[str] = None, show_noise_points: Optional[bool] = None, interactive: bool = True, output_file: Optional[Union[str, Path]] = None, **plot_kwargs: Any, ) -> Any: """ Create noise estimation diagnostic visualization. This method creates diagnostic plots showing the spectrum, the noise points, and the per-bin σ estimate (with 3×/5×σ reference levels). Equivalent to the CLI ``noise show`` command. Parameters ---------- y_max_factor : float, optional Y-axis maximum as multiple of median RMS noise (default: 20.0) figsize : tuple, optional Figure size (width, height) in inches (default: (16, 6)) title : str, optional Custom title for the plot show_noise_points : bool, optional Whether to highlight noise points (default: True) interactive : bool, default True Whether to create interactive plots output_file : str or Path, optional If provided, save plot to this file **plot_kwargs Additional plotting parameters Returns ------- matplotlib.figure.Figure The created figure object Raises ------ StageDependencyError If Stage 2 (noise estimation) has not been completed RuntimeError If visualization fails """ try: # Create visualization using shared implementation (handles dependency checking) fig = visualize_noise_impl( file_path=str(self.filepath), y_max_factor=y_max_factor, figsize=figsize, title=title, show_noise_points=show_noise_points, interactive=interactive, **plot_kwargs, ) # Save output file if requested if output_file: try: fig.savefig(str(output_file), dpi=300, bbox_inches="tight") self.logger.info(f"Visualization saved to: {output_file}") except Exception as e: self.logger.warning(f"Failed to save visualization: {e}") self.logger.info("Noise visualization completed") return fig except StageDependencyError: # Re-raise dependency errors with clear message raise except Exception as e: raise RuntimeError(f"Failed to create noise visualization: {e}") from e
[docs] def calibrate_tau( self, *, shape: str = "lorentzian", settings: Optional[TauCalibrationSettings] = None, preset: Optional[str] = None, ) -> TauCalibrationResult: """Run the Stage 2b data-driven tau calibration. Requires Stages 0-2 completed. Extracts a global majority-vote molecular decay constant ``tau_maj`` (with robust spread ``sigma_tau``) from the raw FID by sliding a ``T_w = T_full / n_seg``-long active sub-window across the zero-padded record and fitting a per-bin decay to the magnitude vs frame-start time. ``shape`` selects the decay model and its persistence group: ``"lorentzian"`` (pure-exponential, ``/stage2b_tau_calibration``; consumed by a ``shape='lorentzian'`` Stage 5 fit) or ``"gaussian"`` (pure-Gaussian envelope τ_G, ``/stage2b_tau_G_calibration``; consumed by a ``shape='gaussian'`` fit). The result invalidates downstream stages. Settings resolve through the chain (``settings`` / ``preset`` > persisted > hard default); pass ``settings=`` to drive the calibration from a :class:`TauCalibrationSettings` instance, or ``preset=NAME_OR_PATH`` to load from packaged YAML. They may be combined: the ``settings`` bundle is the explicit override that outranks the persisted record, while the ``preset`` seeds only the fields neither the explicit layer nor the persisted record has fixed (the persisted record outranks the preset, per D11), so a no-arg follow-up call reproduces the previously resolved settings (stamped to the shared ``processing_parameters/stage2b_tau`` block both shape variants use). """ try: result = calibrate_tau_impl( file_path=str(self.filepath), shape=shape, settings=settings, preset=preset, ) tc = result["tau_calibration"] self.logger.info( "Stage 2b (%s): tau_maj=%.3f us, sigma_tau=%.3f us, " "n_contributors=%d, preconditions=%s", shape, tc.tau_maj_us, tc.sigma_tau_us, tc.n_contributors, "pass" if tc.preconditions_passed else "fail", ) return cast(TauCalibrationResult, tc) except StageDependencyError: raise except Exception as e: raise RuntimeError(f"Failed to calibrate tau: {e}") from e
[docs] def load_tau_calibration( self, *, shape: str = "lorentzian" ) -> TauCalibrationResult: """Load the persisted Stage 2b :class:`TauCalibrationResult` for ``shape``.""" return cast( TauCalibrationResult, load_tau_calibration_impl(str(self.filepath), shape=shape)[ "tau_calibration" ], )
[docs] def calibrate_timebase( self, *, clocks: Optional[Any] = None, kappa_sys: Optional[float] = None, snr_min: Optional[float] = None, ) -> TimebaseCalibrationResult: """Measure the scope-timebase scale error ``eps`` from Rb-locked tones. Requires Stage 0 (the raw FID); the canonical Stage 1 active-region bounds are read opportunistically. Resolves the instrument clock declaration from the explicit ``clocks`` argument, else the persisted Stage 5 ``spur.clocks``; raises ``ValueError`` if neither yields a non-empty declaration with at least one locked source. Persists the measured ``eps`` to ``/timebase_calibration``. Measures ``eps`` only; applying it to the frequency axis is out of scope. """ try: result = calibrate_timebase_impl( file_path=str(self.filepath), clocks=clocks, kappa_sys=kappa_sys, snr_min=snr_min, ) tc = result["timebase_calibration"] self.logger.info( "Timebase: eps=%+.3f ppm, sigma=%.3f ppm, used %d/%d tones, " "g=%.1f MHz, preconditions=%s", tc.epsilon * 1e6, tc.sigma_epsilon * 1e6, tc.n_used, tc.n_detected, tc.lattice_g_mhz, "pass" if tc.preconditions_passed else "fail", ) return cast(TimebaseCalibrationResult, tc) except StageDependencyError: raise except ValueError: raise except Exception as e: raise RuntimeError(f"Failed to calibrate timebase: {e}") from e
[docs] def load_timebase_calibration(self) -> TimebaseCalibrationResult: """Load the persisted :class:`TimebaseCalibrationResult`.""" return cast( TimebaseCalibrationResult, load_timebase_calibration_impl(str(self.filepath))["timebase_calibration"], )
[docs] def get_clock_sources(self) -> Optional[Tuple["ClockSource", ...]]: """Return the declared (recommended) instrument clock sources, or ``None``. These are the clock fundamentals the Stage 5 spur gate builds its lattice prior from. The declaration is the *recommended* resolver layer; an explicit ``clocks=`` at fit time and persisted Stage 5 settings still outrank it. """ from ._internal.clocks_impl import get_clock_sources_impl return get_clock_sources_impl(str(self.filepath))
[docs] def set_clock_sources( self, clocks: Any, *, replace: bool = True, ) -> Tuple["ClockSource", ...]: """Declare instrument clock sources on this experiment. ``clocks`` is a sequence of :class:`ClockSource` or ``{freq_mhz, locked, label}`` mappings. With ``replace=False`` the sources are appended to the current declaration. Declare chain *fundamentals* (e.g. 5760, not the 11520 product). Returns the resulting declaration. Writes the recommended layer, so the D11 reproducibility guarantee holds. """ from ._internal.clocks_impl import set_clock_sources_impl return set_clock_sources_impl(str(self.filepath), clocks, replace=replace)
[docs] def remove_clock_sources( self, freqs_mhz: Sequence[float] ) -> Tuple["ClockSource", ...]: """Remove declared clock sources matching the given frequencies (MHz).""" from ._internal.clocks_impl import remove_clock_sources_impl return remove_clock_sources_impl(str(self.filepath), freqs_mhz)
[docs] def clear_clock_sources(self) -> None: """Clear the clock-source declaration on this experiment.""" from ._internal.clocks_impl import clear_clock_sources_impl clear_clock_sources_impl(str(self.filepath))
[docs] def recommend_shape( self, *, settings: Optional[TauCalibrationSettings] = None, preset: Optional[str] = None, ) -> ShapeRecommendation: """Run the 3-way L/G/V per-bin AICc shape-recommendation hook. Mirrors the τ calibrations' STFT classifier and per-bin fit machinery, then fits exp / gauss / voigt on every contributor bin, computes AICc per bin, and aggregates an SNR-weighted majority vote. The verdict's ``recommended_shape`` (``"lorentzian"`` / ``"gaussian"`` / ``None``) is stamped onto every Stage 2b group present on the file so the Stage 5 resolver's *recommended* layer picks it up automatically. The Voigt vote mass is reported as a diagnostic but does not enter the recommendation (the production Stage 5 line-shape selector supports L and G only). Requires Stage 1 (active region + frequency trim) to have completed. The Stage 2b calibrations are *not* required for the verdict itself, but the persisted contract only fires when at least one of them has run; without a Stage 2b group the verdict is returned but no attr is stamped. Settings resolve through the chain (``settings`` / ``preset`` > persisted > hard default); ``settings=`` and ``preset=`` may be combined -- the ``settings`` bundle is the explicit override that outranks the persisted record, while the ``preset`` seeds only the fields neither the explicit layer nor the persisted record has fixed (the persisted record outranks the preset, per D11). The resolved settings are stamped to ``processing_parameters/stage2b_tau`` so a follow-up no-arg call inherits the same recipe. """ try: result = recommend_shape_impl( file_path=str(self.filepath), settings=settings, preset=preset, ) rec = result["shape_recommendation"] groups = result["groups_written"] self.logger.info( "Shape recommendation: %s (n_contributors=%d, " "vote rates exp=%.1f%%/gauss=%.1f%%/voigt=%.1f%%); " "written to %s", rec.recommended_shape, rec.n_contributors, rec.vote_rates["exp"] * 100, rec.vote_rates["gauss"] * 100, rec.vote_rates["voigt"] * 100, groups if groups else "no Stage 2b group present", ) return cast(ShapeRecommendation, rec) except StageDependencyError: raise except Exception as e: raise RuntimeError(f"Failed to recommend shape: {e}") from e
[docs] def visualize_tau_heatmap( self, output_file: Optional[Union[str, Path]] = None, interactive: bool = True, figsize: Optional[tuple] = None, shape: str = "lorentzian", ) -> Any: """Render the 2D STFT-magnitude heatmap (frame x molecular frequency). ``shape`` selects the pure-exp (``"lorentzian"``) or Gaussian (``"gaussian"``) Stage 2b calibration group. """ from .visualization.tau_calibration_visualization import ( plot_tau_heatmap_from_file, ) fig = plot_tau_heatmap_from_file( str(self.filepath), shape=shape, figsize=figsize ) if output_file: fig.savefig(str(output_file), dpi=150, bbox_inches="tight") self.logger.info(f"Plot saved to: {output_file}") elif interactive: import matplotlib.pyplot as plt plt.show() return fig
[docs] def visualize_tau_distribution( self, output_file: Optional[Union[str, Path]] = None, interactive: bool = True, figsize: Optional[tuple] = None, shape: str = "lorentzian", ) -> Any: """Render the tau-distribution analysis panel. ``shape`` selects the pure-exp (``"lorentzian"``) or Gaussian (``"gaussian"``) Stage 2b calibration group. """ from .visualization.tau_calibration_visualization import ( plot_tau_distribution_from_file, ) fig = plot_tau_distribution_from_file( str(self.filepath), shape=shape, figsize=figsize ) if output_file: fig.savefig(str(output_file), dpi=150, bbox_inches="tight") self.logger.info(f"Plot saved to: {output_file}") elif interactive: import matplotlib.pyplot as plt plt.show() return fig
[docs] def detect_start_time( self, *, band: Optional[Tuple[float, float]] = None, stamp: bool = True, settings: Optional[StartDetectionSettings] = None, ) -> StartDetectionResult: """Infer a good FID ``start_us`` from the data and stamp it. Sweeps the FID window start time, integrates the FT magnitude over the active band, and locates the chirp-end collapse; the recommended start is ``chirp_end + guard_margin_us`` (the switch-bounce settling time). When ``stamp=True`` (default) the recommended ``start_us`` is written to the Stage 0 ``recommended_processing`` layer, so a later ``compute_ft`` with no explicit ``start_us`` inherits it. Requires Stage 0 (FID) only; the integration band is resolved from the canonical Stage 1 trim when present, else the full positive spectrum. Equivalent to the CLI ``start run`` command and ``ftmwpipeline.api.detect_start_time``. Parameters ---------- band : tuple of float, optional Explicit ``(min_mhz, max_mhz)`` integration band override (a convenience for ``settings`` ``band_min_mhz`` / ``band_max_mhz``). stamp : bool, default True Whether to persist the recommended ``start_us`` to the recommended layer. settings : StartDetectionSettings, optional The detection knobs. Unlike the resolver-backed stages, Stage 0 is a flat bundle with concrete defaults: construct a :class:`~ftmwpipeline.core.start_detection_settings.StartDetectionSettings` with the fields to override (``sweep_max_us`` / ``step_us`` / ``guard_margin_us`` / ``floor_factor`` / …). ``band`` wins over the bundle's band fields when supplied. Returns ------- StartDetectionResult The recommendation plus diagnostics (chirp-end, sweep arrays). """ resolved = self._apply_band_override(settings, band) result = detect_start_time_impl( str(self.filepath), settings=resolved, stamp=stamp ) return cast(StartDetectionResult, result["start_detection"])
@staticmethod def _apply_band_override( settings: Optional[StartDetectionSettings], band: Optional[Tuple[float, float]], ) -> StartDetectionSettings: """Fold the ``band`` convenience tuple onto a base settings bundle.""" base = settings or StartDetectionSettings() if band is None: return base from dataclasses import replace return replace(base, band_min_mhz=float(band[0]), band_max_mhz=float(band[1]))
[docs] def visualize_start_detection( self, output_file: Optional[Union[str, Path]] = None, interactive: bool = True, figsize: Optional[tuple] = None, *, settings: Optional[StartDetectionSettings] = None, ) -> Any: """Render the start-detection sweep diagnostic (no stamping).""" from .visualization.start_detection_visualization import ( plot_start_detection_from_file, ) fig = plot_start_detection_from_file( str(self.filepath), settings=settings, figsize=figsize ) if output_file: fig.savefig(str(output_file), dpi=150, bbox_inches="tight") self.logger.info(f"Plot saved to: {output_file}") elif interactive: import matplotlib.pyplot as plt plt.show() return fig
[docs] def detect_peaks( self, *, settings: Optional[PeakDetectionSettings] = None, preset: Optional[str] = None, ) -> List[Peak]: """Detect and classify peaks (Stage 3, two-pass). Requires Stage 1 (FT) and Stage 2 (noise) completed. Runs an apodized (Blackman-Harris) primary pass for the robust strong-line list plus a shape-aware matched-filter gap pass that recovers weak lines the apodization smears; both raise their detection floor continuously by the local coherent-leakage amplitude rather than a hard mask. Every peak is then scored (amplitude + SNR) on the canonical unapodized active FT, classified by SNR, and ALL detected peaks are persisted to the .ftmw file. Equivalent to the CLI ``peaks run`` command and ``ftmwpipeline.api.detect_peaks``. Detection operates on the Stage 1 persisted canonical spectrum, including its frequency trim range. Peaks are reported on that user grid with amplitude and SNR measured against the canonical Stage 2 noise. There is no per-Stage-3 trim or zpf. Settings resolve through the chain (``settings`` / ``preset`` > persisted > hard default); pass ``settings=`` to drive detection from a :class:`PeakDetectionSettings` instance, or ``preset=NAME_OR_PATH`` to load from packaged YAML. They may be combined: a ``settings`` bundle is the explicit override that outranks the persisted record, while a ``preset`` seeds only the fields neither the explicit layer nor the persisted record has fixed (the persisted record outranks the preset, per D11). Set individual knobs via ``settings=PeakDetectionSettings(...)`` or a YAML preset's ``stage3:`` block. Parameters ---------- settings : PeakDetectionSettings, optional Bundle of Stage 3 knobs; fields left ``None`` fall through the resolution chain. May be combined with ``preset``. preset : str, optional Bare preset name or path to a YAML file carrying a ``stage3:`` block. Seeds the preset layer beneath the persisted record; may be combined with ``settings``. Returns ------- list of Peak ALL detected peaks (promoted and non-promoted), sorted by frequency. Each peak's ``properties`` dict includes ``promoted`` (bool), ``internal_snr``, ``internal_frequency``, and ``detection_pass``. Raises ------ StageDependencyError If Stage 1 or Stage 2 has not been completed. RuntimeError If detection fails. """ try: result = detect_peaks_impl( file_path=str(self.filepath), settings=settings, preset=preset, ) self.logger.info( "Stage 3: %d detected (%d promoted, SNR >= %.1f); " "%d primary, %d gap", result["n_peaks"], result["n_promoted"], result["promotion_min_snr"], result["n_primary"], result["n_gap"], ) return cast(List[Peak], result["peaks"]) except StageDependencyError: raise except Exception as e: raise RuntimeError(f"Failed to detect peaks: {e}") from e
[docs] def load_peaks(self) -> List[Peak]: """Load the persisted Stage 3 peak list (validates structure loudly).""" return cast(List[Peak], load_peaks_impl(str(self.filepath))["peaks"])
[docs] def visualize_peaks( self, figsize: Optional[tuple] = None, title: Optional[str] = None, y_max_factor: Optional[float] = None, interactive: bool = True, output_file: Optional[Union[str, Path]] = None, show_snr_histogram: bool = False, ) -> Any: """Overlay classified detected peaks on the unapodized spectrum (Stage 3). Equivalent to the CLI ``peaks show`` command. Interactive matplotlib (log-y) by default; pass ``interactive=False`` with ``output_file`` to save instead. Parameters ---------- figsize : tuple, optional Figure size ``(width, height)`` in inches. title : str, optional Custom plot title. y_max_factor : float, optional Y-axis max as multiple of median RMS noise (default 25.0). interactive : bool, default True Whether to open an interactive window. output_file : str or Path, optional Save plot to this path (non-interactive mode). show_snr_histogram : bool, default False If True, add a second panel showing the user-grid SNR distribution with the promotion cutoff marked (curation view). Returns ------- figure Matplotlib figure (single-panel or two-panel when ``show_snr_histogram=True``). Raises ------ RuntimeError If Stage 3 has not been completed or visualization fails. """ try: fig = visualize_peaks_impl( file_path=str(self.filepath), figsize=figsize, title=title, y_max_factor=y_max_factor, interactive=interactive, show_snr_histogram=show_snr_histogram, ) if not interactive and output_file: fig.savefig(str(output_file), dpi=300, bbox_inches="tight") self.logger.info(f"Plot saved to: {output_file}") elif interactive: import matplotlib.pyplot as plt plt.show() return fig except Exception as e: raise RuntimeError(f"Failed to create peak visualization: {e}") from e
[docs] def assign_windows( self, *, settings: Optional[WindowPlanningSettings] = None, preset: Optional[str] = None, ) -> WindowPlan: """Assign analysis windows (Stage 4), turning promoted peaks into a fit plan. Requires Stage 3 (peak detection) completed. Builds a set of disjoint fit windows over the persisted user spectrum, each annotated with the peaks to fit freely, the strong out-of-band lines whose leakage is carried frozen, and a fit dependency order. Stage 4 is purely structural -- it makes no fits. Equivalent to the CLI ``windows run`` command and ``ftmwpipeline.api.assign_windows``. Consumes only the peaks flagged ``promoted`` by Stage 3, on the Stage 1 canonical spectrum with the canonical Stage 2 noise. The result is persisted to ``/stage4_windows`` and the stage marked complete. Settings resolve through the chain (``settings`` / ``preset`` > persisted > hard default); pass ``settings=`` to drive window planning from a :class:`WindowPlanningSettings` instance, or ``preset=NAME_OR_PATH`` to load from packaged YAML. They may be combined: a ``settings`` bundle is the explicit override that outranks the persisted record, while a ``preset`` seeds only the fields neither the explicit layer nor the persisted record has fixed (the persisted record outranks the preset, per D11). Set individual knobs via ``settings=WindowPlanningSettings(...)`` or a YAML preset's ``stage4:`` block. Parameters ---------- settings : WindowPlanningSettings, optional Bundle of Stage 4 knobs; fields left ``None`` fall through the resolution chain. May be combined with ``preset``. preset : str, optional Bare preset name or path to a YAML file carrying a ``stage4:`` block. Seeds the preset layer beneath the persisted record; may be combined with ``settings``. Returns ------- WindowPlan The fit plan: disjoint windows, dependency DAG, topological order, parallel batches, parameters and diagnostics. Raises ------ StageDependencyError If Stage 3 has not been completed. RuntimeError If window assignment fails. """ try: result = assign_windows_impl( file_path=str(self.filepath), settings=settings, preset=preset, ) self.logger.info( "Stage 4: %d windows, %d batches, %d free peaks, " "%d fixed contributors", result["n_windows"], result["n_batches"], result["n_free_peaks"], result["n_fixed_contributors"], ) return cast(WindowPlan, result["plan"]) except StageDependencyError: raise except Exception as e: raise RuntimeError(f"Failed to assign windows: {e}") from e
[docs] def load_windows(self) -> WindowPlan: """Load the persisted Stage 4 window plan (validates structure loudly).""" return cast(WindowPlan, load_windows_impl(str(self.filepath))["plan"])
[docs] def visualize_windows( self, figsize: Optional[tuple] = None, title: Optional[str] = None, y_max_factor: Optional[float] = None, interactive: bool = True, output_file: Optional[Union[str, Path]] = None, ) -> Any: """Overlay the Stage 4 window plan on the spectrum. Equivalent to the CLI ``windows show`` command. Shows each fit window's span, free peaks, fixed contributors, and the rolling complex-edge coherence statistic. Requires Stage 4 completed. Parameters ---------- figsize : tuple, optional Figure size ``(width, height)`` in inches. title : str, optional Custom plot title. y_max_factor : float, optional Spectrum-panel y-axis headroom (default 25.0). interactive : bool, default True Whether to open an interactive window. output_file : str or Path, optional Save plot to this path (non-interactive mode). Returns ------- figure Matplotlib figure. Raises ------ RuntimeError If Stage 4 has not been completed or visualization fails. """ try: fig = visualize_windows_impl( file_path=str(self.filepath), figsize=figsize, title=title, y_max_factor=y_max_factor, interactive=interactive, ) if not interactive and output_file: fig.savefig(str(output_file), dpi=300, bbox_inches="tight") self.logger.info(f"Plot saved to: {output_file}") elif interactive: import matplotlib.pyplot as plt plt.show() return fig except Exception as e: raise RuntimeError(f"Failed to create window visualization: {e}") from e
[docs] def fit_peaks( self, *, shape: Optional[str] = None, tau_maj_override_us: Optional[float] = None, sigma_tau_override_us: Optional[float] = None, settings: Optional["StageFitSettings"] = None, preset: Optional[str] = None, jobs: Optional[int] = None, ) -> SpectrumFit: """Fit each Stage 4 window's lines (Stage 5). Requires Stage 4 (window assignment) completed. Drives the conservative add-one-peak loop over each window with the shared per-window decay ``tau`` and the frozen-contributor model, then the residual edge-coherence handshake (local thaw + structural replan). Equivalent to the CLI ``fit run`` command and ``ftmwpipeline.api.fit_peaks``. The fit runs on the active-portion FT (computed on demand from the FID + canonical Stage 1 settings), so per-bin statistics are independent and reduced chi-squared / F-test / AIC are calibrated as written. The persistent :class:`SpectrumFit` -- per-window :class:`FittingResult` s, the merged global fitted-peak list, the thaw / replan histories, and the parameters used -- is written to ``/stage5_fitting``. Settings resolve through the chain (``settings`` / ``preset`` > persisted > recommended > hard default); pass ``settings=`` to drive the fit from a :class:`StageFitSettings` instance, or ``preset=NAME_OR_PATH`` to load from packaged YAML. They may be combined: a ``settings`` bundle outranks a value persisted in the ``.ftmw`` while a ``preset`` .yml seeds only the fields neither the explicit layer nor the persisted record has fixed (the persisted record outranks the preset, per D11). Parameters ---------- shape : {"lorentzian", "gaussian"}, optional Time-domain envelope of the per-line model, kept as a first-class convenience argument. ``"lorentzian"`` uses ``exp(-t/τ)``; ``"gaussian"`` uses ``exp(-(t/τ_G)²)`` and consumes the Stage 2b τ_G calibration (``calibrate_tau(shape="gaussian")``) in place of the pure-exp variant for the bidirectional τ anchoring penalty. ``None`` falls through to the resolved settings (preset / persisted / default). tau_maj_override_us, sigma_tau_override_us : float, optional Atomic-pair manual override for the Stage 2b tau calibration, kept as explicit arguments (an A/B escape hatch crossing a stage boundary, not a fit knob). When both are supplied (positive), they replace any persisted Stage 2b result for this fit. Supplying only one of the pair raises ``ValueError``. settings : StageFitSettings, optional Bundle of Stage 5 knobs; fields left ``None`` fall through the resolution chain. Resolves at the explicit override layer (outranks the persisted record). Build with :class:`~ftmwpipeline.core.stage_fit_settings.StageFitSettings`. May be combined with ``preset``. preset : str, optional Bare preset name (e.g. ``"defaults"``) or a path to a YAML file carrying a ``stage5:`` block. Enters at the preset layer (a persisted ``.ftmw`` outranks it). The preset name is captured in the persisted Stage 5 fit's audit attrs for reproducibility. May be combined with ``settings``. jobs : int, optional Worker-pool size for the cross-window parallel fit. ``None`` (the default) resolves the pool from the ``FTMW_MAX_WORKERS`` environment variable, falling back to ``cpu_count() - 2``; ``1`` forces a sequential fit. The fit result is byte-identical regardless of the worker count. Returns ------- SpectrumFit The persistent fit aggregate. Raises ------ StageDependencyError If Stage 4 has not been completed. RuntimeError If fitting fails. """ try: result = fit_peaks_impl( file_path=str(self.filepath), shape=shape, tau_maj_override_us=tau_maj_override_us, sigma_tau_override_us=sigma_tau_override_us, settings=settings, preset=preset, jobs=jobs, ) self.logger.info( "Stage 5: %d windows, %d fitted peaks; thaw %d/%d, " "rescue %d/%d (added %d, %d rescue-origin pruned), " "%d structural replans accepted (revision %d)", result["n_windows"], result["n_fitted_peaks"], result["n_thaw_accepted"], result["n_thaw_events"], result["n_rescue_accepted"], result["n_rescue_events"], result["n_rescue_added"], result["n_rescue_origin_pruned"], result["n_replan_accepted"], result["final_plan_revision"], ) return cast(SpectrumFit, result["fit"]) except StageDependencyError: raise except Exception as e: raise RuntimeError(f"Failed to fit peaks: {e}") from e
[docs] def load_fit(self) -> SpectrumFit: """Load the persisted Stage 5 fit (validates structure loudly).""" return cast(SpectrumFit, load_fit_impl(str(self.filepath))["fit"])
[docs] def candidate_ledger( self, window_id: Optional[int] = None, *, bar: float = DEFAULT_DISPLAY_BAR, ) -> List[LedgerCandidate]: """Derive the Stage 6 candidate ledger from the persisted Stage 5 fit. Returns revivable candidates from the conservative add-loop and rescue records -- candidates that were considered but not accepted by the automatic fit. The ledger is derived on demand from the already-persisted audit trail; it does not re-fit anything. Parameters ---------- window_id : When given, return candidates for that window only. bar : Display SNR / evidence bar; candidates below it are dropped. Returns ------- list of LedgerCandidate Sorted by molecular frequency. Requires Stage 5 completed. """ return get_candidate_ledger_impl(self.filepath, window_id=window_id, bar=bar)
[docs] def review_edit( self, window_id: int, *, add: Sequence[float] = (), remove: Sequence[float] = (), snap_tol_mhz: float = 0.05, ) -> RefitWindowResult: """User-directed single-window refit (Stage 6 ``review edit``). Re-fits ``window_id`` from the persisted Stage 5 fit using the production NLS primitive, applying ``add``/``remove`` edits. User- added peaks carry ``origin="user"`` and survive the HDF5 round-trip; removed peaks are excluded from the refit and will not be re-added by the rescue pass. Parameters ---------- window_id : The window to refit. add : Molecular frequencies (MHz) of peaks to add. Snapped to the nearest ledger candidate within ``snap_tol_mhz`` or seeded fresh at F. remove : Molecular frequencies (MHz) of fitted peaks to remove. Snapped to the nearest fitted peak within ``snap_tol_mhz``. snap_tol_mhz : Snap tolerance for ``add``/``remove`` (MHz; default 0.05 = 50 kHz). Returns ------- RefitWindowResult Old vs new peak count, χ²ᵣ before/after, and the new fitted peaks. Requires Stage 5 completed. """ return refit_window_impl( self.filepath, window_id, add=add, remove=remove, snap_tol_mhz=snap_tol_mhz, )
[docs] def review_merge( self, window_id: int, peaks: Sequence[float], *, snap_tol_mhz: float = 0.05, ) -> RefitWindowResult: """Collapse ≥2 fitted peaks in a window into one (Stage 6 ``review merge``). Removes the named peaks and adds one replacement seeded at their SNR-weighted centroid (or amplitude-weighted centroid when SNR is unavailable). When the pair matches a persisted doublet-alternative record with a successful merged refit, the recorded merged seed is used instead of the centroid. All products carry ``origin="user"``. Parameters ---------- window_id : The window containing the peaks to merge. peaks : Molecular frequencies (MHz) of the peaks to collapse (≥2). snap_tol_mhz : Maximum distance (MHz) for frequency snapping to fitted peaks. Returns ------- RefitWindowResult Old vs new peak count, χ²ᵣ before/after, and the new fitted peaks. Requires Stage 5 completed. """ return merge_peaks_impl( self.filepath, window_id, peaks, snap_tol_mhz=snap_tol_mhz, )
[docs] def review_split( self, window_id: int, peak: float, *, into: int = 2, snap_tol_mhz: float = 0.05, ) -> RefitWindowResult: """Replace one fitted peak with ``into`` peaks (Stage 6 ``review split``). Removes the named peak and adds ``into`` replacements spread symmetrically about it by ±½ of one Fourier resolution element (``1 / acquisition_us`` MHz). All products carry ``origin="user"``. Parameters ---------- window_id : The window containing the peak to split. peak : Molecular frequency (MHz) of the peak to split. into : Number of replacement peaks (≥2, default 2). snap_tol_mhz : Maximum distance (MHz) for frequency snapping to fitted peaks. Returns ------- RefitWindowResult Old vs new peak count, χ²ᵣ before/after, and the new fitted peaks. Requires Stage 5 completed. """ return split_peak_impl( self.filepath, window_id, peak, into=into, snap_tol_mhz=snap_tol_mhz, )
[docs] def review_run( self, *, bar: float = DEFAULT_DISPLAY_BAR, attention_candidate_evidence: float = DEFAULT_ATTENTION_CANDIDATE_EVIDENCE, sigma_floor_khz: Optional[float] = None, ) -> ReviewRunResult: """Build or refresh the Stage 6 curation layer and final-products table. Computes advisory attention reasons for each fitted window, consolidates the calibrated final-products table (timebase-corrected frequencies + the three-term ``sigma_f`` budget), persists the :class:`~ftmwpipeline.core.data_structures.Stage6Review` to the ``stage6_review`` HDF5 group, and marks the stage complete. Existing per-window provenance (``"reviewed"``/``"user-edited"``) and the decision log are preserved; only attention reasons are refreshed. Parameters ---------- bar : Display bar for the candidate-bearing attention reason. attention_candidate_evidence : Evidence threshold above which a candidate-bearing window flags (stiffer than ``bar``; keeps the attention surface actionable). sigma_floor_khz : When given, persist this user-declared accuracy floor (kHz) into the file-level ``/frequency_calibration`` record and fold it into the budget. ``None`` (default) keeps the persisted floor unchanged. Returns ------- ReviewRunResult Total window count, attention count, and per-kind breakdown. Requires Stage 5 completed. """ return review_run_impl( self.filepath, bar=bar, attention_candidate_evidence=attention_candidate_evidence, sigma_floor_khz=sigma_floor_khz, )
[docs] def set_sigma_floor(self, sigma_floor_khz: float) -> None: """Declare the systematic frequency-accuracy floor (kHz), persisted in-file. Stores the floor as file-level provenance (``/frequency_calibration``) so any reported ``sigma_f`` is reproducible from the record alone. Call :meth:`review_run` afterwards to fold the new floor into the budget. """ set_sigma_floor_impl(self.filepath, sigma_floor_khz)
[docs] def final_products(self) -> Optional[FinalProducts]: """Return the persisted Stage 6 calibrated final-products table. Read-only; ``None`` until :meth:`review_run` has built it. """ return get_final_products_impl(self.filepath)
[docs] def report_table( self, *, fmt: str = "csv", output: Optional[Union[str, Path]] = None, catalog: Optional[Union[str, Path]] = None, catalog_n_sigma: float = 3.0, ) -> str: """Render the calibrated final-products table (report Level 1). Serializes the persisted :class:`FinalProducts` table to ``csv``, ``json``, or a LaTeX ``booktabs`` table. Renders the persisted record; does not recompute. Requires :meth:`review_run` to have built the table. Parameters ---------- fmt : ``"csv"`` (default), ``"json"``, or ``"latex"``. output : When given, also write the rendered text to this path. catalog : Optional frequency-catalog path; when given, each line is proximity-flagged against the nearest catalog entry (label echo only, never an assignment) and the match is added to the output. catalog_n_sigma : Catalog match tolerance in combined sigmas (default ``3``). Returns ------- str The rendered table. """ return report_table_impl( self.filepath, fmt=fmt, output=output, catalog=catalog, catalog_n_sigma=catalog_n_sigma, )
[docs] def report_run( self, *, output_dir: Optional[Union[str, Path]] = None, windows: str = "all", emit_table: bool = True, emit_html: bool = True, table_format: str = "csv", scope: str = "full", catalog: Optional[Union[str, Path]] = None, catalog_n_sigma: float = 3.0, jobs: Optional[int] = None, ) -> Dict[str, Optional[str]]: """Write the default Stage 6 deliverables: the L1 table + the L3 report. Writes the Level-1 final-products table (``<stem>_lines.csv``) and the self-contained Level-3 HTML report with every window folded in (``<stem>_report.html``) into *output_dir*, reusing the existing renderers. Renders the persisted record; does not recompute. Requires :meth:`review_run` to have built the final-products table. Parameters ---------- output_dir : Directory to write the artifacts into (created if absent). Defaults to the current working directory when omitted. windows : ``"all"`` (a detail page per window) or ``"attention"`` (pages only for review-flagged windows). The index always lists every window. emit_table : Write the Level-1 table artifact (default ``True``). emit_html : Write the Level-3 HTML report (default ``True``). table_format : Format for the table artifact: ``"csv"`` (default), ``"json"``, or ``"latex"``. scope : ``"full"`` (default) writes one self-contained ``<stem>_report.html`` with every per-window detail folded in; ``"summary"`` writes ``<stem>_report_summary.html`` (index + methods only). Either way the output is a single self-contained file. catalog : Optional frequency-catalog path; adds proximity-match cross-references to the table and HTML (label echo only, never an assignment). catalog_n_sigma : Catalog match tolerance in combined sigmas (default ``3``). jobs : Worker-pool size for the per-window figure rendering. ``None`` (the default) resolves the pool from the ``FTMW_MAX_WORKERS`` environment variable, falling back to ``cpu_count() - 2``; ``1`` renders sequentially. Returns ------- dict ``{"table": <path|None>, "html": <path|None>}`` -- the path of each artifact written (``None`` when suppressed). """ return report_run_impl( self.filepath, output_dir=output_dir, windows=windows, emit_table=emit_table, emit_html=emit_html, table_format=table_format, scope=scope, catalog=catalog, catalog_n_sigma=catalog_n_sigma, jobs=jobs, )
[docs] def report_diff( self, *, output_dir: Optional[Union[str, Path]] = None, dpi: int = 110, ) -> str: """Render the post-curation before/after diff report; return its path. Compares the automatic-fit baseline snapshot with the current curated fit and writes a self-contained ``<stem>_diff.html`` with a side-by-side panel for every window that differs materially -- both the windows edited directly and the dependents the contributor-edit cascade changed -- so the changes can be evaluated before they are committed. When no curation edit has been made (no baseline snapshot exists), a report stating that is written instead. Read-only; never recomputes the fit. Parameters ---------- output_dir : Directory to write the report into (created if absent). Defaults to the current working directory. dpi : Resolution for the per-window panels. Returns ------- str Path to the generated ``<stem>_diff.html`` file. """ return report_diff_impl(self.filepath, output_dir=output_dir, dpi=dpi)
[docs] def review_accept( self, window_id: int, *, candidate_freq: Optional[float] = None, ) -> Optional[RefitWindowResult]: """Accept a window as-is or accept a specific revived candidate. With no ``candidate_freq``: records a ``"accept"`` log entry, sets the window's provenance to ``"reviewed"``, and returns ``None``. The fit is left unchanged; the acceptance is a "looked, no change needed" decision. With ``candidate_freq``: adds the candidate peak via a single-window refit, records an ``"add"`` log entry, sets provenance to ``"user-edited"``, and returns the :class:`RefitWindowResult`. Parameters ---------- window_id : The window to accept. candidate_freq : When given, accept by adding this molecular frequency (MHz) as a new peak. Snapped to the nearest ledger candidate within 50 kHz. Returns ------- RefitWindowResult or None """ return review_accept_impl( self.filepath, window_id, candidate_freq=candidate_freq )
[docs] def review_apply( self, curation_path: Union[str, Path], *, dry_run: bool = False, ) -> CurationApplyResult: """Apply a curation file of batched review edits. Replays a curation CSV (``action,window,freqs,params`` rows) through the same edit impls the interactive verbs use: a run of add/remove rows on one window coalesces into a single refit, while merge/split/accept stand alone. With ``dry_run`` the resolved plan and any frequency-resolution warnings are returned without modifying the file. Parameters ---------- curation_path : Path to the curation CSV to apply. dry_run : Preview the resolved plan without writing (default ``False``). Returns ------- CurationApplyResult The resolved action plan, warnings, and the number applied. """ return apply_curation_impl(self.filepath, curation_path, dry_run=dry_run)
[docs] def review_log(self) -> List[DecisionLogEntry]: """Return the persisted Stage 6 decision log (read-only, in order). Returns ------- list of DecisionLogEntry Every recorded user decision, keyed by ``order_index``. """ return review_log_impl(self.filepath)
[docs] def review_undo( self, ids: Sequence[int], *, dry_run: bool = False, ) -> UndoResult: """Undo recorded decisions by id, replaying the rest from baseline. Restores the automatic Stage 5 fit (snapshotted before the first edit), rebuilds the review from it, and re-applies every surviving decision, so decision ids are renumbered afterward. ``dry_run`` previews the removed/ surviving split and the replay plan without writing. Parameters ---------- ids : Decision ids (``order_index`` values from :meth:`review_log`) to undo. dry_run : Preview without mutating (default ``False``). Returns ------- UndoResult Raises ------ ValueError If an id is unknown, there are no decisions, or the automatic-fit baseline is unavailable while fit-mutating decisions exist. """ return review_undo_impl(self.filepath, ids, dry_run=dry_run)
[docs] def review_status(self) -> Stage6Review: """Load the persisted Stage 6 review state, or return an empty one. Read-only: safe to call before ``review run``. Returns ------- Stage6Review The persisted per-window statuses and decision log. """ return get_review_status_impl(self.filepath)
[docs] def rank_windows( self, by: str, *, top: Optional[int] = None, ) -> List["RankedWindow"]: """Rank fit windows by a persisted per-window statistic (read-only). On-demand exploration decoupled from the attention flags: ranks all windows worst-first by ``by`` (see :data:`~ftmwpipeline._internal.stage6_impl.RANK_METRICS` for the choices -- ``min-snr``, ``max-vif``, ``chi2r``, ``candidate-evidence``, ``edge-distance``, ``spur-proximity``, ``merged-chi2r``). Parameters ---------- by : Metric name (``_`` and ``-`` interchangeable). top : Return at most this many windows; ``None`` returns all. Requires Stage 5 completed. """ return rank_windows_impl(self.filepath, by=by, top=top)
[docs] def validate_stage5_shape_error( self, kappa: Optional[float] = None, noise_floor: Optional[float] = None, ground_truth: Optional[Union[str, Path]] = None, match_tol_fwhm: float = 0.5, ) -> Dict[str, Any]: """Assess the persisted Stage 5 fit against the SNR-aware framework. Read-only. Returns a Tier 1 (SNR-aware per-window acceptance ``chi2r <= F + (kappa*SNR_max)**2`` with the fractional deficit ``eps`` binned by SNR) / Tier 2 (rescue/merge/thaw gate firing) / Tier 3 (known-line ground truth, when ``ground_truth`` is given) report. Equivalent to the CLI ``fit check`` command. Requires Stage 5 completed. """ try: report = validate_stage5_shape_error_impl( file_path=str(self.filepath), kappa=kappa, noise_floor=noise_floor, ground_truth=str(ground_truth) if ground_truth is not None else None, match_tol_fwhm=match_tol_fwhm, ) t1 = report["tier1"] self.logger.info( "Stage 5 shape-error validation: %d windows, SNR-aware pass " "rate %.3f (kappa=%.3g)", t1.get("n_windows", 0), t1.get("pass_rate", 0.0), report["parameters"]["kappa"], ) return report except StageDependencyError: raise except Exception as e: raise RuntimeError(f"Failed to validate Stage 5 shape error: {e}") from e
[docs] def visualize_fit( self, figsize: Optional[tuple] = None, title: Optional[str] = None, window_id: Optional[int] = None, interactive: bool = True, output_file: Optional[Union[str, Path]] = None, ) -> Any: """Overlay the Stage 5 fit on the spectrum. Equivalent to the CLI ``fit show`` command. With ``window_id`` set, draws a per-window detail figure (re/im, magnitude+residual, time envelope, audit-trail); otherwise an overview overlay of the fitted model on the persisted spectrum. Requires Stage 5 completed. """ try: fig = visualize_fit_impl( file_path=str(self.filepath), figsize=figsize, title=title, window_id=window_id, interactive=interactive, ) if not interactive and output_file: fig.savefig(str(output_file), dpi=300, bbox_inches="tight") self.logger.info(f"Plot saved to: {output_file}") elif interactive: import matplotlib.pyplot as plt plt.show() return fig except Exception as e: raise RuntimeError(f"Failed to create fit visualization: {e}") from e
[docs] def show_fit( self, *, window_ids: Optional[list] = None, freqs: Optional[list] = None, random_n: Optional[int] = None, random_seed: Optional[int] = None, top_snr: Optional[int] = None, all_windows: bool = False, output_dir: Optional[Union[str, Path]] = None, show_audit: bool = False, apodize: Optional[str] = None, apodize_us: Optional[float] = None, rescue: bool = False, figsize: Optional[tuple] = None, title: Optional[str] = None, interactive: bool = False, ) -> Dict[str, Any]: """Show the Stage 5 fit -- overview, or a consolidated per-window detail figure for each selected window. Equivalent to the CLI ``fit show`` command. With no selector, returns the spectrum-wide overview. The selectors (``window_ids`` / ``freqs`` / ``random_n`` / ``top_snr`` / ``all_windows``) compose as a union; with ``output_dir`` each detail figure is written as ``<stem>_window_<id>.png``. With ``rescue`` an extra residual-rescue summary figure is produced per window (no re-fit): data/model, the final residual with each round's nominations, the chi-squared trajectory, and the peak budget. Returns ``{"mode", "window_ids", "figures", "paths", "log"}``. Requires Stage 5. """ try: result = fit_show_impl( file_path=str(self.filepath), window_ids=window_ids, freqs=freqs, random_n=random_n, random_seed=random_seed, top_snr=top_snr, all_windows=all_windows, output_dir=str(output_dir) if output_dir is not None else None, show_audit=show_audit, apodize=apodize, apodize_us=apodize_us, rescue=rescue, figsize=figsize, title=title, ) if interactive and not result["paths"]: import matplotlib.pyplot as plt plt.show() return result except Exception as e: raise RuntimeError(f"Failed to show fit: {e}") from e
[docs] def info(self) -> Dict[str, Any]: """ Get pipeline file information and status. Returns ------- dict Pipeline status and metadata information """ try: validation_report = validate_pipeline_file(self.filepath) # Refresh from disk: stages completed by compute_ft()/estimate_noise() # (or by another interface) are written to the file, so the # in-memory tracker captured at open()/create() time is stale. _, self.source_metadata, self.stage_tracker = open_pipeline_file( self.filepath ) info_dict = { "filepath": str(self.filepath), "valid": validation_report["valid"], "source_path": str(self.source_metadata.source_path), "format": self.source_metadata.format_name, "import_time": self.source_metadata.import_timestamp.isoformat(), "completed_stages": list(self.stage_tracker.completed_stages), "next_available_stages": self.stage_tracker.get_next_available_stages(), } if not validation_report["valid"]: info_dict["errors"] = validation_report["errors"] if validation_report.get("warnings"): info_dict["warnings"] = validation_report["warnings"] return info_dict except Exception as e: return { "filepath": str(self.filepath), "valid": False, "error": f"Failed to get info: {e}", }
[docs] def validate(self) -> Dict[str, Any]: """ Validate pipeline file integrity. Returns ------- dict Detailed validation report Raises ------ PipelineCorruptionError If file is corrupted and cannot be validated """ return validate_pipeline_file(self.filepath)
# ========================================================================= # Parameter-scan surface # =========================================================================
[docs] @staticmethod def scan_list( selector: Optional[str] = None, *, include_advanced: bool = False, ) -> Tuple["KnobSpec", ...]: """List the registered tunable knobs. Equivalent to :func:`ftmwpipeline.api.scan_list`. The returned :class:`KnobSpec` tuple is independent of any file, so this is a staticmethod; it is exposed on the class for dual-interface parity. ``selector`` filters by dotted-path prefix (e.g. ``"stage2b"`` / ``"stage2b.gaussian"``); ``include_advanced`` reveals advanced-tier knobs hidden from the default listing. """ from ._internal.tuning import list_knobs return list_knobs(selector, include_advanced=include_advanced)
[docs] def scan_run( self, knob: str, grid: Optional[Sequence[Any]] = None, output_dir: Optional[Union[str, Path]] = None, reuse: bool = False, make_plot: bool = True, quiet: bool = False, zoom_regions: Optional[Sequence[Tuple[float, float]]] = None, n_zoom: Optional[int] = None, zoom_width_mhz: Optional[float] = None, fit_top_snr: int = 3, fit_sample: int = 20, fit_freqs: Optional[Sequence[float]] = None, fit_sample_seed: int = 0, fit_all: bool = False, ) -> "SweepResult": """Sweep a single knob across a grid on a copy of this file. Equivalent to :func:`ftmwpipeline.api.scan_run`. Re-runs the knob's stage for each grid value on a working copy (this file is never mutated), returning a :class:`SweepResult` with the table, CSV path, optional plot, recommendation, and how-to-apply instructions. A progress indicator is printed to stderr unless ``quiet=True``. ``zoom_regions`` pins explicit ``(lo_mhz, hi_mhz)`` windows for the region-based plot adapters (Stage 3 / Stage 4), overriding their divergence auto-selection; ``n_zoom`` / ``zoom_width_mhz`` instead tune how many regions to auto-select and how wide each is. The ``fit_*`` controls bound a Stage 5 fit sweep to a window subset (the ``fit_top_snr`` brightest + a seeded ``fit_sample`` sample + the windows nearest ``fit_freqs``); ``fit_all`` re-fits every window. """ from ._internal.tuning import get_knob, run_scan return run_scan( get_knob(knob), self.filepath, grid=grid, output_dir=Path(output_dir) if output_dir is not None else None, reuse=reuse, make_plot=make_plot, quiet=quiet, zoom_regions=zoom_regions, n_zoom=n_zoom, zoom_width_mhz=zoom_width_mhz, fit_top_snr=fit_top_snr, fit_sample=fit_sample, fit_freqs=fit_freqs, fit_sample_seed=fit_sample_seed, fit_all=fit_all, )
[docs] def scan_all( self, selector: Optional[str] = None, *, include_advanced: bool = False, output_dir: Optional[Union[str, Path]] = None, reuse: bool = False, make_plot: bool = True, quiet: bool = False, zoom_regions: Optional[Sequence[Tuple[float, float]]] = None, n_zoom: Optional[int] = None, zoom_width_mhz: Optional[float] = None, fit_top_snr: int = 3, fit_sample: int = 20, fit_freqs: Optional[Sequence[float]] = None, fit_sample_seed: int = 0, fit_all: bool = False, ) -> "List[BatchItem]": """Sweep every knob matched by ``selector`` on its default grid. Equivalent to :func:`ftmwpipeline.api.scan_all`. A convenience over :meth:`scan_run` for reviewing a whole stage / sub-block at once: ``selector`` filters by dotted-path prefix (e.g. ``"stage2b"`` / ``"stage2b.gaussian"``) exactly as :meth:`scan_list`, and ``include_advanced`` adds the advanced-tier knobs. Each knob runs on its own working copy (this file is never mutated); a knob whose scan fails (e.g. its required stage is absent) is recorded as a failed :class:`BatchItem` and the batch continues. The ``zoom_*`` controls apply the same explicit-regions / count-width steering to every knob's plot. """ from ._internal.tuning import list_knobs, run_scan_batch specs = list_knobs(selector, include_advanced=include_advanced) return run_scan_batch( specs, self.filepath, output_dir=Path(output_dir) if output_dir is not None else None, reuse=reuse, make_plot=make_plot, quiet=quiet, zoom_regions=zoom_regions, n_zoom=n_zoom, zoom_width_mhz=zoom_width_mhz, fit_top_snr=fit_top_snr, fit_sample=fit_sample, fit_freqs=fit_freqs, fit_sample_seed=fit_sample_seed, fit_all=fit_all, )
[docs] def settings_show( self, selector: Optional[str] = None, *, include_advanced: bool = False, preset: Optional[Union[str, Path]] = None, ) -> "Tuple[SettingRow, ...]": """Resolved value + provenance for each covered setting of this file. Equivalent to :func:`ftmwpipeline.api.settings_show`. Returns one :class:`SettingRow` per setting -- its ``path``, the resolved ``value``, the ``source`` layer that supplied it (``.ftmw`` / ``.yml:<name>`` / ``recommended`` / ``default``), the ``hard_default`` for reference, and the registry ``tier`` / ``help`` enrichment. ``selector`` filters by dotted-path prefix (e.g. ``"stage2b"`` / ``"stage2b.gaussian"``); ``include_advanced`` reveals advanced-tier rows; ``preset`` (a bare name or YAML path) populates the ``.yml`` provenance layer. Because a persisted ``.ftmw`` value outranks a preset, a named preset changes the resolved view only for fields the file has not persisted. """ from ._internal.tuning import resolve_settings_view return resolve_settings_view( self.filepath, selector, include_advanced=include_advanced, preset=preset, )
[docs] def settings_set(self, knob: str, value: str) -> "SetResult": """Persist a chosen value for ``knob`` into this file's persisted layer. Equivalent to :func:`ftmwpipeline.api.settings_set`. ``knob`` is a dotted settings path (``stage2.window_mhz`` / ``stage5.tau.max_decay_factor``); ``value`` is the string form, coerced to the field's type. Because the stored stage results were computed against the old value, the affected stage and all downstream stages are invalidated (their results dropped and completion cleared) so the file never carries results inconsistent with its settings. Stage 1 data-selection knobs (``stage1.start_us`` / ``stage1.end_us`` / ``stage1.trim_min_mhz`` / ``stage1.trim_max_mhz`` / ``stage1.units_power``) may also be set here, equivalently to passing them to :meth:`compute_ft`. Returns a :class:`SetResult` with the invalidated stage names. """ from ._internal.tuning import set_setting return set_setting(self.filepath, knob, value)
[docs] def settings_export( self, out_path: Union[str, Path], selector: Optional[str] = None, *, name: Optional[str] = None, description: Optional[str] = None, ) -> "ExportResult": """Write this file's chosen Stage 2--5 values to a ``.yml`` preset block. Equivalent to :func:`ftmwpipeline.api.settings_export`. Each stage's persisted settings are serialized into the matching ``stageN:`` block, filtered by the dotted ``selector``; the result loads back through the stages' ``--preset`` path. Stage 1 is excluded (presets do not carry FT settings). Returns an :class:`ExportResult` with the written path and the exported setting paths. """ from ._internal.tuning import export_settings return export_settings( self.filepath, out_path, selector, name=name, description=description, )
[docs] def __repr__(self) -> str: """String representation of Pipeline instance.""" return ( f"Pipeline(file={self.filepath.name}, " f"source={self.source_metadata.source_path.name}, " f"stages={len(self.stage_tracker.completed_stages)})" )