From 58cb09fe42b9e1f1dcbc6d9549b86dd989c8b800 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Mon, 27 Apr 2026 18:40:28 -0700 Subject: [PATCH 1/4] test: add roundtrip matrix and shared utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/roundtrip/: 89-cell single-file matrix (F1-F12 × {B,Sp,SbS,2D} × {M,G,C}), project matrix (PF1-PF3, M-only), noisy variants, and focused MCMC / W2 / SbS-seed tests - tests/_utils.py: shared make_project, simulate_clean/noisy, extract_truth_pars, assert_recovery_*; rewired across 12 test files - delete superseded test_roundtrip_basic.py + test_roundtrip_profile.py + TestProjectFitClean.test_shared_tau_recovery - pyproject: add tests/ to pythonpath - tests/models/project_energy.yaml: add project_glp_expr_base for PF2 --- docs/design/roundtrip_test_matrix.md | 58 +- docs/design/supported_models.md | 8 +- pyproject.toml | 4 +- tests/_utils.py | 118 ++++ tests/models/project_energy.yaml | 14 + tests/roundtrip/__init__.py | 0 tests/roundtrip/families.py | 821 +++++++++++++++++++++++++++ tests/roundtrip/matrix.py | 123 ++++ tests/roundtrip/test_focused.py | 224 ++++++++ tests/roundtrip/test_matrix.py | 58 ++ tests/roundtrip/test_noisy.py | 62 ++ tests/roundtrip/test_project.py | 321 +++++++++++ tests/roundtrip/workflows.py | 137 +++++ tests/test_evaluate_1d.py | 9 +- tests/test_evaluate_2d.py | 13 +- tests/test_file.py | 36 +- tests/test_fit_spectrum.py | 80 +-- tests/test_gir_integration.py | 77 +-- tests/test_graph_ir.py | 17 +- tests/test_mcp_eval.py | 7 +- tests/test_mcp_library.py | 26 +- tests/test_model_parser.py | 28 +- tests/test_parameter_sweep.py | 7 +- tests/test_plotting.py | 5 +- tests/test_project_fit.py | 153 +---- tests/test_roundtrip_basic.py | 183 ------ tests/test_roundtrip_profile.py | 375 ------------ 27 files changed, 2053 insertions(+), 911 deletions(-) create mode 100644 tests/_utils.py create mode 100644 tests/roundtrip/__init__.py create mode 100644 tests/roundtrip/families.py create mode 100644 tests/roundtrip/matrix.py create mode 100644 tests/roundtrip/test_focused.py create mode 100644 tests/roundtrip/test_matrix.py create mode 100644 tests/roundtrip/test_noisy.py create mode 100644 tests/roundtrip/test_project.py create mode 100644 tests/roundtrip/workflows.py delete mode 100644 tests/test_roundtrip_basic.py delete mode 100644 tests/test_roundtrip_profile.py diff --git a/docs/design/roundtrip_test_matrix.md b/docs/design/roundtrip_test_matrix.md index 0567180..528fbc3 100644 --- a/docs/design/roundtrip_test_matrix.md +++ b/docs/design/roundtrip_test_matrix.md @@ -60,7 +60,7 @@ be parameterized under the same row instead of getting separate rows. | ID | Model family | Representative fixture(s) | | --- | --- | --- | | `F1` | Plain energy model | `single_glp`, `glp_only` | -| `F2` | Static expressions in energy model: direct refs, fan-out, forward refs, static chains | `two_glp_expr_amplitude`, `expression_fan_out`, `energy_expression_forward_reference`, `expression_chain`, `glp_expression` | +| `F2` | Static expressions in energy model: direct refs, fan-out, forward refs | `two_glp_expr_amplitude`, `expression_fan_out`, `energy_expression_forward_reference`, `glp_expression` | | `F3` | Top-level standard dynamics | `single_glp` + `MonoExpPos` | | `F4` | Top-level dynamics with IRF / convolution | `single_glp` + `MonoExpPosIRF` and other lowerable IRF kernels | | `F5` | Top-level subcycle / multi-cycle dynamics | `single_glp` + `["ModelNone", "MonoExpNeg", "MonoExpPosExpr"]`, `frequency=10` | @@ -103,7 +103,7 @@ apply cleanly yet. | --- | --- | --- | | `PF1` Shared plain dynamics across files | `M` | Current core project roundtrip surface | | `PF2` Project-level expressions | `M` | Includes file/project prefix rewriting and shared refs | -| `PF3` Shared dynamics with IRF | `M` | Add once project fixtures exist | +| `PF3` Shared dynamics with IRF | `M` | Covered with `BiExpProject` + `gaussCONV` | | `PF4` Shared subcycle dynamics | `M` | Add once project fixtures exist | Future: @@ -140,18 +140,16 @@ Rationale: ### SbS worker requirements -Yes, `SbS` should eventually distinguish `W1` and `W2`, but only after -parallel SbS exists as a real API. +Yes, `SbS` should distinguish `W1` and `W2` because `n_workers=1` uses the +serial path and `n_workers>1` crosses a process boundary. Current status: -- today `File.fit_slice_by_slice()` does not expose a worker-count API, so only - serial `SbS` roundtrips are testable +- the main `SbS` matrix runs with `n_workers=1` +- one focused `W2` test covers `F1` with `n_workers=2` -Future requirement after `n_workers` lands: +Future requirement if worker-specific risk grows: -- `W1`: one canonical `SbS` roundtrip on `F1` -- `W2`: the same canonical `SbS` roundtrip on `F1` - `W2`: one expression/profile-sensitive `SbS` case, likely `F2` or `F6` ### Project worker requirements @@ -198,35 +196,31 @@ clean matrix above is the baseline contract. This is the current high-level state of the suite, not a substitute for the table above. -- Covered reasonably well today: - - `F1` on `B`, `Sp`, `SbS`, and `2D` for GIR-path or compare-mode smoke - - `F3` on `2D` for GIR roundtrip and compare-mode - - `F4` on `2D` for parity / compare-mode - - `F5` on `2D` for parity / compare-mode - - `F6` on `B` for GIR roundtrip - - `F8` on `2D` for GIR roundtrip - - project-level `M` roundtrips for plain shared-dynamics fits +- Covered today: + - the full single-file clean matrix above for `M/G/C` + - `F2` variants for direct, fan-out, and forward-reference expressions + - noisy second-layer checks for `F3`, `F6`, and `F8` on the GIR path + - focused MCMC checks for `MC1`, `MC2`, expression-sensitive `MC2`, and 2D `MC2` + - focused `W2` coverage for `fit_slice_by_slice()` + - project-level `M` roundtrips for `PF1`, `PF2`, and `PF3` - Thin or missing today: - - forced `M` roundtrip coverage for almost every family - - full workflow roundtrips for `F2`, `F5`, `F7`, `F9`, `F10`, `F11`, `F12` - - explicit `SbS` roundtrips outside the plain-energy family - - expression-heavy roundtrips through serialization-sensitive paths - - MCMC coverage beyond a simple plain-model smoke case - - any worker-mode matrix for `SbS` because parallel `SbS` does not exist yet - - project-level coverage for expression/subcycle/IRF families + - project-level `PF4` shared subcycle dynamics + - project-level `G/C` coverage, because project fitting is still MCP-only + - expression/profile-sensitive `W2` coverage for `fit_slice_by_slice()` + - MCMC assertions beyond no-crash / process-boundary coverage + - exhaustive noisy coverage, intentionally kept out of the main matrix ## Suggested implementation order -If we fill this incrementally, the highest-value order is: +The original single-file matrix is implemented. Highest-value next steps: -1. Add forced-`M` twins for the existing plain and profile roundtrips. -2. Add `F9` and `F10` because expression + varying-parameter interactions are a known bug surface. -3. Add `MC2` coverage for one expression-heavy case and one nested-model case. -4. Add one canonical `F5` subcycle roundtrip through `fit_2d`. -5. Add one canonical `F4` IRF roundtrip through `fit_2d`. -6. Add `F7`, `F11`, and `F12` as the mixed-feature stress cases. -7. Expand the separate project-level matrix, starting with project expressions. +1. Add `PF4` once a shared project-subcycle fixture exists. +2. Add a focused expression/profile-sensitive `W2` `SbS` test if process-boundary + risk shows up beyond the plain `F1` case. +3. Add lightweight recovery or constraint-preservation assertions to focused + MCMC tests when runtime allows. +4. Upgrade project-level cells from `M` to `M/G/C` if project-level GIR lands. ## Non-goals diff --git a/docs/design/supported_models.md b/docs/design/supported_models.md index 2883e3f..ff23cc9 100644 --- a/docs/design/supported_models.md +++ b/docs/design/supported_models.md @@ -48,7 +48,9 @@ The sections above describe model semantics. The graph intermediate representati - Project-level fitting is still wired through ``fit_project_mcp`` even when the underlying per-file models are lowerable. -## Notes/ Future Changes +## Notes -We may choose to disallow all transitive expression chains in the future. Static transitive expression chains in energy models are currently allowed. However there is a user experience issue: a chain that works in the static case can become invalid once dynamics or a profile is added. This is surprising and hard to document/ communicate clearly. -If this change is implemented the recommended pattern would be direct fan-out expressions from the base parameter rather than multi-step chains. +Static energy-only expression chains are handled by lmfit, so they may continue to work in interpreter-backed workflows. They are not the model shape we use as +the backend-portability contract, because the same chain pattern becomes invalid +or ambiguous once dynamics or profiles enter the dependency path. The recommended +pattern is direct fan-out from base parameters instead of multi-step chains. diff --git a/pyproject.toml b/pyproject.toml index 2d49347..50e7cd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trspecfit" -version = "0.8.2" +version = "0.8.3" authors = [ {name = "Johannes Mahl", email = "johannes.a.mahl@gmail.com"}, ] @@ -100,7 +100,7 @@ reportMissingImports = true typeCheckingMode = "basic" [tool.pytest.ini_options] -pythonpath = ["src"] +pythonpath = ["src", "tests"] testpaths = ["tests"] addopts = "-m 'not slow'" markers = ["slow: long-running round-trip tests (skipped by default, use -m slow)"] diff --git a/tests/_utils.py b/tests/_utils.py new file mode 100644 index 0000000..b9a2323 --- /dev/null +++ b/tests/_utils.py @@ -0,0 +1,118 @@ +"""Shared helpers across the test suite. + +Plain module-level functions, not pytest fixtures. Promote a helper here +when the same setup logic is duplicated in two or more test files. +""" + +from __future__ import annotations + +import matplotlib + +matplotlib.use("Agg") + +from typing import Any + +import numpy as np + +from trspecfit import Project, Simulator + + +# +def make_project( + *, + name: str = "test", + spec_fun_str: str = "fit_model_gir", + show_output: int = 0, +): + """Create a Project pointing at tests/ for YAML access. + + Defaults to ``show_output=0`` (silent) so test output stays clean. Pass + ``show_output=1`` for tests that exercise display/plot behavior. + """ + + project = Project(path="tests", name=name) + project.show_output = show_output + project.spec_fun_str = spec_fun_str + return project + + +# +def extract_truth_pars(model) -> dict[str, float]: + """Return ``{name: value}`` for all non-expression parameters.""" + + return { + name: model.lmfit_pars[name].value + for name in model.parameter_names + if model.lmfit_pars[name].expr is None + } + + +# +def simulate_clean(model, *, seed: int = 42) -> np.ndarray: + """Simulate noiseless 2D data from a truth model.""" + + sim = Simulator( + model=model, + detection="analog", + noise_level=0.0, + noise_type="none", + seed=seed, + ) + clean, _, _ = sim.simulate_2d() + return clean + + +# +def simulate_noisy(model, *, noise_level: float = 0.01, seed: int = 42) -> np.ndarray: + """Simulate Gaussian-noisy 2D data from a truth model.""" + + sim = Simulator( + model=model, + detection="analog", + noise_level=noise_level, + noise_type="gaussian", + seed=seed, + ) + _, noisy, _ = sim.simulate_2d() + return noisy + + +# +def assert_recovery_exact( + truth_pars: dict[str, float], + fitted_pars: Any, + *, + rtol: float = 1e-10, + atol: float = 1e-12, +) -> None: + """Assert exact (clean-data) parameter recovery. + + ``fitted_pars`` is an ``lmfit.Parameters`` object or anything indexable + by name returning an object with a ``.value`` attribute. + """ + + for name, true_val in truth_pars.items(): + fit_val = fitted_pars[name].value + assert np.isclose(true_val, fit_val, rtol=rtol, atol=atol), ( + f"{name}: true={true_val:.6f}, fit={fit_val:.6f}" + ) + + +# +def assert_recovery_within( + truth_pars: dict[str, float], + fitted_pars: Any, + *, + rel_tol: float = 0.05, + skip_zero_threshold: float = 1e-6, +) -> None: + """Assert noisy-fit recovery within ``rel_tol``, skipping near-zero truth values.""" + + for name, true_val in truth_pars.items(): + if abs(true_val) < skip_zero_threshold: + continue + fit_val = fitted_pars[name].value + rel_err = abs(fit_val - true_val) / abs(true_val) + assert rel_err < rel_tol, ( + f"{name}: true={true_val:.4f}, fit={fit_val:.4f}, rel_err={rel_err:.1%}" + ) diff --git a/tests/models/project_energy.yaml b/tests/models/project_energy.yaml index 08ff37c..0cb0974 100644 --- a/tests/models/project_energy.yaml +++ b/tests/models/project_energy.yaml @@ -20,6 +20,20 @@ project_glp: F: [1.0, "static", 0.75, 2.5] m: [0.3, "static", 0, 1] +# Two-peak baseline for project_glp_expr — all vary=True so baseline +# fit can pin the static (F, m) parameters of both peaks. +project_glp_expr_base: + GLP: + A: [20, True, 5, 25] + x0: [85.0, True, 82, 88] + F: [1.0, True, 0.75, 2.5] + m: [0.3, True, 0, 1] + GLP: + A: ["GLP_01_A * 0.5"] + x0: [86.5, True, 84, 89] + F: [1.0, True, 0.75, 2.5] + m: [0.3, True, 0, 1] + # Two GLP peaks — second amplitude constrained by expression. # Tests that expressions are rewritten with file prefixes. project_glp_expr: diff --git a/tests/roundtrip/__init__.py b/tests/roundtrip/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/roundtrip/families.py b/tests/roundtrip/families.py new file mode 100644 index 0000000..4737457 --- /dev/null +++ b/tests/roundtrip/families.py @@ -0,0 +1,821 @@ +"""Family registry for the roundtrip test matrix. + +Each family declares how to build a *truth* file (used to simulate clean +data) and a *fit* file (the empty file the workflow runner fits against +the simulated data). Builders are lazy: nothing is constructed at import +time. Each family also exposes ``model_name(variant)`` so the workflow +runner can pass the right ``model_name`` to ``fit_*`` even for families +whose YAML key depends on the variant (e.g. F2's expression variants). + +The two builders share a private recipe helper that applies the model, +profiles, and dynamics in the same order on both files. Truth/fit +asymmetry (``define_baseline`` is fit-side only, etc.) is handled by the +workflow runner, not by the builders. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field + +import numpy as np + +from trspecfit import File + +_ENERGY_YAML = "models/file_energy.yaml" +_TIME_YAML = "models/file_time.yaml" +_PROFILE_YAML = "models/file_profile.yaml" + + +# +# +@dataclass(frozen=True) +class Family: + """Static metadata + lazy builders for one model family.""" + + id: str + description: str + model_name: Callable[[str], str] # variant -> YAML model key + needs_aux: bool + needs_time: bool + single_cycle_only: bool + build_truth: Callable[..., File] + build_fit: Callable[..., File] + add_dynamics: Callable[[File, str], None] | None = None + variants: tuple[str, ...] = field(default_factory=lambda: ("default",)) + + +# ---- axis defaults ---- + + +# +def _energy_axis_glp() -> np.ndarray: + return np.linspace(83, 87, 30) + + +# +def _energy_axis_two_glp() -> np.ndarray: + return np.linspace(82, 92, 30) + + +# +def _energy_axis_gauss() -> np.ndarray: + return np.linspace(81, 89, 50) + + +# +def _time_axis() -> np.ndarray: + return np.linspace(-2, 10, 24) + + +# +def _aux_axis() -> np.ndarray: + return np.linspace(0, 8, 20) + + +# ---- F1: Plain energy (single GLP, no profile, no dynamics) ---- + + +# +def _f1_apply(file: File) -> None: + file.load_model(model_yaml=_ENERGY_YAML, model_info="single_glp") + + +# +def _f1_truth(project, *, variant: str = "default") -> File: + file = File( + parent_project=project, + name="truth", + energy=_energy_axis_glp(), + time=_time_axis(), + ) + _f1_apply(file) + return file + + +# +def _f1_fit(project, *, data, energy, time, aux=None, variant: str = "default") -> File: + file = File( + parent_project=project, + name="fit", + data=data, + energy=energy.copy(), + time=time.copy(), + ) + _f1_apply(file) + file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) + return file + + +# ---- F2: Static expressions on a plain energy model ---- + + +_F2_VARIANT_TO_KEY = { + "direct": "energy_expression", + "fan_out": "expression_fan_out", + "forward_ref": "energy_expression_forward_reference", +} + + +# +def _f2_apply(file: File, variant: str) -> None: + file.load_model(model_yaml=_ENERGY_YAML, model_info=_F2_VARIANT_TO_KEY[variant]) + + +# +def _f2_truth(project, *, variant: str) -> File: + file = File( + parent_project=project, + name="truth", + energy=_energy_axis_two_glp(), + time=_time_axis(), + ) + _f2_apply(file, variant) + return file + + +# +def _f2_fit(project, *, data, energy, time, aux=None, variant: str) -> File: + file = File( + parent_project=project, + name="fit", + data=data, + energy=energy.copy(), + time=time.copy(), + ) + _f2_apply(file, variant) + file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) + return file + + +# ---- F3: Top-level standard dynamics ---- + + +# +def _f3_apply(file: File) -> None: + file.load_model(model_yaml=_ENERGY_YAML, model_info="single_glp") + + +# +def _f3_truth(project, *, variant: str = "default") -> File: + file = File( + parent_project=project, + name="truth", + energy=_energy_axis_glp(), + time=_time_axis(), + ) + _f3_apply(file) + file.add_time_dependence( + target_model="single_glp", + target_parameter="GLP_01_A", + dynamics_yaml=_TIME_YAML, + dynamics_model=["MonoExpPos"], + ) + return file + + +# +def _f3_fit(project, *, data, energy, time, aux=None, variant: str = "default") -> File: + file = File( + parent_project=project, + name="fit", + data=data, + energy=energy.copy(), + time=time.copy(), + ) + _f3_apply(file) + file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) + return file + + +# +def _f3_add_dynamics(file: File, variant: str) -> None: + file.add_time_dependence( + target_model="single_glp", + target_parameter="GLP_01_A", + dynamics_yaml=_TIME_YAML, + dynamics_model=["MonoExpPos"], + ) + + +# ---- F4: IRF dynamics ---- + + +# +def _f4_truth(project, *, variant: str = "default") -> File: + file = File( + parent_project=project, + name="truth", + energy=_energy_axis_glp(), + time=_time_axis(), + ) + file.load_model(model_yaml=_ENERGY_YAML, model_info="single_glp") + file.add_time_dependence( + target_model="single_glp", + target_parameter="GLP_01_A", + dynamics_yaml=_TIME_YAML, + dynamics_model=["MonoExpPosIRF"], + ) + return file + + +# +def _f4_fit(project, *, data, energy, time, aux=None, variant: str = "default") -> File: + file = File( + parent_project=project, + name="fit", + data=data, + energy=energy.copy(), + time=time.copy(), + ) + file.load_model(model_yaml=_ENERGY_YAML, model_info="single_glp") + file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) + return file + + +# +def _f4_add_dynamics(file: File, variant: str) -> None: + file.add_time_dependence( + target_model="single_glp", + target_parameter="GLP_01_A", + dynamics_yaml=_TIME_YAML, + dynamics_model=["MonoExpPosIRF"], + ) + + +# ---- F5: Subcycle dynamics ---- + + +_F5_DYNAMICS = ["ModelNone", "MonoExpNeg", "MonoExpPosExpr"] +_F5_FREQ = 10.0 + + +# +def _f5_truth(project, *, variant: str = "default") -> File: + file = File( + parent_project=project, + name="truth", + energy=_energy_axis_glp(), + time=_time_axis(), + ) + file.load_model(model_yaml=_ENERGY_YAML, model_info="single_glp") + file.add_time_dependence( + target_model="single_glp", + target_parameter="GLP_01_A", + dynamics_yaml=_TIME_YAML, + dynamics_model=_F5_DYNAMICS, + frequency=_F5_FREQ, + ) + return file + + +# +def _f5_fit(project, *, data, energy, time, aux=None, variant: str = "default") -> File: + file = File( + parent_project=project, + name="fit", + data=data, + energy=energy.copy(), + time=time.copy(), + ) + file.load_model(model_yaml=_ENERGY_YAML, model_info="single_glp") + file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) + return file + + +# +def _f5_add_dynamics(file: File, variant: str) -> None: + file.add_time_dependence( + target_model="single_glp", + target_parameter="GLP_01_A", + dynamics_yaml=_TIME_YAML, + dynamics_model=_F5_DYNAMICS, + frequency=_F5_FREQ, + ) + + +# ---- F6: Top-level profile only (two profiles on single_gauss) ---- + + +# +def _f6_apply(file: File) -> None: + file.load_model(model_yaml=_ENERGY_YAML, model_info="single_gauss") + file.add_par_profile( + target_model="single_gauss", + target_parameter="Gauss_01_x0", + profile_yaml=_PROFILE_YAML, + profile_model=["roundtrip_pLinear_x0"], + ) + file.add_par_profile( + target_model="single_gauss", + target_parameter="Gauss_01_A", + profile_yaml=_PROFILE_YAML, + profile_model=["roundtrip_pExpDecay_A"], + ) + + +# +def _f6_truth(project, *, variant: str = "default") -> File: + file = File( + parent_project=project, + name="truth", + energy=_energy_axis_gauss(), + time=_time_axis(), + aux_axis=_aux_axis(), + ) + _f6_apply(file) + return file + + +# +def _f6_fit(project, *, data, energy, time, aux, variant: str = "default") -> File: + file = File( + parent_project=project, + name="fit", + data=data, + energy=energy.copy(), + time=time.copy(), + aux_axis=aux.copy(), + ) + _f6_apply(file) + file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) + return file + + +# ---- F7: Profile + separate dynamics on different params ---- + + +# +def _f7_apply(file: File) -> None: + file.load_model(model_yaml=_ENERGY_YAML, model_info="single_gauss") + file.add_par_profile( + target_model="single_gauss", + target_parameter="Gauss_01_A", + profile_yaml=_PROFILE_YAML, + profile_model=["roundtrip_pExpDecay_A"], + ) + + +# +def _f7_truth(project, *, variant: str = "default") -> File: + file = File( + parent_project=project, + name="truth", + energy=_energy_axis_gauss(), + time=_time_axis(), + aux_axis=_aux_axis(), + ) + _f7_apply(file) + file.add_time_dependence( + target_model="single_gauss", + target_parameter="Gauss_01_x0", + dynamics_yaml=_TIME_YAML, + dynamics_model=["MonoExpPos"], + ) + return file + + +# +def _f7_fit(project, *, data, energy, time, aux, variant: str = "default") -> File: + file = File( + parent_project=project, + name="fit", + data=data, + energy=energy.copy(), + time=time.copy(), + aux_axis=aux.copy(), + ) + _f7_apply(file) + file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) + return file + + +# +def _f7_add_dynamics(file: File, variant: str) -> None: + file.add_time_dependence( + target_model="single_gauss", + target_parameter="Gauss_01_x0", + dynamics_yaml=_TIME_YAML, + dynamics_model=["MonoExpPos"], + ) + + +# ---- F8: Profile-internal dynamics ---- + + +# +def _f8_apply(file: File) -> None: + file.load_model(model_yaml=_ENERGY_YAML, model_info="single_gauss") + file.add_par_profile( + target_model="single_gauss", + target_parameter="Gauss_01_x0", + profile_yaml=_PROFILE_YAML, + profile_model=["roundtrip_pLinear_x0"], + ) + file.add_par_profile( + target_model="single_gauss", + target_parameter="Gauss_01_A", + profile_yaml=_PROFILE_YAML, + profile_model=["roundtrip_pExpDecay_A"], + ) + + +# +def _f8_truth(project, *, variant: str = "default") -> File: + file = File( + parent_project=project, + name="truth", + energy=_energy_axis_gauss(), + time=_time_axis(), + aux_axis=_aux_axis(), + ) + _f8_apply(file) + file.add_time_dependence( + target_model="single_gauss", + target_parameter="Gauss_01_A_pExpDecay_01_A", + dynamics_yaml=_TIME_YAML, + dynamics_model=["MonoExpPosStrong"], + ) + return file + + +# +def _f8_fit(project, *, data, energy, time, aux, variant: str = "default") -> File: + file = File( + parent_project=project, + name="fit", + data=data, + energy=energy.copy(), + time=time.copy(), + aux_axis=aux.copy(), + ) + _f8_apply(file) + file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) + return file + + +# +def _f8_add_dynamics(file: File, variant: str) -> None: + file.add_time_dependence( + target_model="single_gauss", + target_parameter="Gauss_01_A_pExpDecay_01_A", + dynamics_yaml=_TIME_YAML, + dynamics_model=["MonoExpPosStrong"], + ) + + +# ---- F9: Expression -> time-dependent base parameter ---- + + +# +def _f9_apply(file: File) -> None: + file.load_model(model_yaml=_ENERGY_YAML, model_info="two_glp_expr_amplitude") + + +# +def _f9_truth(project, *, variant: str = "default") -> File: + file = File( + parent_project=project, + name="truth", + energy=_energy_axis_two_glp(), + time=_time_axis(), + ) + _f9_apply(file) + file.add_time_dependence( + target_model="two_glp_expr_amplitude", + target_parameter="GLP_01_A", + dynamics_yaml=_TIME_YAML, + dynamics_model=["MonoExpPos"], + ) + return file + + +# +def _f9_fit(project, *, data, energy, time, aux=None, variant: str = "default") -> File: + file = File( + parent_project=project, + name="fit", + data=data, + energy=energy.copy(), + time=time.copy(), + ) + _f9_apply(file) + file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) + return file + + +# +def _f9_add_dynamics(file: File, variant: str) -> None: + file.add_time_dependence( + target_model="two_glp_expr_amplitude", + target_parameter="GLP_01_A", + dynamics_yaml=_TIME_YAML, + dynamics_model=["MonoExpPos"], + ) + + +# ---- F10: Expression -> profiled base parameter ---- + + +# +def _f10_apply(file: File) -> None: + file.load_model(model_yaml=_ENERGY_YAML, model_info="two_glp_expr_amplitude") + file.add_par_profile( + target_model="two_glp_expr_amplitude", + target_parameter="GLP_01_A", + profile_yaml=_PROFILE_YAML, + profile_model=["roundtrip_pExpDecay_A"], + ) + + +# +def _f10_truth(project, *, variant: str = "default") -> File: + file = File( + parent_project=project, + name="truth", + energy=_energy_axis_two_glp(), + time=_time_axis(), + aux_axis=_aux_axis(), + ) + _f10_apply(file) + return file + + +# +def _f10_fit(project, *, data, energy, time, aux, variant: str = "default") -> File: + file = File( + parent_project=project, + name="fit", + data=data, + energy=energy.copy(), + time=time.copy(), + aux_axis=aux.copy(), + ) + _f10_apply(file) + file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) + return file + + +# ---- F11: Expression -> profiled base par with profile-internal dynamics ---- + + +# +def _f11_apply(file: File) -> None: + file.load_model(model_yaml=_ENERGY_YAML, model_info="two_glp_expr_amplitude") + file.add_par_profile( + target_model="two_glp_expr_amplitude", + target_parameter="GLP_01_A", + profile_yaml=_PROFILE_YAML, + profile_model=["roundtrip_pExpDecay_A"], + ) + + +# +def _f11_truth(project, *, variant: str = "default") -> File: + file = File( + parent_project=project, + name="truth", + energy=_energy_axis_two_glp(), + time=_time_axis(), + aux_axis=_aux_axis(), + ) + _f11_apply(file) + file.add_time_dependence( + target_model="two_glp_expr_amplitude", + target_parameter="GLP_01_A_pExpDecay_01_A", + dynamics_yaml=_TIME_YAML, + dynamics_model=["MonoExpPosStrong"], + ) + return file + + +# +def _f11_fit(project, *, data, energy, time, aux, variant: str = "default") -> File: + file = File( + parent_project=project, + name="fit", + data=data, + energy=energy.copy(), + time=time.copy(), + aux_axis=aux.copy(), + ) + _f11_apply(file) + file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) + return file + + +# +def _f11_add_dynamics(file: File, variant: str) -> None: + file.add_time_dependence( + target_model="two_glp_expr_amplitude", + target_parameter="GLP_01_A_pExpDecay_01_A", + dynamics_yaml=_TIME_YAML, + dynamics_model=["MonoExpPosStrong"], + ) + + +# ---- F12: Mixed expression referencing both profiled and time-dep base pars ---- + + +# +def _f12_apply(file: File) -> None: + file.load_model( + model_yaml=_ENERGY_YAML, model_info="two_glp_mixed_profile_dynamics" + ) + file.add_par_profile( + target_model="two_glp_mixed_profile_dynamics", + target_parameter="GLP_01_A", + profile_yaml=_PROFILE_YAML, + profile_model=["roundtrip_pExpDecay_A"], + ) + + +# +def _f12_truth(project, *, variant: str = "default") -> File: + file = File( + parent_project=project, + name="truth", + energy=_energy_axis_two_glp(), + time=_time_axis(), + aux_axis=_aux_axis(), + ) + _f12_apply(file) + file.add_time_dependence( + target_model="two_glp_mixed_profile_dynamics", + target_parameter="GLP_01_x0", + dynamics_yaml=_TIME_YAML, + dynamics_model=["MonoExpPos"], + ) + return file + + +# +def _f12_fit(project, *, data, energy, time, aux, variant: str = "default") -> File: + file = File( + parent_project=project, + name="fit", + data=data, + energy=energy.copy(), + time=time.copy(), + aux_axis=aux.copy(), + ) + _f12_apply(file) + file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) + return file + + +# +def _f12_add_dynamics(file: File, variant: str) -> None: + file.add_time_dependence( + target_model="two_glp_mixed_profile_dynamics", + target_parameter="GLP_01_x0", + dynamics_yaml=_TIME_YAML, + dynamics_model=["MonoExpPos"], + ) + + +# ---- registry ---- + + +FAMILIES: dict[str, Family] = { + "F1": Family( + id="F1", + description="Plain energy (single_glp, no profile, no dynamics)", + model_name=lambda v: "single_glp", + needs_aux=False, + needs_time=True, + single_cycle_only=False, + build_truth=_f1_truth, + build_fit=_f1_fit, + ), + "F2": Family( + id="F2", + description="Static energy expressions (direct/fan_out/forward_ref)", + model_name=lambda v: _F2_VARIANT_TO_KEY[v], + needs_aux=False, + needs_time=True, + single_cycle_only=False, + build_truth=_f2_truth, + build_fit=_f2_fit, + variants=("direct", "fan_out", "forward_ref"), + ), + "F3": Family( + id="F3", + description="Top-level standard dynamics (single_glp + MonoExpPos on GLP_01_A)", + model_name=lambda v: "single_glp", + needs_aux=False, + needs_time=True, + single_cycle_only=False, + build_truth=_f3_truth, + build_fit=_f3_fit, + add_dynamics=_f3_add_dynamics, + ), + "F4": Family( + id="F4", + description="Top-level IRF dynamics (single_glp + MonoExpPosIRF on GLP_01_A)", + model_name=lambda v: "single_glp", + needs_aux=False, + needs_time=True, + single_cycle_only=False, + build_truth=_f4_truth, + build_fit=_f4_fit, + add_dynamics=_f4_add_dynamics, + ), + "F5": Family( + id="F5", + description=( + "Subcycle dynamics " + "(single_glp + ModelNone/MonoExpNeg/MonoExpPosExpr, freq=10)" + ), + model_name=lambda v: "single_glp", + needs_aux=False, + needs_time=True, + single_cycle_only=False, + build_truth=_f5_truth, + build_fit=_f5_fit, + add_dynamics=_f5_add_dynamics, + ), + "F6": Family( + id="F6", + description=( + "Top-level profile only (single_gauss + pLinear on x0 + pExpDecay on A)" + ), + model_name=lambda v: "single_gauss", + needs_aux=True, + needs_time=True, + single_cycle_only=False, + build_truth=_f6_truth, + build_fit=_f6_fit, + ), + "F7": Family( + id="F7", + description="Profile + separate dynamics (pExpDecay on A, MonoExpPos on x0)", + model_name=lambda v: "single_gauss", + needs_aux=True, + needs_time=True, + single_cycle_only=False, + build_truth=_f7_truth, + build_fit=_f7_fit, + add_dynamics=_f7_add_dynamics, + ), + "F8": Family( + id="F8", + description=( + "Profile-internal dynamics (two profiles + MonoExpPosStrong on pExpDecay A)" + ), + model_name=lambda v: "single_gauss", + needs_aux=True, + needs_time=True, + single_cycle_only=True, + build_truth=_f8_truth, + build_fit=_f8_fit, + add_dynamics=_f8_add_dynamics, + ), + "F9": Family( + id="F9", + description=( + "Expr -> time-dep base par (two_glp_expr_amplitude + dyn on GLP_01_A)" + ), + model_name=lambda v: "two_glp_expr_amplitude", + needs_aux=False, + needs_time=True, + single_cycle_only=False, + build_truth=_f9_truth, + build_fit=_f9_fit, + add_dynamics=_f9_add_dynamics, + ), + "F10": Family( + id="F10", + description=( + "Expr -> profiled base par (two_glp_expr_amplitude + profile on GLP_01_A)" + ), + model_name=lambda v: "two_glp_expr_amplitude", + needs_aux=True, + needs_time=True, + single_cycle_only=False, + build_truth=_f10_truth, + build_fit=_f10_fit, + ), + "F11": Family( + id="F11", + description="Expr -> profiled base par with profile-internal dynamics", + model_name=lambda v: "two_glp_expr_amplitude", + needs_aux=True, + needs_time=True, + single_cycle_only=True, + build_truth=_f11_truth, + build_fit=_f11_fit, + add_dynamics=_f11_add_dynamics, + ), + "F12": Family( + id="F12", + description="Mixed expr (profile on GLP_01_A + dynamics on GLP_01_x0)", + model_name=lambda v: "two_glp_mixed_profile_dynamics", + needs_aux=True, + needs_time=True, + single_cycle_only=False, + build_truth=_f12_truth, + build_fit=_f12_fit, + add_dynamics=_f12_add_dynamics, + ), +} diff --git a/tests/roundtrip/matrix.py b/tests/roundtrip/matrix.py new file mode 100644 index 0000000..6601cd5 --- /dev/null +++ b/tests/roundtrip/matrix.py @@ -0,0 +1,123 @@ +"""Static matrix declaration for the roundtrip test suite. + +The :data:`MATRIX` list mirrors the per-cell coverage in +``docs/design/roundtrip_test_matrix.md``. Adding a new family/workflow +combination is a one-line edit here. + +:func:`iter_cells` expands ``MATRIX`` into one :class:`ResolvedCell` per +``(family, workflow, backend, variant)`` tuple — that is the form +:func:`pytest.mark.parametrize` consumes. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass + +from .families import FAMILIES + +BACKEND_TO_SPEC_FUN = { + "M": "fit_model_mcp", + "G": "fit_model_gir", + "C": "fit_model_compare", +} + +_ALL_BACKENDS = ("M", "G", "C") + + +# +# +@dataclass(frozen=True) +class Cell: + """One row of the matrix: a (family, workflow) pair with its backends.""" + + family_id: str + workflow_id: str + backends: tuple[str, ...] = _ALL_BACKENDS + variants: tuple[str, ...] | None = None # None -> use family.variants + + +# +# +@dataclass(frozen=True) +class ResolvedCell: + """A fully expanded cell — what each parametrized test invocation runs.""" + + family_id: str + workflow_id: str + backend: str + variant: str + + @property + def spec_fun_str(self) -> str: + return BACKEND_TO_SPEC_FUN[self.backend] + + +# ---- the matrix ---- +# +# Mirrored from docs/design/roundtrip_test_matrix.md. Each row corresponds +# to one (family, workflow) cell with all its applicable backends. + +MATRIX: list[Cell] = [ + # F1 — plain energy: B, Sp, SbS + Cell("F1", "B"), + Cell("F1", "Sp"), + Cell("F1", "SbS"), + # F2 — static expressions: B, Sp, SbS across direct/fan_out/forward_ref + Cell("F2", "B"), + Cell("F2", "Sp"), + Cell("F2", "SbS"), + # F3 — top-level standard dynamics: 2D + Cell("F3", "2D"), + # F4 — IRF dynamics: 2D + Cell("F4", "2D"), + # F5 — subcycle dynamics: 2D + Cell("F5", "2D"), + # F6 — top-level profile only: B, Sp, SbS + Cell("F6", "B"), + Cell("F6", "Sp"), + Cell("F6", "SbS"), + # F7 — profile + separate dynamics: 2D + Cell("F7", "2D"), + # F8 — profile-internal dynamics: 2D + Cell("F8", "2D"), + # F9 — expr -> time-dep base par: 2D + Cell("F9", "2D"), + # F10 — expr -> profiled base par: B, Sp, SbS + Cell("F10", "B"), + Cell("F10", "Sp"), + Cell("F10", "SbS"), + # F11 — expr -> profiled base par with profile-internal dynamics: 2D + Cell("F11", "2D"), + # F12 — mixed expr: 2D + Cell("F12", "2D"), +] + + +# +def iter_cells() -> Iterable[ResolvedCell]: + """Expand :data:`MATRIX` into per-test ``ResolvedCell`` instances.""" + + for cell in MATRIX: + family = FAMILIES[cell.family_id] + variants = cell.variants if cell.variants is not None else family.variants + for variant in variants: + for backend in cell.backends: + yield ResolvedCell( + family_id=cell.family_id, + workflow_id=cell.workflow_id, + backend=backend, + variant=variant, + ) + + +# +def cell_id(cell: ResolvedCell) -> str: + """Stable, greppable test ID — e.g. ``F3-2D-M`` or ``F2[fan_out]-B-C``.""" + + family_part = ( + cell.family_id + if cell.variant == "default" + else f"{cell.family_id}[{cell.variant}]" + ) + return f"{family_part}-{cell.workflow_id}-{cell.backend}" diff --git a/tests/roundtrip/test_focused.py b/tests/roundtrip/test_focused.py new file mode 100644 index 0000000..d5c65fb --- /dev/null +++ b/tests/roundtrip/test_focused.py @@ -0,0 +1,224 @@ +"""Focused MCMC and worker variants for the roundtrip suite. + +Per the matrix doc, MCMC and worker mode are *focused* secondary +requirements, not full axes on the main matrix. This module covers them +with a small set of dedicated tests: + +- MC1: MCMC with workers=1 — exercises MCMC sampling itself. +- MC2: MCMC with workers=2 — exercises pickling / process boundary. + +Worker variants for ``fit_slice_by_slice`` are deferred until the API +exposes a parallel-versus-serial semantic difference; today +``n_workers=1`` and ``n_workers=2`` both go through ``ProcessPoolExecutor`` +once n_workers > 1, so a focused W2 test is sufficient. +""" + +from __future__ import annotations + +import pytest +from _utils import ( + assert_recovery_exact, + extract_truth_pars, + make_project, + simulate_clean, +) + +from trspecfit.utils.lmfit import MC + +from .families import FAMILIES + +pytestmark = pytest.mark.slow + + +_MC_KWARGS = { + "use_mc": 1, + "steps": 50, + "nwalkers": 32, + "burn": 5, + "thin": 1, +} + + +# +def _build_fit_file_for_baseline(family_id: str): + """Common setup: build truth, simulate, build a baseline-ready fit file.""" + + family = FAMILIES[family_id] + truth_project = make_project(name="mc_truth", spec_fun_str="fit_model_mcp") + truth_file = family.build_truth(truth_project, variant="default") + data = simulate_clean(truth_file.model_active) + + fit_project = make_project(name="mc_fit", spec_fun_str="fit_model_gir") + fit_kwargs = { + "data": data, + "energy": truth_file.energy, + "time": truth_file.time, + "variant": "default", + } + if family.needs_aux: + fit_kwargs["aux"] = truth_file.aux_axis + fit_file = family.build_fit(fit_project, **fit_kwargs) + return fit_file, family + + +# ---- MC1: serial MCMC on F1 baseline ---- + + +# +def test_mc1_baseline_f1(): + """MCMC with workers=1 on the simplest baseline fit must run without crashing.""" + + fit_file, family = _build_fit_file_for_baseline("F1") + mc = MC(workers=1, **_MC_KWARGS) + fit_file.fit_baseline( + model_name=family.model_name("default"), + stages=1, + try_ci=0, + mc_settings=mc, + ) + assert fit_file.model_base is not None # type guard + + +# ---- MC2: parallel MCMC on F1 baseline ---- + + +# +def test_mc2_baseline_f1(): + """MCMC with workers=2 must not hit pickling / serialization errors.""" + + fit_file, family = _build_fit_file_for_baseline("F1") + mc = MC(workers=2, **_MC_KWARGS) + fit_file.fit_baseline( + model_name=family.model_name("default"), + stages=1, + try_ci=0, + mc_settings=mc, + ) + assert fit_file.model_base is not None # type guard + + +# ---- MC2 on an expression-sensitive case (F10 baseline) ---- + + +# +def test_mc2_baseline_f10_expression(): + """MCMC workers=2 on an expression+profile family — pickling stress test.""" + + fit_file, family = _build_fit_file_for_baseline("F10") + mc = MC(workers=2, **_MC_KWARGS) + fit_file.fit_baseline( + model_name=family.model_name("default"), + stages=1, + try_ci=0, + mc_settings=mc, + ) + assert fit_file.model_base is not None # type guard + + +# ---- MC2 on a 2D varying case (F3) ---- + + +# +def test_mc2_2d_f3(): + """MCMC workers=2 through fit_2d on a standard-dynamics family.""" + + fit_file, family = _build_fit_file_for_baseline("F3") + fit_file.fit_baseline(model_name=family.model_name("default"), stages=2, try_ci=0) + assert family.add_dynamics is not None + family.add_dynamics(fit_file, "default") + + mc = MC(workers=2, **_MC_KWARGS) + fit_file.fit_2d( + model_name=family.model_name("default"), + stages=1, + try_ci=0, + mc_settings=mc, + ) + assert fit_file.model_2d is not None # type guard + + +# ---- W2: parallel SbS on F1 ---- + + +# +def test_w2_sbs_f1(): + """fit_slice_by_slice with n_workers=2 must complete without pickling errors.""" + + fit_file, family = _build_fit_file_for_baseline("F1") + fit_file.fit_slice_by_slice( + model_name=family.model_name("default"), + stages=1, + n_workers=2, + seed_source="model", + seed_adapt=None, + try_ci=0, + ) + assert len(fit_file.results_sbs) == len(fit_file.time) + + +# ---- SbS seed combos: explicit seed + baseline+argmax_shift ---- + + +# +def _build_sbs_truth_and_fit(family_id: str): + """Build a (truth_file, fit_file, family) triple for SbS seed-combo tests.""" + + family = FAMILIES[family_id] + truth_project = make_project(name="sbs_truth", spec_fun_str="fit_model_mcp") + truth_file = family.build_truth(truth_project, variant="default") + data = simulate_clean(truth_file.model_active) + + fit_project = make_project(name="sbs_fit", spec_fun_str="fit_model_gir") + fit_kwargs = { + "data": data, + "energy": truth_file.energy, + "time": truth_file.time, + "variant": "default", + } + if family.needs_aux: + fit_kwargs["aux"] = truth_file.aux_axis + fit_file = family.build_fit(fit_project, **fit_kwargs) + return truth_file, fit_file, family + + +# +def test_sbs_explicit_seed_f1(): + """seed_source='explicit' + seed_adapt=None must accept seed_values and recover.""" + + truth_file, fit_file, family = _build_sbs_truth_and_fit("F1") + truth_pars = extract_truth_pars(truth_file.model_active) + seed_values = {name: val for name, val in truth_pars.items()} + + fit_file.fit_slice_by_slice( + model_name=family.model_name("default"), + stages=2, + n_workers=1, + seed_source="explicit", + seed_values=seed_values, + seed_adapt=None, + try_ci=0, + ) + + mid = len(fit_file.results_sbs) // 2 + assert_recovery_exact(truth_pars, fit_file.results_sbs[mid][1].params) + + +# +def test_sbs_baseline_argmax_shift_f1(): + """Production-default SbS: seed_source='baseline' + seed_adapt='argmax_shift'.""" + + truth_file, fit_file, family = _build_sbs_truth_and_fit("F1") + truth_pars = extract_truth_pars(truth_file.model_active) + + fit_file.fit_baseline(model_name=family.model_name("default"), stages=2, try_ci=0) + fit_file.fit_slice_by_slice( + model_name=family.model_name("default"), + stages=2, + n_workers=1, + seed_source="baseline", + seed_adapt="argmax_shift", + try_ci=0, + ) + + mid = len(fit_file.results_sbs) // 2 + assert_recovery_exact(truth_pars, fit_file.results_sbs[mid][1].params) diff --git a/tests/roundtrip/test_matrix.py b/tests/roundtrip/test_matrix.py new file mode 100644 index 0000000..32904f7 --- /dev/null +++ b/tests/roundtrip/test_matrix.py @@ -0,0 +1,58 @@ +"""Single-file roundtrip matrix. + +Every cell follows the same shape: build a truth file, simulate clean 2D +data, build a fit file, run the workflow, assert exact recovery on all +non-expression parameters. Backend (M/G/C) is selected by setting +``project.spec_fun_str``; the truth project always uses MCP so the +reference data is independent of the path under test. +""" + +from __future__ import annotations + +import pytest +from _utils import ( + assert_recovery_exact, + extract_truth_pars, + make_project, + simulate_clean, +) + +from .families import FAMILIES +from .matrix import cell_id, iter_cells +from .workflows import WORKFLOWS + +pytestmark = pytest.mark.slow + + +_CELLS = list(iter_cells()) + + +# +@pytest.mark.parametrize("cell", _CELLS, ids=[cell_id(c) for c in _CELLS]) +def test_roundtrip_cell(cell): + """Simulate from truth, fit through ``cell`` workflow + backend, recover.""" + + family = FAMILIES[cell.family_id] + workflow = WORKFLOWS[cell.workflow_id] + model_name = family.model_name(cell.variant) + + truth_project = make_project(name="truth", spec_fun_str="fit_model_mcp") + truth_file = family.build_truth(truth_project, variant=cell.variant) + truth_pars = extract_truth_pars(truth_file.model_active) + + data = simulate_clean(truth_file.model_active) + + fit_project = make_project(name="fit", spec_fun_str=cell.spec_fun_str) + fit_kwargs = { + "data": data, + "energy": truth_file.energy, + "time": truth_file.time, + "variant": cell.variant, + } + if family.needs_aux: + fit_kwargs["aux"] = truth_file.aux_axis + + fit_file = family.build_fit(fit_project, **fit_kwargs) + result = workflow.run(fit_file, family, model_name, cell.variant) + + assert_recovery_exact(truth_pars, result.params) diff --git a/tests/roundtrip/test_noisy.py b/tests/roundtrip/test_noisy.py new file mode 100644 index 0000000..0299ac0 --- /dev/null +++ b/tests/roundtrip/test_noisy.py @@ -0,0 +1,62 @@ +"""Noisy roundtrips — second-layer coverage on top of the clean matrix. + +Per the matrix doc, noisy is *not* an axis on the main matrix: the +assertion shape differs (5% relative tolerance + skip near-zero), and +parametrising it doubles every cell's runtime for marginal gain. Instead, +hand-pick representative cells that exercise distinct code paths +(plain energy / dynamics / profile-only / profile-internal dynamics). +""" + +from __future__ import annotations + +import pytest +from _utils import ( + assert_recovery_within, + extract_truth_pars, + make_project, + simulate_noisy, +) + +from .families import FAMILIES +from .matrix import ResolvedCell, cell_id +from .workflows import WORKFLOWS + +pytestmark = pytest.mark.slow + + +_NOISY_CELLS = [ + ResolvedCell(family_id="F3", workflow_id="2D", backend="G", variant="default"), + ResolvedCell(family_id="F6", workflow_id="B", backend="G", variant="default"), + ResolvedCell(family_id="F8", workflow_id="2D", backend="G", variant="default"), +] + + +# +@pytest.mark.parametrize("cell", _NOISY_CELLS, ids=[cell_id(c) for c in _NOISY_CELLS]) +def test_noisy_roundtrip(cell): + """Noisy fit should recover truth parameters within 5% relative error.""" + + family = FAMILIES[cell.family_id] + workflow = WORKFLOWS[cell.workflow_id] + model_name = family.model_name(cell.variant) + + truth_project = make_project(name="truth_noisy", spec_fun_str="fit_model_mcp") + truth_file = family.build_truth(truth_project, variant=cell.variant) + truth_pars = extract_truth_pars(truth_file.model_active) + + data = simulate_noisy(truth_file.model_active, noise_level=0.01) + + fit_project = make_project(name="fit_noisy", spec_fun_str=cell.spec_fun_str) + fit_kwargs = { + "data": data, + "energy": truth_file.energy, + "time": truth_file.time, + "variant": cell.variant, + } + if family.needs_aux: + fit_kwargs["aux"] = truth_file.aux_axis + + fit_file = family.build_fit(fit_project, **fit_kwargs) + result = workflow.run(fit_file, family, model_name, cell.variant) + + assert_recovery_within(truth_pars, result.params, rel_tol=0.05) diff --git a/tests/roundtrip/test_project.py b/tests/roundtrip/test_project.py new file mode 100644 index 0000000..74ccbc7 --- /dev/null +++ b/tests/roundtrip/test_project.py @@ -0,0 +1,321 @@ +"""Project-level roundtrip matrix. + +Project-level fitting uses ``Project.fit_2d`` which is wired through +``fit_project_mcp``. Per the matrix doc, PF cells are M-only today; if +project-level GIR ever lands, the ``backends`` field on each ``PFCell`` +upgrades to ``("M", "G", "C")`` with no other shape change. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from dataclasses import dataclass + +import matplotlib + +matplotlib.use("Agg") + +import numpy as np +import pytest +from _utils import make_project, simulate_clean + +from trspecfit import File + +pytestmark = pytest.mark.slow + + +_PROJECT_ENERGY_YAML = "models/project_energy.yaml" +_PROJECT_TIME_YAML = "models/project_time.yaml" + + +# ---- PF1: shared plain dynamics ---- + + +# +def _build_pf1_truth(*, amplitude: float, x0_shift: float, tau: float): + project = make_project(name="pf1_truth") + file = File( + parent_project=project, + energy=np.linspace(83, 87, 30), + time=np.linspace(-2, 10, 24), + ) + file.load_model(model_yaml=_PROJECT_ENERGY_YAML, model_info="project_glp") + file.add_time_dependence( + target_model="project_glp", + target_parameter="GLP_01_x0", + dynamics_yaml=_PROJECT_TIME_YAML, + dynamics_model=["MonoExpProject"], + ) + model = file.model_active + assert model is not None + model.lmfit_pars["GLP_01_A"].value = amplitude + model.lmfit_pars["GLP_01_x0"].value = 85.0 + model.lmfit_pars["GLP_01_F"].value = 1.0 + model.lmfit_pars["GLP_01_m"].value = 0.3 + model.lmfit_pars["GLP_01_x0_expFun_01_A"].value = x0_shift + model.lmfit_pars["GLP_01_x0_expFun_01_tau"].value = tau + return file + + +# +def _build_pf1_fit(project, data, energy, time, *, name: str) -> File: + file = File( + parent_project=project, + name=name, + data=data, + energy=energy.copy(), + time=time.copy(), + ) + file.load_model(model_yaml=_PROJECT_ENERGY_YAML, model_info="project_glp_base") + file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) + file.fit_baseline(model_name="project_glp_base", stages=2, try_ci=0) + file.load_model(model_yaml=_PROJECT_ENERGY_YAML, model_info="project_glp") + file.add_time_dependence( + target_model="project_glp", + target_parameter="GLP_01_x0", + dynamics_yaml=_PROJECT_TIME_YAML, + dynamics_model=["MonoExpProject"], + ) + return file + + +# +def _run_pf1(): + # Different amplitude AND different x0_shift per file exercise the + # file-vary semantics; identical tau exercises the project-vary semantics. + truth_a = _build_pf1_truth(amplitude=20.0, x0_shift=3.0, tau=5.0) + truth_b = _build_pf1_truth(amplitude=15.0, x0_shift=2.0, tau=5.0) + data_a = simulate_clean(truth_a.model_active, seed=42) + data_b = simulate_clean(truth_b.model_active, seed=43) + + project = make_project(name="pf1_fit") + fit_a = _build_pf1_fit(project, data_a, truth_a.energy, truth_a.time, name="file_a") + fit_b = _build_pf1_fit(project, data_b, truth_b.energy, truth_b.time, name="file_b") + project.fit_2d(model_name="project_glp", stages=2, try_ci=0) + + cases = ( + (fit_a, 20.0, 3.0), + (fit_b, 15.0, 2.0), + ) + models = [] + for fit_file, truth_amp, truth_shift in cases: + model = fit_file.select_model("project_glp") + assert model is not None + assert np.isclose(model.lmfit_pars["GLP_01_A"].value, truth_amp, rtol=1e-3) + assert np.isclose( + model.lmfit_pars["GLP_01_x0_expFun_01_A"].value, truth_shift, rtol=1e-3 + ) + assert np.isclose( + model.lmfit_pars["GLP_01_x0_expFun_01_tau"].value, 5.0, rtol=1e-3 + ) + models.append(model) + + # Project-vary invariant: tau is shared exactly across files. + tau_a = models[0].lmfit_pars["GLP_01_x0_expFun_01_tau"].value + tau_b = models[1].lmfit_pars["GLP_01_x0_expFun_01_tau"].value + assert tau_a == tau_b, f"tau should be project-shared: {tau_a} != {tau_b}" + + +# ---- PF2: project-level expressions ---- + + +# +def _build_pf2_truth(*, amplitude: float, tau: float): + project = make_project(name="pf2_truth") + file = File( + parent_project=project, + energy=np.linspace(83, 90, 40), + time=np.linspace(-2, 10, 24), + ) + file.load_model(model_yaml=_PROJECT_ENERGY_YAML, model_info="project_glp_expr") + file.add_time_dependence( + target_model="project_glp_expr", + target_parameter="GLP_01_x0", + dynamics_yaml=_PROJECT_TIME_YAML, + dynamics_model=["MonoExpProject"], + ) + model = file.model_active + assert model is not None + model.lmfit_pars["GLP_01_A"].value = amplitude + model.lmfit_pars["GLP_01_x0"].value = 85.0 + model.lmfit_pars["GLP_01_F"].value = 1.0 + model.lmfit_pars["GLP_01_m"].value = 0.3 + model.lmfit_pars["GLP_02_x0"].value = 86.5 + model.lmfit_pars["GLP_02_F"].value = 1.0 + model.lmfit_pars["GLP_02_m"].value = 0.3 + model.lmfit_pars["GLP_01_x0_expFun_01_A"].value = 3.0 + model.lmfit_pars["GLP_01_x0_expFun_01_tau"].value = tau + return file + + +# +def _build_pf2_fit(project, data, energy, time, *, name: str) -> File: + file = File( + parent_project=project, + name=name, + data=data, + energy=energy.copy(), + time=time.copy(), + ) + file.load_model(model_yaml=_PROJECT_ENERGY_YAML, model_info="project_glp_expr_base") + file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) + file.fit_baseline(model_name="project_glp_expr_base", stages=2, try_ci=0) + file.load_model(model_yaml=_PROJECT_ENERGY_YAML, model_info="project_glp_expr") + file.add_time_dependence( + target_model="project_glp_expr", + target_parameter="GLP_01_x0", + dynamics_yaml=_PROJECT_TIME_YAML, + dynamics_model=["MonoExpProject"], + ) + return file + + +# +def _run_pf2(): + truth_a = _build_pf2_truth(amplitude=20.0, tau=4.0) + truth_b = _build_pf2_truth(amplitude=14.0, tau=4.0) + data_a = simulate_clean(truth_a.model_active) + data_b = simulate_clean(truth_b.model_active) + + project = make_project(name="pf2_fit") + fit_a = _build_pf2_fit(project, data_a, truth_a.energy, truth_a.time, name="file_a") + fit_b = _build_pf2_fit(project, data_b, truth_b.energy, truth_b.time, name="file_b") + project.fit_2d(model_name="project_glp_expr", stages=2, try_ci=0) + + for fit_file, truth_amp in ((fit_a, 20.0), (fit_b, 14.0)): + model = fit_file.select_model("project_glp_expr") + assert model is not None + a1 = model.lmfit_pars["GLP_01_A"].value + a2 = model.lmfit_pars["GLP_02_A"].value + assert np.isclose(a1, truth_amp, rtol=1e-3) + assert np.isclose(a2, 0.5 * a1, rtol=1e-6), ( + "expression GLP_02_A = GLP_01_A * 0.5 must hold" + ) + assert np.isclose( + model.lmfit_pars["GLP_01_x0_expFun_01_tau"].value, 4.0, rtol=1e-3 + ) + + +# ---- PF3: shared dynamics with IRF (BiExpProject — bi-exponential + gaussCONV) ---- + + +# +def _build_pf3_truth(*, amplitude: float, t0: float, tau1: float, tau2: float): + project = make_project(name="pf3_truth") + file = File( + parent_project=project, + energy=np.linspace(80, 90, 40), + time=np.linspace(-5, 50, 60), + ) + file.load_model(model_yaml=_PROJECT_ENERGY_YAML, model_info="project_glp") + file.add_time_dependence( + target_model="project_glp", + target_parameter="GLP_01_x0", + dynamics_yaml=_PROJECT_TIME_YAML, + dynamics_model=["BiExpProject"], + ) + model = file.model_active + assert model is not None + model.lmfit_pars["GLP_01_A"].value = amplitude + model.lmfit_pars["GLP_01_x0"].value = 85.0 + model.lmfit_pars["GLP_01_F"].value = 1.0 + model.lmfit_pars["GLP_01_m"].value = 0.3 + model.lmfit_pars["GLP_01_x0_expFun_01_A"].value = 2.0 + model.lmfit_pars["GLP_01_x0_expFun_01_tau"].value = tau1 + model.lmfit_pars["GLP_01_x0_expFun_01_t0"].value = t0 + model.lmfit_pars["GLP_01_x0_expFun_02_A"].value = 1.0 + model.lmfit_pars["GLP_01_x0_expFun_02_tau"].value = tau2 + return file + + +# +def _build_pf3_fit(project, data, energy, time, *, name: str) -> File: + file = File( + parent_project=project, + name=name, + data=data, + energy=energy.copy(), + time=time.copy(), + ) + file.load_model(model_yaml=_PROJECT_ENERGY_YAML, model_info="project_glp_base") + file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) + file.fit_baseline(model_name="project_glp_base", stages=2, try_ci=0) + file.load_model(model_yaml=_PROJECT_ENERGY_YAML, model_info="project_glp") + file.add_time_dependence( + target_model="project_glp", + target_parameter="GLP_01_x0", + dynamics_yaml=_PROJECT_TIME_YAML, + dynamics_model=["BiExpProject"], + ) + return file + + +# +def _run_pf3(): + truth_a = _build_pf3_truth(amplitude=20.0, t0=3.0, tau1=2.0, tau2=20.0) + truth_b = _build_pf3_truth(amplitude=15.0, t0=3.0, tau1=2.0, tau2=20.0) + data_a = simulate_clean(truth_a.model_active) + data_b = simulate_clean(truth_b.model_active) + + project = make_project(name="pf3_fit") + fit_a = _build_pf3_fit(project, data_a, truth_a.energy, truth_a.time, name="file_a") + fit_b = _build_pf3_fit(project, data_b, truth_b.energy, truth_b.time, name="file_b") + project.fit_2d(model_name="project_glp", stages=2, try_ci=0) + + for fit_file, truth_amp in ((fit_a, 20.0), (fit_b, 15.0)): + model = fit_file.select_model("project_glp") + assert model is not None + assert np.isclose(model.lmfit_pars["GLP_01_A"].value, truth_amp, rtol=1e-2) + assert np.isclose( + model.lmfit_pars["GLP_01_x0_expFun_01_tau"].value, 2.0, rtol=1e-2 + ) + assert np.isclose( + model.lmfit_pars["GLP_01_x0_expFun_02_tau"].value, 20.0, rtol=1e-2 + ) + + +# ---- registry + parametrize ---- + + +# +# +@dataclass(frozen=True) +class PFCell: + """One project-level matrix cell.""" + + id: str + description: str + backends: tuple[str, ...] + run: Callable[[], None] + + +PROJECT_MATRIX: list[PFCell] = [ + PFCell("PF1", "Shared plain dynamics", ("M",), _run_pf1), + PFCell("PF2", "Project-level expressions", ("M",), _run_pf2), + PFCell( + "PF3", "Shared dynamics with IRF (BiExpProject + gaussCONV)", ("M",), _run_pf3 + ), + # PF4 (shared subcycle dynamics) deferred — no project-subcycle fixture yet. +] + + +# +def _iter_project_cells() -> Iterable[tuple[str, str, Callable[[], None]]]: + for cell in PROJECT_MATRIX: + for backend in cell.backends: + yield (cell.id, backend, cell.run) + + +_PROJECT_CELLS = list(_iter_project_cells()) + + +# +@pytest.mark.parametrize( + "pf_id, backend, runner", + _PROJECT_CELLS, + ids=[f"{pf_id}-{backend}" for pf_id, backend, _ in _PROJECT_CELLS], +) +def test_project_roundtrip(pf_id, backend, runner): + """Project-level fits must recover shared and per-file truth parameters.""" + + runner() diff --git a/tests/roundtrip/workflows.py b/tests/roundtrip/workflows.py new file mode 100644 index 0000000..13c75c1 --- /dev/null +++ b/tests/roundtrip/workflows.py @@ -0,0 +1,137 @@ +"""Workflow registry for the roundtrip test matrix. + +Each workflow encapsulates the canonical "fit through this API" sequence +plus the rule for where the fitted ``lmfit_pars`` live afterwards. The +test entry point treats every workflow uniformly: pass a fit file, get a +``FitResult`` back. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from trspecfit import File + +from .families import Family + + +# +# +@dataclass(frozen=True) +class FitResult: + """Normalized output of a workflow runner.""" + + params: Any # lmfit.Parameters or any name -> {.value} mapping + + +# +# +@dataclass(frozen=True) +class Workflow: + """Static metadata + runner for one fit workflow API.""" + + id: str + description: str + requires_baseline: bool + requires_2d_data: bool + run: Callable[..., FitResult] + + +# ---- B: fit_baseline ---- + + +# +def _run_baseline( + file: File, family: Family, model_name: str, variant: str +) -> FitResult: + file.fit_baseline(model_name=model_name, stages=2, try_ci=0) + assert file.model_base is not None # type guard + return FitResult(params=file.model_base.result[1].params) + + +# ---- Sp: fit_spectrum ---- + + +# +def _run_spectrum( + file: File, family: Family, model_name: str, variant: str +) -> FitResult: + assert file.time is not None # type guard + mid = len(file.time) // 2 + file.fit_spectrum( + model_name=model_name, + time_point=int(mid), + time_type="ind", + stages=2, + show_plot=False, + try_ci=0, + ) + assert file.model_spec is not None # type guard + return FitResult(params=file.model_spec.result[1].params) + + +# ---- SbS: fit_slice_by_slice ---- + + +# +def _run_sbs(file: File, family: Family, model_name: str, variant: str) -> FitResult: + file.fit_slice_by_slice( + model_name=model_name, + stages=2, + n_workers=1, + seed_source="model", + seed_adapt=None, + try_ci=0, + ) + mid = len(file.results_sbs) // 2 + return FitResult(params=file.results_sbs[mid][1].params) + + +# ---- 2D: fit_baseline + (re-add dynamics) + fit_2d ---- + + +# +def _run_2d(file: File, family: Family, model_name: str, variant: str) -> FitResult: + file.fit_baseline(model_name=model_name, stages=2, try_ci=0) + if family.add_dynamics is not None: + family.add_dynamics(file, variant) + file.fit_2d(model_name=model_name, stages=2, try_ci=0) + assert file.model_2d is not None # type guard + return FitResult(params=file.model_2d.result[1].params) + + +# ---- registry ---- + + +WORKFLOWS: dict[str, Workflow] = { + "B": Workflow( + id="B", + description="File.fit_baseline()", + requires_baseline=False, + requires_2d_data=False, + run=_run_baseline, + ), + "Sp": Workflow( + id="Sp", + description="File.fit_spectrum() at the middle time index", + requires_baseline=False, + requires_2d_data=True, + run=_run_spectrum, + ), + "SbS": Workflow( + id="SbS", + description="File.fit_slice_by_slice() (serial, seed_source='model')", + requires_baseline=False, + requires_2d_data=True, + run=_run_sbs, + ), + "2D": Workflow( + id="2D", + description="File.fit_baseline() + File.fit_2d()", + requires_baseline=True, + requires_2d_data=True, + run=_run_2d, + ), +} diff --git a/tests/test_evaluate_1d.py b/tests/test_evaluate_1d.py index 5f3b707..49b1073 100644 --- a/tests/test_evaluate_1d.py +++ b/tests/test_evaluate_1d.py @@ -9,8 +9,9 @@ """ import numpy as np +from _utils import make_project -from trspecfit import File, Project +from trspecfit import File from trspecfit.eval_1d import evaluate_1d from trspecfit.graph_ir import build_graph, can_lower_1d, schedule_1d @@ -27,7 +28,7 @@ def _make_energy_model(model_info): """Load a 1D energy model from eval_2d_energy.yaml.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project, energy=np.linspace(80, 90, 101)) file.load_model(model_yaml=_ENERGY_YAML, model_info=model_info) model = file.model_active @@ -53,7 +54,7 @@ def _make_profile_energy_model( if model_yaml is None: model_yaml = _FILE_ENERGY_YAML - project = Project(path="tests") + project = make_project() file = File(parent_project=project, energy=energy, aux_axis=aux_axis) file.load_model(model_yaml=model_yaml, model_info=model_info) for target_parameter, profile_model in profiles: @@ -363,7 +364,7 @@ def test_energy_only_model_is_lowerable(self): def test_2d_model_is_not_lowerable_1d(self): """A model with dynamics should not pass can_lower_1d.""" - project = Project(path="tests") + project = make_project() file = File( parent_project=project, energy=np.linspace(80, 90, 101), diff --git a/tests/test_evaluate_2d.py b/tests/test_evaluate_2d.py index 04bf5c2..6e82737 100644 --- a/tests/test_evaluate_2d.py +++ b/tests/test_evaluate_2d.py @@ -10,8 +10,9 @@ import numpy as np import pytest +from _utils import make_project -from trspecfit import File, Project +from trspecfit import File from trspecfit.eval_2d import evaluate_2d from trspecfit.graph_ir import ( OpKind, @@ -32,7 +33,7 @@ def _make_energy_model(model_info): """Load a 1D energy model from eval_2d_energy.yaml.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = np.linspace(80, 90, 101) file.load_model(model_yaml=_ENERGY_YAML, model_info=model_info) @@ -51,7 +52,7 @@ def _make_2d_model(model_info, dynamics_params, *, frequency=None, time=None): place; ``time`` (optional) overrides the default time axis. """ - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = np.linspace(80, 90, 101) file.time = np.linspace(-10, 100, 51) if time is None else time @@ -470,7 +471,7 @@ def _make_2d_profile_model( if model_yaml is None: model_yaml = _FILE_ENERGY_YAML - project = Project(path="tests") + project = make_project() file = File(parent_project=project, energy=energy, time=time, aux_axis=aux_axis) file.load_model(model_yaml=model_yaml, model_info=model_info) model = file.model_active @@ -619,7 +620,7 @@ def test_profile_with_time_dep_profile_params(self): Profile must be added first, then dynamics on the profile param. """ - project = Project(path="tests") + project = make_project() file = File( parent_project=project, energy=np.linspace(83, 87, 61), @@ -971,7 +972,7 @@ def test_mixed_irf_and_subcycle_parity(self): path) coexists with subcycle-aware dynamics in the same model. """ - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = np.linspace(80, 90, 101) file.time = np.linspace(-10, 100, 51) diff --git a/tests/test_file.py b/tests/test_file.py index cba2ef7..9a4d439 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -8,8 +8,9 @@ import numpy as np import pytest +from _utils import make_project -from trspecfit import File, Project +from trspecfit import File # @@ -21,7 +22,7 @@ class TestModelManagement: def _make_file_with_axes(self): """Create project and file with axes and dummy data.""" - project = Project(path="tests") + project = make_project() aux_axis = np.array([0.0, 1.0, 2.0, 3.0]) file = File(parent_project=project, aux_axis=aux_axis) file.energy = np.linspace(80, 90, 201) @@ -361,7 +362,7 @@ class TestFitLimitsAndBaseline: def _make_file_with_data(self): """Create file with axes and 2D data.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = np.linspace(80, 90, 201) file.time = np.linspace(-10, 100, 111) @@ -414,7 +415,7 @@ def test_set_fit_limits_none_uses_full_range(self): def test_set_fit_limits_descending_energy(self): """set_fit_limits should handle descending energy axes correctly.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = np.linspace(90, 80, 201) # descending file.time = np.linspace(-10, 100, 111) @@ -436,7 +437,7 @@ def test_set_fit_limits_descending_energy(self): def test_set_fit_limits_time_without_time_axis_raises(self): """set_fit_limits with time_limits but no time axis (1D) should raise.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = np.linspace(80, 90, 201) file.dim = 1 @@ -447,7 +448,7 @@ def test_set_fit_limits_time_without_time_axis_raises(self): def test_set_fit_limits_no_data_raises(self): """set_fit_limits without data or energy should raise.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) with pytest.raises(ValueError, match="cannot set fit limits"): file.set_fit_limits([82, 88], show_plot=False) @@ -505,7 +506,7 @@ def test_define_baseline_1d_raises(self): def test_define_baseline_no_data_raises(self): """define_baseline without data should raise.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.dim = 2 with pytest.raises(ValueError, match="No data loaded"): @@ -534,7 +535,7 @@ class TestFitLimitsSlicing: def _make_file(self, *, energy, time=None): """Create a File with given energy axis and optional time axis.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = energy if time is not None: @@ -650,7 +651,7 @@ def _make_file_with_model( ): """Helper: File with data, loaded model, and fit limits set.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = energy if time is not None: @@ -806,7 +807,7 @@ class TestFitLimitsOutOfRange: def _make_file(self, *, energy, time=None): """Create a File with data on the given axes.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = energy if time is not None: @@ -897,7 +898,7 @@ class TestFitPreconditions: def _make_file_with_model(self): """Create file with axes, 2D data, and a loaded energy model.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = np.linspace(80, 90, 201) file.time = np.linspace(-10, 100, 111) @@ -1097,8 +1098,7 @@ class TestFileNameAndProjectAccess: def _make_project(self): """Create a silent project with three files.""" - project = Project(path="tests", name="access") - project.show_output = 0 + project = make_project(name="access") energy = np.arange(10) time_ax = np.arange(5) @@ -1176,16 +1176,14 @@ def test_getitem_int_out_of_range_raises(self): # def test_duplicate_name_raises(self): - project = Project(path="tests", name="dup") - project.show_output = 0 + project = make_project(name="dup") File(parent_project=project, path="scan1/data.csv") with pytest.raises(ValueError, match="Duplicate file name"): File(parent_project=project, path="scan2/data.csv") # def test_duplicate_name_resolved_with_explicit_name(self): - project = Project(path="tests", name="dup2") - project.show_output = 0 + project = make_project(name="dup2") File(parent_project=project, path="scan1/data.csv") File(parent_project=project, path="scan2/data.csv", name="data_2") assert project["data"].path == "scan1/data.csv" @@ -1201,7 +1199,7 @@ class TestDescribeWaterfall: def _make_file(self, *, n_time): """Create a 2D File with *n_time* spectra.""" - project = Project(path="tests") + project = make_project(show_output=1) file = File(parent_project=project) file.energy = np.linspace(80, 90, 50) file.time = np.linspace(0, 10, n_time) @@ -1373,7 +1371,7 @@ def test_waterfall_no_time_limits_no_alphas(self): def test_describe_1d_unaffected(self): """waterfall parameter should not affect 1D data display.""" - project = Project(path="tests") + project = make_project(show_output=1) file = File(parent_project=project) file.energy = np.linspace(80, 90, 50) file.data = np.random.default_rng(42).normal(size=50) diff --git a/tests/test_fit_spectrum.py b/tests/test_fit_spectrum.py index a6b09bd..efb8811 100644 --- a/tests/test_fit_spectrum.py +++ b/tests/test_fit_spectrum.py @@ -6,17 +6,9 @@ import numpy as np import pytest +from _utils import make_project, simulate_clean -from trspecfit import File, Project, Simulator - - -# -def _make_project(): - """Create a silent project pointing to tests/ for YAML access.""" - - project = Project(path="tests", name="fit_spectrum") - project.show_output = 0 - return project +from trspecfit import File # @@ -63,32 +55,6 @@ def _make_fit_file(project, data, energy, time): return file -# -def _extract_par_dict(model): - """Return {name: value} for all non-expression parameters.""" - - return { - name: model.lmfit_pars[name].value - for name in model.parameter_names - if model.lmfit_pars[name].expr is None - } - - -# -def _simulate_clean(truth_file): - """Generate noiseless 2D data from the truth model.""" - - sim = Simulator( - model=truth_file.model_active, - detection="analog", - noise_level=0.0, - noise_type="none", - seed=42, - ) - clean, _, _ = sim.simulate_2d() - return clean - - # # class TestFitSpectrumErrors: @@ -98,7 +64,7 @@ class TestFitSpectrumErrors: def test_1d_data_raises(self): """fit_spectrum raises ValueError for 1D data.""" - project = _make_project() + project = make_project(name="fit_spectrum") file = File( parent_project=project, name="err_1d", @@ -116,7 +82,7 @@ def test_1d_data_raises(self): def test_no_time_selection_raises(self): """fit_spectrum raises ValueError if neither time_point nor time_range given.""" - project = _make_project() + project = make_project(name="fit_spectrum") energy = np.linspace(83, 87, 30) time = np.linspace(-2, 10, 24) data = np.random.default_rng(42).normal(size=(len(time), len(energy))) @@ -138,7 +104,7 @@ def test_no_time_selection_raises(self): def test_both_time_point_and_range_raises(self): """fit_spectrum raises ValueError if both time_point and time_range given.""" - project = _make_project() + project = make_project(name="fit_spectrum") energy = np.linspace(83, 87, 30) time = np.linspace(-2, 10, 24) data = np.random.default_rng(42).normal(size=(len(time), len(energy))) @@ -160,9 +126,9 @@ def test_both_time_point_and_range_raises(self): def test_2d_model_raises(self): """fit_spectrum raises ValueError for a model with time dependence (dim=2).""" - project = _make_project() + project = make_project(name="fit_spectrum") truth_file = _make_truth_file(project) - clean = _simulate_clean(truth_file) + clean = simulate_clean(truth_file.model_active) fit_file = _make_fit_file(project, clean, truth_file.energy, truth_file.time) fit_file.add_time_dependence( @@ -178,9 +144,9 @@ def test_2d_model_raises(self): def test_time_point_out_of_range_raises(self): """fit_spectrum raises ValueError for a time_point beyond the time axis.""" - project = _make_project() + project = make_project(name="fit_spectrum") truth_file = _make_truth_file(project) - clean = _simulate_clean(truth_file) + clean = simulate_clean(truth_file.model_active) fit_file = _make_fit_file(project, clean, truth_file.energy, truth_file.time) with pytest.raises(ValueError, match="out-of-range"): @@ -190,9 +156,9 @@ def test_time_point_out_of_range_raises(self): def test_time_point_ind_out_of_range_raises(self): """fit_spectrum raises ValueError for an index beyond the time axis.""" - project = _make_project() + project = make_project(name="fit_spectrum") truth_file = _make_truth_file(project) - clean = _simulate_clean(truth_file) + clean = simulate_clean(truth_file.model_active) fit_file = _make_fit_file(project, clean, truth_file.energy, truth_file.time) with pytest.raises(ValueError, match="out-of-range"): @@ -202,9 +168,9 @@ def test_time_point_ind_out_of_range_raises(self): def test_reversed_time_range_raises(self): """fit_spectrum raises ValueError for a reversed time_range (start > stop).""" - project = _make_project() + project = make_project(name="fit_spectrum") truth_file = _make_truth_file(project) - clean = _simulate_clean(truth_file) + clean = simulate_clean(truth_file.model_active) fit_file = _make_fit_file(project, clean, truth_file.energy, truth_file.time) with pytest.raises(ValueError, match="empty or out-of-range"): @@ -221,9 +187,9 @@ class TestFitSpectrumTimePoint: def test_time_point_abs_recovery(self): """Fit at a time_point (abs) recovers the 1D spectrum parameters.""" - project = _make_project() + project = make_project(name="fit_spectrum") truth_file = _make_truth_file(project) - clean = _simulate_clean(truth_file) + clean = simulate_clean(truth_file.model_active) fit_file = _make_fit_file(project, clean, truth_file.energy, truth_file.time) fit_file.fit_spectrum( @@ -246,9 +212,9 @@ def test_time_point_abs_recovery(self): def test_time_point_ind(self): """Fit at a time_point using index addressing.""" - project = _make_project() + project = make_project(name="fit_spectrum") truth_file = _make_truth_file(project) - clean = _simulate_clean(truth_file) + clean = simulate_clean(truth_file.model_active) fit_file = _make_fit_file(project, clean, truth_file.energy, truth_file.time) fit_file.fit_spectrum( @@ -274,9 +240,9 @@ class TestFitSpectrumTimeRange: def test_time_range_abs(self): """Fit averaged spectrum over a time range (abs).""" - project = _make_project() + project = make_project(name="fit_spectrum") truth_file = _make_truth_file(project) - clean = _simulate_clean(truth_file) + clean = simulate_clean(truth_file.model_active) fit_file = _make_fit_file(project, clean, truth_file.energy, truth_file.time) fit_file.fit_spectrum( @@ -299,9 +265,9 @@ def test_time_range_abs(self): def test_time_range_ind(self): """Fit averaged spectrum over a time range using indices.""" - project = _make_project() + project = make_project(name="fit_spectrum") truth_file = _make_truth_file(project) - clean = _simulate_clean(truth_file) + clean = simulate_clean(truth_file.model_active) fit_file = _make_fit_file(project, clean, truth_file.energy, truth_file.time) fit_file.fit_spectrum( @@ -321,9 +287,9 @@ def test_time_range_ind(self): def test_data_spec_matches_manual_average(self): """Extracted spectrum matches manual np.mean over the same range.""" - project = _make_project() + project = make_project(name="fit_spectrum") truth_file = _make_truth_file(project) - clean = _simulate_clean(truth_file) + clean = simulate_clean(truth_file.model_active) fit_file = _make_fit_file(project, clean, truth_file.energy, truth_file.time) fit_file.fit_spectrum( diff --git a/tests/test_gir_integration.py b/tests/test_gir_integration.py index d472407..2fd5a3a 100644 --- a/tests/test_gir_integration.py +++ b/tests/test_gir_integration.py @@ -11,8 +11,9 @@ import numpy as np import pytest +from _utils import extract_truth_pars, make_project, simulate_clean -from trspecfit import File, Project, Simulator, fitlib, spectra +from trspecfit import File, fitlib, spectra from trspecfit.graph_ir import ( ScheduledPlan1D, build_graph, @@ -35,12 +36,9 @@ # def _make_project(*, spec_fun_str="fit_model_gir"): - """Create a silent project with configurable spec_fun_str.""" + """Create a silent project — wraps make_project with a fixed test name.""" - project = Project(path="tests", name="gir_int") - project.show_output = 0 - project.spec_fun_str = spec_fun_str - return project + return make_project(name="gir_int", spec_fun_str=spec_fun_str) # @@ -663,25 +661,12 @@ class TestFileFit2D: def test_gir_fit_writes_back_to_model(self): """After GIR fit, model_2d.lmfit_pars reflects optimized values.""" - project = Project(path="tests", name="gir_e2e") - project.show_output = 0 + project = make_project(name="gir_e2e") # Default spec_fun_str is "fit_model_gir" truth_file = _make_truth_file(project) - truth_pars = { - name: truth_file.model_active.lmfit_pars[name].value - for name in truth_file.model_active.parameter_names - if truth_file.model_active.lmfit_pars[name].expr is None - } - - sim = Simulator( - model=truth_file.model_active, - detection="analog", - noise_level=0.0, - noise_type="none", - seed=42, - ) - clean, _, _ = sim.simulate_2d() + truth_pars = extract_truth_pars(truth_file.model_active) + clean = simulate_clean(truth_file.model_active) fit_file = _make_fit_file(project, clean, truth_file.energy, truth_file.time) fit_file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) @@ -715,20 +700,10 @@ def test_gir_fit_writes_back_to_model(self): def test_compare_mode_through_fit_2d(self): """fit_model_compare through File.fit_2d validates both paths.""" - project = Project(path="tests", name="gir_cmp") - project.show_output = 0 - project.spec_fun_str = "fit_model_compare" + project = make_project(name="gir_cmp", spec_fun_str="fit_model_compare") truth_file = _make_truth_file(project) - - sim = Simulator( - model=truth_file.model_active, - detection="analog", - noise_level=0.0, - noise_type="none", - seed=42, - ) - clean, _, _ = sim.simulate_2d() + clean = simulate_clean(truth_file.model_active) fit_file = _make_fit_file(project, clean, truth_file.energy, truth_file.time) fit_file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) @@ -1069,15 +1044,10 @@ def test_1d_dispatch_args_lower_on_2d_file(self): def test_gir_baseline_writes_back(self): """After GIR baseline fit, model_base.lmfit_pars reflects results.""" - project = Project(path="tests", name="gir_base") - project.show_output = 0 + project = make_project(name="gir_base") truth = _make_1d_truth_file(project) - truth_pars = { - name: truth.model_active.lmfit_pars[name].value - for name in truth.model_active.parameter_names - if truth.model_active.lmfit_pars[name].expr is None - } + truth_pars = extract_truth_pars(truth.model_active) # Tile 1D truth spectrum into 2D data (constant across time) truth.model_active.create_value_1d() @@ -1110,9 +1080,7 @@ def test_gir_baseline_writes_back(self): def test_compare_mode_through_baseline(self): """fit_model_compare through File.fit_baseline validates both paths.""" - project = Project(path="tests", name="gir_base_cmp") - project.show_output = 0 - project.spec_fun_str = "fit_model_compare" + project = make_project(name="gir_base_cmp", spec_fun_str="fit_model_compare") truth = _make_1d_truth_file(project) truth.model_active.create_value_1d() @@ -1134,9 +1102,7 @@ class TestFileFitSpectrum: def test_compare_mode_through_fit_spectrum(self): """fit_model_compare through File.fit_spectrum validates both paths.""" - project = Project(path="tests", name="gir_spec_cmp") - project.show_output = 0 - project.spec_fun_str = "fit_model_compare" + project = make_project(name="gir_spec_cmp", spec_fun_str="fit_model_compare") # Build 2D data: tile a 1D spectrum across time so every slice # is identical and recovery is deterministic. @@ -1183,9 +1149,9 @@ def test_compare_mode_through_fit_slice_by_slice(self, n_workers): model state (const, args) the serial path does. """ - project = Project(path="tests", name=f"gir_sbs_cmp_w{n_workers}") - project.show_output = 0 - project.spec_fun_str = "fit_model_compare" + project = make_project( + name=f"gir_sbs_cmp_w{n_workers}", spec_fun_str="fit_model_compare" + ) truth = _make_1d_truth_file(project) truth.model_active.create_value_1d() @@ -1217,9 +1183,7 @@ def test_serial_and_parallel_produce_same_fit(self): """ def _run_sbs(name: str, n_workers: int) -> tuple[list, int]: - project = Project(path="tests", name=name) - project.show_output = 0 - project.spec_fun_str = "fit_model_mcp" + project = make_project(name=name, spec_fun_str="fit_model_mcp") truth = _make_1d_truth_file(project) truth.model_active.create_value_1d() @@ -1277,11 +1241,10 @@ def test_fit_slice_by_slice_restores_seed_template( ): """SbS leaves model_sbs at the shared seed template in both paths.""" - project = Project( - path="tests", name=f"sbs_seed_restore_{seed_source}_w{n_workers}" + project = make_project( + name=f"sbs_seed_restore_{seed_source}_w{n_workers}", + spec_fun_str="fit_model_mcp", ) - project.show_output = 0 - project.spec_fun_str = "fit_model_mcp" truth = _make_1d_truth_file(project) truth.model_active.create_value_1d() diff --git a/tests/test_graph_ir.py b/tests/test_graph_ir.py index 8dcafe3..eeff438 100644 --- a/tests/test_graph_ir.py +++ b/tests/test_graph_ir.py @@ -2,8 +2,9 @@ import numpy as np import pytest +from _utils import make_project -from trspecfit import File, Project +from trspecfit import File from trspecfit.graph_ir import ( DomainKind, DynFuncKind, @@ -24,7 +25,7 @@ def _make_energy_model(model_info): """Create project + file + load energy model, return (file, model).""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = np.linspace(80, 90, 201) file.load_model( @@ -48,7 +49,7 @@ def _make_2d_model(model_info, dynamics_params): Each tuple: (target_parameter, dynamics_yaml_model, dynamics_model_info). """ - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = np.linspace(80, 90, 201) file.time = np.linspace(-10, 100, 111) @@ -74,7 +75,7 @@ def _make_2d_model(model_info, dynamics_params): def _make_time_only_model(model_info, *, frequency=-1): """Create a standalone dynamics model, return (file, model).""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.time = np.linspace(-10, 100, 111) model = file.load_model( @@ -645,7 +646,7 @@ def test_dynamics_trace_has_time_package(self): def _make_profile_model(energy_model_info, target_par, profile_model_info): """Create model with a profiled parameter.""" - project = Project(path="tests") + project = make_project() aux_axis = np.array([0.0, 1.0, 2.0, 3.0, 4.0]) file = File(parent_project=project, aux_axis=aux_axis) file.energy = np.linspace(80, 90, 201) @@ -892,7 +893,7 @@ def test_all_component_evals_reachable_from_output(self): def _make_subcycle_model(): """Create a 2D model with multi-cycle subcycle dynamics.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = np.linspace(80, 90, 201) file.time = np.linspace(-10, 100, 111) @@ -1245,7 +1246,7 @@ def _make_time_dep_profile_model(dynamics_model=None): then MonoExpPos dynamics on GLP_01_A_pLinear_01_m. """ - project = Project(path="tests") + project = make_project() aux_axis = np.array([0.0, 1.0, 2.0, 3.0, 4.0]) file = File(parent_project=project, aux_axis=aux_axis) file.energy = np.linspace(80, 90, 201) @@ -1364,7 +1365,7 @@ def test_schedule_2d_accepts_profile_par_dynamics_convolution(self): def _make_irf_dynamics_model(): """Create a 2D model where dynamics includes a convolution (gaussCONV).""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = np.linspace(80, 90, 201) file.time = np.linspace(-10, 100, 111) diff --git a/tests/test_mcp_eval.py b/tests/test_mcp_eval.py index d0a4406..15f5629 100644 --- a/tests/test_mcp_eval.py +++ b/tests/test_mcp_eval.py @@ -6,8 +6,9 @@ import numpy as np import pytest +from _utils import make_project -from trspecfit import File, Project +from trspecfit import File from trspecfit.functions.energy import GLP from trspecfit.functions.profile import pLinear @@ -21,7 +22,7 @@ class TestEvaluation: def _make_file_with_model(self, model_info): """Create project, file, and load model.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = np.linspace(80, 90, 201) file.time = np.linspace(-10, 100, 111) @@ -37,7 +38,7 @@ def _make_file_with_model(self, model_info): def _make_file_with_profile_model(self, model_info): """Create project, file with aux_axis, and load model.""" - project = Project(path="tests") + project = make_project() aux_axis = np.array([0.0, 1.0, 2.0, 3.0, 4.0]) file = File(parent_project=project, aux_axis=aux_axis) file.energy = np.linspace(80, 90, 201) diff --git a/tests/test_mcp_library.py b/tests/test_mcp_library.py index 46fe974..5c54d56 100644 --- a/tests/test_mcp_library.py +++ b/tests/test_mcp_library.py @@ -4,6 +4,7 @@ import numpy as np import pytest +from _utils import make_project from trspecfit.functions import profile as fcts_profile from trspecfit.functions import time as fcts_time @@ -359,9 +360,9 @@ class TestMCPIntegration: def _make_file(self): """Helper: File with energy model loaded via public API.""" - from trspecfit import File, Project + from trspecfit import File - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = np.linspace(80, 90, 100) file.time = np.linspace(0, 10, 50) @@ -399,9 +400,9 @@ def test_2d_model_with_dynamics(self): def test_parameter_value_updates(self): """lmfit parameter values can be read and updated after model loading.""" - from trspecfit import File, Project + from trspecfit import File - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = np.linspace(80, 90, 100) file.time = np.linspace(0, 10, 50) @@ -555,9 +556,9 @@ class TestMCPProfile: def _make_file(self, *, aux_axis=None): """Helper: File with single GLP energy model loaded via public API.""" - from trspecfit import File, Project + from trspecfit import File - project = Project(path="tests") + project = make_project() file = File(parent_project=project, aux_axis=aux_axis) file.energy = np.linspace(80, 90, 100) file.time = np.linspace(-10, 50, 60) @@ -766,9 +767,9 @@ def test_add_profile_raises_without_aux_axis(self): def test_add_profile_raises_for_expression_par(self): """add_par_profile() should raise for expression parameters.""" - from trspecfit import File, Project + from trspecfit import File - project = Project(path="tests") + project = make_project() file = File( parent_project=project, aux_axis=np.linspace(0, 5, 20), @@ -793,9 +794,9 @@ def test_add_profile_raises_for_expression_par(self): def test_file_aux_axis_propagation(self): """File.aux_axis should propagate to loaded Model via load_model().""" - from trspecfit import File, Project + from trspecfit import File - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.energy = np.linspace(80, 90, 100) file.time = np.linspace(-10, 100, 50) @@ -860,10 +861,9 @@ class TestMCPPickling: def _make_fittable_file(self): """File with a 1D energy model loaded and data populated.""" - from trspecfit import File, Project + from trspecfit import File - project = Project(path="tests") - project.show_output = 0 + project = make_project() file = File(parent_project=project, energy=np.linspace(80, 90, 101)) file.load_model( model_yaml="models/file_energy.yaml", diff --git a/tests/test_model_parser.py b/tests/test_model_parser.py index 5911520..ee9b864 100644 --- a/tests/test_model_parser.py +++ b/tests/test_model_parser.py @@ -6,7 +6,9 @@ import pytest # local imports -from trspecfit import File, Project +from _utils import make_project + +from trspecfit import File from trspecfit.utils.parsing import ModelValidationError @@ -19,7 +21,7 @@ class TestEnergyParsing: def _load_energy_model(self, model_info): """Create project, file, and load energy model.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.load_model( model_yaml="models/file_energy.yaml", @@ -184,7 +186,7 @@ class TestTimeParsing: def _load_dynamics_model(self, model_info): """Create project, file, and load dynamics model.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.time = np.linspace(-10, 100, 111) # needed for time-dependent models model = file.load_model( @@ -300,7 +302,7 @@ def _make_file_with_energy_model( ) -> File: """Create a File with loaded energy model and time axis.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project, aux_axis=aux_axis) file.load_model( model_yaml="models/file_energy.yaml", @@ -413,7 +415,7 @@ def _make_file( ) -> File: """Create a File with loaded energy model and optional aux axis.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project, aux_axis=aux_axis) file.energy = np.linspace(80, 90, 201) file.load_model( @@ -558,7 +560,7 @@ class TestYAMLValidationErrors: def test_wrong_parameter_order_accepted(self): """Parameters in non-standard order should still parse correctly.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.load_model( model_yaml="models/file_energy.yaml", @@ -574,7 +576,7 @@ def test_wrong_parameter_order_accepted(self): def test_wrong_parameter_name_raises(self): """Unknown parameter name (q instead of m for GLP) should fail validation.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) with pytest.raises(ModelValidationError, match="Invalid parameter"): file.load_model( @@ -586,7 +588,7 @@ def test_wrong_parameter_name_raises(self): def test_nonexistent_model_raises(self): """Loading a model name that doesn't exist in the YAML should fail.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) with pytest.raises(ValueError, match="not found in"): file.load_model( @@ -598,7 +600,7 @@ def test_nonexistent_model_raises(self): def test_background_last_raises(self): """Background as last component should fail ordering validation.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) with pytest.raises(ModelValidationError, match="background function"): file.load_model( @@ -610,7 +612,7 @@ def test_background_last_raises(self): def test_energy_convolution_rejected(self): """Top-level energy-model convolution should fail validation early.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) with pytest.raises( ModelValidationError, @@ -630,7 +632,7 @@ def test_unknown_function_name_raises(self): typo quickly, so pin both in the assertion. """ - project = Project(path="tests") + project = make_project() file = File(parent_project=project) with pytest.raises(ModelValidationError) as exc_info: file.load_model( @@ -648,7 +650,7 @@ def test_unknown_function_name_raises(self): def test_conv_last_raises(self): """Convolution as last component should fail ordering validation.""" - project = Project(path="tests") + project = make_project() file = File(parent_project=project) file.time = np.linspace(-10, 100, 111) with pytest.raises(ModelValidationError, match="convolution function"): @@ -668,7 +670,7 @@ def test_integer_vary_value_rejected(self): isinstance(vary, bool) to distinguish 0 from False. """ - project = Project(path="tests") + project = make_project() file = File(parent_project=project) with pytest.raises(ModelValidationError, match="vary"): file.load_model( diff --git a/tests/test_parameter_sweep.py b/tests/test_parameter_sweep.py index ac115c6..79d20ea 100644 --- a/tests/test_parameter_sweep.py +++ b/tests/test_parameter_sweep.py @@ -8,8 +8,9 @@ import h5py import numpy as np import pytest +from _utils import make_project -from trspecfit import File, Project, Simulator +from trspecfit import File, Simulator from trspecfit.utils.sweep import ParameterSweep, SweepDataset @@ -230,7 +231,7 @@ class TestSimulatorParameterSweep: def _make_1d_model(self): """Create a simple 1D model for sweep testing.""" - project = Project(path="tests", name="test") + project = make_project(name="test") file = File( parent_project=project, energy=np.arange(0, 20, 0.5), # Coarse for speed @@ -246,7 +247,7 @@ def _make_1d_model(self): def _make_2d_model(self): """Create a simple 2D model for sweep testing.""" - project = Project(path="tests", name="test") + project = make_project(name="test") file = File( parent_project=project, energy=np.arange(0, 20, 0.5), # Coarse for speed diff --git a/tests/test_plotting.py b/tests/test_plotting.py index db67fc9..0eb1a10 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -30,6 +30,7 @@ matplotlib.use("Agg") # Use non-interactive backend import matplotlib.pyplot as plt +from _utils import make_project from trspecfit import File, Project from trspecfit.config.plot import PlotConfig @@ -683,7 +684,7 @@ class TestPlotConfigPropagation: def _make_file_with_model(self, *, x_dir="rev"): """Create a project/file/model with a reversed energy axis.""" - project = Project(path="tests") + project = make_project() project.x_dir = x_dir project.e_label = "Binding Energy (eV)" @@ -829,7 +830,7 @@ class TestHighLevelPlotOverrides: def _make_file_with_model(self): """Return a File with a loaded energy model, default x_dir='def'.""" - project = Project(path="tests") + project = make_project() project.e_label = "Binding Energy (eV)" project.x_dir = "def" diff --git a/tests/test_project_fit.py b/tests/test_project_fit.py index 96a29ed..e6b0aeb 100644 --- a/tests/test_project_fit.py +++ b/tests/test_project_fit.py @@ -11,17 +11,9 @@ import numpy as np import pytest +from _utils import make_project, simulate_clean -from trspecfit import File, Project, Simulator - - -# -def _make_project(): - """Create a silent project pointing to tests/ for YAML access.""" - - project = Project(path="tests", name="project_fit") - project.show_output = 0 - return project +from trspecfit import File # @@ -31,8 +23,7 @@ def _make_truth_file(*, amplitude=20.0, x0_shift=3.0, tau=5.0): Uses a throwaway project so truth files don't pollute the fit project. """ - truth_project = Project(path="tests", name="truth") - truth_project.show_output = 0 + truth_project = make_project(name="truth") energy = np.linspace(83, 87, 30) time_ax = np.linspace(-2, 10, 24) @@ -109,78 +100,7 @@ def _make_fit_file(project, data, energy, time_ax, *, name="test"): # # class TestProjectFitClean: - """Project-level fit on noiseless data with shared tau.""" - - # - @pytest.mark.slow - def test_shared_tau_recovery(self): - """Two files with same tau, different A — project fit recovers both.""" - - project = _make_project() - - # Truth: same tau=5, different amplitudes - truth1 = _make_truth_file(amplitude=20.0, x0_shift=3.0, tau=5.0) - truth2 = _make_truth_file(amplitude=15.0, x0_shift=2.0, tau=5.0) - - # Simulate clean data - sim1 = Simulator( - model=truth1.model_active, - detection="analog", - noise_level=0.0, - noise_type="none", - seed=42, - ) - clean1, _, _ = sim1.simulate_2d() - - sim2 = Simulator( - model=truth2.model_active, - detection="analog", - noise_level=0.0, - noise_type="none", - seed=43, - ) - clean2, _, _ = sim2.simulate_2d() - - # Create fit files (baseline already fitted inside _make_fit_file) - fit1 = _make_fit_file( - project, clean1, truth1.energy, truth1.time, name="file_1" - ) - fit2 = _make_fit_file( - project, clean2, truth2.energy, truth2.time, name="file_2" - ) - - # Project-level 2D fit - project.fit_2d(model_name="project_glp", stages=2, try_ci=0) - - # Check that tau was recovered (shared parameter) - model1 = fit1.select_model("project_glp") - model2 = fit2.select_model("project_glp") - assert model1 is not None # type guard - assert model2 is not None # type guard - - tau1 = model1.lmfit_pars["GLP_01_x0_expFun_01_tau"].value - tau2 = model2.lmfit_pars["GLP_01_x0_expFun_01_tau"].value - - # Both should have the same tau (project-shared) - assert tau1 == tau2, f"tau should be shared: {tau1} != {tau2}" - assert np.isclose(tau1, 5.0, rtol=1e-3), ( - f"tau recovery: true=5.0, fit={tau1:.4f}" - ) - - # Check file-vary params recovered independently - A1 = model1.lmfit_pars["GLP_01_A"].value - A2 = model2.lmfit_pars["GLP_01_A"].value - assert np.isclose(A1, 20.0, rtol=1e-3), f"A1 recovery: true=20.0, fit={A1:.4f}" - assert np.isclose(A2, 15.0, rtol=1e-3), f"A2 recovery: true=15.0, fit={A2:.4f}" - - x0_shift1 = model1.lmfit_pars["GLP_01_x0_expFun_01_A"].value - x0_shift2 = model2.lmfit_pars["GLP_01_x0_expFun_01_A"].value - assert np.isclose(x0_shift1, 3.0, rtol=1e-3), ( - f"x0_shift1 recovery: true=3.0, fit={x0_shift1:.4f}" - ) - assert np.isclose(x0_shift2, 2.0, rtol=1e-3), ( - f"x0_shift2 recovery: true=2.0, fit={x0_shift2:.4f}" - ) + """Project-level fit on noiseless data — non-trivial roundtrips.""" # @pytest.mark.slow @@ -191,7 +111,7 @@ def test_biexp_expr_t0_roundtrip(self): TRUE_TAU1 = 2.0 TRUE_TAU2 = 20.0 - project = _make_project() + project = make_project(name="project_fit") # --- build truth files with bi-exponential + Gaussian IRF on x0 --- # gaussCONV smooths the hard step at t0 into a smooth onset, @@ -201,8 +121,7 @@ def test_biexp_expr_t0_roundtrip(self): (20.0, 2.0, 1.0, 42), (15.0, 1.5, 0.8, 43), ]: - tp = Project(path="tests", name="truth") - tp.show_output = 0 + tp = make_project(name="truth") tf = File(parent_project=tp) tf.energy = np.linspace(80, 90, 50) tf.time = np.linspace(-5, 50, 120) @@ -234,14 +153,7 @@ def test_biexp_expr_t0_roundtrip(self): # --- simulate and build fit files --- for file_idx, (tf, seed) in enumerate(truth_files): - sim = Simulator( - model=tf.model_active, - detection="analog", - noise_level=0.0, - noise_type="none", - seed=seed, - ) - clean, _, _ = sim.simulate_2d() + clean = simulate_clean(tf.model_active, seed=seed) ff = File( parent_project=project, @@ -312,7 +224,7 @@ class TestVaryLevelParsing: def test_vary_levels_on_par(self): """Par.vary_level is set from YAML.""" - project = _make_project() + project = make_project(name="project_fit") file = File(parent_project=project) file.energy = np.linspace(83, 87, 10) file.time = np.linspace(-2, 10, 10) @@ -336,7 +248,7 @@ def test_vary_levels_on_par(self): def test_vary_levels_map(self): """Model.get_vary_levels returns correct levels for all params.""" - project = _make_project() + project = make_project(name="project_fit") file = File(parent_project=project) file.energy = np.linspace(83, 87, 10) file.time = np.linspace(-2, 10, 10) @@ -376,7 +288,7 @@ def test_vary_levels_profile_with_dynamics(self): silently absent (which would cause _build_fit_params to freeze them). """ - project = _make_project() + project = make_project(name="project_fit") file = File(parent_project=project, aux_axis=np.linspace(0, 4, 5)) file.energy = np.linspace(83, 87, 10) file.time = np.linspace(-2, 10, 10) @@ -414,7 +326,7 @@ def test_vary_levels_profile_with_dynamics(self): def test_file_level_fit_treats_project_as_vary(self): """File-level fitting treats both 'project' and 'file' as vary=True.""" - project = _make_project() + project = make_project(name="project_fit") file = File(parent_project=project) file.energy = np.linspace(83, 87, 10) file.time = np.linspace(-2, 10, 10) @@ -443,7 +355,7 @@ class TestBuildFitParams: def test_combined_params_structure(self): """Combined params have prefixed file-vary and unprefixed project-vary.""" - project = _make_project() + project = make_project(name="project_fit") for i in range(2): f = File(parent_project=project, name=f"file_{i}") @@ -481,7 +393,7 @@ def test_combined_params_structure(self): def test_expressions_rewritten_with_prefix(self): """Expressions referencing file-vary params get file prefix.""" - project = _make_project() + project = make_project(name="project_fit") for i in range(2): f = File(parent_project=project, name=f"file_{i}") @@ -508,7 +420,7 @@ def test_expressions_rewritten_with_prefix(self): def test_expr_referencing_project_vary_stays_unprefixed(self): """Expression referencing a project-vary param keeps unprefixed name.""" - project = _make_project() + project = make_project(name="project_fit") for i in range(2): f = File(parent_project=project, name=f"file_{i}") @@ -544,7 +456,7 @@ def test_expr_referencing_project_vary_stays_unprefixed(self): def test_project_vary_initial_value_conflict_warns(self): """Warn when project-vary param has different initial values across files.""" - project = _make_project() + project = make_project(name="project_fit") for i in range(2): f = File(parent_project=project, name=f"file_{i}") @@ -574,7 +486,7 @@ def test_project_vary_initial_value_conflict_warns(self): def test_project_vary_bound_conflict_raises(self): """Raise when project-vary param has different min or max across files.""" - project = _make_project() + project = make_project(name="project_fit") for i in range(2): f = File(parent_project=project, name=f"file_{i}") @@ -612,16 +524,9 @@ class TestProjectFitLifecycle: def test_model_2d_set_after_project_fit(self): """file.model_2d is set on every file after Project.fit_2d().""" - project = _make_project() + project = make_project(name="project_fit") truth = _make_truth_file() - sim = Simulator( - model=truth.model_active, - detection="analog", - noise_level=0.0, - noise_type="none", - seed=42, - ) - clean, _, _ = sim.simulate_2d() + clean = simulate_clean(truth.model_active) for i in range(2): _make_fit_file(project, clean, truth.energy, truth.time, name=f"file_{i}") @@ -636,16 +541,9 @@ def test_model_2d_set_after_project_fit(self): def test_get_fit_results_2d_works_after_project_fit(self): """get_fit_results("2d") returns a DataFrame on project-fitted files.""" - project = _make_project() + project = make_project(name="project_fit") truth = _make_truth_file() - sim = Simulator( - model=truth.model_active, - detection="analog", - noise_level=0.0, - noise_type="none", - seed=42, - ) - clean, _, _ = sim.simulate_2d() + clean = simulate_clean(truth.model_active) for i in range(2): _make_fit_file(project, clean, truth.energy, truth.time, name=f"file_{i}") @@ -662,16 +560,9 @@ def test_get_fit_results_2d_works_after_project_fit(self): def test_save_2d_fit_works_after_project_fit(self, tmp_path): """save_2d_fit() runs without error on project-fitted files.""" - project = _make_project() + project = make_project(name="project_fit") truth = _make_truth_file() - sim = Simulator( - model=truth.model_active, - detection="analog", - noise_level=0.0, - noise_type="none", - seed=42, - ) - clean, _, _ = sim.simulate_2d() + clean = simulate_clean(truth.model_active) for i in range(2): _make_fit_file(project, clean, truth.energy, truth.time, name=f"file_{i}") diff --git a/tests/test_roundtrip_basic.py b/tests/test_roundtrip_basic.py deleted file mode 100644 index 76b4e44..0000000 --- a/tests/test_roundtrip_basic.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Round-trip tests: simulate data with known parameters, fit, recover. - -Uses small grids (30 energy × 30 time points) to keep tests fast. -""" - -import matplotlib - -matplotlib.use("Agg") - -import numpy as np -import pytest - -from trspecfit import File, Project, Simulator - - -# -def _make_project(): - """Create a silent project pointing to tests/ for YAML access.""" - - project = Project(path="tests", name="roundtrip") - project.show_output = 0 - return project - - -# -def _make_truth_file(project): - """Create file with single GLP peak + exponential dynamics on amplitude.""" - - energy = np.linspace(83, 87, 30) - time = np.linspace(-2, 10, 24) - - file = File(parent_project=project, name="truth") - file.energy = energy - file.time = time - file.dim = 2 - - file.load_model( - model_yaml="models/file_energy.yaml", - model_info="single_glp", - ) - file.add_time_dependence( - target_model="single_glp", - target_parameter="GLP_01_A", - dynamics_yaml="models/file_time.yaml", - dynamics_model=["MonoExpPos"], - ) - return file - - -# -def _make_fit_file(project, data, energy, time): - """Create a fresh file loaded with simulated data, ready for baseline fit.""" - - file = File( - parent_project=project, - name="fit", - data=data, - energy=energy.copy(), - time=time.copy(), - ) - - file.load_model( - model_yaml="models/file_energy.yaml", - model_info="single_glp", - ) - file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) - return file - - -# -def _add_fit_dynamics(file): - """Add the 2D dynamics after baseline fitting the energy-only model.""" - - file.add_time_dependence( - target_model="single_glp", - target_parameter="GLP_01_A", - dynamics_yaml="models/file_time.yaml", - dynamics_model=["MonoExpPos"], - ) - - -# -def _extract_par_dict(model): - """Return {name: value} for all non-expression parameters.""" - - return { - name: model.lmfit_pars[name].value - for name in model.parameter_names - if model.lmfit_pars[name].expr is None - } - - -# -# -class TestRoundTripClean: - """Fit noiseless data — parameters should be recovered exactly.""" - - # - @pytest.mark.slow - def test_clean_recovery(self): - """Simulate clean 2D data, fit, assert parameter recovery.""" - - project = _make_project() - truth_file = _make_truth_file(project) - truth_pars = _extract_par_dict(truth_file.model_active) - - sim = Simulator( - model=truth_file.model_active, - detection="analog", - noise_level=0.0, - noise_type="none", - seed=42, - ) - clean, _, _ = sim.simulate_2d() - - fit_file = _make_fit_file(project, clean, truth_file.energy, truth_file.time) - - fit_file.fit_baseline( - model_name="single_glp", - stages=2, - try_ci=0, - ) - _add_fit_dynamics(fit_file) - fit_file.fit_2d( - model_name="single_glp", - stages=2, - try_ci=0, - ) - - fitted_pars = fit_file.model_2d.result[1].params - for name, true_val in truth_pars.items(): - fit_val = fitted_pars[name].value - assert np.isclose(true_val, fit_val, rtol=1e-10, atol=1e-12), ( - f"{name}: true={true_val:.6f}, fit={fit_val:.6f}" - ) - - -# -# -class TestRoundTripNoisy: - """Fit noisy data — parameters should be recovered within tolerance.""" - - # - @pytest.mark.slow - def test_noisy_recovery(self): - """Simulate noisy 2D data, fit, assert recovery within 5%.""" - - project = _make_project() - truth_file = _make_truth_file(project) - truth_pars = _extract_par_dict(truth_file.model_active) - - sim = Simulator( - model=truth_file.model_active, - detection="analog", - noise_level=0.01, - noise_type="gaussian", - seed=42, - ) - _, noisy, _ = sim.simulate_2d() - - fit_file = _make_fit_file(project, noisy, truth_file.energy, truth_file.time) - - fit_file.fit_baseline( - model_name="single_glp", - stages=2, - try_ci=0, - ) - _add_fit_dynamics(fit_file) - fit_file.fit_2d( - model_name="single_glp", - stages=1, - try_ci=0, - ) - - fitted_pars = fit_file.model_2d.result[1].params - for name, true_val in truth_pars.items(): - if abs(true_val) < 1e-6: - continue # skip fixed-at-zero params (t0, y0) - fit_val = fitted_pars[name].value - rel_err = abs(fit_val - true_val) / abs(true_val) - assert rel_err < 0.05, ( - f"{name}: true={true_val:.4f}, fit={fit_val:.4f}, rel_err={rel_err:.1%}" - ) diff --git a/tests/test_roundtrip_profile.py b/tests/test_roundtrip_profile.py deleted file mode 100644 index 6cb554c..0000000 --- a/tests/test_roundtrip_profile.py +++ /dev/null @@ -1,375 +0,0 @@ -"""Round-trip tests for parameter profiles: simulate, fit, recover. - -Clean tests (plumbing): noiseless data, machine-precision recovery. -Noisy tests: Gaussian noise, recovery within 5% relative error. - -Test 1: Two profiles on same component (pLinear on x0 + pExpDecay on A), - no dynamics — baseline-only fit. -Test 2: Two profiles + dynamics (expFun on pExpDecay A) — full 2D fit. -""" - -import matplotlib - -matplotlib.use("Agg") - -import numpy as np -import pytest - -from trspecfit import File, Project, Simulator - - -# -def _make_project(): - """Create a silent project pointing to tests/ for YAML access.""" - - project = Project(path="tests", name="roundtrip_profile") - project.show_output = 0 - return project - - -# -def _make_energy_axis(): - return np.linspace(81, 89, 50) - - -# -def _make_time_axis(): - return np.linspace(-2, 10, 24) - - -# -def _make_aux_axis(): - return np.linspace(0, 8, 20) - - -# -def _extract_par_dict(model): - """Return {name: value} for all non-expression parameters.""" - - return { - name: model.lmfit_pars[name].value - for name in model.parameter_names - if model.lmfit_pars[name].expr is None - } - - -# ---- truth file builders ---- - - -# -def _make_truth_two_profiles(project): - """Gauss + pLinear on x0 + pExpDecay on A (two profiles, no dynamics).""" - - file = File(parent_project=project, name="truth", aux_axis=_make_aux_axis()) - file.energy = _make_energy_axis() - file.time = _make_time_axis() - file.dim = 2 - - file.load_model( - model_yaml="models/file_energy.yaml", - model_info="single_gauss", - ) - file.add_par_profile( - target_model="single_gauss", - target_parameter="Gauss_01_x0", - profile_yaml="models/file_profile.yaml", - profile_model=["roundtrip_pLinear_x0"], - ) - file.add_par_profile( - target_model="single_gauss", - target_parameter="Gauss_01_A", - profile_yaml="models/file_profile.yaml", - profile_model=["roundtrip_pExpDecay_A"], - ) - return file - - -# -def _make_truth_profile_dynamics(project): - """Gauss + two profiles + expFun dynamics on pExpDecay A.""" - - file = File(parent_project=project, name="truth", aux_axis=_make_aux_axis()) - file.energy = _make_energy_axis() - file.time = _make_time_axis() - file.dim = 2 - - file.load_model( - model_yaml="models/file_energy.yaml", - model_info="single_gauss", - ) - file.add_par_profile( - target_model="single_gauss", - target_parameter="Gauss_01_x0", - profile_yaml="models/file_profile.yaml", - profile_model=["roundtrip_pLinear_x0"], - ) - file.add_par_profile( - target_model="single_gauss", - target_parameter="Gauss_01_A", - profile_yaml="models/file_profile.yaml", - profile_model=["roundtrip_pExpDecay_A"], - ) - file.add_time_dependence( - target_model="single_gauss", - target_parameter="Gauss_01_A_pExpDecay_01_A", - dynamics_yaml="models/file_time.yaml", - dynamics_model=["MonoExpPosStrong"], - ) - return file - - -# ---- fit file builders ---- - - -# -def _make_fit_two_profiles(project, data, energy, time, aux): - """Fresh file with two profiles, loaded with simulated data.""" - - file = File( - parent_project=project, - name="fit", - data=data, - energy=energy.copy(), - time=time.copy(), - aux_axis=aux.copy(), - ) - file.load_model( - model_yaml="models/file_energy.yaml", - model_info="single_gauss", - ) - file.add_par_profile( - target_model="single_gauss", - target_parameter="Gauss_01_x0", - profile_yaml="models/file_profile.yaml", - profile_model=["roundtrip_pLinear_x0"], - ) - file.add_par_profile( - target_model="single_gauss", - target_parameter="Gauss_01_A", - profile_yaml="models/file_profile.yaml", - profile_model=["roundtrip_pExpDecay_A"], - ) - file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) - return file - - -# -def _make_fit_profile_dynamics(project, data, energy, time, aux): - """Fresh file with two profiles, ready for baseline fit.""" - - file = File( - parent_project=project, - name="fit", - data=data, - energy=energy.copy(), - time=time.copy(), - aux_axis=aux.copy(), - ) - file.load_model( - model_yaml="models/file_energy.yaml", - model_info="single_gauss", - ) - file.add_par_profile( - target_model="single_gauss", - target_parameter="Gauss_01_x0", - profile_yaml="models/file_profile.yaml", - profile_model=["roundtrip_pLinear_x0"], - ) - file.add_par_profile( - target_model="single_gauss", - target_parameter="Gauss_01_A", - profile_yaml="models/file_profile.yaml", - profile_model=["roundtrip_pExpDecay_A"], - ) - file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) - return file - - -# -def _add_fit_profile_dynamics(file): - """Add dynamics after baseline fitting the profile-only model.""" - - file.add_time_dependence( - target_model="single_gauss", - target_parameter="Gauss_01_A_pExpDecay_01_A", - dynamics_yaml="models/file_time.yaml", - dynamics_model=["MonoExpPosStrong"], - ) - - -# -# -class TestRoundTripTwoProfiles: - """Two profiles on same component, no dynamics — baseline fit recovery.""" - - # - @pytest.mark.slow - def test_clean_recovery(self): - """Simulate clean 2D data with two profiles, fit baseline, recover.""" - - project = _make_project() - truth_file = _make_truth_two_profiles(project) - truth_pars = _extract_par_dict(truth_file.model_active) - - sim = Simulator( - model=truth_file.model_active, - detection="analog", - noise_level=0.0, - noise_type="none", - seed=42, - ) - clean, _, _ = sim.simulate_2d() - - fit_file = _make_fit_two_profiles( - project, clean, truth_file.energy, truth_file.time, truth_file.aux_axis - ) - fit_file.fit_baseline( - model_name="single_gauss", - stages=2, - try_ci=0, - ) - - fitted_pars = fit_file.model_base.result[1].params - for name, true_val in truth_pars.items(): - fit_val = fitted_pars[name].value - assert np.isclose(true_val, fit_val, rtol=1e-10, atol=1e-12), ( - f"{name}: true={true_val:.6f}, fit={fit_val:.6f}" - ) - - -# -# -class TestRoundTripProfileDynamics: - """Two profiles + dynamics on pExpDecay A — full 2D fit recovery.""" - - # - @pytest.mark.slow - def test_clean_recovery(self): - """Simulate clean 2D data with profile + dynamics, fit, recover.""" - - project = _make_project() - truth_file = _make_truth_profile_dynamics(project) - truth_pars = _extract_par_dict(truth_file.model_active) - - sim = Simulator( - model=truth_file.model_active, - detection="analog", - noise_level=0.0, - noise_type="none", - seed=42, - ) - clean, _, _ = sim.simulate_2d() - - fit_file = _make_fit_profile_dynamics( - project, clean, truth_file.energy, truth_file.time, truth_file.aux_axis - ) - fit_file.fit_baseline( - model_name="single_gauss", - stages=2, - try_ci=0, - ) - _add_fit_profile_dynamics(fit_file) - fit_file.fit_2d( - model_name="single_gauss", - stages=2, - try_ci=0, - ) - - fitted_pars = fit_file.model_2d.result[1].params - for name, true_val in truth_pars.items(): - fit_val = fitted_pars[name].value - assert np.isclose(true_val, fit_val, rtol=1e-10, atol=1e-12), ( - f"{name}: true={true_val:.6f}, fit={fit_val:.6f}" - ) - - -# -# -class TestRoundTripTwoProfilesNoisy: - """Two profiles, no dynamics — noisy baseline fit recovery within 5%.""" - - # - @pytest.mark.slow - def test_noisy_recovery(self): - """Simulate noisy 2D data with two profiles, fit baseline, recover.""" - - project = _make_project() - truth_file = _make_truth_two_profiles(project) - truth_pars = _extract_par_dict(truth_file.model_active) - - sim = Simulator( - model=truth_file.model_active, - detection="analog", - noise_level=0.01, - noise_type="gaussian", - seed=42, - ) - _, noisy, _ = sim.simulate_2d() - - fit_file = _make_fit_two_profiles( - project, noisy, truth_file.energy, truth_file.time, truth_file.aux_axis - ) - fit_file.fit_baseline( - model_name="single_gauss", - stages=2, - try_ci=0, - ) - - fitted_pars = fit_file.model_base.result[1].params - for name, true_val in truth_pars.items(): - if abs(true_val) < 1e-6: - continue # skip fixed-at-zero params - fit_val = fitted_pars[name].value - rel_err = abs(fit_val - true_val) / abs(true_val) - assert rel_err < 0.05, ( - f"{name}: true={true_val:.4f}, fit={fit_val:.4f}, rel_err={rel_err:.1%}" - ) - - -# -# -class TestRoundTripProfileDynamicsNoisy: - """Two profiles + dynamics on pExpDecay A — noisy 2D fit recovery.""" - - # - @pytest.mark.slow - def test_noisy_recovery(self): - """Simulate noisy 2D data with profile + dynamics, fit, recover.""" - - project = _make_project() - truth_file = _make_truth_profile_dynamics(project) - truth_pars = _extract_par_dict(truth_file.model_active) - - sim = Simulator( - model=truth_file.model_active, - detection="analog", - noise_level=0.01, - noise_type="gaussian", - seed=42, - ) - _, noisy, _ = sim.simulate_2d() - - fit_file = _make_fit_profile_dynamics( - project, noisy, truth_file.energy, truth_file.time, truth_file.aux_axis - ) - fit_file.fit_baseline( - model_name="single_gauss", - stages=2, - try_ci=0, - ) - _add_fit_profile_dynamics(fit_file) - fit_file.fit_2d( - model_name="single_gauss", - stages=1, - try_ci=0, - ) - - fitted_pars = fit_file.model_2d.result[1].params - for name, true_val in truth_pars.items(): - if abs(true_val) < 1e-6: - continue # skip fixed-at-zero params - fit_val = fitted_pars[name].value - rel_err = abs(fit_val - true_val) / abs(true_val) - assert rel_err < 0.05, ( - f"{name}: true={true_val:.4f}, fit={fit_val:.4f}, rel_err={rel_err:.1%}" - ) From 4219e90d8823c037406f4c78f8b45a6a894342e2 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Mon, 27 Apr 2026 20:15:55 -0700 Subject: [PATCH 2/4] fix Par expression resolution after Model unpickle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model.__getstate__ nulls Par.parent_model on every parameter to keep pickles bounded. That broke Par._evaluate_dynamic_expression, which resolves cross-component references via parent_model.get_all_parameters(): any model whose expression on one Par references a t_vary or p_vary Par on a different component (e.g. roundtrip F12) raised NameError after unpickling. Latent until now because the matrix runs SbS at n_workers=1 and MCMC focused tests didn't cover that shape. Model.__setstate__ rewires the intra-Model back-refs from self: comp.parent_model, par.parent_model, and par.t_model / par.p_model parent_model on attached sub-Models. Dynamics/Profile inherit the hook and recurse for their internal pars. parent_file stays nulled — that's a cross-File relationship the caller still owns. - tests/roundtrip/test_pickle.py: pickle + deepcopy value_2d byte-equality parametrized over F3/F6/F8/F12. F12 caught the bug. - tests/roundtrip/test_focused.py: test_w2_sbs_f6_profile exercises SbS n_workers=2 on a profile-bearing family; existing test_w2_sbs_f1 only covered a static model. --- CHANGELOG.md | 1 + src/trspecfit/mcp.py | 25 ++++++++++++ tests/roundtrip/test_focused.py | 25 ++++++++++++ tests/roundtrip/test_pickle.py | 72 +++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 tests/roundtrip/test_pickle.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a942ac6..ef5f481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ This file is maintained using the shared changelog workflow in ### Fixed - **MCMC `workers > 1`**: `lmfit.emcee(workers=N)` via `ulmfit.MC(workers=N)` previously failed with `TypeError: cannot pickle 'module' object` because the residual closure carried a live module reference. The pickleable-model work plus the `spec_lib` removal close both sources of the error; MCMC parallel sampling now works end-to-end. +- **Cross-component expressions across the pickle boundary**: `Model.__getstate__` nulled `parent_model` on every `Par`, which broke `Par._evaluate_dynamic_expression` because it resolves expression references through `parent_model.get_all_parameters()`. Any model whose expression on one Par references a `t_vary` or `p_vary` Par on a different component (e.g. roundtrip family F12) raised `NameError` after unpickling. `Model.__setstate__` now rewires the intra-Model `parent_model` back-refs (Components, Pars, and any attached `Par.t_model` / `Par.p_model` sub-Models) from `self`, so `lmfit.emcee(workers > 1)` and `fit_slice_by_slice(n_workers > 1)` work on those models too. ## [0.8.0] - 2026-04-20 diff --git a/src/trspecfit/mcp.py b/src/trspecfit/mcp.py index e7a017f..810a806 100644 --- a/src/trspecfit/mcp.py +++ b/src/trspecfit/mcp.py @@ -235,6 +235,31 @@ def __getstate__(self) -> dict[str, Any]: state[key] = None return state + # + def __setstate__(self, state: dict[str, Any]) -> None: + """Pickle protocol: restore intra-Model ``parent_model`` back-refs. + + ``parent_file`` stays ``None`` (caller must re-attach if needed — + see ``__getstate__``). ``parent_model`` is different: the back-ref + is internal to this Model graph, since this Model owns its + Components, their Pars, and any Dynamics/Profile sub-Models + attached to those Pars. Rewire from ``self`` so expression-on-Par + evaluation paths that traverse ``Par.get_all_parameters`` keep + working post-unpickle. Sub-Models (Dynamics/Profile) inherit this + method, so their internal back-refs are restored by the same + recursion. + """ + + self.__dict__.update(state) + for comp in self.components: + comp.parent_model = self + for par in comp.pars: + par.parent_model = self + if par.t_model is not None: + par.t_model.parent_model = self + if par.p_model is not None: + par.p_model.parent_model = self + @property def plot_config(self) -> PlotConfig: """ diff --git a/tests/roundtrip/test_focused.py b/tests/roundtrip/test_focused.py index d5c65fb..d55c804 100644 --- a/tests/roundtrip/test_focused.py +++ b/tests/roundtrip/test_focused.py @@ -156,6 +156,31 @@ def test_w2_sbs_f1(): assert len(fit_file.results_sbs) == len(fit_file.time) +# ---- W2 on a profile family: pickles Par.p_model into workers ---- + + +# +def test_w2_sbs_f6_profile(): + """SbS workers=2 on a profile-bearing family. + + Per-slice fits are 1D, so dynamics aren't inside the worker model, + but profiles are: ``Par.p_model`` (a Profile sub-Model) must survive + the worker pickle boundary or this fails. F1 doesn't exercise that + path. + """ + + fit_file, family = _build_fit_file_for_baseline("F6") + fit_file.fit_slice_by_slice( + model_name=family.model_name("default"), + stages=1, + n_workers=2, + seed_source="model", + seed_adapt=None, + try_ci=0, + ) + assert len(fit_file.results_sbs) == len(fit_file.time) + + # ---- SbS seed combos: explicit seed + baseline+argmax_shift ---- diff --git a/tests/roundtrip/test_pickle.py b/tests/roundtrip/test_pickle.py new file mode 100644 index 0000000..bde8c05 --- /dev/null +++ b/tests/roundtrip/test_pickle.py @@ -0,0 +1,72 @@ +"""Pickle / deepcopy evaluator-parity for non-static roundtrip families. + +Complements ``TestMCPPickling`` in ``tests/test_mcp_library.py``, which +covers static models only (``single_glp``, no ``t_vary``, no ``p_vary``). +Here we exercise families that carry a ``Par.t_model`` (Dynamics), +``Par.p_model`` (Profile), or both, and assert that the restored Model's +2D evaluator is byte-identical to the original. This locks down the +contract that ``Par.t_model`` / ``Par.p_model`` survive the +``parent_model`` nulling baked into the pickle hooks. +""" + +from __future__ import annotations + +import copy +import pickle + +import numpy as np +import pytest +from _utils import make_project + +from .families import FAMILIES + +pytestmark = pytest.mark.slow + + +# Cover the four non-static regimes: +# - F3: top-level dynamics only (Par.t_model) +# - F6: top-level profile only (Par.p_model x2) +# - F8: profile-internal dynamics (Par on a Profile component carries t_model) +# - F12: mixed expression referencing a profiled and a time-dep base par +_FAMILIES = ("F3", "F6", "F8", "F12") + + +# +def _build_truth_model(family_id: str): + """Build a truth file for ``family_id`` and return its active model.""" + + family = FAMILIES[family_id] + project = make_project(name=f"pickle_{family_id}", spec_fun_str="fit_model_mcp") + truth_file = family.build_truth(project, variant="default") + assert truth_file.model_active is not None # type guard + return truth_file.model_active + + +# +@pytest.mark.parametrize("family_id", _FAMILIES) +def test_pickle_value_2d_parity(family_id): + """Pickled non-static Model reproduces ``value_2d`` byte-for-byte.""" + + model = _build_truth_model(family_id) + model.create_value_2d() + assert model.value_2d is not None # type guard + expected = model.value_2d.copy() + + restored = pickle.loads(pickle.dumps(model)) + restored.create_value_2d() + np.testing.assert_array_equal(restored.value_2d, expected) + + +# +@pytest.mark.parametrize("family_id", _FAMILIES) +def test_deepcopy_value_2d_parity(family_id): + """``copy.deepcopy`` non-static Model reproduces ``value_2d`` byte-for-byte.""" + + model = _build_truth_model(family_id) + model.create_value_2d() + assert model.value_2d is not None # type guard + expected = model.value_2d.copy() + + clone = copy.deepcopy(model) + clone.create_value_2d() + np.testing.assert_array_equal(clone.value_2d, expected) From 218a8bd80e00bd8b5e30afcc33bc06bd2daeeb47 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Wed, 29 Apr 2026 13:12:34 -0700 Subject: [PATCH 3/4] matrix-parametrize update_value and par-ordering invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both invariants share the pickle work's topology-sensitive surface: they walk the whole Par graph along the parameter_names flattening, so single_glp coverage doesn't surface ordering / dropped-par / cache-invalidation bugs that appear once Components grow Dynamics or Profile sub-Models. tests/roundtrip/test_update_value.py — 4 cases × F3/F6/F8/F12: - update_value(current, 'all') is a no-op on the value list - update_value(current, par_select=[names]) matches the 'all' no-op - no-op update_value preserves value_2d byte-for-byte (stale-cache guard) - perturbing one vary non-expr par propagates to value_2d (dropped-par guard) tests/roundtrip/test_par_ordering.py — 2 cases × F3/F6/F8/F12: - list(model.lmfit_pars) == model.parameter_names (positional contract shared by update_value, par_extract, SbS seed templates, GIR theta_indices) - parameter_names is deterministic across rebuild All pass on current main; these are regression guards, not bug discoveries. --- tests/roundtrip/test_par_ordering.py | 64 ++++++++++++++ tests/roundtrip/test_update_value.py | 121 +++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 tests/roundtrip/test_par_ordering.py create mode 100644 tests/roundtrip/test_update_value.py diff --git a/tests/roundtrip/test_par_ordering.py b/tests/roundtrip/test_par_ordering.py new file mode 100644 index 0000000..8dcedb5 --- /dev/null +++ b/tests/roundtrip/test_par_ordering.py @@ -0,0 +1,64 @@ +"""``parameter_names`` / ``lmfit_pars`` ordering invariants across topology. + +``parameter_names`` is the positional contract that ``update_value``, +``ulmfit.par_extract``, SbS seed templates, and the GIR ``theta_indices`` +all rely on. Topology matters because the flattening crosses +Components × ``t_model`` × ``p_model`` and the order is non-obvious for +F8 (profile-internal dynamics) and F12 (mixed expressions). These tests +pin two invariants single-static-model coverage can't: + +- ``list(model.lmfit_pars)`` iterates in ``model.parameter_names`` order. +- Rebuilding the same family produces an identical ``parameter_names`` + list (deterministic flattening). +""" + +from __future__ import annotations + +import pytest +from _utils import make_project + +from .families import FAMILIES + +pytestmark = pytest.mark.slow + + +_FAMILIES = ("F3", "F6", "F8", "F12") + + +# +def _build_truth_model(family_id: str, suffix: str): + """Build a truth file for ``family_id`` and return its active model.""" + + family = FAMILIES[family_id] + project = make_project( + name=f"ord_{family_id}_{suffix}", spec_fun_str="fit_model_mcp" + ) + truth_file = family.build_truth(project, variant="default") + assert truth_file.model_active is not None # type guard + return truth_file.model_active + + +# +@pytest.mark.parametrize("family_id", _FAMILIES) +def test_lmfit_pars_iter_matches_parameter_names(family_id): + """``list(model.lmfit_pars)`` must equal ``model.parameter_names``. + + ``update_value(par_select='all')`` enumerates ``lmfit_pars`` and + indexes positionally into the input list; if the iteration order + drifts from ``parameter_names``, every consumer that built its + input via ``parameter_names`` (par_extract, GIR theta_indices, SbS + seeds) silently writes to the wrong slot. + """ + + model = _build_truth_model(family_id, "single") + assert list(model.lmfit_pars) == list(model.parameter_names) + + +# +@pytest.mark.parametrize("family_id", _FAMILIES) +def test_parameter_names_deterministic_across_rebuild(family_id): + """Rebuilding a family twice produces an identical ``parameter_names``.""" + + model_a = _build_truth_model(family_id, "a") + model_b = _build_truth_model(family_id, "b") + assert list(model_a.parameter_names) == list(model_b.parameter_names) diff --git a/tests/roundtrip/test_update_value.py b/tests/roundtrip/test_update_value.py new file mode 100644 index 0000000..f06a4d4 --- /dev/null +++ b/tests/roundtrip/test_update_value.py @@ -0,0 +1,121 @@ +"""``Model.update_value`` matrix coverage for non-static families. + +Mirrors ``test_pickle.py``. ``update_value`` is the dual of evaluation: +it writes positional values back into ``lmfit_pars`` along the same +flattening that ``parameter_names`` defines, and runs every fit +iteration plus every SbS slice reset. The single-static-case coverage +in ``tests/test_mcp_library.py`` and ``tests/test_gir_integration.py`` +won't surface ordering / dropped-par / cache-invalidation bugs that +only appear once Components grow Dynamics or Profile sub-Models. +""" + +from __future__ import annotations + +import numpy as np +import pytest +from _utils import make_project + +from .families import FAMILIES + +pytestmark = pytest.mark.slow + + +_FAMILIES = ("F3", "F6", "F8", "F12") + + +# +def _build_truth_model(family_id: str): + """Build a truth file for ``family_id`` and return its active model.""" + + family = FAMILIES[family_id] + project = make_project(name=f"upd_{family_id}", spec_fun_str="fit_model_mcp") + truth_file = family.build_truth(project, variant="default") + assert truth_file.model_active is not None # type guard + return truth_file.model_active + + +# +def _extract_values(model) -> list[float]: + """Return current ``lmfit_pars`` values in ``parameter_names`` order.""" + + return [model.lmfit_pars[n].value for n in model.parameter_names] + + +# +@pytest.mark.parametrize("family_id", _FAMILIES) +def test_update_value_all_identity(family_id): + """``update_value(current, 'all')`` is a no-op on the value list.""" + + model = _build_truth_model(family_id) + expected = _extract_values(model) + + model.update_value(expected, par_select="all") + actual = _extract_values(model) + + assert actual == expected + + +# +@pytest.mark.parametrize("family_id", _FAMILIES) +def test_update_value_list_identity(family_id): + """``par_select=[names]`` no-op matches ``par_select='all'`` no-op.""" + + model = _build_truth_model(family_id) + expected = _extract_values(model) + + model.update_value(expected, par_select=list(model.parameter_names)) + actual = _extract_values(model) + + assert actual == expected + + +# +@pytest.mark.parametrize("family_id", _FAMILIES) +def test_update_value_no_op_preserves_value_2d(family_id): + """Re-writing the same values must not perturb ``value_2d``. + + Catches stale-cache bugs in the evaluator: ``update_value`` mutates + ``lmfit_pars`` but doesn't itself trigger evaluation, so any cached + component state must be invalidated correctly on next eval. + """ + + model = _build_truth_model(family_id) + model.create_value_2d() + assert model.value_2d is not None # type guard + expected = model.value_2d.copy() + + model.update_value(_extract_values(model), par_select="all") + model.create_value_2d() + np.testing.assert_array_equal(model.value_2d, expected) + + +# +@pytest.mark.parametrize("family_id", _FAMILIES) +def test_update_value_perturbation_propagates(family_id): + """Perturbing one vary non-expr par must change ``value_2d``. + + Catches "``update_value`` silently dropped a par" bugs: if the + targeted slot wasn't actually written, the evaluator would return + the baseline output and the test would fail. + """ + + model = _build_truth_model(family_id) + model.create_value_2d() + assert model.value_2d is not None # type guard + baseline = model.value_2d.copy() + + target_idx = None + for i, name in enumerate(model.parameter_names): + lp = model.lmfit_pars[name] + if lp.expr is None and lp.vary: + target_idx = i + break + assert target_idx is not None, f"{family_id}: no vary non-expr par to perturb" + + new_values = _extract_values(model) + old = new_values[target_idx] + new_values[target_idx] = old * 1.01 if old != 0.0 else 0.01 + + model.update_value(new_values, par_select="all") + model.create_value_2d() + assert not np.array_equal(model.value_2d, baseline) From 36757ef4dc65f176145dd1119334ab93569c71c5 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Wed, 29 Apr 2026 13:21:17 -0700 Subject: [PATCH 4/4] clean up todo md --- TODO.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/TODO.md b/TODO.md index 9aa4669..12b6b56 100644 --- a/TODO.md +++ b/TODO.md @@ -12,8 +12,6 @@ Note: `fitlib.py` hardcodes `__lnsigma` value/min/max for MCMC sampling — make - [ ] **Project-level fit backend**: `Project.fit_2d()` already supports `Project`/`File`/`Static` vary levels, but it currently evaluates through `fit_project_mcp()` and `Model.create_value_2d()` rather than the GIR scheduler/evaluator path. Decide whether to lower the multi-file residual to GIR or explicitly prefer project-managed per-file loops when we want maximum graph-IR speedups. - [ ] **JAX backend / Jacobian follow-on**: if we revisit a JAX evaluator, analytic Jacobians, or optimizer replacement, use [docs/design/jax-planning.md](docs/design/jax-planning.md) as the roadmap for scope, sequencing, and open technical constraints. - [ ] **MCMC multiprocessing context**: `lmfit.emcee(workers=N)` currently inherits Python's default multiprocessing start method, which triggers a Python 3.12 `fork()` deprecation warning in multithreaded test runs. Investigate whether we can supply a `spawn`-backed worker pool or otherwise steer emcee/lmfit away from raw `fork`. -- [ ] **Evaluation order correctness**: component eval order depends on coincidental list position; make it explicit. One option: build a directed acyclic graph (DAG) at model construction and topological-sort. -- [ ] **Freeze non-varying pars**: pars without time-dependence (or profile dependence) are re-evaluated at every aux-axis point; could evaluate once and reuse. ## Testing