From 59cb670e02de21bcfebbb157b4d011d2e8127916 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Tue, 14 Jul 2026 10:02:04 -0700 Subject: [PATCH 01/29] docs: fix headless-fit guidance in llms.txt and roadmap numbering in TODO - llms.txt: fits are governed by Project.show_output / auto_export, not a save_img kwarg (passing save_img to a fit_* method raises TypeError); keep show_plot / save_img as the plot- and setup-helper convention. - TODO.md: correct the road-to-v1 rationale to "contract surfaces (1-4) before the website (5)" after the stability-policy item was dropped, and note that llms.txt / AGENTS.md must be reconciled against the API tiers. --- TODO.md | 4 ++-- llms.txt | 12 +++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index c78de8c..ba261c7 100644 --- a/TODO.md +++ b/TODO.md @@ -35,11 +35,11 @@ ## Road to v1.0.0 & first adopters -Rationale (2026-07-06): lock-in comes from schemas and public names, not from users — so stabilize the contract surfaces (1–5) before the website (6) and outreach (tracked outside the repo: `~/Desktop/trspecfit_outreach.md`). Early TR-XPS/TR-XAS adopters are then a feature, not a risk: they supply the usage signal that feature decisions currently lack, and their needs are a strict subset of the general "parameterized lineshapes vs. control axis" problem, so serving them does not distort the architecture. +Rationale (2026-07-06): lock-in comes from schemas and public names, not from users — so stabilize the contract surfaces (1–4) before the website (5) and outreach (tracked outside the repo: `~/Desktop/trspecfit_outreach.md`). Early TR-XPS/TR-XAS adopters are then a feature, not a risk: they supply the usage signal that feature decisions currently lack, and their needs are a strict subset of the general "parameterized lineshapes vs. control axis" problem, so serving them does not distort the architecture. 1. [ ] **Version the model YAML schema**: model YAML files are the artifact user groups accumulate, and currently the only unversioned contract — the fit archive already carries `SCHEMA_VERSION` ([fit_io.py](src/trspecfit/utils/fit_io.py) ~L46) and refuses mismatched reads/appends. Add an optional top-level format-version key in `utils/parsing.py` (absent = current version) so future syntax changes can warn or migrate instead of silently misparsing old model files. 2. [ ] **Curate the public API surface**: audit user-facing classes (`File`, `Project`, `Simulator`, `Model`, etc.) and decide which methods/attributes should be discoverable in notebooks and docs. Add curated `__dir__()` output for autocomplete, keep `__all__`/API docs aligned, and gradually rename or deprecate internal helper methods that should not look like primary user workflows. This should improve both human notebook ergonomics and AI/LLM efficiency by making the intended workflow surface smaller, clearer, and easier to infer. Prerequisite for outreach: once external groups have notebooks, every public-looking name is frozen in practice. -3. [ ] **Document API tiers**: add a short guide that separates stable user API (`Project`, `File`, `Simulator`, `PlotConfig`), advanced public API (`mcp.Model`, `Component`, `Par`, `ParameterSweep`, `MC`), and internal implementation modules (`graph_ir`, `eval_1d`, `eval_2d`, low-level parsing/HDF5 helpers). Use this as the source of truth for docs, tests, examples, and AI-agent guidance. Also settles which surfaces get the post-1.0 deprecation commitment — `docs/stability.md` (2026-07-12) commits the user API and explicitly defers the advanced tier to this guide. +3. [ ] **Document API tiers**: add a short guide that separates stable user API (`Project`, `File`, `Simulator`, `PlotConfig`), advanced public API (`mcp.Model`, `Component`, `Par`, `ParameterSweep`, `MC`), and internal implementation modules (`graph_ir`, `eval_1d`, `eval_2d`, low-level parsing/HDF5 helpers). Use this as the source of truth for docs, tests, examples, and AI-agent guidance. Also settles which surfaces get the post-1.0 deprecation commitment — `docs/stability.md` (2026-07-12) commits the user API and explicitly defers the advanced tier to this guide. Reconcile the agent-facing orientation docs (`llms.txt`, `AGENTS.md`) against the finalized tiers in the same pass — they restate API/knob details (e.g. the headless `show_output`/`auto_export` guidance) that silently drift from the code otherwise. 4. [ ] **Remove legacy/backwards-compat code**: audit codebase for legacy fallbacks and backwards-compatibility shims and consider removing before v1.0.0. Full removal depends on the plotting/saving disentanglement item (Performance & architecture) — the `_save_*_fit_legacy` impls are still the live SbS/2D plotting path. Known shims slated for removal: - `File.save_sbs_fit` / `File.save_2d_fit` (deprecated wrappers — replace internal callers and drop the public methods plus their `_save_*_fit_legacy` impls; users should migrate to `File.export_fit(fit_type=...)`). - Legacy pre-rename format branch in [sweep.py](src/trspecfit/utils/sweep.py) ~L624 and the legacy `save_img` int-mapping helper in [plot.py](src/trspecfit/utils/plot.py) ~L813. diff --git a/llms.txt b/llms.txt index e055cb3..93cb37d 100644 --- a/llms.txt +++ b/llms.txt @@ -90,9 +90,15 @@ Component types (see the API reference for signatures): ## Pitfalls for scripted / headless use -- Fit and plot methods display figures by default. Pass `show_plot=False` - where available, or `save_img=-1` (save without display; `0` = display, - `1` = both) when running outside a notebook. +- Fits display figures and write CSV/PNG side effects by default. To run + headless, set the Project knobs `project.show_output = 0` (suppress + display) and `project.auto_export = False` (suppress automatic export). + These govern the `fit_baseline` / `fit_spectrum` / `fit_slice_by_slice` / + `fit_2d` calls; those methods do not accept `save_img` (passing it raises + `TypeError`). +- The low-level plot and setup helpers instead take their own arguments: + pass `show_plot=False` where available, or `save_img=-1` (save without + display; `0` = display, `1` = both) when running outside a notebook. - Time axes for multi-cycle dynamics should not sample exactly on subcycle boundaries — assignment there flips with floating-point representation, and `trspecfit` warns when it detects this. From ab58128ae817273aa4f7d7b30f68c2815960b8c8 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Tue, 14 Jul 2026 10:37:36 -0700 Subject: [PATCH 02/29] test: default make_project to auto_export=False for xdist isolation Under `-n auto --dist worksteal`, tests writing fit-completion CSV/PNG side effects into the shared `tests_fits/` tree could race on colliding `File.name`/`model_name` output paths. Default the shared helper to auto_export=False so a test writes to disk only when export is its actual subject; those tests opt in with auto_export=True and redirect path_results to tmp_path. test_default_is_true now asserts the production default on a bare Project. Full default (parallel) suite green; tests_fits/ no longer created. --- tests/_utils.py | 11 ++++++++--- tests/test_auto_export.py | 8 ++++---- tests/test_export_fits_parity.py | 3 ++- tests/test_project_fit.py | 7 +++++-- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/tests/_utils.py b/tests/_utils.py index 7a076bd..e86dab1 100644 --- a/tests/_utils.py +++ b/tests/_utils.py @@ -23,14 +23,19 @@ def make_project( name: str = "test", spec_fun_str: str = "fit_model_gir", show_output: int = 0, - auto_export: bool = True, + auto_export: bool = False, ): """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. - ``auto_export=True`` matches production default; flip to ``False`` to - verify suppression of fit-completion CSV/PNG side effects. + + ``auto_export`` defaults to ``False`` here (the production default is + ``True``) so tests do not write fit-completion CSV/PNG side effects into + the shared ``tests_fits/`` tree by default -- concurrent xdist workers + would otherwise race on colliding output paths. Pass ``auto_export=True`` + only in tests whose subject is the save/export behavior itself, and + redirect ``project.path_results`` to a ``tmp_path`` there. """ project = Project(path="tests", name=name) diff --git a/tests/test_auto_export.py b/tests/test_auto_export.py index f05b0e0..18e2fcc 100644 --- a/tests/test_auto_export.py +++ b/tests/test_auto_export.py @@ -17,7 +17,7 @@ import pytest from _utils import make_project, simulate_noisy -from trspecfit import File, fitlib +from trspecfit import File, Project, fitlib from trspecfit.utils.lmfit import MC @@ -79,9 +79,9 @@ class TestProjectDefault: # def test_default_is_true(self): - project = make_project(name="default") - # _utils.make_project respects its kwarg default, which mirrors the - # production default declared in Project._set_defaults. + # make_project defaults to auto_export=False for test isolation, so + # assert the *production* default directly on a bare Project. + project = Project(path="tests", name="default") assert project.auto_export is True # diff --git a/tests/test_export_fits_parity.py b/tests/test_export_fits_parity.py index eeb8872..52aa085 100644 --- a/tests/test_export_fits_parity.py +++ b/tests/test_export_fits_parity.py @@ -75,7 +75,8 @@ def _make_parity_fit_file(*, name: str, tmp_path: Path, spec_fun_str: str): """ data = _truth_2d_data() - project = make_project(name=name, spec_fun_str=spec_fun_str) + # export parity is this test's subject, so opt back into auto-export + project = make_project(name=name, spec_fun_str=spec_fun_str, auto_export=True) project.path_results = tmp_path / "legacy" file = File( parent_project=project, diff --git a/tests/test_project_fit.py b/tests/test_project_fit.py index 793083e..9404edf 100644 --- a/tests/test_project_fit.py +++ b/tests/test_project_fit.py @@ -599,7 +599,7 @@ def test_fit_history_populated_after_project_fit(self): # @pytest.mark.slow - def test_num_fmt_and_delim_propagate_to_csv_outputs(self): + def test_num_fmt_and_delim_propagate_to_csv_outputs(self, tmp_path): """Custom num_fmt/delim on the Project flow into fit-CSV writes. Covers two pandas ``to_csv`` paths exercised by fit_baseline: @@ -607,7 +607,10 @@ def test_num_fmt_and_delim_propagate_to_csv_outputs(self): - save_baseline_fit -> ``fit_1d.csv`` """ - project = make_project(name="num_fmt_test") + # the exported CSVs are this test's subject, so opt into auto-export + # and redirect the output tree into tmp_path for xdist isolation + project = make_project(name="num_fmt_test", auto_export=True) + project.path_results = tmp_path project.num_fmt = "%.3f" project.delim = ";" From 0145638bad2806d76ab2efdfbf084eea375363d4 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Tue, 14 Jul 2026 11:00:41 -0700 Subject: [PATCH 03/29] fix: guard the MCMC spawn pool against notebook __main__ re-execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workers>1 MCMC pool started spawn workers directly, so a `%run notebook.ipynb` session — where __main__.__file__ is the notebook JSON — would crash every worker as spawn tries to runpy the JSON. The SbS executor already guards this via sanitized_spawn_main; the MCMC pool did not. Relocate sanitized_spawn_main from utils/sbs.py to a neutral utils/spawn.py (fitlib importing sbs would be circular, and the helper is generic, not SbS-specific) and wrap the MCMC pool with it, matching SbS. Bump version to 0.13.1. --- pyproject.toml | 2 +- src/trspecfit/fitlib.py | 9 +++++++- src/trspecfit/trspecfit.py | 3 ++- src/trspecfit/utils/sbs.py | 32 -------------------------- src/trspecfit/utils/spawn.py | 44 ++++++++++++++++++++++++++++++++++++ 5 files changed, 55 insertions(+), 35 deletions(-) create mode 100644 src/trspecfit/utils/spawn.py diff --git a/pyproject.toml b/pyproject.toml index 369a2dc..d1cc93b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trspecfit" -version = "0.13.0" +version = "0.13.1" authors = [ {name = "Johannes Mahl", email = "infinitymonkeyatwork@gmail.com"}, ] diff --git a/src/trspecfit/fitlib.py b/src/trspecfit/fitlib.py index 6024668..117c5ba 100644 --- a/src/trspecfit/fitlib.py +++ b/src/trspecfit/fitlib.py @@ -40,6 +40,7 @@ from trspecfit.config.plot import PlotConfig from trspecfit.utils import lmfit as ulmfit from trspecfit.utils import plot as uplt +from trspecfit.utils import spawn as uspawn # Define a type alias for file paths type PathLike = str | pathlib.Path @@ -917,8 +918,14 @@ def _method_kws(method: str) -> dict[str, Any]: # Linux < 3.14 — deadlock-prone in multithreaded processes. # Supply a spawn-backed pool instead (lmfit hands any object # with .map to emcee), matching the slice-by-slice executor. + # sanitized_spawn_main keeps the spawn workers from re-running a + # non-.py __main__ (e.g. a notebook executed via %run), the same + # guard the SbS executor uses. ctx = multiprocessing.get_context("spawn") - with ctx.Pool(mc_settings.workers) as pool: + with ( + uspawn.sanitized_spawn_main(), + ctx.Pool(mc_settings.workers) as pool, + ): # lmfit annotates workers as int but accepts pool-likes emcee_fin = mini.emcee(workers=cast("int", pool), **emcee_kwargs) else: diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index 17f3013..41dc2fd 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -90,6 +90,7 @@ from trspecfit.utils import parsing as uparsing from trspecfit.utils import plot as uplt from trspecfit.utils import sbs as usbs +from trspecfit.utils import spawn as uspawn PathLike = str | pathlib.Path ModelRef = str | int | list[str] @@ -3363,7 +3364,7 @@ def _slice_path(s_i: int) -> pathlib.Path: ctx = multiprocessing.get_context("spawn") by_id: dict[int, list[Any]] = {} with ( - usbs.sanitized_spawn_main(), + uspawn.sanitized_spawn_main(), concurrent.futures.ProcessPoolExecutor( max_workers=n_workers, mp_context=ctx, diff --git a/src/trspecfit/utils/sbs.py b/src/trspecfit/utils/sbs.py index 92efa1d..1bf2e85 100644 --- a/src/trspecfit/utils/sbs.py +++ b/src/trspecfit/utils/sbs.py @@ -12,9 +12,7 @@ from __future__ import annotations -import contextlib import pathlib -import sys from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Literal @@ -107,36 +105,6 @@ def prepare_sbs_model_for_slice( return initial_guess -# -@contextlib.contextmanager -def sanitized_spawn_main(): - """Hide a non-importable ``__main__.__file__`` from spawn workers. - - When a notebook is executed via IPython's ``%run example.ipynb``, - ``__main__.__file__`` points at the notebook JSON; multiprocessing's - spawn ``prepare()`` would re-run that path via ``runpy`` in every - worker and crash (the JSON is not Python). SbS workers never need - ``__main__`` content — everything they use is installed by - ``sbs_worker_init`` from trspecfit modules — so drop the attribute - for the pool's lifetime and restore it afterwards. A regular - ``python script.py`` main keeps its ``.py`` ``__file__`` untouched. - """ - - main_mod = sys.modules.get("__main__") - if main_mod is None: - yield - return - main_file = getattr(main_mod, "__file__", None) - sanitize = main_file is not None and not str(main_file).endswith(".py") - if sanitize: - del main_mod.__file__ - try: - yield - finally: - if sanitize: - main_mod.__file__ = main_file - - # def sbs_worker_init( model: mcp.Model, diff --git a/src/trspecfit/utils/spawn.py b/src/trspecfit/utils/spawn.py new file mode 100644 index 0000000..49bd549 --- /dev/null +++ b/src/trspecfit/utils/spawn.py @@ -0,0 +1,44 @@ +""" +Shared multiprocessing helpers for spawn-backed worker pools. + +Both the Slice-by-Slice executor (``File.fit_slice_by_slice``) and the +MCMC worker pool (``fitlib.fit_wrapper``) start workers with the ``spawn`` +method and need the same ``__main__`` protection, so the helper lives in +this neutral module rather than in either caller. +""" + +from __future__ import annotations + +import contextlib +import sys + + +# +@contextlib.contextmanager +def sanitized_spawn_main(): + """Hide a non-importable ``__main__.__file__`` from spawn workers. + + When a notebook is executed via IPython's ``%run example.ipynb``, + ``__main__.__file__`` points at the notebook JSON; multiprocessing's + spawn ``prepare()`` would re-run that path via ``runpy`` in every + worker and crash (the JSON is not Python). Spawn workers never need + ``__main__`` content here — everything they use is installed from + trspecfit modules (SbS via ``sbs_worker_init``, MCMC via the pickled + objective) — so drop the attribute for the pool's lifetime and restore + it afterwards. A regular ``python script.py`` main keeps its ``.py`` + ``__file__`` untouched. + """ + + main_mod = sys.modules.get("__main__") + if main_mod is None: + yield + return + main_file = getattr(main_mod, "__file__", None) + sanitize = main_file is not None and not str(main_file).endswith(".py") + if sanitize: + del main_mod.__file__ + try: + yield + finally: + if sanitize: + main_mod.__file__ = main_file From 3ee45877a5d29d050e3043e39fe74ce382891d08 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Tue, 14 Jul 2026 15:23:56 -0700 Subject: [PATCH 04/29] docs: plan the results-ownership and plotting-disentanglement work Populate PLAN.md with the six-phase design (decisions settled 2026-07-14: full auto-export layout break, FitResults-homed plot API with File sugar, slot-backed accessors). Tag both TODO items [ACTIVE] and record the Simulator.sigma_data staleness sibling case in the mutation-guard item. --- PLAN.md | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- TODO.md | 6 +-- 2 files changed, 133 insertions(+), 11 deletions(-) diff --git a/PLAN.md b/PLAN.md index 0d24ddf..b62d5aa 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,11 +1,133 @@ -# Active Plan +# Active Plan: results ownership boundary + plotting/saving disentanglement -No active multi-step feature in progress. +Branch: `model-vs-fitresult`. Covers two TODO items (both `[ACTIVE]`): +"Define the results-data ownership boundary" and "Disentangle plotting from +saving/conversion in the fit pipeline". Scoping session 2026-07-13 and design +decisions 2026-07-14. -- Backlog lives in [`TODO.md`](TODO.md). -- Shipped-feature design notes live in [`docs/design/`](docs/design/) (and - archived deep-dives in [`docs/design/archive/`](docs/design/archive/)). +## Decisions (settled with user, 2026-07-14) -Populate this file when starting the next multi-step feature; clear it on -completion per `CLAUDE.md` (archive the design into `docs/design/` if the -decisions are durable, otherwise let the changelog stand as the record). +1. **Full layout break**: auto-export for SbS/2D routes through the slot-based + exporter (`fit_io._export_slot`); the legacy flat layout is dropped. + `_save_sbs_fit_legacy`, `_save_2d_fit_legacy`, and the deprecated public + `save_sbs_fit` / `save_2d_fit` are deleted in this branch. Partially + unblocks v1.0.0 checklist item 4 (legacy-shim removal). +2. **Plot API home**: explicit plot methods live on `FitResults` (reading + slots), with thin `File.plot_*` sugar — same pattern as `compare_models`. +3. **Data source**: relocated accessors read **persisted slots** (latest + matching slot in `_fit_history`). Live `model.result[...]` becomes an + internal detail. Requires persisting `correl` + `acceptance_fraction` + (Phase 1). Raw `result[1..4]` access and the deeper unified-results-object + question stay deferred (per TODO). + +## Ownership boundary (the contract) + +- **`Model`/`File` (live layer)**: own inputs and fit execution. `model.result` + is a transient internal of the fit run; nothing user-facing reads it. +- **`SavedFitSlot`**: the single authoritative record of a completed fit — + everything a user can ask about a fit must be in (or derivable from) the slot. +- **`FitResults`**: the single read/query/plot surface over slots (live history + and loaded archives alike). `File.get_*` / `File.plot_*` / `File.compare_models` + are thin sugar delegating to `project.results` filtered to that file. + +## Phase 1 — Persist `correl` + `acceptance_fraction` (schema v3) + +- [ ] Add `correl: pd.DataFrame | None` field to `SavedFitSlot` (correlation + matrix over varied params, mirroring `get_correlations()` output). + Stored like `conf_ci` via `_encode_dataframe`. Populate in + `_build_slot` / per-fit-type extractors from `par_fin.params[*].correl`. + (TODO wording said "into the `params` payload"; a sibling matrix dataset + mirrors the accessor's return shape and the `conf_ci` precedent — a + per-row JSON column in the long params table would be strictly worse to + read back.) +- [ ] Add `acceptance_fraction` (per-walker float array) to the slot `mcmc` + payload (`_mcmc_payload`, `_write_mcmc_group`, `_read_mcmc_group`). +- [ ] Bump `SCHEMA_VERSION` "2" → "3". Reader accepts v2 archives (new fields + → `None`); append across versions stays refused (existing policy). +- [ ] Save/load round-trip tests for both fields (baseline, 2d, mcmc-on/off, + v2-archive read tolerance). + +## Phase 2 — Relocate accessors to `FitResults`, slot-backed + +- [ ] `FitResults.get_fit_results / get_correlations / get_conf_intervals / + get_mcmc(file=..., model=..., fit_type=...)` reading the latest matching + slot (define "latest" = last in history order; document). +- [ ] `get_mcmc` builds `ulmfit.MCMCResult` from the slot payload + (flatchain, ci table, lnsigma, acceptance_fraction). +- [ ] `File.get_*` become thin delegates to `self.p.results` (signatures + unchanged → notebook 12 keeps working). Delete `File._result_model`. +- [ ] SbS gains accessor coverage for free (slots exist; `_result_model` + used to raise for sbs). +- [ ] Re-run / spot-check notebook 12 (`12_uncertainty_mcmc`) — its calls are + the compatibility contract. + +## Phase 3 — Purify the conversion layer (fitlib) + +- [ ] `results_to_df`: strip CSV writing and plotting → pure + results-list → DataFrame conversion (drop `save_df`/`save_path`/plot + args). Its only caller today is `_save_sbs_fit_legacy` (dies in Phase 4). +- [ ] `results_to_fit_2d`: strip `save_2d` CSV writing → pure reconstruction. + (Slot already stores the `fit` array; exporter doesn't need this.) +- [ ] `plt_fit_res_1d` / `plt_fit_res_2d` / `plt_fit_res_pars` remain the + pure renderers (already flag-driven via `_save_img_flag`). + +## Phase 4 — Route auto-export through slots; delete legacy path + +- [ ] `fit_slice_by_slice` / `File.fit_2d` / `Project.fit_2d`: replace the + `_save_*_fit_legacy(save_files=...)` calls with the baseline template — + display (`show_output>=1`) via direct renderer call on slot data with + `_save_img_flag(save=..., show=...)`; export (`auto_export`) via the + slot exporter. Keep the skip-entirely guard when neither is set + (hot-path invariant pinned by `TestPlotHelperSkipped`). +- [ ] Per-slice PNGs inside the SbS loop (trspecfit.py ~3345) stay gated by + `auto_export` (unchanged behavior, new destination layout). +- [ ] Delete `_save_sbs_fit_legacy`, `_save_2d_fit_legacy`, `save_sbs_fit`, + `save_2d_fit`. **Grep whole repo** (notebooks, YAML, docs, llms.txt, + AGENTS.md) for callers/mentions. +- [ ] Update guardrail tests (`TestVerboseDisplayWithoutExport`, + `TestPlotHelperSkipped`, auto-export write/no-write classes) to the new + call targets; semantics (display-without-write, silent-skip) unchanged. +- [ ] Changelog entry flagging the auto-export layout change (breaking). + +## Phase 5 — Explicit plotting API + +- [ ] **Prerequisite**: `FitResults` must retain slot → axes context. Today it + flattens `SavedProject.files[*].slots` and discards `SavedFile` + (why `plot_residuals` plots vs. array index). Keep a per-slot reference + to its `SavedFile` (or a fingerprint-keyed axes lookup); `Project.results` + builds the equivalent from live `File` axes. `_slot_axes` + (fit_io.py ~1707) already implements the slicing. +- [ ] `FitResults.plot_fit(file=..., model=..., fit_type=...)` — 1D/2D + observed/fit/residual from slot, real energy/time axes, delegating to + the fitlib renderers; `show_plot`/save args via `_save_img_flag`. +- [ ] `FitResults.plot_param_evolution(...)` — SbS per-parameter-vs-time + (successor of the `results_to_df` → `plt_fit_res_pars` chain; varied + params by default). +- [ ] Upgrade `plot_residuals` to use real axes now that they're available. +- [ ] `File.plot_fit` / `File.plot_param_evolution` sugar. +- [ ] PlotConfig: renderers already accept `config`; thread the owning file's + `plot_config` through (keep `figsize`-style overrides minimal). + +## Phase 6 — Docs, tests, release hygiene + +- [ ] Update `docs/design/repo_architecture.md` (ownership contract), + `docs/design/ui.md` cross-refs, export-related docstrings. +- [ ] Reconcile `llms.txt` / `AGENTS.md` guidance (auto-export layout, + accessor story). +- [ ] Notebooks: 12 (accessors), 11/20 (saving/export) — check for legacy + layout or `save_*_fit` mentions. +- [ ] TODO.md: drop both items on completion, remove `[ACTIVE]`; note partial + progress on v1.0.0 item 4 (remaining shims: sweep.py legacy branch, + plot.py int-mapping helper). +- [ ] Version bump: breaking layout change + schema bump → `0.14.0`. +- [ ] Archive decision: this file likely warrants `docs/design/archive/` + (ownership contract is durable) — ask at completion per protocol. + +## Open implementation points (resolve while working, not user-blocking) + +- Exact "latest slot" tie-break when the same file/model/fit_type was fit + multiple times in one session (history order; consider a `history_key` sort). +- Whether `Project.fit_2d`'s forced `show_output=0` block survives the Phase 4 + rewrite or collapses into the uniform template. +- `MCMCResult` construction from slot: ci table column fidelity after HDF5 + round-trip (dtype/index). diff --git a/TODO.md b/TODO.md index ba261c7..d10dfdf 100644 --- a/TODO.md +++ b/TODO.md @@ -15,12 +15,12 @@ - vmap-batched slice-by-slice solver (the one workload where lmfit overhead plausibly dominates; would be the Phase E pilot) — see [docs/design/ui.md](docs/design/ui.md). - `vmap`-batch homogeneous file series in the fused project fit (unrolled per-file fusion shipped in v0.13.0) — see [docs/design/project-level-fits.md](docs/design/project-level-fits.md). - `fit_model_compare`-style runtime JAX parity mode, or a cheaper one-shot pre-fit parity check on the JAX path. -- [ ] **Define the results-data ownership boundary**: take a look at what should live as class attributes on the `trspecfit`/`mcp` Python classes (`File`/`Model`) versus inside the `FitResults` class. Where should the line be — should fit outputs (params, `conf_ci`, MCMC payload, correlations, acceptance fraction, diagnostics) all be unified under `FitResults`, or stay split between live `model.result[...]` and persisted slots? Then update all callers and the `get_*` accessor methods to match the chosen boundary. Sub-items: +- [ ] `[ACTIVE]` **Define the results-data ownership boundary**: take a look at what should live as class attributes on the `trspecfit`/`mcp` Python classes (`File`/`Model`) versus inside the `FitResults` class. Where should the line be — should fit outputs (params, `conf_ci`, MCMC payload, correlations, acceptance fraction, diagnostics) all be unified under `FitResults`, or stay split between live `model.result[...]` and persisted slots? Then update all callers and the `get_*` accessor methods to match the chosen boundary. Sub-items: - **Persist `correl` and `acceptance_fraction` into the slots**: 2026-06 added live-only accessors (`get_correlations`, `get_conf_intervals`, `get_mcmc`) reading `model.result` as a stopgap for notebook 12, so these are NOT yet saved. Add per-parameter correlations to the slot `params` payload and `acceptance_fraction` to the slot `mcmc` payload, with `.fit.h5` read/write support and save/load round-trip tests, so they survive persistence like the rest of the slot. - **Relocate the live accessors to `FitResults`**: 2026-06 added `File.get_correlations`, `File.get_conf_intervals`, `File.get_mcmc` (and the private `File._result_model` resolver) reading `model.result[...]` directly. These conceptually belong on `FitResults` (like `compare_models`, which already lives there with `File.compare_models` as thin sugar). The existing `File.get_fit_results` is in the same boat. Decide whether all of these should move into `FitResults` (with thin `File.*` sugar that delegates), and whether they read live `model.result` or persisted slots — then move them and update callers (notebook 12 reads them). - The raw list-index access (`result[1..4]`) and the deeper unified-results-object question are deferred to this item. -- [ ] **Decide how to guard/warn against in-place mutation of user-facing arrays**: internal machinery assumes `File.data`/`energy`/`time` and fit outputs are stable once set — e.g. `SavedFitSlot` stores `params`/`observed`/`fit`/`selection` by reference (`frozen=True` blocks reassignment, not in-place mutation; 2026-07 code review, check 1), and file fingerprints / `observed_sha256` are computed once at slot construction. A user mutating `file.data` in place instead of re-instantiating would desynchronize slots, fit limits, and cached evaluations in ways no single defensive copy fixes — so slot-level copies were considered and declined (2026-07-10) as papering over one symptom. Decide on a systemic stance instead: read-only views (`setflags(write=False)`) on public arrays, copy-on-set in setters, a documented ownership contract, and/or re-hash validation at save time. -- [ ] **Disentangle plotting from saving/conversion in the fit pipeline**: figure rendering is currently entangled with data conversion and file IO. `fitlib.results_to_df` ([fitlib.py](src/trspecfit/fitlib.py) ~L1015) is the worst offender — it converts results → DataFrame, writes `fit_pars.csv`, *and* plots the per-parameter curves, including per-column show/save logic based on which parameters varied; its output feeds `results_to_fit_2d` which feeds `plt_fit_res_2d`, so the SbS chain must be restructured as a whole. `File._save_2d_fit_legacy` / `_save_sbs_fit_legacy` (CSV writers) also render figures, and `fit_slice_by_slice` / `fit_2d` reach plotting only by calling that save-legacy path (`fit_baseline` is already disentangled — it calls `fitlib.plt_fit_res_1d` directly, gated by `_save_img_flag`; use it as the template). Split into (a) compute/convert, (b) explicit save/export, (c) an explicit plotting API. Scoping session 2026-07-13 found this is design work, not refactoring — three decisions are load-bearing: +- [ ] **Decide how to guard/warn against in-place mutation of user-facing arrays**: internal machinery assumes `File.data`/`energy`/`time` and fit outputs are stable once set — e.g. `SavedFitSlot` stores `params`/`observed`/`fit`/`selection` by reference (`frozen=True` blocks reassignment, not in-place mutation; 2026-07 code review, check 1), and file fingerprints / `observed_sha256` are computed once at slot construction. A user mutating `file.data` in place instead of re-instantiating would desynchronize slots, fit limits, and cached evaluations in ways no single defensive copy fixes — so slot-level copies were considered and declined (2026-07-10) as papering over one symptom. Decide on a systemic stance instead: read-only views (`setflags(write=False)`) on public arrays, copy-on-set in setters, a documented ownership contract, and/or re-hash validation at save time. A sibling case: `Simulator.sigma_data` is recomputed on read from the current `noise_level`/`noise_type` ([simulator.py](src/trspecfit/simulator.py) ~L1057), so calling `set_noise_level`/`set_noise_type` after `simulate()` but before `save_data()` persists a stale or missing `metadata.sigma_data` (the value fed to `File.set_sigma`) that no longer matches the saved noisy data — cheap dedicated fix is to snapshot the derived sigma at simulation time; fold it into whatever stance is chosen. +- [ ] `[ACTIVE]` **Disentangle plotting from saving/conversion in the fit pipeline**: figure rendering is currently entangled with data conversion and file IO. `fitlib.results_to_df` ([fitlib.py](src/trspecfit/fitlib.py) ~L1015) is the worst offender — it converts results → DataFrame, writes `fit_pars.csv`, *and* plots the per-parameter curves, including per-column show/save logic based on which parameters varied; its output feeds `results_to_fit_2d` which feeds `plt_fit_res_2d`, so the SbS chain must be restructured as a whole. `File._save_2d_fit_legacy` / `_save_sbs_fit_legacy` (CSV writers) also render figures, and `fit_slice_by_slice` / `fit_2d` reach plotting only by calling that save-legacy path (`fit_baseline` is already disentangled — it calls `fitlib.plt_fit_res_1d` directly, gated by `_save_img_flag`; use it as the template). Split into (a) compute/convert, (b) explicit save/export, (c) an explicit plotting API. Scoping session 2026-07-13 found this is design work, not refactoring — three decisions are load-bearing: - **The disentangled implementation already exists**: `fit_io._export_2d_slot` / `_export_sbs_param_evolution` ([fit_io.py](src/trspecfit/utils/fit_io.py) ~L1918/~L1953) are pure writers reading from saved slots, plotting explicit. The `_save_*_legacy` methods survive *only* because auto-export promises the original on-disk layout byte-for-byte (see the `save_sbs_fit`/`save_2d_fit` deprecation docstrings). The honest fix is routing auto-export through the slot-based export path and accepting the layout change — a user-facing breaking decision, tied to the legacy-shim removal item in the v1.0.0 checklist (which this blocks: the `_save_*_legacy` impls are the live SbS/2D plotting path). - **The explicit plotting API (c) needs designing**: `FitResults.plot_*` vs `File.plot_*` — cf. `_save_img_flag`, `FitResults.plot_residuals`, and the interactive-UI notes in [docs/design/ui.md](docs/design/ui.md). - **Coupled to the results-data ownership boundary item above**: whether plots read live `model.result` or persisted slots is the same question. From e9c6868ac739bc48338af13a9cc276d3fecbf229 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Wed, 15 Jul 2026 23:25:42 -0700 Subject: [PATCH 05/29] persist correl and acceptance_fraction in fit slots (schema 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SavedFitSlot.correl (varying-parameter correlation matrix, captured only when the optimizer produced covariance — None for covariance-less fits and project joint fits, slice 0 for SbS) and acceptance_fraction in the mcmc payload. Bump the archive schema to 3; the reader accepts 2 and 3 (older archives load the new fields as None), appends stay same-version only. Extract the matrix builder into ulmfit.correl_to_df and delegate File.get_correlations to it. Round-trip, v2-tolerance, and unknown-version tests; fit_archive_schema.md updated. --- PLAN.md | 33 +++--- docs/design/fit_archive_schema.md | 78 ++++++++++---- src/trspecfit/trspecfit.py | 45 ++++++-- src/trspecfit/utils/fit_io.py | 100 +++++++++++++++--- src/trspecfit/utils/lmfit.py | 30 +++++- tests/test_fit_archive_roundtrip.py | 156 ++++++++++++++++++++++++++++ tests/test_fit_history.py | 20 +++- 7 files changed, 402 insertions(+), 60 deletions(-) diff --git a/PLAN.md b/PLAN.md index b62d5aa..17800de 100644 --- a/PLAN.md +++ b/PLAN.md @@ -30,22 +30,29 @@ decisions 2026-07-14. and loaded archives alike). `File.get_*` / `File.plot_*` / `File.compare_models` are thin sugar delegating to `project.results` filtered to that file. -## Phase 1 — Persist `correl` + `acceptance_fraction` (schema v3) +## Phase 1 — Persist `correl` + `acceptance_fraction` (schema v3) — DONE -- [ ] Add `correl: pd.DataFrame | None` field to `SavedFitSlot` (correlation +- [x] Add `correl: pd.DataFrame | None` field to `SavedFitSlot` (correlation matrix over varied params, mirroring `get_correlations()` output). - Stored like `conf_ci` via `_encode_dataframe`. Populate in - `_build_slot` / per-fit-type extractors from `par_fin.params[*].correl`. - (TODO wording said "into the `params` payload"; a sibling matrix dataset - mirrors the accessor's return shape and the `conf_ci` precedent — a - per-row JSON column in the long params table would be strictly worse to - read back.) -- [ ] Add `acceptance_fraction` (per-walker float array) to the slot `mcmc` + Stored like `conf_ci` via `_encode_dataframe` (all-float64 square + matrix; index restored from `columns` on read). Matrix builder is + `ulmfit.correl_to_df`; `File.get_correlations` now delegates to it. + Captured in the `_append_*_slot` methods, gated on + `result_fin.covar is not None` — so `correl` is `None` for + covariance-less optimizers and project joint fits (mirrors + stderr/conf_ci absence) instead of a misleading identity matrix. + SbS captures slice 0 (the conf_ci/mcmc convention). +- [x] Add `acceptance_fraction` (per-walker float array) to the slot `mcmc` payload (`_mcmc_payload`, `_write_mcmc_group`, `_read_mcmc_group`). -- [ ] Bump `SCHEMA_VERSION` "2" → "3". Reader accepts v2 archives (new fields - → `None`); append across versions stays refused (existing policy). -- [ ] Save/load round-trip tests for both fields (baseline, 2d, mcmc-on/off, - v2-archive read tolerance). +- [x] Bump `SCHEMA_VERSION` "2" → "3" with `SUPPORTED_READ_VERSIONS = ("2", + "3")`. Reader accepts v2 archives (new fields → `None`); append across + versions stays refused (existing policy). `fit_archive_schema.md` + updated (also fixed the stale metrics attr list there). +- [x] Round-trip tests: `test_correl_roundtrip` (leastsq pins the + deterministic covar path; Nelder covar depends on numdifftools), + conf_ci/correl/mcmc comparison added to `_assert_slot_round_tripped`, + acceptance_fraction round-trip in `TestMcmcPayload` (slow), v2 + read-tolerance + unknown-version rejection tests. ## Phase 2 — Relocate accessors to `FitResults`, slot-backed diff --git a/docs/design/fit_archive_schema.md b/docs/design/fit_archive_schema.md index 9004f67..c57f461 100644 --- a/docs/design/fit_archive_schema.md +++ b/docs/design/fit_archive_schema.md @@ -1,4 +1,4 @@ -# Fit-archive HDF5 schema (schema_version 2) +# Fit-archive HDF5 schema (schema_version 3) On-disk layout for the fit-results archive written by `Project.save_fits()` and read by `FitResults.load()` / `Project.load_fits()`. The object model @@ -87,7 +87,7 @@ dtypes. │ project_name : str # Project.name; set on first write │ timestamp_created : str # ISO 8601 UTC, first write │ timestamp_updated : str # ISO 8601 UTC, most recent write -│ schema_version : str # "2"; bump on incompatible change +│ schema_version : str # "3"; bump on incompatible change └── files/ # group; one subgroup per file ├── 000000/ # SavedFile (see "File group") └── 000001/... @@ -101,14 +101,23 @@ must not recreate the archive on subsequent saves unless the caller explicitly asks for that; the canonical way to start fresh is to choose a new path. -`schema_version` is currently `"2"`. It was bumped from `"1"` before this -branch shipped, when the σ-calibrated chi-square columns and per-slot sigma -metadata changed the stored fields — a clean break, so archives written by -the older schema can no longer be read. Future incompatible changes (e.g. -project-scoped joint-result slots or `keep_history=True` full-log save — -both deferred, see "What's *not* in v1") bump it again. The reader rejects -archives with a `schema_version` it does not recognize. (This wire-format -number is independent of the feature-scope "v1" used elsewhere in this doc.) +`schema_version` is currently `"3"`. Version history: + +- `"1"` → `"2"`: the σ-calibrated chi-square columns and per-slot sigma + metadata changed the stored fields — a clean break, so schema-1 archives + can no longer be read. +- `"2"` → `"3"` (2026-07): **additive** — slot `correl` dataset and mcmc + `acceptance_fraction` dataset. The reader accepts both `"2"` and `"3"` + (`SUPPORTED_READ_VERSIONS` in `utils/fit_io.py`); schema-2 archives load + with the new fields as `None`. The writer still refuses to append to an + archive whose version differs from its own — re-save to a new path to + migrate. + +Future incompatible changes (e.g. project-scoped joint-result slots or +`keep_history=True` full-log save — both deferred, see "What's *not* in +v1") bump it again. The reader rejects archives with a `schema_version` it +does not recognize. (This wire-format number is independent of the +feature-scope "v1" used elsewhere in this doc.) ## File group @@ -186,6 +195,8 @@ files/000000/slots/000000/ │ yaml_filename : str (opt) # human breadcrumb; omit if None │ timestamp : str # ISO 8601 UTC, slot creation time │ # --- metrics (baseline / spectrum / 2d only) --- +│ chi2_raw : float64 (cond) +│ chi2_red_raw : float64 (cond) │ chi2 : float64 (cond) │ chi2_red : float64 (cond) │ r2 : float64 (cond) @@ -196,6 +207,7 @@ files/000000/slots/000000/ ├── fit # 1D or 2D dataset; preserves source dtype; observed.shape == fit.shape ├── metrics_per_slice (opt) # 1D structured dataset; sbs only ├── conf_ci (opt) # heterogeneous-DataFrame dataset; see "conf_ci dataset" +├── correl (opt) # all-numeric DataFrame dataset; see "correl dataset" └── mcmc/ (opt) # see "mcmc group" ``` @@ -204,7 +216,7 @@ files/000000/slots/000000/ scalars. `(opt)` = present iff the corresponding `SavedFitSlot` field is non-`None` -(`conf_ci`, `mcmc`) or applicable to the fit type +(`conf_ci`, `correl`, `mcmc`) or applicable to the fit type (`metrics_per_slice` is sbs-only). ### `archive_slot_key` vs `history_key` @@ -282,11 +294,13 @@ heterogeneous-DataFrame dataset; do not redefine `params`. ``` metrics_per_slice : 1D structured dataset, shape (n_slices,) dtype: - chi2 : float64 - chi2_red : float64 - r2 : float64 - aic : float64 - bic : float64 + chi2_raw : float64 + chi2_red_raw : float64 + chi2 : float64 + chi2_red : float64 + r2 : float64 + aic : float64 + bic : float64 ``` Row order follows the time-slice order in `observed` axis 0. The reader @@ -316,6 +330,25 @@ positional fields insulate HDF5 from arbitrary user-facing labels; the `columns` attr restores them on read. Omitted entirely if `SavedFitSlot.conf_ci is None`. +## `correl` dataset (optional; schema ≥ 3) + +All-numeric DataFrame — the varying-parameter correlation matrix built by +`correl_to_df` in `utils/lmfit.py`: + +``` +correl : 2D float64 dataset, shape (n_vary, n_vary) + attrs: + columns : vlen str[n_vary] # varying parameter names; axis-1 order +``` + +The matrix is square with `index == columns`, so only the column labels +are stored; the reader restores the index from the `columns` attr. +Omitted entirely if `SavedFitSlot.correl is None` — which is the case +when the optimizer reported no covariance (e.g. Nelder without +numdifftools) and for project-level joint fits (joint covariance does not +decompose per file). For SbS the matrix is slice 0's, mirroring +`conf_ci` / `mcmc`. + ## `mcmc/` group (optional) ``` @@ -327,6 +360,7 @@ mcmc/ ├── ci (opt) # heterogeneous-dtype DataFrame │ 1D structured dataset, shape (n_par,) │ field/attr layout identical to conf_ci above +├── acceptance_fraction (opt) # 1D float64 dataset, shape (n_walkers,); schema ≥ 3 └── attrs: lnsigma : float64 # __lnsigma point estimate ``` @@ -338,6 +372,9 @@ Within the group: emcee returned an empty chain. - `ci` is optional (emcee CI may not have been computed). - `lnsigma` is required when `mcmc/` is present. +- `acceptance_fraction` is optional (absent in schema-2 archives and when + emcee did not expose it); the reader maps absence to `None` in the + payload dict. ## Reader → object-model mapping @@ -361,6 +398,7 @@ Per slot, the reader produces a `SavedFitSlot` with: | `yaml_filename` | slot `metadata.yaml_filename` attr (None if absent) | | `timestamp` | slot `metadata.timestamp` attr | | `conf_ci` | `conf_ci` dataset → DataFrame, or `None` if absent | +| `correl` | `correl` dataset → DataFrame (index restored from `columns`), or `None` if absent | | `mcmc` | `mcmc/` group → dict, or `None` if absent | `history_key` is persisted as a non-authoritative attr but recomputed @@ -408,10 +446,10 @@ The archive does not distinguish them from slots produced by timestamp/sequence component in the slot key; deferred to v2. - **Model rehydration.** `yaml_filename` is a breadcrumb; v1 does not promise to deserialize a `Model` from the archive. -- **MCMC trace metadata** (acceptance fraction, autocorrelation times, - etc.) — only `flatchain` / `ci` / `lnsigma` are persisted. If the - decoupled-MCMC follow-on (the archived design plan, "Out of scope") lands, that work owns - the schema extension. +- **MCMC trace metadata beyond acceptance fraction** (autocorrelation + times, etc.) — schema 3 added `acceptance_fraction`; the rest is still + not persisted. If the decoupled-MCMC follow-on (the archived design + plan, "Out of scope") lands, that work owns the schema extension. ## Cross-references diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index 41dc2fd..efae9b2 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -3570,6 +3570,14 @@ def _append_baseline_slot(self, *, model_name: str, fit_fun_str: str) -> None: par_names=self.model_base.parameter_names, ) conf_ci = self.model_base.result[2] + # correl only when the optimizer produced a covariance matrix — + # otherwise the matrix would misreport "no covariance" as + # "uncorrelated" (identity + zeros). + correl = ( + ulmfit.correl_to_df(result_fin.params) + if getattr(result_fin, "covar", None) is not None + else None + ) mcmc = fit_io._mcmc_payload( self.model_base.result[3], self.model_base.result[4], @@ -3591,6 +3599,7 @@ def _append_baseline_slot(self, *, model_name: str, fit_fun_str: str) -> None: sigma_type=self.sigma_type, sigma_data=self.sigma_data, conf_ci=conf_ci if not conf_ci.empty else None, + correl=correl, mcmc=mcmc, ) self.p._fit_history.append(slot) @@ -3636,6 +3645,11 @@ def _append_spectrum_slot( par_names=self.model_spec.parameter_names, ) conf_ci = self.model_spec.result[2] + correl = ( + ulmfit.correl_to_df(result_fin.params) + if getattr(result_fin, "covar", None) is not None + else None + ) mcmc = fit_io._mcmc_payload( self.model_spec.result[3], self.model_spec.result[4], @@ -3659,6 +3673,7 @@ def _append_spectrum_slot( sigma_type=self.sigma_type, sigma_data=self.sigma_data, conf_ci=conf_ci if not conf_ci.empty else None, + correl=correl, mcmc=mcmc, ) self.p._fit_history.append(slot) @@ -3713,13 +3728,19 @@ def _append_sbs_slot(self, *, model_name: str, fit_fun_str: str) -> None: # because the same model_sbs is used for every slice). slice0_result = self.results_sbs[0][1] slice0_conf_ci = self.results_sbs[0][2] - # MCMC payload — captured from slice 0, mirroring fit_alg / nvarys. - # Per-slice MCMC chains are not stored; aggregate analysis uses - # slice 0 as the representative. + # MCMC and correl payloads — captured from slice 0, mirroring + # fit_alg / nvarys. Per-slice MCMC chains / correlation matrices + # are not stored; aggregate analysis uses slice 0 as the + # representative. slice0_mcmc = fit_io._mcmc_payload( self.results_sbs[0][3], self.results_sbs[0][4], ) + slice0_correl = ( + ulmfit.correl_to_df(slice0_result.params) + if getattr(slice0_result, "covar", None) is not None + else None + ) slot = fit_io._slot_from_sbs( file_fingerprint=self.fingerprint(), file_name=self.name, @@ -3737,6 +3758,7 @@ def _append_sbs_slot(self, *, model_name: str, fit_fun_str: str) -> None: sigma_type=self.sigma_type, sigma_data=self.sigma_data, conf_ci=slice0_conf_ci if not slice0_conf_ci.empty else None, + correl=slice0_correl, mcmc=slice0_mcmc, ) self.p._fit_history.append(slot) @@ -3781,6 +3803,14 @@ def _append_2d_slot(self, *, model_name: str, fit_fun_str: str) -> None: par_names=self.model_2d.parameter_names, ) conf_ci = self.model_2d.result[2] + # covar is absent on the project-fit path (SimpleNamespace result) + # and for covariance-less optimizers; correl stays None there, + # mirroring the per-file absence of stderr / conf_ci. + correl = ( + ulmfit.correl_to_df(result_fin.params) + if getattr(result_fin, "covar", None) is not None + else None + ) mcmc = fit_io._mcmc_payload( self.model_2d.result[3], self.model_2d.result[4], @@ -3802,6 +3832,7 @@ def _append_2d_slot(self, *, model_name: str, fit_fun_str: str) -> None: sigma_type=self.sigma_type, sigma_data=self.sigma_data, conf_ci=conf_ci if not conf_ci.empty else None, + correl=correl, mcmc=mcmc, ) self.p._fit_history.append(slot) @@ -4398,13 +4429,7 @@ def get_correlations( """ params = self._result_model(fit_type).result[1].params - names = [n for n in params if params[n].vary] - mat = pd.DataFrame(np.eye(len(names)), index=names, columns=names, dtype=float) - for n in names: - for other, corr in (params[n].correl or {}).items(): - if other in mat.columns: - mat.loc[n, other] = corr - return mat + return ulmfit.correl_to_df(params) # def get_conf_intervals( diff --git a/src/trspecfit/utils/fit_io.py b/src/trspecfit/utils/fit_io.py index fe823d7..cca23d3 100644 --- a/src/trspecfit/utils/fit_io.py +++ b/src/trspecfit/utils/fit_io.py @@ -44,7 +44,11 @@ from trspecfit.utils.hdf5 import require_dataset, require_group FitType = Literal["baseline", "spectrum", "sbs", "2d"] -SCHEMA_VERSION = "2" +SCHEMA_VERSION = "3" +# Schema 3 is additive over 2 (slot `correl` dataset, mcmc +# `acceptance_fraction` dataset), so the reader accepts both; the writer +# still refuses cross-version appends (see _classify_archive_for_write). +SUPPORTED_READ_VERSIONS = ("2", "3") # Default noise metadata used when no σ has been set on the File. Mirrors the # project.yaml defaults defined in ``Project._set_defaults``; if you change one, @@ -223,8 +227,17 @@ class SavedFitSlot: baseline (``N_avg`` = number of time slices averaged into ``data_base``). ``NaN`` when ``sigma_data`` is ``NaN``. conf_ci : pd.DataFrame | None + correl : pd.DataFrame | None + Varying-parameter correlation matrix (index == columns == varying + parameter names, 1.0 on the diagonal). ``None`` when the optimizer + reported no covariance (e.g. Nelder without numdifftools) and for + project-level joint fits, whose joint covariance does not decompose + per file. For SbS, captured from slice 0 (the representative slice, + mirroring ``conf_ci`` / ``mcmc``). mcmc : dict | None - ``{"flatchain", "ci", "lnsigma"}`` if MCMC ran, else ``None``. + ``{"flatchain", "ci", "lnsigma", "acceptance_fraction"}`` if MCMC + ran, else ``None``. ``acceptance_fraction`` is emcee's per-walker + array (``None`` in slots loaded from schema-2 archives). """ file_fingerprint: dict[str, Any] @@ -248,6 +261,7 @@ class SavedFitSlot: sigma_data: float sigma_eff: float conf_ci: pd.DataFrame | None = None + correl: pd.DataFrame | None = None mcmc: dict[str, Any] | None = None @@ -468,8 +482,9 @@ def _mcmc_payload( Build the ``mcmc`` slot payload from ``fit_wrapper``'s emcee outputs. Returns ``None`` if MCMC did not run (``emcee_fin is None``). Otherwise - returns ``{"flatchain", "ci", "lnsigma"}`` matching ``SavedFitSlot.mcmc``. - Frames are copied so the slot is invariant to subsequent state changes. + returns ``{"flatchain", "ci", "lnsigma", "acceptance_fraction"}`` matching + ``SavedFitSlot.mcmc``. Frames and arrays are copied so the slot is + invariant to subsequent state changes. """ if emcee_fin is None: @@ -483,7 +498,16 @@ def _mcmc_payload( lnsigma_par = params.get("__lnsigma") if params is not None else None lnsigma = float(lnsigma_par.value) if lnsigma_par is not None else None ci_out = emcee_ci.copy() if not emcee_ci.empty else None - return {"flatchain": flatchain_out, "ci": ci_out, "lnsigma": lnsigma} + acceptance = getattr(emcee_fin, "acceptance_fraction", None) + acceptance_out = ( + np.array(acceptance, dtype=np.float64) if acceptance is not None else None + ) + return { + "flatchain": flatchain_out, + "ci": ci_out, + "lnsigma": lnsigma, + "acceptance_fraction": acceptance_out, + } # @@ -510,6 +534,7 @@ def _slot_from_baseline( sigma_type: str, sigma_data: float, conf_ci: pd.DataFrame | None = None, + correl: pd.DataFrame | None = None, mcmc: dict[str, Any] | None = None, ) -> SavedFitSlot: """ @@ -538,6 +563,7 @@ def _slot_from_baseline( fit_alg=fit_alg, yaml_filename=yaml_filename, conf_ci=conf_ci, + correl=correl, mcmc=mcmc, noise_type=noise_type, sigma_source=sigma_source, @@ -567,6 +593,7 @@ def _slot_from_spectrum( sigma_type: str, sigma_data: float, conf_ci: pd.DataFrame | None = None, + correl: pd.DataFrame | None = None, mcmc: dict[str, Any] | None = None, ) -> SavedFitSlot: """Build a SavedFitSlot for a completed spectrum fit. @@ -595,6 +622,7 @@ def _slot_from_spectrum( fit_alg=fit_alg, yaml_filename=yaml_filename, conf_ci=conf_ci, + correl=correl, mcmc=mcmc, noise_type=noise_type, sigma_source=sigma_source, @@ -622,6 +650,7 @@ def _slot_from_sbs( sigma_type: str, sigma_data: float, conf_ci: pd.DataFrame | None = None, + correl: pd.DataFrame | None = None, mcmc: dict[str, Any] | None = None, ) -> SavedFitSlot: """ @@ -673,6 +702,7 @@ def _slot_from_sbs( sigma_data=float(sigma_data), sigma_eff=float(sigma_eff), conf_ci=conf_ci, + correl=correl, mcmc=mcmc, ) @@ -696,6 +726,7 @@ def _slot_from_2d( sigma_type: str, sigma_data: float, conf_ci: pd.DataFrame | None = None, + correl: pd.DataFrame | None = None, mcmc: dict[str, Any] | None = None, ) -> SavedFitSlot: """Build a SavedFitSlot for a completed 2D global fit.""" @@ -717,6 +748,7 @@ def _slot_from_2d( fit_alg=fit_alg, yaml_filename=yaml_filename, conf_ci=conf_ci, + correl=correl, mcmc=mcmc, noise_type=noise_type, sigma_source=sigma_source, @@ -745,6 +777,7 @@ def _build_slot( fit_alg: str, yaml_filename: str | None, conf_ci: pd.DataFrame | None, + correl: pd.DataFrame | None, mcmc: dict[str, Any] | None, noise_type: str, sigma_source: str, @@ -790,6 +823,7 @@ def _build_slot( sigma_data=float(sigma_data), sigma_eff=float(sigma_eff), conf_ci=conf_ci, + correl=correl, mcmc=mcmc, ) @@ -1300,6 +1334,15 @@ def _write_slot( _write_metrics_per_slice(slot_group, slot.metrics) if slot.conf_ci is not None: _encode_dataframe(slot_group, "conf_ci", slot.conf_ci) + if slot.correl is not None: + # Square all-float matrix; index == columns, so only the columns + # attr is stored and the reader restores the index from it. + _encode_dataframe( + slot_group, + "correl", + slot.correl, + type_tags=_all_float64_tags(len(slot.correl.columns)), + ) if slot.mcmc is not None: _write_mcmc_group(slot_group, slot.mcmc) @@ -1375,7 +1418,8 @@ def _write_metrics_per_slice( # def _write_mcmc_group(slot_group: h5py.Group, mcmc: dict[str, Any]) -> None: - """``mcmc/`` subgroup: flatchain (always), ci (optional), lnsigma attr.""" + """``mcmc/`` subgroup: flatchain (always), ci / acceptance_fraction + (optional), lnsigma attr.""" mcmc_group = slot_group.create_group("mcmc") lnsigma = mcmc.get("lnsigma") @@ -1398,6 +1442,12 @@ def _write_mcmc_group(slot_group: h5py.Group, mcmc: dict[str, Any]) -> None: ci = mcmc.get("ci") if ci is not None: _encode_dataframe(mcmc_group, "ci", ci) + acceptance = mcmc.get("acceptance_fraction") + if acceptance is not None: + mcmc_group.create_dataset( + "acceptance_fraction", + data=np.asarray(acceptance, dtype=np.float64), + ) # @@ -1496,9 +1546,11 @@ def _read_mcmc_group(group: h5py.Group) -> dict[str, Any]: """ Inverse of ``_write_mcmc_group``. - Returns ``{"flatchain", "ci", "lnsigma"}`` matching the writer's - payload. ``lnsigma`` NaN maps back to ``None``; ``ci`` is ``None`` if - the optional dataset was not written. + Returns ``{"flatchain", "ci", "lnsigma", "acceptance_fraction"}`` + matching the writer's payload. ``lnsigma`` NaN maps back to ``None``; + ``ci`` and ``acceptance_fraction`` are ``None`` if the optional dataset + was not written (``acceptance_fraction`` is always absent in schema-2 + archives). """ flatchain_obj = group.get("flatchain") @@ -1519,7 +1571,18 @@ def _read_mcmc_group(group: h5py.Group) -> dict[str, Any]: else: v = float(np.asarray(lnsigma_attr).item()) lnsigma = None if np.isnan(v) else v - return {"flatchain": flatchain, "ci": ci, "lnsigma": lnsigma} + acc_obj = group.get("acceptance_fraction") + acceptance = ( + np.asarray(require_dataset(acc_obj, "mcmc/acceptance_fraction")[...]) + if acc_obj is not None + else None + ) + return { + "flatchain": flatchain, + "ci": ci, + "lnsigma": lnsigma, + "acceptance_fraction": acceptance, + } # @@ -1560,6 +1623,12 @@ def _read_slot( if conf_ci_obj is not None else None ) + correl_obj = slot_group.get("correl") + correl: pd.DataFrame | None = None + if correl_obj is not None: + correl = _decode_dataframe(require_dataset(correl_obj, "correl")) + # Square matrix stores only column labels; index == columns. + correl.index = pd.Index(correl.columns) mcmc_obj = slot_group.get("mcmc") mcmc = ( _read_mcmc_group(require_group(mcmc_obj, "mcmc")) @@ -1600,6 +1669,7 @@ def _read_slot( sigma_data=float(np.asarray(a["sigma_data"]).item()), sigma_eff=float(np.asarray(a["sigma_eff"]).item()), conf_ci=conf_ci, + correl=correl, mcmc=mcmc, ) @@ -1661,8 +1731,9 @@ def read_archive(filepath: PathLike | str) -> SavedProject: ``File``, or ``Model`` state — the returned ``SavedProject`` is a standalone, immutable view of the archive's contents at read time. - Raises ``ValueError`` if ``schema_version`` does not match the - reader's ``SCHEMA_VERSION``. + Raises ``ValueError`` if ``schema_version`` is not one of + ``SUPPORTED_READ_VERSIONS``. Schema-2 archives load with the schema-3 + additions (slot ``correl``, mcmc ``acceptance_fraction``) as ``None``. """ path = Path(filepath) @@ -1670,10 +1741,11 @@ def read_archive(filepath: PathLike | str) -> SavedProject: meta = require_group(archive["metadata"], "metadata") ma = meta.attrs schema_version = _attr_str(ma["schema_version"]) - if schema_version != SCHEMA_VERSION: + if schema_version not in SUPPORTED_READ_VERSIONS: + supported = ", ".join(repr(v) for v in SUPPORTED_READ_VERSIONS) raise ValueError( f"Archive at {path} has schema_version {schema_version!r}; " - f"this reader supports {SCHEMA_VERSION!r}." + f"this reader supports {supported}." ) files_obj = archive.get("files") files: list[SavedFile] = [] diff --git a/src/trspecfit/utils/lmfit.py b/src/trspecfit/utils/lmfit.py index 6efb578..893d94b 100644 --- a/src/trspecfit/utils/lmfit.py +++ b/src/trspecfit/utils/lmfit.py @@ -418,6 +418,33 @@ def par_to_df( return pd.DataFrame(data=par_info_list, columns=cols) +# +def correl_to_df(lmfit_params: lmfit.Parameters) -> pd.DataFrame: + """ + Build the varying-parameter correlation matrix from lmfit Parameters. + + Parameters + ---------- + lmfit_params : lmfit.Parameters + Parameters from a completed fit (pass ``result.params``). + + Returns + ------- + pd.DataFrame + Square matrix indexed by the varying parameter names: 1.0 on the + diagonal, lmfit's pairwise correlations off-diagonal (0.0 where a + pair is uncorrelated or the optimizer reported no covariance). + """ + + names = [n for n in lmfit_params if lmfit_params[n].vary] + mat = pd.DataFrame(np.eye(len(names)), index=names, columns=names, dtype=float) + for n in names: + for other, corr in (lmfit_params[n].correl or {}).items(): + if other in mat.columns: + mat.loc[n, other] = corr + return mat + + # def list_of_par_to_df(results: list[Any]) -> pd.DataFrame: """ @@ -611,7 +638,8 @@ class MCMCResult: """Live MCMC outputs for a single fit (counterpart to the ``MC`` settings). A read-only view over ``model.result`` (the raw ``lmfit.emcee`` result and - its quantile table), returned by ``File.get_mcmc``. Not persisted — see the + its quantile table), returned by ``File.get_mcmc``. The underlying data is + persisted in the ``SavedFitSlot.mcmc`` payload (schema 3) — see the "results-data ownership boundary" TODO for the planned unified results API. Attributes diff --git a/tests/test_fit_archive_roundtrip.py b/tests/test_fit_archive_roundtrip.py index c6a2e28..28532e1 100644 --- a/tests/test_fit_archive_roundtrip.py +++ b/tests/test_fit_archive_roundtrip.py @@ -146,6 +146,30 @@ def _assert_slot_round_tripped(loaded: SavedFitSlot, original: SavedFitSlot) -> # --- params -------------------------------------------------------- _assert_params_equal(loaded.params, original.params, fit_type=original.fit_type) + # --- uncertainty payloads (None ↔ None or exact) -------------------- + _assert_optional_df_equal(loaded.conf_ci, original.conf_ci, label="conf_ci") + _assert_optional_df_equal(loaded.correl, original.correl, label="correl") + if original.correl is not None: + assert loaded.correl is not None # type guard + # The square matrix persists only column labels; the reader must + # restore index == columns. + assert list(loaded.correl.index) == list(loaded.correl.columns) + if original.mcmc is None: + assert loaded.mcmc is None + else: + assert loaded.mcmc is not None # type guard + assert set(loaded.mcmc.keys()) == set(original.mcmc.keys()) + _assert_optional_df_equal( + loaded.mcmc["flatchain"], original.mcmc["flatchain"], label="flatchain" + ) + _assert_optional_df_equal(loaded.mcmc["ci"], original.mcmc["ci"], label="ci") + assert loaded.mcmc["lnsigma"] == original.mcmc["lnsigma"] + orig_acc = original.mcmc["acceptance_fraction"] + if orig_acc is None: + assert loaded.mcmc["acceptance_fraction"] is None + else: + np.testing.assert_array_equal(loaded.mcmc["acceptance_fraction"], orig_acc) + # --- provenance ---------------------------------------------------- assert loaded.fit_alg == original.fit_alg assert loaded.yaml_filename == original.yaml_filename @@ -167,6 +191,29 @@ def _assert_slot_round_tripped(loaded: SavedFitSlot, original: SavedFitSlot) -> assert loaded.metrics["chi2_raw"] == pytest.approx(float(np.sum(residual**2))) +# +def _assert_optional_df_equal( + loaded: pd.DataFrame | None, original: pd.DataFrame | None, *, label: str +) -> None: + """None ↔ None, or column labels + cell values equal (str cells exact, + float cells exact-or-NaN-matched).""" + + if original is None: + assert loaded is None, f"{label}: orig=None, loaded is not None" + return + assert loaded is not None, f"{label}: orig is a DataFrame, loaded=None" + assert list(loaded.columns) == list(original.columns), label + assert len(loaded) == len(original), label + for col in original.columns: + for o, ll in zip(original[col].to_list(), loaded[col].to_list(), strict=True): + if isinstance(o, float) and np.isnan(o): + assert isinstance(ll, float) and np.isnan(ll), f"{label}.{col}" + elif isinstance(o, float): + assert ll == pytest.approx(o, rel=0, abs=0), f"{label}.{col}" + else: + assert ll == o, f"{label}.{col}" + + # def _assert_params_equal( loaded: pd.DataFrame, original: pd.DataFrame, *, fit_type: str @@ -418,3 +465,112 @@ def test_multi_slot_roundtrip(tmp_path) -> None: for original in project._fit_history: assert original.history_key in by_key _assert_slot_round_tripped(by_key[original.history_key], original) + + +# --------------------------------------------------------------------------- +# correlation-matrix round-trip +# --------------------------------------------------------------------------- + + +# +def test_correl_roundtrip(tmp_path) -> None: + """leastsq always produces covariance, so the slot must capture the + correlation matrix and round-trip it with index == columns intact. + + (The parametrized round-trips above use Nelder, where covariance — + and therefore ``correl`` — depends on numdifftools being installed; + this test pins the deterministic path.) + """ + + _, fit_file, family = _build_fit_file("F1") + fit_file.fit_baseline( + model_name=family.model_name("default"), + stages=1, + fit_alg_1="leastsq", + try_ci=0, + ) + project = fit_file.p + original = project._fit_history[0] + assert original.correl is not None # type guard + n_vary = int(original.params["vary"].sum()) + assert original.correl.shape == (n_vary, n_vary) + np.testing.assert_allclose(np.diag(original.correl.to_numpy()), 1.0) + + loaded_slot, _ = _save_load_one(project, tmp_path / "correl.fit.h5") + _assert_slot_round_tripped(loaded_slot, original) + + +# --------------------------------------------------------------------------- +# schema-version compatibility +# --------------------------------------------------------------------------- + + +# +def _downgrade_archive_to_v2(archive_path) -> None: + """Rewrite a schema-3 archive as schema 2 in place: relabel the version + and delete the schema-3 additions (slot ``correl``, mcmc + ``acceptance_fraction``) so the payload matches what a v2 writer produced.""" + + import h5py + + from trspecfit.utils.hdf5 import require_group + + with h5py.File(archive_path, "r+") as h5: + require_group(h5["metadata"], "metadata").attrs["schema_version"] = "2" + files_group = require_group(h5["files"], "files") + for f_key in files_group: + slots_obj = require_group(files_group[f_key], f_key).get("slots") + if slots_obj is None: + continue + slots = require_group(slots_obj, "slots") + for s_key in slots: + sg = require_group(slots[s_key], s_key) + if "correl" in sg: + del sg["correl"] + if "mcmc" in sg: + mcmc_group = require_group(sg["mcmc"], "mcmc") + if "acceptance_fraction" in mcmc_group: + del mcmc_group["acceptance_fraction"] + + +# +def test_reader_accepts_schema_v2_archive(tmp_path) -> None: + """Schema 3 is additive, so v2 archives must still load — with the + schema-3 fields (``correl``, mcmc ``acceptance_fraction``) as None.""" + + _, fit_file, family = _build_fit_file("F1") + fit_file.fit_baseline( + model_name=family.model_name("default"), + stages=1, + fit_alg_1="leastsq", + try_ci=0, + ) + archive_path = tmp_path / "v2.fit.h5" + fit_file.p.save_fits(archive_path, show_output=0) + _downgrade_archive_to_v2(archive_path) + + loaded = FitResults.load(archive_path) + assert len(loaded) == 1 + slot = next(iter(loaded)) + assert slot.correl is None + original = fit_file.p._fit_history[0] + _assert_params_equal(slot.params, original.params, fit_type="baseline") + + +# +def test_reader_rejects_unknown_schema_version(tmp_path) -> None: + """Versions outside SUPPORTED_READ_VERSIONS raise a clear ValueError.""" + + import h5py + + from trspecfit.utils.hdf5 import require_group + + _, fit_file, family = _build_fit_file("F1") + fit_file.fit_baseline(model_name=family.model_name("default"), stages=1, try_ci=0) + archive_path = tmp_path / "v1.fit.h5" + fit_file.p.save_fits(archive_path, show_output=0) + with h5py.File(archive_path, "r+") as h5: + require_group(h5["metadata"], "metadata").attrs["schema_version"] = "1" + + with pytest.raises(ValueError, match=r"schema_version '1'"): + FitResults.load(archive_path) diff --git a/tests/test_fit_history.py b/tests/test_fit_history.py index 3c39b0e..a67507d 100644 --- a/tests/test_fit_history.py +++ b/tests/test_fit_history.py @@ -444,7 +444,7 @@ class TestMcmcPayload: # @pytest.mark.slow - def test_baseline_slot_captures_mcmc(self): + def test_baseline_slot_captures_mcmc(self, tmp_path): from trspecfit.utils.lmfit import MC truth_project = make_project(name="truth") @@ -462,10 +462,26 @@ def test_baseline_slot_captures_mcmc(self): slot = project._fit_history[0] assert slot.mcmc is not None - assert set(slot.mcmc.keys()) == {"flatchain", "ci", "lnsigma"} + assert set(slot.mcmc.keys()) == { + "flatchain", + "ci", + "lnsigma", + "acceptance_fraction", + } assert slot.mcmc["flatchain"] is not None assert slot.mcmc["ci"] is not None assert slot.mcmc["lnsigma"] is not None + # emcee's acceptance fraction is per-walker. + acceptance = slot.mcmc["acceptance_fraction"] + assert acceptance is not None # type guard + assert acceptance.shape == (32,) + + # acceptance_fraction survives the archive round-trip (schema 3). + archive_path = tmp_path / "mcmc.fit.h5" + project.save_fits(archive_path, show_output=0) + loaded = next(iter(FitResults.load(archive_path))) + assert loaded.mcmc is not None # type guard + np.testing.assert_array_equal(loaded.mcmc["acceptance_fraction"], acceptance) # def test_baseline_slot_mcmc_none_when_mcmc_skipped(self): From 825037da0bee3ebaf6cee2ea0b72bb614d7036d0 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Wed, 15 Jul 2026 23:41:18 -0700 Subject: [PATCH 06/29] relocate results accessors to FitResults, backed by persisted slots FitResults gains get_fit_results / get_correlations / get_conf_intervals / get_mcmc, reading the latest matching SavedFitSlot; File.get_* become thin delegates (with a new optional model= filter) and File._result_model is deleted. The accessors now serve sbs fits (slice-0 payloads) and loaded archives, and hand out copies. Behavior changes: covariance-less fits raise from get_correlations instead of returning an identity-with- zeros matrix, and fits on Files without data record no slot so accessors raise (data_base-only test fixtures now set file.data). MCMCResult.acceptance_fraction is None for schema-2 archives. --- PLAN.md | 32 +++-- src/trspecfit/fit_results.py | 238 ++++++++++++++++++++++++++++++++++- src/trspecfit/trspecfit.py | 139 +++++++------------- src/trspecfit/utils/lmfit.py | 16 +-- tests/test_fit_history.py | 93 +++++++++++++- tests/test_mcp_library.py | 15 ++- 6 files changed, 418 insertions(+), 115 deletions(-) diff --git a/PLAN.md b/PLAN.md index 17800de..cfc8b44 100644 --- a/PLAN.md +++ b/PLAN.md @@ -54,19 +54,29 @@ decisions 2026-07-14. acceptance_fraction round-trip in `TestMcmcPayload` (slow), v2 read-tolerance + unknown-version rejection tests. -## Phase 2 — Relocate accessors to `FitResults`, slot-backed +## Phase 2 — Relocate accessors to `FitResults`, slot-backed — DONE -- [ ] `FitResults.get_fit_results / get_correlations / get_conf_intervals / +- [x] `FitResults.get_fit_results / get_correlations / get_conf_intervals / get_mcmc(file=..., model=..., fit_type=...)` reading the latest matching - slot (define "latest" = last in history order; document). -- [ ] `get_mcmc` builds `ulmfit.MCMCResult` from the slot payload - (flatchain, ci table, lnsigma, acceptance_fraction). -- [ ] `File.get_*` become thin delegates to `self.p.results` (signatures - unchanged → notebook 12 keeps working). Delete `File._result_model`. -- [ ] SbS gains accessor coverage for free (slots exist; `_result_model` - used to raise for sbs). -- [ ] Re-run / spot-check notebook 12 (`12_uncertainty_mcmc`) — its calls are - the compatibility contract. + slot via `_latest_slot` (find() is history-ordered; last match wins, + mirroring the live-model overwrite convention). Accessors return + copies so callers can't mutate slot state. "Not fit yet" raises + ValueError with the legacy "Run fit_x() first" message shape. +- [x] `get_mcmc` builds `ulmfit.MCMCResult` from the slot payload; + `MCMCResult.acceptance_fraction` widened to `np.ndarray | None` + (None for slots loaded from schema-2 archives). +- [x] `File.get_*` are thin delegates to `self.p.results` (fit_type kwarg + unchanged; optional `model=` filter added). `File._result_model` + deleted. Behavior changes: (a) covariance-less fits now raise from + `get_correlations` instead of returning an identity-with-zeros + matrix; (b) fits on Files without `data` (fingerprint source) record + no slot, so accessors raise — data_base-only test fixtures updated + to set `file.data`. +- [x] SbS accessor coverage: get_fit_results serves the wide per-slice + frame; correlations/conf_intervals/mcmc serve slice 0 (documented). +- [x] Notebook 12 compatibility: call signatures unchanged (fit_type + kwarg); stages=2 default → leastsq covar → correl present. Full + notebook re-run deferred to the Phase 6 docs/examples pass. ## Phase 3 — Purify the conversion layer (fitlib) diff --git a/src/trspecfit/fit_results.py b/src/trspecfit/fit_results.py index 1d37f52..77c5816 100644 --- a/src/trspecfit/fit_results.py +++ b/src/trspecfit/fit_results.py @@ -22,16 +22,25 @@ from collections.abc import Iterator, Sequence from os import PathLike -from typing import Any, Literal +from typing import Any, Literal, cast import numpy as np import pandas as pd from trspecfit.utils.fit_io import SavedFile, SavedFitSlot, read_archive +from trspecfit.utils.lmfit import MCMCResult FitType = Literal["baseline", "spectrum", "sbs", "2d"] SbsAggregation = Literal["median", "mean", "sum", "long"] +# Fit entry point per fit_type — used in "not fit yet" error messages. +_FIT_METHOD_BY_TYPE: dict[str, str] = { + "baseline": "fit_baseline", + "spectrum": "fit_spectrum", + "sbs": "fit_slice_by_slice", + "2d": "fit_2d", +} + # Default columns. Dynamic: which set is used depends on whether any matched # slot carries a finite ``sigma_data``. ``chi2_red_raw`` is the lmfit-unweighted # diagnostic (always populated); ``chi2_red`` is the σ-calibrated value @@ -293,6 +302,233 @@ def get( ) return matches[0] + # + def _latest_slot( + self, + *, + file: Any, + model: str | None, + fit_type: str, + ) -> SavedFitSlot: + """ + Return the most recent slot matching the filters. + + ``find()`` preserves history order, so the last match is the latest + fit — mirroring the live-model convention where each ``fit_*`` call + overwrites the previous result of that type. Raises ``ValueError`` + (not ``LookupError``) with a "run fit_x() first" hint, matching the + long-standing accessor contract on ``File``. + """ + + if fit_type not in _FIT_METHOD_BY_TYPE: + raise ValueError( + f"Unknown fit_type={fit_type!r}; " + "use 'baseline', 'spectrum', 'sbs', or '2d'." + ) + file_name = _resolve_file_arg(file) + matches = self.find( + file=file_name, model=model, fit_type=cast(FitType, fit_type) + ) + if not matches: + parts = [ + f"{k}={v!r}" + for k, v in (("file", file_name), ("model", model)) + if v is not None + ] + detail = f" ({', '.join(parts)})" if parts else "" + raise ValueError( + f"No {fit_type} fit results{detail}. " + f"Run {_FIT_METHOD_BY_TYPE[fit_type]}() first." + ) + return matches[-1] + + # + def get_fit_results( + self, + *, + file: Any = None, + model: str | None = None, + fit_type: FitType = "baseline", + ) -> pd.DataFrame: + """ + Return the fitted parameters of the latest matching fit. + + Reads the persisted slot (``SavedFitSlot.params``) — works identically + on ``Project.results`` and on archives loaded via + :meth:`FitResults.load`. + + Parameters + ---------- + file : str | SavedFile | trspecfit.File | None + Filter to a single file (name string or object with ``.name``). + model : str, optional + Filter to a single model name. + fit_type : {'baseline', 'spectrum', 'sbs', '2d'}, default='baseline' + Which fit type to read. When several slots match, the most + recent fit wins. + + Returns + ------- + pd.DataFrame + For 'baseline', 'spectrum', and '2d': one row per parameter with + columns ``['name', 'value', 'stderr', 'init_value', 'min', 'max', + 'vary', 'expr']``. + For 'sbs': one row per time slice with columns = parameter names. + + Raises + ------ + ValueError + If no matching fit has been performed (or loaded) yet. + """ + + slot = self._latest_slot(file=file, model=model, fit_type=fit_type) + return slot.params.copy() + + # + def get_correlations( + self, + *, + file: Any = None, + model: str | None = None, + fit_type: FitType = "baseline", + ) -> pd.DataFrame: + """ + Return the parameter correlation matrix of the latest matching fit. + + Reads the persisted slot (``SavedFitSlot.correl``). For SbS fits the + matrix is slice 0's (the representative slice, like ``conf_ci`` and + ``mcmc``). + + Parameters + ---------- + file : str | SavedFile | trspecfit.File | None + Filter to a single file (name string or object with ``.name``). + model : str, optional + Filter to a single model name. + fit_type : {'baseline', 'spectrum', 'sbs', '2d'}, default='baseline' + Which fit type to read (latest matching fit wins). + + Returns + ------- + pd.DataFrame + Square matrix indexed by the varying parameter names: 1.0 on the + diagonal, lmfit's pairwise correlations off-diagonal. + + Raises + ------ + ValueError + If no matching fit exists, or the fit produced no covariance + (e.g. Nelder without numdifftools installed, or a project-level + joint fit). + """ + + slot = self._latest_slot(file=file, model=model, fit_type=fit_type) + if slot.correl is None: + raise ValueError( + f"No correlation matrix for the {fit_type} fit: the optimizer " + "reported no covariance. Use a covariance-producing method " + "(e.g. leastsq, or Nelder with numdifftools installed); " + "project-level joint fits do not decompose per file." + ) + return slot.correl.copy() + + # + def get_conf_intervals( + self, + *, + file: Any = None, + model: str | None = None, + fit_type: FitType = "baseline", + ) -> pd.DataFrame: + """ + Return the profiled confidence-interval table of the latest matching + fit. + + Reads the persisted slot (``SavedFitSlot.conf_ci``). Populated only + when the fit ran with ``try_ci=1`` (otherwise an empty DataFrame). + For SbS fits the table is slice 0's. + + Parameters + ---------- + file : str | SavedFile | trspecfit.File | None + Filter to a single file (name string or object with ``.name``). + model : str, optional + Filter to a single model name. + fit_type : {'baseline', 'spectrum', 'sbs', '2d'}, default='baseline' + Which fit type to read (latest matching fit wins). + + Returns + ------- + pd.DataFrame + The conf_interval table, or an empty DataFrame if ``try_ci`` was + off. + + Raises + ------ + ValueError + If no matching fit has been performed (or loaded) yet. + """ + + slot = self._latest_slot(file=file, model=model, fit_type=fit_type) + if slot.conf_ci is None: + return pd.DataFrame() + return slot.conf_ci.copy() + + # + def get_mcmc( + self, + *, + file: Any = None, + model: str | None = None, + fit_type: FitType = "baseline", + ) -> MCMCResult: + """ + Return the MCMC outputs (quantile table, chain, acceptance) of the + latest matching fit. + + Reads the persisted slot (``SavedFitSlot.mcmc``). Available only when + the fit ran with ``mc_settings`` enabling MCMC. For SbS fits the + payload is slice 0's. + + Parameters + ---------- + file : str | SavedFile | trspecfit.File | None + Filter to a single file (name string or object with ``.name``). + model : str, optional + Filter to a single model name. + fit_type : {'baseline', 'spectrum', 'sbs', '2d'}, default='baseline' + Which fit type to read (latest matching fit wins). + + Returns + ------- + MCMCResult + Bundle of ``table`` (posterior quantiles), ``flatchain``, and + ``acceptance_fraction`` (``None`` for slots loaded from schema-2 + archives, which did not store it). + + Raises + ------ + ValueError + If no matching fit exists, or the fit had no MCMC step. + """ + + slot = self._latest_slot(file=file, model=model, fit_type=fit_type) + if slot.mcmc is None: + raise ValueError( + f"No MCMC results for the {fit_type} fit. Re-run with " + "mc_settings=MC(use_mc=1, ...)." + ) + flatchain = slot.mcmc.get("flatchain") + ci = slot.mcmc.get("ci") + acceptance = slot.mcmc.get("acceptance_fraction") + return MCMCResult( + table=ci.copy() if ci is not None else pd.DataFrame(), + flatchain=flatchain.copy() if flatchain is not None else pd.DataFrame(), + acceptance_fraction=( + np.asarray(acceptance) if acceptance is not None else None + ), + ) + # def compare_models( self, diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index efae9b2..905645f 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -4308,13 +4308,21 @@ def _save_2d_fit_legacy( def get_fit_results( self, *, + model: str | None = None, fit_type: Literal["baseline", "spectrum", "sbs", "2d"] = "baseline", ) -> pd.DataFrame: """ Return fit results as a DataFrame for programmatic access. + Sugar for ``self.p.results.get_fit_results(file=self, ...)`` — reads + the persisted fit slot (latest matching fit), so results survive + model reloads and are identical to what ``save_fits`` archives. + Parameters ---------- + model : str, optional + Restrict to a single model name. Default: latest fit of + ``fit_type`` regardless of model. fit_type : {'baseline', 'spectrum', 'sbs', '2d'}, default='baseline' Which fit results to return: @@ -4337,116 +4345,68 @@ def get_fit_results( If the requested fit has not been performed yet. """ - if fit_type == "baseline": - if self.model_base is None or not self.model_base.result: - raise ValueError("No baseline fit results. Run fit_baseline() first.") - return ulmfit.par_to_df( - self.model_base.result[1].params, - col_type="min", - par_names=self.model_base.parameter_names, - ) - if fit_type == "spectrum": - if self.model_spec is None or not self.model_spec.result: - raise ValueError("No spectrum fit results. Run fit_spectrum() first.") - return ulmfit.par_to_df( - self.model_spec.result[1].params, - col_type="min", - par_names=self.model_spec.parameter_names, - ) - if fit_type == "sbs": - if not self.results_sbs: - raise ValueError( - "No Slice-by-Slice fit results. Run fit_slice_by_slice() first." - ) - return ulmfit.list_of_par_to_df(self.results_sbs) - if fit_type == "2d": - if self.model_2d is None or not self.model_2d.result: - raise ValueError("No 2D fit results. Run fit_2d() first.") - return ulmfit.par_to_df( - self.model_2d.result[1].params, - col_type="min", - par_names=self.model_2d.parameter_names, - ) - raise ValueError( - f"Unknown fit_type={fit_type!r}; " - "use 'baseline', 'spectrum', 'sbs', or '2d'." - ) - - # - def _result_model(self, fit_type: str) -> mcp.Model: - """Resolve the fitted Model for a fit_type, or raise if not yet fit. - - Shared by get_correlations / get_conf_intervals / get_mcmc. Slice-by- - Slice is excluded — its per-slice results have a different shape (use - ``get_fit_results(fit_type='sbs')``). - """ - - models = { - "baseline": (self.model_base, "fit_baseline"), - "spectrum": (self.model_spec, "fit_spectrum"), - "2d": (self.model_2d, "fit_2d"), - } - if fit_type == "sbs": - raise ValueError( - "get_correlations / get_conf_intervals / get_mcmc are not " - "available for Slice-by-Slice fits (per-slice results); use " - "get_fit_results(fit_type='sbs')." - ) - if fit_type not in models: - raise ValueError( - f"Unknown fit_type={fit_type!r}; use 'baseline', 'spectrum', or '2d'." - ) - model, fit_method = models[fit_type] - if model is None or not model.result: - raise ValueError(f"No {fit_type} fit results. Run {fit_method}() first.") - return model + return self.p.results.get_fit_results(file=self, model=model, fit_type=fit_type) # def get_correlations( self, *, - fit_type: Literal["baseline", "spectrum", "2d"] = "baseline", + model: str | None = None, + fit_type: Literal["baseline", "spectrum", "sbs", "2d"] = "baseline", ) -> pd.DataFrame: """ Return the parameter correlation matrix from a completed fit. + Sugar for ``self.p.results.get_correlations(file=self, ...)`` — + reads the persisted fit slot (latest matching fit). For SbS fits + the matrix is slice 0's (the representative slice). + Parameters ---------- - fit_type : {'baseline', 'spectrum', '2d'}, default='baseline' + model : str, optional + Restrict to a single model name. Default: latest fit of + ``fit_type`` regardless of model. + fit_type : {'baseline', 'spectrum', 'sbs', '2d'}, default='baseline' Which fit to read (see :meth:`get_fit_results`). Returns ------- pd.DataFrame Square matrix indexed by the varying parameter names: 1.0 on the - diagonal, lmfit's pairwise correlations off-diagonal (0.0 where a - pair is uncorrelated or the optimizer reported no covariance). + diagonal, lmfit's pairwise correlations off-diagonal. Raises ------ ValueError - If the requested fit has not been performed yet. + If the requested fit has not been performed yet, or produced no + covariance (e.g. Nelder without numdifftools, or a project-level + joint fit). """ - params = self._result_model(fit_type).result[1].params - return ulmfit.correl_to_df(params) + return self.p.results.get_correlations( + file=self, model=model, fit_type=fit_type + ) # def get_conf_intervals( self, *, - fit_type: Literal["baseline", "spectrum", "2d"] = "baseline", + model: str | None = None, + fit_type: Literal["baseline", "spectrum", "sbs", "2d"] = "baseline", ) -> pd.DataFrame: """ Return the profiled confidence-interval table from a completed fit. - Populated only when the fit ran with ``try_ci=1`` (otherwise an empty - DataFrame). Columns are the per-sigma bounds with the best-fit value in - the middle (see ``fitlib.fit_wrapper``). + Sugar for ``self.p.results.get_conf_intervals(file=self, ...)`` — + reads the persisted fit slot (latest matching fit). Populated only + when the fit ran with ``try_ci=1`` (otherwise an empty DataFrame). + For SbS fits the table is slice 0's. Parameters ---------- - fit_type : {'baseline', 'spectrum', '2d'}, default='baseline' + model : str, optional + Restrict to a single model name. + fit_type : {'baseline', 'spectrum', 'sbs', '2d'}, default='baseline' Which fit to read. Returns @@ -4460,22 +4420,30 @@ def get_conf_intervals( If the requested fit has not been performed yet. """ - return self._result_model(fit_type).result[2] + return self.p.results.get_conf_intervals( + file=self, model=model, fit_type=fit_type + ) # def get_mcmc( self, *, - fit_type: Literal["baseline", "spectrum", "2d"] = "baseline", + model: str | None = None, + fit_type: Literal["baseline", "spectrum", "sbs", "2d"] = "baseline", ) -> ulmfit.MCMCResult: """ Return the MCMC outputs (quantile table, chain, acceptance) of a fit. - Available only when the fit ran with ``mc_settings`` enabling MCMC. + Sugar for ``self.p.results.get_mcmc(file=self, ...)`` — reads the + persisted fit slot (latest matching fit). Available only when the + fit ran with ``mc_settings`` enabling MCMC. For SbS fits the payload + is slice 0's. Parameters ---------- - fit_type : {'baseline', 'spectrum', '2d'}, default='baseline' + model : str, optional + Restrict to a single model name. + fit_type : {'baseline', 'spectrum', 'sbs', '2d'}, default='baseline' Which fit to read. Returns @@ -4490,18 +4458,7 @@ def get_mcmc( If the requested fit has not been performed, or had no MCMC step. """ - model = self._result_model(fit_type) - emcee_fin = model.result[3] - if emcee_fin is None: - raise ValueError( - f"No MCMC results for the {fit_type} fit. Re-run with " - "mc_settings=MC(use_mc=1, ...)." - ) - return ulmfit.MCMCResult( - table=model.result[4], - flatchain=emcee_fin.flatchain, - acceptance_fraction=np.asarray(emcee_fin.acceptance_fraction), - ) + return self.p.results.get_mcmc(file=self, model=model, fit_type=fit_type) # def compare_models( diff --git a/src/trspecfit/utils/lmfit.py b/src/trspecfit/utils/lmfit.py index 893d94b..5d84963 100644 --- a/src/trspecfit/utils/lmfit.py +++ b/src/trspecfit/utils/lmfit.py @@ -635,12 +635,11 @@ def __repr__(self) -> str: # @dataclass(frozen=True) class MCMCResult: - """Live MCMC outputs for a single fit (counterpart to the ``MC`` settings). + """MCMC outputs for a single fit (counterpart to the ``MC`` settings). - A read-only view over ``model.result`` (the raw ``lmfit.emcee`` result and - its quantile table), returned by ``File.get_mcmc``. The underlying data is - persisted in the ``SavedFitSlot.mcmc`` payload (schema 3) — see the - "results-data ownership boundary" TODO for the planned unified results API. + A read-only bundle built from the persisted ``SavedFitSlot.mcmc`` + payload (schema 3), returned by ``FitResults.get_mcmc`` and the + ``File.get_mcmc`` sugar. Attributes ---------- @@ -650,13 +649,14 @@ class MCMCResult: nuisance row. Fixed parameters have no posterior and are excluded. flatchain : pandas.DataFrame Flattened MCMC chain, one column per sampled parameter. - acceptance_fraction : numpy.ndarray - Per-walker acceptance fraction (healthy range ≈ 0.2–0.5). + acceptance_fraction : numpy.ndarray | None + Per-walker acceptance fraction (healthy range ≈ 0.2–0.5). ``None`` + for slots loaded from schema-2 archives, which did not store it. """ table: pd.DataFrame flatchain: pd.DataFrame - acceptance_fraction: np.ndarray + acceptance_fraction: np.ndarray | None # diff --git a/tests/test_fit_history.py b/tests/test_fit_history.py index a67507d..d9c8d04 100644 --- a/tests/test_fit_history.py +++ b/tests/test_fit_history.py @@ -355,6 +355,10 @@ def test_sbs_slot_per_slice_metrics(self): for k in ("chi2", "chi2_red", "r2", "aic", "bic"): assert isinstance(slot.metrics[k], np.ndarray) assert slot.metrics[k].shape == (len(file.time),) + # The slot-backed accessor serves the wide per-slice params frame. + sbs_df = file.get_fit_results(fit_type="sbs") + pd.testing.assert_frame_equal(sbs_df, slot.params) + assert len(sbs_df) == len(file.time) # @pytest.mark.slow @@ -479,10 +483,18 @@ def test_baseline_slot_captures_mcmc(self, tmp_path): # acceptance_fraction survives the archive round-trip (schema 3). archive_path = tmp_path / "mcmc.fit.h5" project.save_fits(archive_path, show_output=0) - loaded = next(iter(FitResults.load(archive_path))) + loaded_results = FitResults.load(archive_path) + loaded = next(iter(loaded_results)) assert loaded.mcmc is not None # type guard np.testing.assert_array_equal(loaded.mcmc["acceptance_fraction"], acceptance) + # ... and the slot-backed accessor serves it from the loaded archive. + mcmc_res = loaded_results.get_mcmc(file="fit", fit_type="baseline") + assert mcmc_res.acceptance_fraction is not None # type guard + np.testing.assert_array_equal(mcmc_res.acceptance_fraction, acceptance) + assert not mcmc_res.table.empty + assert not mcmc_res.flatchain.empty + # def test_baseline_slot_mcmc_none_when_mcmc_skipped(self): project, _ = _setup_baseline_fit() # try_ci=0, no MCMC @@ -490,6 +502,85 @@ def test_baseline_slot_mcmc_none_when_mcmc_skipped(self): assert slot.mcmc is None +# +# --- slot-backed get_* accessors ---------------------------------------------- +# + + +# +class TestSlotBackedAccessors: + """FitResults.get_fit_results / get_correlations / get_conf_intervals / + get_mcmc read the latest matching SavedFitSlot; the File.get_* methods + are thin sugar delegating with file=self.""" + + # + def test_file_sugar_matches_fitresults_accessor(self): + project, file = _setup_baseline_fit() + via_file = file.get_fit_results(fit_type="baseline") + via_results = project.results.get_fit_results(file=file, fit_type="baseline") + pd.testing.assert_frame_equal(via_file, via_results) + # ... and both match the slot payload. + pd.testing.assert_frame_equal(via_file, project._fit_history[0].params) + + # + def test_returned_frame_is_a_copy(self): + """Accessors hand out copies — mutating the return value must not + desynchronize the persisted slot.""" + + project, file = _setup_baseline_fit() + df = file.get_fit_results(fit_type="baseline") + df.loc[0, "value"] = -999.0 + assert project._fit_history[0].params.loc[0, "value"] != -999.0 + + # + def test_latest_slot_wins_after_refit(self): + project, file = _setup_baseline_fit() + assert file.data_base is not None # type guard + # Refit against rescaled data: same (file, model, fit_type, selection) + # → a second slot appends, and the accessors must serve the newer one. + file.data_base = file.data_base * 1.5 + file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) + assert len(project._fit_history) == 2 + + df = file.get_fit_results(fit_type="baseline") + pd.testing.assert_frame_equal(df, project._fit_history[-1].params) + first_values = project._fit_history[0].params["value"].to_numpy() + assert not np.allclose(df["value"].to_numpy(), first_values) + + # + def test_get_correlations_raises_without_covariance(self): + """A slot with correl=None (covariance-less optimizer, project joint + fit) must produce a clear error, not a fabricated identity matrix.""" + + import dataclasses + + project, _ = _setup_baseline_fit() + slot = dataclasses.replace(project._fit_history[0], correl=None) + results = FitResults(slots=[slot]) + with pytest.raises(ValueError, match="reported no covariance"): + results.get_correlations(fit_type="baseline") + + # + def test_get_mcmc_tolerates_missing_acceptance(self): + """Slots loaded from schema-2 archives carry acceptance_fraction=None; + get_mcmc must still serve table/flatchain.""" + + import dataclasses + + project, _ = _setup_baseline_fit() + v2_payload = { + "flatchain": pd.DataFrame({"GLP_01_A": [1.0, 2.0]}), + "ci": None, + "lnsigma": None, + "acceptance_fraction": None, + } + slot = dataclasses.replace(project._fit_history[0], mcmc=v2_payload) + res = FitResults(slots=[slot]).get_mcmc(fit_type="baseline") + assert res.acceptance_fraction is None + assert res.table.empty + assert list(res.flatchain.columns) == ["GLP_01_A"] + + # # --- Project.results snapshot semantics --------------------------------------- # diff --git a/tests/test_mcp_library.py b/tests/test_mcp_library.py index a953667..886bb16 100644 --- a/tests/test_mcp_library.py +++ b/tests/test_mcp_library.py @@ -964,6 +964,10 @@ def _make_fittable_file(self): file.model_active.create_value_1d() assert file.model_active.value_1d is not None # type guard file.data_base = file.model_active.value_1d.copy() + # file.data backs the fingerprint used for slot capture — without it + # the fit completes but records no slot, and the slot-backed get_* + # accessors have nothing to read. + file.data = file.model_active.value_1d.copy() file.e_lim = [0, len(file.energy)] return file @@ -1209,16 +1213,20 @@ def test_get_mcmc_raises_without_mcmc(self): file.get_mcmc(fit_type="baseline") # - def test_accessors_raise_before_fit_and_reject_sbs(self): - """Accessors raise before a fit, and reject the per-slice 'sbs' type.""" + def test_accessors_raise_before_fit(self): + """Accessors raise a clear "run fit_x() first" error before a fit — + for every fit type, including 'sbs' (served from slots since the + results-ownership relocation).""" from trspecfit.utils.lmfit import MCMCResult # noqa: F401 (import check) file = self._make_fittable_file() with pytest.raises(ValueError, match="No baseline fit results"): file.get_correlations(fit_type="baseline") - with pytest.raises(ValueError, match="not available for Slice-by-Slice"): + with pytest.raises(ValueError, match="No sbs fit results"): file.get_conf_intervals(fit_type="sbs") + with pytest.raises(ValueError, match="Unknown fit_type"): + file.get_fit_results(fit_type="bogus") # type: ignore[arg-type] # @pytest.mark.slow @@ -1254,6 +1262,7 @@ def test_get_mcmc_table_excludes_fixed_parameters(self): file.model_active.create_value_1d() assert file.model_active.value_1d is not None # type guard file.data_base = file.model_active.value_1d.copy() + file.data = file.model_active.value_1d.copy() # fingerprint for slot capture file.e_lim = [0, len(file.energy)] mc = MC(use_mc=1, steps=20, nwalkers=32, burn=5, thin=1) From 374a65f1365ebdb58a8c8005de3fbe84d705bb7c Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Wed, 15 Jul 2026 23:47:03 -0700 Subject: [PATCH 07/29] purify the fitlib SbS conversion functions results_to_df and results_to_fit_2d are now pure conversions: the fit_pars.csv / fit_2d.csv writes, the per-parameter plotting, and the save_df/save_2d flag logic move into their sole caller, _save_sbs_fit_legacy (byte-identical output, pinned by the export-parity tests). That legacy block is slated for removal when auto-export routes through the slot exporter. --- PLAN.md | 22 ++++--- src/trspecfit/fitlib.py | 114 +++++-------------------------------- src/trspecfit/trspecfit.py | 53 +++++++++++++---- 3 files changed, 70 insertions(+), 119 deletions(-) diff --git a/PLAN.md b/PLAN.md index cfc8b44..df02111 100644 --- a/PLAN.md +++ b/PLAN.md @@ -78,15 +78,19 @@ decisions 2026-07-14. kwarg); stages=2 default → leastsq covar → correl present. Full notebook re-run deferred to the Phase 6 docs/examples pass. -## Phase 3 — Purify the conversion layer (fitlib) - -- [ ] `results_to_df`: strip CSV writing and plotting → pure - results-list → DataFrame conversion (drop `save_df`/`save_path`/plot - args). Its only caller today is `_save_sbs_fit_legacy` (dies in Phase 4). -- [ ] `results_to_fit_2d`: strip `save_2d` CSV writing → pure reconstruction. - (Slot already stores the `fit` array; exporter doesn't need this.) -- [ ] `plt_fit_res_1d` / `plt_fit_res_2d` / `plt_fit_res_pars` remain the - pure renderers (already flag-driven via `_save_img_flag`). +## Phase 3 — Purify the conversion layer (fitlib) — DONE + +- [x] `results_to_df`: stripped CSV writing and plotting → pure + results-list → DataFrame conversion (dropped `save_df`/`save_path`/ + `num_fmt`/`delim`; kept `config` only for the `y_label` column name). +- [x] `results_to_fit_2d`: stripped `save_2d` CSV writing → pure + reconstruction (slot already stores the `fit` array). +- [x] `_save_sbs_fit_legacy` compensates inline (CSV writes + the + varied-only `plt_fit_res_pars` flag logic) — byte-identical output, + pinned by the slow export-parity tests; the whole block dies in + Phase 4. +- [x] `plt_fit_res_1d` / `plt_fit_res_2d` / `plt_fit_res_pars` remain the + pure renderers (flag-driven via `_save_img_flag`). ## Phase 4 — Route auto-export through slots; delete legacy path diff --git a/src/trspecfit/fitlib.py b/src/trspecfit/fitlib.py index 117c5ba..2d488b8 100644 --- a/src/trspecfit/fitlib.py +++ b/src/trspecfit/fitlib.py @@ -1083,17 +1083,14 @@ def results_to_df( x: ArrayLike | None = None, index: ArrayLike | None = None, config: PlotConfig | None = None, - save_df: int = -2, - save_path: PathLike = "", - num_fmt: str = "%.6e", - delim: str = ",", ) -> pd.DataFrame: """ - Convert Slice-by-Slice fit results to DataFrame with parameter plots. + Convert Slice-by-Slice fit results to a DataFrame. - Transforms list of fit results (from slice-by-slice fitting) into - a pandas DataFrame with time/index as rows and parameters as columns. - Optionally creates individual plots for each parameter vs. time. + Pure conversion: transforms a list of fit results (from slice-by-slice + fitting) into a pandas DataFrame with time/index as rows and parameters + as columns. Saving and plotting are the caller's responsibility + (``df.to_csv`` / ``plt_fit_res_pars``). Parameters ---------- @@ -1105,24 +1102,8 @@ def results_to_df( index : array-like, optional Index values (e.g., slice numbers). If provided, included as column. config : PlotConfig, optional - Plot configuration. If None, uses defaults. - save_df : {-2, -1, 0, 1}, default=-2 - Output mode (standard ``_finalize_plot`` convention): - - - -2: neither save nor show (do nothing) - - -1: save ``fit_pars.csv`` + parameter PNGs, do not display - - 0: display only (no files written) - - 1: save ``fit_pars.csv`` + parameter PNGs and display - - Only *varied* (not fixed) parameters are ever displayed; when saving, - every parameter PNG is written regardless of vary state. - save_path : str or Path, default='' - Directory path for saving files (not full filename) (created if not exists). - Files saved: 'fit_pars.csv', '{param_name}.png' for each parameter - num_fmt : str, default='%.6e' - Float format applied to ``fit_pars.csv`` (pandas ``float_format``). - delim : str, default=',' - Delimiter applied to ``fit_pars.csv`` (pandas ``sep``). + Supplies the label of the ``x`` column (``config.y_label``). + If None, uses defaults. Returns ------- @@ -1139,8 +1120,6 @@ def results_to_df( # transform lmfit_wrapper results to dataframe df = ulmfit.list_of_par_to_df(results) - # get columns names for plot before adding x/index - cols_plt = df.columns # insert x (time) and index data if passed if x is not None: @@ -1148,46 +1127,6 @@ def results_to_df( if index is not None: df.insert(0, "index", index) - # get par_fin([1]) of first slice(index=0) - # (their "vary" attribute is the same for all) - df_par_fin_slice0 = ulmfit.par_to_df( - lmfit_params=results[0][1].params, col_type="min" - ) - # Map save_df onto per-parameter save_img flags using the standard - # convention (1 save+show, -1 save+close, 0 show only, -2 neither). Only - # varied parameters are ever shown; every parameter is saved when saving. - do_save = abs(save_df) == 1 - do_show = save_df >= 0 - save_array = [] - for vary in df_par_fin_slice0["vary"]: - show_this = do_show and bool(vary) - if do_save and show_this: - save_array.append(1) - elif do_save: - save_array.append(-1) - elif show_this: - save_array.append(0) - else: - save_array.append(-2) - - if do_save: - pathlib.Path(save_path).mkdir(parents=True, exist_ok=True) - # save the dataframe (index, x axis, parameter1, parameter2, ... - df.to_csv( - pathlib.Path(save_path) / "fit_pars.csv", - float_format=num_fmt, - sep=delim, - ) - if do_save or do_show: - # plot individual parameters as a function of time (s) - plt_fit_res_pars( - df=df.loc[:, list(cols_plt)], - x=x, - config=config, - save_img=save_array, - save_path=save_path, - ) - return df @@ -1197,17 +1136,16 @@ def results_to_fit_2d( const: tuple[Any, ...], args: tuple[Any, ...], parameter_names: list[str] | None = None, - num_fmt: str = "%.6e", - delim: str = ",", - save_2d: int = 0, - save_path: PathLike = "", ) -> np.ndarray: """ Reconstruct 2D fit spectrum from Slice-by-Slice fit results. - Takes individual 1D fit results (one per time slice) and stacks them - into a complete 2D fit array. This allows visualization and comparison - with the measured 2D data for Slice-by-Slice fitting. + Pure reconstruction: takes individual 1D fit results (one per time + slice) and stacks them into a complete 2D fit array. This allows + visualization and comparison with the measured 2D data for + Slice-by-Slice fitting. Saving is the caller's responsibility + (``np.savetxt``); note that completed SbS fits already persist this + array as ``SavedFitSlot.fit``. Parameters ---------- @@ -1221,7 +1159,7 @@ def results_to_fit_2d( parameter_names : list of str, optional For DataFrame results: select and order these columns as the parameter vector before evaluation. Pass when the DataFrame may - carry extra non-parameter columns (e.g. the metrics columns in + carry extra non-parameter columns (e.g. the index/time columns in ``results_to_df`` output); extra columns are otherwise passed to the fit function as parameters. If None, all columns are used in DataFrame order. Ignored for list results. @@ -1233,20 +1171,6 @@ def results_to_fit_2d( args : tuple Arguments for fit function (model, dim). Passed to residual_fun for spectrum generation. - num_fmt : str, default='%.6e' - Number format for saving (scientific notation with 6 decimals) - delim : str, default=',' - Delimiter for CSV output - save_2d : {-1, 0, 1}, default=0 - Save 2D fit to file: - - - 0: Don't save - - 1: Save to CSV - - -1: Save to CSV (same as 1) - - save_path : str or Path, default='' - Directory path for saving. File saved as: save_path/fit_2d.csv - Directory created if doesn't exist. Returns ------- @@ -1300,15 +1224,7 @@ def results_to_fit_2d( args=args, ) ) - fit_2d = np.asarray(lst) - # - if abs(save_2d) == 1: - pathlib.Path(save_path).mkdir(parents=True, exist_ok=True) - np.savetxt( - pathlib.Path(save_path) / "fit_2d.csv", fit_2d, fmt=num_fmt, delimiter=delim - ) - - return fit_2d + return np.asarray(lst) # diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index 905645f..c01ff25 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -3484,19 +3484,47 @@ def _save_sbs_fit_legacy( fmt=self.p.num_fmt, delimiter=self.p.delim, ) - # convert results, specifically par_fin to dataframe; this also plots - # the varied parameters as a function of time. save_df follows the - # standard convention: save+show / save-only when writing files, else - # show-only (save_df=0) so the varied-parameter curves appear inline. df_sbs = fitlib.results_to_df( results=self.results_sbs, x=self.time, index=np.arange(0, len(self.time)), config=self.plot_config, - save_df=(-1 if self.p.show_output == 0 else 1) if save_files else 0, + ) + if save_files: + # save the dataframe (index, x axis, parameter1, parameter2, ...) + df_sbs.to_csv( + pathlib.Path(save_path) / "fit_pars.csv", + float_format=self.p.num_fmt, + sep=self.p.delim, + ) + + # Per-parameter curves vs time. Only varied parameters are shown; + # every parameter PNG is written when saving (save_img convention: + # 1 save+show, -1 save+close, 0 show only, -2 neither). + do_show = self.p.show_output >= 1 if save_files else True + vary_flags = ulmfit.par_to_df( + lmfit_params=self.results_sbs[0][1].params, col_type="min" + )["vary"] + save_array = [] + for vary in vary_flags: + show_this = do_show and bool(vary) + if save_files and show_this: + save_array.append(1) + elif save_files: + save_array.append(-1) + elif show_this: + save_array.append(0) + else: + save_array.append(-2) + par_cols = [ + c for c in df_sbs.columns if c not in ("index", self.plot_config.y_label) + ] + fitlib.plt_fit_res_pars( + df=df_sbs.loc[:, par_cols], + x=self.time, + config=self.plot_config, + save_img=save_array, save_path=save_path, - num_fmt=self.p.num_fmt, - delim=self.p.delim, ) # get slice-by-slice fit spectra as a 2D map (write CSV only when saving) @@ -3505,11 +3533,14 @@ def _save_sbs_fit_legacy( parameter_names=self.model_sbs.parameter_names, const=self.model_sbs.const, args=self.model_sbs.args, - num_fmt=self.p.num_fmt, - delim=self.p.delim, - save_2d=(-1 if self.p.show_output == 0 else 1) if save_files else 0, - save_path=save_path, ) + if save_files: + np.savetxt( + pathlib.Path(save_path) / "fit_2d.csv", + fit_2d_sbs, + fmt=self.p.num_fmt, + delimiter=self.p.delim, + ) # plot data, fit, and residual 2D maps (save only when writing files) fitlib.plt_fit_res_2d( From 6233ac4535cfaf4d83164c4542aedb99de7da397 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Thu, 16 Jul 2026 14:42:30 -0700 Subject: [PATCH 08/29] route auto-export through the slot exporter, drop the legacy save path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fit_slice_by_slice / File.fit_2d / Project.fit_2d now auto-export via export_fit(path_results, overwrite=True) — the grouped slot layout — and display inline from the captured SavedFitSlot (_display_fit_2d_maps / _display_sbs_fit), so the shown figure equals the exported one. Project.fit_2d displays per-file maps instead of reading saved PNGs back. Fit-time diagnostics keep writing under model_path(). Deletes save_sbs_fit / save_2d_fit and the _save_*_fit_legacy impls; the _append_*_slot helpers return the slot. Export-parity tests now pin auto-export == explicit export; changelog and repo-architecture doc updated for the breaking layout change. --- CHANGELOG.md | 17 ++ PLAN.md | 46 ++-- docs/design/repo_architecture.md | 21 +- src/trspecfit/trspecfit.py | 401 ++++++++++--------------------- tests/test_auto_export.py | 45 ++-- tests/test_export_fits_parity.py | 157 +++++------- tests/test_file.py | 72 +----- tests/test_project_fit.py | 10 +- 8 files changed, 293 insertions(+), 476 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3276468..033c238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/). This file is maintained using the shared changelog workflow in [`docs/ai/changelog.md`](docs/ai/changelog.md). +## [Unreleased] + +### Added + +- **Fit slots persist the correlation matrix and MCMC acceptance fraction** (archive schema 3): `SavedFitSlot.correl` stores the varying-parameter correlation matrix when the optimizer produced covariance (`None` for covariance-less methods and project-level joint fits, slice 0 for SbS), and the `mcmc` payload gains emcee's per-walker `acceptance_fraction`. Schema-2 archives still load (new fields read as `None`); appends remain same-version only. +- `FitResults.get_fit_results` / `get_correlations` / `get_conf_intervals` / `get_mcmc`: the results accessors now live on `FitResults`, read the latest matching persisted slot (`file=` / `model=` / `fit_type=` filters), and therefore also work for SbS fits (slice-0 payloads) and on archives loaded with `FitResults.load`. The `File.get_*` methods remain as thin delegates with an added optional `model=` filter. + +### Changed + +- **Breaking: auto-export writes the slot-export layout.** `fit_slice_by_slice`, `File.fit_2d`, and `Project.fit_2d` now route auto-export through the same slot exporter as `export_fits`, producing the grouped `{path_results}/{file}/{model}__{fit_type}/` tree (params.csv, metrics, fit_2d.csv, observed_2d.csv, axis sidecars, PNGs) instead of the legacy flat `{file}/{fit_type}/{model}/` CSV layout. Fit-time diagnostics (per-stage parameter CSVs, SbS per-slice artifacts) still land under `File.model_path()`. Interactive display now renders from the captured fit slot, so the figure shown equals the figure exported; `Project.fit_2d` shows per-file maps instead of a PNG grid read back from disk. +- **Breaking: `File.get_correlations` raises for covariance-less fits** (e.g. Nelder without numdifftools, project joint fits) instead of returning an identity-with-zeros matrix that misread as "uncorrelated". +- `fitlib.results_to_df` and `fitlib.results_to_fit_2d` are pure conversions: the CSV writes, per-parameter plotting, and `save_df`/`save_2d` flags were removed along with the legacy save path that used them. + +### Removed + +- **Breaking: `File.save_sbs_fit` / `File.save_2d_fit`** (deprecated since the export pipeline landed) and their internal `_save_*_fit_legacy` implementations. Use `File.export_fit` / `Project.export_fits`. + ## [0.13.0] - 2026-07-13 ### Added diff --git a/PLAN.md b/PLAN.md index df02111..ec1b690 100644 --- a/PLAN.md +++ b/PLAN.md @@ -92,23 +92,35 @@ decisions 2026-07-14. - [x] `plt_fit_res_1d` / `plt_fit_res_2d` / `plt_fit_res_pars` remain the pure renderers (flag-driven via `_save_img_flag`). -## Phase 4 — Route auto-export through slots; delete legacy path - -- [ ] `fit_slice_by_slice` / `File.fit_2d` / `Project.fit_2d`: replace the - `_save_*_fit_legacy(save_files=...)` calls with the baseline template — - display (`show_output>=1`) via direct renderer call on slot data with - `_save_img_flag(save=..., show=...)`; export (`auto_export`) via the - slot exporter. Keep the skip-entirely guard when neither is set - (hot-path invariant pinned by `TestPlotHelperSkipped`). -- [ ] Per-slice PNGs inside the SbS loop (trspecfit.py ~3345) stay gated by - `auto_export` (unchanged behavior, new destination layout). -- [ ] Delete `_save_sbs_fit_legacy`, `_save_2d_fit_legacy`, `save_sbs_fit`, - `save_2d_fit`. **Grep whole repo** (notebooks, YAML, docs, llms.txt, - AGENTS.md) for callers/mentions. -- [ ] Update guardrail tests (`TestVerboseDisplayWithoutExport`, - `TestPlotHelperSkipped`, auto-export write/no-write classes) to the new - call targets; semantics (display-without-write, silent-skip) unchanged. -- [ ] Changelog entry flagging the auto-export layout change (breaking). +## Phase 4 — Route auto-export through slots; delete legacy path — DONE + +- [x] `fit_slice_by_slice` / `File.fit_2d` / `Project.fit_2d`: display + (`show_output>=1`) renders inline from the just-captured slot via new + `File._display_fit_2d_maps` / `_display_sbs_fit` helpers (save_img=0); + export (`auto_export`) calls `export_fit(self.p.path_results, ..., + overwrite=True)` — the slot exporter, rooted at `path_results` so the + configured results dir (and test redirection) is respected. Skip- + entirely guard preserved (`TestPlotHelperSkipped` green). The + `_append_*_slot` methods now return the slot (None for mocked/ + no-data fits, which then skip display/export gracefully). +- [x] Fit-time diagnostics unchanged in place: per-slice CSVs/PNGs and + `fit_wrapper`'s per-stage CSVs stay under `model_path()` + (`{path_results}/{file}/{fit_type}/{model}/`). Deviation from the + original "new destination" note: the export slot-dir name is + snapshot-dependent (hash suffix), so it can't be computed mid-fit, + and these are fit diagnostics, not results. +- [x] Deleted `_save_sbs_fit_legacy`, `_save_2d_fit_legacy`, `save_sbs_fit`, + `save_2d_fit`. Repo grep clean; `Project.fit_2d`'s PNG-grid display + replaced by per-file inline slot maps (works without auto_export now). +- [x] Tests: `TestVerboseDisplayWithoutExport` semantics unchanged; + `test_2d_legacy_saver_creates_its_directory` → slot-tree + refit- + overwrite tests; `test_export_fits_parity.py` repurposed to + auto-export ≡ explicit-export tree parity; test_file legacy-saver + validation tests → absence test; project-fit lifecycle test uses + `export_fit`. +- [x] CHANGELOG `[Unreleased]` section written (breaking layout, breaking + get_correlations, removals, schema 3, accessor relocation); + `repo_architecture.md` save/export section updated. ## Phase 5 — Explicit plotting API diff --git a/docs/design/repo_architecture.md b/docs/design/repo_architecture.md index bc2e23a..e451a86 100644 --- a/docs/design/repo_architecture.md +++ b/docs/design/repo_architecture.md @@ -182,15 +182,18 @@ HDF5 archive ────► reader ────► FitResults (FitResults.load one-line delegates to the corresponding `Project.*` / `FitResults.*` methods. There is no `File.load_fit`: load is path-scoped, not file-scoped. -The legacy `File.save_sbs_fit` / `File.save_2d_fit` are deprecated -aliases that emit `DeprecationWarning` and forward to the new -`File.export_fit`. The legacy on-disk layout is preserved internally -by `_save_sbs_fit_legacy` / `_save_2d_fit_legacy`, which are called -from inside `fit_slice_by_slice` / `fit_2d` / `Project.fit_2d` on every -fit unless the auto-export side effect is disabled via -`Project.auto_export = False` (default `True`). Both are scheduled for -removal before v1.0.0; new code should use `Project.export_fits` / -`File.export_fit`. +Auto-export inside `fit_slice_by_slice` / `fit_2d` / `Project.fit_2d` +routes through the same slot exporter as explicit `export_fits` calls, +writing the grouped `{path_results}/{file}/{model}__{fit_type}/` tree +(unless disabled via `Project.auto_export = False`, default `True`). +Interactive display (`show_output >= 1`) renders inline from the +just-captured `SavedFitSlot` via the `File._display_*` helpers — the +figure a user sees is built from the same arrays the export saves. +Fit-time diagnostics (per-stage parameter CSVs from `fitlib.fit_wrapper`, +SbS per-slice CSVs/PNGs) are separate from the results export and land +under `File.model_path()` (`{path_results}/{file}/{fit_type}/{model}/`). +The pre-0.14 legacy savers (`save_sbs_fit` / `save_2d_fit` and their +`_save_*_fit_legacy` impls, which wrote a flat layout) were removed. ## `config/` — runtime configuration diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index c01ff25..ff587fa 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -455,8 +455,8 @@ def export_fits( root = pathlib.Path(filepath) # Per-file plot_config preserves File.plot_config customizations - # (axis labels, colormaps, etc.) — File.save_sbs_fit / save_2d_fit - # used self.plot_config for plotting, and we keep that contract. + # (axis labels, colormaps, etc.) — the fit methods use + # self.plot_config for inline display, and export keeps that contract. plot_configs: dict[str, Any] = {} for sf in project.files: live = self._find_file_for_slot(sf.slots[0]) @@ -1451,7 +1451,7 @@ def fit_2d( ) # Distribute final parameters back to file models and hook into - # the standard File 2D-fit lifecycle so that save_2d_fit() and + # the standard File 2D-fit lifecycle so that export_fit() and # get_fit_results("2d") work on project-fitted files. mapping = project_fit_info["mapping"] models = project_fit_info["models"] @@ -1502,39 +1502,35 @@ def fit_2d( # Append a per-file 2D slot to Project._fit_history so the joint fit # is discoverable via Project.results, matching the File.fit_2d() # entry point. + slots_2d: list[fit_io.SavedFitSlot | None] = [] if joint_result: for f in self.files: - f._append_2d_slot(model_name=model_name, fit_fun_str="fit_model_mcp") + slots_2d.append( + f._append_2d_slot( + model_name=model_name, fit_fun_str="fit_model_mcp" + ) + ) - # Save per-file 2D fit results (silently) - if self.auto_export: - saved = self.show_output - self.show_output = 0 - try: - for f in self.files: - if f.model_2d is not None: - path_2d = f.model_path(model_name, fit_type="2d") - f._save_2d_fit_legacy(save_path=path_2d) - finally: - self.show_output = saved + # Export per-file 2D fit results through the slot exporter (silently) + if self.auto_export and any(s is not None for s in slots_2d): + self.export_fits( + self.path_results, + model=model_name, + fit_type="2d", + overwrite=True, + show_output=0, + ) if self.show_output >= 1: fitlib.time_display( t_start=t_start, print_str="Time elapsed for project-level 2D fit: ", ) - # Show saved 2D fit plots in a grid - import matplotlib.image as mpimg - - images = [] - for f in self.files: - img_path = ( - f.model_path(model_name, fit_type="2d") / "2D_data_fit_res.png" - ) - if img_path.exists(): - images.append(mpimg.imread(str(img_path))) - if images: - uplt.plot_grid(images, columns=min(3, len(images))) + # Show each file's data/fit/residual maps inline from its slot. + if slots_2d: + for f, slot in zip(self.files, slots_2d, strict=True): + if slot is not None: + f._display_fit_2d_maps(slot) # @@ -3078,52 +3074,6 @@ def export_fit( show_output=show_output, ) - # - # ------------------------------------------------------------------ - # Deprecated aliases — scheduled for removal before v1.0.0. - # See TODO.md "Build & release → Remove legacy/backwards-compat code". - # ------------------------------------------------------------------ - - # - def save_sbs_fit(self, save_path: PathLike) -> None: - """ - .. deprecated:: - Use :meth:`File.export_fit` (``fit_type="sbs"``) instead. - ``save_sbs_fit`` is scheduled for removal before v1.0.0. - - Behavior is preserved: this wrapper still writes the legacy - on-disk layout. Only the warning is new. - """ - - warnings.warn( - "File.save_sbs_fit is deprecated and will be removed before " - "v1.0.0; use File.export_fit(fit_type='sbs', filepath=...) " - "for the supported export pipeline.", - DeprecationWarning, - stacklevel=2, - ) - self._save_sbs_fit_legacy(save_path) - - # - def save_2d_fit(self, save_path: PathLike) -> None: - """ - .. deprecated:: - Use :meth:`File.export_fit` (``fit_type="2d"``) instead. - ``save_2d_fit`` is scheduled for removal before v1.0.0. - - Behavior is preserved: this wrapper still writes the legacy - on-disk layout. Only the warning is new. - """ - - warnings.warn( - "File.save_2d_fit is deprecated and will be removed before " - "v1.0.0; use File.export_fit(fit_type='2d', filepath=...) " - "for the supported export pipeline.", - DeprecationWarning, - stacklevel=2, - ) - self._save_2d_fit_legacy(save_path) - # def fit_slice_by_slice( self, @@ -3235,7 +3185,8 @@ def fit_slice_by_slice( "run define_baseline() first or use seed_adapt=None." ) - # define path where SbS fit results will be saved to + # path for fit-time diagnostics (per-slice CSVs/PNGs from the fit + # loop); the results export goes through export_fit below path_sbs_results = self.model_path(model_name, fit_type="sbs") if seed_source == "model": @@ -3407,7 +3358,7 @@ def _slice_path(s_i: int) -> pathlib.Path: raise self.results_sbs = [by_id[i] for i in sorted(by_id)] # mirror the serial path's final model state so downstream - # consumers (save_sbs_fit, plot helpers) see identical + # consumers (slot capture, plot helpers) see identical # const/args regardless of which path produced the results. self.model_sbs.const = ( self.energy, @@ -3420,15 +3371,25 @@ def _slice_path(s_i: int) -> pathlib.Path: self.model_sbs.args = _args_sbs if stages >= 1: - # Extract slot BEFORE save_sbs_fit / seed restoration so the slot - # captures pristine per-slice fit state (results_sbs and parameter - # names taken before any post-fit cleanup). - self._append_sbs_slot(model_name=model_name, fit_fun_str=_fun_str) - # Display the data/fit/residual maps (and varied-parameter plots) - # when interactive (show_output); save only when auto_export. - if self.p.auto_export or self.p.show_output >= 1: - self._save_sbs_fit_legacy( - save_path=path_sbs_results, save_files=self.p.auto_export + # Extract slot BEFORE seed restoration so the slot captures + # pristine per-slice fit state (results_sbs and parameter names + # taken before any post-fit cleanup). + slot_sbs = self._append_sbs_slot( + model_name=model_name, fit_fun_str=_fun_str + ) + # Display inline when interactive (show_output); export the slot + # when auto_export — mirroring fit_baseline's split. Per-slice + # diagnostics (fit_wrapper CSVs, per-slice PNGs) were already + # written under model_path during the fit loop. + if self.p.show_output >= 1 and slot_sbs is not None: + self._display_sbs_fit(slot_sbs) + if self.p.auto_export and slot_sbs is not None: + self.export_fit( + self.p.path_results, + model=model_name, + fit_type="sbs", + overwrite=True, + show_output=0, ) self.model_sbs.update_value(new_par_values=seed_template, par_select="all") self.model_sbs.args = _args_sbs @@ -3438,122 +3399,63 @@ def _slice_path(s_i: int) -> pathlib.Path: ) # - def _save_sbs_fit_legacy( - self, save_path: PathLike, *, save_files: bool = True - ) -> None: + def _display_fit_2d_maps(self, slot: fit_io.SavedFitSlot) -> None: """ - Legacy SbS export — preserves the original on-disk layout used by - the auto-export path inside :meth:`fit_slice_by_slice`. - - Internal use only. The public ``save_sbs_fit`` is a deprecated - wrapper that forwards to :meth:`export_fit` (different layout); - prefer :meth:`export_fit` for new code. + Show a 2D-shaped slot's data/fit/residual maps inline (no writes). - Parameters - ---------- - save_files : bool, default True - When ``True`` (auto-export), write the CSVs and save the figures. - When ``False`` (interactive display only), skip the CSVs and just - show the varied-parameter and data/fit/residual plots inline. + The slot's ``observed`` / ``fit`` arrays live on the cropped fit + grid, so the live axes are cut to the slot's selection before + plotting (mirrors ``fit_io._slot_axes``). """ - if self.model_sbs is None or self.time is None: - raise ValueError( - "Slice-by-Slice model/results are incomplete; nothing to save." - ) - if self.data is None: - raise ValueError("Data missing; cannot save Slice-by-Slice fit.") - if self.energy is None: - raise ValueError("Energy axis missing; cannot save Slice-by-Slice fit.") - if self.model_sbs.const is None or self.model_sbs.args is None: - raise ValueError( - "Slice-by-Slice model const/args missing; cannot reconstruct 2D fit." - ) - if save_files: - pathlib.Path(save_path).mkdir(parents=True, exist_ok=True) - # axis sidecars (one value per row); paired with fit_2d.csv - np.savetxt( - pathlib.Path(save_path) / "energy.csv", - np.asarray(self.energy), - fmt=self.p.num_fmt, - delimiter=self.p.delim, - ) - np.savetxt( - pathlib.Path(save_path) / "time.csv", - np.asarray(self.time), - fmt=self.p.num_fmt, - delimiter=self.p.delim, - ) - df_sbs = fitlib.results_to_df( - results=self.results_sbs, - x=self.time, - index=np.arange(0, len(self.time)), + assert self.energy is not None # type guard + assert self.time is not None # type guard + energy = np.asarray(self.energy) + e_lim = slot.selection.get("e_lim") + if e_lim: + energy = energy[int(e_lim[0]) : int(e_lim[1])] + time = np.asarray(self.time) + if slot.fit_type == "2d": + t_lim = slot.selection.get("t_lim") + if t_lim: + time = time[int(t_lim[0]) : int(t_lim[1])] + fitlib.plt_fit_res_2d( + data=slot.observed, + fit=slot.fit, + x=energy, + y=time, config=self.plot_config, + save_img=0, ) - if save_files: - # save the dataframe (index, x axis, parameter1, parameter2, ...) - df_sbs.to_csv( - pathlib.Path(save_path) / "fit_pars.csv", - float_format=self.p.num_fmt, - sep=self.p.delim, - ) - # Per-parameter curves vs time. Only varied parameters are shown; - # every parameter PNG is written when saving (save_img convention: - # 1 save+show, -1 save+close, 0 show only, -2 neither). - do_show = self.p.show_output >= 1 if save_files else True - vary_flags = ulmfit.par_to_df( - lmfit_params=self.results_sbs[0][1].params, col_type="min" - )["vary"] - save_array = [] - for vary in vary_flags: - show_this = do_show and bool(vary) - if save_files and show_this: - save_array.append(1) - elif save_files: - save_array.append(-1) - elif show_this: - save_array.append(0) - else: - save_array.append(-2) - par_cols = [ - c for c in df_sbs.columns if c not in ("index", self.plot_config.y_label) - ] - fitlib.plt_fit_res_pars( - df=df_sbs.loc[:, par_cols], - x=self.time, - config=self.plot_config, - save_img=save_array, - save_path=save_path, - ) + # + def _display_sbs_fit(self, slot: fit_io.SavedFitSlot) -> None: + """ + Show an SbS slot inline: varied-parameter evolution + fit maps. - # get slice-by-slice fit spectra as a 2D map (write CSV only when saving) - fit_2d_sbs = fitlib.results_to_fit_2d( - results=df_sbs, - parameter_names=self.model_sbs.parameter_names, - const=self.model_sbs.const, - args=self.model_sbs.args, - ) - if save_files: - np.savetxt( - pathlib.Path(save_path) / "fit_2d.csv", - fit_2d_sbs, - fmt=self.p.num_fmt, - delimiter=self.p.delim, - ) + Vary flags come from the live slice-0 result (the wide per-slice + params frame carries no vary column; every slice shares the same + vary set). + """ - # plot data, fit, and residual 2D maps (save only when writing files) - fitlib.plt_fit_res_2d( - data=self.data, - fit=fit_2d_sbs, - x=self.energy, - y=self.time, - config=self.plot_config, - x_lim=self.e_lim, - y_lim=self.t_lim, - save_img=(-1 if self.p.show_output == 0 else 1) if save_files else 0, - save_path=save_path, + assert self.time is not None # type guard + vary_df = ulmfit.par_to_df( + lmfit_params=self.results_sbs[0][1].params, col_type="min" ) + varied = { + str(name) + for name, vary in zip(vary_df["name"], vary_df["vary"], strict=True) + if vary + } + par_cols = [c for c in slot.params.columns if c in varied] + if par_cols: + fitlib.plt_fit_res_pars( + df=slot.params.loc[:, par_cols], + x=np.asarray(self.time)[: len(slot.params)], + config=self.plot_config, + save_img=0, + ) + self._display_fit_2d_maps(slot) # # ------------------------------------------------------------------ @@ -3561,7 +3463,9 @@ def _save_sbs_fit_legacy( # ------------------------------------------------------------------ # - def _append_baseline_slot(self, *, model_name: str, fit_fun_str: str) -> None: + def _append_baseline_slot( + self, *, model_name: str, fit_fun_str: str + ) -> fit_io.SavedFitSlot | None: """ Build a SavedFitSlot from the just-completed baseline fit and append it to ``self.p._fit_history``. Uses copied snapshot args so the slot @@ -3572,10 +3476,10 @@ def _append_baseline_slot(self, *, model_name: str, fit_fun_str: str) -> None: assert self.data_base is not None # type guard assert self.energy is not None # type guard if self.data is None: - return # data_base-only fixture / no source File data to fingerprint + return None # data_base-only fixture / no File data to fingerprint result_fin = self.model_base.result[1] if not hasattr(result_fin, "params"): - return # mocked / placeholder result; nothing to record + return None # mocked / placeholder result; nothing to record # Evaluate model on the same grid as data_base, then crop to e_lim # so observed.shape == fit.shape and matches the residual grid. fit_full = np.asarray( @@ -3634,6 +3538,7 @@ def _append_baseline_slot(self, *, model_name: str, fit_fun_str: str) -> None: mcmc=mcmc, ) self.p._fit_history.append(slot) + return slot # def _append_spectrum_slot( @@ -3644,7 +3549,7 @@ def _append_spectrum_slot( time_point: float | None, time_range: list[float] | None, time_type: str, - ) -> None: + ) -> fit_io.SavedFitSlot | None: """Build and append a SavedFitSlot for a completed spectrum fit.""" assert self.model_spec is not None # type guard @@ -3652,7 +3557,7 @@ def _append_spectrum_slot( assert self.energy is not None # type guard result_fin = self.model_spec.result[1] if not hasattr(result_fin, "params"): - return # mocked / placeholder result; nothing to record + return None # mocked / placeholder result; nothing to record fit_full = np.asarray( fitlib.residual_fun( par=result_fin.params, @@ -3708,9 +3613,12 @@ def _append_spectrum_slot( mcmc=mcmc, ) self.p._fit_history.append(slot) + return slot # - def _append_sbs_slot(self, *, model_name: str, fit_fun_str: str) -> None: + def _append_sbs_slot( + self, *, model_name: str, fit_fun_str: str + ) -> fit_io.SavedFitSlot | None: """ Build and append a SavedFitSlot for a completed slice-by-slice fit. @@ -3725,7 +3633,7 @@ def _append_sbs_slot(self, *, model_name: str, fit_fun_str: str) -> None: assert self.data is not None # type guard assert self.energy is not None # type guard if not self.results_sbs or not hasattr(self.results_sbs[0][1], "params"): - return # mocked / placeholder results; nothing to record + return None # mocked / placeholder results; nothing to record n_slices = len(self.data) e_lim = list(self.e_lim) if self.e_lim else None # Per-slice observed view + per-slice model evaluation. residual_fun @@ -3793,9 +3701,12 @@ def _append_sbs_slot(self, *, model_name: str, fit_fun_str: str) -> None: mcmc=slice0_mcmc, ) self.p._fit_history.append(slot) + return slot # - def _append_2d_slot(self, *, model_name: str, fit_fun_str: str) -> None: + def _append_2d_slot( + self, *, model_name: str, fit_fun_str: str + ) -> fit_io.SavedFitSlot | None: """Build and append a SavedFitSlot for a completed 2D global fit.""" assert self.model_2d is not None # type guard @@ -3803,7 +3714,7 @@ def _append_2d_slot(self, *, model_name: str, fit_fun_str: str) -> None: assert self.energy is not None # type guard result_fin = self.model_2d.result[1] if not hasattr(result_fin, "params"): - return # mocked / placeholder result; nothing to record + return None # mocked / placeholder result; nothing to record # Evaluate the 2D model on the full grid; crop to (t_lim, e_lim) so # observed.shape == fit.shape and matches the residual grid. fit_full = np.asarray( @@ -3867,6 +3778,7 @@ def _append_2d_slot(self, *, model_name: str, fit_fun_str: str) -> None: mcmc=mcmc, ) self.p._fit_history.append(slot) + return slot # def _resolve_model(self, model_name: str | None) -> mcp.Model: @@ -4158,7 +4070,8 @@ def fit_2d(self, model_name: str, stages: int = 1, **fit_wrapper_kwargs) -> None if self.energy is None or self.time is None or self.data is None: raise ValueError("Data/axes missing; cannot run 2D fit.") - # define path where 2D fit results will be saved to + # path for fit-time diagnostics (fit_wrapper's per-stage CSVs); the + # results export goes through export_fit below path_2d_results = self.model_path(model_name, fit_type="2d") # set all fixed 2D fit parameters equal to baseline model results @@ -4245,19 +4158,26 @@ def fit_2d(self, model_name: str, stages: int = 1, **fit_wrapper_kwargs) -> None # Write optimized values back to model.lmfit_pars. fit_wrapper # optimizes a deepcopy, so model.lmfit_pars may be stale — especially # on the GIR path where fit_model_gir never calls model.update_value. + slot_2d: fit_io.SavedFitSlot | None = None if stages >= 1 and self.model_2d.result[1] != []: final_params = self.model_2d.result[1].params for name in self.model_2d.parameter_names: if name in final_params: self.model_2d.lmfit_pars[name].value = final_params[name].value - self._append_2d_slot(model_name=model_name, fit_fun_str=_fun_str) + slot_2d = self._append_2d_slot(model_name=model_name, fit_fun_str=_fun_str) if stages >= 1: - # Display the data/fit/residual maps when interactive (show_output), - # save them only when auto_export — mirroring fit_baseline. - if self.p.auto_export or self.p.show_output >= 1: - self._save_2d_fit_legacy( - save_path=path_2d_results, save_files=self.p.auto_export + # Display inline when interactive (show_output); export the slot + # when auto_export — mirroring fit_baseline's split. + if self.p.show_output >= 1 and slot_2d is not None: + self._display_fit_2d_maps(slot_2d) + if self.p.auto_export and slot_2d is not None: + self.export_fit( + self.p.path_results, + model=model_name, + fit_type="2d", + overwrite=True, + show_output=0, ) if self.p.show_output >= 1: fitlib.time_display( @@ -4266,75 +4186,6 @@ def fit_2d(self, model_name: str, stages: int = 1, **fit_wrapper_kwargs) -> None # display final pars below figure display(self.model_2d.result[1].params) - # - def _save_2d_fit_legacy( - self, save_path: PathLike, *, save_files: bool = True - ) -> None: - """ - Legacy 2D export — preserves the original on-disk layout used by - the auto-export path inside :meth:`fit_2d` and - :meth:`Project.fit_2d`. - - Internal use only. The public ``save_2d_fit`` is a deprecated - wrapper that forwards to :meth:`export_fit` (different layout); - prefer :meth:`export_fit` for new code. - - Parameters - ---------- - save_files : bool, default True - When ``True`` (auto-export), write the CSVs and save the figure. - When ``False`` (interactive display only), skip the CSVs and just - show the data/fit/residual maps inline. - """ - - if ( - self.model_2d is None - or self.energy is None - or self.time is None - or self.data is None - ): - raise ValueError("2D model/data/axes missing; nothing to save.") - self.model_2d.create_value_2d() # update 2D spectrum to final fit result - if self.model_2d.value_2d is None: - raise ValueError( - "2D model evaluation did not produce value_2d; nothing to save." - ) - if save_files: - pathlib.Path(save_path).mkdir(parents=True, exist_ok=True) - # save 2D fit map as CSV (rows=time, cols=energy), mirroring save_sbs_fit - np.savetxt( - pathlib.Path(save_path) / "fit_2d.csv", - self.model_2d.value_2d, - fmt=self.p.num_fmt, - delimiter=self.p.delim, - ) - # axis sidecars (one value per row); paired with fit_2d.csv - np.savetxt( - pathlib.Path(save_path) / "energy.csv", - np.asarray(self.energy), - fmt=self.p.num_fmt, - delimiter=self.p.delim, - ) - np.savetxt( - pathlib.Path(save_path) / "time.csv", - np.asarray(self.time), - fmt=self.p.num_fmt, - delimiter=self.p.delim, - ) - # plot data, fit, and residual 2D maps (save only when writing files) - fitlib.plt_fit_res_2d( - data=self.data, - fit=self.model_2d.value_2d, - x=self.energy, - y=self.time, - config=self.plot_config, - x_lim=self.e_lim, - y_lim=self.t_lim, - save_img=(-1 if self.p.show_output == 0 else 1) if save_files else 0, - save_path=save_path, - ) - # dpi_plot = round(1.5 *self.p.dpi_plt), NOT AVAILABLE YET (fig_size) - # def get_fit_results( self, diff --git a/tests/test_auto_export.py b/tests/test_auto_export.py index 18e2fcc..85afed7 100644 --- a/tests/test_auto_export.py +++ b/tests/test_auto_export.py @@ -141,13 +141,10 @@ def test_baseline_writes_files(self, tmp_path): assert outputs, "expected at least one auto-written file" # - def test_2d_legacy_saver_creates_its_directory(self, tmp_path): - """Regression: the 2D legacy saver writes np.savetxt sidecars into a - directory that must be created on write (dirs are no longer - pre-created at path computation). File.fit_2d masks this — its - fit_wrapper CSVs create the dir first — but Project.fit_2d's - per-file save loop and the save_2d_fit wrapper reach the saver - with a fresh directory.""" + def test_2d_auto_export_writes_slot_tree(self, tmp_path): + """fit_2d auto-export routes through the slot exporter: the grouped + ``//__2d/`` tree appears with the slot + artifacts (fit_2d.csv + observed_2d.csv + axis sidecars).""" project, file = _baseline_setup(tmp_path, auto_export=True) file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) @@ -159,10 +156,30 @@ def test_2d_legacy_saver_creates_its_directory(self, tmp_path): ) file.fit_2d("single_glp", stages=1, try_ci=0) - fresh = tmp_path / "fresh_export" - with pytest.warns(DeprecationWarning): - file.save_2d_fit(fresh) - assert (fresh / "fit_2d.csv").exists() + slot_dir = tmp_path / "auto" / file.name / "single_glp__2d" + assert (slot_dir / "fit_2d.csv").exists() + assert (slot_dir / "observed_2d.csv").exists() + assert (slot_dir / "energy.csv").exists() + assert (slot_dir / "time.csv").exists() + + # + def test_2d_auto_export_overwrites_on_refit(self, tmp_path): + """Refitting the same (file, model, fit_type, selection) must not + raise FileExistsError — auto-export overwrites its own slot dir.""" + + project, file = _baseline_setup(tmp_path, auto_export=True) + file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) + file.add_time_dependence( + target_model="single_glp", + target_parameter="GLP_01_A", + dynamics_yaml="models/file_time.yaml", + dynamics_model=["MonoExpPos"], + ) + file.fit_2d("single_glp", stages=1, try_ci=0) + file.fit_2d("single_glp", stages=1, try_ci=0) # refit — must not raise + + slot_dir = tmp_path / "auto" / file.name / "single_glp__2d" + assert (slot_dir / "fit_2d.csv").exists() # @@ -307,9 +324,9 @@ def test_sbs_skips_per_slice_plot_when_no_export(self, tmp_path, monkeypatch): # class TestVerboseDisplayWithoutExport: """``show_output>=1`` + ``auto_export=False`` shows the data/fit/residual - maps inline (via ``_save_{sbs,2d}_fit_legacy(save_files=False)``) but writes - no files — the interactive-display path mirroring fit_baseline. Guards the - save_files=False branch added to fit_slice_by_slice / fit_2d.""" + maps inline (via the ``_display_*`` slot helpers) but writes no files — + the interactive-display path mirroring fit_baseline. Guards the + display/export split in fit_slice_by_slice / fit_2d.""" # def _verbose_no_export_setup(self, tmp_path): diff --git a/tests/test_export_fits_parity.py b/tests/test_export_fits_parity.py index 52aa085..2ac9f74 100644 --- a/tests/test_export_fits_parity.py +++ b/tests/test_export_fits_parity.py @@ -1,19 +1,16 @@ """ -``Project.export_fits`` parity vs the legacy ``_save_sbs_fit_legacy`` / -``_save_2d_fit_legacy`` paths. - -Goal: same column shapes as the old ``save_sbs_fit`` / ``save_2d_fit`` -outputs. The new export tree is slot-driven and richer (observed_2d.csv, -metrics.csv, params.csv land alongside the legacy files), but for the -artifacts that *do* overlap — ``fit_pars.csv``, ``fit_2d.csv``, -``energy.csv``, ``time.csv`` — column / shape parity must hold so users -can keep their downstream CSV-consuming pipelines unchanged. - -Strategy: redirect the project's auto-save directory into ``tmp_path``, -run the fit (which triggers the legacy auto-save via -``_save_sbs_fit_legacy`` / ``_save_2d_fit_legacy``), then call -``project.export_fits`` into a sibling directory. Read both trees and -diff column names + shapes. +Auto-export vs explicit ``Project.export_fits`` parity. + +Both paths route through the same slot exporter (``fit_io._export_slot``) +since the legacy ``_save_*_fit_legacy`` savers were removed: auto-export +inside ``fit_slice_by_slice`` / ``fit_2d`` writes the grouped slot tree +under ``Project.path_results``, and an explicit ``export_fits`` call +writes it under the caller's root. The artifacts must be identical — a +divergence means one of the paths grew its own writer again. + +Strategy: redirect ``path_results`` into ``tmp_path``, run the fit with +``auto_export=True`` (which writes the auto tree), then call +``project.export_fits`` into a sibling directory and diff the trees. """ from __future__ import annotations @@ -65,19 +62,16 @@ def _truth_2d_data(): # def _make_parity_fit_file(*, name: str, tmp_path: Path, spec_fun_str: str): - """Build a fit-side project + file with auto-save redirected into ``tmp_path``. - - Setting ``path_results`` after construction reroutes the legacy - auto-save (``model_path`` builds paths under - ``project.path_results``) into the test-scoped ``tmp_path / "legacy"`` - tree, so the test has full control over both outputs and the source - repo stays untouched. + """Build a fit-side project + file with auto-export redirected into + ``tmp_path / "auto"`` (auto-export writes the slot tree under + ``project.path_results``), so the test has full control over both + outputs and the source repo stays untouched. """ data = _truth_2d_data() # export parity is this test's subject, so opt back into auto-export project = make_project(name=name, spec_fun_str=spec_fun_str, auto_export=True) - project.path_results = tmp_path / "legacy" + project.path_results = tmp_path / "auto" file = File( parent_project=project, name="fit", @@ -97,11 +91,10 @@ def _make_parity_fit_file(*, name: str, tmp_path: Path, spec_fun_str: str): # @pytest.mark.slow def test_sbs_export_parity(tmp_path): - """SbS exports: fit_pars.csv / fit_2d.csv / energy.csv / time.csv match. + """SbS auto-export tree is identical to an explicit ``export_fits`` tree. - Verifies the new ``Project.export_fits`` SbS output has the same - column names and shapes as the legacy ``_save_sbs_fit_legacy`` files - that the auto-export path inside ``fit_slice_by_slice`` writes today. + Both must go through ``fit_io._export_slot``; the file sets and every + shared artifact's values are compared. """ project, file = _make_parity_fit_file( @@ -115,68 +108,44 @@ def test_sbs_export_parity(tmp_path): try_ci=0, ) - legacy_dir = project.path_results / file.name / "sbs" / "single_glp" + auto_dir = project.path_results / file.name / "single_glp__sbs" new_root = tmp_path / "new" project.export_fits(new_root, show_output=0) new_dir = new_root / file.name / "single_glp__sbs" - # --- fit_pars.csv: per-slice param values with [index, time, par...] cols. - # Legacy ``df.to_csv()`` emits a redundant pandas auto-index column; - # the new export writes ``index=False``, so the legacy file has one - # extra unnamed leading column. Strip "Unnamed: 0" before comparing - # so the parity check focuses on the meaningful columns. - legacy_fp = pd.read_csv(legacy_dir / "fit_pars.csv") - if str(legacy_fp.columns[0]).startswith("Unnamed"): - legacy_fp = legacy_fp.drop(columns=legacy_fp.columns[0]) + # --- identical artifact sets (both trees written by _export_slot) + auto_names = {p.name for p in auto_dir.rglob("*") if p.is_file()} + new_names = {p.name for p in new_dir.rglob("*") if p.is_file()} + assert auto_names == new_names + assert "fit_pars.csv" in auto_names + assert "fit_2d.csv" in auto_names + + # --- fit_pars.csv: per-slice param values with [index, time, par...] cols + auto_fp = pd.read_csv(auto_dir / "fit_pars.csv") new_fp = pd.read_csv(new_dir / "fit_pars.csv") - assert list(legacy_fp.columns) == list(new_fp.columns), ( - f"fit_pars.csv columns differ:\n legacy={list(legacy_fp.columns)}\n" - f" new={list(new_fp.columns)}" - ) - assert legacy_fp.shape == new_fp.shape - # Per-slice param values must match — both go through - # ``list_of_par_to_df`` on the same fit results. - for col in legacy_fp.columns: + assert list(auto_fp.columns) == list(new_fp.columns) + assert auto_fp.shape == new_fp.shape + for col in auto_fp.columns: np.testing.assert_allclose( - legacy_fp[col].to_numpy(dtype=float), + auto_fp[col].to_numpy(dtype=float), new_fp[col].to_numpy(dtype=float), rtol=0, atol=0, ) - # --- fit_2d.csv: stacked per-slice fit spectra (n_time × n_energy). - # Both paths re-evaluate the model at each slice's final params via - # ``residual_fun(..., res_type="fit")`` (legacy through - # ``results_to_fit_2d``, new through ``_slot_from_sbs``'s captured - # ``fit`` array), so values must match exactly. Asserting shape alone - # would let a bug that wrote the right-sized wrong matrix slip through. - legacy_2d = np.loadtxt(legacy_dir / "fit_2d.csv", delimiter=project.delim) + # --- fit_2d.csv: stacked per-slice fit spectra (n_time × n_energy), + # both from the slot's captured ``fit`` array. + auto_2d = np.loadtxt(auto_dir / "fit_2d.csv", delimiter=project.delim) new_2d = np.loadtxt(new_dir / "fit_2d.csv", delimiter=project.delim) - assert legacy_2d.shape == new_2d.shape == (len(file.time), len(file.energy)) - np.testing.assert_allclose(legacy_2d, new_2d, rtol=0, atol=0) + assert auto_2d.shape == new_2d.shape == (len(file.time), len(file.energy)) + np.testing.assert_allclose(auto_2d, new_2d, rtol=0, atol=0) # --- axis sidecars - legacy_e = np.loadtxt(legacy_dir / "energy.csv", delimiter=project.delim) - new_e = np.loadtxt(new_dir / "energy.csv", delimiter=project.delim) - assert legacy_e.shape == new_e.shape == (len(file.energy),) - np.testing.assert_array_equal(legacy_e, new_e) - - legacy_t = np.loadtxt(legacy_dir / "time.csv", delimiter=project.delim) - new_t = np.loadtxt(new_dir / "time.csv", delimiter=project.delim) - assert legacy_t.shape == new_t.shape == (len(file.time),) - np.testing.assert_array_equal(legacy_t, new_t) - - # --- the same parameter PNGs exist in both trees - legacy_pngs = {p.name for p in legacy_dir.glob("*.png")} - new_pngs = {p.name for p in new_dir.glob("*.png")} - # Per-parameter PNGs are emitted only for varied parameters; both - # trees go through the same plt_fit_res_pars helper, so the per- - # parameter set must match. The legacy path also emits an extra - # "*_par_fin*" / fit-quality figure in some pipelines, so check - # subset rather than equality. - per_param_legacy = {n for n in legacy_pngs if "GLP_01_" in n} - per_param_new = {n for n in new_pngs if "GLP_01_" in n} - assert per_param_legacy == per_param_new + for name, axis in (("energy.csv", file.energy), ("time.csv", file.time)): + auto_ax = np.loadtxt(auto_dir / name, delimiter=project.delim) + new_ax = np.loadtxt(new_dir / name, delimiter=project.delim) + assert auto_ax.shape == new_ax.shape == (len(axis),) + np.testing.assert_array_equal(auto_ax, new_ax) # --------------------------------------------------------------------------- @@ -187,13 +156,7 @@ def test_sbs_export_parity(tmp_path): # @pytest.mark.slow def test_2d_export_parity(tmp_path): - """2D exports: fit_2d.csv / energy.csv / time.csv shapes match. - - The legacy ``_save_2d_fit_legacy`` writes fit_2d / energy / time CSVs - plus a residual-map PNG. The new export adds ``observed_2d.csv``, - ``params.csv``, ``metrics.csv`` (no legacy counterparts), but the - shared CSVs must keep identical shapes / values. - """ + """2D auto-export tree is identical to an explicit ``export_fits`` tree.""" project, file = _make_parity_fit_file( name="parity_2d", tmp_path=tmp_path, spec_fun_str="fit_model_gir" @@ -208,30 +171,30 @@ def test_2d_export_parity(tmp_path): ) file.fit_2d("single_glp", stages=1, try_ci=0) - legacy_dir = project.path_results / file.name / "2d" / "single_glp" + auto_dir = project.path_results / file.name / "single_glp__2d" new_root = tmp_path / "new" project.export_fits(new_root, fit_type="2d", show_output=0) new_dir = new_root / file.name / "single_glp__2d" - # --- fit_2d.csv - legacy_2d = np.loadtxt(legacy_dir / "fit_2d.csv", delimiter=project.delim) + # --- identical artifact sets + auto_names = {p.name for p in auto_dir.rglob("*") if p.is_file()} + new_names = {p.name for p in new_dir.rglob("*") if p.is_file()} + assert auto_names == new_names + + # --- fit_2d.csv (both from the slot's captured ``fit`` array) + auto_2d = np.loadtxt(auto_dir / "fit_2d.csv", delimiter=project.delim) new_2d = np.loadtxt(new_dir / "fit_2d.csv", delimiter=project.delim) - assert legacy_2d.shape == new_2d.shape == (len(file.time), len(file.energy)) - # Both go through ``residual_fun(..., res_type="fit")`` on the same - # final params; values should match to numerical precision. - np.testing.assert_allclose(legacy_2d, new_2d, rtol=0, atol=0) + assert auto_2d.shape == new_2d.shape == (len(file.time), len(file.energy)) + np.testing.assert_allclose(auto_2d, new_2d, rtol=0, atol=0) # --- axis sidecars (identical writers, identical inputs) - legacy_e = np.loadtxt(legacy_dir / "energy.csv", delimiter=project.delim) - new_e = np.loadtxt(new_dir / "energy.csv", delimiter=project.delim) - np.testing.assert_array_equal(legacy_e, new_e) - - legacy_t = np.loadtxt(legacy_dir / "time.csv", delimiter=project.delim) - new_t = np.loadtxt(new_dir / "time.csv", delimiter=project.delim) - np.testing.assert_array_equal(legacy_t, new_t) + for name in ("energy.csv", "time.csv"): + auto_ax = np.loadtxt(auto_dir / name, delimiter=project.delim) + new_ax = np.loadtxt(new_dir / name, delimiter=project.delim) + np.testing.assert_array_equal(auto_ax, new_ax) # --- residual-map PNG present in both - assert (legacy_dir / "2D_data_fit_res.png").exists() + assert (auto_dir / "2D_data_fit_res.png").exists() assert (new_dir / "2D_data_fit_res.png").exists() diff --git a/tests/test_file.py b/tests/test_file.py index 3a6b4ec..6634fe9 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -1059,7 +1059,6 @@ def test_fit_sbs_model_seed_allows_no_baseline_fit(self): return_value=[None, object(), None, None, None], ) as mock_fit, unittest.mock.patch("trspecfit.trspecfit.fitlib.plt_fit_res_1d"), - unittest.mock.patch.object(file, "_save_sbs_fit_legacy"), unittest.mock.patch("trspecfit.trspecfit.fitlib.time_display"), ): file.fit_slice_by_slice( @@ -1149,69 +1148,22 @@ def test_fit_2d_no_time_raises(self): with pytest.raises(ValueError, match="missing"): file.fit_2d("simple_energy") - # -- save_sbs_fit -- + # -- removed legacy savers -- # - def test_save_sbs_fit_no_model_raises(self): - """Legacy SbS save raises ValueError when SbS model is missing.""" + def test_legacy_savers_are_gone(self): + """save_sbs_fit / save_2d_fit (deprecated) and their _save_*_legacy + impls were removed with the auto-export slot routing; export_fit is + the only export entry point on File.""" file = self._make_file_with_model() - file.model_sbs = None - with pytest.raises(ValueError, match="incomplete"): - file._save_sbs_fit_legacy("/tmp/dummy") - - # - def test_save_sbs_fit_no_data_raises(self): - """Legacy SbS save raises ValueError when data is missing.""" - - file = self._make_file_with_model() - file.model_sbs = file.model_active - file.data = None - with pytest.raises(ValueError, match="Data missing"): - file._save_sbs_fit_legacy("/tmp/dummy") - - # -- save_2d_fit -- - - # - def test_save_2d_fit_no_model_raises(self): - """Legacy 2D save raises ValueError when 2D model is missing.""" - - file = self._make_file_with_model() - file.model_2d = None - with pytest.raises(ValueError, match="missing"): - file._save_2d_fit_legacy("/tmp/dummy") - - # - def test_save_2d_fit_no_data_raises(self): - """Legacy 2D save raises ValueError when data is missing.""" - - file = self._make_file_with_model() - file.model_2d = file.model_active - file.data = None - with pytest.raises(ValueError, match="missing"): - file._save_2d_fit_legacy("/tmp/dummy") - - # -- deprecation warnings -- - - # - def test_save_sbs_fit_emits_deprecation_warning(self): - """save_sbs_fit emits DeprecationWarning pointing at export_fit.""" - - file = self._make_file_with_model() - file.model_sbs = None # short-circuit so we don't need a real fit - with pytest.warns(DeprecationWarning, match="export_fit"): - with pytest.raises(ValueError): - file.save_sbs_fit("/tmp/dummy") - - # - def test_save_2d_fit_emits_deprecation_warning(self): - """save_2d_fit emits DeprecationWarning pointing at export_fit.""" - - file = self._make_file_with_model() - file.model_2d = None # short-circuit so we don't need a real fit - with pytest.warns(DeprecationWarning, match="export_fit"): - with pytest.raises(ValueError): - file.save_2d_fit("/tmp/dummy") + for name in ( + "save_sbs_fit", + "save_2d_fit", + "_save_sbs_fit_legacy", + "_save_2d_fit_legacy", + ): + assert not hasattr(file, name) # diff --git a/tests/test_project_fit.py b/tests/test_project_fit.py index 9404edf..7a49e9b 100644 --- a/tests/test_project_fit.py +++ b/tests/test_project_fit.py @@ -517,7 +517,7 @@ def test_project_vary_bound_conflict_raises(self): # class TestProjectFitLifecycle: """Project.fit_2d() populates file.model_2d so the standard post-fit - API (get_fit_results, save_2d_fit) works on project-fitted files.""" + API (get_fit_results, export_fit) works on project-fitted files.""" # @pytest.mark.slow @@ -557,8 +557,8 @@ def test_get_fit_results_2d_works_after_project_fit(self): # @pytest.mark.slow - def test_save_2d_fit_works_after_project_fit(self, tmp_path): - """Legacy 2D save runs without error on project-fitted files.""" + def test_export_fit_works_after_project_fit(self, tmp_path): + """Slot export runs without error on project-fitted files.""" project = make_project(name="project_fit") truth = _make_truth_file() @@ -570,7 +570,9 @@ def test_save_2d_fit_works_after_project_fit(self, tmp_path): project.fit_2d(model_name="project_glp", stages=2, try_ci=0) for f in project.files: - f._save_2d_fit_legacy(save_path=tmp_path) # must not raise + f.export_fit(tmp_path, fit_type="2d", show_output=0) + slot_dir = tmp_path / f.name / "project_glp__2d" + assert (slot_dir / "fit_2d.csv").exists() # @pytest.mark.slow From bd14edc76a1ff10cf85d1f9ce962536626a21bb6 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Thu, 16 Jul 2026 21:52:47 -0700 Subject: [PATCH 09/29] persist fit provenance and complete SbS parameter metadata (schema 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every slot gains fit_settings — stages, per-stage methods, try_ci, the SbS seeding recipe, and MC sampling settings when MCMC ran (worker counts deliberately excluded: serial == parallel dispatch is a tested invariant). SbS slots additionally gain params_meta (slice-invariant [name, vary, min, max, expr], captured from slice 0) and params_stderr (per-slice standard errors, previously discarded; feeds the planned 1D trace-fitting weights). _display_sbs_fit reads vary flags from the slot now. All additive within the unreleased schema 3; v2 archives still load with the new fields as None. Schema doc, changelog, round-trip and capture tests updated. --- CHANGELOG.md | 1 + PLAN.md | 63 ++++++++++---- docs/design/fit_archive_schema.md | 83 +++++++++++++++--- src/trspecfit/trspecfit.py | 86 +++++++++++++++---- src/trspecfit/utils/fit_io.py | 126 ++++++++++++++++++++++++++++ src/trspecfit/utils/lmfit.py | 38 +++++++++ tests/test_fit_archive_roundtrip.py | 24 +++++- tests/test_fit_history.py | 62 ++++++++++++++ 8 files changed, 439 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 033c238..f8e06ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This file is maintained using the shared changelog workflow in ### Added - **Fit slots persist the correlation matrix and MCMC acceptance fraction** (archive schema 3): `SavedFitSlot.correl` stores the varying-parameter correlation matrix when the optimizer produced covariance (`None` for covariance-less methods and project-level joint fits, slice 0 for SbS), and the `mcmc` payload gains emcee's per-walker `acceptance_fraction`. Schema-2 archives still load (new fields read as `None`); appends remain same-version only. +- **Fit slots record optimizer provenance and complete SbS parameter metadata** (also schema 3): every slot gains `fit_settings` — stages, per-stage methods, `try_ci`, SbS seeding recipe (`seed_source`/`seed_adapt`/`seed_values`), and MCMC sampling settings when enabled (worker counts deliberately excluded; serial ≡ parallel is a tested invariant). SbS slots additionally gain `params_meta` (the slice-invariant `[name, vary, min, max, expr]` metadata) and `params_stderr` (per-slice standard errors, previously discarded — the future weights for 1D trace fitting). - `FitResults.get_fit_results` / `get_correlations` / `get_conf_intervals` / `get_mcmc`: the results accessors now live on `FitResults`, read the latest matching persisted slot (`file=` / `model=` / `fit_type=` filters), and therefore also work for SbS fits (slice-0 payloads) and on archives loaded with `FitResults.load`. The `File.get_*` methods remain as thin delegates with an added optional `model=` filter. ### Changed diff --git a/PLAN.md b/PLAN.md index ec1b690..81b7e04 100644 --- a/PLAN.md +++ b/PLAN.md @@ -124,22 +124,53 @@ decisions 2026-07-14. ## Phase 5 — Explicit plotting API -- [ ] **Prerequisite**: `FitResults` must retain slot → axes context. Today it - flattens `SavedProject.files[*].slots` and discards `SavedFile` - (why `plot_residuals` plots vs. array index). Keep a per-slot reference - to its `SavedFile` (or a fingerprint-keyed axes lookup); `Project.results` - builds the equivalent from live `File` axes. `_slot_axes` - (fit_io.py ~1707) already implements the slicing. -- [ ] `FitResults.plot_fit(file=..., model=..., fit_type=...)` — 1D/2D - observed/fit/residual from slot, real energy/time axes, delegating to - the fitlib renderers; `show_plot`/save args via `_save_img_flag`. -- [ ] `FitResults.plot_param_evolution(...)` — SbS per-parameter-vs-time - (successor of the `results_to_df` → `plt_fit_res_pars` chain; varied - params by default). -- [ ] Upgrade `plot_residuals` to use real axes now that they're available. -- [ ] `File.plot_fit` / `File.plot_param_evolution` sugar. -- [ ] PlotConfig: renderers already accept `config`; thread the owning file's - `plot_config` through (keep `figsize`-style overrides minimal). +Design settled 2026-07-16 (session notes): three pieces of information the +SbS plotting path used are unavailable from slots — vary flags (lost), +axes (persisted but discarded at the FitResults layer), and PlotConfig +(never persisted, by choice). Vary is slice-invariant by construction +(one model, one vary set, no mid-loop hook; serial ≡ parallel pinned by +test_gir_integration). YAML-derived capture was considered and rejected: +the runtime state diverges from the YAML (default SbS seeds from the +baseline *fit*; users mutate models between load and fit), so slots +snapshot the model *as fit*. Model rehydration stays deferred. + +### 5a — schema-3 additions (still unreleased; no extra bump) — DONE + +- [x] SbS **shared param metadata** frame `[name, vary, min, max, expr]` + (`SavedFitSlot.params_meta`, sbs-only; captured from slice-0 result + params, column-aligned with the wide frame). `_display_sbs_fit` now + reads vary from it (fully slot-driven, live-result dependency gone). +- [x] SbS **per-slice stderr** wide frame (`SavedFitSlot.params_stderr`; + NaN where absent; `ulmfit.list_of_par_stderr_to_df`). +- [x] **`fit_settings` provenance dict** on all fit types + (`fit_io.build_fit_settings`, JSON attr, not in `history_key`). + Full scope incl. MC settings (gotcha: `MC` stores `use_mc` as + `.use_emcee`); worker counts deliberately excluded. +- [x] Round-trip + capture + v2-tolerance tests; schema doc updated + (params_meta / params_stderr / fit_settings sections); changelog. + +### 5b — axes retention + plot methods + +- [ ] `FitResults` keeps a fingerprint-keyed axes lookup: `load` retains + the `SavedFile`s; `Project.results` passes the live `File`s (duck- + typed: both expose `.energy` / `.time`; live Files also expose + `.plot_config`, giving config resolution for free). Missing lookup → + index-based fallback (current plot_residuals behavior). +- [ ] `FitResults.plot_fit(file=..., model=..., fit_type=..., config=None, + show_plot=...)` — latest matching slot; 2d/sbs delegate to + `fitlib.plt_fit_res_2d` on slot arrays + real axes; baseline/spectrum + render observed/fit + residual vs energy directly (the fit-time 1D + renderer `plt_fit_res_1d` re-evaluates the model — not slot-usable). +- [ ] `FitResults.plot_param_evolution(...)` — sbs; slot.params + + `params_meta.vary` (varied-only default) + time axis via + `fitlib.plt_fit_res_pars`. +- [ ] Upgrade `plot_residuals` to real axes; keep index fallback. +- [ ] `File.plot_fit` / `File.plot_param_evolution` sugar; the fit-time + `_display_*` helpers collapse into the plot API where that stays + one rendering path (decide in implementation). +- [ ] PlotConfig resolution: explicit `config=` kwarg > live file's + `plot_config` (via axes lookup) > default `PlotConfig()`. Styling is + deliberately not persisted in archives. ## Phase 6 — Docs, tests, release hygiene diff --git a/docs/design/fit_archive_schema.md b/docs/design/fit_archive_schema.md index c57f461..d038ce4 100644 --- a/docs/design/fit_archive_schema.md +++ b/docs/design/fit_archive_schema.md @@ -106,12 +106,13 @@ a new path. - `"1"` → `"2"`: the σ-calibrated chi-square columns and per-slot sigma metadata changed the stored fields — a clean break, so schema-1 archives can no longer be read. -- `"2"` → `"3"` (2026-07): **additive** — slot `correl` dataset and mcmc - `acceptance_fraction` dataset. The reader accepts both `"2"` and `"3"` - (`SUPPORTED_READ_VERSIONS` in `utils/fit_io.py`); schema-2 archives load - with the new fields as `None`. The writer still refuses to append to an - archive whose version differs from its own — re-save to a new path to - migrate. +- `"2"` → `"3"` (2026-07): **additive** — slot `correl` dataset, mcmc + `acceptance_fraction` dataset, the `fit_settings` provenance attr, and + the sbs-only `params_meta` / `params_stderr` datasets. The reader + accepts both `"2"` and `"3"` (`SUPPORTED_READ_VERSIONS` in + `utils/fit_io.py`); schema-2 archives load with the new fields as + `None`. The writer still refuses to append to an archive whose version + differs from its own — re-save to a new path to migrate. Future incompatible changes (e.g. project-scoped joint-result slots or `keep_history=True` full-log save — both deferred, see "What's *not* in @@ -193,6 +194,7 @@ files/000000/slots/000000/ │ # --- provenance --- │ fit_alg : str # e.g. "leastsq", "Nelder" │ yaml_filename : str (opt) # human breadcrumb; omit if None +│ fit_settings : str (opt) # JSON dict; see "fit_settings attr"; schema ≥ 3 │ timestamp : str # ISO 8601 UTC, slot creation time │ # --- metrics (baseline / spectrum / 2d only) --- │ chi2_raw : float64 (cond) @@ -203,6 +205,8 @@ files/000000/slots/000000/ │ aic : float64 (cond) │ bic : float64 (cond) ├── params # see "params dataset" below; layout depends on fit_type +├── params_meta (opt) # heterogeneous-DataFrame dataset; sbs only; schema ≥ 3 +├── params_stderr (opt) # all-numeric DataFrame dataset; sbs only; schema ≥ 3 ├── observed # 1D or 2D dataset; preserves source dtype ├── fit # 1D or 2D dataset; preserves source dtype; observed.shape == fit.shape ├── metrics_per_slice (opt) # 1D structured dataset; sbs only @@ -284,10 +288,66 @@ params : 2D float64 dataset, shape (n_slices, n_par) columns : vlen str[n_par] # parameter names; axis-1 order ``` -Stores optimized values only — no init / stderr / min / max / vary / -expr. Mirrors `list_of_par_to_df(results)` in `utils/lmfit.py`. If full -per-slice metadata becomes useful later, add a sibling -heterogeneous-DataFrame dataset; do not redefine `params`. +Stores optimized values only. Mirrors `list_of_par_to_df(results)` in +`utils/lmfit.py`. The slice-invariant metadata and the per-slice stderr +live in the sibling `params_meta` / `params_stderr` datasets (schema ≥ 3) +— do not redefine `params`. + +## `params_meta` dataset (sbs only, optional; schema ≥ 3) + +Shared per-parameter metadata — exactly the columns that are +slice-invariant by construction (one model, one vary set for every +slice; only *values* differ per slice): + +``` +params_meta : 1D structured dataset, shape (n_par,) + fields (positional, in column order): + c000000 : vlen str # column "name" + c000001 : bool # column "vary" + c000002 : float64 # column "min" (-inf permitted) + c000003 : float64 # column "max" (+inf permitted) + c000004 : vlen str # column "expr" ("" ↔ None) + attrs: + columns : vlen str[5] = ["name","vary","min","max","expr"] + dtypes : vlen str[5] = ["str","bool","float64","float64","str"] +``` + +Captured from the slice-0 result params; rows are column-aligned with the +wide `params` frame. Deliberately excludes `value` / `stderr` / +`init_value`, which are per-slice (`init_value` diverges under +`seed_adapt`). The runtime state is the source — not the model YAML, +which the fit may have diverged from (e.g. `seed_source="baseline"`). + +## `params_stderr` dataset (sbs only, optional; schema ≥ 3) + +Per-slice parameter standard errors, mirroring the wide `params` layout: + +``` +params_stderr : 2D float64 dataset, shape (n_slices, n_par) + attrs: + columns : vlen str[n_par] # parameter names; axis-1 order +``` + +`NaN` where the optimizer reported no stderr for that slice; the NaN is +data (no None mapping). Mirrors `list_of_par_stderr_to_df(results)` in +`utils/lmfit.py`. + +## `fit_settings` attr (optional; schema ≥ 3) + +JSON-encoded dict on the slot `metadata` group recording the optimizer +configuration that can influence the result: + +- all fit types: `stages`, `fit_alg_1`, `fit_alg_2`, `try_ci`; +- sbs: `seed_source`, `seed_adapt`, `seed_values` (JSON `null` is + meaningful — "no adaptation" is provenance too); +- when MCMC was enabled: an `mc` sub-dict (`use_mc`, `steps`, `nwalkers`, + `burn`, `thin`, `ntemps`, `is_weighted`, `sigma_ini/min/max`). + +Execution details that cannot change the result (SbS / emcee worker +counts; serial ≡ parallel dispatch is pinned by test) are deliberately +excluded. `fit_settings` is not part of `history_key` — a refit with +different settings is still a refit of the same (file, model, fit_type, +selection). Built by `build_fit_settings` in `utils/fit_io.py`. ## `metrics_per_slice` dataset (sbs only) @@ -391,6 +451,9 @@ Per slot, the reader produces a `SavedFitSlot` with: | `observed_sha256` | slot `metadata.observed_sha256` attr | | `history_key` | recomputed from `file_fingerprint + model_name + fit_type + selection_json` | | `params` | `params` dataset (+ its `columns` attr) → DataFrame | +| `params_meta` | `params_meta` dataset → DataFrame, or `None` if absent | +| `params_stderr` | `params_stderr` dataset → DataFrame, or `None` if absent | +| `fit_settings` | `metadata.fit_settings` attr (JSON) → dict, or `None` if absent | | `metrics` | scalar attrs (non-sbs) or `metrics_per_slice` (sbs) → dict | | `observed` | `observed` dataset | | `fit` | `fit` dataset | diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index ff587fa..60c11e8 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -1504,10 +1504,15 @@ def fit_2d( # entry point. slots_2d: list[fit_io.SavedFitSlot | None] = [] if joint_result: + joint_fit_settings = fit_io.build_fit_settings( + stages=stages, fit_wrapper_kwargs=fit_wrapper_kwargs + ) for f in self.files: slots_2d.append( f._append_2d_slot( - model_name=model_name, fit_fun_str="fit_model_mcp" + model_name=model_name, + fit_fun_str="fit_model_mcp", + fit_settings=joint_fit_settings, ) ) @@ -2723,7 +2728,13 @@ def fit_baseline( self.model_base.result[1], return_type="list" ) ) - self._append_baseline_slot(model_name=model_name, fit_fun_str=_fun_str) + self._append_baseline_slot( + model_name=model_name, + fit_fun_str=_fun_str, + fit_settings=fit_io.build_fit_settings( + stages=stages, fit_wrapper_kwargs=lmfit_wrapper_kwargs + ), + ) if self.p.auto_export: self.save_baseline_fit(save_path=path_base_results) @@ -2960,6 +2971,9 @@ def fit_spectrum( time_point=time_point, time_range=list(time_range) if time_range is not None else None, time_type=time_type, + fit_settings=fit_io.build_fit_settings( + stages=stages, fit_wrapper_kwargs=lmfit_wrapper_kwargs + ), ) if self.p.auto_export: self.save_spectrum_fit(save_path=path_spec_results) @@ -3375,7 +3389,19 @@ def _slice_path(s_i: int) -> pathlib.Path: # pristine per-slice fit state (results_sbs and parameter names # taken before any post-fit cleanup). slot_sbs = self._append_sbs_slot( - model_name=model_name, fit_fun_str=_fun_str + model_name=model_name, + fit_fun_str=_fun_str, + fit_settings=fit_io.build_fit_settings( + stages=stages, + fit_wrapper_kwargs=fit_wrapper_kwargs, + seed_source=seed_source, + seed_adapt=seed_adapt, + seed_values=( + [float(v) for v in np.asarray(seed_values).ravel()] + if seed_values is not None + else None + ), + ), ) # Display inline when interactive (show_output); export the slot # when auto_export — mirroring fit_baseline's split. Per-slice @@ -3433,18 +3459,18 @@ def _display_sbs_fit(self, slot: fit_io.SavedFitSlot) -> None: """ Show an SbS slot inline: varied-parameter evolution + fit maps. - Vary flags come from the live slice-0 result (the wide per-slice - params frame carries no vary column; every slice shares the same - vary set). + Fully slot-driven: vary flags come from ``slot.params_meta`` (the + vary set is slice-invariant, so the shared metadata frame is + authoritative). """ assert self.time is not None # type guard - vary_df = ulmfit.par_to_df( - lmfit_params=self.results_sbs[0][1].params, col_type="min" - ) + assert slot.params_meta is not None # type guard (sbs slots carry it) varied = { str(name) - for name, vary in zip(vary_df["name"], vary_df["vary"], strict=True) + for name, vary in zip( + slot.params_meta["name"], slot.params_meta["vary"], strict=True + ) if vary } par_cols = [c for c in slot.params.columns if c in varied] @@ -3464,7 +3490,11 @@ def _display_sbs_fit(self, slot: fit_io.SavedFitSlot) -> None: # def _append_baseline_slot( - self, *, model_name: str, fit_fun_str: str + self, + *, + model_name: str, + fit_fun_str: str, + fit_settings: dict[str, Any] | None = None, ) -> fit_io.SavedFitSlot | None: """ Build a SavedFitSlot from the just-completed baseline fit and append @@ -3536,6 +3566,7 @@ def _append_baseline_slot( conf_ci=conf_ci if not conf_ci.empty else None, correl=correl, mcmc=mcmc, + fit_settings=fit_settings, ) self.p._fit_history.append(slot) return slot @@ -3549,6 +3580,7 @@ def _append_spectrum_slot( time_point: float | None, time_range: list[float] | None, time_type: str, + fit_settings: dict[str, Any] | None = None, ) -> fit_io.SavedFitSlot | None: """Build and append a SavedFitSlot for a completed spectrum fit.""" @@ -3611,13 +3643,18 @@ def _append_spectrum_slot( conf_ci=conf_ci if not conf_ci.empty else None, correl=correl, mcmc=mcmc, + fit_settings=fit_settings, ) self.p._fit_history.append(slot) return slot # def _append_sbs_slot( - self, *, model_name: str, fit_fun_str: str + self, + *, + model_name: str, + fit_fun_str: str, + fit_settings: dict[str, Any] | None = None, ) -> fit_io.SavedFitSlot | None: """ Build and append a SavedFitSlot for a completed slice-by-slice fit. @@ -3680,6 +3717,13 @@ def _append_sbs_slot( if getattr(slice0_result, "covar", None) is not None else None ) + # Shared per-parameter metadata (vary/bounds/expr are slice-invariant; + # captured from slice 0) and per-slice stderr — both column-aligned + # with the wide params frame. + params_meta = ulmfit.par_to_df( + slice0_result.params, col_type=["name", "vary", "min", "max", "expr"] + ) + params_stderr = ulmfit.list_of_par_stderr_to_df(self.results_sbs) slot = fit_io._slot_from_sbs( file_fingerprint=self.fingerprint(), file_name=self.name, @@ -3699,13 +3743,20 @@ def _append_sbs_slot( conf_ci=slice0_conf_ci if not slice0_conf_ci.empty else None, correl=slice0_correl, mcmc=slice0_mcmc, + params_meta=params_meta, + params_stderr=params_stderr, + fit_settings=fit_settings, ) self.p._fit_history.append(slot) return slot # def _append_2d_slot( - self, *, model_name: str, fit_fun_str: str + self, + *, + model_name: str, + fit_fun_str: str, + fit_settings: dict[str, Any] | None = None, ) -> fit_io.SavedFitSlot | None: """Build and append a SavedFitSlot for a completed 2D global fit.""" @@ -3776,6 +3827,7 @@ def _append_2d_slot( conf_ci=conf_ci if not conf_ci.empty else None, correl=correl, mcmc=mcmc, + fit_settings=fit_settings, ) self.p._fit_history.append(slot) return slot @@ -4164,7 +4216,13 @@ def fit_2d(self, model_name: str, stages: int = 1, **fit_wrapper_kwargs) -> None for name in self.model_2d.parameter_names: if name in final_params: self.model_2d.lmfit_pars[name].value = final_params[name].value - slot_2d = self._append_2d_slot(model_name=model_name, fit_fun_str=_fun_str) + slot_2d = self._append_2d_slot( + model_name=model_name, + fit_fun_str=_fun_str, + fit_settings=fit_io.build_fit_settings( + stages=stages, fit_wrapper_kwargs=fit_wrapper_kwargs + ), + ) if stages >= 1: # Display inline when interactive (show_output); export the slot diff --git a/src/trspecfit/utils/fit_io.py b/src/trspecfit/utils/fit_io.py index cca23d3..981bb4f 100644 --- a/src/trspecfit/utils/fit_io.py +++ b/src/trspecfit/utils/fit_io.py @@ -226,6 +226,24 @@ class SavedFitSlot: for SbS / 2D / spectrum; equals ``sigma_data / √N_avg`` for baseline (``N_avg`` = number of time slices averaged into ``data_base``). ``NaN`` when ``sigma_data`` is ``NaN``. + params_meta : pd.DataFrame | None + SbS only: shared per-parameter metadata ``[name, vary, min, max, + expr]`` — the columns that are slice-invariant by construction + (one model, one vary set for every slice). ``None`` for other fit + types, whose long-form ``params`` already carry these. + params_stderr : pd.DataFrame | None + SbS only: per-slice parameter standard errors, same shape/columns + as the wide ``params`` frame; ``NaN`` where the optimizer reported + none. ``None`` for other fit types (long-form ``params`` has a + ``stderr`` column). + fit_settings : dict | None + Optimizer-configuration provenance: ``{"stages", "fit_alg_1", + "fit_alg_2", "try_ci"}``, plus ``{"seed_source", "seed_adapt", + "seed_values"}`` for SbS and an ``"mc"`` sub-dict (steps, walkers, + burn, thin, ntemps, is_weighted, sigma bounds) when MCMC was + enabled. Deliberately excludes execution details that cannot + change the result (worker counts). Not part of ``history_key`` — + a refit with different settings is still a refit. conf_ci : pd.DataFrame | None correl : pd.DataFrame | None Varying-parameter correlation matrix (index == columns == varying @@ -263,6 +281,9 @@ class SavedFitSlot: conf_ci: pd.DataFrame | None = None correl: pd.DataFrame | None = None mcmc: dict[str, Any] | None = None + params_meta: pd.DataFrame | None = None + params_stderr: pd.DataFrame | None = None + fit_settings: dict[str, Any] | None = None # @@ -510,6 +531,55 @@ def _mcmc_payload( } +# +def build_fit_settings( + *, + stages: int, + fit_wrapper_kwargs: dict[str, Any] | None = None, + **extra: Any, +) -> dict[str, Any]: + """ + Assemble the provenance dict stored as ``SavedFitSlot.fit_settings``. + + Records the optimizer configuration that can influence the fit result: + stage count, per-stage methods, the profiled-CI request, MCMC sampling + settings (when enabled), plus any fit-type-specific extras the caller + passes verbatim (e.g. SbS ``seed_source`` / ``seed_adapt`` / + ``seed_values`` — ``None`` values are kept: "no seed adaptation" is + provenance too). Execution details that cannot change the result + (SbS / emcee worker counts) are deliberately excluded — serial and + parallel dispatch are pinned result-identical by test. + + Defaults mirror ``fitlib.fit_wrapper``'s signature; if you change one, + change the other. + """ + + kwargs = fit_wrapper_kwargs or {} + settings: dict[str, Any] = { + "stages": int(stages), + "fit_alg_1": str(kwargs.get("fit_alg_1", "Nelder")), + "fit_alg_2": str(kwargs.get("fit_alg_2", "leastsq")), + "try_ci": int(kwargs.get("try_ci", 1)), + } + mc = kwargs.get("mc_settings") + # MC stores its use_mc constructor arg as the use_emcee attribute. + if mc is not None and getattr(mc, "use_emcee", False): + settings["mc"] = { + "use_mc": int(mc.use_emcee), + "steps": int(mc.steps), + "nwalkers": int(mc.nwalkers), + "burn": int(mc.burn), + "thin": int(mc.thin), + "ntemps": int(mc.ntemps), + "is_weighted": bool(mc.is_weighted), + "sigma_ini": float(mc.sigma_ini), + "sigma_min": float(mc.sigma_min), + "sigma_max": float(mc.sigma_max), + } + settings.update(extra) + return settings + + # # --- per-fit-type slot extractors ------------------------------------------- # @@ -536,6 +606,7 @@ def _slot_from_baseline( conf_ci: pd.DataFrame | None = None, correl: pd.DataFrame | None = None, mcmc: dict[str, Any] | None = None, + fit_settings: dict[str, Any] | None = None, ) -> SavedFitSlot: """ Build a SavedFitSlot for a completed baseline fit. @@ -565,6 +636,7 @@ def _slot_from_baseline( conf_ci=conf_ci, correl=correl, mcmc=mcmc, + fit_settings=fit_settings, noise_type=noise_type, sigma_source=sigma_source, sigma_type=sigma_type, @@ -595,6 +667,7 @@ def _slot_from_spectrum( conf_ci: pd.DataFrame | None = None, correl: pd.DataFrame | None = None, mcmc: dict[str, Any] | None = None, + fit_settings: dict[str, Any] | None = None, ) -> SavedFitSlot: """Build a SavedFitSlot for a completed spectrum fit. @@ -624,6 +697,7 @@ def _slot_from_spectrum( conf_ci=conf_ci, correl=correl, mcmc=mcmc, + fit_settings=fit_settings, noise_type=noise_type, sigma_source=sigma_source, sigma_type=sigma_type, @@ -652,6 +726,9 @@ def _slot_from_sbs( conf_ci: pd.DataFrame | None = None, correl: pd.DataFrame | None = None, mcmc: dict[str, Any] | None = None, + params_meta: pd.DataFrame | None = None, + params_stderr: pd.DataFrame | None = None, + fit_settings: dict[str, Any] | None = None, ) -> SavedFitSlot: """ Build a SavedFitSlot for a completed slice-by-slice fit. @@ -704,6 +781,9 @@ def _slot_from_sbs( conf_ci=conf_ci, correl=correl, mcmc=mcmc, + params_meta=params_meta, + params_stderr=params_stderr, + fit_settings=fit_settings, ) @@ -728,6 +808,7 @@ def _slot_from_2d( conf_ci: pd.DataFrame | None = None, correl: pd.DataFrame | None = None, mcmc: dict[str, Any] | None = None, + fit_settings: dict[str, Any] | None = None, ) -> SavedFitSlot: """Build a SavedFitSlot for a completed 2D global fit.""" @@ -750,6 +831,7 @@ def _slot_from_2d( conf_ci=conf_ci, correl=correl, mcmc=mcmc, + fit_settings=fit_settings, noise_type=noise_type, sigma_source=sigma_source, sigma_type=sigma_type, @@ -779,6 +861,7 @@ def _build_slot( conf_ci: pd.DataFrame | None, correl: pd.DataFrame | None, mcmc: dict[str, Any] | None, + fit_settings: dict[str, Any] | None, noise_type: str, sigma_source: str, sigma_type: str, @@ -825,6 +908,7 @@ def _build_slot( conf_ci=conf_ci, correl=correl, mcmc=mcmc, + fit_settings=fit_settings, ) @@ -1098,6 +1182,14 @@ def _encode_dataframe( "bool", # vary "str", # expr ] +# SbS shared per-parameter metadata (slice-invariant columns only). +_PARAMS_META_TYPE_TAGS: list[TypeTag] = [ + "str", # name + "bool", # vary + "float64", # min + "float64", # max + "str", # expr +] _METRICS_KEYS = ( "chi2_raw", "chi2_red_raw", @@ -1332,6 +1424,20 @@ def _write_slot( slot_group.create_dataset("fit", data=np.ascontiguousarray(slot.fit)) if slot.fit_type == "sbs": _write_metrics_per_slice(slot_group, slot.metrics) + if slot.params_meta is not None: + _encode_dataframe( + slot_group, + "params_meta", + slot.params_meta, + type_tags=_PARAMS_META_TYPE_TAGS, + ) + if slot.params_stderr is not None: + _encode_dataframe( + slot_group, + "params_stderr", + slot.params_stderr, + type_tags=_all_float64_tags(len(slot.params_stderr.columns)), + ) if slot.conf_ci is not None: _encode_dataframe(slot_group, "conf_ci", slot.conf_ci) if slot.correl is not None: @@ -1367,6 +1473,8 @@ def _write_slot_metadata( meta.attrs["fit_alg"] = slot.fit_alg if slot.yaml_filename is not None: meta.attrs["yaml_filename"] = slot.yaml_filename + if slot.fit_settings is not None: + meta.attrs["fit_settings"] = json.dumps(slot.fit_settings, sort_keys=True) meta.attrs["timestamp"] = slot.timestamp # Noise metadata snapshot at fit time — see SavedFitSlot docstring. meta.attrs["noise_type"] = slot.noise_type @@ -1629,6 +1737,21 @@ def _read_slot( correl = _decode_dataframe(require_dataset(correl_obj, "correl")) # Square matrix stores only column labels; index == columns. correl.index = pd.Index(correl.columns) + params_meta_obj = slot_group.get("params_meta") + params_meta: pd.DataFrame | None = None + if params_meta_obj is not None: + params_meta = _decode_dataframe(require_dataset(params_meta_obj, "params_meta")) + # Same "" ↔ None mapping as long-form params (expr column). + _restore_long_params_nones(params_meta) + params_stderr_obj = slot_group.get("params_stderr") + params_stderr = ( + _decode_dataframe(require_dataset(params_stderr_obj, "params_stderr")) + if params_stderr_obj is not None + else None + ) + fit_settings = ( + json.loads(_attr_str(a["fit_settings"])) if "fit_settings" in a else None + ) mcmc_obj = slot_group.get("mcmc") mcmc = ( _read_mcmc_group(require_group(mcmc_obj, "mcmc")) @@ -1671,6 +1794,9 @@ def _read_slot( conf_ci=conf_ci, correl=correl, mcmc=mcmc, + params_meta=params_meta, + params_stderr=params_stderr, + fit_settings=fit_settings, ) diff --git a/src/trspecfit/utils/lmfit.py b/src/trspecfit/utils/lmfit.py index 5d84963..d30ebe0 100644 --- a/src/trspecfit/utils/lmfit.py +++ b/src/trspecfit/utils/lmfit.py @@ -496,6 +496,44 @@ def list_of_par_to_df(results: list[Any]) -> pd.DataFrame: return pd.DataFrame(param_values_list, columns=param_names) +# +def list_of_par_stderr_to_df(results: list[Any]) -> pd.DataFrame: + """ + Extract per-fit parameter stderr into a DataFrame (NaN where absent). + + Companion to :func:`list_of_par_to_df` with the same shape contract + (rows=fits, columns=parameters): collects each fit's per-parameter + standard errors instead of the optimized values. lmfit reports + ``stderr=None`` when the optimizer produced no covariance; those cells + become ``NaN`` so the frame stays numeric. + + Parameters + ---------- + results : list + List of fit results from fit_wrapper or similar; element [1] holds + the lmfit.MinimizerResult with a .params attribute. + + Returns + ------- + pd.DataFrame + DataFrame with rows=individual fits, columns=parameter stderr. + """ + + param_names = list(results[0][1].params.keys()) + rows = [] + for result in results: + params = result[1].params + rows.append( + [ + float(params[name].stderr) + if params[name].stderr is not None + else np.nan + for name in param_names + ] + ) + return pd.DataFrame(rows, columns=param_names) + + # # Configuration and compatibility classes # diff --git a/tests/test_fit_archive_roundtrip.py b/tests/test_fit_archive_roundtrip.py index 28532e1..1afcfed 100644 --- a/tests/test_fit_archive_roundtrip.py +++ b/tests/test_fit_archive_roundtrip.py @@ -149,6 +149,13 @@ def _assert_slot_round_tripped(loaded: SavedFitSlot, original: SavedFitSlot) -> # --- uncertainty payloads (None ↔ None or exact) -------------------- _assert_optional_df_equal(loaded.conf_ci, original.conf_ci, label="conf_ci") _assert_optional_df_equal(loaded.correl, original.correl, label="correl") + _assert_optional_df_equal( + loaded.params_meta, original.params_meta, label="params_meta" + ) + _assert_optional_df_equal( + loaded.params_stderr, original.params_stderr, label="params_stderr" + ) + assert loaded.fit_settings == original.fit_settings if original.correl is not None: assert loaded.correl is not None # type guard # The square matrix persists only column labels; the reader must @@ -508,8 +515,10 @@ def test_correl_roundtrip(tmp_path) -> None: # def _downgrade_archive_to_v2(archive_path) -> None: """Rewrite a schema-3 archive as schema 2 in place: relabel the version - and delete the schema-3 additions (slot ``correl``, mcmc - ``acceptance_fraction``) so the payload matches what a v2 writer produced.""" + and delete the schema-3 additions (slot ``correl`` / ``params_meta`` / + ``params_stderr`` datasets, ``fit_settings`` attr, mcmc + ``acceptance_fraction``) so the payload matches what a v2 writer + produced.""" import h5py @@ -525,8 +534,12 @@ def _downgrade_archive_to_v2(archive_path) -> None: slots = require_group(slots_obj, "slots") for s_key in slots: sg = require_group(slots[s_key], s_key) - if "correl" in sg: - del sg["correl"] + for ds in ("correl", "params_meta", "params_stderr"): + if ds in sg: + del sg[ds] + meta = require_group(sg["metadata"], "metadata") + if "fit_settings" in meta.attrs: + del meta.attrs["fit_settings"] if "mcmc" in sg: mcmc_group = require_group(sg["mcmc"], "mcmc") if "acceptance_fraction" in mcmc_group: @@ -553,6 +566,9 @@ def test_reader_accepts_schema_v2_archive(tmp_path) -> None: assert len(loaded) == 1 slot = next(iter(loaded)) assert slot.correl is None + assert slot.params_meta is None + assert slot.params_stderr is None + assert slot.fit_settings is None original = fit_file.p._fit_history[0] _assert_params_equal(slot.params, original.params, fit_type="baseline") diff --git a/tests/test_fit_history.py b/tests/test_fit_history.py index d9c8d04..185996f 100644 --- a/tests/test_fit_history.py +++ b/tests/test_fit_history.py @@ -359,6 +359,21 @@ def test_sbs_slot_per_slice_metrics(self): sbs_df = file.get_fit_results(fit_type="sbs") pd.testing.assert_frame_equal(sbs_df, slot.params) assert len(sbs_df) == len(file.time) + # Shared per-parameter metadata, column-aligned with the wide frame. + assert slot.params_meta is not None # type guard + assert list(slot.params_meta.columns) == ["name", "vary", "min", "max", "expr"] + assert list(slot.params_meta["name"]) == list(slot.params.columns) + assert bool(slot.params_meta["vary"].any()) + # Per-slice stderr mirrors the wide params frame's shape. + assert slot.params_stderr is not None # type guard + assert slot.params_stderr.shape == slot.params.shape + assert list(slot.params_stderr.columns) == list(slot.params.columns) + # Provenance records the SbS seeding recipe. + assert slot.fit_settings is not None # type guard + assert slot.fit_settings["seed_source"] == "model" + assert slot.fit_settings["seed_adapt"] is None + assert slot.fit_settings["seed_values"] is None + assert slot.fit_settings["stages"] == 1 # @pytest.mark.slow @@ -479,6 +494,11 @@ def test_baseline_slot_captures_mcmc(self, tmp_path): acceptance = slot.mcmc["acceptance_fraction"] assert acceptance is not None # type guard assert acceptance.shape == (32,) + # MCMC settings land in the fit_settings provenance. + assert slot.fit_settings is not None # type guard + assert slot.fit_settings["mc"]["steps"] == 20 + assert slot.fit_settings["mc"]["nwalkers"] == 32 + assert slot.fit_settings["mc"]["burn"] == 5 # acceptance_fraction survives the archive round-trip (schema 3). archive_path = tmp_path / "mcmc.fit.h5" @@ -502,6 +522,48 @@ def test_baseline_slot_mcmc_none_when_mcmc_skipped(self): assert slot.mcmc is None +# +# --- fit_settings provenance --------------------------------------------------- +# + + +# +class TestFitSettingsProvenance: + """Every fit type records its optimizer configuration in the slot.""" + + # + def test_baseline_slot_records_fit_settings(self): + project, _ = _setup_baseline_fit() # stages=2, try_ci=0 + slot = project._fit_history[0] + assert slot.fit_settings == { + "stages": 2, + "fit_alg_1": "Nelder", + "fit_alg_2": "leastsq", + "try_ci": 0, + } + # Non-sbs slots carry no sbs-only payloads. + assert slot.params_meta is None + assert slot.params_stderr is None + + # + def test_fit_settings_records_custom_algorithms(self): + truth_project = make_project(name="truth") + truth = _make_truth_file(truth_project) + data = simulate_noisy(truth.model_active, noise_level=0.01) + project = make_project(name="fit") + file = _make_fit_file(project, data, truth.energy, truth.time) + file.define_baseline( + time_start=0, time_stop=3, time_type="ind", show_plot=False + ) + file.fit_baseline( + model_name="single_glp", stages=1, fit_alg_1="leastsq", try_ci=0 + ) + settings = project._fit_history[0].fit_settings + assert settings is not None # type guard + assert settings["fit_alg_1"] == "leastsq" + assert settings["stages"] == 1 + + # # --- slot-backed get_* accessors ---------------------------------------------- # From e5ac0fcdaab67969a3f2cd5f71f6d78cf22f9d14 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Fri, 17 Jul 2026 10:19:07 -0700 Subject: [PATCH 10/29] add the explicit plotting API on FitResults with real axes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FitResults now accepts fingerprint-matched axes providers (live Files from Project.results, SavedFiles from FitResults.load) and gains plot_fit (observed/fit/residual, all fit types) and plot_param_evolution (SbS, varied-only default via params_meta), with File.plot_* sugar and config resolution config= > live plot_config > defaults. plot_residuals uses real axes now (index fallback kept). The fit methods' inline display routes through this API — the _display_* helpers are gone, so the figure shown at fit time is the one the API reproduces from a slot or a loaded archive. fitlib is imported lazily to avoid a package-init cycle. --- CHANGELOG.md | 1 + PLAN.md | 45 ++--- src/trspecfit/fit_results.py | 322 +++++++++++++++++++++++++++++++++-- src/trspecfit/trspecfit.py | 146 +++++++++------- tests/test_fit_history.py | 142 +++++++++++++++ 5 files changed, 557 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e06ea..a6a0b2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ This file is maintained using the shared changelog workflow in - **Fit slots persist the correlation matrix and MCMC acceptance fraction** (archive schema 3): `SavedFitSlot.correl` stores the varying-parameter correlation matrix when the optimizer produced covariance (`None` for covariance-less methods and project-level joint fits, slice 0 for SbS), and the `mcmc` payload gains emcee's per-walker `acceptance_fraction`. Schema-2 archives still load (new fields read as `None`); appends remain same-version only. - **Fit slots record optimizer provenance and complete SbS parameter metadata** (also schema 3): every slot gains `fit_settings` — stages, per-stage methods, `try_ci`, SbS seeding recipe (`seed_source`/`seed_adapt`/`seed_values`), and MCMC sampling settings when enabled (worker counts deliberately excluded; serial ≡ parallel is a tested invariant). SbS slots additionally gain `params_meta` (the slice-invariant `[name, vary, min, max, expr]` metadata) and `params_stderr` (per-slice standard errors, previously discarded — the future weights for 1D trace fitting). - `FitResults.get_fit_results` / `get_correlations` / `get_conf_intervals` / `get_mcmc`: the results accessors now live on `FitResults`, read the latest matching persisted slot (`file=` / `model=` / `fit_type=` filters), and therefore also work for SbS fits (slice-0 payloads) and on archives loaded with `FitResults.load`. The `File.get_*` methods remain as thin delegates with an added optional `model=` filter. +- **Explicit plotting API**: `FitResults.plot_fit` (observed/fit/residual for any fit type) and `FitResults.plot_param_evolution` (SbS per-parameter evolution, varied parameters by default), with `File.plot_fit` / `File.plot_param_evolution` sugar. `FitResults` now carries fingerprint-matched axes providers (live `File`s via `Project.results`, `SavedFile`s via `FitResults.load`), so plots — including the upgraded `plot_residuals` — use real energy/time axes on live sessions *and* loaded archives, falling back to array indices only when no provider matches. Styling resolves as explicit `config=` > the live file's `plot_config` > defaults (styling is deliberately not persisted in archives). The fit methods' inline display now routes through this same API, so the figure shown at fit time is exactly the figure the API reproduces later. ### Changed diff --git a/PLAN.md b/PLAN.md index 81b7e04..7d0d3e5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -149,28 +149,29 @@ snapshot the model *as fit*. Model rehydration stays deferred. - [x] Round-trip + capture + v2-tolerance tests; schema doc updated (params_meta / params_stderr / fit_settings sections); changelog. -### 5b — axes retention + plot methods - -- [ ] `FitResults` keeps a fingerprint-keyed axes lookup: `load` retains - the `SavedFile`s; `Project.results` passes the live `File`s (duck- - typed: both expose `.energy` / `.time`; live Files also expose - `.plot_config`, giving config resolution for free). Missing lookup → - index-based fallback (current plot_residuals behavior). -- [ ] `FitResults.plot_fit(file=..., model=..., fit_type=..., config=None, - show_plot=...)` — latest matching slot; 2d/sbs delegate to - `fitlib.plt_fit_res_2d` on slot arrays + real axes; baseline/spectrum - render observed/fit + residual vs energy directly (the fit-time 1D - renderer `plt_fit_res_1d` re-evaluates the model — not slot-usable). -- [ ] `FitResults.plot_param_evolution(...)` — sbs; slot.params + - `params_meta.vary` (varied-only default) + time axis via - `fitlib.plt_fit_res_pars`. -- [ ] Upgrade `plot_residuals` to real axes; keep index fallback. -- [ ] `File.plot_fit` / `File.plot_param_evolution` sugar; the fit-time - `_display_*` helpers collapse into the plot API where that stays - one rendering path (decide in implementation). -- [ ] PlotConfig resolution: explicit `config=` kwarg > live file's - `plot_config` (via axes lookup) > default `PlotConfig()`. Styling is - deliberately not persisted in archives. +### 5b — axes retention + plot methods — DONE + +- [x] `FitResults(slots=..., files=...)`: fingerprint-keyed provider lookup + (`_files_by_fp`); `load` passes the archive's `SavedFile`s, + `Project.results` the live `File`s (duck-typed `.energy`/`.time`; + live Files also give `.plot_config`). Files that can't fingerprint + (no data) are skipped — they produced no slots. Missing lookup → + index-based fallback. `_axes_for` mirrors `fit_io._slot_axes` + cropping but tolerates missing providers/axes. +- [x] `FitResults.plot_fit`: 2d/sbs → `fitlib.plt_fit_res_2d` on slot + arrays + real axes (fitlib imported lazily — the package `__init__` + imports fit_results, so a top-level import would cycle); baseline/ + spectrum → direct observed/fit + residual panels vs energy. +- [x] `FitResults.plot_param_evolution`: varied-only default via + `params_meta` (all params for schema-2 archives), explicit `params=` + with KeyError on unknown names, silent no-op when nothing varied. +- [x] `plot_residuals` upgraded to real axes (1D energy x-axis, 2D + imshow extent); index fallback preserved. +- [x] `File.plot_fit` / `File.plot_param_evolution` sugar. The fit-time + `_display_*` helpers were deleted — fit methods display through the + plot API, so the fit-time figure equals what the API reproduces. +- [x] Config resolution: explicit `config=` > live `plot_config` > + `PlotConfig()`. Changelog updated. ## Phase 6 — Docs, tests, release hygiene diff --git a/src/trspecfit/fit_results.py b/src/trspecfit/fit_results.py index 77c5816..d79fca9 100644 --- a/src/trspecfit/fit_results.py +++ b/src/trspecfit/fit_results.py @@ -27,6 +27,7 @@ import numpy as np import pandas as pd +from trspecfit.config.plot import PlotConfig from trspecfit.utils.fit_io import SavedFile, SavedFitSlot, read_archive from trspecfit.utils.lmfit import MCMCResult @@ -155,13 +156,97 @@ class FitResults: """ Immutable view over a list of ``SavedFitSlot``. - Construction is positional-only (``FitResults(slots=...)``); users normally + Construction is keyword-only (``FitResults(slots=...)``); users normally obtain instances via ``Project.results`` or ``FitResults.load(path)``. + + ``files`` optionally supplies per-file axes / plot-config providers — + ``SavedFile`` records (load path) or live ``trspecfit.File`` objects + (``Project.results``), matched to slots by fingerprint. The plot + methods use them to label real energy/time axes; without a provider + they fall back to array-index axes. """ # - def __init__(self, *, slots: list[SavedFitSlot]) -> None: + def __init__( + self, + *, + slots: list[SavedFitSlot], + files: Sequence[Any] | None = None, + ) -> None: self._slots: tuple[SavedFitSlot, ...] = tuple(slots) + self._files_by_fp: dict[tuple[Any, ...], Any] = {} + for f in files or (): + fp = self._provider_fp_key(f) + if fp is not None: + self._files_by_fp[fp] = f + + # + @staticmethod + def _provider_fp_key(f: Any) -> tuple[Any, ...] | None: + """ + Fingerprint key for an axes provider, or ``None`` if unavailable. + + ``SavedFile.fingerprint`` is a dict attribute; the live + ``trspecfit.File.fingerprint`` is a method that raises when the + file has no data — such files produced no slots, so skipping them + is safe. + """ + + fingerprint = getattr(f, "fingerprint", None) + if callable(fingerprint): + try: + fingerprint = fingerprint() + except ValueError: + return None + if isinstance(fingerprint, dict): + return _fp_key(fingerprint) + return None + + # + def _axes_for( + self, slot: SavedFitSlot + ) -> tuple[np.ndarray | None, np.ndarray | None]: + """ + ``(energy, time)`` on the slot's grid, or ``None`` where unknown. + + Crops the provider's full axes to the slot's selection (same rule + as ``fit_io._slot_axes``, tolerant of missing providers/axes). + """ + + provider = self._files_by_fp.get(_fp_key(slot.file_fingerprint)) + energy = getattr(provider, "energy", None) if provider is not None else None + time = getattr(provider, "time", None) if provider is not None else None + if energy is not None: + energy = np.asarray(energy) + e_lim = slot.selection.get("e_lim") + if e_lim: + energy = energy[int(e_lim[0]) : int(e_lim[1])] + if time is not None: + time = np.asarray(time) + if time.ndim == 0 or time.size == 0: + time = None + elif slot.fit_type == "2d": + t_lim = slot.selection.get("t_lim") + if t_lim: + time = time[int(t_lim[0]) : int(t_lim[1])] + return energy, time + + # + def _config_for(self, slot: SavedFitSlot, config: Any) -> Any: + """ + Resolve the ``PlotConfig`` for a plot call. + + Explicit ``config=`` wins; otherwise the live file's + ``plot_config`` when this ``FitResults`` was built from a Project; + default ``PlotConfig()`` for loaded archives (styling is + deliberately not persisted). + """ + + if config is not None: + return config + provider = self._files_by_fp.get(_fp_key(slot.file_fingerprint)) + live_config = getattr(provider, "plot_config", None) + return live_config if live_config is not None else PlotConfig() # @classmethod @@ -201,7 +286,9 @@ def load( if types_filter is not None and slot.fit_type not in types_filter: continue slots.append(slot) - return cls(slots=slots) + # SavedFiles are kept (unfiltered) as axes providers for the plot + # methods; slots are matched to them by fingerprint at plot time. + return cls(slots=slots, files=list(project.files)) # def __iter__(self) -> Iterator[SavedFitSlot]: @@ -529,6 +616,192 @@ def get_mcmc( ), ) + # + def plot_fit( + self, + *, + file: Any = None, + model: str | None = None, + fit_type: FitType = "baseline", + config: Any = None, + show_plot: bool = True, + ) -> None: + """ + Plot the latest matching fit: observed, fit, and residual. + + Reads the persisted slot. 1D fits (baseline / spectrum) render an + observed+fit panel over a residual panel vs energy; 2D fits and SbS + render the data/fit/residual maps via ``fitlib.plt_fit_res_2d``. + Real axes are used when this ``FitResults`` carries an axes + provider for the slot's file (always the case for + ``Project.results`` and ``FitResults.load``); otherwise array + indices. + + Parameters + ---------- + file : str | SavedFile | trspecfit.File | None + Filter to a single file (name string or object with ``.name``). + model : str, optional + Filter to a single model name. + fit_type : {'baseline', 'spectrum', 'sbs', '2d'}, default='baseline' + Which fit type to plot (latest matching fit wins). + config : PlotConfig, optional + Styling override. Default: the live file's ``plot_config`` + when available, else ``PlotConfig()``. + show_plot : bool, default True + Set ``False`` to build without displaying (tests / batch use). + + Raises + ------ + ValueError + If no matching fit has been performed (or loaded) yet. + """ + + slot = self._latest_slot(file=file, model=model, fit_type=fit_type) + cfg = self._config_for(slot, config) + energy, time = self._axes_for(slot) + if slot.fit_type in ("2d", "sbs"): + from trspecfit import fitlib + + fitlib.plt_fit_res_2d( + data=np.asarray(slot.observed), + fit=np.asarray(slot.fit), + x=energy, + y=time, + config=cfg, + save_img=0 if show_plot else -2, + ) + return + self._plot_fit_1d(slot, energy=energy, config=cfg, show_plot=show_plot) + + # + @staticmethod + def _plot_fit_1d( + slot: SavedFitSlot, + *, + energy: np.ndarray | None, + config: Any, + show_plot: bool, + ) -> Any: + """Observed + fit over a residual panel for a 1D slot.""" + + import matplotlib.pyplot as plt + + obs = np.asarray(slot.observed).ravel() + fit = np.asarray(slot.fit).ravel() + if energy is not None and energy.size == obs.size: + x = energy + x_label = getattr(config, "x_label", "energy") + else: + x = np.arange(obs.size) + x_label = "index" + fig, (ax_fit, ax_res) = plt.subplots( + 2, + 1, + sharex=True, + figsize=(6.0, 5.0), + height_ratios=[3, 1], + ) + ax_fit.plot(x, obs, "k.", ms=3, label="observed") + ax_fit.plot(x, fit, "-", lw=1.5, label="fit") + ax_fit.set_ylabel("intensity") + ax_fit.legend(fontsize="small") + ax_fit.set_title(f"{slot.model_name} ({slot.fit_type})") + ax_res.plot(x, obs - fit, "-", lw=1.0) + ax_res.axhline(0, color="gray", lw=0.5) + ax_res.set_xlabel(x_label) + ax_res.set_ylabel("residual") + if getattr(config, "x_dir", "def") == "rev": + ax_res.invert_xaxis() + fig.tight_layout() + if show_plot: + plt.show() + else: + plt.close(fig) + return fig + + # + def plot_param_evolution( + self, + *, + file: Any = None, + model: str | None = None, + params: Sequence[str] | None = None, + config: Any = None, + show_plot: bool = True, + ) -> None: + """ + Plot per-parameter evolution vs time for the latest matching SbS fit. + + One panel per parameter, values from the slot's wide per-slice + ``params`` frame, x-axis from the file's time axis (array index if + no axes provider is available). + + Parameters + ---------- + file : str | SavedFile | trspecfit.File | None + Filter to a single file (name string or object with ``.name``). + model : str, optional + Filter to a single model name. + params : sequence of str, optional + Which parameters to plot. Default: the varied parameters (from + the slot's ``params_meta``); for slots loaded from schema-2 + archives (no ``params_meta``), every parameter. Plots nothing + if the default resolves to an empty set (all-fixed model). + config : PlotConfig, optional + Styling override (see :meth:`plot_fit`). + show_plot : bool, default True + Set ``False`` to build without displaying. + + Raises + ------ + ValueError + If no matching SbS fit exists. + KeyError + If ``params`` names a parameter the fit does not have. + """ + + slot = self._latest_slot(file=file, model=model, fit_type="sbs") + if params is None: + if slot.params_meta is not None: + params = [ + str(name) + for name, vary in zip( + slot.params_meta["name"], + slot.params_meta["vary"], + strict=True, + ) + if vary + ] + else: + params = [str(c) for c in slot.params.columns] + else: + params = [str(p) for p in params] + missing = [p for p in params if p not in slot.params.columns] + if missing: + raise KeyError( + f"Parameter(s) {missing} not in this SbS fit; available: " + f"{list(slot.params.columns)}" + ) + if not params: + return + cfg = self._config_for(slot, config) + _, time = self._axes_for(slot) + n_slices = len(slot.params) + x = ( + np.asarray(time)[:n_slices] + if time is not None and np.asarray(time).size >= n_slices + else np.arange(n_slices) + ) + from trspecfit import fitlib + + fitlib.plt_fit_res_pars( + df=slot.params.loc[:, params], + x=x, + config=cfg, + save_img=0 if show_plot else -2, + ) + # def compare_models( self, @@ -812,10 +1085,10 @@ def plot_residuals( """ Plot observed/fit/residual for the selected slots side-by-side. - Smoke-test-grade visualization: x-axis is array index (no energy / - time labels), since slots do not carry the parent file's axes. - Users wanting publication-quality plots should build their own from - ``slot.observed`` / ``slot.fit``. + Uses real energy/time axes when this ``FitResults`` carries an + axes provider for the file (always the case for ``Project.results`` + and ``FitResults.load``); falls back to array indices otherwise. + For single-fit plots with full styling, see :meth:`plot_fit`. Parameters ---------- @@ -883,8 +1156,8 @@ def plot_residuals( ) # - @staticmethod def _plot_residuals_1d( + self, slots: list[SavedFitSlot], file_name: str, *, @@ -906,14 +1179,18 @@ def _plot_residuals_1d( for col, slot in enumerate(slots): obs = np.asarray(slot.observed).ravel() fit = np.asarray(slot.fit).ravel() - x = np.arange(obs.size) + energy, _ = self._axes_for(slot) + if energy is not None and energy.size == obs.size: + x, x_label = energy, "energy" + else: + x, x_label = np.arange(obs.size), "index" axs[0, col].plot(x, obs, "k.", ms=3, label="observed") axs[0, col].plot(x, fit, "-", lw=1.5, label="fit") axs[0, col].set_title(f"{slot.model_name} ({slot.fit_type})") axs[0, col].legend(fontsize="small") axs[1, col].plot(x, obs - fit, "-", lw=1.0) axs[1, col].axhline(0, color="gray", lw=0.5) - axs[1, col].set_xlabel("index") + axs[1, col].set_xlabel(x_label) if col == 0: axs[0, col].set_ylabel("intensity") axs[1, col].set_ylabel("residual") @@ -926,8 +1203,8 @@ def _plot_residuals_1d( return fig # - @staticmethod def _plot_residuals_2d( + self, slots: list[SavedFitSlot], file_name: str, *, @@ -957,6 +1234,24 @@ def _plot_residuals_2d( ) im = None for col, (slot, res) in enumerate(zip(slots, residuals, strict=True)): + energy, time = self._axes_for(slot) + extent = None + x_label, y_label = "energy index", "time / slice index" + if ( + energy is not None + and time is not None + and res.ndim == 2 + and energy.size == res.shape[1] + and time.size >= res.shape[0] + ): + time_view = np.asarray(time)[: res.shape[0]] + extent = ( + float(energy[0]), + float(energy[-1]), + float(time_view[0]), + float(time_view[-1]), + ) + x_label, y_label = "energy", "time" im = axs[0, col].imshow( res, aspect="auto", @@ -964,11 +1259,12 @@ def _plot_residuals_2d( vmin=-global_max, vmax=global_max, origin="lower", + extent=extent, ) axs[0, col].set_title(f"{slot.model_name} ({slot.fit_type})") - axs[0, col].set_xlabel("energy index") + axs[0, col].set_xlabel(x_label) if col == 0: - axs[0, col].set_ylabel("time / slice index") + axs[0, col].set_ylabel(y_label) if im is not None: fig.colorbar(im, ax=axs[0, :].tolist(), shrink=0.85) fig.suptitle(f"Residuals — {file_name}") diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index 60c11e8..2e2f723 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -300,10 +300,11 @@ def results(self) -> FitResults: subsequent fits append to the log and do not affect previously returned ``FitResults``. Object identity is unstable (``p.results is p.results`` is False); the contents at a given access - are fixed. + are fixed. The live ``File`` objects are passed along as axes / + plot-config providers for the plot methods. """ - return FitResults(slots=list(self._fit_history)) + return FitResults(slots=list(self._fit_history), files=list(self.files)) # def save_fits( @@ -1535,7 +1536,7 @@ def fit_2d( if slots_2d: for f, slot in zip(self.files, slots_2d, strict=True): if slot is not None: - f._display_fit_2d_maps(slot) + f.plot_fit(model=model_name, fit_type="2d") # @@ -3408,7 +3409,10 @@ def _slice_path(s_i: int) -> pathlib.Path: # diagnostics (fit_wrapper CSVs, per-slice PNGs) were already # written under model_path during the fit loop. if self.p.show_output >= 1 and slot_sbs is not None: - self._display_sbs_fit(slot_sbs) + # Inline display via the explicit plot API (reads the slot + # just appended): varied-parameter evolution + fit maps. + self.plot_param_evolution(model=model_name) + self.plot_fit(model=model_name, fit_type="sbs") if self.p.auto_export and slot_sbs is not None: self.export_fit( self.p.path_results, @@ -3424,65 +3428,6 @@ def _slice_path(s_i: int) -> pathlib.Path: t_start=t_sbs, print_str="Time elapsed for Slice-by-Slice fit: " ) - # - def _display_fit_2d_maps(self, slot: fit_io.SavedFitSlot) -> None: - """ - Show a 2D-shaped slot's data/fit/residual maps inline (no writes). - - The slot's ``observed`` / ``fit`` arrays live on the cropped fit - grid, so the live axes are cut to the slot's selection before - plotting (mirrors ``fit_io._slot_axes``). - """ - - assert self.energy is not None # type guard - assert self.time is not None # type guard - energy = np.asarray(self.energy) - e_lim = slot.selection.get("e_lim") - if e_lim: - energy = energy[int(e_lim[0]) : int(e_lim[1])] - time = np.asarray(self.time) - if slot.fit_type == "2d": - t_lim = slot.selection.get("t_lim") - if t_lim: - time = time[int(t_lim[0]) : int(t_lim[1])] - fitlib.plt_fit_res_2d( - data=slot.observed, - fit=slot.fit, - x=energy, - y=time, - config=self.plot_config, - save_img=0, - ) - - # - def _display_sbs_fit(self, slot: fit_io.SavedFitSlot) -> None: - """ - Show an SbS slot inline: varied-parameter evolution + fit maps. - - Fully slot-driven: vary flags come from ``slot.params_meta`` (the - vary set is slice-invariant, so the shared metadata frame is - authoritative). - """ - - assert self.time is not None # type guard - assert slot.params_meta is not None # type guard (sbs slots carry it) - varied = { - str(name) - for name, vary in zip( - slot.params_meta["name"], slot.params_meta["vary"], strict=True - ) - if vary - } - par_cols = [c for c in slot.params.columns if c in varied] - if par_cols: - fitlib.plt_fit_res_pars( - df=slot.params.loc[:, par_cols], - x=np.asarray(self.time)[: len(slot.params)], - config=self.plot_config, - save_img=0, - ) - self._display_fit_2d_maps(slot) - # # ------------------------------------------------------------------ # Slot capture (eager extraction into Project._fit_history) @@ -4228,7 +4173,7 @@ def fit_2d(self, model_name: str, stages: int = 1, **fit_wrapper_kwargs) -> None # Display inline when interactive (show_output); export the slot # when auto_export — mirroring fit_baseline's split. if self.p.show_output >= 1 and slot_2d is not None: - self._display_fit_2d_maps(slot_2d) + self.plot_fit(model=model_name, fit_type="2d") if self.p.auto_export and slot_2d is not None: self.export_fit( self.p.path_results, @@ -4400,6 +4345,79 @@ def get_mcmc( return self.p.results.get_mcmc(file=self, model=model, fit_type=fit_type) + # + def plot_fit( + self, + *, + model: str | None = None, + fit_type: Literal["baseline", "spectrum", "sbs", "2d"] = "baseline", + config: PlotConfig | None = None, + show_plot: bool = True, + ) -> None: + """ + Plot the latest matching fit: observed, fit, and residual. + + Sugar for ``self.p.results.plot_fit(file=self, ...)`` — reads the + persisted fit slot and uses this file's axes and ``plot_config``. + See :meth:`FitResults.plot_fit`. + + Parameters + ---------- + model : str, optional + Restrict to a single model name. Default: latest fit of + ``fit_type`` regardless of model. + fit_type : {'baseline', 'spectrum', 'sbs', '2d'}, default='baseline' + Which fit to plot. + config : PlotConfig, optional + Styling override; defaults to this file's ``plot_config``. + show_plot : bool, default True + Set ``False`` to build without displaying. + """ + + self.p.results.plot_fit( + file=self, + model=model, + fit_type=fit_type, + config=config, + show_plot=show_plot, + ) + + # + def plot_param_evolution( + self, + *, + model: str | None = None, + params: Sequence[str] | None = None, + config: PlotConfig | None = None, + show_plot: bool = True, + ) -> None: + """ + Plot per-parameter evolution vs time for the latest SbS fit. + + Sugar for ``self.p.results.plot_param_evolution(file=self, ...)``. + Defaults to the varied parameters; see + :meth:`FitResults.plot_param_evolution`. + + Parameters + ---------- + model : str, optional + Restrict to a single model name. + params : sequence of str, optional + Which parameters to plot (default: varied parameters). + config : PlotConfig, optional + Styling override; defaults to this file's ``plot_config``. + show_plot : bool, default True + Set ``False`` to build without displaying. + """ + + self.p.results.plot_param_evolution( + file=self, + model=model, + params=params, + config=config, + show_plot=show_plot, + ) + # def compare_models( self, diff --git a/tests/test_fit_history.py b/tests/test_fit_history.py index 185996f..bbb2632 100644 --- a/tests/test_fit_history.py +++ b/tests/test_fit_history.py @@ -1478,6 +1478,148 @@ def test_sbs_sum_chi2_red_uses_calibrated_numerator(self): assert df["chi2_red"].iloc[0] == pytest.approx(per_slice_raw / sigma**2) +# +class TestPlotFitAPI: + """FitResults.plot_fit / plot_param_evolution and the File.* sugar.""" + + # + def test_plot_fit_1d_uses_real_axes_and_config(self): + import matplotlib.pyplot as plt + + _, file = _setup_baseline_fit() + file.plot_fit(fit_type="baseline") # show under Agg keeps the fig live + fig = plt.gcf() + try: + line_x = fig.axes[0].lines[0].get_xdata() + np.testing.assert_array_equal(line_x, np.asarray(file.energy)) + assert fig.axes[1].get_xlabel() == file.plot_config.x_label + finally: + plt.close("all") + + # + def test_plot_fit_2d_passes_real_axes(self, monkeypatch): + from unittest.mock import MagicMock + + from trspecfit import fitlib + + mock_2d = MagicMock() + monkeypatch.setattr(fitlib, "plt_fit_res_2d", mock_2d) + + project, file = _setup_baseline_fit() + file.add_time_dependence( + target_model="single_glp", + target_parameter="GLP_01_A", + dynamics_yaml="models/file_time.yaml", + dynamics_model=["MonoExpPos"], + ) + file.fit_2d("single_glp", stages=1, try_ci=0) + mock_2d.reset_mock() + + project.results.plot_fit(file=file, fit_type="2d", show_plot=False) + assert mock_2d.call_count == 1 + kwargs = mock_2d.call_args.kwargs + np.testing.assert_array_equal(kwargs["x"], np.asarray(file.energy)) + np.testing.assert_array_equal(kwargs["y"], np.asarray(file.time)) + assert kwargs["save_img"] == -2 # show_plot=False + + # + def test_plot_fit_from_loaded_archive_has_axes(self, tmp_path): + import matplotlib.pyplot as plt + + project, file = _setup_baseline_fit() + archive_path = tmp_path / "plot.fit.h5" + project.save_fits(archive_path, show_output=0) + + loaded = FitResults.load(archive_path) + loaded.plot_fit(file=file.name, fit_type="baseline") + fig = plt.gcf() + try: + line_x = fig.axes[0].lines[0].get_xdata() + np.testing.assert_array_equal(line_x, np.asarray(file.energy)) + finally: + plt.close("all") + + # + @staticmethod + def _fake_sbs_results(*, vary=(True, False, True)): + """FitResults around a synthetic SbS slot (wide params + metadata).""" + + import dataclasses + + project, _ = _setup_baseline_fit() + wide = pd.DataFrame( + { + "A": [1.0, 2.0, 3.0], + "B": [4.0, 5.0, 6.0], + "C": [7.0, 8.0, 9.0], + } + ) + meta = pd.DataFrame( + { + "name": ["A", "B", "C"], + "vary": list(vary), + "min": [0.0] * 3, + "max": [10.0] * 3, + "expr": [None] * 3, + } + ) + fake = dataclasses.replace( + project._fit_history[0], fit_type="sbs", params=wide, params_meta=meta + ) + return FitResults(slots=[fake]) + + # + def test_plot_param_evolution_defaults_to_varied(self, monkeypatch): + from unittest.mock import MagicMock + + from trspecfit import fitlib + + mock_pars = MagicMock() + monkeypatch.setattr(fitlib, "plt_fit_res_pars", mock_pars) + + results = self._fake_sbs_results(vary=(True, False, True)) + results.plot_param_evolution(show_plot=False) + assert mock_pars.call_count == 1 + kwargs = mock_pars.call_args.kwargs + assert list(kwargs["df"].columns) == ["A", "C"] # varied only + # No axes provider on this FitResults -> index fallback. + np.testing.assert_array_equal(kwargs["x"], np.arange(3)) + + # + def test_plot_param_evolution_explicit_and_missing_params(self, monkeypatch): + from unittest.mock import MagicMock + + from trspecfit import fitlib + + mock_pars = MagicMock() + monkeypatch.setattr(fitlib, "plt_fit_res_pars", mock_pars) + + results = self._fake_sbs_results() + results.plot_param_evolution(params=["B"], show_plot=False) + assert list(mock_pars.call_args.kwargs["df"].columns) == ["B"] + with pytest.raises(KeyError, match="not in this SbS fit"): + results.plot_param_evolution(params=["nope"], show_plot=False) + + # + def test_plot_param_evolution_all_fixed_plots_nothing(self, monkeypatch): + from unittest.mock import MagicMock + + from trspecfit import fitlib + + mock_pars = MagicMock() + monkeypatch.setattr(fitlib, "plt_fit_res_pars", mock_pars) + + results = self._fake_sbs_results(vary=(False, False, False)) + results.plot_param_evolution(show_plot=False) + assert mock_pars.call_count == 0 + + # + def test_plot_residuals_uses_energy_axis_with_provider(self): + project, file = _setup_baseline_fit() + fig = project.results.plot_residuals(file=file.name, show_plot=False) + assert fig.axes[1].get_xlabel() == "energy" + + # class TestFitResultsPlotResiduals: """Smoke tests for FitResults.plot_residuals — figure construction only.""" From fdbcb814e5d8ebff1729999d753f69b76761ea05 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Fri, 17 Jul 2026 11:57:17 -0700 Subject: [PATCH 11/29] reconcile docs and TODO for the results-ownership work, bump to 0.14.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the fit_results.py section of repo_architecture.md around the ownership contract; add docs/api/fit_results.rst (FitResults + MCMCResult) and replace the deleted save_sbs_fit / save_2d_fit autodoc entries with a results/plotting/persistence section, making the docs build warning-free again. llms.txt gains plot_fit and a note that the get_*/plot_* accessors read persisted records (live sessions and loaded archives alike). TODO.md: both [ACTIVE] items are done — replaced by a slim typed-result-object follow-up; the v1.0.0 legacy-shim item reflects the removed savers. Version 0.13.1 -> 0.14.0 (breaking auto-export layout + archive schema 3). --- PLAN.md | 41 +++++++++++++++++++++----------- TODO.md | 15 +++--------- docs/api/fit_results.rst | 17 +++++++++++++ docs/api/index.rst | 1 + docs/api/trspecfit.rst | 20 +++++++++++++--- docs/design/repo_architecture.md | 27 +++++++++++++-------- llms.txt | 5 ++++ pyproject.toml | 2 +- 8 files changed, 88 insertions(+), 40 deletions(-) create mode 100644 docs/api/fit_results.rst diff --git a/PLAN.md b/PLAN.md index 7d0d3e5..7f3d886 100644 --- a/PLAN.md +++ b/PLAN.md @@ -173,20 +173,33 @@ snapshot the model *as fit*. Model rehydration stays deferred. - [x] Config resolution: explicit `config=` > live `plot_config` > `PlotConfig()`. Changelog updated. -## Phase 6 — Docs, tests, release hygiene - -- [ ] Update `docs/design/repo_architecture.md` (ownership contract), - `docs/design/ui.md` cross-refs, export-related docstrings. -- [ ] Reconcile `llms.txt` / `AGENTS.md` guidance (auto-export layout, - accessor story). -- [ ] Notebooks: 12 (accessors), 11/20 (saving/export) — check for legacy - layout or `save_*_fit` mentions. -- [ ] TODO.md: drop both items on completion, remove `[ACTIVE]`; note partial - progress on v1.0.0 item 4 (remaining shims: sweep.py legacy branch, - plot.py int-mapping helper). -- [ ] Version bump: breaking layout change + schema bump → `0.14.0`. -- [ ] Archive decision: this file likely warrants `docs/design/archive/` - (ownership contract is durable) — ask at completion per protocol. +## Phase 6 — Docs, tests, release hygiene — DONE + +- [x] `docs/design/repo_architecture.md`: fit_results.py section rewritten + around the ownership contract (accessors, plot API, axes providers); + save/export section was updated in Phase 4. `docs/design/ui.md` had + no affected content. +- [x] `llms.txt`: plot_fit added to the workflow snippet + a line stating + the get_*/plot_* accessors read persisted records and work on loaded + archives. AGENTS.md had no affected content. +- [x] Notebooks greped for legacy layout / removed APIs: only notebook 11 + mentions export artifacts, and its description matches the slot + exporter (which is unchanged). Accessor signatures unchanged → + notebooks 12/01/03/04/20/21 compatible. Full notebook re-runs left + to the release flow (`docs/ai/check-example`). +- [x] API docs: `docs/api/trspecfit.rst` gained a "Results, Plotting, and + Persistence" section (dropping the deleted save_sbs_fit/save_2d_fit + entries that broke autodoc); new `docs/api/fit_results.rst` documents + `FitResults` + `MCMCResult`. `make -C docs html` warning-free. +- [x] TODO.md: both `[ACTIVE]` items removed — replaced by a slim deferred + item (typed result object / raw `result[1..4]` internal cleanup); + v1.0.0 item 4 updated (legacy savers gone; sweep.py + plot.py shims + remain); trace-fitting item notes params_stderr now persisted. +- [x] Version bump: 0.13.1 → 0.14.0 (breaking auto-export layout + + schema 3). CHANGELOG `[Unreleased]` section already complete. +- [ ] Archive decision: ask user — move this plan into + `docs/design/archive/` (ownership contract is durable) or let the + changelog stand; then clear PLAN.md per protocol. ## Open implementation points (resolve while working, not user-blocking) diff --git a/TODO.md b/TODO.md index d10dfdf..7778c4a 100644 --- a/TODO.md +++ b/TODO.md @@ -2,7 +2,7 @@ ## Fitting -- [ ] **Enable 1D time-trace fitting (post-SbS kinetics)**: wire standalone `TIME_1D` dynamics models (already evaluable on the mcp path per `docs/design/supported_models.md`, but connected to no fit method) to a fit entry point, so parameter-vs-time traces extracted from SbS results can be fit to `functions/time.py` dynamics (`expFun` sums, IRF convolution) inside the package instead of in external scripts. Frame it as the diagnostic/initialization rung before `fit_2d`, not a statistical equivalent (per-slice correlations and unpropagated SbS uncertainties make two-step inferior). Design direction: promote SbS results into a time-axis `File` — the promoted object inherits limits/CI/MCMC/save/export machinery, and trace fits land in a `SavedFitSlot` like every other fit type (no parallel fitting pipeline). Requires `File` to support time as the primary axis, which today it assumes is energy. Weighting traces by per-slice stderr ties into the `sigma_type` expansion item below. +- [ ] **Enable 1D time-trace fitting (post-SbS kinetics)**: wire standalone `TIME_1D` dynamics models (already evaluable on the mcp path per `docs/design/supported_models.md`, but connected to no fit method) to a fit entry point, so parameter-vs-time traces extracted from SbS results can be fit to `functions/time.py` dynamics (`expFun` sums, IRF convolution) inside the package instead of in external scripts. Frame it as the diagnostic/initialization rung before `fit_2d`, not a statistical equivalent (per-slice correlations and unpropagated SbS uncertainties make two-step inferior). Design direction: promote SbS results into a time-axis `File` — the promoted object inherits limits/CI/MCMC/save/export machinery, and trace fits land in a `SavedFitSlot` like every other fit type (no parallel fitting pipeline). Requires `File` to support time as the primary axis, which today it assumes is energy. Weighting traces by per-slice stderr ties into the `sigma_type` expansion item below — the per-slice stderr itself is persisted since v0.14.0 (`SavedFitSlot.params_stderr`). ## Noise and simulation @@ -15,16 +15,8 @@ - vmap-batched slice-by-slice solver (the one workload where lmfit overhead plausibly dominates; would be the Phase E pilot) — see [docs/design/ui.md](docs/design/ui.md). - `vmap`-batch homogeneous file series in the fused project fit (unrolled per-file fusion shipped in v0.13.0) — see [docs/design/project-level-fits.md](docs/design/project-level-fits.md). - `fit_model_compare`-style runtime JAX parity mode, or a cheaper one-shot pre-fit parity check on the JAX path. -- [ ] `[ACTIVE]` **Define the results-data ownership boundary**: take a look at what should live as class attributes on the `trspecfit`/`mcp` Python classes (`File`/`Model`) versus inside the `FitResults` class. Where should the line be — should fit outputs (params, `conf_ci`, MCMC payload, correlations, acceptance fraction, diagnostics) all be unified under `FitResults`, or stay split between live `model.result[...]` and persisted slots? Then update all callers and the `get_*` accessor methods to match the chosen boundary. Sub-items: - - **Persist `correl` and `acceptance_fraction` into the slots**: 2026-06 added live-only accessors (`get_correlations`, `get_conf_intervals`, `get_mcmc`) reading `model.result` as a stopgap for notebook 12, so these are NOT yet saved. Add per-parameter correlations to the slot `params` payload and `acceptance_fraction` to the slot `mcmc` payload, with `.fit.h5` read/write support and save/load round-trip tests, so they survive persistence like the rest of the slot. - - **Relocate the live accessors to `FitResults`**: 2026-06 added `File.get_correlations`, `File.get_conf_intervals`, `File.get_mcmc` (and the private `File._result_model` resolver) reading `model.result[...]` directly. These conceptually belong on `FitResults` (like `compare_models`, which already lives there with `File.compare_models` as thin sugar). The existing `File.get_fit_results` is in the same boat. Decide whether all of these should move into `FitResults` (with thin `File.*` sugar that delegates), and whether they read live `model.result` or persisted slots — then move them and update callers (notebook 12 reads them). - - The raw list-index access (`result[1..4]`) and the deeper unified-results-object question are deferred to this item. +- [ ] **Unified results object / raw `result[1..4]` cleanup**: the results-data ownership boundary shipped in v0.14.0 (`SavedFitSlot` is the authoritative fit record; `FitResults` is the single read/query/plot surface; `File.get_*` / `plot_*` are thin sugar — see the archived design in `docs/design/`). What remains deferred from that pass: internal code (fit methods, slot capture) still passes the raw `[par_ini, par_fin, conf_ci, emcee_fin, emcee_ci]` list around as `model.result` — decide whether to replace it with a typed result object now that nothing user-facing reads it. - [ ] **Decide how to guard/warn against in-place mutation of user-facing arrays**: internal machinery assumes `File.data`/`energy`/`time` and fit outputs are stable once set — e.g. `SavedFitSlot` stores `params`/`observed`/`fit`/`selection` by reference (`frozen=True` blocks reassignment, not in-place mutation; 2026-07 code review, check 1), and file fingerprints / `observed_sha256` are computed once at slot construction. A user mutating `file.data` in place instead of re-instantiating would desynchronize slots, fit limits, and cached evaluations in ways no single defensive copy fixes — so slot-level copies were considered and declined (2026-07-10) as papering over one symptom. Decide on a systemic stance instead: read-only views (`setflags(write=False)`) on public arrays, copy-on-set in setters, a documented ownership contract, and/or re-hash validation at save time. A sibling case: `Simulator.sigma_data` is recomputed on read from the current `noise_level`/`noise_type` ([simulator.py](src/trspecfit/simulator.py) ~L1057), so calling `set_noise_level`/`set_noise_type` after `simulate()` but before `save_data()` persists a stale or missing `metadata.sigma_data` (the value fed to `File.set_sigma`) that no longer matches the saved noisy data — cheap dedicated fix is to snapshot the derived sigma at simulation time; fold it into whatever stance is chosen. -- [ ] `[ACTIVE]` **Disentangle plotting from saving/conversion in the fit pipeline**: figure rendering is currently entangled with data conversion and file IO. `fitlib.results_to_df` ([fitlib.py](src/trspecfit/fitlib.py) ~L1015) is the worst offender — it converts results → DataFrame, writes `fit_pars.csv`, *and* plots the per-parameter curves, including per-column show/save logic based on which parameters varied; its output feeds `results_to_fit_2d` which feeds `plt_fit_res_2d`, so the SbS chain must be restructured as a whole. `File._save_2d_fit_legacy` / `_save_sbs_fit_legacy` (CSV writers) also render figures, and `fit_slice_by_slice` / `fit_2d` reach plotting only by calling that save-legacy path (`fit_baseline` is already disentangled — it calls `fitlib.plt_fit_res_1d` directly, gated by `_save_img_flag`; use it as the template). Split into (a) compute/convert, (b) explicit save/export, (c) an explicit plotting API. Scoping session 2026-07-13 found this is design work, not refactoring — three decisions are load-bearing: - - **The disentangled implementation already exists**: `fit_io._export_2d_slot` / `_export_sbs_param_evolution` ([fit_io.py](src/trspecfit/utils/fit_io.py) ~L1918/~L1953) are pure writers reading from saved slots, plotting explicit. The `_save_*_legacy` methods survive *only* because auto-export promises the original on-disk layout byte-for-byte (see the `save_sbs_fit`/`save_2d_fit` deprecation docstrings). The honest fix is routing auto-export through the slot-based export path and accepting the layout change — a user-facing breaking decision, tied to the legacy-shim removal item in the v1.0.0 checklist (which this blocks: the `_save_*_legacy` impls are the live SbS/2D plotting path). - - **The explicit plotting API (c) needs designing**: `FitResults.plot_*` vs `File.plot_*` — cf. `_save_img_flag`, `FitResults.plot_residuals`, and the interactive-UI notes in [docs/design/ui.md](docs/design/ui.md). - - **Coupled to the results-data ownership boundary item above**: whether plots read live `model.result` or persisted slots is the same question. - - Do it as one deliberate pass — a 2D-only hoist (`plt_fit_res_2d` out of `_save_2d_fit_legacy`, ~30 lines) was considered and declined (2026-07-13): it would leave three conventions (baseline disentangled, 2D half-done, SbS legacy). Guardrail tests already pin the display/save matrix (`TestVerboseDisplayWithoutExport`, `TestPlotHelperSkipped` in [tests/test_auto_export.py](tests/test_auto_export.py)). Until then, the fit methods gate display on `show_output` and saving on `auto_export` via a `save_files` flag through the legacy methods. Scope: beyond the examples-upgrade branch. ## User and AI ergonomics @@ -40,7 +32,6 @@ Rationale (2026-07-06): lock-in comes from schemas and public names, not from us 1. [ ] **Version the model YAML schema**: model YAML files are the artifact user groups accumulate, and currently the only unversioned contract — the fit archive already carries `SCHEMA_VERSION` ([fit_io.py](src/trspecfit/utils/fit_io.py) ~L46) and refuses mismatched reads/appends. Add an optional top-level format-version key in `utils/parsing.py` (absent = current version) so future syntax changes can warn or migrate instead of silently misparsing old model files. 2. [ ] **Curate the public API surface**: audit user-facing classes (`File`, `Project`, `Simulator`, `Model`, etc.) and decide which methods/attributes should be discoverable in notebooks and docs. Add curated `__dir__()` output for autocomplete, keep `__all__`/API docs aligned, and gradually rename or deprecate internal helper methods that should not look like primary user workflows. This should improve both human notebook ergonomics and AI/LLM efficiency by making the intended workflow surface smaller, clearer, and easier to infer. Prerequisite for outreach: once external groups have notebooks, every public-looking name is frozen in practice. 3. [ ] **Document API tiers**: add a short guide that separates stable user API (`Project`, `File`, `Simulator`, `PlotConfig`), advanced public API (`mcp.Model`, `Component`, `Par`, `ParameterSweep`, `MC`), and internal implementation modules (`graph_ir`, `eval_1d`, `eval_2d`, low-level parsing/HDF5 helpers). Use this as the source of truth for docs, tests, examples, and AI-agent guidance. Also settles which surfaces get the post-1.0 deprecation commitment — `docs/stability.md` (2026-07-12) commits the user API and explicitly defers the advanced tier to this guide. Reconcile the agent-facing orientation docs (`llms.txt`, `AGENTS.md`) against the finalized tiers in the same pass — they restate API/knob details (e.g. the headless `show_output`/`auto_export` guidance) that silently drift from the code otherwise. -4. [ ] **Remove legacy/backwards-compat code**: audit codebase for legacy fallbacks and backwards-compatibility shims and consider removing before v1.0.0. Full removal depends on the plotting/saving disentanglement item (Performance & architecture) — the `_save_*_fit_legacy` impls are still the live SbS/2D plotting path. Known shims slated for removal: - - `File.save_sbs_fit` / `File.save_2d_fit` (deprecated wrappers — replace internal callers and drop the public methods plus their `_save_*_fit_legacy` impls; users should migrate to `File.export_fit(fit_type=...)`). +4. [ ] **Remove legacy/backwards-compat code**: audit codebase for legacy fallbacks and backwards-compatibility shims and consider removing before v1.0.0. The big blocker cleared in v0.14.0 (`save_sbs_fit` / `save_2d_fit` and the `_save_*_fit_legacy` impls are gone; auto-export routes through the slot exporter). Known shims still slated for removal: - Legacy pre-rename format branch in [sweep.py](src/trspecfit/utils/sweep.py) ~L624 and the legacy `save_img` int-mapping helper in [plot.py](src/trspecfit/utils/plot.py) ~L813. 5. [ ] **`trspec.fit` project website**: stand up the site as the home for fully rendered example notebooks (plots, fit tables) so users can browse without installing — preferred over Read the Docs, which has build timeouts/execution limits for notebooks. Room to grow into interactive tooling: a model-builder UI (GUI that outputs the YAML model files) and other future work. Keep git notebook sources stripped (`nbstripout`); render/execute at publish time so outputs never drift from the code. diff --git a/docs/api/fit_results.rst b/docs/api/fit_results.rst new file mode 100644 index 0000000..b439dc8 --- /dev/null +++ b/docs/api/fit_results.rst @@ -0,0 +1,17 @@ +Fit Results +=========== + +Completed-fit inspection, comparison, and plotting. A ``FitResults`` is +an immutable view over persisted fit records (``SavedFitSlot``), obtained +from ``Project.results`` (in-session) or ``FitResults.load(path)`` +(archives) — the accessors and plot methods behave identically on both. +The ``File.get_*`` / ``File.plot_*`` / ``File.compare_models`` methods +are thin delegates into this class. + +.. autoclass:: trspecfit.fit_results.FitResults + :members: + :show-inheritance: + +.. autoclass:: trspecfit.utils.lmfit.MCMCResult + :members: + :show-inheritance: diff --git a/docs/api/index.rst b/docs/api/index.rst index 98bbda8..7fd63fa 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -7,6 +7,7 @@ This section contains the auto-generated API documentation. :maxdepth: 2 trspecfit + fit_results mcp functions_energy functions_time diff --git a/docs/api/trspecfit.rst b/docs/api/trspecfit.rst index 0a049f8..7bcd829 100644 --- a/docs/api/trspecfit.rst +++ b/docs/api/trspecfit.rst @@ -58,6 +58,23 @@ Fitting Workflow .. automethod:: trspecfit.trspecfit.File.fit_2d .. automethod:: trspecfit.trspecfit.File.fit_spectrum +Results, Plotting, and Persistence +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +These read the persisted fit record (latest matching fit), so they work +identically on a live session and on loaded archives — see +:class:`trspecfit.fit_results.FitResults` for the underlying API. + +.. automethod:: trspecfit.trspecfit.File.get_fit_results +.. automethod:: trspecfit.trspecfit.File.get_correlations +.. automethod:: trspecfit.trspecfit.File.get_conf_intervals +.. automethod:: trspecfit.trspecfit.File.get_mcmc +.. automethod:: trspecfit.trspecfit.File.plot_fit +.. automethod:: trspecfit.trspecfit.File.plot_param_evolution +.. automethod:: trspecfit.trspecfit.File.compare_models +.. automethod:: trspecfit.trspecfit.File.save_fit +.. automethod:: trspecfit.trspecfit.File.export_fit + Utility Methods ~~~~~~~~~~~~~~~ @@ -67,6 +84,3 @@ Utility Methods .. automethod:: trspecfit.trspecfit.File.model_list_to_name .. automethod:: trspecfit.trspecfit.File.model_path -.. automethod:: trspecfit.trspecfit.File.get_fit_results -.. automethod:: trspecfit.trspecfit.File.save_sbs_fit -.. automethod:: trspecfit.trspecfit.File.save_2d_fit diff --git a/docs/design/repo_architecture.md b/docs/design/repo_architecture.md index e451a86..68a017e 100644 --- a/docs/design/repo_architecture.md +++ b/docs/design/repo_architecture.md @@ -127,16 +127,23 @@ training-data synthesis. ### `fit_results.py` — completed-fit inspection / comparison User-facing `FitResults` class — the immutable view over a list of -`SavedFitSlot`. Two construction paths: `FitResults.load(path)` for -loaded archives and the `Project.results` property for in-session work. -A `FitResults` is frozen at construction (the underlying slot list is -copied), so `r1 = p.results; ; r2 = p.results` gives -two distinct snapshots — `r1` does not see the new slot. Query API: -`find` / `get` / `files` / `models` / iteration. Comparison: -`compare_models` (returns a metrics DataFrame; refuses to compare -slots whose `observed_sha256` differs on the same `(file, fit_type)`) -and `plot_residuals` (smoke-test-grade panels, no energy/time labels — -slots don't carry parent-file axes). The save/export side lives in +`SavedFitSlot`, and the single read/query/plot surface of the +results-ownership contract: everything a user asks about a completed fit +is answered from slots, never from live `Model.result`. Two construction +paths: `FitResults.load(path)` for loaded archives and the +`Project.results` property for in-session work; both also attach +fingerprint-matched axes providers (`SavedFile`s / live `File`s) so plots +carry real energy/time axes. A `FitResults` is frozen at construction +(the underlying slot list is copied), so `r1 = p.results; +; r2 = p.results` gives two distinct snapshots — `r1` +does not see the new slot. Query API: `find` / `get` / `files` / +`models` / iteration. Accessors (latest matching slot; `File.get_*` is +thin sugar): `get_fit_results` / `get_correlations` / +`get_conf_intervals` / `get_mcmc`. Comparison: `compare_models` (returns +a metrics DataFrame; refuses to compare slots whose `observed_sha256` +differs on the same `(file, fit_type)`). Plotting (`File.plot_*` sugar; +also the fit methods' inline-display path): `plot_fit`, +`plot_param_evolution`, `plot_residuals`. The save/export side lives in `utils/fit_io.py`; this module is read-only on top of those slots. ## Fit results: save / export / load architecture diff --git a/llms.txt b/llms.txt index 93cb37d..64f6a73 100644 --- a/llms.txt +++ b/llms.txt @@ -33,9 +33,14 @@ file.fit_2d('2D') # 3) inspect results df = file.get_fit_results(fit_type='2d') # pandas DataFrame +file.plot_fit(fit_type='2d') # observed/fit/residual file.save_fit(...) # HDF5 fit archive ``` +All `get_*` / `plot_*` result accessors read the persisted fit record +(latest matching fit), so they work identically on a live session and on +archives loaded via `FitResults.load(path)`. + Simulation mirrors this: build a model the same way, then use `trspecfit.Simulator` (`simulate_1d`, `simulate_2d`, `simulate_n`) to generate clean or noisy synthetic data from it. diff --git a/pyproject.toml b/pyproject.toml index d1cc93b..e72a4f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trspecfit" -version = "0.13.1" +version = "0.14.0" authors = [ {name = "Johannes Mahl", email = "infinitymonkeyatwork@gmail.com"}, ] From 9955d131c86a075a10d3c626ec623b6337275ab5 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Fri, 17 Jul 2026 14:20:03 -0700 Subject: [PATCH 12/29] archive the results-ownership design record, clear PLAN.md Move the durable decisions (ownership contract, auto-export layout break, slot-backed accessor semantics, schema-3 additions, and the rejected alternatives) into docs/design/archive/results-ownership-and-plotting.md; reset PLAN.md to its empty template per the completion protocol. --- PLAN.md | 216 +----------------- .../archive/results-ownership-and-plotting.md | 105 +++++++++ 2 files changed, 113 insertions(+), 208 deletions(-) create mode 100644 docs/design/archive/results-ownership-and-plotting.md diff --git a/PLAN.md b/PLAN.md index 7f3d886..0d24ddf 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,211 +1,11 @@ -# Active Plan: results ownership boundary + plotting/saving disentanglement +# Active Plan -Branch: `model-vs-fitresult`. Covers two TODO items (both `[ACTIVE]`): -"Define the results-data ownership boundary" and "Disentangle plotting from -saving/conversion in the fit pipeline". Scoping session 2026-07-13 and design -decisions 2026-07-14. +No active multi-step feature in progress. -## Decisions (settled with user, 2026-07-14) +- Backlog lives in [`TODO.md`](TODO.md). +- Shipped-feature design notes live in [`docs/design/`](docs/design/) (and + archived deep-dives in [`docs/design/archive/`](docs/design/archive/)). -1. **Full layout break**: auto-export for SbS/2D routes through the slot-based - exporter (`fit_io._export_slot`); the legacy flat layout is dropped. - `_save_sbs_fit_legacy`, `_save_2d_fit_legacy`, and the deprecated public - `save_sbs_fit` / `save_2d_fit` are deleted in this branch. Partially - unblocks v1.0.0 checklist item 4 (legacy-shim removal). -2. **Plot API home**: explicit plot methods live on `FitResults` (reading - slots), with thin `File.plot_*` sugar — same pattern as `compare_models`. -3. **Data source**: relocated accessors read **persisted slots** (latest - matching slot in `_fit_history`). Live `model.result[...]` becomes an - internal detail. Requires persisting `correl` + `acceptance_fraction` - (Phase 1). Raw `result[1..4]` access and the deeper unified-results-object - question stay deferred (per TODO). - -## Ownership boundary (the contract) - -- **`Model`/`File` (live layer)**: own inputs and fit execution. `model.result` - is a transient internal of the fit run; nothing user-facing reads it. -- **`SavedFitSlot`**: the single authoritative record of a completed fit — - everything a user can ask about a fit must be in (or derivable from) the slot. -- **`FitResults`**: the single read/query/plot surface over slots (live history - and loaded archives alike). `File.get_*` / `File.plot_*` / `File.compare_models` - are thin sugar delegating to `project.results` filtered to that file. - -## Phase 1 — Persist `correl` + `acceptance_fraction` (schema v3) — DONE - -- [x] Add `correl: pd.DataFrame | None` field to `SavedFitSlot` (correlation - matrix over varied params, mirroring `get_correlations()` output). - Stored like `conf_ci` via `_encode_dataframe` (all-float64 square - matrix; index restored from `columns` on read). Matrix builder is - `ulmfit.correl_to_df`; `File.get_correlations` now delegates to it. - Captured in the `_append_*_slot` methods, gated on - `result_fin.covar is not None` — so `correl` is `None` for - covariance-less optimizers and project joint fits (mirrors - stderr/conf_ci absence) instead of a misleading identity matrix. - SbS captures slice 0 (the conf_ci/mcmc convention). -- [x] Add `acceptance_fraction` (per-walker float array) to the slot `mcmc` - payload (`_mcmc_payload`, `_write_mcmc_group`, `_read_mcmc_group`). -- [x] Bump `SCHEMA_VERSION` "2" → "3" with `SUPPORTED_READ_VERSIONS = ("2", - "3")`. Reader accepts v2 archives (new fields → `None`); append across - versions stays refused (existing policy). `fit_archive_schema.md` - updated (also fixed the stale metrics attr list there). -- [x] Round-trip tests: `test_correl_roundtrip` (leastsq pins the - deterministic covar path; Nelder covar depends on numdifftools), - conf_ci/correl/mcmc comparison added to `_assert_slot_round_tripped`, - acceptance_fraction round-trip in `TestMcmcPayload` (slow), v2 - read-tolerance + unknown-version rejection tests. - -## Phase 2 — Relocate accessors to `FitResults`, slot-backed — DONE - -- [x] `FitResults.get_fit_results / get_correlations / get_conf_intervals / - get_mcmc(file=..., model=..., fit_type=...)` reading the latest matching - slot via `_latest_slot` (find() is history-ordered; last match wins, - mirroring the live-model overwrite convention). Accessors return - copies so callers can't mutate slot state. "Not fit yet" raises - ValueError with the legacy "Run fit_x() first" message shape. -- [x] `get_mcmc` builds `ulmfit.MCMCResult` from the slot payload; - `MCMCResult.acceptance_fraction` widened to `np.ndarray | None` - (None for slots loaded from schema-2 archives). -- [x] `File.get_*` are thin delegates to `self.p.results` (fit_type kwarg - unchanged; optional `model=` filter added). `File._result_model` - deleted. Behavior changes: (a) covariance-less fits now raise from - `get_correlations` instead of returning an identity-with-zeros - matrix; (b) fits on Files without `data` (fingerprint source) record - no slot, so accessors raise — data_base-only test fixtures updated - to set `file.data`. -- [x] SbS accessor coverage: get_fit_results serves the wide per-slice - frame; correlations/conf_intervals/mcmc serve slice 0 (documented). -- [x] Notebook 12 compatibility: call signatures unchanged (fit_type - kwarg); stages=2 default → leastsq covar → correl present. Full - notebook re-run deferred to the Phase 6 docs/examples pass. - -## Phase 3 — Purify the conversion layer (fitlib) — DONE - -- [x] `results_to_df`: stripped CSV writing and plotting → pure - results-list → DataFrame conversion (dropped `save_df`/`save_path`/ - `num_fmt`/`delim`; kept `config` only for the `y_label` column name). -- [x] `results_to_fit_2d`: stripped `save_2d` CSV writing → pure - reconstruction (slot already stores the `fit` array). -- [x] `_save_sbs_fit_legacy` compensates inline (CSV writes + the - varied-only `plt_fit_res_pars` flag logic) — byte-identical output, - pinned by the slow export-parity tests; the whole block dies in - Phase 4. -- [x] `plt_fit_res_1d` / `plt_fit_res_2d` / `plt_fit_res_pars` remain the - pure renderers (flag-driven via `_save_img_flag`). - -## Phase 4 — Route auto-export through slots; delete legacy path — DONE - -- [x] `fit_slice_by_slice` / `File.fit_2d` / `Project.fit_2d`: display - (`show_output>=1`) renders inline from the just-captured slot via new - `File._display_fit_2d_maps` / `_display_sbs_fit` helpers (save_img=0); - export (`auto_export`) calls `export_fit(self.p.path_results, ..., - overwrite=True)` — the slot exporter, rooted at `path_results` so the - configured results dir (and test redirection) is respected. Skip- - entirely guard preserved (`TestPlotHelperSkipped` green). The - `_append_*_slot` methods now return the slot (None for mocked/ - no-data fits, which then skip display/export gracefully). -- [x] Fit-time diagnostics unchanged in place: per-slice CSVs/PNGs and - `fit_wrapper`'s per-stage CSVs stay under `model_path()` - (`{path_results}/{file}/{fit_type}/{model}/`). Deviation from the - original "new destination" note: the export slot-dir name is - snapshot-dependent (hash suffix), so it can't be computed mid-fit, - and these are fit diagnostics, not results. -- [x] Deleted `_save_sbs_fit_legacy`, `_save_2d_fit_legacy`, `save_sbs_fit`, - `save_2d_fit`. Repo grep clean; `Project.fit_2d`'s PNG-grid display - replaced by per-file inline slot maps (works without auto_export now). -- [x] Tests: `TestVerboseDisplayWithoutExport` semantics unchanged; - `test_2d_legacy_saver_creates_its_directory` → slot-tree + refit- - overwrite tests; `test_export_fits_parity.py` repurposed to - auto-export ≡ explicit-export tree parity; test_file legacy-saver - validation tests → absence test; project-fit lifecycle test uses - `export_fit`. -- [x] CHANGELOG `[Unreleased]` section written (breaking layout, breaking - get_correlations, removals, schema 3, accessor relocation); - `repo_architecture.md` save/export section updated. - -## Phase 5 — Explicit plotting API - -Design settled 2026-07-16 (session notes): three pieces of information the -SbS plotting path used are unavailable from slots — vary flags (lost), -axes (persisted but discarded at the FitResults layer), and PlotConfig -(never persisted, by choice). Vary is slice-invariant by construction -(one model, one vary set, no mid-loop hook; serial ≡ parallel pinned by -test_gir_integration). YAML-derived capture was considered and rejected: -the runtime state diverges from the YAML (default SbS seeds from the -baseline *fit*; users mutate models between load and fit), so slots -snapshot the model *as fit*. Model rehydration stays deferred. - -### 5a — schema-3 additions (still unreleased; no extra bump) — DONE - -- [x] SbS **shared param metadata** frame `[name, vary, min, max, expr]` - (`SavedFitSlot.params_meta`, sbs-only; captured from slice-0 result - params, column-aligned with the wide frame). `_display_sbs_fit` now - reads vary from it (fully slot-driven, live-result dependency gone). -- [x] SbS **per-slice stderr** wide frame (`SavedFitSlot.params_stderr`; - NaN where absent; `ulmfit.list_of_par_stderr_to_df`). -- [x] **`fit_settings` provenance dict** on all fit types - (`fit_io.build_fit_settings`, JSON attr, not in `history_key`). - Full scope incl. MC settings (gotcha: `MC` stores `use_mc` as - `.use_emcee`); worker counts deliberately excluded. -- [x] Round-trip + capture + v2-tolerance tests; schema doc updated - (params_meta / params_stderr / fit_settings sections); changelog. - -### 5b — axes retention + plot methods — DONE - -- [x] `FitResults(slots=..., files=...)`: fingerprint-keyed provider lookup - (`_files_by_fp`); `load` passes the archive's `SavedFile`s, - `Project.results` the live `File`s (duck-typed `.energy`/`.time`; - live Files also give `.plot_config`). Files that can't fingerprint - (no data) are skipped — they produced no slots. Missing lookup → - index-based fallback. `_axes_for` mirrors `fit_io._slot_axes` - cropping but tolerates missing providers/axes. -- [x] `FitResults.plot_fit`: 2d/sbs → `fitlib.plt_fit_res_2d` on slot - arrays + real axes (fitlib imported lazily — the package `__init__` - imports fit_results, so a top-level import would cycle); baseline/ - spectrum → direct observed/fit + residual panels vs energy. -- [x] `FitResults.plot_param_evolution`: varied-only default via - `params_meta` (all params for schema-2 archives), explicit `params=` - with KeyError on unknown names, silent no-op when nothing varied. -- [x] `plot_residuals` upgraded to real axes (1D energy x-axis, 2D - imshow extent); index fallback preserved. -- [x] `File.plot_fit` / `File.plot_param_evolution` sugar. The fit-time - `_display_*` helpers were deleted — fit methods display through the - plot API, so the fit-time figure equals what the API reproduces. -- [x] Config resolution: explicit `config=` > live `plot_config` > - `PlotConfig()`. Changelog updated. - -## Phase 6 — Docs, tests, release hygiene — DONE - -- [x] `docs/design/repo_architecture.md`: fit_results.py section rewritten - around the ownership contract (accessors, plot API, axes providers); - save/export section was updated in Phase 4. `docs/design/ui.md` had - no affected content. -- [x] `llms.txt`: plot_fit added to the workflow snippet + a line stating - the get_*/plot_* accessors read persisted records and work on loaded - archives. AGENTS.md had no affected content. -- [x] Notebooks greped for legacy layout / removed APIs: only notebook 11 - mentions export artifacts, and its description matches the slot - exporter (which is unchanged). Accessor signatures unchanged → - notebooks 12/01/03/04/20/21 compatible. Full notebook re-runs left - to the release flow (`docs/ai/check-example`). -- [x] API docs: `docs/api/trspecfit.rst` gained a "Results, Plotting, and - Persistence" section (dropping the deleted save_sbs_fit/save_2d_fit - entries that broke autodoc); new `docs/api/fit_results.rst` documents - `FitResults` + `MCMCResult`. `make -C docs html` warning-free. -- [x] TODO.md: both `[ACTIVE]` items removed — replaced by a slim deferred - item (typed result object / raw `result[1..4]` internal cleanup); - v1.0.0 item 4 updated (legacy savers gone; sweep.py + plot.py shims - remain); trace-fitting item notes params_stderr now persisted. -- [x] Version bump: 0.13.1 → 0.14.0 (breaking auto-export layout + - schema 3). CHANGELOG `[Unreleased]` section already complete. -- [ ] Archive decision: ask user — move this plan into - `docs/design/archive/` (ownership contract is durable) or let the - changelog stand; then clear PLAN.md per protocol. - -## Open implementation points (resolve while working, not user-blocking) - -- Exact "latest slot" tie-break when the same file/model/fit_type was fit - multiple times in one session (history order; consider a `history_key` sort). -- Whether `Project.fit_2d`'s forced `show_output=0` block survives the Phase 4 - rewrite or collapses into the uniform template. -- `MCMCResult` construction from slot: ci table column fidelity after HDF5 - round-trip (dtype/index). +Populate this file when starting the next multi-step feature; clear it on +completion per `CLAUDE.md` (archive the design into `docs/design/` if the +decisions are durable, otherwise let the changelog stand as the record). diff --git a/docs/design/archive/results-ownership-and-plotting.md b/docs/design/archive/results-ownership-and-plotting.md new file mode 100644 index 0000000..38a0898 --- /dev/null +++ b/docs/design/archive/results-ownership-and-plotting.md @@ -0,0 +1,105 @@ +--- +orphan: true +--- + +# Results Ownership Boundary & Plotting Disentanglement + +> **Status: implemented** (2026-07-17, `model-vs-fitresult` branch, v0.14.0). +> Execution record for two coupled TODO items: "Define the results-data +> ownership boundary" and "Disentangle plotting from saving/conversion in +> the fit pipeline". The living contract is summarized in +> [../repo_architecture.md](../repo_architecture.md); the wire format in +> [../fit_archive_schema.md](../fit_archive_schema.md) (schema 3). + +## The ownership contract + +- **`Model`/`File` (live layer)** own inputs and fit execution. + `model.result` (the raw `[par_ini, par_fin, conf_ci, emcee_fin, + emcee_ci]` list) is a transient internal of the fit run; nothing + user-facing reads it. +- **`SavedFitSlot`** is the single authoritative record of a completed + fit — everything a user can ask about a fit must be in (or derivable + from) the slot. +- **`FitResults`** is the single read/query/plot surface over slots — + live history (`Project.results`) and loaded archives + (`FitResults.load`) alike. `File.get_*` / `File.plot_*` / + `File.compare_models` are thin sugar delegating to it. + +## Load-bearing decisions (settled with user, 2026-07-14/16) + +1. **Full auto-export layout break.** Auto-export inside + `fit_slice_by_slice` / `fit_2d` / `Project.fit_2d` routes through the + slot exporter (`export_fit(path_results, ..., overwrite=True)`), + producing the grouped `{path_results}/{file}/{model}__{fit_type}/` + tree. The legacy flat layout, the deprecated `save_sbs_fit` / + `save_2d_fit`, and the `_save_*_fit_legacy` impls were deleted. + Fit-time diagnostics (per-stage CSVs from `fit_wrapper`, SbS + per-slice artifacts) intentionally stay under `File.model_path()` — + they are the fit audit trail, not the results export, and the export + slot-dir name is snapshot-dependent (hash suffix) so it cannot be + computed mid-fit. +2. **Slot-backed accessors, latest-match semantics.** + `get_fit_results` / `get_correlations` / `get_conf_intervals` / + `get_mcmc` read the latest matching slot (`find()` is history-ordered), + mirroring the old convention where each `fit_*` call overwrote the + live result of its type. Accessors return copies. Consequences + accepted: covariance-less fits raise from `get_correlations` instead + of fabricating an identity matrix (`correl` is captured only when + `result.covar` exists — Nelder without numdifftools and project joint + fits store `None`, mirroring absent stderr/conf_ci); fits on Files + without `data` record no slot (nothing to fingerprint) and so the + accessors raise. +3. **Plot API on `FitResults` with `File` sugar** (the `compare_models` + precedent): `plot_fit`, `plot_param_evolution`, upgraded + `plot_residuals`. `FitResults` carries fingerprint-matched axes + providers (live `File`s / `SavedFile`s) so plots get real energy/time + axes on both construction paths, with array-index fallback. The fit + methods' inline display goes through the same API — the figure shown + at fit time is the one the API reproduces later. Config resolution: + explicit `config=` > live file's `plot_config` > `PlotConfig()`; + styling is deliberately not persisted in archives. + +## Schema-3 additions (all additive; v2 archives load with `None`) + +- `correl` — varying-parameter correlation matrix (slice 0 for SbS). +- mcmc `acceptance_fraction` — emcee's per-walker array. +- `params_meta` (SbS) — the slice-invariant `[name, vary, min, max, + expr]` metadata. Vary is uniform across slices by construction (one + model, no mid-loop hook; serial ≡ parallel dispatch pinned by test). +- `params_stderr` (SbS) — per-slice standard errors, previously + discarded entirely; the future weights for 1D trace fitting. +- `fit_settings` — optimizer provenance on every fit type: `stages`, + `fit_alg_1/2`, `try_ci`, SbS seeding recipe (`seed_source` / + `seed_adapt` / `seed_values`; `None` kept as meaningful), and the MC + sampling settings when MCMC ran. Excluded from `history_key` (a refit + with different settings is still a refit). + +## Considered and rejected + +- **YAML-derived parameter capture** (store the model.yaml as JSON in + the slot): the runtime state routinely diverges from the YAML — the + default SbS workflow seeds from the *baseline fit result*, users + mutate models between load and fit, and composed models span multiple + YAMLs. Slots snapshot the model *as fit*. Raw-YAML provenance / + model rehydration stays a separate, deferred feature. +- **Slice-0 long-form params sidecar** for SbS: would have carried + per-slice fields (`value`, `stderr`, `init_value` — the latter + diverges under `seed_adapt`) misleadingly presented as representative. + Split instead into the honest `params_meta` + `params_stderr`. +- **`n_workers` in `fit_settings`**: serial and parallel SbS dispatch + are result-identical by design (same seed template per slice, no + cross-slice warm start) and pinned by a parity test — recording the + worker count would imply it can influence results. +- **Live-with-slot-fallback accessors**: two code paths to test for + zero benefit once slots are captured on every fit. +- **Interim 2D-only hoist** of the legacy plotting (pre-branch): would + have left three coexisting conventions; done as one deliberate pass + instead. + +## Deferred + +- Typed result object replacing the internal raw `result[1..4]` list + (tracked in the repo-root `TODO.md`). +- Model rehydration from archives (raw YAML text provenance). +- The in-place-mutation guard stance for user-facing arrays (separate + TODO item; slots store arrays by reference). From 40dd55f75c046078d1ff371a3a9edd1ca807c420 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Fri, 17 Jul 2026 14:37:37 -0700 Subject: [PATCH 13/29] copy the MCMC payload arrays in FitResults.get_mcmc np.asarray is a no-copy passthrough for ndarray input, so the returned acceptance_fraction aliased the slot's stored array, violating the accessors-return-copies contract (review finding). Regression test pins copy semantics for acceptance_fraction, flatchain, and the ci table. --- src/trspecfit/fit_results.py | 2 +- tests/test_fit_history.py | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/trspecfit/fit_results.py b/src/trspecfit/fit_results.py index d79fca9..11cc233 100644 --- a/src/trspecfit/fit_results.py +++ b/src/trspecfit/fit_results.py @@ -612,7 +612,7 @@ def get_mcmc( table=ci.copy() if ci is not None else pd.DataFrame(), flatchain=flatchain.copy() if flatchain is not None else pd.DataFrame(), acceptance_fraction=( - np.asarray(acceptance) if acceptance is not None else None + np.asarray(acceptance).copy() if acceptance is not None else None ), ) diff --git a/tests/test_fit_history.py b/tests/test_fit_history.py index bbb2632..aa5e052 100644 --- a/tests/test_fit_history.py +++ b/tests/test_fit_history.py @@ -594,6 +594,33 @@ def test_returned_frame_is_a_copy(self): df.loc[0, "value"] = -999.0 assert project._fit_history[0].params.loc[0, "value"] != -999.0 + # + def test_get_mcmc_payload_is_a_copy(self): + """get_mcmc must not alias the slot's stored arrays/frames — + np.asarray on an ndarray is a no-copy passthrough (regression).""" + + import dataclasses + + project, _ = _setup_baseline_fit() + payload = { + "flatchain": pd.DataFrame({"GLP_01_A": [1.0, 2.0]}), + "ci": pd.DataFrame({"par[v]/sigma[>]": ["GLP_01_A"], "best fit": [1.5]}), + "lnsigma": None, + "acceptance_fraction": np.array([0.3, 0.4]), + } + slot = dataclasses.replace(project._fit_history[0], mcmc=payload) + res = FitResults(slots=[slot]).get_mcmc(fit_type="baseline") + + assert res.acceptance_fraction is not None # type guard + res.acceptance_fraction[0] = -1.0 + res.flatchain.loc[0, "GLP_01_A"] = -999.0 + res.table.loc[0, "best fit"] = -999.0 + np.testing.assert_array_equal( + payload["acceptance_fraction"], np.array([0.3, 0.4]) + ) + assert payload["flatchain"].loc[0, "GLP_01_A"] == 1.0 + assert payload["ci"].loc[0, "best fit"] == 1.5 + # def test_latest_slot_wins_after_refit(self): project, file = _setup_baseline_fit() From 1acabcf96d0faec695464314714eefc466f0aa47 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Fri, 17 Jul 2026 16:09:17 -0700 Subject: [PATCH 14/29] docs: plan auto-export removal and the typed fit-result object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Populate PLAN.md with Phases 7-8, continuing the results-ownership branch (decisions settled 2026-07-17 in review discussion): remove Project.auto_export entirely — fits compute, display, and capture slots but never write; persistence stays the explicit save_fits/export_fits pair fed from the slot history. Everything auto-export wrote maps to persisted slot data or new on-demand plot_mcmc / plot_sbs_slices methods. Unify on the ./fit_results// output root (path_results and model_path removed as dead), default Project.name becomes my_project, and plot save_path=None means display-only. Phase 8 replaces the internal raw result 5-list with a typed class. TODO: tag the unified-results item [ACTIVE]; add a new item for persisting project-level joint fits as a first-class record (shared-parameter map, joint covariance, sibling identity), noting the joint covariance currently evaporates. --- PLAN.md | 145 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- TODO.md | 3 +- 2 files changed, 139 insertions(+), 9 deletions(-) diff --git a/PLAN.md b/PLAN.md index 0d24ddf..5cd6b48 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,11 +1,140 @@ -# Active Plan +# Active Plan: remove auto-export; typed fit-result object -No active multi-step feature in progress. +Branch: `model-vs-fitresult` (continuation). Phases 1–6 (results ownership +boundary, plotting disentanglement, schema 3, plot API) are complete and +archived in +[docs/design/archive/results-ownership-and-plotting.md](docs/design/archive/results-ownership-and-plotting.md). +Phases 7–8 emerged from the post-completion review discussion (2026-07-17) +and complete the same principle, in the same breaking release (0.14.0). -- Backlog lives in [`TODO.md`](TODO.md). -- Shipped-feature design notes live in [`docs/design/`](docs/design/) (and - archived deep-dives in [`docs/design/archive/`](docs/design/archive/)). +## Decisions (settled with user, 2026-07-17) -Populate this file when starting the next multi-step feature; clear it on -completion per `CLAUDE.md` (archive the design into `docs/design/` if the -decisions are durable, otherwise let the changelog stand as the record). +1. **Remove `Project.auto_export` entirely.** Fits compute, display (per + `show_output`), and capture slots — they never write to disk. The + original justification for auto-export (results existed only as files) + died with the slot architecture; persistence is the explicit + `save_fit(s)` (HDF5) / `export_fit(s)` (CSV/PNG tree) pair. No new + method names — the save-vs-export split already covers it. +2. **Everything auto-export used to write must be reproducible** from + what the slot persists, or via a documented explicit call (the Phase 7 + reproducibility checklist below). +3. **Keeps**: `save_baseline_fit` / `save_spectrum_fit` stay as explicit + API (their component-decomposed `fit_1d.csv` is the one artifact slots + can't reproduce — only the total fit curve is persisted). They take + explicit paths, as do all persistence calls. +4. **One output root** (settled 2026-07-17): the `./fit_results//` + family already used by explicit `save_fits` / `export_fits` defaults. + `Project.path_results` and `File.model_path` are removed — after the + de-wiring nothing in the package writes there, so keeping them would + preserve a dead second convention. `Project.name` default changes + from `"test"` to `"my_project"` — a clear placeholder that stops + `fit_results/test/` / `test.fit.h5` from colliding with test-suite + naming (pytest traversal, glob confusion). The per-example naming + convention stays with the "Revisit default Project.name" TODO item. +5. **`plot_*` saving convention**: `save_path=None` means display-only + (consistent across the plot API); saving always requires an explicit + path. No default save locations for diagnostics. +4. **Phase 8 scope is internal-only**: replace the raw + `[par_ini, par_fin, conf_ci, emcee_fin, emcee_ci]` list with a typed + class. No archive-schema change, no public-API change — this branch + removed every user-facing reader of `model.result`, which is what + makes the cleanup safe now. + +## Phase 7 — remove auto-export; fits never write + +### Knob removal + +- [ ] Delete `Project.auto_export` (attribute, `_set_defaults`, + `project.yaml` default). Check how YAML parsing treats the removed + key — an existing `project.yaml` with `auto_export:` must fail or + warn clearly, not be silently ignored (grep example/test yamls). +- [ ] Delete `Project.path_results` and `File.model_path` (dead once the + fit methods stop writing): update `Project.describe`, the tests + that redirect `path_results`, and any `project.yaml` key handling. + Single default output root remains `./fit_results//`. +- [ ] `Project.name` default `"test"` → `"my_project"` (placeholder that + can't be mistaken for test-suite artifacts). Grep tests/docs/ + examples for reliance on the old default. +- [ ] Fit methods drop all export wiring: + - `fit_baseline` / `fit_spectrum`: the `save_baseline_fit` / + `save_spectrum_fit` auto-calls and `fit_wrapper(save_output=...)`. + - `fit_slice_by_slice`: the post-fit `export_fit` call, the serial + per-slice PNG block, per-slice `save_output`, and the worker args + that exist only for mid-loop IO (`auto_export`, `path_slice`, + `plot_config`) — leaner spawn payload, faster hot path. + - `File.fit_2d` / `Project.fit_2d`: the post-fit `export_fit(s)` + calls and `save_output` wiring. + - Display gating (`show_output`) is untouched. +- [ ] `fitlib.fit_wrapper`: remove `save_output` / `save_path` / + `num_fmt` / `delim` params, the CSV/txt dump block, and the emcee + figure *save* path (walker/corner figures become display-only via + `show_output`). Fit methods drop their `num_fmt`/`delim` + setdefaults for fit_wrapper. + +### Reproducibility checklist (what auto-export wrote → where it lives now) + +- [ ] `*_par_ini.csv` / `*_par_fin.csv` → slot `params` + (`init_value`/`value`/`stderr`/bounds/`vary`/`expr`); exported as + `params.csv`. ✓ nothing to add. +- [ ] `*_conf_ci.csv`, `*_emcee_flatchain.csv`, `*_emcee_ci.csv` → slot + `conf_ci` / `mcmc` payload; exported under the slot dir. ✓ +- [ ] `*_emcee_walker_acceptance_ratio.png`, `*_emcee_corner_plot.png` → + add `FitResults.plot_mcmc(file=..., model=..., fit_type=..., + show_plot=...)` rendering the corner plot (from persisted + `flatchain`) and per-walker acceptance (from persisted + `acceptance_fraction`), with `File.plot_mcmc` sugar — turnkey + reproduction from live sessions *and* archives. +- [ ] SbS per-slice PNGs → new `File.plot_sbs_slices(model=..., + slices=None, show_init=True, save_path=None, show_plot=...)`, + logic in `utils/sbs.py`; uses live `results_sbs` (per-slice + `par_ini` + component decomposition via `plt_fit_res_1d`), so it + can do *more* than the old auto-PNGs. Live-session only — + document. `save_path=None` → display-only; deliberately NOT part + of `export_fits` (export stays slot-fed and archive-reproducible; + per-slice diagnostics need live inputs and would flood the tree). +- [ ] SbS per-slice `*_par_ini.csv` → accepted loss: re-derivable from + the persisted `fit_settings` seeding recipe; document in changelog. +- [ ] `lmfit.fit_report` text dumps → accepted loss: contents (params, + stderr, correlations, metrics) all persisted; document. +- [ ] Baseline/spectrum component-decomposed `fit_1d.csv` → explicit + `save_baseline_fit` / `save_spectrum_fit` (kept, no longer + auto-called). +- [ ] 2D/SbS result trees → explicit `export_fit(s)` (unchanged). + +### Tests / docs + +- [ ] `tests/test_auto_export.py` reshaped: "fits write nothing" becomes + the unconditional default; explicit save/export tests remain; the + display/silent guardrail matrix (`TestPlotHelperSkipped`, + `TestVerboseDisplayWithoutExport`) survives with `auto_export` + references removed. `make_project(auto_export=...)` helper param + goes; export-parity tests re-anchor on two explicit exports. +- [ ] New tests: `plot_mcmc` (slot-backed, incl. loaded archive), + `plot_sbs_slices` (live; raises helpfully without `results_sbs`). +- [ ] Docs: llms.txt headless section shrinks (`show_output=0` is the + only knob — no-write is default); repo_architecture.md auto-export + paragraph rewritten (fits never write; explicit save/export; + diagnostics on demand); changelog breaking entry; grep notebooks + + example `project.yaml`s for `auto_export`. + +## Phase 8 — typed fit-result object (internal) + +- [ ] Introduce a small class (e.g. `FitOutcome`, name TBD at + implementation; `fitlib` or `utils/lmfit.py`) with named fields + `par_ini`, `par_fin`, `conf_ci`, `emcee_fin`, `emcee_ci` replacing + the raw 5-list. `fit_wrapper` returns it. +- [ ] Update all internal consumers: the four fit methods, + `_append_*_slot` capture, `results_sbs` per-slice entries, the + MCMC-payload builder, and `Project.fit_2d`'s `SimpleNamespace` + stand-in (becomes a real `FitOutcome`). +- [ ] No list-index back-compat: verify by grep that nothing outside the + package (notebooks, docs) indexes `model.result[...]` or + `results_sbs[i][...]`; update mocked-result tests. +- [ ] Closes the "Unified results object / raw `result[1..4]` cleanup" + TODO item. + +## Completion + +- [ ] Changelog entries for both phases; docs build; full + slow suites. +- [ ] Extend the archive doc (results-ownership-and-plotting.md) with a + Phases 7–8 section; clear PLAN.md; un-tag TODO. diff --git a/TODO.md b/TODO.md index 7778c4a..ee25bfe 100644 --- a/TODO.md +++ b/TODO.md @@ -3,6 +3,7 @@ ## Fitting - [ ] **Enable 1D time-trace fitting (post-SbS kinetics)**: wire standalone `TIME_1D` dynamics models (already evaluable on the mcp path per `docs/design/supported_models.md`, but connected to no fit method) to a fit entry point, so parameter-vs-time traces extracted from SbS results can be fit to `functions/time.py` dynamics (`expFun` sums, IRF convolution) inside the package instead of in external scripts. Frame it as the diagnostic/initialization rung before `fit_2d`, not a statistical equivalent (per-slice correlations and unpropagated SbS uncertainties make two-step inferior). Design direction: promote SbS results into a time-axis `File` — the promoted object inherits limits/CI/MCMC/save/export machinery, and trace fits land in a `SavedFitSlot` like every other fit type (no parallel fitting pipeline). Requires `File` to support time as the primary axis, which today it assumes is energy. Weighting traces by per-slice stderr ties into the `sigma_type` expansion item below — the per-slice stderr itself is persisted since v0.14.0 (`SavedFitSlot.params_stderr`). +- [ ] **Persist project-level joint fits as a first-class record**: `Project.fit_2d` currently emits N ordinary per-file 2d slots, each carrying that file's projection of the joint result — users reassemble the joint picture by hand, and three joint-level facts are persisted nowhere: the shared-parameter map (`vary_level` project/file/static), the joint covariance/correlation (per-file `stderr`/`conf_ci`/`correl` are deliberately absent because the joint covariance does not decompose per file — but the joint matrix itself, which the optimizer produces, currently evaporates), and sibling identity (nothing marks the N slots as products of one optimization vs N independent fits). Design direction per [docs/design/fit_archive_schema.md](docs/design/fit_archive_schema.md) "What's *not* in v1": a strict additive change — new top-level `project_slots/` archive group + schema bump; decide how `FitResults` queries joint records and whether the per-file slots become views of them. ## Noise and simulation @@ -15,7 +16,7 @@ - vmap-batched slice-by-slice solver (the one workload where lmfit overhead plausibly dominates; would be the Phase E pilot) — see [docs/design/ui.md](docs/design/ui.md). - `vmap`-batch homogeneous file series in the fused project fit (unrolled per-file fusion shipped in v0.13.0) — see [docs/design/project-level-fits.md](docs/design/project-level-fits.md). - `fit_model_compare`-style runtime JAX parity mode, or a cheaper one-shot pre-fit parity check on the JAX path. -- [ ] **Unified results object / raw `result[1..4]` cleanup**: the results-data ownership boundary shipped in v0.14.0 (`SavedFitSlot` is the authoritative fit record; `FitResults` is the single read/query/plot surface; `File.get_*` / `plot_*` are thin sugar — see the archived design in `docs/design/`). What remains deferred from that pass: internal code (fit methods, slot capture) still passes the raw `[par_ini, par_fin, conf_ci, emcee_fin, emcee_ci]` list around as `model.result` — decide whether to replace it with a typed result object now that nothing user-facing reads it. +- [ ] `[ACTIVE]` **Unified results object / raw `result[1..4]` cleanup**: the results-data ownership boundary shipped in v0.14.0 (`SavedFitSlot` is the authoritative fit record; `FitResults` is the single read/query/plot surface; `File.get_*` / `plot_*` are thin sugar — see the archived design in `docs/design/`). What remains deferred from that pass: internal code (fit methods, slot capture) still passes the raw `[par_ini, par_fin, conf_ci, emcee_fin, emcee_ci]` list around as `model.result` — decide whether to replace it with a typed result object now that nothing user-facing reads it. - [ ] **Decide how to guard/warn against in-place mutation of user-facing arrays**: internal machinery assumes `File.data`/`energy`/`time` and fit outputs are stable once set — e.g. `SavedFitSlot` stores `params`/`observed`/`fit`/`selection` by reference (`frozen=True` blocks reassignment, not in-place mutation; 2026-07 code review, check 1), and file fingerprints / `observed_sha256` are computed once at slot construction. A user mutating `file.data` in place instead of re-instantiating would desynchronize slots, fit limits, and cached evaluations in ways no single defensive copy fixes — so slot-level copies were considered and declined (2026-07-10) as papering over one symptom. Decide on a systemic stance instead: read-only views (`setflags(write=False)`) on public arrays, copy-on-set in setters, a documented ownership contract, and/or re-hash validation at save time. A sibling case: `Simulator.sigma_data` is recomputed on read from the current `noise_level`/`noise_type` ([simulator.py](src/trspecfit/simulator.py) ~L1057), so calling `set_noise_level`/`set_noise_type` after `simulate()` but before `save_data()` persists a stale or missing `metadata.sigma_data` (the value fed to `File.set_sigma`) that no longer matches the saved noisy data — cheap dedicated fix is to snapshot the derived sigma at simulation time; fold it into whatever stance is chosen. ## User and AI ergonomics From 1d67c9009e3040545e07073bb63f124af72d810e Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Fri, 17 Jul 2026 20:22:33 -0700 Subject: [PATCH 15/29] remove auto-export: fits never write, diagnostics render on demand - delete Project.auto_export / Project.path_results / File.model_path; project.yaml files still setting the removed keys fail loudly - fit methods and fitlib.fit_wrapper drop all disk IO (save_output / save_path / num_fmt / delim, the CSV/TXT dump block, SbS per-slice writes, post-fit export calls); show_output display gating unchanged - add FitResults.plot_mcmc (walker acceptance + corner from the persisted payload, live sessions and archives) and File.plot_sbs_slices (per-slice panels from live results_sbs) - Project.name default "test" -> "my_project" - fix explicit dict SbS seeds crashing slot capture: fit_settings now records the normalized seed template - reshape test_auto_export.py into test_fit_side_effects.py (fits write nothing, verified via cwd isolation); export parity re-anchored on two explicit exports; docs, examples, and changelog updated --- .../check-example/check_example_mechanics.py | 23 +- CHANGELOG.md | 6 +- PLAN.md | 32 +- TODO.md | 2 +- docs/ai/check-example.md | 23 +- docs/api/trspecfit.rst | 3 +- docs/design/examples_upgrade.md | 7 +- docs/design/repo_architecture.md | 27 +- .../01_basic_fitting/project.yaml | 6 - .../02_dependent_parameters/project.yaml | 6 - .../03_multi_cycle_dynamics/project.yaml | 6 - .../04_parameter_profiles/project.yaml | 6 - .../10_model_comparison/example.ipynb | 2 +- .../10_model_comparison/project.yaml | 7 - .../11_save_load_export/example.ipynb | 4 +- .../21_multi_file_shared_fit/project.yaml | 5 - examples/fitting_workflows/README.md | 4 +- llms.txt | 10 +- src/trspecfit/fit_results.py | 75 ++++ src/trspecfit/fitlib.py | 115 +---- src/trspecfit/trspecfit.py | 278 +++++------- src/trspecfit/utils/sbs.py | 113 ++++- tests/_utils.py | 10 +- tests/test_auto_export.py | 382 ----------------- tests/test_export_fits_parity.py | 87 ++-- tests/test_fit_history.py | 118 +++++ tests/test_fit_side_effects.py | 404 ++++++++++++++++++ tests/test_project_fit.py | 28 +- 28 files changed, 939 insertions(+), 850 deletions(-) delete mode 100644 tests/test_auto_export.py create mode 100644 tests/test_fit_side_effects.py diff --git a/.claude/skills/check-example/check_example_mechanics.py b/.claude/skills/check-example/check_example_mechanics.py index a4755aa..0d641ca 100644 --- a/.claude/skills/check-example/check_example_mechanics.py +++ b/.claude/skills/check-example/check_example_mechanics.py @@ -2,7 +2,8 @@ Covers the statically-checkable parts of docs/ai/check-example.md: stripped outputs (9), roadmap/TOC numbering (5), required files (3), committed truth (2), -auto_export (4), side-effect artifacts (4), and prose-voice candidates (10). +removed config keys (4), side-effect artifacts (4), and prose-voice +candidates (10). Prints a PASS / WARN / FAIL / INFO line per check — INFO marks a fact the agent must resolve by reading (evidence, not a verdict). The judgment criteria (1, 6, 7, 8, 11, plus the prose/message parts of 5 and 10) are graded by reading @@ -280,22 +281,22 @@ def check_truth(ex: Path) -> tuple[str, str]: # -def check_auto_export(ex: Path) -> tuple[str, str]: - """Criterion 4 — project.yaml sets auto_export: False.""" +def check_removed_config_keys(ex: Path) -> tuple[str, str]: + """Criterion 4 — project.yaml carries no removed config keys. + + Fits never write to disk since v0.14.0; a leftover ``auto_export:`` / + ``path_results:`` key makes ``Project()`` raise at load. + """ pj = ex / "project.yaml" if not pj.is_file(): # INFO: a %run-preamble notebook inherits a sibling's config. return "INFO", "no project.yaml — resolve: %run preamble inherits config?" text = pj.read_text(encoding="utf-8") - m = re.search(r"^\s*auto_export\s*:\s*(\w+)", text, re.MULTILINE) - if m and m.group(1).lower() == "false": - return "PASS", "auto_export: False" - # INFO, not WARN: only a defect if export is not the notebook's topic — - # the agent decides that by reading. + m = re.search(r"^\s*(auto_export|path_results)\s*:", text, re.MULTILINE) if m: - return "INFO", f"auto_export: {m.group(1)} — resolve: is export the topic?" - return "INFO", "no auto_export key — resolve: is export the topic?" + return "FAIL", f"removed key '{m.group(1)}' present — Project() will raise" + return "PASS", "no removed config keys" # @@ -378,7 +379,7 @@ def report_example(ex: Path) -> tuple[int, int]: rows.append(("9 Stripped outputs", "FAIL", "no example.ipynb")) rows.append(("3 Required files", *check_required_files(ex))) rows.append(("2 Committed truth", *check_truth(ex))) - rows.append(("4 auto_export", *check_auto_export(ex))) + rows.append(("4 Removed keys", *check_removed_config_keys(ex))) rows.append(("4 Artifacts", *check_artifacts(ex))) for name, status, detail in rows: print(f"{status:4} | {name:22} | {detail}") diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a0b2b..0bded00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,15 +15,19 @@ This file is maintained using the shared changelog workflow in - **Fit slots record optimizer provenance and complete SbS parameter metadata** (also schema 3): every slot gains `fit_settings` — stages, per-stage methods, `try_ci`, SbS seeding recipe (`seed_source`/`seed_adapt`/`seed_values`), and MCMC sampling settings when enabled (worker counts deliberately excluded; serial ≡ parallel is a tested invariant). SbS slots additionally gain `params_meta` (the slice-invariant `[name, vary, min, max, expr]` metadata) and `params_stderr` (per-slice standard errors, previously discarded — the future weights for 1D trace fitting). - `FitResults.get_fit_results` / `get_correlations` / `get_conf_intervals` / `get_mcmc`: the results accessors now live on `FitResults`, read the latest matching persisted slot (`file=` / `model=` / `fit_type=` filters), and therefore also work for SbS fits (slice-0 payloads) and on archives loaded with `FitResults.load`. The `File.get_*` methods remain as thin delegates with an added optional `model=` filter. - **Explicit plotting API**: `FitResults.plot_fit` (observed/fit/residual for any fit type) and `FitResults.plot_param_evolution` (SbS per-parameter evolution, varied parameters by default), with `File.plot_fit` / `File.plot_param_evolution` sugar. `FitResults` now carries fingerprint-matched axes providers (live `File`s via `Project.results`, `SavedFile`s via `FitResults.load`), so plots — including the upgraded `plot_residuals` — use real energy/time axes on live sessions *and* loaded archives, falling back to array indices only when no provider matches. Styling resolves as explicit `config=` > the live file's `plot_config` > defaults (styling is deliberately not persisted in archives). The fit methods' inline display now routes through this same API, so the figure shown at fit time is exactly the figure the API reproduces later. +- `FitResults.plot_mcmc` (with `File.plot_mcmc` sugar): re-renders the MCMC diagnostics — per-walker acceptance fraction and the corner plot — from the persisted slot payload, on live sessions and loaded archives alike. +- `File.plot_sbs_slices`: on-demand per-slice fit panels for the most recent Slice-by-Slice fit (slice data, per-slice seeded initial guess, final fit, component decomposition — more than the old auto-written per-slice PNGs showed). Live-session only (reads `results_sbs`); `save_path=None` means display-only, pass a directory to also write one PNG per slice. ### Changed -- **Breaking: auto-export writes the slot-export layout.** `fit_slice_by_slice`, `File.fit_2d`, and `Project.fit_2d` now route auto-export through the same slot exporter as `export_fits`, producing the grouped `{path_results}/{file}/{model}__{fit_type}/` tree (params.csv, metrics, fit_2d.csv, observed_2d.csv, axis sidecars, PNGs) instead of the legacy flat `{file}/{fit_type}/{model}/` CSV layout. Fit-time diagnostics (per-stage parameter CSVs, SbS per-slice artifacts) still land under `File.model_path()`. Interactive display now renders from the captured fit slot, so the figure shown equals the figure exported; `Project.fit_2d` shows per-file maps instead of a PNG grid read back from disk. +- **Breaking: fits never write to disk.** `fit_baseline` / `fit_spectrum` / `fit_slice_by_slice` / `fit_2d` and `Project.fit_2d` compute, display (per `show_output`), and record fit slots — persistence is always an explicit `save_fits` (HDF5 archive) or `export_fits` (grouped CSV/PNG tree) call, both fed from the slot history, with the single default output root `./fit_results/{Project.name}/`. Everything the automatic writes used to produce is reproducible: parameter tables, confidence intervals, and MCMC chains are persisted in the slot and exported by `export_fits`; the MCMC walker/corner PNGs via `plot_mcmc`; the SbS per-slice PNGs via `plot_sbs_slices`; the component-decomposed `fit_1d.csv` via `save_baseline_fit` / `save_spectrum_fit` (kept, no longer auto-called). Accepted losses: per-slice `par_ini` CSVs (re-derivable from the persisted `fit_settings` seeding recipe) and the `lmfit.fit_report` text dumps (all their contents are persisted). Interactive display renders from the captured fit slot, so the figure shown equals the figure the plot API reproduces later; `Project.fit_2d` / `Project.fit_baselines` show per-file plots instead of PNGs read back from disk. - **Breaking: `File.get_correlations` raises for covariance-less fits** (e.g. Nelder without numdifftools, project joint fits) instead of returning an identity-with-zeros matrix that misread as "uncorrelated". +- **Breaking: `Project.name` defaults to `"my_project"`** (was `"test"`), so a bare `save_fits()` / `export_fits()` lands in a clearly-placeholder `fit_results/my_project/` instead of colliding with test-suite naming. - `fitlib.results_to_df` and `fitlib.results_to_fit_2d` are pure conversions: the CSV writes, per-parameter plotting, and `save_df`/`save_2d` flags were removed along with the legacy save path that used them. ### Removed +- **Breaking: `Project.auto_export`, `Project.path_results`, and `File.model_path`.** With fits no longer writing, the auto-export toggle and the second output-root convention are gone; a `project.yaml` that still sets `auto_export:` or `path_results:` fails loudly at load with migration guidance. `fitlib.fit_wrapper` loses its `save_output` / `save_path` / `num_fmt` / `delim` parameters and the CSV/TXT dump block; its emcee figures are display-only (`show_output`), reproducible later via `plot_mcmc`. - **Breaking: `File.save_sbs_fit` / `File.save_2d_fit`** (deprecated since the export pipeline landed) and their internal `_save_*_fit_legacy` implementations. Use `File.export_fit` / `Project.export_fits`. ## [0.13.0] - 2026-07-13 diff --git a/PLAN.md b/PLAN.md index 5cd6b48..ab8ea71 100644 --- a/PLAN.md +++ b/PLAN.md @@ -44,18 +44,18 @@ and complete the same principle, in the same breaking release (0.14.0). ### Knob removal -- [ ] Delete `Project.auto_export` (attribute, `_set_defaults`, +- [x] Delete `Project.auto_export` (attribute, `_set_defaults`, `project.yaml` default). Check how YAML parsing treats the removed key — an existing `project.yaml` with `auto_export:` must fail or warn clearly, not be silently ignored (grep example/test yamls). -- [ ] Delete `Project.path_results` and `File.model_path` (dead once the +- [x] Delete `Project.path_results` and `File.model_path` (dead once the fit methods stop writing): update `Project.describe`, the tests that redirect `path_results`, and any `project.yaml` key handling. Single default output root remains `./fit_results//`. -- [ ] `Project.name` default `"test"` → `"my_project"` (placeholder that +- [x] `Project.name` default `"test"` → `"my_project"` (placeholder that can't be mistaken for test-suite artifacts). Grep tests/docs/ examples for reliance on the old default. -- [ ] Fit methods drop all export wiring: +- [x] Fit methods drop all export wiring: - `fit_baseline` / `fit_spectrum`: the `save_baseline_fit` / `save_spectrum_fit` auto-calls and `fit_wrapper(save_output=...)`. - `fit_slice_by_slice`: the post-fit `export_fit` call, the serial @@ -65,7 +65,7 @@ and complete the same principle, in the same breaking release (0.14.0). - `File.fit_2d` / `Project.fit_2d`: the post-fit `export_fit(s)` calls and `save_output` wiring. - Display gating (`show_output`) is untouched. -- [ ] `fitlib.fit_wrapper`: remove `save_output` / `save_path` / +- [x] `fitlib.fit_wrapper`: remove `save_output` / `save_path` / `num_fmt` / `delim` params, the CSV/txt dump block, and the emcee figure *save* path (walker/corner figures become display-only via `show_output`). Fit methods drop their `num_fmt`/`delim` @@ -73,18 +73,18 @@ and complete the same principle, in the same breaking release (0.14.0). ### Reproducibility checklist (what auto-export wrote → where it lives now) -- [ ] `*_par_ini.csv` / `*_par_fin.csv` → slot `params` +- [x] `*_par_ini.csv` / `*_par_fin.csv` → slot `params` (`init_value`/`value`/`stderr`/bounds/`vary`/`expr`); exported as `params.csv`. ✓ nothing to add. -- [ ] `*_conf_ci.csv`, `*_emcee_flatchain.csv`, `*_emcee_ci.csv` → slot +- [x] `*_conf_ci.csv`, `*_emcee_flatchain.csv`, `*_emcee_ci.csv` → slot `conf_ci` / `mcmc` payload; exported under the slot dir. ✓ -- [ ] `*_emcee_walker_acceptance_ratio.png`, `*_emcee_corner_plot.png` → +- [x] `*_emcee_walker_acceptance_ratio.png`, `*_emcee_corner_plot.png` → add `FitResults.plot_mcmc(file=..., model=..., fit_type=..., show_plot=...)` rendering the corner plot (from persisted `flatchain`) and per-walker acceptance (from persisted `acceptance_fraction`), with `File.plot_mcmc` sugar — turnkey reproduction from live sessions *and* archives. -- [ ] SbS per-slice PNGs → new `File.plot_sbs_slices(model=..., +- [x] SbS per-slice PNGs → new `File.plot_sbs_slices(model=..., slices=None, show_init=True, save_path=None, show_plot=...)`, logic in `utils/sbs.py`; uses live `results_sbs` (per-slice `par_ini` + component decomposition via `plt_fit_res_1d`), so it @@ -92,26 +92,26 @@ and complete the same principle, in the same breaking release (0.14.0). document. `save_path=None` → display-only; deliberately NOT part of `export_fits` (export stays slot-fed and archive-reproducible; per-slice diagnostics need live inputs and would flood the tree). -- [ ] SbS per-slice `*_par_ini.csv` → accepted loss: re-derivable from +- [x] SbS per-slice `*_par_ini.csv` → accepted loss: re-derivable from the persisted `fit_settings` seeding recipe; document in changelog. -- [ ] `lmfit.fit_report` text dumps → accepted loss: contents (params, +- [x] `lmfit.fit_report` text dumps → accepted loss: contents (params, stderr, correlations, metrics) all persisted; document. -- [ ] Baseline/spectrum component-decomposed `fit_1d.csv` → explicit +- [x] Baseline/spectrum component-decomposed `fit_1d.csv` → explicit `save_baseline_fit` / `save_spectrum_fit` (kept, no longer auto-called). -- [ ] 2D/SbS result trees → explicit `export_fit(s)` (unchanged). +- [x] 2D/SbS result trees → explicit `export_fit(s)` (unchanged). ### Tests / docs -- [ ] `tests/test_auto_export.py` reshaped: "fits write nothing" becomes +- [x] `tests/test_auto_export.py` reshaped: "fits write nothing" becomes the unconditional default; explicit save/export tests remain; the display/silent guardrail matrix (`TestPlotHelperSkipped`, `TestVerboseDisplayWithoutExport`) survives with `auto_export` references removed. `make_project(auto_export=...)` helper param goes; export-parity tests re-anchor on two explicit exports. -- [ ] New tests: `plot_mcmc` (slot-backed, incl. loaded archive), +- [x] New tests: `plot_mcmc` (slot-backed, incl. loaded archive), `plot_sbs_slices` (live; raises helpfully without `results_sbs`). -- [ ] Docs: llms.txt headless section shrinks (`show_output=0` is the +- [x] Docs: llms.txt headless section shrinks (`show_output=0` is the only knob — no-write is default); repo_architecture.md auto-export paragraph rewritten (fits never write; explicit save/export; diagnostics on demand); changelog breaking entry; grep notebooks + diff --git a/TODO.md b/TODO.md index ee25bfe..0957a7d 100644 --- a/TODO.md +++ b/TODO.md @@ -22,7 +22,7 @@ ## User and AI ergonomics - [ ] **Add more AI-friendly task recipes**: `docs/ai/` already covers add-function, check-example, check-docs, code-review, changelog, and benchmark. Extend with checklists for the remaining common changes: adding YAML syntax, adding plotting options, changing fitting workflows, modifying GIR/evaluator behavior, extending save/load fields, and preparing a release. -- [ ] **Revisit default `Project.name` for saving examples**: `Project.name` defaults to `"test"` ([trspecfit.py](src/trspecfit/trspecfit.py) ~L202), so a bare `save_fit()` / `save_fits()` with no path writes `./fit_results/test.fit.h5`. The example `project.yaml` files set no `name:`. When working through the saving notebooks (`11_save_load_export`, `20_multi_file_independent_fit`), decide on a convention — set a meaningful `name:` per example so the default archive path is self-describing, and/or keep using explicit content-named paths (`comparison.fit.h5`, `batch.fit.h5`). Pick one and apply consistently. +- [ ] **Revisit default `Project.name` for saving examples**: `Project.name` defaults to the placeholder `"my_project"` (since v0.14.0), so a bare `save_fit()` / `save_fits()` with no path writes under `./fit_results/my_project/`. The example `project.yaml` files set no `name:`. When working through the saving notebooks (`11_save_load_export`, `20_multi_file_independent_fit`), decide on a convention — set a meaningful `name:` per example so the default archive path is self-describing, and/or keep using explicit content-named paths (`comparison.fit.h5`, `batch.fit.h5`). Pick one and apply consistently. - [ ] **Add minimal runnable workflow examples**: supplement notebooks with small script-like examples or docs snippets for the canonical public workflows: load data, load a model, set limits, fit baseline, fit 2D, inspect results, simulate data, and run a parameter sweep. - [ ] **Improve public validation errors**: YAML parsing and model loading already meet the bar — `ModelValidationError` messages carry model/component/parameter context plus hints (verified 2026-07-06). Remaining scope: audit fit setup and unsupported-model fallback paths and bring them to the same standard (state what failed, where, and what to change next). diff --git a/docs/ai/check-example.md b/docs/ai/check-example.md index d64f83f..630457f 100644 --- a/docs/ai/check-example.md +++ b/docs/ai/check-example.md @@ -24,11 +24,11 @@ For each criterion report one of: (e.g. narrative present but a section never says *why*). - **FAIL** — criterion not met; list the specific gap with file/cell. - **N/A** — criterion does not apply to this notebook's deliberate variant - (e.g. `auto_export` on an export-topic notebook). State *why* it is N/A. + (e.g. untracked export artifacts beside an export-topic notebook). State + *why* it is N/A. The pre-pass also prints **INFO** lines — facts it found but cannot judge -(missing `data/`, no `*_truth.yaml`, `auto_export` unset, prose-voice -candidates). These are **not defects**: resolve every INFO to PASS / N/A / FAIL +(missing `data/`, no `*_truth.yaml`, prose-voice candidates). These are **not defects**: resolve every INFO to PASS / N/A / FAIL by reading the notebook. A clean example ends with **0 FAIL, 0 WARN** even when the pre-pass emitted several INFO lines. @@ -43,11 +43,11 @@ judgment checks. Work through the list in order. ``` This gathers evidence for the scriptable criteria (stripped outputs, required -files, committed truth, `auto_export`, side-effect artifacts, the roadmap/TOC -numbering). It emits **PASS/WARN/FAIL** for what it can decide deterministically -(committed outputs and artifacts FAIL; the roadmap/TOC numbering PASS or WARN); -everything intent-dependent (missing `data/`, no `*_truth.yaml`, `auto_export` -unset) and +files, committed truth, removed config keys, side-effect artifacts, the +roadmap/TOC numbering). It emits **PASS/WARN/FAIL** for what it can decide +deterministically (committed outputs, artifacts, and removed keys FAIL; the +roadmap/TOC numbering PASS or WARN); everything intent-dependent (missing +`data/`, no `*_truth.yaml`) and the prose-voice candidates come out as **INFO** for you to resolve. Fold its output into criteria 2, 3, 4, 5, 9, and 10 below. It does **not** execute the notebook (criterion 1 is the slow one — run it separately). @@ -95,9 +95,10 @@ sibling with no prose explaining it. If the reuse is stated up front, PASS. ## 4. No surprise side-effects -`project.yaml` sets `auto_export: False` with an explanatory comment, so the -fit calls don't spray CSV/PNG dumps. N/A when persistence/export is the -notebook's actual topic — say so. +Fits never write to disk (v0.14.0), so no opt-out is needed — but a stale +removed key (`auto_export:`, `path_results:`) in `project.yaml` makes +`Project()` raise at load and FAILs here. On-disk artifacts must come only +from the notebook's explicit `save_fits` / `export_fits` calls. Artifact severity: **committed** CSV/PNG/`.fit.h5` fit outputs FAIL (they pollute the repo). **Untracked/gitignored** outputs are reported INFO, not a diff --git a/docs/api/trspecfit.rst b/docs/api/trspecfit.rst index 7bcd829..b50869b 100644 --- a/docs/api/trspecfit.rst +++ b/docs/api/trspecfit.rst @@ -71,6 +71,8 @@ identically on a live session and on loaded archives — see .. automethod:: trspecfit.trspecfit.File.get_mcmc .. automethod:: trspecfit.trspecfit.File.plot_fit .. automethod:: trspecfit.trspecfit.File.plot_param_evolution +.. automethod:: trspecfit.trspecfit.File.plot_mcmc +.. automethod:: trspecfit.trspecfit.File.plot_sbs_slices .. automethod:: trspecfit.trspecfit.File.compare_models .. automethod:: trspecfit.trspecfit.File.save_fit .. automethod:: trspecfit.trspecfit.File.export_fit @@ -83,4 +85,3 @@ Utility Methods Most users won't need to call these directly. .. automethod:: trspecfit.trspecfit.File.model_list_to_name -.. automethod:: trspecfit.trspecfit.File.model_path diff --git a/docs/design/examples_upgrade.md b/docs/design/examples_upgrade.md index 9dadb26..1d0e326 100644 --- a/docs/design/examples_upgrade.md +++ b/docs/design/examples_upgrade.md @@ -123,10 +123,9 @@ section is framed as data generation, not a fitting tutorial. duplicating the CSVs. - **Casual user's mental model is `File`.** `file.save_fit()` snapshots this file's completed fits (latest slot per model / fit type / selection). -- **`auto_export` opt-out.** `fit_*` methods auto-write CSV/PNG on completion by - default; example `project.yaml` files set `auto_export: False` (with a - comment) so notebooks leave no surprise files. The default + opt-out is taught - where export is the topic. +- **Fits never write to disk** (v0.14.0), so notebooks leave no surprise + files by construction; on-disk artifacts come only from explicit + `save_fits` / `export_fits` calls, taught where persistence is the topic. - **`pathlib.Path.cwd()`** for `Project(path=...)`, not `import os`. ## Save / export / load language diff --git a/docs/design/repo_architecture.md b/docs/design/repo_architecture.md index 68a017e..16a66d6 100644 --- a/docs/design/repo_architecture.md +++ b/docs/design/repo_architecture.md @@ -189,18 +189,21 @@ HDF5 archive ────► reader ────► FitResults (FitResults.load one-line delegates to the corresponding `Project.*` / `FitResults.*` methods. There is no `File.load_fit`: load is path-scoped, not file-scoped. -Auto-export inside `fit_slice_by_slice` / `fit_2d` / `Project.fit_2d` -routes through the same slot exporter as explicit `export_fits` calls, -writing the grouped `{path_results}/{file}/{model}__{fit_type}/` tree -(unless disabled via `Project.auto_export = False`, default `True`). -Interactive display (`show_output >= 1`) renders inline from the -just-captured `SavedFitSlot` via the `File._display_*` helpers — the -figure a user sees is built from the same arrays the export saves. -Fit-time diagnostics (per-stage parameter CSVs from `fitlib.fit_wrapper`, -SbS per-slice CSVs/PNGs) are separate from the results export and land -under `File.model_path()` (`{path_results}/{file}/{fit_type}/{model}/`). -The pre-0.14 legacy savers (`save_sbs_fit` / `save_2d_fit` and their -`_save_*_fit_legacy` impls, which wrote a flat layout) were removed. +Fits never write to disk (v0.14.0): the fit methods compute, display +(per `show_output`), and capture `SavedFitSlot`s — persistence is always +the explicit `save_fits` (HDF5) / `export_fits` (CSV/PNG tree) pair, fed +from the slot history, with one default output root +(`./fit_results/{Project.name}/`). Interactive display (`show_output >= +1`) renders inline from the just-captured slot via the `FitResults` plot +API — the figure a user sees is the one the API reproduces later. +On-demand diagnostics replace the old fit-time file dumps: +`FitResults.plot_mcmc` re-renders the emcee walker-acceptance and corner +figures from the persisted payload (live or loaded archive); +`File.plot_sbs_slices` renders per-slice fit panels from the live +`results_sbs` state (live-session only). The pre-0.14 auto-export +machinery (`Project.auto_export`, `Project.path_results`, +`File.model_path`, the legacy `save_sbs_fit` / `save_2d_fit` savers, and +`fit_wrapper`'s CSV/TXT dump block) was removed. ## `config/` — runtime configuration diff --git a/examples/fitting_workflows/01_basic_fitting/project.yaml b/examples/fitting_workflows/01_basic_fitting/project.yaml index eb76eb8..e85ca07 100644 --- a/examples/fitting_workflows/01_basic_fitting/project.yaml +++ b/examples/fitting_workflows/01_basic_fitting/project.yaml @@ -3,12 +3,6 @@ # Display settings (general verbosity) show_output: 1 # 0: silent/API mode, 1: interactive/notebook mode -# Auto-export side effects (CSV/PNG written at fit completion) are off here so -# this introductory notebook produces no surprise files on disk. Saving, -# loading, and exporting fits is the subject of the 11_save_load_export -# notebook; MCMC uncertainty estimation lives in 12_uncertainty_mcmc. -auto_export: False - # Axis labels e_label: 'Energy (arb. units)' # x -> energy t_label: 'Time (arb. units)' # y -> time diff --git a/examples/fitting_workflows/02_dependent_parameters/project.yaml b/examples/fitting_workflows/02_dependent_parameters/project.yaml index 03ebe30..2d67fb1 100644 --- a/examples/fitting_workflows/02_dependent_parameters/project.yaml +++ b/examples/fitting_workflows/02_dependent_parameters/project.yaml @@ -3,12 +3,6 @@ # Display settings (general verbosity) show_output: 1 # 0: silent/API mode, 1: interactive/notebook mode -# Auto-export side effects (CSV/PNG written at fit completion) are off here so -# this introductory notebook produces no surprise files on disk. Saving, -# loading, and exporting fits is the subject of the 11_save_load_export -# notebook; MCMC uncertainty estimation lives in 12_uncertainty_mcmc. -auto_export: False - # Axis labels e_label: 'Binding energy (eV)' # x -> energy t_label: 'Time (s)' # y -> time diff --git a/examples/fitting_workflows/03_multi_cycle_dynamics/project.yaml b/examples/fitting_workflows/03_multi_cycle_dynamics/project.yaml index 2a7170d..0e6fdd4 100644 --- a/examples/fitting_workflows/03_multi_cycle_dynamics/project.yaml +++ b/examples/fitting_workflows/03_multi_cycle_dynamics/project.yaml @@ -3,12 +3,6 @@ # Display settings (general verbosity) show_output: 1 # 0: silent/API mode, 1: interactive/notebook mode -# Auto-export side effects (CSV/PNG written at fit completion) are off here so -# this introductory notebook produces no surprise files on disk. Saving, -# loading, and exporting fits is the subject of the 11_save_load_export -# notebook; MCMC uncertainty estimation lives in 12_uncertainty_mcmc. -auto_export: False - # Axis labels e_label: 'Energy (arb. units)' # x -> energy t_label: 'Time (arb. units)' # y -> time diff --git a/examples/fitting_workflows/04_parameter_profiles/project.yaml b/examples/fitting_workflows/04_parameter_profiles/project.yaml index 2a4adc8..84c398c 100644 --- a/examples/fitting_workflows/04_parameter_profiles/project.yaml +++ b/examples/fitting_workflows/04_parameter_profiles/project.yaml @@ -4,12 +4,6 @@ # Display settings (general verbosity) show_output: 1 # 0: silent/API mode, 1: interactive/notebook mode -# Auto-export side effects (CSV/PNG written at fit completion) are off here so -# this notebook produces no surprise files on disk. Saving, loading, and -# exporting fits is the subject of the 11_save_load_export notebook; MCMC -# uncertainty estimation lives in 12_uncertainty_mcmc. -auto_export: False - # Axis labels e_label: 'Binding energy (eV)' # x -> energy t_label: 'Time (ps)' # y -> time diff --git a/examples/fitting_workflows/10_model_comparison/example.ipynb b/examples/fitting_workflows/10_model_comparison/example.ipynb index 4c743ec..079cf2f 100644 --- a/examples/fitting_workflows/10_model_comparison/example.ipynb +++ b/examples/fitting_workflows/10_model_comparison/example.ipynb @@ -17,7 +17,7 @@ "\n", "Set `M_TRUE → 0` to neuter the baseline comparison; set `SD_TRUE → 0` to neuter the 2D comparison.\n", "\n", - "`auto_export: False` is set in `project.yaml` so fit-completion side effects (CSV/PNG drops) stay out of the way; this notebook only writes the artifacts it asks for explicitly.\n", + "Fits never write to disk; this notebook only writes the artifacts it asks for explicitly (via `save_fits` / `export_fits`).\n", "\n", "**Persistence, archive inspection, and CSV/PNG export** live in the sibling notebook [`11_save_load_export`](../11_save_load_export/example.ipynb), which uses this notebook's fitted state as its preamble." ] diff --git a/examples/fitting_workflows/10_model_comparison/project.yaml b/examples/fitting_workflows/10_model_comparison/project.yaml index cc8f9e4..bb169d5 100644 --- a/examples/fitting_workflows/10_model_comparison/project.yaml +++ b/examples/fitting_workflows/10_model_comparison/project.yaml @@ -3,13 +3,6 @@ # Display settings show_output: 1 -# Auto-export side effects (CSV/PNG written at fit completion) are off here: -# the six baseline/SbS/2D fits this notebook runs would otherwise each spawn -# their own CSV/PNG dump, drowning out the model-comparison narrative. -# Save/load/export demos live in the sibling 11_save_load_export notebook, -# which %%capture-imports this notebook's fitted state as its preamble. -auto_export: False - # Axis labels e_label: 'Energy (arb. units)' t_label: 'Time (arb. units)' diff --git a/examples/fitting_workflows/11_save_load_export/example.ipynb b/examples/fitting_workflows/11_save_load_export/example.ipynb index cba9c6f..80ebed0 100644 --- a/examples/fitting_workflows/11_save_load_export/example.ipynb +++ b/examples/fitting_workflows/11_save_load_export/example.ipynb @@ -17,7 +17,7 @@ "**Preamble (next cell)** — `%run`s notebook 10 in the kernel so all of its variables (`file`, `project`, the fitted `baseA/baseB/sbsA/sbsB/m2dA/m2dB` slots) are in scope below. The mechanics:\n", "\n", "1. `%cd` into notebook 10's directory so its `project.yaml` and model YAMLs resolve.\n", - "2. `%%capture` wraps the whole cell so the noisy run (notebook 10 keeps `show_output: 1` for its own interactive use) doesn't dump into this notebook's output. `auto_export: false` in notebook 10's `project.yaml` keeps the run from writing CSV/PNG side effects either.\n", + "2. `%%capture` wraps the whole cell so the noisy run (notebook 10 keeps `show_output: 1` for its own interactive use) doesn't dump into this notebook's output. Fits never write to disk, so the run leaves no CSV/PNG side effects either.\n", "3. `%cd -` back so any HDF5/CSV/PNG artifacts this notebook writes land in `11_save_load_export/`.\n", "\n", "Expected runtime: ~30–40 s. The cell after the preamble prints a one-line confirmation so you know the fits landed." @@ -241,7 +241,7 @@ "\n", "- **Default path:** `file.save_fit()` / `project.save_fits()` write to `./fit_results/.fit.h5` when no path is given; `file.export_fit()` / `project.export_fits()` write to `./fit_results//`. Pass an explicit path to override (as the cells above do).\n", "- **Slot-scoped overwrite:** re-saving with the same `(file, model, fit_type, selection)` raises `FileExistsError` unless `overwrite=True`. Append-by-default: writing to an existing archive augments it.\n", - "- **`auto_export` side effect:** `fit_*` methods auto-write CSV/PNG into `project.path_results` on completion by default. Notebook 10's `project.yaml` sets `auto_export: false` to keep that quiet; toggle it (or call `project.auto_export = False`) in any session where you only want explicit export calls to write to disk.\n", + "- **Fits never write to disk:** `fit_*` methods compute, display (per `show_output`), and record the fit — persistence is always an explicit `save_fits` (HDF5 archive) or `export_fits` (CSV/PNG tree) call.\n", "- **`File.set_sigma` is the only sigma entry point.** Calibrated columns (`chi2`, `chi2_red`) and stored metrics (`aic`, `bic`, …) survive load without re-`set_sigma` — they were materialized at fit time and live inside each slot's σ snapshot. For what-if recalibration of *existing* slots, use the always-present `chi2_red_raw` column (see preamble §1).\n", "\n", "**Next steps:**\n", diff --git a/examples/fitting_workflows/21_multi_file_shared_fit/project.yaml b/examples/fitting_workflows/21_multi_file_shared_fit/project.yaml index 6920a80..7e54187 100644 --- a/examples/fitting_workflows/21_multi_file_shared_fit/project.yaml +++ b/examples/fitting_workflows/21_multi_file_shared_fit/project.yaml @@ -3,11 +3,6 @@ # Display settings show_output: 1 -# Auto-export side effects (CSV/PNG written at fit completion) are off here so -# this notebook produces no surprise files on disk. Saving, loading, and -# exporting fits is the subject of the 11_save_load_export notebook. -auto_export: False - # Axis labels e_label: 'Energy (arb. units)' t_label: 'Time (arb. units)' diff --git a/examples/fitting_workflows/README.md b/examples/fitting_workflows/README.md index c70647b..f6f22f8 100644 --- a/examples/fitting_workflows/README.md +++ b/examples/fitting_workflows/README.md @@ -46,8 +46,8 @@ Each notebook directory contains: - `data/` — input CSVs (where applicable; some notebooks generate data inline). - `models_energy.yaml` / `models_time.yaml` / `models_profile.yaml` — model definitions. -- `project.yaml` — project-level configuration (display, axis labels, plotting, - `auto_export`, etc.). +- `project.yaml` — project-level configuration (display, axis labels, + plotting, etc.). **Exception — post-fit notebooks.** `11_save_load_export` and `12_uncertainty_mcmc` have no model/`project.yaml` of their own; their preamble diff --git a/llms.txt b/llms.txt index 64f6a73..4145b0b 100644 --- a/llms.txt +++ b/llms.txt @@ -95,11 +95,11 @@ Component types (see the API reference for signatures): ## Pitfalls for scripted / headless use -- Fits display figures and write CSV/PNG side effects by default. To run - headless, set the Project knobs `project.show_output = 0` (suppress - display) and `project.auto_export = False` (suppress automatic export). - These govern the `fit_baseline` / `fit_spectrum` / `fit_slice_by_slice` / - `fit_2d` calls; those methods do not accept `save_img` (passing it raises +- Fits never write to disk; persistence is always an explicit `save_fits` + (HDF5) or `export_fits` (CSV/PNG) call. Fits do display figures by + default — to run headless, set `project.show_output = 0`. This governs + the `fit_baseline` / `fit_spectrum` / `fit_slice_by_slice` / `fit_2d` + calls; those methods do not accept `save_img` (passing it raises `TypeError`). - The low-level plot and setup helpers instead take their own arguments: pass `show_plot=False` where available, or `save_img=-1` (save without diff --git a/src/trspecfit/fit_results.py b/src/trspecfit/fit_results.py index 11cc233..9aa3228 100644 --- a/src/trspecfit/fit_results.py +++ b/src/trspecfit/fit_results.py @@ -720,6 +720,81 @@ def _plot_fit_1d( plt.close(fig) return fig + # + def plot_mcmc( + self, + *, + file: Any = None, + model: str | None = None, + fit_type: FitType = "baseline", + show_plot: bool = True, + ) -> None: + """ + Plot the MCMC diagnostics of the latest matching fit. + + Renders the two figures ``fit_wrapper`` shows at fit time, from the + persisted slot (``SavedFitSlot.mcmc``): the per-walker acceptance + fraction and the corner plot of the posterior samples. Works + identically on ``Project.results`` and on archives loaded via + :meth:`FitResults.load`. The acceptance panel is skipped for slots + loaded from schema-2 archives (which did not store + ``acceptance_fraction``). + + Parameters + ---------- + file : str | SavedFile | trspecfit.File | None + Filter to a single file (name string or object with ``.name``). + model : str, optional + Filter to a single model name. + fit_type : {'baseline', 'spectrum', 'sbs', '2d'}, default='baseline' + Which fit to plot (latest matching fit wins). For SbS fits the + payload is slice 0's. + show_plot : bool, default True + Set ``False`` to build without displaying (tests / batch use). + + Raises + ------ + ValueError + If no matching fit exists, or the fit had no MCMC step. + """ + + import corner + import matplotlib.pyplot as plt + + mcmc = self.get_mcmc(file=file, model=model, fit_type=fit_type) + if mcmc.acceptance_fraction is not None: + fig_walker, ax = plt.subplots(1, 1, dpi=75) + ax.plot(mcmc.acceptance_fraction, "o") + ax.set_xlabel("Walker number") + ax.set_ylabel("Acceptance fraction") + if show_plot: + plt.show() + else: + plt.close(fig_walker) + if not mcmc.flatchain.empty: + var_names = list(mcmc.flatchain.columns) + truths = None + if not mcmc.table.empty: + best = dict( + zip( + mcmc.table.iloc[:, 0], + mcmc.table["best fit"], + strict=True, + ) + ) + truths = [best.get(name) for name in var_names] + fig_corner = plt.figure(figsize=(10, 10)) + corner.corner( + mcmc.flatchain, + labels=var_names, + truths=truths, + fig=fig_corner, + ) + if show_plot: + plt.show() + else: + plt.close(fig_corner) + # def plot_param_evolution( self, diff --git a/src/trspecfit/fitlib.py b/src/trspecfit/fitlib.py index 2d488b8..88d6f45 100644 --- a/src/trspecfit/fitlib.py +++ b/src/trspecfit/fitlib.py @@ -578,10 +578,6 @@ def fit_wrapper( fit_alg_2: str = "leastsq", jac_fun: Callable[..., np.ndarray] | None = None, show_output: int = 0, - save_output: int = 0, - save_path: PathLike = "", - num_fmt: str = "%.6e", - delim: str = ",", ) -> list[Any]: """ Comprehensive fitting wrapper with optimization, CI, and MCMC. @@ -590,7 +586,7 @@ def fit_wrapper( - Single or two-stage optimization - Confidence interval estimation via lmfit.conf_interval - MCMC sampling via lmfit.emcee - - Result visualization and export + - Result visualization (never writes to disk) Two-stage fitting (stages=2) is recommended for robust optimization: first finds global minimum with Nelder-Mead, then refines locally with @@ -659,25 +655,7 @@ def fit_wrapper( - 0: Silent / programmatic / API mode -- no prints - 1: Interactive / notebook / UI mode -- show timing, fit results, - and confidence intervals - - save_output : {-1, 0, 1}, default=0 - Save results to files: - - - 0: Don't save - - 1: Save all results (parameters, CIs, MCMC, plots) - - -1: Same as 1 (for compatibility) - - save_path : str or Path, default='' - Base path for saved files (without extension). - Files saved: _par_ini.csv, _par_fin.txt, _par_fin.csv, - _conf_ci.csv, _emcee_fin.txt, _emcee_flatchain.csv, - _emcee_ci.csv, _emcee_walker_acceptance_ratio.png, - _emcee_corner_plot.png - num_fmt : str, default='%.6e' - Float format applied to CSV outputs (pandas ``float_format``). - delim : str, default=',' - Delimiter applied to CSV outputs (pandas ``sep``). + confidence intervals, and MCMC diagnostic figures Returns ------- @@ -724,9 +702,7 @@ def fit_wrapper( ... stages=2, ... try_ci=1, ... ci_sigmas=[1, 2, 3], - ... show_output=1, - ... save_output=1, - ... save_path='fit_results/baseline_fit' + ... show_output=1 ... ) >>> # Fit with MCMC for uncertainty quantification @@ -758,20 +734,17 @@ def fit_wrapper( **MCMC Diagnostics:** When using MCMC, check: - - Acceptance ratios (saved plot): Should be 0.2-0.5 - - Corner plot (saved): Should show well-defined peaks + - Acceptance ratios: Should be 0.2-0.5 + - Corner plot: Should show well-defined peaks - Chain length: Increase steps if distributions look noisy + Both figures are displayed when show_output=1 and can be reproduced + later from the persisted fit slot via FitResults.plot_mcmc(). + **Performance Tips:** - Use stages=1 for quick fits during model development - Use stages=2 for final/publication fits - MCMC is slow (minutes for complex models) but provides best uncertainties - - **File Outputs:** - When save_output=1: - - CSV files: Comma-separated, easy to read in Excel/pandas - - TXT files: Human-readable lmfit.fit_report format - - PNG files: High-resolution plots for documentation """ if ci_sigmas is None: @@ -801,8 +774,6 @@ def fit_wrapper( par_ini = copy.deepcopy(par) else: par_ini = ulmfit.par_construct(par_names=par_names, par_info=par) - # convert par_ini to pandas dataframe and save all lmfit info - df_par_ini = ulmfit.par_to_df(par_ini, "ini", par_names) if show_output >= 1: t_0 = time.time() # start time @@ -944,22 +915,16 @@ def _method_kws(method: str) -> dict[str, Any]: lmfit.report_fit(emcee_fin_params) t_emcee1 = time.time() print(f"Time lmfit.emcee: {t_emcee1 - t_emcee0} s") - # display per show_output, save per save_output (_finalize_plot - # semantics: >= 0 shows, abs == 1 saves, so -2 means neither); - # skip figure construction entirely when neither shows nor saves + # diagnostics figures are display-only (reproducible later from the + # persisted slot via FitResults.plot_mcmc); skip construction when + # silent if show_output >= 1: - emcee_save = save_output - else: - emcee_save = -1 if abs(save_output) == 1 else -2 - if emcee_save != -2: # acceptance fraction of all walkers (plot) fig_emcee_walker, _ax = plt.subplots(1, 1, dpi=75) plt.plot(emcee_acceptance_fraction, "o") plt.xlabel("Walker number") plt.ylabel("Acceptance fraction") - uplt._finalize_plot( - emcee_save, f"{save_path}_emcee_walker_acceptance_ratio.png" - ) + uplt._finalize_plot(0) # draw all combinations of the typically ellipsoidal chi plot # [ plot] emcee_truths = [ @@ -973,7 +938,7 @@ def _method_kws(method: str) -> dict[str, Any]: truths=emcee_truths, fig=fig_emcee_corner, ) - uplt._finalize_plot(emcee_save, f"{save_path}_emcee_corner_plot.png") + uplt._finalize_plot(0) # get percentage borders to categorize emcee.flatchain data sigma_borders = sigma_start_stop_percent(ci_sigmas) # one row per sampled parameter (varying model params + the __lnsigma @@ -1015,60 +980,6 @@ def _method_kws(method: str) -> dict[str, Any]: emcee_fin = None emcee_ci = pd.DataFrame() - # optional save (figures are saved above) - # [if statements check for empty list/dataframe] - if abs(save_output) == 1: - # save_path is a file prefix; make sure its directory exists - pathlib.Path(save_path).parent.mkdir(parents=True, exist_ok=True) - # par_ini (pandas DataFrame) as csv file - df_par_ini.to_csv( - str(save_path) + "_par_ini.csv", - index=False, - float_format=num_fmt, - sep=delim, - ) - # par_fin as text dump - if par_fin: - with pathlib.Path(f"{save_path}_par_fin.txt").open("w") as par_fin_file: - par_fin_file.write(lmfit.fit_report(par_fin)) - # par_fin variables as csv file - df_par_fin = ulmfit.par_to_df(_result_params(par_fin), "min", par_names) - df_par_fin.to_csv( - str(save_path) + "_par_fin.csv", - index=False, - float_format=num_fmt, - sep=delim, - ) - # conf_ci using pandas as it is a pd.DataFrame - if not conf_ci.empty: - conf_ci.to_csv( - str(save_path) + "_conf_ci.csv", - index=False, - float_format=num_fmt, - sep=delim, - ) - # emcee_fin (fit_report) as text dump, emcee flatchain as csv - if emcee_fin is not None: - with pathlib.Path(f"{save_path}_emcee_fin.txt").open("w") as emcee_fin_file: - emcee_fin_file.write(lmfit.fit_report(emcee_fin)) - emcee_flatchain = cast( - "pd.DataFrame", getattr(emcee_fin, "flatchain", pd.DataFrame()) - ) - emcee_flatchain.to_csv( - f"{save_path}_emcee_flatchain.csv", - index=False, - float_format=num_fmt, - sep=delim, - ) - # emcee_ci using pandas as it is a pd.DataFrame - if not emcee_ci.empty: - emcee_ci.to_csv( - str(save_path) + "_emcee_ci.csv", - index=False, - float_format=num_fmt, - sep=delim, - ) - return [par_ini, par_fin, conf_ci, emcee_fin, emcee_ci] diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index 2e2f723..cd3a501 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -152,8 +152,9 @@ class Project: path : str or Path Base directory for project data files and YAML configuration. If None, defaults to 'test' directory. - name : str, default='test' - Name for this analysis run. Creates subdirectory in results folder. + name : str, default='my_project' + Name for this analysis run. Names the default output root used by + ``save_fits`` / ``export_fits`` (``./fit_results//``). config_file : str or Path, optional YAML configuration file name (located in path directory). If None, uses default settings only. @@ -162,8 +163,6 @@ class Project: ---------- path : Path Base project directory containing data and configuration - path_results : Path - Results directory (path + '_fits' suffix) name : str Name for this analysis run files : list of File @@ -204,11 +203,10 @@ class Project: def __init__( self, path: PathLike | None, - name: str = "test", + name: str = "my_project", config_file: PathLike | None = "project.yaml", ) -> None: self.path = pathlib.Path(path) if path is not None else pathlib.Path("test") - self.path_results = pathlib.Path(f"{path}_fits") self.name = name self._config_file: PathLike | None = None @@ -230,10 +228,6 @@ def _set_defaults(self) -> None: """Set default project configuration.""" self.show_output = 1 - # When False, fit_* methods skip the automatic CSV/PNG side effects - # (fit_wrapper(save_output=1) and the legacy save_*_fit calls). - # Explicit File.export_fit / Project.export_fits still write. - self.auto_export = True # Plot settings self.e_label = "Energy" self.t_label = "Time" @@ -700,7 +694,6 @@ def describe(self, detail: int = 0) -> None: print("Project") print(f" path: {self.path}") - print(f" results: {self.path_results}") print(f" name: {self.name}") if self._config_file is not None: print(f" config: {self._config_file}") @@ -810,8 +803,25 @@ def _load_config(self, config_file: PathLike) -> None: "y_label": "t_label", "dpi_plot": "dpi_plt", } + _removed_keys = { + "auto_export": ( + "fits no longer write to disk; use " + "save_fits()/export_fits() to persist results" + ), + "path_results": ( + "the fit-time output tree is gone; save_fits()/" + "export_fits() take an explicit path (default " + "./fit_results//)" + ), + } project_key = _key_map.get(normalized_key) or normalized_key + if project_key in _removed_keys: + raise ValueError( + f"Config key '{key}' was removed in v0.14.0: " + f"{_removed_keys[project_key]}. " + f"Remove it from {config_path}." + ) if hasattr(self, project_key): setattr(self, project_key, value) else: @@ -1071,18 +1081,11 @@ def fit_baselines( t_start=t_start, print_str=(f"Baseline fit complete for {len(self.files)} files: "), ) - # Show saved baseline fit plots in a grid - import matplotlib.image as mpimg - - images = [] + # Show each file's baseline fit inline from its slot. + results = self.results for f in self.files: - img_path = ( - f.model_path(model_name, fit_type="baseline") / "base_fit.png" - ) - if img_path.exists(): - images.append(mpimg.imread(str(img_path))) - if images: - uplt.plot_grid(images, columns=min(3, len(images))) + if results.find(file=f.name, model=model_name, fit_type="baseline"): + results.plot_fit(file=f, model=model_name, fit_type="baseline") # ------------------------------------------------------------------ # Project-level fitting @@ -1447,7 +1450,6 @@ def fit_2d( par=combined_pars, stages=stages, show_output=1 if self.show_output >= 1 else 0, - save_output=0, **fit_wrapper_kwargs, ) @@ -1517,16 +1519,6 @@ def fit_2d( ) ) - # Export per-file 2D fit results through the slot exporter (silently) - if self.auto_export and any(s is not None for s in slots_2d): - self.export_fits( - self.path_results, - model=model_name, - fit_type="2d", - overwrite=True, - show_output=0, - ) - if self.show_output >= 1: fitlib.time_display( t_start=t_start, @@ -2265,35 +2257,6 @@ def fingerprint(self) -> dict[str, Any]: data=self.data, energy=self.energy, time=self.time ) - # - def model_path( - self, - model_name: str, - *, - fit_type: Literal["baseline", "spectrum", "sbs", "2d"], - ) -> pathlib.Path: - """ - Build the path where model fit results are saved. - - Layout: ``{Project.path_results}/{File.name}/{fit_type}/{model_name}/``. - Only computes the path — directories are created by the write sites - when a file is actually saved. - - Parameters - ---------- - model_name : str - Name of model (must exist in self.models) - fit_type : {"baseline", "spectrum", "sbs", "2d"} - Fit type segment in the output path. - - Returns - ------- - Path - Path to model results directory - """ - - return self.p.path_results / self.name / fit_type / model_name - # def _apply_corrections(self) -> None: """Rebuild ``data`` from ``data_raw`` by applying dark and calibration.""" @@ -2688,8 +2651,6 @@ def fit_baseline( initial_guess = ulmfit.par_extract( self.model_base.lmfit_pars, return_type="list" ) - # define path where baseline fit results will be saved to - path_base_results = self.model_path(model_name, fit_type="baseline") # const = (x, data, package, fnctn string, unpack, energy limits, time limits) _fun_str = self.p.spec_fun_str @@ -2704,9 +2665,6 @@ def fit_baseline( # --- dispatch: GIR fast path vs interpreter --- _args = self._build_1d_dispatch_args(self.model_base, _fun_str) self.model_base.args = _args - # CSV format/delimiter: project defaults unless caller overrides - lmfit_wrapper_kwargs.setdefault("num_fmt", self.p.num_fmt) - lmfit_wrapper_kwargs.setdefault("delim", self.p.delim) # fit (optionally) with confidence intervals self.model_base.result = fitlib.fit_wrapper( const=self.model_base.const, @@ -2715,8 +2673,6 @@ def fit_baseline( par=self.model_base.lmfit_pars, stages=stages, show_output=1 if self.p.show_output >= 1 else 0, - save_output=1 if self.p.auto_export else 0, - save_path=path_base_results / model_name, **lmfit_wrapper_kwargs, ) @@ -2736,18 +2692,14 @@ def fit_baseline( stages=stages, fit_wrapper_kwargs=lmfit_wrapper_kwargs ), ) - if self.p.auto_export: - self.save_baseline_fit(save_path=path_base_results) - # display/plot and save baseline fit summary + # display baseline fit summary title_base = ( f"File: {self.path}, " f'Model: "{model_name}" (from "{self.model_base.yaml_f_name}.yaml")' ) - save_plot = self.p.auto_export - show_plot = self.p.show_output >= 1 - if save_plot or show_plot: + if self.p.show_output >= 1: fitlib.plt_fit_res_1d( x=self.energy, y=self.data_base, @@ -2761,8 +2713,7 @@ def fit_baseline( fit_lim=self.e_lim, config=self.plot_config, legend=[comp.name for comp in self.model_base.components], - save_img=uplt._save_img_flag(save=save_plot, show=show_plot), - save_path=path_base_results / "base_fit.png", + save_img=0, ) if stages >= 1 and self.p.show_output >= 1: @@ -2927,8 +2878,6 @@ def fit_spectrum( initial_guess = ulmfit.par_extract( self.model_spec.lmfit_pars, return_type="list" ) - # define path where spectrum fit results will be saved to - path_spec_results = self.model_path(model_name, fit_type="spectrum") # const = (x, data, fnctn string, unpack, energy limits, time limits) _fun_str = self.p.spec_fun_str @@ -2943,9 +2892,6 @@ def fit_spectrum( # --- dispatch: GIR fast path vs interpreter --- _args = self._build_1d_dispatch_args(self.model_spec, _fun_str) self.model_spec.args = _args - # CSV format/delimiter: project defaults unless caller overrides - lmfit_wrapper_kwargs.setdefault("num_fmt", self.p.num_fmt) - lmfit_wrapper_kwargs.setdefault("delim", self.p.delim) # fit self.model_spec.result = fitlib.fit_wrapper( const=self.model_spec.const, @@ -2954,8 +2900,6 @@ def fit_spectrum( par=self.model_spec.lmfit_pars, stages=stages, show_output=1 if self.p.show_output >= 1 else 0, - save_output=1 if self.p.auto_export else 0, - save_path=path_spec_results / model_name, **lmfit_wrapper_kwargs, ) @@ -2976,10 +2920,8 @@ def fit_spectrum( stages=stages, fit_wrapper_kwargs=lmfit_wrapper_kwargs ), ) - if self.p.auto_export: - self.save_spectrum_fit(save_path=path_spec_results) - # display/plot and save spectrum fit summary + # display spectrum fit summary time_label = ( f"t = {self.spec_t_abs[0]:.4g}" if self.spec_t_abs[0] == self.spec_t_abs[1] @@ -2991,9 +2933,7 @@ def fit_spectrum( f'(from "{self.model_spec.yaml_f_name}.yaml")' ) - save_fig = self.p.auto_export - show_fig = show_plot and self.p.show_output >= 1 - if save_fig or show_fig: + if show_plot and self.p.show_output >= 1: fitlib.plt_fit_res_1d( x=self.energy, y=self.data_spec, @@ -3007,8 +2947,7 @@ def fit_spectrum( fit_lim=self.e_lim, config=self.plot_config, legend=[comp.name for comp in self.model_spec.components], - save_img=uplt._save_img_flag(save=save_fig, show=show_fig), - save_path=path_spec_results / "spec_fit.png", + save_img=0, ) if stages >= 1 and self.p.show_output >= 1: @@ -3200,10 +3139,6 @@ def fit_slice_by_slice( "run define_baseline() first or use seed_adapt=None." ) - # path for fit-time diagnostics (per-slice CSVs/PNGs from the fit - # loop); the results export goes through export_fit below - path_sbs_results = self.model_path(model_name, fit_type="sbs") - if seed_source == "model": seed_template = ulmfit.par_extract( self.model_sbs.lmfit_pars, return_type="list" @@ -3246,20 +3181,12 @@ def fit_slice_by_slice( n_slices = len(self.data) - def _slice_path(s_i: int) -> pathlib.Path: - return path_sbs_results / "slices" / str(self.p.da_slices_fmt % s_i) - # resolve worker count: None -> auto, otherwise honour user. if n_workers is None: n_workers = max(1, (os.cpu_count() or 1) - 1) # No point spawning more workers than slices. n_workers = max(1, min(n_workers, n_slices)) - # CSV format/delimiter: project defaults unless caller overrides - # (forwarded into both the serial and parallel SbS dispatch paths) - fit_wrapper_kwargs.setdefault("num_fmt", self.p.num_fmt) - fit_wrapper_kwargs.setdefault("delim", self.p.delim) - if n_workers == 1: # serial path (debug escape hatch). tqdm (not a raw print with # "\r") keeps the per-slice progress notebook-friendly: it renders @@ -3270,14 +3197,12 @@ def _slice_path(s_i: int) -> pathlib.Path: # ipywidgets-based tqdm.notebook inside a kernel, and ipywidgets is # not a dependency -- it would emit "IProgress not found" warnings. self.results_sbs = [] - for s_i, s in tqdm( + for _s_i, s in tqdm( enumerate(self.data), total=n_slices, desc="SbS fit (serial)", ): - path_slice = _slice_path(s_i) - - initial_guess = usbs.prepare_sbs_model_for_slice( + usbs.prepare_sbs_model_for_slice( self.model_sbs, _args_sbs, seed_template, @@ -3302,27 +3227,9 @@ def _slice_path(s_i: int) -> pathlib.Path: par=self.model_sbs.lmfit_pars, stages=stages, show_output=0, - save_output=1 if self.p.auto_export else 0, - save_path=path_slice, **fit_wrapper_kwargs, ) self.results_sbs.append(result_sbs) - - if self.p.auto_export: - fitlib.plt_fit_res_1d( - x=const[0], - y=const[1], - fit_fun_str=self.p.spec_fun_str, - par_init=initial_guess, - par_fin=result_sbs[1], - args=args, - plot_sum=False, - show_init=True, - fit_lim=self.e_lim, - config=self.plot_config, - save_img=-1, - save_path=path_slice.with_suffix(".png"), - ) else: # parallel path: spawn pool, install model once per worker. # sanitized_spawn_main keeps workers from re-running a @@ -3351,10 +3258,7 @@ def _slice_path(s_i: int) -> pathlib.Path: data_base_argmax_energy=data_base_argmax_energy, fit_fun_str=_fun_str, stages=stages, - path_slice=_slice_path(s_i), - plot_config=self.plot_config, fit_wrapper_kwargs=fit_wrapper_kwargs, - auto_export=self.p.auto_export, ): s_i for s_i in range(n_slices) } @@ -3397,30 +3301,21 @@ def _slice_path(s_i: int) -> pathlib.Path: fit_wrapper_kwargs=fit_wrapper_kwargs, seed_source=seed_source, seed_adapt=seed_adapt, + # capture the normalized template (ordered by + # parameter_names), not the raw user input — explicit + # seeds arrive as dicts/lists/Parameters alike seed_values=( - [float(v) for v in np.asarray(seed_values).ravel()] - if seed_values is not None + [float(v) for v in seed_template] + if seed_source == "explicit" else None ), ), ) - # Display inline when interactive (show_output); export the slot - # when auto_export — mirroring fit_baseline's split. Per-slice - # diagnostics (fit_wrapper CSVs, per-slice PNGs) were already - # written under model_path during the fit loop. if self.p.show_output >= 1 and slot_sbs is not None: # Inline display via the explicit plot API (reads the slot # just appended): varied-parameter evolution + fit maps. self.plot_param_evolution(model=model_name) self.plot_fit(model=model_name, fit_type="sbs") - if self.p.auto_export and slot_sbs is not None: - self.export_fit( - self.p.path_results, - model=model_name, - fit_type="sbs", - overwrite=True, - show_output=0, - ) self.model_sbs.update_value(new_par_values=seed_template, par_select="all") self.model_sbs.args = _args_sbs if stages >= 1 and self.p.show_output >= 1: @@ -4067,10 +3962,6 @@ def fit_2d(self, model_name: str, stages: int = 1, **fit_wrapper_kwargs) -> None if self.energy is None or self.time is None or self.data is None: raise ValueError("Data/axes missing; cannot run 2D fit.") - # path for fit-time diagnostics (fit_wrapper's per-stage CSVs); the - # results export goes through export_fit below - path_2d_results = self.model_path(model_name, fit_type="2d") - # set all fixed 2D fit parameters equal to baseline model results base_df = ulmfit.par_to_df(self.model_base.lmfit_pars, col_type="min") self.model_2d.update_value( @@ -4137,9 +4028,6 @@ def fit_2d(self, model_name: str, stages: int = 1, **fit_wrapper_kwargs) -> None ) self.model_2d.args = _args - # CSV format/delimiter: project defaults unless caller overrides - fit_wrapper_kwargs.setdefault("num_fmt", self.p.num_fmt) - fit_wrapper_kwargs.setdefault("delim", self.p.delim) # fit (with confidence intervals) self.model_2d.result = fitlib.fit_wrapper( const=self.model_2d.const, @@ -4148,8 +4036,6 @@ def fit_2d(self, model_name: str, stages: int = 1, **fit_wrapper_kwargs) -> None par=self.model_2d.lmfit_pars, stages=stages, show_output=1 if self.p.show_output >= 1 else 0, - save_output=1 if self.p.auto_export else 0, - save_path=path_2d_results / model_name, **fit_wrapper_kwargs, ) # Write optimized values back to model.lmfit_pars. fit_wrapper @@ -4170,18 +4056,8 @@ def fit_2d(self, model_name: str, stages: int = 1, **fit_wrapper_kwargs) -> None ) if stages >= 1: - # Display inline when interactive (show_output); export the slot - # when auto_export — mirroring fit_baseline's split. if self.p.show_output >= 1 and slot_2d is not None: self.plot_fit(model=model_name, fit_type="2d") - if self.p.auto_export and slot_2d is not None: - self.export_fit( - self.p.path_results, - model=model_name, - fit_type="2d", - overwrite=True, - show_output=0, - ) if self.p.show_output >= 1: fitlib.time_display( t_start=t_2d, print_str="Time elapsed for 2D model fit: " @@ -4418,6 +4294,84 @@ def plot_param_evolution( show_plot=show_plot, ) + # + def plot_mcmc( + self, + *, + model: str | None = None, + fit_type: Literal["baseline", "spectrum", "sbs", "2d"] = "baseline", + show_plot: bool = True, + ) -> None: + """ + Plot the MCMC diagnostics (walker acceptance, corner plot) of a fit. + + Sugar for ``self.p.results.plot_mcmc(file=self, ...)`` — reads the + persisted fit slot (latest matching fit). Available only when the + fit ran with ``mc_settings`` enabling MCMC. See + :meth:`FitResults.plot_mcmc`. + + Parameters + ---------- + model : str, optional + Restrict to a single model name. + fit_type : {'baseline', 'spectrum', 'sbs', '2d'}, default='baseline' + Which fit to plot. For SbS fits the payload is slice 0's. + show_plot : bool, default True + Set ``False`` to build without displaying. + """ + + self.p.results.plot_mcmc( + file=self, + model=model, + fit_type=fit_type, + show_plot=show_plot, + ) + + # + def plot_sbs_slices( + self, + *, + model: str | None = None, + slices: Sequence[int] | None = None, + show_init: bool = True, + save_path: PathLike | None = None, + show_plot: bool = True, + ) -> None: + """ + Plot per-slice fit panels for the most recent Slice-by-Slice fit. + + Each panel shows the slice data, the per-slice seeded initial + guess, the final fit, and the component decomposition. Reads the + in-session fit state (``results_sbs``), which is richer than the + persisted slot but does not survive it: this diagnostic is + live-session only and raises on a File without a completed + ``fit_slice_by_slice`` run. + + Parameters + ---------- + model : str, optional + Guard against stale expectations: raises if the live SbS + results belong to a different model. + slices : sequence of int, optional + Slice indices to render. Default: all slices. + show_init : bool, default True + Overlay the per-slice initial guess. + save_path : str or Path, optional + Directory to write one PNG per slice (named by + ``Project.da_slices_fmt``). Default ``None`` = display-only. + show_plot : bool, default True + Set ``False`` to build without displaying. + """ + + usbs.plot_sbs_slices( + self, + model=model, + slices=slices, + show_init=show_init, + save_path=save_path, + show_plot=show_plot, + ) + # def compare_models( self, diff --git a/src/trspecfit/utils/sbs.py b/src/trspecfit/utils/sbs.py index 1bf2e85..163013c 100644 --- a/src/trspecfit/utils/sbs.py +++ b/src/trspecfit/utils/sbs.py @@ -20,11 +20,12 @@ import pandas as pd from trspecfit import fitlib -from trspecfit.config.plot import PlotConfig from trspecfit.utils import lmfit as ulmfit +from trspecfit.utils import plot as uplt if TYPE_CHECKING: from trspecfit import mcp + from trspecfit.trspecfit import File # These globals are populated only inside ProcessPoolExecutor worker # processes by ``sbs_worker_init``. They let workers reuse a single @@ -116,8 +117,8 @@ def sbs_worker_init( Runs once per worker process before any task. Stashes the deep-pickled model and GIR/MCP dispatch args as worker-local globals so individual slice tasks don't have to pay the pickle cost on every submission. - Forces matplotlib to the non-interactive Agg backend so the per-slice - plot calls inside workers don't try to open a display. + Forces matplotlib to the non-interactive Agg backend so nothing in a + worker process ever tries to open a display. """ global _WORKER_MODEL, _WORKER_DISPATCH_ARGS, _WORKER_SEED_TEMPLATE @@ -142,10 +143,7 @@ def sbs_fit_one_slice( data_base_argmax_energy: float | None, fit_fun_str: str, stages: int, - path_slice: pathlib.Path, - plot_config: PlotConfig, fit_wrapper_kwargs: dict[str, Any], - auto_export: bool = True, ) -> tuple[int, list[Any]]: """Fit one energy slice in a worker process. @@ -169,7 +167,7 @@ def sbs_fit_one_slice( dispatch_args = _WORKER_DISPATCH_ARGS seed_template = _WORKER_SEED_TEMPLATE - initial_guess = prepare_sbs_model_for_slice( + prepare_sbs_model_for_slice( model, dispatch_args, seed_template, @@ -194,25 +192,94 @@ def sbs_fit_one_slice( par=model.lmfit_pars, stages=stages, show_output=0, - save_output=1 if auto_export else 0, - save_path=path_slice, **fit_wrapper_kwargs, ) - if auto_export: + return s_i, result_sbs + + +# +def plot_sbs_slices( + file: File, + *, + model: str | None = None, + slices: Sequence[int] | None = None, + show_init: bool = True, + save_path: str | pathlib.Path | None = None, + show_plot: bool = True, +) -> None: + """Render per-slice fit panels for the most recent Slice-by-Slice fit. + + Live-session diagnostic behind ``File.plot_sbs_slices``: each panel + shows the slice data, the per-slice seeded initial guess, the final + fit, and the component decomposition via ``fitlib.plt_fit_res_1d``. + Reads the in-session ``file.results_sbs`` (per-slice ``par_ini`` and + final parameters are not persisted in fit slots), so it is not + available on archives loaded via ``FitResults.load``. + + ``save_path=None`` means display-only; pass a directory to also write + one PNG per slice (named by ``Project.da_slices_fmt``). + """ + + results_sbs = getattr(file, "results_sbs", None) + model_sbs = file.model_sbs + if not results_sbs or model_sbs is None: + raise ValueError( + "No live SbS results on this File. plot_sbs_slices() renders " + "per-slice diagnostics from the in-session fit state — run " + "fit_slice_by_slice() first (not available from loaded archives)." + ) + if model is not None and model != model_sbs.name: + raise ValueError( + f'Live SbS results are for model "{model_sbs.name}", not ' + f'"{model}". Only the most recent fit_slice_by_slice() run is ' + "available; re-run it with the requested model." + ) + assert file.data is not None and file.energy is not None # type guard + + n_slices = len(results_sbs) + if slices is None: + slice_indices = list(range(n_slices)) + else: + slice_indices = [int(s) for s in slices] + bad = [s for s in slice_indices if not 0 <= s < n_slices] + if bad: + raise ValueError(f"Slice indices out of range [0, {n_slices}): {bad}") + + save = save_path is not None + if not save and not show_plot: + return + + legend = [comp.name for comp in model_sbs.components] + for s_i in slice_indices: + result_slice = results_sbs[s_i] + if file.time is not None: + title = f"{file.name} — slice {s_i} (t = {file.time[s_i]:.4g})" + else: + title = f"{file.name} — slice {s_i}" + img_path: str | pathlib.Path + if save: + assert save_path is not None # type guard + save_img = uplt._save_img_flag(save=True, show=show_plot) + img_path = pathlib.Path(save_path) / ( + str(file.p.da_slices_fmt % s_i) + ".png" + ) + else: + save_img = 0 + img_path = "" fitlib.plt_fit_res_1d( - x=const[0], - y=const[1], - fit_fun_str=fit_fun_str, - par_init=initial_guess, - par_fin=result_sbs[1], - args=args, + x=file.energy, + y=file.data[s_i], + fit_fun_str=file.p.spec_fun_str, + par_init=result_slice[0], + par_fin=result_slice[1], + args=model_sbs.args, plot_sum=False, - show_init=True, - fit_lim=e_lim, - config=plot_config, - save_img=-1, - save_path=path_slice.with_suffix(".png"), + show_init=show_init, + title=title, + fit_lim=file.e_lim, + config=file.plot_config, + legend=legend, + save_img=save_img, + save_path=img_path, ) - - return s_i, result_sbs diff --git a/tests/_utils.py b/tests/_utils.py index e86dab1..e8944ea 100644 --- a/tests/_utils.py +++ b/tests/_utils.py @@ -23,25 +23,19 @@ def make_project( name: str = "test", spec_fun_str: str = "fit_model_gir", show_output: int = 0, - auto_export: bool = False, ): """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. - ``auto_export`` defaults to ``False`` here (the production default is - ``True``) so tests do not write fit-completion CSV/PNG side effects into - the shared ``tests_fits/`` tree by default -- concurrent xdist workers - would otherwise race on colliding output paths. Pass ``auto_export=True`` - only in tests whose subject is the save/export behavior itself, and - redirect ``project.path_results`` to a ``tmp_path`` there. + Fits never write to disk (v0.14.0); tests that exercise persistence call + ``save_fits`` / ``export_fits`` explicitly with a ``tmp_path``. """ project = Project(path="tests", name=name) project.show_output = show_output project.spec_fun_str = spec_fun_str - project.auto_export = auto_export return project diff --git a/tests/test_auto_export.py b/tests/test_auto_export.py deleted file mode 100644 index 85afed7..0000000 --- a/tests/test_auto_export.py +++ /dev/null @@ -1,382 +0,0 @@ -"""Tests for the ``Project.auto_export`` toggle. - -``auto_export=False`` must suppress the automatic CSV / PNG side effects -inside fit_baseline / fit_spectrum / fit_slice_by_slice / fit_2d while -preserving in-memory state (``_fit_history``, ``Model.result``) and the -explicit File.export_fit / Project.export_fits / save_fits paths. -""" - -from unittest.mock import MagicMock - -import matplotlib - -matplotlib.use("Agg") - -import matplotlib.pyplot as plt -import numpy as np -import pytest -from _utils import make_project, simulate_noisy - -from trspecfit import File, Project, fitlib -from trspecfit.utils.lmfit import MC - - -# -def _make_truth_file(project): - 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, *, name="fit"): - file = File( - parent_project=project, - name=name, - data=data, - energy=energy.copy(), - time=time.copy(), - ) - file.load_model(model_yaml="models/file_energy.yaml", model_info="single_glp") - return file - - -# -def _list_files(root): - """Return the set of files (recursive) under ``root``; empty if missing.""" - - if not root.exists(): - return set() - return {p for p in root.rglob("*") if p.is_file()} - - -# -def _baseline_setup(tmp_path, *, auto_export: bool): - truth_project = make_project(name="truth") - truth = _make_truth_file(truth_project) - data = simulate_noisy(truth.model_active, noise_level=0.01) - project = make_project(name="fit", auto_export=auto_export) - project.path_results = tmp_path / "auto" - file = _make_fit_file(project, data, truth.energy, truth.time) - file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) - return project, file - - -# -class TestProjectDefault: - """``auto_export`` defaults to ``True`` for backward compatibility.""" - - # - def test_default_is_true(self): - # make_project defaults to auto_export=False for test isolation, so - # assert the *production* default directly on a bare Project. - project = Project(path="tests", name="default") - assert project.auto_export is True - - # - def test_toggle_can_be_flipped_after_init(self): - project = make_project(name="flip") - project.auto_export = False - assert project.auto_export is False - - -# -class TestAutoExportFalseSuppressesSideEffects: - """Auto-CSV/PNG side effects are skipped when ``auto_export=False``.""" - - # - def test_baseline_writes_nothing(self, tmp_path): - project, file = _baseline_setup(tmp_path, auto_export=False) - file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) - - # In-memory state is intact. - assert file.model_base.result is not None - assert file.model_base.result[1] != [] - assert len(project._fit_history) == 1 - assert project._fit_history[0].fit_type == "baseline" - - # Nothing hit disk — not even empty directories (model_path only - # computes paths; dirs are created at the actual write sites). - assert not (tmp_path / "auto").exists() - - # - def test_2d_writes_nothing(self, tmp_path): - project, file = _baseline_setup(tmp_path, auto_export=False) - file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) - file.add_time_dependence( - target_model="single_glp", - target_parameter="GLP_01_A", - dynamics_yaml="models/file_time.yaml", - dynamics_model=["MonoExpPos"], - ) - file.fit_2d("single_glp", stages=1, try_ci=0) - - assert file.model_2d.result is not None - assert file.model_2d.result[1] != [] - assert any(slot.fit_type == "2d" for slot in project._fit_history) - assert not (tmp_path / "auto").exists() - - -# -class TestAutoExportTrueWritesFiles: - """Default behavior (``auto_export=True``) preserves on-disk side effects.""" - - # - def test_baseline_writes_files(self, tmp_path): - project, file = _baseline_setup(tmp_path, auto_export=True) - file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) - outputs = _list_files(tmp_path / "auto") - # At least one CSV/PNG should be auto-written by the baseline path. - assert outputs, "expected at least one auto-written file" - - # - def test_2d_auto_export_writes_slot_tree(self, tmp_path): - """fit_2d auto-export routes through the slot exporter: the grouped - ``//__2d/`` tree appears with the slot - artifacts (fit_2d.csv + observed_2d.csv + axis sidecars).""" - - project, file = _baseline_setup(tmp_path, auto_export=True) - file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) - file.add_time_dependence( - target_model="single_glp", - target_parameter="GLP_01_A", - dynamics_yaml="models/file_time.yaml", - dynamics_model=["MonoExpPos"], - ) - file.fit_2d("single_glp", stages=1, try_ci=0) - - slot_dir = tmp_path / "auto" / file.name / "single_glp__2d" - assert (slot_dir / "fit_2d.csv").exists() - assert (slot_dir / "observed_2d.csv").exists() - assert (slot_dir / "energy.csv").exists() - assert (slot_dir / "time.csv").exists() - - # - def test_2d_auto_export_overwrites_on_refit(self, tmp_path): - """Refitting the same (file, model, fit_type, selection) must not - raise FileExistsError — auto-export overwrites its own slot dir.""" - - project, file = _baseline_setup(tmp_path, auto_export=True) - file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) - file.add_time_dependence( - target_model="single_glp", - target_parameter="GLP_01_A", - dynamics_yaml="models/file_time.yaml", - dynamics_model=["MonoExpPos"], - ) - file.fit_2d("single_glp", stages=1, try_ci=0) - file.fit_2d("single_glp", stages=1, try_ci=0) # refit — must not raise - - slot_dir = tmp_path / "auto" / file.name / "single_glp__2d" - assert (slot_dir / "fit_2d.csv").exists() - - -# -class TestExplicitPathsStillWrite: - """Explicit save / export bypass ``auto_export`` entirely.""" - - # - def test_export_fits_writes_under_auto_export_false(self, tmp_path): - project, file = _baseline_setup(tmp_path, auto_export=False) - file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) - - # Auto path stayed silent. - assert not (tmp_path / "auto").exists() - - # Explicit CSV/PNG export still writes. - explicit_root = tmp_path / "explicit_csv" - project.export_fits(explicit_root, show_output=0) - assert _list_files(explicit_root), ( - "project.export_fits must write even with auto_export=False" - ) - - # - def test_save_fits_writes_under_auto_export_false(self, tmp_path): - project, file = _baseline_setup(tmp_path, auto_export=False) - file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) - - archive = tmp_path / "explicit.fit.h5" - project.save_fits(archive, show_output=0) - assert archive.exists() - assert archive.stat().st_size > 0 - - -# -class TestPlotHelperSkipped: - """``auto_export=False`` + silent mode must skip ``plt_fit_res_1d`` - entirely — not just suppress its save. Guards against future regressions - where figures get built and immediately closed (the SbS hot path is the - expensive case).""" - - # - def test_baseline_skips_plot_when_silent_and_no_export(self, tmp_path, monkeypatch): - mock = MagicMock() - monkeypatch.setattr(fitlib, "plt_fit_res_1d", mock) - - project, file = _baseline_setup(tmp_path, auto_export=False) - # show_output default in make_project is 0 (silent). - file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) - - assert mock.call_count == 0 - - # - def test_baseline_plots_when_verbose_even_without_export( - self, tmp_path, monkeypatch - ): - mock = MagicMock() - monkeypatch.setattr(fitlib, "plt_fit_res_1d", mock) - - truth_project = make_project(name="truth") - truth = _make_truth_file(truth_project) - data = simulate_noisy(truth.model_active, noise_level=0.01) - project = make_project(name="fit", auto_export=False, show_output=1) - project.path_results = tmp_path / "auto" - file = _make_fit_file(project, data, truth.energy, truth.time) - file.define_baseline( - time_start=0, time_stop=3, time_type="ind", show_plot=False - ) - file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) - - # The plot still runs so the user sees results inline; only the - # disk write was suppressed. - assert mock.call_count == 1 - - # - def test_fit_2d_silent_mode_prints_nothing(self, tmp_path, capsys): - """fit_2d honors show_output=0: no timing line, no params display. - - Regression: time_display and display(params) ran whenever - stages >= 1, regardless of show_output. - """ - - project, file = _baseline_setup(tmp_path, auto_export=False) - file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) - file.add_time_dependence( - target_model="single_glp", - target_parameter="GLP_01_A", - dynamics_yaml="models/file_time.yaml", - dynamics_model=["MonoExpPos"], - ) - capsys.readouterr() # drop setup output - file.fit_2d("single_glp", stages=1, try_ci=0) - assert capsys.readouterr().out == "" - - # - @pytest.mark.slow - def test_mcmc_silent_mode_no_output_no_figures(self, tmp_path, capsys, monkeypatch): - """MCMC honors silent mode: no progress banner, no figures built. - - Regression: the emcee progress banner and progress=True ran - unconditionally, and with show_output=0, save_output=0 the walker - and corner figures were built and reached _finalize_plot(0), - i.e. plt.show(), and were left open. With neither display nor - save requested the figures must not be constructed at all. - """ - - mock_corner = MagicMock() - monkeypatch.setattr(fitlib, "corner", mock_corner) - - project, file = _baseline_setup(tmp_path, auto_export=False) - mc = MC(use_mc=1, steps=20, nwalkers=32, burn=5, thin=1) - n_figs = len(plt.get_fignums()) - capsys.readouterr() # drop setup output - file.fit_baseline(model_name="single_glp", stages=1, try_ci=0, mc_settings=mc) - - # emcee prints its own short-chain autocorrelation notice for the - # deliberately tiny chain; only our banner is under test here - assert "Progress of lmfit.emcee" not in capsys.readouterr().out - assert len(plt.get_fignums()) == n_figs - assert mock_corner.corner.call_count == 0 - - # - def test_sbs_skips_per_slice_plot_when_no_export(self, tmp_path, monkeypatch): - mock = MagicMock() - monkeypatch.setattr(fitlib, "plt_fit_res_1d", mock) - - project, file = _baseline_setup(tmp_path, auto_export=False) - # spec_fun_str defaults to "fit_model_gir"; SbS does not lower, so - # use the interpreter path. n_workers=1 keeps the call in-process - # so the monkeypatch sees it. - project.spec_fun_str = "fit_model_mcp" - file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) - file.fit_slice_by_slice( - "single_glp", - n_workers=1, - seed_source="model", - seed_adapt=None, - try_ci=0, - ) - - assert mock.call_count == 0 - - -# -class TestVerboseDisplayWithoutExport: - """``show_output>=1`` + ``auto_export=False`` shows the data/fit/residual - maps inline (via the ``_display_*`` slot helpers) but writes no files — - the interactive-display path mirroring fit_baseline. Guards the - display/export split in fit_slice_by_slice / fit_2d.""" - - # - def _verbose_no_export_setup(self, tmp_path): - truth_project = make_project(name="truth") - truth = _make_truth_file(truth_project) - data = simulate_noisy(truth.model_active, noise_level=0.01) - project = make_project(name="fit", auto_export=False, show_output=1) - project.path_results = tmp_path / "auto" - file = _make_fit_file(project, data, truth.energy, truth.time) - file.define_baseline( - time_start=0, time_stop=3, time_type="ind", show_plot=False - ) - return project, file - - # - def test_sbs_displays_but_writes_nothing(self, tmp_path, monkeypatch): - # plt_fit_res_2d runs in the main process (after fitting), so the - # monkeypatch is visible regardless of worker path; n_workers=1 keeps - # the run cheap and deterministic. - mock_2d = MagicMock() - monkeypatch.setattr(fitlib, "plt_fit_res_2d", mock_2d) - - project, file = self._verbose_no_export_setup(tmp_path) - file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) - file.fit_slice_by_slice( - "single_glp", - n_workers=1, - seed_source="model", - seed_adapt=None, - try_ci=0, - ) - - # Display branch ran (maps shown) but nothing hit disk. - assert mock_2d.call_count == 1 - assert not (tmp_path / "auto").exists() - - # - def test_2d_displays_but_writes_nothing(self, tmp_path, monkeypatch): - mock_2d = MagicMock() - monkeypatch.setattr(fitlib, "plt_fit_res_2d", mock_2d) - - project, file = self._verbose_no_export_setup(tmp_path) - file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) - file.add_time_dependence( - target_model="single_glp", - target_parameter="GLP_01_A", - dynamics_yaml="models/file_time.yaml", - dynamics_model=["MonoExpPos"], - ) - file.fit_2d("single_glp", stages=1, try_ci=0) - - assert mock_2d.call_count == 1 - assert not (tmp_path / "auto").exists() diff --git a/tests/test_export_fits_parity.py b/tests/test_export_fits_parity.py index 2ac9f74..b6534e7 100644 --- a/tests/test_export_fits_parity.py +++ b/tests/test_export_fits_parity.py @@ -1,16 +1,11 @@ """ -Auto-export vs explicit ``Project.export_fits`` parity. - -Both paths route through the same slot exporter (``fit_io._export_slot``) -since the legacy ``_save_*_fit_legacy`` savers were removed: auto-export -inside ``fit_slice_by_slice`` / ``fit_2d`` writes the grouped slot tree -under ``Project.path_results``, and an explicit ``export_fits`` call -writes it under the caller's root. The artifacts must be identical — a -divergence means one of the paths grew its own writer again. - -Strategy: redirect ``path_results`` into ``tmp_path``, run the fit with -``auto_export=True`` (which writes the auto tree), then call -``project.export_fits`` into a sibling directory and diff the trees. +Explicit ``Project.export_fits`` determinism. + +Fits never write to disk (v0.14.0) — ``export_fits`` is the only CSV/PNG +writer, always fed from the persisted fit slots. Two exports of the same +history into different roots must therefore produce identical trees; a +divergence means the exporter grew run-dependent state (timestamps, +ordering, slot mutation) or a second writer crept back in. """ from __future__ import annotations @@ -62,16 +57,10 @@ def _truth_2d_data(): # def _make_parity_fit_file(*, name: str, tmp_path: Path, spec_fun_str: str): - """Build a fit-side project + file with auto-export redirected into - ``tmp_path / "auto"`` (auto-export writes the slot tree under - ``project.path_results``), so the test has full control over both - outputs and the source repo stays untouched. - """ + """Build a fit-side project + file for the export-determinism tests.""" data = _truth_2d_data() - # export parity is this test's subject, so opt back into auto-export - project = make_project(name=name, spec_fun_str=spec_fun_str, auto_export=True) - project.path_results = tmp_path / "auto" + project = make_project(name=name, spec_fun_str=spec_fun_str) file = File( parent_project=project, name="fit", @@ -91,9 +80,9 @@ def _make_parity_fit_file(*, name: str, tmp_path: Path, spec_fun_str: str): # @pytest.mark.slow def test_sbs_export_parity(tmp_path): - """SbS auto-export tree is identical to an explicit ``export_fits`` tree. + """Two explicit SbS ``export_fits`` runs produce identical trees. - Both must go through ``fit_io._export_slot``; the file sets and every + Both go through ``fit_io._export_slot``; the file sets and every shared artifact's values are compared. """ @@ -108,26 +97,27 @@ def test_sbs_export_parity(tmp_path): try_ci=0, ) - auto_dir = project.path_results / file.name / "single_glp__sbs" + project.export_fits(tmp_path / "first", show_output=0) + first_dir = tmp_path / "first" / file.name / "single_glp__sbs" new_root = tmp_path / "new" project.export_fits(new_root, show_output=0) new_dir = new_root / file.name / "single_glp__sbs" # --- identical artifact sets (both trees written by _export_slot) - auto_names = {p.name for p in auto_dir.rglob("*") if p.is_file()} + first_names = {p.name for p in first_dir.rglob("*") if p.is_file()} new_names = {p.name for p in new_dir.rglob("*") if p.is_file()} - assert auto_names == new_names - assert "fit_pars.csv" in auto_names - assert "fit_2d.csv" in auto_names + assert first_names == new_names + assert "fit_pars.csv" in first_names + assert "fit_2d.csv" in first_names # --- fit_pars.csv: per-slice param values with [index, time, par...] cols - auto_fp = pd.read_csv(auto_dir / "fit_pars.csv") + first_fp = pd.read_csv(first_dir / "fit_pars.csv") new_fp = pd.read_csv(new_dir / "fit_pars.csv") - assert list(auto_fp.columns) == list(new_fp.columns) - assert auto_fp.shape == new_fp.shape - for col in auto_fp.columns: + assert list(first_fp.columns) == list(new_fp.columns) + assert first_fp.shape == new_fp.shape + for col in first_fp.columns: np.testing.assert_allclose( - auto_fp[col].to_numpy(dtype=float), + first_fp[col].to_numpy(dtype=float), new_fp[col].to_numpy(dtype=float), rtol=0, atol=0, @@ -135,17 +125,17 @@ def test_sbs_export_parity(tmp_path): # --- fit_2d.csv: stacked per-slice fit spectra (n_time × n_energy), # both from the slot's captured ``fit`` array. - auto_2d = np.loadtxt(auto_dir / "fit_2d.csv", delimiter=project.delim) + first_2d = np.loadtxt(first_dir / "fit_2d.csv", delimiter=project.delim) new_2d = np.loadtxt(new_dir / "fit_2d.csv", delimiter=project.delim) - assert auto_2d.shape == new_2d.shape == (len(file.time), len(file.energy)) - np.testing.assert_allclose(auto_2d, new_2d, rtol=0, atol=0) + assert first_2d.shape == new_2d.shape == (len(file.time), len(file.energy)) + np.testing.assert_allclose(first_2d, new_2d, rtol=0, atol=0) # --- axis sidecars for name, axis in (("energy.csv", file.energy), ("time.csv", file.time)): - auto_ax = np.loadtxt(auto_dir / name, delimiter=project.delim) + first_ax = np.loadtxt(first_dir / name, delimiter=project.delim) new_ax = np.loadtxt(new_dir / name, delimiter=project.delim) - assert auto_ax.shape == new_ax.shape == (len(axis),) - np.testing.assert_array_equal(auto_ax, new_ax) + assert first_ax.shape == new_ax.shape == (len(axis),) + np.testing.assert_array_equal(first_ax, new_ax) # --------------------------------------------------------------------------- @@ -156,7 +146,7 @@ def test_sbs_export_parity(tmp_path): # @pytest.mark.slow def test_2d_export_parity(tmp_path): - """2D auto-export tree is identical to an explicit ``export_fits`` tree.""" + """Two explicit 2D ``export_fits`` runs produce identical trees.""" project, file = _make_parity_fit_file( name="parity_2d", tmp_path=tmp_path, spec_fun_str="fit_model_gir" @@ -171,30 +161,31 @@ def test_2d_export_parity(tmp_path): ) file.fit_2d("single_glp", stages=1, try_ci=0) - auto_dir = project.path_results / file.name / "single_glp__2d" + project.export_fits(tmp_path / "first", fit_type="2d", show_output=0) + first_dir = tmp_path / "first" / file.name / "single_glp__2d" new_root = tmp_path / "new" project.export_fits(new_root, fit_type="2d", show_output=0) new_dir = new_root / file.name / "single_glp__2d" # --- identical artifact sets - auto_names = {p.name for p in auto_dir.rglob("*") if p.is_file()} + first_names = {p.name for p in first_dir.rglob("*") if p.is_file()} new_names = {p.name for p in new_dir.rglob("*") if p.is_file()} - assert auto_names == new_names + assert first_names == new_names # --- fit_2d.csv (both from the slot's captured ``fit`` array) - auto_2d = np.loadtxt(auto_dir / "fit_2d.csv", delimiter=project.delim) + first_2d = np.loadtxt(first_dir / "fit_2d.csv", delimiter=project.delim) new_2d = np.loadtxt(new_dir / "fit_2d.csv", delimiter=project.delim) - assert auto_2d.shape == new_2d.shape == (len(file.time), len(file.energy)) - np.testing.assert_allclose(auto_2d, new_2d, rtol=0, atol=0) + assert first_2d.shape == new_2d.shape == (len(file.time), len(file.energy)) + np.testing.assert_allclose(first_2d, new_2d, rtol=0, atol=0) # --- axis sidecars (identical writers, identical inputs) for name in ("energy.csv", "time.csv"): - auto_ax = np.loadtxt(auto_dir / name, delimiter=project.delim) + first_ax = np.loadtxt(first_dir / name, delimiter=project.delim) new_ax = np.loadtxt(new_dir / name, delimiter=project.delim) - np.testing.assert_array_equal(auto_ax, new_ax) + np.testing.assert_array_equal(first_ax, new_ax) # --- residual-map PNG present in both - assert (auto_dir / "2D_data_fit_res.png").exists() + assert (first_dir / "2D_data_fit_res.png").exists() assert (new_dir / "2D_data_fit_res.png").exists() diff --git a/tests/test_fit_history.py b/tests/test_fit_history.py index aa5e052..4d41353 100644 --- a/tests/test_fit_history.py +++ b/tests/test_fit_history.py @@ -375,6 +375,40 @@ def test_sbs_slot_per_slice_metrics(self): assert slot.fit_settings["seed_values"] is None assert slot.fit_settings["stages"] == 1 + # + @pytest.mark.slow + def test_sbs_slot_records_explicit_dict_seed(self): + """Explicit dict seeds land in fit_settings as the normalized, + parameter-ordered float list (regression: the raw dict used to be + np.asarray()'d, which raised TypeError at slot capture).""" + + truth_project = make_project(name="truth") + truth = _make_truth_file(truth_project) + data = simulate_clean(truth.model_active) + + project = make_project(name="fit") + project.spec_fun_str = "fit_model_mcp" + file = _make_fit_file(project, data, truth.energy, truth.time) + model = file.model_active + seed_values = { + name: model.lmfit_pars[name].value for name in model.parameter_names + } + file.fit_slice_by_slice( + "single_glp", + n_workers=1, + seed_source="explicit", + seed_values=seed_values, + seed_adapt=None, + try_ci=0, + ) + + settings = project._fit_history[0].fit_settings + assert settings is not None # type guard + assert settings["seed_source"] == "explicit" + assert settings["seed_values"] == [ + float(seed_values[name]) for name in model.parameter_names + ] + # @pytest.mark.slow def test_sbs_slot_survives_seed_template_restoration(self): @@ -515,6 +549,15 @@ def test_baseline_slot_captures_mcmc(self, tmp_path): assert not mcmc_res.table.empty assert not mcmc_res.flatchain.empty + # plot_mcmc reproduces the fit-time diagnostics from the persisted + # payload — live history and loaded archive alike. + import matplotlib.pyplot as plt + + n_figs = len(plt.get_fignums()) + file.plot_mcmc(fit_type="baseline", show_plot=False) + loaded_results.plot_mcmc(file="fit", fit_type="baseline", show_plot=False) + assert len(plt.get_fignums()) == n_figs + # def test_baseline_slot_mcmc_none_when_mcmc_skipped(self): project, _ = _setup_baseline_fit() # try_ci=0, no MCMC @@ -1647,6 +1690,81 @@ def test_plot_residuals_uses_energy_axis_with_provider(self): assert fig.axes[1].get_xlabel() == "energy" +# +class TestPlotMcmc: + """FitResults.plot_mcmc renders diagnostics from the slot's mcmc payload + (synthetic slots here; the live-fit + loaded-archive path is covered in + TestMcmcPayload).""" + + # + @staticmethod + def _mcmc_results(*, with_acceptance=True): + import dataclasses + + n = 40 + flatchain = pd.DataFrame( + { + "GLP_01_A": np.linspace(0.9, 1.1, n), + "__lnsigma": np.linspace(-2.1, -1.9, n), + } + ) + ci = pd.DataFrame( + { + "par[v]/sigma[>]": ["GLP_01_A", "__lnsigma"], + "-1.0": [0.95, -2.05], + "best fit": [1.0, -2.0], + "+1.0": [1.05, -1.95], + } + ) + mcmc = { + "flatchain": flatchain, + "ci": ci, + "lnsigma": -2.0, + "acceptance_fraction": (np.full(8, 0.4) if with_acceptance else None), + } + return FitResults(slots=[dataclasses.replace(_slot_stub(), mcmc=mcmc)]) + + # + def test_renders_acceptance_and_corner(self): + import matplotlib.pyplot as plt + + results = self._mcmc_results() + plt.close("all") + results.plot_mcmc(file="f1", fit_type="baseline") # show under Agg + try: + assert len(plt.get_fignums()) == 2 + finally: + plt.close("all") + + # + def test_skips_acceptance_when_absent(self): + import matplotlib.pyplot as plt + + # schema-2 archives did not store acceptance_fraction. + results = self._mcmc_results(with_acceptance=False) + plt.close("all") + results.plot_mcmc(file="f1", fit_type="baseline") + try: + assert len(plt.get_fignums()) == 1 # corner only + finally: + plt.close("all") + + # + def test_show_plot_false_leaves_no_figures(self): + import matplotlib.pyplot as plt + + results = self._mcmc_results() + n_figs = len(plt.get_fignums()) + results.plot_mcmc(file="f1", fit_type="baseline", show_plot=False) + assert len(plt.get_fignums()) == n_figs + + # + def test_raises_without_mcmc_payload(self): + results = FitResults(slots=[_slot_stub()]) + with pytest.raises(ValueError, match="No MCMC results"): + results.plot_mcmc(file="f1", fit_type="baseline", show_plot=False) + + # class TestFitResultsPlotResiduals: """Smoke tests for FitResults.plot_residuals — figure construction only.""" diff --git a/tests/test_fit_side_effects.py b/tests/test_fit_side_effects.py new file mode 100644 index 0000000..5aef5c7 --- /dev/null +++ b/tests/test_fit_side_effects.py @@ -0,0 +1,404 @@ +"""Fits never write to disk (v0.14.0). + +fit_baseline / fit_spectrum / fit_slice_by_slice / fit_2d compute, display +(per ``show_output``), and capture fit slots — persistence is only ever +the explicit ``save_fits`` (HDF5) / ``export_fits`` (CSV/PNG) calls. The +write-nothing tests run each fit with the working directory pointed at an +empty ``tmp_path`` so any accidental relative-path write is caught. The +display/silent guardrail matrix (plot helpers skipped when silent, shown +when verbose, never written) lives here too, as does the loud failure on +removed ``project.yaml`` keys. +""" + +import pathlib +from unittest.mock import MagicMock + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np +import pytest +from _utils import simulate_noisy + +from trspecfit import File, Project, fitlib +from trspecfit.utils.lmfit import MC + +TESTS_DIR = pathlib.Path(__file__).resolve().parent + + +# +def _make_abs_project(*, name="fit", show_output=0): + """Project anchored at the tests dir by absolute path, so tests can + chdir into a tmp_path without breaking YAML/model resolution.""" + + project = Project(path=TESTS_DIR, name=name) + project.show_output = show_output + project.spec_fun_str = "fit_model_gir" + return project + + +# +def _make_truth_file(project): + 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, *, name="fit"): + file = File( + parent_project=project, + name=name, + data=data, + energy=energy.copy(), + time=time.copy(), + ) + file.load_model(model_yaml="models/file_energy.yaml", model_info="single_glp") + return file + + +# +def _list_files(root): + """Return the set of files (recursive) under ``root``; empty if missing.""" + + if not root.exists(): + return set() + return {p for p in root.rglob("*") if p.is_file()} + + +# +def _baseline_setup(tmp_path, monkeypatch, *, show_output=0): + """Build a fit-ready project/file and chdir into an empty tmp_path so + any file a fit method writes (absolute or relative) is detectable.""" + + truth_project = _make_abs_project(name="truth") + truth = _make_truth_file(truth_project) + data = simulate_noisy(truth.model_active, noise_level=0.01) + project = _make_abs_project(name="fit", show_output=show_output) + file = _make_fit_file(project, data, truth.energy, truth.time) + file.define_baseline(time_start=0, time_stop=3, time_type="ind", show_plot=False) + monkeypatch.chdir(tmp_path) + return project, file + + +# +def _add_dynamics(file): + file.add_time_dependence( + target_model="single_glp", + target_parameter="GLP_01_A", + dynamics_yaml="models/file_time.yaml", + dynamics_model=["MonoExpPos"], + ) + + +# +class TestDefaults: + # + def test_project_name_default_is_placeholder(self): + project = Project(path=TESTS_DIR, config_file=None) + assert project.name == "my_project" + + +# +class TestRemovedConfigKeys: + """Removed ``project.yaml`` keys fail loudly instead of being silently + ignored — an old config relying on them would otherwise change behavior + without a trace.""" + + # + @pytest.mark.parametrize("key", ["auto_export", "path_results"]) + def test_removed_key_raises(self, tmp_path, key): + (tmp_path / "project.yaml").write_text(f"{key}: false\n") + with pytest.raises(ValueError, match=f"'{key}' was removed"): + Project(path=tmp_path) + + +# +class TestFitsWriteNothing: + """Every fit method leaves the filesystem untouched while keeping the + in-memory state (``Model.result``, fit slots) intact.""" + + # + def test_baseline_writes_nothing(self, tmp_path, monkeypatch): + project, file = _baseline_setup(tmp_path, monkeypatch) + file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) + + assert file.model_base.result is not None + assert file.model_base.result[1] != [] + assert len(project._fit_history) == 1 + assert project._fit_history[0].fit_type == "baseline" + assert _list_files(tmp_path) == set() + + # + def test_spectrum_writes_nothing(self, tmp_path, monkeypatch): + project, file = _baseline_setup(tmp_path, monkeypatch) + file.fit_spectrum( + "single_glp", time_point=0, time_type="ind", stages=1, try_ci=0 + ) + + assert any(slot.fit_type == "spectrum" for slot in project._fit_history) + assert _list_files(tmp_path) == set() + + # + def test_sbs_writes_nothing(self, tmp_path, monkeypatch): + project, file = _baseline_setup(tmp_path, monkeypatch) + # SbS does not lower on the GIR path; use the interpreter. Serial + # (n_workers=1) keeps the run cheap and in-process. + project.spec_fun_str = "fit_model_mcp" + file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) + file.fit_slice_by_slice( + "single_glp", + n_workers=1, + seed_source="model", + seed_adapt=None, + try_ci=0, + ) + + assert any(slot.fit_type == "sbs" for slot in project._fit_history) + assert _list_files(tmp_path) == set() + + # + def test_2d_writes_nothing(self, tmp_path, monkeypatch): + project, file = _baseline_setup(tmp_path, monkeypatch) + file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) + _add_dynamics(file) + file.fit_2d("single_glp", stages=1, try_ci=0) + + assert file.model_2d.result is not None + assert file.model_2d.result[1] != [] + assert any(slot.fit_type == "2d" for slot in project._fit_history) + assert _list_files(tmp_path) == set() + + +# +class TestExplicitPathsStillWrite: + """``save_fits`` / ``export_fits`` are the only persistence paths.""" + + # + def test_export_fits_writes(self, tmp_path, monkeypatch): + project, file = _baseline_setup(tmp_path, monkeypatch) + file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) + + explicit_root = tmp_path / "explicit_csv" + project.export_fits(explicit_root, show_output=0) + assert _list_files(explicit_root) + + # + def test_save_fits_writes(self, tmp_path, monkeypatch): + project, file = _baseline_setup(tmp_path, monkeypatch) + file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) + + archive = tmp_path / "explicit.fit.h5" + project.save_fits(archive, show_output=0) + assert archive.exists() + assert archive.stat().st_size > 0 + + +# +class TestPlotHelperSkipped: + """Silent mode must skip ``plt_fit_res_1d`` entirely — not just + suppress its display. Guards against future regressions where figures + get built and immediately closed (the SbS hot path is the expensive + case).""" + + # + def test_baseline_skips_plot_when_silent(self, tmp_path, monkeypatch): + mock = MagicMock() + monkeypatch.setattr(fitlib, "plt_fit_res_1d", mock) + + project, file = _baseline_setup(tmp_path, monkeypatch) + # show_output defaults to 0 (silent) in _baseline_setup. + file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) + + assert mock.call_count == 0 + + # + def test_baseline_plots_when_verbose(self, tmp_path, monkeypatch): + mock = MagicMock() + monkeypatch.setattr(fitlib, "plt_fit_res_1d", mock) + + project, file = _baseline_setup(tmp_path, monkeypatch, show_output=1) + file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) + + # The plot runs so the user sees results inline; nothing hits disk. + assert mock.call_count == 1 + assert _list_files(tmp_path) == set() + + # + def test_fit_2d_silent_mode_prints_nothing(self, tmp_path, monkeypatch, capsys): + """fit_2d honors show_output=0: no timing line, no params display. + + Regression: time_display and display(params) ran whenever + stages >= 1, regardless of show_output. + """ + + project, file = _baseline_setup(tmp_path, monkeypatch) + file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) + _add_dynamics(file) + capsys.readouterr() # drop setup output + file.fit_2d("single_glp", stages=1, try_ci=0) + assert capsys.readouterr().out == "" + + # + @pytest.mark.slow + def test_mcmc_silent_mode_no_output_no_figures(self, tmp_path, capsys, monkeypatch): + """MCMC honors silent mode: no progress banner, no figures built. + + Regression: the emcee progress banner and progress=True ran + unconditionally, and with show_output=0 the walker and corner + figures were built, shown, and left open. When silent the figures + must not be constructed at all. + """ + + mock_corner = MagicMock() + monkeypatch.setattr(fitlib, "corner", mock_corner) + + project, file = _baseline_setup(tmp_path, monkeypatch) + mc = MC(use_mc=1, steps=20, nwalkers=32, burn=5, thin=1) + n_figs = len(plt.get_fignums()) + capsys.readouterr() # drop setup output + file.fit_baseline(model_name="single_glp", stages=1, try_ci=0, mc_settings=mc) + + # emcee prints its own short-chain autocorrelation notice for the + # deliberately tiny chain; only our banner is under test here + assert "Progress of lmfit.emcee" not in capsys.readouterr().out + assert len(plt.get_fignums()) == n_figs + assert mock_corner.corner.call_count == 0 + assert _list_files(tmp_path) == set() + + # + def test_sbs_never_plots_per_slice_during_fit(self, tmp_path, monkeypatch): + """The SbS fit loop builds no per-slice figures; per-slice panels + are on-demand via File.plot_sbs_slices.""" + + mock = MagicMock() + monkeypatch.setattr(fitlib, "plt_fit_res_1d", mock) + + project, file = _baseline_setup(tmp_path, monkeypatch) + # spec_fun_str defaults to "fit_model_gir"; SbS does not lower, so + # use the interpreter path. n_workers=1 keeps the call in-process + # so the monkeypatch sees it. + project.spec_fun_str = "fit_model_mcp" + file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) + file.fit_slice_by_slice( + "single_glp", + n_workers=1, + seed_source="model", + seed_adapt=None, + try_ci=0, + ) + + assert mock.call_count == 0 + + +# +class TestPlotSbsSlices: + """File.plot_sbs_slices: on-demand per-slice diagnostics from the live + SbS fit state — display-only by default, PNGs only on explicit + ``save_path``.""" + + # + def _sbs_fit(self, tmp_path, monkeypatch): + project, file = _baseline_setup(tmp_path, monkeypatch) + project.spec_fun_str = "fit_model_mcp" + file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) + file.fit_slice_by_slice( + "single_glp", + n_workers=1, + seed_source="model", + seed_adapt=None, + try_ci=0, + ) + return project, file + + # + def test_display_only_writes_nothing(self, tmp_path, monkeypatch): + _, file = self._sbs_fit(tmp_path, monkeypatch) + plt.close("all") + file.plot_sbs_slices(slices=[0, 1]) # show under Agg + try: + assert len(plt.get_fignums()) == 2 + assert _list_files(tmp_path) == set() + finally: + plt.close("all") + + # + def test_save_path_writes_one_png_per_slice(self, tmp_path, monkeypatch): + project, file = self._sbs_fit(tmp_path, monkeypatch) + out = tmp_path / "slices" + file.plot_sbs_slices(slices=[0, 2], save_path=out, show_plot=False) + expected = {str(project.da_slices_fmt % s) + ".png" for s in (0, 2)} + assert {p.name for p in out.iterdir()} == expected + + # + def test_raises_without_live_results(self, tmp_path, monkeypatch): + _, file = _baseline_setup(tmp_path, monkeypatch) + with pytest.raises(ValueError, match="fit_slice_by_slice"): + file.plot_sbs_slices(show_plot=False) + + # + def test_raises_on_model_mismatch(self, tmp_path, monkeypatch): + _, file = self._sbs_fit(tmp_path, monkeypatch) + with pytest.raises(ValueError, match="most recent"): + file.plot_sbs_slices(model="other_model", show_plot=False) + + # + def test_raises_on_out_of_range_slice(self, tmp_path, monkeypatch): + _, file = self._sbs_fit(tmp_path, monkeypatch) + with pytest.raises(ValueError, match="out of range"): + file.plot_sbs_slices(slices=[9999], show_plot=False) + + +# +class TestVerboseDisplay: + """``show_output>=1`` shows the data/fit/residual maps inline via the + plot API but writes no files — guards the display path in + fit_slice_by_slice / fit_2d.""" + + # + def test_sbs_displays_but_writes_nothing(self, tmp_path, monkeypatch): + # plt_fit_res_2d runs in the main process (after fitting), so the + # monkeypatch is visible regardless of worker path; n_workers=1 keeps + # the run cheap and deterministic. + mock_2d = MagicMock() + monkeypatch.setattr(fitlib, "plt_fit_res_2d", mock_2d) + + project, file = _baseline_setup(tmp_path, monkeypatch, show_output=1) + file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) + file.fit_slice_by_slice( + "single_glp", + n_workers=1, + seed_source="model", + seed_adapt=None, + try_ci=0, + ) + + # Display branch ran (maps shown) but nothing hit disk. + assert mock_2d.call_count == 1 + assert _list_files(tmp_path) == set() + + # + def test_2d_displays_but_writes_nothing(self, tmp_path, monkeypatch): + mock_2d = MagicMock() + monkeypatch.setattr(fitlib, "plt_fit_res_2d", mock_2d) + + project, file = _baseline_setup(tmp_path, monkeypatch, show_output=1) + file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) + _add_dynamics(file) + file.fit_2d("single_glp", stages=1, try_ci=0) + + assert mock_2d.call_count == 1 + assert _list_files(tmp_path) == set() diff --git a/tests/test_project_fit.py b/tests/test_project_fit.py index 7a49e9b..ef3269a 100644 --- a/tests/test_project_fit.py +++ b/tests/test_project_fit.py @@ -602,17 +602,10 @@ def test_fit_history_populated_after_project_fit(self): # @pytest.mark.slow def test_num_fmt_and_delim_propagate_to_csv_outputs(self, tmp_path): - """Custom num_fmt/delim on the Project flow into fit-CSV writes. + """Custom num_fmt/delim on the Project flow into explicit CSV writes + (``save_baseline_fit`` -> ``fit_1d.csv``).""" - Covers two pandas ``to_csv`` paths exercised by fit_baseline: - - fit_wrapper -> ``{model}_par_fin.csv`` - - save_baseline_fit -> ``fit_1d.csv`` - """ - - # the exported CSVs are this test's subject, so opt into auto-export - # and redirect the output tree into tmp_path for xdist isolation - project = make_project(name="num_fmt_test", auto_export=True) - project.path_results = tmp_path + project = make_project(name="num_fmt_test") project.num_fmt = "%.3f" project.delim = ";" @@ -621,21 +614,13 @@ def test_num_fmt_and_delim_propagate_to_csv_outputs(self, tmp_path): _make_fit_file(project, clean, truth.energy, truth.time, name="file_fmt") f = project.files[0] - base_dir = project.path_results / f.name / "baseline" / "project_glp_base" - - # fit_wrapper writes _par_fin.csv via pandas to_csv - par_fin_lines = ( - (base_dir / "project_glp_base_par_fin.csv").read_text().splitlines() - ) - assert ";" in par_fin_lines[0] # custom delimiter on header - value_field = par_fin_lines[1].split(";")[1] - # %.3f -> fixed-point; %.6e fallback would contain 'e' - assert "." in value_field and "e" not in value_field.lower(), value_field + base_dir = tmp_path / "base" + f.save_baseline_fit(save_path=base_dir) - # save_baseline_fit writes fit_1d.csv via pandas to_csv fit_1d_lines = (base_dir / "fit_1d.csv").read_text().splitlines() assert fit_1d_lines[0].startswith("energy;sum;") energy_field = fit_1d_lines[1].split(";")[0] + # %.3f -> fixed-point; %.6e fallback would contain 'e' assert "." in energy_field and "e" not in energy_field.lower(), energy_field @@ -814,7 +799,6 @@ def _make_shared_tau_project(*, spec_fun_str, grids=None, show_output=0): project = make_project( name=f"jax_project_{spec_fun_str}", spec_fun_str=spec_fun_str, - auto_export=False, ) for i, ((energy, time_ax), amplitude, seed) in enumerate( zip(grids, amplitudes, seeds, strict=True) From 1727cf6595b090b9776cb3a50330f6f7c7908e44 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Fri, 17 Jul 2026 21:39:01 -0700 Subject: [PATCH 16/29] replace the raw 5-list fit result with a typed FitOutput --- CHANGELOG.md | 1 + PLAN.md | 25 +++-- docs/api/fitlib.rst | 4 + docs/design/lowered_evaluator.md | 2 +- docs/design/repo_architecture.md | 7 +- src/trspecfit/fitlib.py | 50 +++++---- src/trspecfit/mcp.py | 2 +- src/trspecfit/trspecfit.py | 176 +++++++++++++++---------------- src/trspecfit/utils/lmfit.py | 101 +++++++++++++++--- src/trspecfit/utils/sbs.py | 8 +- tests/roundtrip/test_focused.py | 4 +- tests/roundtrip/workflows.py | 11 +- tests/test_evaluate_jax.py | 3 +- tests/test_file.py | 15 ++- tests/test_fit_history.py | 2 +- tests/test_fit_side_effects.py | 4 +- tests/test_fit_validation.py | 14 +-- tests/test_gir_integration.py | 22 ++-- tests/test_mcp_library.py | 23 ++-- 19 files changed, 294 insertions(+), 180 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bded00..b93c5b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ This file is maintained using the shared changelog workflow in - **Breaking: fits never write to disk.** `fit_baseline` / `fit_spectrum` / `fit_slice_by_slice` / `fit_2d` and `Project.fit_2d` compute, display (per `show_output`), and record fit slots — persistence is always an explicit `save_fits` (HDF5 archive) or `export_fits` (grouped CSV/PNG tree) call, both fed from the slot history, with the single default output root `./fit_results/{Project.name}/`. Everything the automatic writes used to produce is reproducible: parameter tables, confidence intervals, and MCMC chains are persisted in the slot and exported by `export_fits`; the MCMC walker/corner PNGs via `plot_mcmc`; the SbS per-slice PNGs via `plot_sbs_slices`; the component-decomposed `fit_1d.csv` via `save_baseline_fit` / `save_spectrum_fit` (kept, no longer auto-called). Accepted losses: per-slice `par_ini` CSVs (re-derivable from the persisted `fit_settings` seeding recipe) and the `lmfit.fit_report` text dumps (all their contents are persisted). Interactive display renders from the captured fit slot, so the figure shown equals the figure the plot API reproduces later; `Project.fit_2d` / `Project.fit_baselines` show per-file plots instead of PNGs read back from disk. - **Breaking: `File.get_correlations` raises for covariance-less fits** (e.g. Nelder without numdifftools, project joint fits) instead of returning an identity-with-zeros matrix that misread as "uncorrelated". - **Breaking: `Project.name` defaults to `"my_project"`** (was `"test"`), so a bare `save_fits()` / `export_fits()` lands in a clearly-placeholder `fit_results/my_project/` instead of colliding with test-suite naming. +- **Breaking (advanced API): fit results are a typed `FitOutput` object.** `fitlib.fit_wrapper` returns a frozen `FitOutput` dataclass (fields `par_ini`, `par_fin`, `conf_ci`, `emcee_fin`, `emcee_ci`) instead of the raw five-element list, and `Model.result` / the per-slice entries of `File.results_sbs` hold it. Positional indexing (`model.result[1].params`) becomes attribute access (`model.result.par_fin.params`); an unfitted model's `result` is now `None` instead of `[]`, and a skipped MCMC yields `emcee_fin=None`. `Project.fit_2d`'s per-file stand-in result is a real (minimal) `lmfit` `MinimizerResult` instead of a `SimpleNamespace`. The persisted `SavedFitSlot` record and all `FitResults` accessors are unchanged. - `fitlib.results_to_df` and `fitlib.results_to_fit_2d` are pure conversions: the CSV writes, per-parameter plotting, and `save_df`/`save_2d` flags were removed along with the legacy save path that used them. ### Removed diff --git a/PLAN.md b/PLAN.md index ab8ea71..143acf6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -119,18 +119,23 @@ and complete the same principle, in the same breaking release (0.14.0). ## Phase 8 — typed fit-result object (internal) -- [ ] Introduce a small class (e.g. `FitOutcome`, name TBD at - implementation; `fitlib` or `utils/lmfit.py`) with named fields - `par_ini`, `par_fin`, `conf_ci`, `emcee_fin`, `emcee_ci` replacing - the raw 5-list. `fit_wrapper` returns it. -- [ ] Update all internal consumers: the four fit methods, +- [x] Introduce a small class (named `FitOutput` at implementation; in + `utils/lmfit.py` — `fitlib` imports `spectra`→`mcp`, so the class + lives below both) with named fields `par_ini`, `par_fin`, + `conf_ci`, `emcee_fin`, `emcee_ci` replacing the raw 5-list. + `fit_wrapper` returns it. Frozen dataclass; `par_fin` is annotated + via a TYPE_CHECKING-only `TypedMinimizerResult` shim (lmfit sets + result attributes dynamically, invisible to pyright). +- [x] Update all internal consumers: the four fit methods, `_append_*_slot` capture, `results_sbs` per-slice entries, the MCMC-payload builder, and `Project.fit_2d`'s `SimpleNamespace` - stand-in (becomes a real `FitOutcome`). -- [ ] No list-index back-compat: verify by grep that nothing outside the - package (notebooks, docs) indexes `model.result[...]` or - `results_sbs[i][...]`; update mocked-result tests. -- [ ] Closes the "Unified results object / raw `result[1..4]` cleanup" + stand-in (now a real `FitOutput` wrapping a minimal + `MinimizerResult`). +- [x] No list-index back-compat: verified by grep that nothing outside + the package (notebooks, docs) indexes `model.result[...]` or + `results_sbs[i][...]`; mocked-result test (`test_file.py`) now + builds a placeholder `FitOutput`. +- [x] Closes the "Unified results object / raw `result[1..4]` cleanup" TODO item. ## Completion diff --git a/docs/api/fitlib.rst b/docs/api/fitlib.rst index 4312adc..0564d66 100644 --- a/docs/api/fitlib.rst +++ b/docs/api/fitlib.rst @@ -4,4 +4,8 @@ Fitting Module .. automodule:: trspecfit.fitlib :members: :undoc-members: + :show-inheritance: + +.. autoclass:: trspecfit.utils.lmfit.FitOutput + :members: :show-inheritance: \ No newline at end of file diff --git a/docs/design/lowered_evaluator.md b/docs/design/lowered_evaluator.md index f09e232..a817bf1 100644 --- a/docs/design/lowered_evaluator.md +++ b/docs/design/lowered_evaluator.md @@ -942,7 +942,7 @@ Implemented behavior: - `File.fit_2d`, `File.fit_baseline`, and `File.fit_spectrum` build a graph / plan when `spec_fun_str` is `fit_model_gir` or `fit_model_compare`. -- After fitting, all three methods write `result[1].params` back +- After fitting, all three methods write `result.par_fin.params` back into `model.lmfit_pars` via `par_extract` + `update_value`, because `fit_wrapper` optimizes a deepcopy and the GIR path does not mutate model state on every residual call. diff --git a/docs/design/repo_architecture.md b/docs/design/repo_architecture.md index 16a66d6..278f57f 100644 --- a/docs/design/repo_architecture.md +++ b/docs/design/repo_architecture.md @@ -112,8 +112,11 @@ fit setup). The fitting machinery: residual function, `fit_wrapper` (global + local solvers), confidence intervals via `lmfit.conf_interval`, MCMC via `lmfit.emcee`, and the 1D/2D fit-result plotting (`plt_fit_res_1d`, -`plt_fit_res_2d`). Internal module — method docstrings stay minimal, -module-level doc carries the weight. +`plt_fit_res_2d`). `fit_wrapper` returns a typed +`utils.lmfit.FitOutput` (`par_ini` / `par_fin` / `conf_ci` / +`emcee_fin` / `emcee_ci`), which is what `Model.result` and the +per-slice entries of `File.results_sbs` hold. Internal module — method +docstrings stay minimal, module-level doc carries the weight. ### `simulator.py` — synthetic data generation diff --git a/src/trspecfit/fitlib.py b/src/trspecfit/fitlib.py index 88d6f45..a9c04cd 100644 --- a/src/trspecfit/fitlib.py +++ b/src/trspecfit/fitlib.py @@ -578,7 +578,7 @@ def fit_wrapper( fit_alg_2: str = "leastsq", jac_fun: Callable[..., np.ndarray] | None = None, show_output: int = 0, -) -> list[Any]: +) -> ulmfit.FitOutput: """ Comprehensive fitting wrapper with optimization, CI, and MCMC. @@ -659,19 +659,18 @@ def fit_wrapper( Returns ------- - list - Five-element list containing results: - [par_ini, par_fin, conf_ci, emcee_fin, emcee_ci] + ulmfit.FitOutput + Typed result container with fields: - **par_ini** (*lmfit.Parameters*) -- Initial parameter guess. - - **par_fin** (*lmfit.MinimizerResult or []*) -- Final fit result + - **par_fin** (*lmfit.MinimizerResult*) -- Final fit result from lmfit.minimize. - **conf_ci** (*pd.DataFrame*) -- Confidence intervals from lmfit.conf_interval. Columns: ``['par[v]/sigma[>]', '-3σ', '-2σ', '-1σ', 'best', '+1σ', '+2σ', '+3σ']``. Empty DataFrame if CI not calculated/failed. - - **emcee_fin** (*lmfit.MinimizerResult or []*) -- MCMC result - from lmfit.emcee. Empty list if MCMC not used. + - **emcee_fin** (*lmfit.MinimizerResult or None*) -- MCMC result + from lmfit.emcee. None if MCMC not used. - **emcee_ci** (*pd.DataFrame*) -- MCMC confidence intervals from quantiles of flatchain. Same column structure as conf_ci; one row per sampled parameter (varying model params + the @@ -691,7 +690,7 @@ def fit_wrapper( ... stages=1, ... show_output=1 ... ) - >>> par_ini, par_fin, conf_ci, emcee_fin, emcee_ci = results + >>> results.par_fin.params # optimized parameters >>> # Two-stage fit with confidence intervals >>> results = fit_wrapper( @@ -857,9 +856,10 @@ def _method_kws(method: str) -> dict[str, Any]: t_emcee0 = time.time() # deepcopy first: __lnsigma is an MCMC sampling construct, not a model # parameter. _result_params returns the live par_fin.params (stored as - # result[1] and consumed downstream as the model-only fit result), so - # adding __lnsigma in place would leak it into every consumer of that - # result (display, get_fit_results, SbS tables). emcee gets the copy. + # FitOutput.par_fin and consumed downstream as the model-only fit + # result), so adding __lnsigma in place would leak it into every + # consumer of that result (display, get_fit_results, SbS tables). + # emcee gets the copy. par_fin_params = copy.deepcopy(_result_params(par_fin)) par_fin_params.add( "__lnsigma", @@ -980,7 +980,15 @@ def _method_kws(method: str) -> dict[str, Any]: emcee_fin = None emcee_ci = pd.DataFrame() - return [par_ini, par_fin, conf_ci, emcee_fin, emcee_ci] + # cast: lmfit sets result attributes dynamically, so its returns are + # opaque to type checkers — TypedMinimizerResult declares what we read + return ulmfit.FitOutput( + par_ini=par_ini, + par_fin=cast("ulmfit.TypedMinimizerResult", par_fin), + conf_ci=conf_ci, + emcee_fin=cast("ulmfit.TypedMinimizerResult | None", emcee_fin), + emcee_ci=emcee_ci, + ) # @@ -990,7 +998,7 @@ def _method_kws(method: str) -> dict[str, Any]: # def results_to_df( - results: list[Any], + results: list[ulmfit.FitOutput], x: ArrayLike | None = None, index: ArrayLike | None = None, config: PlotConfig | None = None, @@ -1005,9 +1013,8 @@ def results_to_df( Parameters ---------- - results : list - List of fit results from fit_wrapper, one per time slice. - Each element: [par_ini, par_fin, conf_ci, emcee_fin, emcee_ci] + results : list of ulmfit.FitOutput + Fit results from fit_wrapper, one per time slice. x : array-like, optional Time axis values. If provided, included as column in DataFrame. index : array-like, optional @@ -1043,7 +1050,7 @@ def results_to_df( # def results_to_fit_2d( - results: list[Any] | pd.DataFrame, + results: list[ulmfit.FitOutput] | pd.DataFrame, const: tuple[Any, ...], args: tuple[Any, ...], parameter_names: list[str] | None = None, @@ -1060,11 +1067,10 @@ def results_to_fit_2d( Parameters ---------- - results : list or pd.DataFrame + results : list of ulmfit.FitOutput or pd.DataFrame Fit results, either: - - list: Output from fit_wrapper for each slice. - Each element: ``[par_ini, par_fin, conf_ci, emcee_fin, emcee_ci]`` + - list: ``fit_wrapper`` output for each slice. - pd.DataFrame: From results_to_df() with parameters as columns parameter_names : list of str, optional @@ -1109,7 +1115,7 @@ def results_to_fit_2d( if isinstance(results, list): lst.append( residual_fun( - results[i][1].params, + results[i].par_fin.params, x_const, np.asarray(data_const), fit_fun_const, @@ -1181,7 +1187,7 @@ def plt_fit_res_1d( par_fin : lmfit.MinimizerResult or lmfit.Parameters or list Final fit parameters: - - lmfit.MinimizerResult: From fit_wrapper result[1] + - lmfit.MinimizerResult: From fit_wrapper (``FitOutput.par_fin``) - lmfit.Parameters: Manual parameter object - list: Empty list shows initial guess only (no final fit) diff --git a/src/trspecfit/mcp.py b/src/trspecfit/mcp.py index 54bc8a1..63fbd31 100644 --- a/src/trspecfit/mcp.py +++ b/src/trspecfit/mcp.py @@ -197,7 +197,7 @@ def __init__(self, model_name: str = "test") -> None: # fit parameters and results self.const: tuple | None = None self.args: tuple | None = None - self.result: list = [] + self.result: ulmfit.FitOutput | None = None # ATTRIBUTES THAT SHOULD BE INHERITED FROM A PARENT ENTITY WHEN LOADING MODEL self.parent_file: Any | None = None # parent reference # self.data = None # (currently) not necessary diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index cd3a501..e11abd6 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -58,7 +58,6 @@ import pathlib import re import time -import types import warnings from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Literal, cast, overload @@ -72,6 +71,7 @@ import numpy as np import pandas as pd from IPython.display import display +from lmfit.minimizer import MinimizerResult from ruamel.yaml import YAML from tqdm import tqdm @@ -211,7 +211,7 @@ def __init__( self._config_file: PathLike | None = None self.files: list[File] = [] - self._project_fit_result: list[Any] | None = None + self._project_fit_result: ulmfit.FitOutput | None = None # Append-only log of completed fit slots, populated eagerly at fit # completion by the _slot_from_ helpers in utils/fit_io.py. self._fit_history: list[fit_io.SavedFitSlot] = [] @@ -1458,41 +1458,42 @@ def fit_2d( # get_fit_results("2d") work on project-fitted files. mapping = project_fit_info["mapping"] models = project_fit_info["models"] - joint_result = result[1] if result[1] else None - if joint_result: - final_pars = joint_result.params - for proj_name, file_idx, local_name in mapping: - if proj_name in final_pars: - models[file_idx].lmfit_pars[local_name].value = final_pars[ - proj_name - ].value + joint_result = result.par_fin + final_pars = joint_result.params + for proj_name, file_idx, local_name in mapping: + if proj_name in final_pars: + models[file_idx].lmfit_pars[local_name].value = final_pars[ + proj_name + ].value # method/nvarys come from the joint optimizer; per-file stderr/CI are # absent by design (joint covariance does not decompose cleanly per # file). conf_ci is an empty DataFrame so _append_2d_slot's - # `result[2].empty` check works without branching. - joint_method = ( - getattr(joint_result, "method", "unknown") if joint_result else "unknown" - ) - joint_nvarys = int(getattr(joint_result, "nvarys", 0)) if joint_result else 0 + # `conf_ci.empty` check works without branching. + joint_method = str(getattr(joint_result, "method", "unknown")) + joint_nvarys = int(getattr(joint_result, "nvarys", 0)) for f, model in zip(self.files, models, strict=True): f.model_2d = model assert model is not None # type guard assert f.energy is not None and f.data is not None # type guard - # Length-5 list mirrors fit_wrapper's [par_ini, par_fin, conf_ci, - # emcee_fin, emcee_ci]. Project fits do not run per-file MCMC, so - # emcee slots are inert (None / empty DataFrame). - model.result = [ - None, - types.SimpleNamespace( - params=model.lmfit_pars, - method=joint_method, - nvarys=joint_nvarys, + # Per-file FitOutput whose par_fin is a minimal MinimizerResult + # (params/method/nvarys only, no covar): the joint optimization + # has no per-file initial guess, and project fits do not run + # per-file MCMC, so those fields are inert (None / empty). + model.result = ulmfit.FitOutput( + par_ini=None, + par_fin=cast( + "ulmfit.TypedMinimizerResult", + MinimizerResult( + params=model.lmfit_pars, + method=joint_method, + nvarys=joint_nvarys, + ), ), - pd.DataFrame(), - None, - pd.DataFrame(), - ] + conf_ci=pd.DataFrame(), + emcee_fin=None, + emcee_ci=pd.DataFrame(), + ) # const/args mirror File.fit_2d so _append_2d_slot can evaluate # the per-file fit grid via fitlib.residual_fun. Per-file # re-evaluation always uses the interpreter — the fused JAX @@ -1505,19 +1506,17 @@ def fit_2d( # Append a per-file 2D slot to Project._fit_history so the joint fit # is discoverable via Project.results, matching the File.fit_2d() # entry point. - slots_2d: list[fit_io.SavedFitSlot | None] = [] - if joint_result: - joint_fit_settings = fit_io.build_fit_settings( - stages=stages, fit_wrapper_kwargs=fit_wrapper_kwargs + joint_fit_settings = fit_io.build_fit_settings( + stages=stages, fit_wrapper_kwargs=fit_wrapper_kwargs + ) + slots_2d: list[fit_io.SavedFitSlot | None] = [ + f._append_2d_slot( + model_name=model_name, + fit_fun_str="fit_model_mcp", + fit_settings=joint_fit_settings, ) - for f in self.files: - slots_2d.append( - f._append_2d_slot( - model_name=model_name, - fit_fun_str="fit_model_mcp", - fit_settings=joint_fit_settings, - ) - ) + for f in self.files + ] if self.show_output >= 1: fitlib.time_display( @@ -1608,7 +1607,7 @@ class File: Model used for baseline fitting model_sbs : Model or None Model used for Slice-by-Slice fitting - results_sbs : list + results_sbs : list of ulmfit.FitOutput Slice-by-Slice fit results for all time slices model_spec : Model or None Model used for individual spectrum fitting @@ -1710,7 +1709,7 @@ def __init__( self.model_sbs: mcp.Model | None = None self.model_2d: mcp.Model | None = None # all Slice-by-Slice fit results (different from model_sbs.result) - self.results_sbs: list = [] + self.results_sbs: list[ulmfit.FitOutput] = [] # self.model_spec: mcp.Model | None = None # model for individual spectrum fit self.data_spec: np.ndarray | None = None # extracted 1D spectrum @@ -2666,7 +2665,7 @@ def fit_baseline( _args = self._build_1d_dispatch_args(self.model_base, _fun_str) self.model_base.args = _args # fit (optionally) with confidence intervals - self.model_base.result = fitlib.fit_wrapper( + fit_out = fitlib.fit_wrapper( const=self.model_base.const, args=self.model_base.args, par_names=self.model_base.parameter_names, @@ -2675,15 +2674,14 @@ def fit_baseline( show_output=1 if self.p.show_output >= 1 else 0, **lmfit_wrapper_kwargs, ) + self.model_base.result = fit_out # Write optimized values back to model.lmfit_pars. fit_wrapper # optimizes a deepcopy, so model.lmfit_pars may be stale when # the GIR path was used (it never calls model.update_value). - if stages >= 1 and self.model_base.result[1] != []: + if stages >= 1: self.model_base.update_value( - new_par_values=ulmfit.par_extract( - self.model_base.result[1], return_type="list" - ) + new_par_values=ulmfit.par_extract(fit_out.par_fin, return_type="list") ) self._append_baseline_slot( model_name=model_name, @@ -2705,7 +2703,7 @@ def fit_baseline( y=self.data_base, fit_fun_str=self.p.spec_fun_str, par_init=initial_guess, - par_fin=self.model_base.result[1], + par_fin=fit_out.par_fin, args=self.model_base.args, plot_sum=False, show_init=True, @@ -2720,7 +2718,7 @@ def fit_baseline( fitlib.time_display( t_start=t_base, print_str="Time elapsed for baseline fit: " ) - display(self.model_base.result[1].params) # display final pars below figure + display(fit_out.par_fin.params) # display final pars below figure # def _save_1d_fit(self, model: mcp.Model | None, save_path: PathLike) -> None: @@ -2733,8 +2731,8 @@ def _save_1d_fit(self, model: mcp.Model | None, save_path: PathLike) -> None: if model is None or self.energy is None: raise ValueError("Model/energy missing; nothing to save.") - if not model.result or not getattr(model.result[1], "params", None): - return # mocked / placeholder; nothing to dump + if model.result is None or not getattr(model.result.par_fin, "params", None): + return # unfitted or mocked / placeholder; nothing to dump model.create_value_1d(store_1d=1) if model.value_1d is None: raise ValueError( @@ -2893,7 +2891,7 @@ def fit_spectrum( _args = self._build_1d_dispatch_args(self.model_spec, _fun_str) self.model_spec.args = _args # fit - self.model_spec.result = fitlib.fit_wrapper( + fit_out = fitlib.fit_wrapper( const=self.model_spec.const, args=self.model_spec.args, par_names=self.model_spec.parameter_names, @@ -2902,13 +2900,12 @@ def fit_spectrum( show_output=1 if self.p.show_output >= 1 else 0, **lmfit_wrapper_kwargs, ) + self.model_spec.result = fit_out # Write optimized values back to model.lmfit_pars (see fit_baseline). - if stages >= 1 and self.model_spec.result[1] != []: + if stages >= 1: self.model_spec.update_value( - new_par_values=ulmfit.par_extract( - self.model_spec.result[1], return_type="list" - ) + new_par_values=ulmfit.par_extract(fit_out.par_fin, return_type="list") ) self._append_spectrum_slot( model_name=model_name, @@ -2939,7 +2936,7 @@ def fit_spectrum( y=self.data_spec, fit_fun_str=self.p.spec_fun_str, par_init=initial_guess, - par_fin=self.model_spec.result[1], + par_fin=fit_out.par_fin, args=self.model_spec.args, plot_sum=False, show_init=True, @@ -2954,7 +2951,7 @@ def fit_spectrum( fitlib.time_display( t_start=t_spec, print_str="Time elapsed for spectrum fit: " ) - display(self.model_spec.result[1].params) + display(fit_out.par_fin.params) # def save_spectrum_fit(self, save_path: PathLike) -> None: @@ -3123,7 +3120,7 @@ def fit_slice_by_slice( if seed_adapt not in (None, "argmax_shift"): raise ValueError("seed_adapt must be None or 'argmax_shift'.") if seed_source == "baseline" and ( - self.model_base is None or not self.model_base.result + self.model_base is None or self.model_base.result is None ): raise ValueError( "Baseline seed requested but baseline model is not fitted yet; " @@ -3145,8 +3142,9 @@ def fit_slice_by_slice( ) elif seed_source == "baseline": assert self.model_base is not None # type guard + assert self.model_base.result is not None # type guard seed_template = ulmfit.par_extract( - self.model_base.result[1], return_type="list" + self.model_base.result.par_fin, return_type="list" ) else: seed_template = usbs.extract_sbs_seed_template( @@ -3235,7 +3233,7 @@ def fit_slice_by_slice( # sanitized_spawn_main keeps workers from re-running a # non-.py __main__ (e.g. a notebook executed via %run). ctx = multiprocessing.get_context("spawn") - by_id: dict[int, list[Any]] = {} + by_id: dict[int, ulmfit.FitOutput] = {} with ( uspawn.sanitized_spawn_main(), concurrent.futures.ProcessPoolExecutor( @@ -3347,7 +3345,9 @@ def _append_baseline_slot( assert self.energy is not None # type guard if self.data is None: return None # data_base-only fixture / no File data to fingerprint - result_fin = self.model_base.result[1] + fit_out = self.model_base.result + assert fit_out is not None # type guard + result_fin = fit_out.par_fin if not hasattr(result_fin, "params"): return None # mocked / placeholder result; nothing to record # Evaluate model on the same grid as data_base, then crop to e_lim @@ -3374,7 +3374,7 @@ def _append_baseline_slot( col_type="min", par_names=self.model_base.parameter_names, ) - conf_ci = self.model_base.result[2] + conf_ci = fit_out.conf_ci # correl only when the optimizer produced a covariance matrix — # otherwise the matrix would misreport "no covariance" as # "uncorrelated" (identity + zeros). @@ -3383,10 +3383,7 @@ def _append_baseline_slot( if getattr(result_fin, "covar", None) is not None else None ) - mcmc = fit_io._mcmc_payload( - self.model_base.result[3], - self.model_base.result[4], - ) + mcmc = fit_io._mcmc_payload(fit_out.emcee_fin, fit_out.emcee_ci) slot = fit_io._slot_from_baseline( file_fingerprint=self.fingerprint(), file_name=self.name, @@ -3427,7 +3424,9 @@ def _append_spectrum_slot( assert self.model_spec is not None # type guard assert self.data_spec is not None # type guard assert self.energy is not None # type guard - result_fin = self.model_spec.result[1] + fit_out = self.model_spec.result + assert fit_out is not None # type guard + result_fin = fit_out.par_fin if not hasattr(result_fin, "params"): return None # mocked / placeholder result; nothing to record fit_full = np.asarray( @@ -3452,16 +3451,13 @@ def _append_spectrum_slot( col_type="min", par_names=self.model_spec.parameter_names, ) - conf_ci = self.model_spec.result[2] + conf_ci = fit_out.conf_ci correl = ( ulmfit.correl_to_df(result_fin.params) if getattr(result_fin, "covar", None) is not None else None ) - mcmc = fit_io._mcmc_payload( - self.model_spec.result[3], - self.model_spec.result[4], - ) + mcmc = fit_io._mcmc_payload(fit_out.emcee_fin, fit_out.emcee_ci) slot = fit_io._slot_from_spectrum( file_fingerprint=self.fingerprint(), file_name=self.name, @@ -3509,7 +3505,7 @@ def _append_sbs_slot( assert self.model_sbs is not None # type guard assert self.data is not None # type guard assert self.energy is not None # type guard - if not self.results_sbs or not hasattr(self.results_sbs[0][1], "params"): + if not self.results_sbs or not hasattr(self.results_sbs[0].par_fin, "params"): return None # mocked / placeholder results; nothing to record n_slices = len(self.data) e_lim = list(self.e_lim) if self.e_lim else None @@ -3519,7 +3515,7 @@ def _append_sbs_slot( fit_rows = [] for s_i in range(n_slices): slice_data = self.data[s_i] - slice_par = self.results_sbs[s_i][1].params + slice_par = self.results_sbs[s_i].par_fin.params fit_full = np.asarray( fitlib.residual_fun( par=slice_par, @@ -3542,15 +3538,15 @@ def _append_sbs_slot( params_df = ulmfit.list_of_par_to_df(self.results_sbs) # n_free_pars + fit_alg captured from slice 0 (consistent across slices # because the same model_sbs is used for every slice). - slice0_result = self.results_sbs[0][1] - slice0_conf_ci = self.results_sbs[0][2] + slice0_result = self.results_sbs[0].par_fin + slice0_conf_ci = self.results_sbs[0].conf_ci # MCMC and correl payloads — captured from slice 0, mirroring # fit_alg / nvarys. Per-slice MCMC chains / correlation matrices # are not stored; aggregate analysis uses slice 0 as the # representative. slice0_mcmc = fit_io._mcmc_payload( - self.results_sbs[0][3], - self.results_sbs[0][4], + self.results_sbs[0].emcee_fin, + self.results_sbs[0].emcee_ci, ) slice0_correl = ( ulmfit.correl_to_df(slice0_result.params) @@ -3603,7 +3599,9 @@ def _append_2d_slot( assert self.model_2d is not None # type guard assert self.data is not None # type guard assert self.energy is not None # type guard - result_fin = self.model_2d.result[1] + fit_out = self.model_2d.result + assert fit_out is not None # type guard + result_fin = fit_out.par_fin if not hasattr(result_fin, "params"): return None # mocked / placeholder result; nothing to record # Evaluate the 2D model on the full grid; crop to (t_lim, e_lim) so @@ -3635,8 +3633,8 @@ def _append_2d_slot( col_type="min", par_names=self.model_2d.parameter_names, ) - conf_ci = self.model_2d.result[2] - # covar is absent on the project-fit path (SimpleNamespace result) + conf_ci = fit_out.conf_ci + # covar is absent on the project-fit path (minimal MinimizerResult) # and for covariance-less optimizers; correl stays None there, # mirroring the per-file absence of stderr / conf_ci. correl = ( @@ -3644,10 +3642,7 @@ def _append_2d_slot( if getattr(result_fin, "covar", None) is not None else None ) - mcmc = fit_io._mcmc_payload( - self.model_2d.result[3], - self.model_2d.result[4], - ) + mcmc = fit_io._mcmc_payload(fit_out.emcee_fin, fit_out.emcee_ci) slot = fit_io._slot_from_2d( file_fingerprint=self.fingerprint(), file_name=self.name, @@ -4029,7 +4024,7 @@ def fit_2d(self, model_name: str, stages: int = 1, **fit_wrapper_kwargs) -> None self.model_2d.args = _args # fit (with confidence intervals) - self.model_2d.result = fitlib.fit_wrapper( + fit_out = fitlib.fit_wrapper( const=self.model_2d.const, args=self.model_2d.args, par_names=self.model_2d.parameter_names, @@ -4038,12 +4033,13 @@ def fit_2d(self, model_name: str, stages: int = 1, **fit_wrapper_kwargs) -> None show_output=1 if self.p.show_output >= 1 else 0, **fit_wrapper_kwargs, ) + self.model_2d.result = fit_out # Write optimized values back to model.lmfit_pars. fit_wrapper # optimizes a deepcopy, so model.lmfit_pars may be stale — especially # on the GIR path where fit_model_gir never calls model.update_value. slot_2d: fit_io.SavedFitSlot | None = None - if stages >= 1 and self.model_2d.result[1] != []: - final_params = self.model_2d.result[1].params + if stages >= 1: + final_params = fit_out.par_fin.params for name in self.model_2d.parameter_names: if name in final_params: self.model_2d.lmfit_pars[name].value = final_params[name].value @@ -4063,7 +4059,7 @@ def fit_2d(self, model_name: str, stages: int = 1, **fit_wrapper_kwargs) -> None t_start=t_2d, print_str="Time elapsed for 2D model fit: " ) # display final pars below figure - display(self.model_2d.result[1].params) + display(fit_out.par_fin.params) # def get_fit_results( diff --git a/src/trspecfit/utils/lmfit.py b/src/trspecfit/utils/lmfit.py index d30ebe0..d8a5f8c 100644 --- a/src/trspecfit/utils/lmfit.py +++ b/src/trspecfit/utils/lmfit.py @@ -13,13 +13,41 @@ import warnings from dataclasses import dataclass -from typing import Any, Literal, overload +from typing import TYPE_CHECKING, Any, Literal, overload import lmfit import numpy as np import pandas as pd from lmfit.minimizer import MinimizerResult +if TYPE_CHECKING: + # + # + class TypedMinimizerResult(MinimizerResult): + """Annotation-only view of ``lmfit.minimizer.MinimizerResult``. + + lmfit sets result attributes dynamically (``setattr`` in + ``__init__`` / ``minimize``), so type checkers see none of them. + This subclass declares the ones trspecfit reads; at runtime it IS + ``MinimizerResult`` (see the ``else`` branch). + """ + + params: lmfit.Parameters + method: str + success: bool + errorbars: bool + nvarys: int + nfree: int + chisqr: float + redchi: float + covar: np.ndarray | None + var_names: list[str] + # set by Minimizer.emcee only + flatchain: pd.DataFrame + acceptance_fraction: np.ndarray +else: + TypedMinimizerResult = MinimizerResult + # # lmfit parameter creation and extraction # @@ -446,11 +474,11 @@ def correl_to_df(lmfit_params: lmfit.Parameters) -> pd.DataFrame: # -def list_of_par_to_df(results: list[Any]) -> pd.DataFrame: +def list_of_par_to_df(results: list[FitOutput]) -> pd.DataFrame: """ Extract parameter values from multiple fit results into DataFrame. - Collects optimized parameter values from a list of lmfit fit results + Collects optimized parameter values from a list of fit results (e.g., from slice-by-slice fitting) and organizes them in a DataFrame with rows=fits and columns=parameters. Assumes all fits have the same parameter names (typical for slice-by-slice). @@ -458,10 +486,10 @@ def list_of_par_to_df(results: list[Any]) -> pd.DataFrame: Parameters ---------- - results : list - List of fit results from fit_wrapper or similar. - Each element is expected to be a tuple/list where element [1] contains - the lmfit.MinimizerResult with a .params attribute. + results : list of FitOutput + Fit results from ``fitlib.fit_wrapper``, one per fit; each + ``par_fin`` holds the lmfit.MinimizerResult with a ``.params`` + attribute. Returns ------- @@ -488,16 +516,16 @@ def list_of_par_to_df(results: list[Any]) -> pd.DataFrame: """ # Extract parameter values from each result - param_values_list = [par_extract(results[i][1].params) for i in range(len(results))] + param_values_list = [par_extract(result.par_fin.params) for result in results] # Get parameter names from first result (all should be identical) - param_names = [k for k, v in results[0][1].params.valuesdict().items()] + param_names = list(results[0].par_fin.params.valuesdict()) return pd.DataFrame(param_values_list, columns=param_names) # -def list_of_par_stderr_to_df(results: list[Any]) -> pd.DataFrame: +def list_of_par_stderr_to_df(results: list[FitOutput]) -> pd.DataFrame: """ Extract per-fit parameter stderr into a DataFrame (NaN where absent). @@ -509,9 +537,9 @@ def list_of_par_stderr_to_df(results: list[Any]) -> pd.DataFrame: Parameters ---------- - results : list - List of fit results from fit_wrapper or similar; element [1] holds - the lmfit.MinimizerResult with a .params attribute. + results : list of FitOutput + Fit results from ``fitlib.fit_wrapper``; each ``par_fin`` holds + the lmfit.MinimizerResult with a ``.params`` attribute. Returns ------- @@ -519,10 +547,10 @@ def list_of_par_stderr_to_df(results: list[Any]) -> pd.DataFrame: DataFrame with rows=individual fits, columns=parameter stderr. """ - param_names = list(results[0][1].params.keys()) + param_names = list(results[0].par_fin.params.keys()) rows = [] for result in results: - params = result[1].params + params = result.par_fin.params rows.append( [ float(params[name].stderr) @@ -539,6 +567,49 @@ def list_of_par_stderr_to_df(results: list[Any]) -> pd.DataFrame: # +# +# +@dataclass(frozen=True) +class FitOutput: + """ + Typed result of one ``fitlib.fit_wrapper`` optimization run. + + Internal container replacing the historical raw five-element list + ``[par_ini, par_fin, conf_ci, emcee_fin, emcee_ci]``. Stored on + ``mcp.Model.result`` and, per slice, in ``File.results_sbs``; the + authoritative persisted record remains ``SavedFitSlot``. + + Attributes + ---------- + par_ini : lmfit.Parameters or None + Initial parameter guess (deep copy, untouched by the fit). None + on the project-level joint-fit path, where per-file results are + projections of one joint optimization and no per-file initial + guess exists. + par_fin : lmfit.minimizer.MinimizerResult + Final fit result from ``lmfit.minimize`` (annotated as + ``TypedMinimizerResult`` for static attribute access). On the + project-level joint-fit path this is a minimal + ``MinimizerResult`` carrying only ``params`` / ``method`` / + ``nvarys``. + conf_ci : pd.DataFrame + Confidence intervals from ``lmfit.conf_interval`` (columns + ``['par[v]/sigma[>]', '-3.0', ..., 'best fit', ..., '+3.0']``). + Empty if CI was skipped or failed. + emcee_fin : lmfit.minimizer.MinimizerResult or None + MCMC sampling result from ``lmfit.emcee``. None if MCMC not used. + emcee_ci : pd.DataFrame + MCMC confidence intervals (quantiles of the flatchain, same + column structure as ``conf_ci``). Empty if MCMC not used. + """ + + par_ini: lmfit.Parameters | None + par_fin: TypedMinimizerResult + conf_ci: pd.DataFrame + emcee_fin: TypedMinimizerResult | None + emcee_ci: pd.DataFrame + + # # class MC: diff --git a/src/trspecfit/utils/sbs.py b/src/trspecfit/utils/sbs.py index 163013c..541a222 100644 --- a/src/trspecfit/utils/sbs.py +++ b/src/trspecfit/utils/sbs.py @@ -144,7 +144,7 @@ def sbs_fit_one_slice( fit_fun_str: str, stages: int, fit_wrapper_kwargs: dict[str, Any], -) -> tuple[int, list[Any]]: +) -> tuple[int, ulmfit.FitOutput]: """Fit one energy slice in a worker process. Uses worker-local ``_WORKER_MODEL`` and ``_WORKER_DISPATCH_ARGS`` @@ -155,7 +155,7 @@ def sbs_fit_one_slice( Returns ------- - tuple[int, list] + tuple[int, ulmfit.FitOutput] (slice_index, fit_wrapper_result) so the caller can reassemble out-of-order completions back into slice order. """ @@ -271,8 +271,8 @@ def plot_sbs_slices( x=file.energy, y=file.data[s_i], fit_fun_str=file.p.spec_fun_str, - par_init=result_slice[0], - par_fin=result_slice[1], + par_init=result_slice.par_ini, + par_fin=result_slice.par_fin, args=model_sbs.args, plot_sum=False, show_init=show_init, diff --git a/tests/roundtrip/test_focused.py b/tests/roundtrip/test_focused.py index 7de89dc..3649c72 100644 --- a/tests/roundtrip/test_focused.py +++ b/tests/roundtrip/test_focused.py @@ -265,7 +265,7 @@ def test_sbs_explicit_seed_f1(): ) mid = len(fit_file.results_sbs) // 2 - assert_recovery_exact(truth_pars, fit_file.results_sbs[mid][1].params) + assert_recovery_exact(truth_pars, fit_file.results_sbs[mid].par_fin.params) # @@ -286,4 +286,4 @@ def test_sbs_baseline_argmax_shift_f1(): ) mid = len(fit_file.results_sbs) // 2 - assert_recovery_exact(truth_pars, fit_file.results_sbs[mid][1].params) + assert_recovery_exact(truth_pars, fit_file.results_sbs[mid].par_fin.params) diff --git a/tests/roundtrip/workflows.py b/tests/roundtrip/workflows.py index 13c75c1..807255a 100644 --- a/tests/roundtrip/workflows.py +++ b/tests/roundtrip/workflows.py @@ -48,7 +48,8 @@ def _run_baseline( ) -> 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) + assert file.model_base.result is not None # type guard + return FitResult(params=file.model_base.result.par_fin.params) # ---- Sp: fit_spectrum ---- @@ -69,7 +70,8 @@ def _run_spectrum( try_ci=0, ) assert file.model_spec is not None # type guard - return FitResult(params=file.model_spec.result[1].params) + assert file.model_spec.result is not None # type guard + return FitResult(params=file.model_spec.result.par_fin.params) # ---- SbS: fit_slice_by_slice ---- @@ -86,7 +88,7 @@ def _run_sbs(file: File, family: Family, model_name: str, variant: str) -> FitRe try_ci=0, ) mid = len(file.results_sbs) // 2 - return FitResult(params=file.results_sbs[mid][1].params) + return FitResult(params=file.results_sbs[mid].par_fin.params) # ---- 2D: fit_baseline + (re-add dynamics) + fit_2d ---- @@ -99,7 +101,8 @@ def _run_2d(file: File, family: Family, model_name: str, variant: str) -> FitRes 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) + assert file.model_2d.result is not None # type guard + return FitResult(params=file.model_2d.result.par_fin.params) # ---- registry ---- diff --git a/tests/test_evaluate_jax.py b/tests/test_evaluate_jax.py index 14af034..e17de46 100644 --- a/tests/test_evaluate_jax.py +++ b/tests/test_evaluate_jax.py @@ -366,7 +366,8 @@ def test_fit_recovers_truth_with_analytic_jacobian(self): fit_file.fit_2d(model_name="single_glp", stages=2, try_ci=0) assert fit_file.model_2d is not None # type guard - result_params = fit_file.model_2d.result[1].params + assert fit_file.model_2d.result is not None # type guard + result_params = fit_file.model_2d.result.par_fin.params for name, true_val in truth_pars.items(): fit_val = result_params[name].value assert np.isclose(true_val, fit_val, rtol=1e-8, atol=1e-10), ( diff --git a/tests/test_file.py b/tests/test_file.py index 6634fe9..4901cc7 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -4,13 +4,17 @@ set_fit_limits, and define_baseline. """ +import typing import unittest.mock import numpy as np +import pandas as pd import pytest from _utils import make_project +from lmfit.minimizer import MinimizerResult from trspecfit import File +from trspecfit.utils import lmfit as ulmfit # @@ -1053,10 +1057,19 @@ def test_fit_sbs_model_seed_allows_no_baseline_fit(self): file = self._make_file_with_model() file.p.spec_fun_str = "fit_model_mcp" + # bare MinimizerResult (no .params) marks this as a placeholder: + # slot capture skips it, so no real fit machinery runs + mock_result = ulmfit.FitOutput( + par_ini=None, + par_fin=typing.cast("ulmfit.TypedMinimizerResult", MinimizerResult()), + conf_ci=pd.DataFrame(), + emcee_fin=None, + emcee_ci=pd.DataFrame(), + ) with ( unittest.mock.patch( "trspecfit.trspecfit.fitlib.fit_wrapper", - return_value=[None, object(), None, None, None], + return_value=mock_result, ) as mock_fit, unittest.mock.patch("trspecfit.trspecfit.fitlib.plt_fit_res_1d"), unittest.mock.patch("trspecfit.trspecfit.fitlib.time_display"), diff --git a/tests/test_fit_history.py b/tests/test_fit_history.py index 4d41353..fc88e7a 100644 --- a/tests/test_fit_history.py +++ b/tests/test_fit_history.py @@ -489,7 +489,7 @@ def test_2d_slot_basic_fields(self): # class TestMcmcPayload: - """fit_wrapper's emcee outputs (result[3]/[4]) flow into SavedFitSlot.mcmc. + """fit_wrapper's emcee outputs (emcee_fin/emcee_ci) flow into SavedFitSlot.mcmc. Without this wiring the slot's ``mcmc`` field stays None even when MCMC actually ran — see _mcmc_payload in utils/fit_io.py. diff --git a/tests/test_fit_side_effects.py b/tests/test_fit_side_effects.py index 5aef5c7..0e396ab 100644 --- a/tests/test_fit_side_effects.py +++ b/tests/test_fit_side_effects.py @@ -137,7 +137,7 @@ def test_baseline_writes_nothing(self, tmp_path, monkeypatch): file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) assert file.model_base.result is not None - assert file.model_base.result[1] != [] + assert file.model_base.result.par_fin.success assert len(project._fit_history) == 1 assert project._fit_history[0].fit_type == "baseline" assert _list_files(tmp_path) == set() @@ -178,7 +178,7 @@ def test_2d_writes_nothing(self, tmp_path, monkeypatch): file.fit_2d("single_glp", stages=1, try_ci=0) assert file.model_2d.result is not None - assert file.model_2d.result[1] != [] + assert file.model_2d.result.par_fin.success assert any(slot.fit_type == "2d" for slot in project._fit_history) assert _list_files(tmp_path) == set() diff --git a/tests/test_fit_validation.py b/tests/test_fit_validation.py index e8f7743..9645ece 100644 --- a/tests/test_fit_validation.py +++ b/tests/test_fit_validation.py @@ -117,9 +117,9 @@ def test_nan_outside_fit_window_is_allowed(self): ) result = fit_file.model_spec.result - assert result[1] != [] - assert result[1].success - assert np.isfinite(result[1].chisqr) + assert result is not None # type guard + assert result.par_fin.success + assert np.isfinite(result.par_fin.chisqr) # def test_nan_in_fit_window_raises_2d(self): @@ -166,8 +166,8 @@ def test_single_element_energy_axis_fits(self): ) result = fit_file.model_spec.result - assert result[1] != [] - assert result[1].nfree < 0 # underdetermined, documented not endorsed + assert result is not None # type guard + assert result.par_fin.nfree < 0 # underdetermined, documented not endorsed # def test_single_element_time_axis_fit_2d(self): @@ -192,5 +192,5 @@ def test_single_element_time_axis_fit_2d(self): fit_file.fit_2d("single_glp", stages=1, try_ci=0) result = fit_file.model_2d.result - assert result[1] != [] - assert result[1].success + assert result is not None # type guard + assert result.par_fin.success diff --git a/tests/test_gir_integration.py b/tests/test_gir_integration.py index fdaaf44..864b613 100644 --- a/tests/test_gir_integration.py +++ b/tests/test_gir_integration.py @@ -851,7 +851,8 @@ def test_gir_fit_writes_back_to_model(self): # Verify writeback: model_2d.lmfit_pars should match result params assert fit_file.model_2d is not None # type guard - result_params = fit_file.model_2d.result[1].params + assert fit_file.model_2d.result is not None # type guard + result_params = fit_file.model_2d.result.par_fin.params for name in fit_file.model_2d.parameter_names: model_val = fit_file.model_2d.lmfit_pars[name].value result_val = result_params[name].value @@ -916,7 +917,8 @@ def test_kernel_width_recovered_when_init_below_truth(self): fit_file.fit_2d(model_name="single_glp", stages=2, try_ci=0) assert fit_file.model_2d is not None # type guard - SD_fit = fit_file.model_2d.result[1].params[SD_name].value + assert fit_file.model_2d.result is not None # type guard + SD_fit = fit_file.model_2d.result.par_fin.params[SD_name].value assert np.isclose(SD_fit, SD_truth, rtol=2e-2), ( f"kernel width not recovered: truth={SD_truth}, fit={SD_fit:.4f}" ) @@ -1336,7 +1338,8 @@ def test_gir_baseline_writes_back(self): # Verify writeback assert fit_file.model_base is not None # type guard - result_params = fit_file.model_base.result[1].params + assert fit_file.model_base.result is not None # type guard + result_params = fit_file.model_base.result.par_fin.params for name in fit_file.model_base.parameter_names: model_val = fit_file.model_base.lmfit_pars[name].value result_val = result_params[name].value @@ -1484,10 +1487,10 @@ def _run_sbs(name: str, n_workers: int) -> tuple[list, int]: for s_i, (r_serial, r_parallel) in enumerate( zip(serial, parallel, strict=True) ): - # result_sbs[1] is the lmfit MinimizerResult; compare its - # final parameter values across the two paths. - params_serial = r_serial[1].params - params_parallel = r_parallel[1].params + # par_fin is the lmfit MinimizerResult; compare its final + # parameter values across the two paths. + params_serial = r_serial.par_fin.params + params_parallel = r_parallel.par_fin.params assert list(params_serial.keys()) == list(params_parallel.keys()) for name in params_serial.keys(): np.testing.assert_allclose( @@ -1531,7 +1534,10 @@ def test_fit_slice_by_slice_restores_seed_template( fit_file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) assert fit_file.model_base is not None # type guard - expected = ulmfit.par_extract(fit_file.model_base.result[1], return_type="list") + assert fit_file.model_base.result is not None # type guard + expected = ulmfit.par_extract( + fit_file.model_base.result.par_fin, return_type="list" + ) seed_values = None if seed_source == "model": diff --git a/tests/test_mcp_library.py b/tests/test_mcp_library.py index 886bb16..71ff6fe 100644 --- a/tests/test_mcp_library.py +++ b/tests/test_mcp_library.py @@ -1136,7 +1136,9 @@ def test_mc_sigma_settings_reach_lnsigma(self): ) file.fit_baseline(model_name="single_glp", stages=1, try_ci=0, mc_settings=mc) - emcee_fin = file.model_base.result[3] + assert file.model_base.result is not None # type guard + emcee_fin = file.model_base.result.emcee_fin + assert emcee_fin is not None # type guard lnsigma = emcee_fin.params["__lnsigma"] np.testing.assert_allclose(lnsigma.min, np.log(0.01)) np.testing.assert_allclose(lnsigma.max, np.log(5.0)) @@ -1144,11 +1146,11 @@ def test_mc_sigma_settings_reach_lnsigma(self): # @pytest.mark.slow def test_lnsigma_does_not_leak_into_leastsq_result(self): - """__lnsigma is an MCMC construct: it must stay out of result[1]. + """__lnsigma is an MCMC construct: it must stay out of par_fin. Regression for the in-place mutation in fit_wrapper that injected - __lnsigma into par_fin.params (result[1]), leaking it into every - downstream consumer of the model-only fit result. + __lnsigma into par_fin.params, leaking it into every downstream + consumer of the model-only fit result. """ from trspecfit.utils.lmfit import MC @@ -1157,10 +1159,12 @@ def test_lnsigma_does_not_leak_into_leastsq_result(self): mc = MC(use_mc=1, steps=20, nwalkers=32, burn=5, thin=1) file.fit_baseline(model_name="single_glp", stages=1, try_ci=0, mc_settings=mc) - # result[1] = par_fin (leastsq): model parameters only, no __lnsigma - assert "__lnsigma" not in file.model_base.result[1].params - # result[3] = emcee_fin (MCMC): __lnsigma belongs here - assert "__lnsigma" in file.model_base.result[3].params + assert file.model_base.result is not None # type guard + assert file.model_base.result.emcee_fin is not None # type guard + # par_fin (leastsq): model parameters only, no __lnsigma + assert "__lnsigma" not in file.model_base.result.par_fin.params + # emcee_fin (MCMC): __lnsigma belongs here + assert "__lnsigma" in file.model_base.result.emcee_fin.params # def test_get_correlations_matrix(self): @@ -1173,10 +1177,11 @@ def test_get_correlations_matrix(self): file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) corr = file.get_correlations(fit_type="baseline") + assert file.model_base.result is not None # type guard varying = [ p for p in file.model_base.parameter_names - if file.model_base.result[1].params[p].vary + if file.model_base.result.par_fin.params[p].vary ] assert list(corr.index) == varying assert list(corr.columns) == varying From 1cf62ec0db57d9a0a3dde616639adbba346d8e0d Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Fri, 17 Jul 2026 21:47:43 -0700 Subject: [PATCH 17/29] consolidate initial-parameter naming on par_ini --- CHANGELOG.md | 2 +- src/trspecfit/fitlib.py | 12 ++++++------ src/trspecfit/trspecfit.py | 6 +++--- src/trspecfit/utils/sbs.py | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b93c5b8..34983ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ This file is maintained using the shared changelog workflow in - **Breaking: fits never write to disk.** `fit_baseline` / `fit_spectrum` / `fit_slice_by_slice` / `fit_2d` and `Project.fit_2d` compute, display (per `show_output`), and record fit slots — persistence is always an explicit `save_fits` (HDF5 archive) or `export_fits` (grouped CSV/PNG tree) call, both fed from the slot history, with the single default output root `./fit_results/{Project.name}/`. Everything the automatic writes used to produce is reproducible: parameter tables, confidence intervals, and MCMC chains are persisted in the slot and exported by `export_fits`; the MCMC walker/corner PNGs via `plot_mcmc`; the SbS per-slice PNGs via `plot_sbs_slices`; the component-decomposed `fit_1d.csv` via `save_baseline_fit` / `save_spectrum_fit` (kept, no longer auto-called). Accepted losses: per-slice `par_ini` CSVs (re-derivable from the persisted `fit_settings` seeding recipe) and the `lmfit.fit_report` text dumps (all their contents are persisted). Interactive display renders from the captured fit slot, so the figure shown equals the figure the plot API reproduces later; `Project.fit_2d` / `Project.fit_baselines` show per-file plots instead of PNGs read back from disk. - **Breaking: `File.get_correlations` raises for covariance-less fits** (e.g. Nelder without numdifftools, project joint fits) instead of returning an identity-with-zeros matrix that misread as "uncorrelated". - **Breaking: `Project.name` defaults to `"my_project"`** (was `"test"`), so a bare `save_fits()` / `export_fits()` lands in a clearly-placeholder `fit_results/my_project/` instead of colliding with test-suite naming. -- **Breaking (advanced API): fit results are a typed `FitOutput` object.** `fitlib.fit_wrapper` returns a frozen `FitOutput` dataclass (fields `par_ini`, `par_fin`, `conf_ci`, `emcee_fin`, `emcee_ci`) instead of the raw five-element list, and `Model.result` / the per-slice entries of `File.results_sbs` hold it. Positional indexing (`model.result[1].params`) becomes attribute access (`model.result.par_fin.params`); an unfitted model's `result` is now `None` instead of `[]`, and a skipped MCMC yields `emcee_fin=None`. `Project.fit_2d`'s per-file stand-in result is a real (minimal) `lmfit` `MinimizerResult` instead of a `SimpleNamespace`. The persisted `SavedFitSlot` record and all `FitResults` accessors are unchanged. +- **Breaking (advanced API): fit results are a typed `FitOutput` object.** `fitlib.fit_wrapper` returns a frozen `FitOutput` dataclass (fields `par_ini`, `par_fin`, `conf_ci`, `emcee_fin`, `emcee_ci`) instead of the raw five-element list, and `Model.result` / the per-slice entries of `File.results_sbs` hold it. Positional indexing (`model.result[1].params`) becomes attribute access (`model.result.par_fin.params`); an unfitted model's `result` is now `None` instead of `[]`, and a skipped MCMC yields `emcee_fin=None`. `Project.fit_2d`'s per-file stand-in result is a real (minimal) `lmfit` `MinimizerResult` instead of a `SimpleNamespace`. In the same naming consolidation, `fitlib.plt_fit_res_1d`'s `par_init` parameter is now `par_ini`, matching the `par_ini`/`par_fin` field pair. The persisted `SavedFitSlot` record and all `FitResults` accessors are unchanged. - `fitlib.results_to_df` and `fitlib.results_to_fit_2d` are pure conversions: the CSV writes, per-parameter plotting, and `save_df`/`save_2d` flags were removed along with the legacy save path that used them. ### Removed diff --git a/src/trspecfit/fitlib.py b/src/trspecfit/fitlib.py index a9c04cd..f2504a6 100644 --- a/src/trspecfit/fitlib.py +++ b/src/trspecfit/fitlib.py @@ -1154,7 +1154,7 @@ def plt_fit_res_1d( x: ArrayLike, y: ArrayLike, fit_fun_str: str, - par_init: Any, + par_ini: Any, par_fin: Any, args: tuple[Any, ...] | None = None, *, @@ -1182,7 +1182,7 @@ def plt_fit_res_1d( fit_fun_str : str Name of fitting function in ``trspecfit.spectra`` (e.g., ``'fit_model_mcp'``, ``'fit_model_gir'``) - par_init : list or lmfit.Parameters + par_ini : list or lmfit.Parameters Initial parameter guess. Can be empty list [] if show_init=False. par_fin : lmfit.MinimizerResult or lmfit.Parameters or list Final fit parameters: @@ -1266,10 +1266,10 @@ def plt_fit_res_1d( # Plot initial guess if requested if show_init: - par_ini = ulmfit.par_extract(par_init, return_type="list") + par_ini_vals = ulmfit.par_extract(par_ini, return_type="list") plt.plot( x_arr, - fit_fun(x_arr, par_ini, True, *args), + fit_fun(x_arr, par_ini_vals, True, *args), color="#FFD700", linestyle=":", linewidth=2, @@ -1312,8 +1312,8 @@ def plt_fit_res_1d( res = y_arr - fit_fun(x_arr, par_fin_vals, True, *args) else: # Initial guess only - par_ini = ulmfit.par_extract(par_init, return_type="list") - res = y_arr - fit_fun(x_arr, par_ini, True, *args) + par_ini_vals = ulmfit.par_extract(par_ini, return_type="list") + res = y_arr - fit_fun(x_arr, par_ini_vals, True, *args) # Plot residual (scaled for visibility) plt.plot( diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index e11abd6..ee7f4b4 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -2164,7 +2164,7 @@ def describe_model( x=self.energy, y=self.data_base, fit_fun_str=self.p.spec_fun_str, - par_init=[], + par_ini=[], par_fin=mod.lmfit_pars, args=(mod, 1), plot_sum=False, @@ -2702,7 +2702,7 @@ def fit_baseline( x=self.energy, y=self.data_base, fit_fun_str=self.p.spec_fun_str, - par_init=initial_guess, + par_ini=initial_guess, par_fin=fit_out.par_fin, args=self.model_base.args, plot_sum=False, @@ -2935,7 +2935,7 @@ def fit_spectrum( x=self.energy, y=self.data_spec, fit_fun_str=self.p.spec_fun_str, - par_init=initial_guess, + par_ini=initial_guess, par_fin=fit_out.par_fin, args=self.model_spec.args, plot_sum=False, diff --git a/src/trspecfit/utils/sbs.py b/src/trspecfit/utils/sbs.py index 541a222..5a6ae28 100644 --- a/src/trspecfit/utils/sbs.py +++ b/src/trspecfit/utils/sbs.py @@ -271,7 +271,7 @@ def plot_sbs_slices( x=file.energy, y=file.data[s_i], fit_fun_str=file.p.spec_fun_str, - par_init=result_slice.par_ini, + par_ini=result_slice.par_ini, par_fin=result_slice.par_fin, args=model_sbs.args, plot_sum=False, From 458018c39ca85c94cd84d6b9af327c88f407ec3b Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Sun, 19 Jul 2026 09:08:34 -0700 Subject: [PATCH 18/29] fix review findings: plot_sbs_slices param leak, stale benchmark caller, Model.result docstring --- .claude/skills/benchmark/benchmark_gir.py | 4 +- src/trspecfit/eval_jax.py | 2 + src/trspecfit/mcp.py | 6 +- src/trspecfit/utils/sbs.py | 70 +++++++++++++---------- tests/test_fit_side_effects.py | 18 ++++++ 5 files changed, 65 insertions(+), 35 deletions(-) diff --git a/.claude/skills/benchmark/benchmark_gir.py b/.claude/skills/benchmark/benchmark_gir.py index 4b47604..ea24064 100644 --- a/.claude/skills/benchmark/benchmark_gir.py +++ b/.claude/skills/benchmark/benchmark_gir.py @@ -588,8 +588,8 @@ def capture_par_variability(example_num, *, n_starts=4): fitted_runs.append({name: fitted[name] for name in free_names}) try: - redchi = model.result[1].redchi - except (AttributeError, IndexError, TypeError): + redchi = model.result.par_fin.redchi + except AttributeError: # result is None or placeholder par_fin redchi = float("nan") labels.append(label) redchis.append(redchi) diff --git a/src/trspecfit/eval_jax.py b/src/trspecfit/eval_jax.py index 8751bc5..8a32d8c 100644 --- a/src/trspecfit/eval_jax.py +++ b/src/trspecfit/eval_jax.py @@ -621,6 +621,8 @@ def _wrap_jitted(jitted: Callable, n_opt: int) -> Callable[[np.ndarray], np.ndar # def evaluate(theta: np.ndarray) -> np.ndarray: + """Validate theta shape, then call the jitted evaluator.""" + theta_arr = np.asarray(theta, dtype=np.float64) if theta_arr.shape != (n_opt,): raise ValueError( diff --git a/src/trspecfit/mcp.py b/src/trspecfit/mcp.py index 63fbd31..d0ce350 100644 --- a/src/trspecfit/mcp.py +++ b/src/trspecfit/mcp.py @@ -129,8 +129,10 @@ class Model: Constants for residual function (x, data, package, function_str, ...) args : tuple or None Arguments for fit function (model, dim) - result : list - Fit results from fit_wrapper [par_ini, par_fin, conf_ci, emcee_fin, emcee_ci] + result : trspecfit.utils.lmfit.FitOutput or None + Fit result from ``fitlib.fit_wrapper`` (fields ``par_ini`` / + ``par_fin`` / ``conf_ci`` / ``emcee_fin`` / ``emcee_ci``); + None until fitted parent_file : File or None Parent File object (set when model is loaded) dim : int or None diff --git a/src/trspecfit/utils/sbs.py b/src/trspecfit/utils/sbs.py index 5a6ae28..13da859 100644 --- a/src/trspecfit/utils/sbs.py +++ b/src/trspecfit/utils/sbs.py @@ -251,35 +251,43 @@ def plot_sbs_slices( return legend = [comp.name for comp in model_sbs.components] - for s_i in slice_indices: - result_slice = results_sbs[s_i] - if file.time is not None: - title = f"{file.name} — slice {s_i} (t = {file.time[s_i]:.4g})" - else: - title = f"{file.name} — slice {s_i}" - img_path: str | pathlib.Path - if save: - assert save_path is not None # type guard - save_img = uplt._save_img_flag(save=True, show=show_plot) - img_path = pathlib.Path(save_path) / ( - str(file.p.da_slices_fmt % s_i) + ".png" + # rendering on the mcp path evaluates through the live model and + # writes the plotted per-slice values into model_sbs.lmfit_pars + # (spectra.fit_model_mcp) — snapshot and restore so a diagnostic + # never leaks into later seed_source="model" runs or inspection + saved_par_values = ulmfit.par_extract(model_sbs.lmfit_pars, return_type="list") + try: + for s_i in slice_indices: + result_slice = results_sbs[s_i] + if file.time is not None: + title = f"{file.name} — slice {s_i} (t = {file.time[s_i]:.4g})" + else: + title = f"{file.name} — slice {s_i}" + img_path: str | pathlib.Path + if save: + assert save_path is not None # type guard + save_img = uplt._save_img_flag(save=True, show=show_plot) + img_path = pathlib.Path(save_path) / ( + str(file.p.da_slices_fmt % s_i) + ".png" + ) + else: + save_img = 0 + img_path = "" + fitlib.plt_fit_res_1d( + x=file.energy, + y=file.data[s_i], + fit_fun_str=file.p.spec_fun_str, + par_ini=result_slice.par_ini, + par_fin=result_slice.par_fin, + args=model_sbs.args, + plot_sum=False, + show_init=show_init, + title=title, + fit_lim=file.e_lim, + config=file.plot_config, + legend=legend, + save_img=save_img, + save_path=img_path, ) - else: - save_img = 0 - img_path = "" - fitlib.plt_fit_res_1d( - x=file.energy, - y=file.data[s_i], - fit_fun_str=file.p.spec_fun_str, - par_ini=result_slice.par_ini, - par_fin=result_slice.par_fin, - args=model_sbs.args, - plot_sum=False, - show_init=show_init, - title=title, - fit_lim=file.e_lim, - config=file.plot_config, - legend=legend, - save_img=save_img, - save_path=img_path, - ) + finally: + model_sbs.update_value(new_par_values=saved_par_values, par_select="all") diff --git a/tests/test_fit_side_effects.py b/tests/test_fit_side_effects.py index 0e396ab..d9fd0f4 100644 --- a/tests/test_fit_side_effects.py +++ b/tests/test_fit_side_effects.py @@ -23,6 +23,7 @@ from _utils import simulate_noisy from trspecfit import File, Project, fitlib +from trspecfit.utils import lmfit as ulmfit from trspecfit.utils.lmfit import MC TESTS_DIR = pathlib.Path(__file__).resolve().parent @@ -343,6 +344,23 @@ def test_save_path_writes_one_png_per_slice(self, tmp_path, monkeypatch): expected = {str(project.da_slices_fmt % s) + ".png" for s in (0, 2)} assert {p.name for p in out.iterdir()} == expected + # + def test_does_not_mutate_model_params(self, tmp_path, monkeypatch): + """Rendering must not leak plotted per-slice values into the model. + + fit_model_mcp evaluation writes the evaluated values into + lmfit_pars in place, and fit_slice_by_slice deliberately restores + the seed template after fitting — a post-fit diagnostic must not + undo that (it would corrupt later seed_source='model' runs). + """ + + _, file = self._sbs_fit(tmp_path, monkeypatch) + assert file.model_sbs is not None # type guard + before = ulmfit.par_extract(file.model_sbs.lmfit_pars, return_type="list") + file.plot_sbs_slices(slices=[0], save_path=tmp_path / "slices", show_plot=False) + after = ulmfit.par_extract(file.model_sbs.lmfit_pars, return_type="list") + assert after == before + # def test_raises_without_live_results(self, tmp_path, monkeypatch): _, file = _baseline_setup(tmp_path, monkeypatch) From 674deedd07d63af40104f3a221c7f09718b09a96 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Sun, 19 Jul 2026 09:09:24 -0700 Subject: [PATCH 19/29] close out the results-ownership branch: archive phases 7-8, clear PLAN --- PLAN.md | 147 +----------------- TODO.md | 1 - .../archive/results-ownership-and-plotting.md | 63 +++++++- 3 files changed, 61 insertions(+), 150 deletions(-) diff --git a/PLAN.md b/PLAN.md index 143acf6..1f851bc 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,145 +1,4 @@ -# Active Plan: remove auto-export; typed fit-result object +# Active Plan -Branch: `model-vs-fitresult` (continuation). Phases 1–6 (results ownership -boundary, plotting disentanglement, schema 3, plot API) are complete and -archived in -[docs/design/archive/results-ownership-and-plotting.md](docs/design/archive/results-ownership-and-plotting.md). -Phases 7–8 emerged from the post-completion review discussion (2026-07-17) -and complete the same principle, in the same breaking release (0.14.0). - -## Decisions (settled with user, 2026-07-17) - -1. **Remove `Project.auto_export` entirely.** Fits compute, display (per - `show_output`), and capture slots — they never write to disk. The - original justification for auto-export (results existed only as files) - died with the slot architecture; persistence is the explicit - `save_fit(s)` (HDF5) / `export_fit(s)` (CSV/PNG tree) pair. No new - method names — the save-vs-export split already covers it. -2. **Everything auto-export used to write must be reproducible** from - what the slot persists, or via a documented explicit call (the Phase 7 - reproducibility checklist below). -3. **Keeps**: `save_baseline_fit` / `save_spectrum_fit` stay as explicit - API (their component-decomposed `fit_1d.csv` is the one artifact slots - can't reproduce — only the total fit curve is persisted). They take - explicit paths, as do all persistence calls. -4. **One output root** (settled 2026-07-17): the `./fit_results//` - family already used by explicit `save_fits` / `export_fits` defaults. - `Project.path_results` and `File.model_path` are removed — after the - de-wiring nothing in the package writes there, so keeping them would - preserve a dead second convention. `Project.name` default changes - from `"test"` to `"my_project"` — a clear placeholder that stops - `fit_results/test/` / `test.fit.h5` from colliding with test-suite - naming (pytest traversal, glob confusion). The per-example naming - convention stays with the "Revisit default Project.name" TODO item. -5. **`plot_*` saving convention**: `save_path=None` means display-only - (consistent across the plot API); saving always requires an explicit - path. No default save locations for diagnostics. -4. **Phase 8 scope is internal-only**: replace the raw - `[par_ini, par_fin, conf_ci, emcee_fin, emcee_ci]` list with a typed - class. No archive-schema change, no public-API change — this branch - removed every user-facing reader of `model.result`, which is what - makes the cleanup safe now. - -## Phase 7 — remove auto-export; fits never write - -### Knob removal - -- [x] Delete `Project.auto_export` (attribute, `_set_defaults`, - `project.yaml` default). Check how YAML parsing treats the removed - key — an existing `project.yaml` with `auto_export:` must fail or - warn clearly, not be silently ignored (grep example/test yamls). -- [x] Delete `Project.path_results` and `File.model_path` (dead once the - fit methods stop writing): update `Project.describe`, the tests - that redirect `path_results`, and any `project.yaml` key handling. - Single default output root remains `./fit_results//`. -- [x] `Project.name` default `"test"` → `"my_project"` (placeholder that - can't be mistaken for test-suite artifacts). Grep tests/docs/ - examples for reliance on the old default. -- [x] Fit methods drop all export wiring: - - `fit_baseline` / `fit_spectrum`: the `save_baseline_fit` / - `save_spectrum_fit` auto-calls and `fit_wrapper(save_output=...)`. - - `fit_slice_by_slice`: the post-fit `export_fit` call, the serial - per-slice PNG block, per-slice `save_output`, and the worker args - that exist only for mid-loop IO (`auto_export`, `path_slice`, - `plot_config`) — leaner spawn payload, faster hot path. - - `File.fit_2d` / `Project.fit_2d`: the post-fit `export_fit(s)` - calls and `save_output` wiring. - - Display gating (`show_output`) is untouched. -- [x] `fitlib.fit_wrapper`: remove `save_output` / `save_path` / - `num_fmt` / `delim` params, the CSV/txt dump block, and the emcee - figure *save* path (walker/corner figures become display-only via - `show_output`). Fit methods drop their `num_fmt`/`delim` - setdefaults for fit_wrapper. - -### Reproducibility checklist (what auto-export wrote → where it lives now) - -- [x] `*_par_ini.csv` / `*_par_fin.csv` → slot `params` - (`init_value`/`value`/`stderr`/bounds/`vary`/`expr`); exported as - `params.csv`. ✓ nothing to add. -- [x] `*_conf_ci.csv`, `*_emcee_flatchain.csv`, `*_emcee_ci.csv` → slot - `conf_ci` / `mcmc` payload; exported under the slot dir. ✓ -- [x] `*_emcee_walker_acceptance_ratio.png`, `*_emcee_corner_plot.png` → - add `FitResults.plot_mcmc(file=..., model=..., fit_type=..., - show_plot=...)` rendering the corner plot (from persisted - `flatchain`) and per-walker acceptance (from persisted - `acceptance_fraction`), with `File.plot_mcmc` sugar — turnkey - reproduction from live sessions *and* archives. -- [x] SbS per-slice PNGs → new `File.plot_sbs_slices(model=..., - slices=None, show_init=True, save_path=None, show_plot=...)`, - logic in `utils/sbs.py`; uses live `results_sbs` (per-slice - `par_ini` + component decomposition via `plt_fit_res_1d`), so it - can do *more* than the old auto-PNGs. Live-session only — - document. `save_path=None` → display-only; deliberately NOT part - of `export_fits` (export stays slot-fed and archive-reproducible; - per-slice diagnostics need live inputs and would flood the tree). -- [x] SbS per-slice `*_par_ini.csv` → accepted loss: re-derivable from - the persisted `fit_settings` seeding recipe; document in changelog. -- [x] `lmfit.fit_report` text dumps → accepted loss: contents (params, - stderr, correlations, metrics) all persisted; document. -- [x] Baseline/spectrum component-decomposed `fit_1d.csv` → explicit - `save_baseline_fit` / `save_spectrum_fit` (kept, no longer - auto-called). -- [x] 2D/SbS result trees → explicit `export_fit(s)` (unchanged). - -### Tests / docs - -- [x] `tests/test_auto_export.py` reshaped: "fits write nothing" becomes - the unconditional default; explicit save/export tests remain; the - display/silent guardrail matrix (`TestPlotHelperSkipped`, - `TestVerboseDisplayWithoutExport`) survives with `auto_export` - references removed. `make_project(auto_export=...)` helper param - goes; export-parity tests re-anchor on two explicit exports. -- [x] New tests: `plot_mcmc` (slot-backed, incl. loaded archive), - `plot_sbs_slices` (live; raises helpfully without `results_sbs`). -- [x] Docs: llms.txt headless section shrinks (`show_output=0` is the - only knob — no-write is default); repo_architecture.md auto-export - paragraph rewritten (fits never write; explicit save/export; - diagnostics on demand); changelog breaking entry; grep notebooks + - example `project.yaml`s for `auto_export`. - -## Phase 8 — typed fit-result object (internal) - -- [x] Introduce a small class (named `FitOutput` at implementation; in - `utils/lmfit.py` — `fitlib` imports `spectra`→`mcp`, so the class - lives below both) with named fields `par_ini`, `par_fin`, - `conf_ci`, `emcee_fin`, `emcee_ci` replacing the raw 5-list. - `fit_wrapper` returns it. Frozen dataclass; `par_fin` is annotated - via a TYPE_CHECKING-only `TypedMinimizerResult` shim (lmfit sets - result attributes dynamically, invisible to pyright). -- [x] Update all internal consumers: the four fit methods, - `_append_*_slot` capture, `results_sbs` per-slice entries, the - MCMC-payload builder, and `Project.fit_2d`'s `SimpleNamespace` - stand-in (now a real `FitOutput` wrapping a minimal - `MinimizerResult`). -- [x] No list-index back-compat: verified by grep that nothing outside - the package (notebooks, docs) indexes `model.result[...]` or - `results_sbs[i][...]`; mocked-result test (`test_file.py`) now - builds a placeholder `FitOutput`. -- [x] Closes the "Unified results object / raw `result[1..4]` cleanup" - TODO item. - -## Completion - -- [ ] Changelog entries for both phases; docs build; full + slow suites. -- [ ] Extend the archive doc (results-ownership-and-plotting.md) with a - Phases 7–8 section; clear PLAN.md; un-tag TODO. +No active multi-step feature. Long-term goals live in [TODO.md](TODO.md); +completed feature records in `docs/design/archive/`. diff --git a/TODO.md b/TODO.md index 0957a7d..c92f1f2 100644 --- a/TODO.md +++ b/TODO.md @@ -16,7 +16,6 @@ - vmap-batched slice-by-slice solver (the one workload where lmfit overhead plausibly dominates; would be the Phase E pilot) — see [docs/design/ui.md](docs/design/ui.md). - `vmap`-batch homogeneous file series in the fused project fit (unrolled per-file fusion shipped in v0.13.0) — see [docs/design/project-level-fits.md](docs/design/project-level-fits.md). - `fit_model_compare`-style runtime JAX parity mode, or a cheaper one-shot pre-fit parity check on the JAX path. -- [ ] `[ACTIVE]` **Unified results object / raw `result[1..4]` cleanup**: the results-data ownership boundary shipped in v0.14.0 (`SavedFitSlot` is the authoritative fit record; `FitResults` is the single read/query/plot surface; `File.get_*` / `plot_*` are thin sugar — see the archived design in `docs/design/`). What remains deferred from that pass: internal code (fit methods, slot capture) still passes the raw `[par_ini, par_fin, conf_ci, emcee_fin, emcee_ci]` list around as `model.result` — decide whether to replace it with a typed result object now that nothing user-facing reads it. - [ ] **Decide how to guard/warn against in-place mutation of user-facing arrays**: internal machinery assumes `File.data`/`energy`/`time` and fit outputs are stable once set — e.g. `SavedFitSlot` stores `params`/`observed`/`fit`/`selection` by reference (`frozen=True` blocks reassignment, not in-place mutation; 2026-07 code review, check 1), and file fingerprints / `observed_sha256` are computed once at slot construction. A user mutating `file.data` in place instead of re-instantiating would desynchronize slots, fit limits, and cached evaluations in ways no single defensive copy fixes — so slot-level copies were considered and declined (2026-07-10) as papering over one symptom. Decide on a systemic stance instead: read-only views (`setflags(write=False)`) on public arrays, copy-on-set in setters, a documented ownership contract, and/or re-hash validation at save time. A sibling case: `Simulator.sigma_data` is recomputed on read from the current `noise_level`/`noise_type` ([simulator.py](src/trspecfit/simulator.py) ~L1057), so calling `set_noise_level`/`set_noise_type` after `simulate()` but before `save_data()` persists a stale or missing `metadata.sigma_data` (the value fed to `File.set_sigma`) that no longer matches the saved noisy data — cheap dedicated fix is to snapshot the derived sigma at simulation time; fold it into whatever stance is chosen. ## User and AI ergonomics diff --git a/docs/design/archive/results-ownership-and-plotting.md b/docs/design/archive/results-ownership-and-plotting.md index 38a0898..e92b2f3 100644 --- a/docs/design/archive/results-ownership-and-plotting.md +++ b/docs/design/archive/results-ownership-and-plotting.md @@ -14,9 +14,9 @@ orphan: true ## The ownership contract - **`Model`/`File` (live layer)** own inputs and fit execution. - `model.result` (the raw `[par_ini, par_fin, conf_ci, emcee_fin, - emcee_ci]` list) is a transient internal of the fit run; nothing - user-facing reads it. + `model.result` (originally the raw `[par_ini, par_fin, conf_ci, + emcee_fin, emcee_ci]` list; a typed `FitOutput` since Phase 8 below) + is a transient internal of the fit run; nothing user-facing reads it. - **`SavedFitSlot`** is the single authoritative record of a completed fit — everything a user can ask about a fit must be in (or derivable from) the slot. @@ -98,8 +98,61 @@ orphan: true ## Deferred -- Typed result object replacing the internal raw `result[1..4]` list - (tracked in the repo-root `TODO.md`). +- Typed result object replacing the internal raw `result[1..4]` list — + completed in Phase 8 (below) rather than deferred after all. - Model rehydration from archives (raw YAML text provenance). - The in-place-mutation guard stance for user-facing arrays (separate TODO item; slots store arrays by reference). + +## Phases 7–8 continuation (same branch, 2026-07-17) + +Two follow-on phases from the post-completion review completed the same +principle in the same release (0.14.0). + +### Phase 7 — remove auto-export; fits never write + +`Project.auto_export`, `Project.path_results`, and `File.model_path` +removed: fit methods compute, display (per `show_output`), and capture +slots — they never touch disk. Persistence is the explicit `save_fits` +(HDF5) / `export_fits` (CSV/PNG tree) pair, both slot-fed, with the +single default output root `./fit_results//` +(`Project.name` default `"test"` → the placeholder `"my_project"`). +A `project.yaml` still setting a removed key fails loudly with +migration guidance. Everything auto-export used to write is +reproducible: + +- MCMC walker-acceptance and corner PNGs → `FitResults.plot_mcmc` + (renders from the persisted slot payload — live sessions and loaded + archives alike; `File.plot_mcmc` sugar). +- SbS per-slice PNGs → `File.plot_sbs_slices` (live `results_sbs` only; + shows the per-slice seeded initial guess and component decomposition + the old PNGs lacked). Deliberately NOT part of `export_fits` — export + stays archive-reproducible, per-slice panels need live inputs. +- Component-decomposed `fit_1d.csv` → `save_baseline_fit` / + `save_spectrum_fit` (kept as explicit calls, no longer auto-invoked). +- Accepted losses: per-slice `par_ini` CSVs (re-derivable from the + persisted `fit_settings` seeding recipe) and `lmfit.fit_report` text + dumps (all contents persisted in the slot). + +`fitlib.fit_wrapper` lost `save_output` / `save_path` / `num_fmt` / +`delim` and its CSV/TXT dump block; emcee diagnostics figures are +display-only. The SbS worker payload shed its IO-only arguments +(`auto_export`, `path_slice`, `plot_config`). + +### Phase 8 — typed `FitOutput` + +The raw 5-list became a frozen dataclass — named `FitOutput`, in +`utils/lmfit.py` because `fitlib` imports `spectra` → `mcp` and `mcp` +needs the annotation (so the class must live below all three). Fields +`par_ini` / `par_fin` / `conf_ci` / `emcee_fin` / `emcee_ci`; +`Model.result` is `FitOutput | None` (was `[]` when unfitted), +`File.results_sbs` holds one per slice, and `Project.fit_2d`'s per-file +stand-in is a real minimal `MinimizerResult` (params/method/nvarys) +instead of a `SimpleNamespace`. lmfit sets result attributes +dynamically (invisible to pyright), so a TYPE_CHECKING-only +`TypedMinimizerResult` shim declares the attributes trspecfit reads, +with casts at the two construction choke points. No list-index +back-compat; `SavedFitSlot` and all `FitResults` accessors unchanged. +Naming consolidation in the same pass: `plt_fit_res_1d(par_init=)` → +`par_ini` (matching the `par_ini`/`par_fin` pair); `show_init` (a verb +phrase) and lmfit's own `init_value` deliberately kept. From 23c938354b9b228d02acb3d6ab203b4eb836d7dd Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Mon, 20 Jul 2026 16:35:06 -0700 Subject: [PATCH 20/29] persist per-component 1D fit data in SavedFitSlot (schema 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Component decomposition for 1D fits (baseline/spectrum/SbS) was live-only — describe_model(detail=1) and plot_sbs_slices could show it, but FitResults.plot_fit never could, live or archived. No researcher trusts a 1D fit whose components they can't see, so persist them directly instead of accepting that gap permanently. - add components/component_names to SavedFitSlot; bump schema 3 -> 4, extend SUPPORTED_READ_VERSIONS; write/read follow the existing additive-optional-field pattern (correl/mcmc). 2d untouched — no per-component concept there. - baseline/spectrum/sbs slot construction evaluates components on the full grid then crops to e_lim, mirroring the fit curve's existing evaluate-then-crop correctness (grid-dependent components don't evaluate the same on a pre-cropped grid). component_names comes directly from model.components, not parsed from params - a static par_profile attachment splices a nested model's params into its host component's name block without adding a components entry, which would break prefix-based re-derivation. - FitResults._plot_fit_1d renders the decomposition when present, falls back to the lean sum-only view for pre-schema-4 archives. - extend the round-trip assertion (F1/F6/F8 families, including the F6 static-profile edge case) with a components round-trip check and a sum-of-components-equals-fit invariant; add schema-3 backward-compat coverage and two focused _plot_fit_1d rendering tests. - update docs/design/fit_archive_schema.md (the stated wire-format contract) with the schema-4 version history entry, the components / component_names dataset section, and the reader-mapping / cheat-sheet table updates; verified with a sphinx -W build. --- PLAN.md | 52 ++++++++++++++++- TODO.md | 5 ++ docs/design/fit_archive_schema.md | 67 ++++++++++++++++++---- src/trspecfit/fit_results.py | 21 ++++++- src/trspecfit/trspecfit.py | 57 +++++++++++++++++++ src/trspecfit/utils/fit_io.py | 68 +++++++++++++++++++--- tests/test_fit_archive_roundtrip.py | 88 +++++++++++++++++++++++++++-- tests/test_fit_history.py | 51 +++++++++++++++++ 8 files changed, 382 insertions(+), 27 deletions(-) diff --git a/PLAN.md b/PLAN.md index 1f851bc..8bb7085 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,4 +1,52 @@ # Active Plan -No active multi-step feature. Long-term goals live in [TODO.md](TODO.md); -completed feature records in `docs/design/archive/`. +## Persist per-component 1D fit data in SavedFitSlot (schema 4) + +Full design/rationale: `/home/yoyo/.claude/plans/hm-i-guess-the-eventual-simon.md` +(session-local; contents mirrored here for repo persistence). + +**Goal**: close the live-vs-archive gap for 1D fit component visibility. +`FitResults.plot_fit` currently shows only observed/fit/residual for +baseline/spectrum/SbS slots; component decomposition is live-only +(`describe_model(detail=1)`, `File.plot_sbs_slices`). Persist components +directly in the slot instead. + +**Decisions**: persist for baseline, spectrum, and every SbS slice +(symmetric); 2D untouched (no component concept there); store explicit +`component_names` labels (not parsed from `params_df` — breaks for +static `par_profile`-attached models); unconditional computation, no +perf opt-out; persistence-only in this pass, no new SbS per-slice +archive viewer yet. + +- [x] **Schema** (`src/trspecfit/utils/fit_io.py`): added `components`/ + `component_names` fields to `SavedFitSlot`; bumped `SCHEMA_VERSION` + 3→4, extended `SUPPORTED_READ_VERSIONS`; `_write_slot`/`_read_slot` + optional-field read/write; threaded through `_slot_from_baseline`/ + `_slot_from_spectrum`/`_slot_from_sbs`. +- [x] **Slot construction** (`src/trspecfit/trspecfit.py`): + `_append_baseline_slot`/`_append_spectrum_slot` evaluate + crop + components alongside the existing fit-curve eval; + `_append_sbs_slot` does the same per-slice inside its existing + parent-process finalization loop. +- [x] **Plotting** (`src/trspecfit/fit_results.py`): `_plot_fit_1d` + renders components when present, falls back to lean sum-only + when `None` (old-schema archives). +- [x] **Tests**: extended `_assert_slot_round_tripped` (schema round-trip + across F1/F6/F8 families, including the F6 static-profile edge + case) with components/component_names + a sum-reconstructs-fit + invariant; added `test_reader_accepts_schema_v3_archive` (schema-3 + backward compat, `components=None`, `plot_fit` still renders); added + `_downgrade_archive_to_v3`; extended `_downgrade_archive_to_v2` to + also strip schema-4 fields; added + `test_plot_fit_1d_renders_components_when_present` / + `test_plot_fit_1d_falls_back_to_lean_when_components_none` in + `test_fit_history.py`. Full suite (1005 tests), mypy, pyright, ruff + all clean. + +**Status**: implementation complete, verified 2026-07-20. Not yet +committed — awaiting user review/approval before commit. + +**Next**: revisit the plot-range-vs-fit-limits inconsistency +(`describe_model`/fit-time displays show full data range, +`FitResults.plot_fit` shows only the fit window) — plan already drafted, +deferred at user's request until this work landed. diff --git a/TODO.md b/TODO.md index c92f1f2..e6a7c13 100644 --- a/TODO.md +++ b/TODO.md @@ -9,6 +9,11 @@ - [ ] **Future `sigma_type` expansion in FitResults**: the constant, user-supplied sigma schema has landed (`SIGMA_TYPE_CONSTANT` in [fit_io.py](src/trspecfit/utils/fit_io.py) ~L54; `validate_noise_metadata` hard-locks `sigma_type` to `"constant"`), so this is now unblocked: extend uncertainty handling beyond scalar `sigma_data`. Keep `noise_type` for the statistical assumption/distribution and use `sigma_type` for sigma shape: initially `constant`, later `per_spectrum` and `per_point`. Add HDF5 storage, validation, baseline/SBS/2D alignment, `compare_models()` behavior, and tests for vector/matrix sigma. Defer automatic Poisson-derived sigma until residual-space variance propagation is explicit. +## Plotting & results + +- [ ] **Archive-portable SbS per-slice viewer**: once `SavedFitSlot` persists per-slice components (see `PLAN.md`), add a `FitResults` method mirroring `File.plot_sbs_slices`'s per-slice panel layout but sourced from the persisted slot instead of live `results_sbs`, so it works from a loaded archive with no live session. Note it will remain a strict subset of the live version — the live diagnostic also overlays the per-slice seeded initial guess, which is never part of a fit result and can't be persisted. +- [ ] **Compiled-plan (GIR/`ScheduledPlan`) persistence for archive-portable diagnostics**: considered (2026-07-19) as a mechanism for reconstructing 1D component decomposition from an archive without a live `Model`, and declined in favor of directly persisting component curves (see `PLAN.md`) because it needs real new work first: neither `evaluate_1d` nor `evaluate_2d` supports per-component retention today (both fold every op into one accumulator; `spectra.fit_model_gir` already falls back to the live `mcp.Model` interpreter whenever components are requested), `ScheduledPlan1D`/`2D` drop component names at compile time (would need a side-channel), and there's no existing serializer for the plan's ~30 heterogeneous array fields. Worth revisiting as a general "resume analysis on any persisted fit" capability, independent of the component-visibility problem it was first proposed for. + ## Performance & architecture - [ ] **JAX backend follow-ons**: the backend itself shipped in v0.12.0 (Phases A–D of [docs/design/jax-planning.md](docs/design/jax-planning.md); execution record in [docs/design/archive/jax-backend.md](docs/design/archive/jax-backend.md)). Remaining candidates, none scheduled: diff --git a/docs/design/fit_archive_schema.md b/docs/design/fit_archive_schema.md index d038ce4..2e207f9 100644 --- a/docs/design/fit_archive_schema.md +++ b/docs/design/fit_archive_schema.md @@ -1,4 +1,4 @@ -# Fit-archive HDF5 schema (schema_version 3) +# Fit-archive HDF5 schema (schema_version 4) On-disk layout for the fit-results archive written by `Project.save_fits()` and read by `FitResults.load()` / `Project.load_fits()`. The object model @@ -87,7 +87,7 @@ dtypes. │ project_name : str # Project.name; set on first write │ timestamp_created : str # ISO 8601 UTC, first write │ timestamp_updated : str # ISO 8601 UTC, most recent write -│ schema_version : str # "3"; bump on incompatible change +│ schema_version : str # "4"; bump on incompatible change └── files/ # group; one subgroup per file ├── 000000/ # SavedFile (see "File group") └── 000001/... @@ -101,7 +101,7 @@ must not recreate the archive on subsequent saves unless the caller explicitly asks for that; the canonical way to start fresh is to choose a new path. -`schema_version` is currently `"3"`. Version history: +`schema_version` is currently `"4"`. Version history: - `"1"` → `"2"`: the σ-calibrated chi-square columns and per-slot sigma metadata changed the stored fields — a clean break, so schema-1 archives @@ -113,6 +113,15 @@ a new path. `utils/fit_io.py`); schema-2 archives load with the new fields as `None`. The writer still refuses to append to an archive whose version differs from its own — re-save to a new path to migrate. +- `"3"` → `"4"` (2026-07): **additive** — slot `components` and + `component_names` datasets for 1D fit types (baseline, spectrum, sbs). + Never present for `fit_type == "2d"` — there is no per-component + concept there. The reader accepts `"2"`, `"3"`, and `"4"` + (`SUPPORTED_READ_VERSIONS` in `utils/fit_io.py`); schema-2/3 archives + load with `components` / `component_names` as `None`, and + `FitResults.plot_fit` falls back to a sum-only rendering in that case. + The writer still refuses to append to an archive whose version differs + from its own. Future incompatible changes (e.g. project-scoped joint-result slots or `keep_history=True` full-log save — both deferred, see "What's *not* in @@ -212,7 +221,9 @@ files/000000/slots/000000/ ├── metrics_per_slice (opt) # 1D structured dataset; sbs only ├── conf_ci (opt) # heterogeneous-DataFrame dataset; see "conf_ci dataset" ├── correl (opt) # all-numeric DataFrame dataset; see "correl dataset" -└── mcmc/ (opt) # see "mcmc group" +├── mcmc/ (opt) # see "mcmc group" +├── components (opt) # 1D fit types only; see "components dataset"; schema ≥ 4 +└── component_names (opt) # present iff components is; see "components dataset"; schema ≥ 4 ``` `(cond)` = present iff `fit_type != "sbs"`. SbS metrics live in the @@ -220,8 +231,9 @@ files/000000/slots/000000/ scalars. `(opt)` = present iff the corresponding `SavedFitSlot` field is non-`None` -(`conf_ci`, `correl`, `mcmc`) or applicable to the fit type -(`metrics_per_slice` is sbs-only). +(`conf_ci`, `correl`, `mcmc`, `components`, `component_names`) or +applicable to the fit type (`metrics_per_slice` is sbs-only; `components` +/ `component_names` are never present for `fit_type == "2d"`). ### `archive_slot_key` vs `history_key` @@ -436,6 +448,35 @@ Within the group: emcee did not expose it); the reader maps absence to `None` in the payload dict. +## `components` / `component_names` (optional; schema ≥ 4) + +Per-component fit curves for 1D fit types (baseline, spectrum, sbs), +evaluated at final params on the same grid as `fit`. Never present for +`fit_type == "2d"` — there is no per-component concept there. + +``` +components : ndarray (preserves source dtype) + baseline / spectrum : shape (n_components, n_e_view) + sbs : shape (n_slices, n_components, n_e_view) +component_names : 1D vlen-utf8 dataset, shape (n_components,) + component labels; order matches components' component axis +``` + +Both fields are omitted together — `components` is `None` on the object +model iff `component_names` is. `component_names` is captured directly +from `[comp.name for comp in model.components]` at fit time rather than +re-derived from `params.name`: a static (`dim == 1`) attached +`par_profile` splices a nested model's parameters into its host +component's name block without adding a distinct `model.components` +entry, which would break any prefix-based re-derivation from parameter +names. Summing `components` along its component axis reconstructs `fit` +exactly (verified by `_assert_slot_round_tripped` in +`tests/test_fit_archive_roundtrip.py`). + +Absent in schema-2/3 archives; the reader maps absence to `None` for +both fields, and `FitResults.plot_fit` falls back to the pre-schema-4 +sum-only 1D rendering when `components is None`. + ## Reader → object-model mapping Per slot, the reader produces a `SavedFitSlot` with: @@ -463,6 +504,8 @@ Per slot, the reader produces a `SavedFitSlot` with: | `conf_ci` | `conf_ci` dataset → DataFrame, or `None` if absent | | `correl` | `correl` dataset → DataFrame (index restored from `columns`), or `None` if absent | | `mcmc` | `mcmc/` group → dict, or `None` if absent | +| `components` | `components` dataset → ndarray, or `None` if absent | +| `component_names` | `component_names` dataset → list of str, or `None` if absent | `history_key` is persisted as a non-authoritative attr but recomputed by the reader (see "`archive_slot_key` vs `history_key`"). The on-disk @@ -471,12 +514,12 @@ on the returned `SavedFitSlot` always comes from the live recompute. ## Per-fit-type cheat sheet -| fit_type | `observed.shape` | `params` layout | metrics location | sbs-only datasets | t_lim applied | -|------------|-------------------------|--------------------------------------|---------------------------|-----------------------|---------------| -| baseline | `(n_e_view,)` | structured (long, named columns) | scalar attrs | — | n/a | -| spectrum | `(n_e_view,)` | structured (long, named columns) | scalar attrs | — | n/a | -| sbs | `(n_t_full, n_e_view)` | 2D float64 + `columns` attr (wide) | `metrics_per_slice` | `metrics_per_slice` | **no** | -| 2d | `(n_t_view, n_e_view)` | structured (long, named columns) | scalar attrs | — | yes | +| fit_type | `observed.shape` | `params` layout | metrics location | `components.shape` | sbs-only datasets | t_lim applied | +|------------|-------------------------|--------------------------------------|---------------------------|----------------------------------------|-----------------------|---------------| +| baseline | `(n_e_view,)` | structured (long, named columns) | scalar attrs | `(n_components, n_e_view)` | — | n/a | +| spectrum | `(n_e_view,)` | structured (long, named columns) | scalar attrs | `(n_components, n_e_view)` | — | n/a | +| sbs | `(n_t_full, n_e_view)` | 2D float64 + `columns` attr (wide) | `metrics_per_slice` | `(n_t_full, n_components, n_e_view)` | `metrics_per_slice` | **no** | +| 2d | `(n_t_view, n_e_view)` | structured (long, named columns) | scalar attrs | always `None` | — | yes | `n_e_view` denotes the energy axis cropped by `e_lim`; `n_t_view` denotes the time axis cropped by `t_lim`. `n_t_full` is the file's full diff --git a/src/trspecfit/fit_results.py b/src/trspecfit/fit_results.py index 9aa3228..a6c697c 100644 --- a/src/trspecfit/fit_results.py +++ b/src/trspecfit/fit_results.py @@ -683,7 +683,7 @@ def _plot_fit_1d( config: Any, show_plot: bool, ) -> Any: - """Observed + fit over a residual panel for a 1D slot.""" + """Observed + fit (with components, when persisted) over a residual panel.""" import matplotlib.pyplot as plt @@ -703,7 +703,24 @@ def _plot_fit_1d( height_ratios=[3, 1], ) ax_fit.plot(x, obs, "k.", ms=3, label="observed") - ax_fit.plot(x, fit, "-", lw=1.5, label="fit") + if slot.components is not None: + # schema >= 4: render the persisted per-component decomposition, + # matching fitlib.plt_fit_res_1d's live visual style. + colors = list( + plt.rcParams["axes.prop_cycle"].by_key().get("color", ["#1f77b4"]) + ) + names = slot.component_names or [ + f"component {i}" for i in range(slot.components.shape[0]) + ] + for p, (peak, name) in enumerate(zip(slot.components, names, strict=True)): + color = colors[p % len(colors)] + ax_fit.plot( + x, peak, color=color, linestyle="-", linewidth=2, label=name + ) + ax_fit.fill_between(x, 0, peak, facecolor=color, alpha=0.5) + ax_fit.plot(x, fit, "-", lw=1.5, color="#000000", label="fit") + else: + ax_fit.plot(x, fit, "-", lw=1.5, label="fit") ax_fit.set_ylabel("intensity") ax_fit.legend(fontsize="small") ax_fit.set_title(f"{slot.model_name} ({slot.fit_type})") diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index ee7f4b4..b6b9b3a 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -3369,6 +3369,22 @@ def _append_baseline_slot( else: observed = self.data_base.copy() fit_arr = fit_full.copy() + # Per-component decomposition, evaluated on the full grid (same + # correctness reasoning as fit_full above) then cropped to e_lim. + from trspecfit import spectra + + par_fin_vals = ulmfit.par_extract(result_fin.params, return_type="list") + assert self.model_base.args is not None # type guard + components_full = getattr(spectra, fit_fun_str)( + self.energy, par_fin_vals, False, *self.model_base.args + ) + component_names = [comp.name for comp in self.model_base.components] + if e_lim: + components = np.stack( + [np.asarray(c)[e_lim[0] : e_lim[1]] for c in components_full], axis=0 + ) + else: + components = np.stack([np.asarray(c) for c in components_full], axis=0) params_df = ulmfit.par_to_df( result_fin.params, col_type="min", @@ -3404,6 +3420,8 @@ def _append_baseline_slot( correl=correl, mcmc=mcmc, fit_settings=fit_settings, + components=components, + component_names=component_names, ) self.p._fit_history.append(slot) return slot @@ -3446,6 +3464,22 @@ def _append_spectrum_slot( else: observed = self.data_spec.copy() fit_arr = fit_full.copy() + # Per-component decomposition, evaluated on the full grid (same + # correctness reasoning as fit_full above) then cropped to e_lim. + from trspecfit import spectra + + par_fin_vals = ulmfit.par_extract(result_fin.params, return_type="list") + assert self.model_spec.args is not None # type guard + components_full = getattr(spectra, fit_fun_str)( + self.energy, par_fin_vals, False, *self.model_spec.args + ) + component_names = [comp.name for comp in self.model_spec.components] + if e_lim: + components = np.stack( + [np.asarray(c)[e_lim[0] : e_lim[1]] for c in components_full], axis=0 + ) + else: + components = np.stack([np.asarray(c) for c in components_full], axis=0) params_df = ulmfit.par_to_df( result_fin.params, col_type="min", @@ -3480,6 +3514,8 @@ def _append_spectrum_slot( correl=correl, mcmc=mcmc, fit_settings=fit_settings, + components=components, + component_names=component_names, ) self.p._fit_history.append(slot) return slot @@ -3511,8 +3547,13 @@ def _append_sbs_slot( e_lim = list(self.e_lim) if self.e_lim else None # Per-slice observed view + per-slice model evaluation. residual_fun # is called once per slice with that slice's final params. + from trspecfit import spectra + + assert self.model_sbs.args is not None # type guard observed_rows = [] fit_rows = [] + component_rows = [] + component_names = [comp.name for comp in self.model_sbs.components] for s_i in range(n_slices): slice_data = self.data[s_i] slice_par = self.results_sbs[s_i].par_fin.params @@ -3526,14 +3567,28 @@ def _append_sbs_slot( res_type="fit", ) ) + slice_par_vals = ulmfit.par_extract(slice_par, return_type="list") + components_full = getattr(spectra, fit_fun_str)( + self.energy, slice_par_vals, False, *self.model_sbs.args + ) if e_lim: observed_rows.append(slice_data[e_lim[0] : e_lim[1]].copy()) fit_rows.append(fit_full[e_lim[0] : e_lim[1]].copy()) + component_rows.append( + np.stack( + [np.asarray(c)[e_lim[0] : e_lim[1]] for c in components_full], + axis=0, + ) + ) else: observed_rows.append(slice_data.copy()) fit_rows.append(fit_full.copy()) + component_rows.append( + np.stack([np.asarray(c) for c in components_full], axis=0) + ) observed = np.stack(observed_rows, axis=0) fit_arr = np.stack(fit_rows, axis=0) + components = np.stack(component_rows, axis=0) # Per-slice DataFrame (one row per slice, columns = parameter values). params_df = ulmfit.list_of_par_to_df(self.results_sbs) # n_free_pars + fit_alg captured from slice 0 (consistent across slices @@ -3582,6 +3637,8 @@ def _append_sbs_slot( params_meta=params_meta, params_stderr=params_stderr, fit_settings=fit_settings, + components=components, + component_names=component_names, ) self.p._fit_history.append(slot) return slot diff --git a/src/trspecfit/utils/fit_io.py b/src/trspecfit/utils/fit_io.py index 981bb4f..7dd81c3 100644 --- a/src/trspecfit/utils/fit_io.py +++ b/src/trspecfit/utils/fit_io.py @@ -44,11 +44,13 @@ from trspecfit.utils.hdf5 import require_dataset, require_group FitType = Literal["baseline", "spectrum", "sbs", "2d"] -SCHEMA_VERSION = "3" +SCHEMA_VERSION = "4" # Schema 3 is additive over 2 (slot `correl` dataset, mcmc -# `acceptance_fraction` dataset), so the reader accepts both; the writer -# still refuses cross-version appends (see _classify_archive_for_write). -SUPPORTED_READ_VERSIONS = ("2", "3") +# `acceptance_fraction` dataset). Schema 4 is additive over 3 (slot +# `components` / `component_names` datasets for 1D fit types — baseline, +# spectrum, sbs; never present for 2d). The reader accepts all three; the +# writer still refuses cross-version appends (see _classify_archive_for_write). +SUPPORTED_READ_VERSIONS = ("2", "3", "4") # Default noise metadata used when no σ has been set on the File. Mirrors the # project.yaml defaults defined in ``Project._set_defaults``; if you change one, @@ -256,6 +258,21 @@ class SavedFitSlot: ``{"flatchain", "ci", "lnsigma", "acceptance_fraction"}`` if MCMC ran, else ``None``. ``acceptance_fraction`` is emcee's per-walker array (``None`` in slots loaded from schema-2 archives). + components : np.ndarray | None + Per-component fit curves on the same grid as ``fit``, evaluated at + final params. ``None`` for ``fit_type == "2d"`` (no per-component + concept there) and for slots loaded from schema < 4 archives. + Shape ``(n_components, energy_in_lim)`` for baseline/spectrum; + ``(n_slices, n_components, energy_in_lim)`` for sbs. + component_names : list of str | None + Component labels, same order as ``components``' component axis — + ``[comp.name for comp in model.components]`` at fit time. Persisted + explicitly rather than parsed from ``params.name`` because a + static (``dim == 1``) attached ``par_profile`` splices a nested + model's parameters into its host component's name block without + adding a distinct ``model.components`` entry, breaking any + prefix-based re-derivation. ``None`` exactly when ``components`` + is ``None``. """ file_fingerprint: dict[str, Any] @@ -284,6 +301,8 @@ class SavedFitSlot: params_meta: pd.DataFrame | None = None params_stderr: pd.DataFrame | None = None fit_settings: dict[str, Any] | None = None + components: np.ndarray | None = None + component_names: list[str] | None = None # @@ -607,6 +626,8 @@ def _slot_from_baseline( correl: pd.DataFrame | None = None, mcmc: dict[str, Any] | None = None, fit_settings: dict[str, Any] | None = None, + components: np.ndarray | None = None, + component_names: list[str] | None = None, ) -> SavedFitSlot: """ Build a SavedFitSlot for a completed baseline fit. @@ -641,6 +662,8 @@ def _slot_from_baseline( sigma_source=sigma_source, sigma_type=sigma_type, sigma_data=sigma_data, + components=components, + component_names=component_names, ) @@ -668,6 +691,8 @@ def _slot_from_spectrum( correl: pd.DataFrame | None = None, mcmc: dict[str, Any] | None = None, fit_settings: dict[str, Any] | None = None, + components: np.ndarray | None = None, + component_names: list[str] | None = None, ) -> SavedFitSlot: """Build a SavedFitSlot for a completed spectrum fit. @@ -702,6 +727,8 @@ def _slot_from_spectrum( sigma_source=sigma_source, sigma_type=sigma_type, sigma_data=sigma_data, + components=components, + component_names=component_names, ) @@ -729,13 +756,16 @@ def _slot_from_sbs( params_meta: pd.DataFrame | None = None, params_stderr: pd.DataFrame | None = None, fit_settings: dict[str, Any] | None = None, + components: np.ndarray | None = None, + component_names: list[str] | None = None, ) -> SavedFitSlot: """ Build a SavedFitSlot for a completed slice-by-slice fit. - ``observed`` and ``fit`` are 2D arrays (slices x energy_in_lim). ``metrics`` - values are per-slice 1D arrays. ``params_df`` is the SbS DataFrame - (one row per slice). + ``observed`` and ``fit`` are 2D arrays (slices x energy_in_lim). + ``metrics`` values are per-slice 1D arrays. ``params_df`` is the SbS + DataFrame (one row per slice). ``components`` is 3D + ``(n_slices, n_components, energy_in_lim)`` when provided. """ selection = { @@ -784,6 +814,8 @@ def _slot_from_sbs( params_meta=params_meta, params_stderr=params_stderr, fit_settings=fit_settings, + components=components, + component_names=component_names, ) @@ -866,6 +898,8 @@ def _build_slot( sigma_source: str, sigma_type: str, sigma_data: float, + components: np.ndarray | None = None, + component_names: list[str] | None = None, ) -> SavedFitSlot: """Shared scalar-metric path for baseline / spectrum / 2d.""" @@ -909,6 +943,8 @@ def _build_slot( correl=correl, mcmc=mcmc, fit_settings=fit_settings, + components=components, + component_names=component_names, ) @@ -1451,6 +1487,15 @@ def _write_slot( ) if slot.mcmc is not None: _write_mcmc_group(slot_group, slot.mcmc) + if slot.components is not None: + slot_group.create_dataset( + "components", data=np.ascontiguousarray(slot.components) + ) + assert slot.component_names is not None # type guard + slot_group.create_dataset( + "component_names", + data=np.array(slot.component_names, dtype=_VLEN_STR), + ) # @@ -1758,6 +1803,13 @@ def _read_slot( if mcmc_obj is not None else None ) + components_obj = slot_group.get("components") + components: np.ndarray | None = None + component_names: list[str] | None = None + if components_obj is not None: + components = np.asarray(require_dataset(components_obj, "components")[...]) + names_obj = require_dataset(slot_group["component_names"], "component_names") + component_names = [_to_str_value(v) for v in names_obj[...]] yaml_filename = _attr_str(a["yaml_filename"]) if "yaml_filename" in a else None selection = json.loads(selection_json) @@ -1797,6 +1849,8 @@ def _read_slot( params_meta=params_meta, params_stderr=params_stderr, fit_settings=fit_settings, + components=components, + component_names=component_names, ) diff --git a/tests/test_fit_archive_roundtrip.py b/tests/test_fit_archive_roundtrip.py index 1afcfed..f8247cc 100644 --- a/tests/test_fit_archive_roundtrip.py +++ b/tests/test_fit_archive_roundtrip.py @@ -182,6 +182,29 @@ def _assert_slot_round_tripped(loaded: SavedFitSlot, original: SavedFitSlot) -> assert loaded.yaml_filename == original.yaml_filename assert loaded.timestamp == original.timestamp + # --- components (schema 4; None for 2d) ----------------------------- + if original.fit_type == "2d": + assert original.components is None + assert loaded.components is None + assert original.component_names is None + assert loaded.component_names is None + else: + assert original.components is not None + assert loaded.components is not None + np.testing.assert_array_equal(loaded.components, original.components) + assert loaded.component_names == original.component_names + if original.fit_type == "sbs": + assert loaded.components.shape[0] == loaded.observed.shape[0] # n_slices + assert loaded.components.shape[2] == loaded.observed.shape[1] # n_energy + assert loaded.components.shape[1] == len(loaded.component_names) + recon = np.sum(loaded.components, axis=1) + else: + assert loaded.components.shape[1] == loaded.observed.shape[0] + assert loaded.components.shape[0] == len(loaded.component_names) + recon = np.sum(loaded.components, axis=0) + # Components must sum back to the persisted fit curve. + np.testing.assert_allclose(recon, loaded.fit, rtol=1e-8, atol=1e-8) + # --- residual reconstruction (design invariant) -------------------- # chi2_raw is the lmfit-unweighted SSE diagnostic; chi2 is σ-calibrated # and NaN when no σ was set on the file, so we cross-check against the raw @@ -514,11 +537,11 @@ def test_correl_roundtrip(tmp_path) -> None: # def _downgrade_archive_to_v2(archive_path) -> None: - """Rewrite a schema-3 archive as schema 2 in place: relabel the version + """Rewrite a schema-4 archive as schema 2 in place: relabel the version and delete the schema-3 additions (slot ``correl`` / ``params_meta`` / ``params_stderr`` datasets, ``fit_settings`` attr, mcmc - ``acceptance_fraction``) so the payload matches what a v2 writer - produced.""" + ``acceptance_fraction``) plus the schema-4 additions (``components`` / + ``component_names``) so the payload matches what a v2 writer produced.""" import h5py @@ -534,7 +557,13 @@ def _downgrade_archive_to_v2(archive_path) -> None: slots = require_group(slots_obj, "slots") for s_key in slots: sg = require_group(slots[s_key], s_key) - for ds in ("correl", "params_meta", "params_stderr"): + for ds in ( + "correl", + "params_meta", + "params_stderr", + "components", + "component_names", + ): if ds in sg: del sg[ds] meta = require_group(sg["metadata"], "metadata") @@ -546,6 +575,31 @@ def _downgrade_archive_to_v2(archive_path) -> None: del mcmc_group["acceptance_fraction"] +# +def _downgrade_archive_to_v3(archive_path) -> None: + """Rewrite a schema-4 archive as schema 3 in place: relabel the version + and delete only the schema-4 additions (slot ``components`` / + ``component_names``), keeping every schema-3 field intact.""" + + import h5py + + from trspecfit.utils.hdf5 import require_group + + with h5py.File(archive_path, "r+") as h5: + require_group(h5["metadata"], "metadata").attrs["schema_version"] = "3" + files_group = require_group(h5["files"], "files") + for f_key in files_group: + slots_obj = require_group(files_group[f_key], f_key).get("slots") + if slots_obj is None: + continue + slots = require_group(slots_obj, "slots") + for s_key in slots: + sg = require_group(slots[s_key], s_key) + for ds in ("components", "component_names"): + if ds in sg: + del sg[ds] + + # def test_reader_accepts_schema_v2_archive(tmp_path) -> None: """Schema 3 is additive, so v2 archives must still load — with the @@ -573,6 +627,32 @@ def test_reader_accepts_schema_v2_archive(tmp_path) -> None: _assert_params_equal(slot.params, original.params, fit_type="baseline") +# +def test_reader_accepts_schema_v3_archive(tmp_path) -> None: + """Schema 4 is additive, so v3 archives must still load — with + components/component_names as None, and FitResults.plot_fit falling + back to the lean sum-only rendering (no live Model needed).""" + + _, fit_file, family = _build_fit_file("F1") + fit_file.fit_baseline(model_name=family.model_name("default"), stages=1, try_ci=0) + archive_path = tmp_path / "v3.fit.h5" + fit_file.p.save_fits(archive_path, show_output=0) + _downgrade_archive_to_v3(archive_path) + + loaded = FitResults.load(archive_path) + assert len(loaded) == 1 + slot = next(iter(loaded)) + assert slot.components is None + assert slot.component_names is None + + import matplotlib.pyplot as plt + + try: + loaded.plot_fit(file=slot.file_name, fit_type="baseline", show_plot=False) + finally: + plt.close("all") + + # def test_reader_rejects_unknown_schema_version(tmp_path) -> None: """Versions outside SUPPORTED_READ_VERSIONS raise a clear ValueError.""" diff --git a/tests/test_fit_history.py b/tests/test_fit_history.py index fc88e7a..9c8a70a 100644 --- a/tests/test_fit_history.py +++ b/tests/test_fit_history.py @@ -1609,6 +1609,57 @@ def test_plot_fit_from_loaded_archive_has_axes(self, tmp_path): finally: plt.close("all") + # + def test_plot_fit_1d_renders_components_when_present(self): + """schema >= 4: components/component_names present -> one line + + one fill_between per component, plus the observed/fit lines.""" + + import dataclasses + + import matplotlib.pyplot as plt + + obs = np.array([1.0, 2.0, 3.0, 4.0]) + comp_a = np.array([0.6, 1.2, 1.8, 2.4]) + comp_b = np.array([0.4, 0.8, 1.2, 1.6]) + slot = dataclasses.replace( + _slot_stub(), + observed=obs, + fit=comp_a + comp_b, + components=np.stack([comp_a, comp_b], axis=0), + component_names=["peak_a", "peak_b"], + ) + fig = FitResults._plot_fit_1d(slot, energy=None, config=None, show_plot=False) + try: + ax_fit = fig.axes[0] + labels = [line.get_label() for line in ax_fit.lines] + assert "peak_a" in labels + assert "peak_b" in labels + assert "fit" in labels + assert "observed" in labels + # one fill_between per component + assert len(ax_fit.collections) == 2 + fit_line = next(line for line in ax_fit.lines if line.get_label() == "fit") + np.testing.assert_array_equal(fit_line.get_ydata(), comp_a + comp_b) + finally: + plt.close(fig) + + # + def test_plot_fit_1d_falls_back_to_lean_when_components_none(self): + """Older-schema slots (components=None) keep the sum-only rendering.""" + + import matplotlib.pyplot as plt + + slot = _slot_stub() + assert slot.components is None + fig = FitResults._plot_fit_1d(slot, energy=None, config=None, show_plot=False) + try: + ax_fit = fig.axes[0] + labels = [line.get_label() for line in ax_fit.lines] + assert labels == ["observed", "fit"] + assert len(ax_fit.collections) == 0 + finally: + plt.close(fig) + # @staticmethod def _fake_sbs_results(*, vary=(True, False, True)): From 5c97fb6a8070229d5703e77660a32edb7cf7ed7c Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Mon, 20 Jul 2026 17:40:21 -0700 Subject: [PATCH 21/29] persist aux_axis at the per-file archive level (schema 4 -> 5) The fit archive already stores the full, uncropped data/energy/time once per file (SavedFile), independent of any slot's cropped observed/fit. aux_axis (File.aux_axis, the auxiliary physical axis used by par_profile-attached models, e.g. depth) was the one array in that same family never persisted - reloading an archive with no live File loses it entirely. Add it now, before the archive's full-data story is otherwise considered complete. - add optional aux_axis field to SavedFile; bump schema 4 -> 5, extend SUPPORTED_READ_VERSIONS; write/read follow the conditional-write / .get()-fallback pattern already used for correl/mcmc/components (not data/energy/time's unconditional write - most files have no aux axis at all). - thread live.aux_axis through the SavedFile construction used by save_fits. - extend the F1/F6/F8 baseline round-trip test with an aux_axis check (present + array-equal for F6/F8, None for F1, via the loaded FitResults axes-provider); add a schema-4 downgrade helper and a compat test confirming pre-5 archives still load with aux_axis=None. - update docs/design/fit_archive_schema.md (the stated wire-format contract) with the schema-5 version history entry and the aux_axis file-group layout entry; verified with a sphinx -W build. --- PLAN.md | 89 ++++++++++++++++------------- docs/design/fit_archive_schema.md | 22 +++++-- src/trspecfit/trspecfit.py | 1 + src/trspecfit/utils/fit_io.py | 21 ++++++- tests/test_fit_archive_roundtrip.py | 46 ++++++++++++++- 5 files changed, 130 insertions(+), 49 deletions(-) diff --git a/PLAN.md b/PLAN.md index 8bb7085..1e85816 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,52 +1,59 @@ # Active Plan -## Persist per-component 1D fit data in SavedFitSlot (schema 4) +## Persist `aux_axis` at the per-file archive level (schema 4 → 5) Full design/rationale: `/home/yoyo/.claude/plans/hm-i-guess-the-eventual-simon.md` (session-local; contents mirrored here for repo persistence). -**Goal**: close the live-vs-archive gap for 1D fit component visibility. -`FitResults.plot_fit` currently shows only observed/fit/residual for -baseline/spectrum/SbS slots; component decomposition is live-only -(`describe_model(detail=1)`, `File.plot_sbs_slices`). Persist components -directly in the slot instead. +**Goal**: while investigating the live-vs-archive display-range question, +found that the archive already persists the full, uncropped `data`/ +`energy`/`time` once per file (`SavedFile`), but not `aux_axis` +(`File.aux_axis` — the auxiliary physical axis used by `par_profile` +models). Reloading an archive with no live `File` loses it entirely. +Added it, following the exact `data`/`energy`/`time` pattern. -**Decisions**: persist for baseline, spectrum, and every SbS slice -(symmetric); 2D untouched (no component concept there); store explicit -`component_names` labels (not parsed from `params_df` — breaks for -static `par_profile`-attached models); unconditional computation, no -perf opt-out; persistence-only in this pass, no new SbS per-slice -archive viewer yet. - -- [x] **Schema** (`src/trspecfit/utils/fit_io.py`): added `components`/ - `component_names` fields to `SavedFitSlot`; bumped `SCHEMA_VERSION` - 3→4, extended `SUPPORTED_READ_VERSIONS`; `_write_slot`/`_read_slot` - optional-field read/write; threaded through `_slot_from_baseline`/ - `_slot_from_spectrum`/`_slot_from_sbs`. -- [x] **Slot construction** (`src/trspecfit/trspecfit.py`): - `_append_baseline_slot`/`_append_spectrum_slot` evaluate + crop - components alongside the existing fit-curve eval; - `_append_sbs_slot` does the same per-slice inside its existing - parent-process finalization loop. -- [x] **Plotting** (`src/trspecfit/fit_results.py`): `_plot_fit_1d` - renders components when present, falls back to lean sum-only - when `None` (old-schema archives). -- [x] **Tests**: extended `_assert_slot_round_tripped` (schema round-trip - across F1/F6/F8 families, including the F6 static-profile edge - case) with components/component_names + a sum-reconstructs-fit - invariant; added `test_reader_accepts_schema_v3_archive` (schema-3 - backward compat, `components=None`, `plot_fit` still renders); added - `_downgrade_archive_to_v3`; extended `_downgrade_archive_to_v2` to - also strip schema-4 fields; added - `test_plot_fit_1d_renders_components_when_present` / - `test_plot_fit_1d_falls_back_to_lean_when_components_none` in - `test_fit_history.py`. Full suite (1005 tests), mypy, pyright, ruff - all clean. +- [x] **Schema** (`src/trspecfit/utils/fit_io.py`): added + `aux_axis: np.ndarray | None = None` to `SavedFile`; bumped + `SCHEMA_VERSION` 4→5, extended `SUPPORTED_READ_VERSIONS`; + `_write_file_payload`/`_read_file` optional-field write/read + (conditional write, `.get()` + `None` fallback — not the + unconditional pattern used for `data`/`energy`/`time`). +- [x] **Writer call site** (`src/trspecfit/trspecfit.py`): threaded + `aux_axis=live.aux_axis` into the `fit_io.SavedFile(...)` + construction used by `save_fits`. +- [x] **Tests**: extended `test_baseline_roundtrip` (F1/F6/F8) with an + `aux_axis` round-trip assertion (non-`None` + array-equal for + F6/F8, `None` for F1) via the loaded `FitResults._files_by_fp` + provider; added `_downgrade_archive_to_v4` + + `test_reader_accepts_schema_v4_archive` (pre-5 archives load with + `aux_axis=None`). Full suite (1006 tests), mypy, pyright, ruff all + clean. +- [x] **Docs**: `docs/design/fit_archive_schema.md` — bumped documented + `schema_version` to `"5"`, added the 4→5 version-history entry, the + `aux_axis` line in the file-group layout diagram, and a notes- + section callout on the omit-if-`None` write rule and the + fingerprint exclusion. Verified with a `sphinx -W` build. **Status**: implementation complete, verified 2026-07-20. Not yet committed — awaiting user review/approval before commit. -**Next**: revisit the plot-range-vs-fit-limits inconsistency -(`describe_model`/fit-time displays show full data range, -`FitResults.plot_fit` shows only the fit window) — plan already drafted, -deferred at user's request until this work landed. +**Out of scope (deliberately deferred)**: exposing `aux_axis` through +`FitResults._axes_for` or any plotting method (no current consumer needs +it yet); adding `aux_axis` to `file_fingerprint` identity hashing. + +**Next**: return to the live-vs-archive 1D post-fit display-range +inconsistency. Investigation this session found `fit_2d`/ +`fit_slice_by_slice` already route their post-fit live display through +`self.plot_fit(...)` (so 2D/SbS are already consistent live vs. archive); +only `fit_baseline`/`fit_spectrum` still call `fitlib.plt_fit_res_1d` +directly with the full, uncropped data. Chosen fix (confirmed with user): +route those two through `self.plot_fit(...)` too, matching `fit_2d`/ +`fit_slice_by_slice` — this drops the `show_init` initial-guess overlay +from post-fit display (confirmed acceptable: init guess isn't a +completed-fit artifact) and deletes a chunk of now-dead code +(`initial_guess` extraction, bespoke title construction) in both methods. +Also needs a small title-informativeness enhancement in +`FitResults._plot_fit_1d` (file name, yaml stem, spectrum's time +selection) since it becomes the sole 1D post-fit display path. Needs a +fresh plan file before implementing (the plan file was overwritten for +this aux_axis detour). diff --git a/docs/design/fit_archive_schema.md b/docs/design/fit_archive_schema.md index 2e207f9..46ff425 100644 --- a/docs/design/fit_archive_schema.md +++ b/docs/design/fit_archive_schema.md @@ -1,4 +1,4 @@ -# Fit-archive HDF5 schema (schema_version 4) +# Fit-archive HDF5 schema (schema_version 5) On-disk layout for the fit-results archive written by `Project.save_fits()` and read by `FitResults.load()` / `Project.load_fits()`. The object model @@ -87,7 +87,7 @@ dtypes. │ project_name : str # Project.name; set on first write │ timestamp_created : str # ISO 8601 UTC, first write │ timestamp_updated : str # ISO 8601 UTC, most recent write -│ schema_version : str # "4"; bump on incompatible change +│ schema_version : str # "5"; bump on incompatible change └── files/ # group; one subgroup per file ├── 000000/ # SavedFile (see "File group") └── 000001/... @@ -101,7 +101,7 @@ must not recreate the archive on subsequent saves unless the caller explicitly asks for that; the canonical way to start fresh is to choose a new path. -`schema_version` is currently `"4"`. Version history: +`schema_version` is currently `"5"`. Version history: - `"1"` → `"2"`: the σ-calibrated chi-square columns and per-slot sigma metadata changed the stored fields — a clean break, so schema-1 archives @@ -122,6 +122,14 @@ a new path. `FitResults.plot_fit` falls back to a sum-only rendering in that case. The writer still refuses to append to an archive whose version differs from its own. +- `"4"` → `"5"` (2026-07): **additive** — per-file (not per-slot) optional + `aux_axis` dataset (`File.aux_axis`, the auxiliary physical axis used by + `par_profile`-attached models, e.g. depth). Sits alongside `data` / + `energy` / `time` in the file group, not under `slots/`. The reader + accepts `"2"` through `"5"` (`SUPPORTED_READ_VERSIONS` in + `utils/fit_io.py`); pre-5 archives and files with no auxiliary axis both + load with `aux_axis` as `None` on `SavedFile`. The writer still refuses + to append to an archive whose version differs from its own. Future incompatible changes (e.g. project-scoped joint-result slots or `keep_history=True` full-log save — both deferred, see "What's *not* in @@ -147,6 +155,7 @@ files/000000/ ├── energy # 1D dataset; preserves source dtype ├── time # 1D dataset; length 0 if 1D file; preserves source dtype ├── data # 1D (1D file) or 2D (n_t, n_e) dataset; preserves source dtype +├── aux_axis (opt) # 1D dataset; preserves source dtype; schema ≥ 5 └── slots/ ├── 000000/ # SavedFitSlot (see "Slot group") └── 000001/... @@ -160,7 +169,12 @@ Notes: - `data_sha256`, `energy_sha256`, `time_sha256` together with `shape` form the `file_fingerprint` used to match an archive's file to a `Project.files[*]` (or to another archive). See - `compute_file_fingerprint` in `utils/fit_io.py`. + `compute_file_fingerprint` in `utils/fit_io.py`. `aux_axis` is not part + of the fingerprint — file identity stays `data`/`energy`/`time`-based. +- `aux_axis` is omitted entirely when `File.aux_axis is None` (most + files — only `par_profile`-attached models use it), following the + same omit-when-`None` rule as the optional slot datasets, not the + "empty array" convention used for `time` on 1D files. ### Identity collisions diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index b6b9b3a..e355115 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -546,6 +546,7 @@ def _build_saved_project_from_history( e_lim=list(live.e_lim) if live.e_lim else None, t_lim=list(live.t_lim) if live.t_lim else None, slots=tuple(slots), + aux_axis=live.aux_axis, ) ) diff --git a/src/trspecfit/utils/fit_io.py b/src/trspecfit/utils/fit_io.py index 7dd81c3..831e32a 100644 --- a/src/trspecfit/utils/fit_io.py +++ b/src/trspecfit/utils/fit_io.py @@ -44,13 +44,14 @@ from trspecfit.utils.hdf5 import require_dataset, require_group FitType = Literal["baseline", "spectrum", "sbs", "2d"] -SCHEMA_VERSION = "4" +SCHEMA_VERSION = "5" # Schema 3 is additive over 2 (slot `correl` dataset, mcmc # `acceptance_fraction` dataset). Schema 4 is additive over 3 (slot # `components` / `component_names` datasets for 1D fit types — baseline, -# spectrum, sbs; never present for 2d). The reader accepts all three; the +# spectrum, sbs; never present for 2d). Schema 5 is additive over 4 (per-file, +# not per-slot, optional `aux_axis` dataset). The reader accepts all four; the # writer still refuses cross-version appends (see _classify_archive_for_write). -SUPPORTED_READ_VERSIONS = ("2", "3", "4") +SUPPORTED_READ_VERSIONS = ("2", "3", "4", "5") # Default noise metadata used when no σ has been set on the File. Mirrors the # project.yaml defaults defined in ``Project._set_defaults``; if you change one, @@ -339,6 +340,10 @@ class SavedFile: Slots belonging to this file. Tuple (not list) to keep the record immutable; the writer accumulates slots into a list and freezes on construction. + aux_axis : np.ndarray | None + Auxiliary physical axis (``File.aux_axis``, e.g. depth) for + ``par_profile``-attached models. ``None`` when the file has none + (most files) or when loaded from a pre-schema-5 archive. """ name: str @@ -352,6 +357,7 @@ class SavedFile: e_lim: list[int] | None t_lim: list[int] | None slots: tuple[SavedFitSlot, ...] + aux_axis: np.ndarray | None = None # @@ -1416,6 +1422,8 @@ def _write_file_payload(file_group: h5py.Group, sf: SavedFile) -> None: file_group.create_dataset("energy", data=np.ascontiguousarray(sf.energy)) file_group.create_dataset("time", data=np.ascontiguousarray(sf.time)) file_group.create_dataset("data", data=np.ascontiguousarray(sf.data)) + if sf.aux_axis is not None: + file_group.create_dataset("aux_axis", data=np.ascontiguousarray(sf.aux_axis)) file_group.create_group("slots") @@ -1876,6 +1884,12 @@ def _read_file(file_group: h5py.Group) -> SavedFile: data = np.asarray(require_dataset(file_group["data"], "data")[...]) energy = np.asarray(require_dataset(file_group["energy"], "energy")[...]) time = np.asarray(require_dataset(file_group["time"], "time")[...]) + aux_axis_obj = file_group.get("aux_axis") + aux_axis = ( + np.asarray(require_dataset(aux_axis_obj, "aux_axis")[...]) + if aux_axis_obj is not None + else None + ) slot_records: list[SavedFitSlot] = [] slots_obj = file_group.get("slots") @@ -1899,6 +1913,7 @@ def _read_file(file_group: h5py.Group) -> SavedFile: e_lim=e_lim, t_lim=t_lim, slots=tuple(slot_records), + aux_axis=aux_axis, ) diff --git a/tests/test_fit_archive_roundtrip.py b/tests/test_fit_archive_roundtrip.py index f8247cc..69ed431 100644 --- a/tests/test_fit_archive_roundtrip.py +++ b/tests/test_fit_archive_roundtrip.py @@ -302,11 +302,19 @@ def test_baseline_roundtrip(family_id: str, tmp_path) -> None: project = fit_file.p archive_path = tmp_path / "baseline.fit.h5" - loaded_slot, _ = _save_load_one(project, archive_path) + loaded_slot, loaded_results = _save_load_one(project, archive_path) original = project._fit_history[0] assert original.fit_type == "baseline" _assert_slot_round_tripped(loaded_slot, original) + # --- per-file aux_axis (schema 5; None unless the family needs it) ----- + provider = next(iter(loaded_results._files_by_fp.values())) + if family.needs_aux: + assert fit_file.aux_axis is not None + np.testing.assert_array_equal(provider.aux_axis, fit_file.aux_axis) + else: + assert provider.aux_axis is None + # --------------------------------------------------------------------------- # spectrum round-trip @@ -600,6 +608,25 @@ def _downgrade_archive_to_v3(archive_path) -> None: del sg[ds] +# +def _downgrade_archive_to_v4(archive_path) -> None: + """Rewrite a schema-5 archive as schema 4 in place: relabel the version + and delete only the schema-5 addition (per-file ``aux_axis`` dataset), + keeping every schema-4 field intact.""" + + import h5py + + from trspecfit.utils.hdf5 import require_group + + with h5py.File(archive_path, "r+") as h5: + require_group(h5["metadata"], "metadata").attrs["schema_version"] = "4" + files_group = require_group(h5["files"], "files") + for f_key in files_group: + fg = require_group(files_group[f_key], f_key) + if "aux_axis" in fg: + del fg["aux_axis"] + + # def test_reader_accepts_schema_v2_archive(tmp_path) -> None: """Schema 3 is additive, so v2 archives must still load — with the @@ -653,6 +680,23 @@ def test_reader_accepts_schema_v3_archive(tmp_path) -> None: plt.close("all") +# +def test_reader_accepts_schema_v4_archive(tmp_path) -> None: + """Schema 5 is additive, so v4 archives must still load — with the + per-file ``aux_axis`` as None (checked via the loaded axes provider).""" + + _, fit_file, family = _build_fit_file("F6") + fit_file.fit_baseline(model_name=family.model_name("default"), stages=1, try_ci=0) + archive_path = tmp_path / "v4.fit.h5" + fit_file.p.save_fits(archive_path, show_output=0) + _downgrade_archive_to_v4(archive_path) + + loaded = FitResults.load(archive_path) + assert len(loaded) == 1 + provider = next(iter(loaded._files_by_fp.values())) + assert provider.aux_axis is None + + # def test_reader_rejects_unknown_schema_version(tmp_path) -> None: """Versions outside SUPPORTED_READ_VERSIONS raise a clear ValueError.""" From 47f6125c0779b0439486898b9fa979daeca6361c Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Tue, 21 Jul 2026 01:49:26 -0700 Subject: [PATCH 22/29] add full_range display mode to FitResults.plot_fit describe_model showed the full data range with fit-limit marker lines; post-fit displays (plot_fit) showed only the fit-limits-cropped window. The archive already persists the full, uncropped data/energy/time once per file (SavedFile) independent of any slot's cropped observed/fit, so give plot_fit a full_range mode instead of forcing one view down to match the other: real full data, with fit/residual/components drawn only inside the fit window (NaN outside it, never a fabricated padding value) plus dashed boundary lines - the same visual language describe_model already uses. - FitResults._axes_for/_full_observed_for/_pad_axis reconstruct the real full-range view per fit type: baseline (mean over persisted base_t_ind), spectrum (resolves the raw time_point/time_range/ time_type selection against the persisted time axis), sbs/2d (provider.data directly - already full). Falls back to today's cropped view when reconstruction isn't possible; never raises. - Extracted File._resolve_time_selection into a standalone utils.arrays.resolve_time_selection so spectrum's reconstruction can resolve indices with no live File. - fitlib.plt_fit_res_2d's title/scale calc now uses nanmin/nanmax for the NaN-padded fit array; identical result when no NaN is present. - fit_baseline/fit_spectrum now route their post-fit display through self.plot_fit(...) instead of a bespoke plt_fit_res_1d call, dropping the show_init overlay (not a completed-fit artifact, can't be reconstructed from the archive) along with the dead initial_guess extraction and bespoke title construction. - full_range is a real PlotConfig member (Project.full_range-backed, True out of the box), not a bool hardcoded at each call site - data_slice/x_lim/y_lim already establish "what to show" as a legitimate PlotConfig concern, not just style. plot_fit's full_range parameter is bool | None = None ("use config"), resolved once via cfg.full_range. All 6 live post-fit display call sites (fit_baseline/fit_spectrum/fit_slice_by_slice/File.fit_2d/ Project.fit_baselines/Project.fit_2d) call plot_fit with no full_range kwarg at all, so a project.yaml override or a per-call override actually takes effect everywhere instead of being silently ignored (project.yaml keys are only applied if a matching Project attribute already exists) or shadowed by a hardcoded True. - Add TestFullRangeConfigResolution: config default is True, a live fit's post-fit display honors config.full_range in both directions, and a per-call override wins over config - verified each failure mode actually fails without its fix via temporary-revert round-trips. - No schema/wire-format change - reuses already-persisted fields. --- PLAN.md | 138 ++++++++------ docs/design/fit_archive_schema.md | 6 + src/trspecfit/config/plot.py | 9 + src/trspecfit/fit_results.py | 239 ++++++++++++++++++++++--- src/trspecfit/fitlib.py | 11 +- src/trspecfit/trspecfit.py | 94 ++-------- src/trspecfit/utils/arrays.py | 61 +++++++ tests/test_arrays.py | 48 ++++- tests/test_fit_history.py | 63 +++++++ tests/test_fit_side_effects.py | 79 +++++++- tests/test_fitlib.py | 36 ++++ tests/test_full_range_plot.py | 288 ++++++++++++++++++++++++++++++ 12 files changed, 910 insertions(+), 162 deletions(-) create mode 100644 tests/test_full_range_plot.py diff --git a/PLAN.md b/PLAN.md index 1e85816..a20bd72 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,59 +1,97 @@ # Active Plan -## Persist `aux_axis` at the per-file archive level (schema 4 → 5) +## `full_range` toggle for `FitResults.plot_fit` Full design/rationale: `/home/yoyo/.claude/plans/hm-i-guess-the-eventual-simon.md` (session-local; contents mirrored here for repo persistence). -**Goal**: while investigating the live-vs-archive display-range question, -found that the archive already persists the full, uncropped `data`/ -`energy`/`time` once per file (`SavedFile`), but not `aux_axis` -(`File.aux_axis` — the auxiliary physical axis used by `par_profile` -models). Reloading an archive with no live `File` loses it entirely. -Added it, following the exact `data`/`energy`/`time` pattern. +**Goal**: close the live-vs-archive display-range gap honestly. The +archive already persists the full, uncropped `data`/`energy`/`time` once +per file (`SavedFile`). Give `FitResults.plot_fit` a `full_range=True` +mode that shows the real full data, with fit/residual/components drawn +only inside the fit window (`NaN` outside — never a fabricated padding +value) plus dashed ROI boundary lines, matching `describe_model`'s +existing visual language. `fit_baseline`/`fit_spectrum`/`fit_2d`/ +`fit_slice_by_slice`'s own post-fit live display switch to this as the +new default. -- [x] **Schema** (`src/trspecfit/utils/fit_io.py`): added - `aux_axis: np.ndarray | None = None` to `SavedFile`; bumped - `SCHEMA_VERSION` 4→5, extended `SUPPORTED_READ_VERSIONS`; - `_write_file_payload`/`_read_file` optional-field write/read - (conditional write, `.get()` + `None` fallback — not the - unconditional pattern used for `data`/`energy`/`time`). -- [x] **Writer call site** (`src/trspecfit/trspecfit.py`): threaded - `aux_axis=live.aux_axis` into the `fit_io.SavedFile(...)` - construction used by `save_fits`. -- [x] **Tests**: extended `test_baseline_roundtrip` (F1/F6/F8) with an - `aux_axis` round-trip assertion (non-`None` + array-equal for - F6/F8, `None` for F1) via the loaded `FitResults._files_by_fp` - provider; added `_downgrade_archive_to_v4` + - `test_reader_accepts_schema_v4_archive` (pre-5 archives load with - `aux_axis=None`). Full suite (1006 tests), mypy, pyright, ruff all - clean. -- [x] **Docs**: `docs/design/fit_archive_schema.md` — bumped documented - `schema_version` to `"5"`, added the 4→5 version-history entry, the - `aux_axis` line in the file-group layout diagram, and a notes- - section callout on the omit-if-`None` write rule and the - fingerprint exclusion. Verified with a `sphinx -W` build. +- [x] **`utils/arrays.py`**: extracted `File._resolve_time_selection`'s + body into a standalone `resolve_time_selection` function (time + array as a plain arg); `File._resolve_time_selection` is now a + 3-line wrapper. Unit-tested in `test_arrays.py`. +- [x] **`FitResults`** (`src/trspecfit/fit_results.py`): + `_axes_for` gained `full_range: bool = False`; added + `_provider_for`, `_full_observed_for` (baseline: `np.mean` over + persisted `base_t_ind`; spectrum: resolves raw time selection via + `resolve_time_selection`; sbs/2d: `provider.data` directly), and + `_pad_axis` (axis-parametrized `NaN`-padding helper reused for + `fit` and `components` across all 4 fit types). `plot_fit` gained + `full_range: bool = False` — reconstructs when possible, falls + back to the cropped view otherwise (never raises); 2d/sbs reuse + `fitlib.plt_fit_res_2d`'s existing `x_lim`/`y_lim` support + unchanged. `_plot_fit_1d` stays a staticmethod, now accepts + precomputed `observed`/`fit`/`components`/`roi` overrides (default + path unchanged); draws dashed ROI boundary lines when `roi` is + given; title enhanced via new `_slot_title` (file name, yaml stem, + spectrum's time selection). +- [x] **`fitlib.plt_fit_res_2d`**: "Fit" panel title + `range_dat_fit` + scale calc switched to `np.nanmin`/`np.nanmax` (needed for + `full_range` mode's `NaN`-padded `fit`; identical result when no + `NaN` present). +- [x] **`trspecfit.py`**: `fit_baseline`/`fit_spectrum` — deleted the dead + `initial_guess` extraction + bespoke title, replaced the direct + `fitlib.plt_fit_res_1d` call with `self.plot_fit(...)`. + `fit_2d`/`fit_slice_by_slice`/`Project.fit_2d`/`Project.fit_baselines` + all call `plot_fit` too (the batch `fit_baselines` call was missed + in an earlier pass and fixed). `File.plot_fit` sugar forwards + `full_range`. +- [x] **`full_range` promoted to a real `PlotConfig` member** (raised as a + design question before committing: project.yaml `full_range: ...` + was silently ignored — `_load_config` only applies known `Project` + attributes, and neither `PlotConfig` nor `Project._set_defaults` + had one). `config/plot.py` gained `full_range: bool = True` + (existing precedent: `data_slice`/`x_lim`/`y_lim` are already + "what to show" fields here, not just style); + `Project._set_defaults` gained `self.full_range = True`. + `FitResults.plot_fit`/`File.plot_fit`'s `full_range` param changed + `bool = False` → `bool | None = None` (`None` = "use config"), + resolved once via `cfg.full_range` right after `_config_for`. All 6 + live call sites now call `plot_fit`/`self.plot_fit` with **no** + `full_range` kwarg at all — they inherit the resolved default + instead of hardcoding `True`, so a project-wide override (or a + per-call explicit `full_range=`) actually takes effect everywhere. + Field-existence coverage comes free from `test_plotting.py`'s + existing generic `test_every_field_settable_via_project` (iterates + `dataclasses.fields(PlotConfig)`). +- [x] **Tests**: `test_fit_history.py` — 2 new `_plot_fit_1d` direct-call + tests (NaN-gap + roi boundary lines; default-path parity); retargeted + `test_baseline_plots_when_verbose`/`_skips_plot_when_silent` to + `FitResults._plot_fit_1d`. New `tests/test_full_range_plot.py` — + real-fit integration tests for all 4 fit types (reload with no live + `File`, reconstructed-observed correctness, `NaN` outside ROI, + values match `slot.fit` inside), plus graceful-fallback-without- + provider. `test_arrays.py` — `resolve_time_selection` unit tests. + `test_fitlib.py` — `plt_fit_res_2d` NaN-aware rendering test. + `test_fit_side_effects.py::TestFullRangeConfigResolution` (replaces + an earlier, now-obsolete systemic mock-and-assert-kwargs test class + that checked all 6 call sites hardcoded `full_range=True` — moot + once nothing hardcodes it): `Project.full_range` defaults `True`; + a live baseline fit's post-fit display shows the full axis when + config is `True` and the cropped window when set to `False`; an + explicit per-call `full_range=False` overrides config. Verified + the `fit_baselines` gap and the config-resolution logic each fail + without their respective fix (temporary-revert round-trips). Full + suite (1200 tests incl. slow), mypy, pyright, ruff all clean + (whole-tree sweep). +- [x] **Docs**: `docs/design/fit_archive_schema.md` — noted + `full_range=True` as a concrete consumer of the already-documented + full-data-duplication design decision. `config/plot.py`'s + `PlotConfig` docstring gained a `full_range` entry. No schema/ + version change (reuses already-persisted fields). Verified with a + `sphinx -W` build. -**Status**: implementation complete, verified 2026-07-20. Not yet -committed — awaiting user review/approval before commit. +**Status**: implementation complete, verified 2026-07-21. -**Out of scope (deliberately deferred)**: exposing `aux_axis` through -`FitResults._axes_for` or any plotting method (no current consumer needs -it yet); adding `aux_axis` to `file_fingerprint` identity hashing. - -**Next**: return to the live-vs-archive 1D post-fit display-range -inconsistency. Investigation this session found `fit_2d`/ -`fit_slice_by_slice` already route their post-fit live display through -`self.plot_fit(...)` (so 2D/SbS are already consistent live vs. archive); -only `fit_baseline`/`fit_spectrum` still call `fitlib.plt_fit_res_1d` -directly with the full, uncropped data. Chosen fix (confirmed with user): -route those two through `self.plot_fit(...)` too, matching `fit_2d`/ -`fit_slice_by_slice` — this drops the `show_init` initial-guess overlay -from post-fit display (confirmed acceptable: init guess isn't a -completed-fit artifact) and deletes a chunk of now-dead code -(`initial_guess` extraction, bespoke title construction) in both methods. -Also needs a small title-informativeness enhancement in -`FitResults._plot_fit_1d` (file name, yaml stem, spectrum's time -selection) since it becomes the sole 1D post-fit display path. Needs a -fresh plan file before implementing (the plan file was overwritten for -this aux_axis detour). +**Out of scope**: no schema/wire-format change; no model rehydration / +extrapolated-fit-curve reconstruction outside the ROI (declined already +for schema-4); no reintroduction of `show_init`. diff --git a/docs/design/fit_archive_schema.md b/docs/design/fit_archive_schema.md index 46ff425..7fcf216 100644 --- a/docs/design/fit_archive_schema.md +++ b/docs/design/fit_archive_schema.md @@ -166,6 +166,12 @@ Notes: - The full data + axes are duplicated into the archive deliberately (decision in the archived design plan — "Self-contained archive"). On load, the reader hands these back via `SavedFile`; the live `Project` is not mutated. + `FitResults.plot_fit(..., full_range=True)` is a concrete consumer: it + re-derives the real, uncropped view from these fields (plus + `SavedFitSlot.selection`) with no live `Model` required — `fit`/ + `components` are `NaN`-masked outside the fit window rather than + fabricated, since only the archived data (not the model) is available + outside it. - `data_sha256`, `energy_sha256`, `time_sha256` together with `shape` form the `file_fingerprint` used to match an archive's file to a `Project.files[*]` (or to another archive). See diff --git a/src/trspecfit/config/plot.py b/src/trspecfit/config/plot.py index 23c8021..00e3222 100644 --- a/src/trspecfit/config/plot.py +++ b/src/trspecfit/config/plot.py @@ -54,6 +54,10 @@ class PlotConfig: DPI for displaying plots dpi_save : int DPI for saving plots + full_range : bool + ``FitResults.plot_fit``: show the full, uncropped data range + (fit/residual/components ``NaN``-masked outside the fit window) + instead of just the fit-limits window. Overridable per call. z_colormap : str Colormap name for 2D plots z_colormap_res : str @@ -143,6 +147,11 @@ class PlotConfig: # Residual multiplier for 1D fit plots res_mult: float = 5 + # FitResults.plot_fit: show the full, uncropped data range (fit/residual/ + # components NaN-masked outside the fit window) instead of just the + # fit-limits window. A per-call full_range= argument overrides this. + full_range: bool = True + # 2D plot settings z_colormap: str = "viridis" # Residual maps are signed and centered on 0 -> diverging colormap diff --git a/src/trspecfit/fit_results.py b/src/trspecfit/fit_results.py index a6c697c..6bac6e8 100644 --- a/src/trspecfit/fit_results.py +++ b/src/trspecfit/fit_results.py @@ -28,6 +28,7 @@ import pandas as pd from trspecfit.config.plot import PlotConfig +from trspecfit.utils.arrays import resolve_time_selection from trspecfit.utils.fit_io import SavedFile, SavedFitSlot, read_archive from trspecfit.utils.lmfit import MCMCResult @@ -111,6 +112,24 @@ def _resolve_file_arg(file: Any) -> str | None: ) +# +def _slot_title(slot: SavedFitSlot) -> str: + """Plot title from persisted slot metadata: file, model, yaml stem, time.""" + + title = f'{slot.file_name} - "{slot.model_name}" ({slot.fit_type})' + if slot.yaml_filename: + title += f" [{slot.yaml_filename}]" + if slot.fit_type == "spectrum": + time_point = slot.selection.get("time_point") + time_range = slot.selection.get("time_range") + time_type = slot.selection.get("time_type", "abs") + if time_point is not None: + title += f", t={time_point} ({time_type})" + elif time_range is not None: + title += f", t in {time_range} ({time_type})" + return title + + # def _has_any_sigma(slots: Sequence[SavedFitSlot]) -> bool: """True if at least one slot carries a finite ``sigma_data``.""" @@ -202,35 +221,128 @@ def _provider_fp_key(f: Any) -> tuple[Any, ...] | None: return _fp_key(fingerprint) return None + # + def _provider_for(self, slot: SavedFitSlot) -> Any | None: + """Axes/data provider (``SavedFile`` or live ``File``) for this slot's file.""" + + return self._files_by_fp.get(_fp_key(slot.file_fingerprint)) + # def _axes_for( - self, slot: SavedFitSlot + self, slot: SavedFitSlot, *, full_range: bool = False ) -> tuple[np.ndarray | None, np.ndarray | None]: """ ``(energy, time)`` on the slot's grid, or ``None`` where unknown. Crops the provider's full axes to the slot's selection (same rule as ``fit_io._slot_axes``, tolerant of missing providers/axes). + Pass ``full_range=True`` to skip the crop and return the + provider's full, uncropped axes instead. """ - provider = self._files_by_fp.get(_fp_key(slot.file_fingerprint)) + provider = self._provider_for(slot) energy = getattr(provider, "energy", None) if provider is not None else None time = getattr(provider, "time", None) if provider is not None else None if energy is not None: energy = np.asarray(energy) - e_lim = slot.selection.get("e_lim") - if e_lim: - energy = energy[int(e_lim[0]) : int(e_lim[1])] + if not full_range: + e_lim = slot.selection.get("e_lim") + if e_lim: + energy = energy[int(e_lim[0]) : int(e_lim[1])] if time is not None: time = np.asarray(time) if time.ndim == 0 or time.size == 0: time = None - elif slot.fit_type == "2d": + elif not full_range and slot.fit_type == "2d": t_lim = slot.selection.get("t_lim") if t_lim: time = time[int(t_lim[0]) : int(t_lim[1])] return energy, time + # + def _full_observed_for( + self, slot: SavedFitSlot, provider: Any + ) -> np.ndarray | None: + """ + Real, full-range observed array for ``slot``, re-derived from the + provider's full ``data`` — or ``None`` if reconstruction isn't + possible (missing provider/selection fields; caller falls back to + the cropped ``slot.observed``). + + ``sbs``/``2d`` need no re-derivation — the provider's full ``data`` + already matches (sbs never crops time; 2d's slot is a direct + sub-slice of it). ``baseline``/``spectrum`` re-derive the + time-reduced spectrum (average or single row) from the persisted + selection, mirroring ``File.fit_baseline``/``File.fit_spectrum``. + """ + + data = getattr(provider, "data", None) + if data is None: + return None + data = np.asarray(data) + + if slot.fit_type in ("sbs", "2d"): + return data + + if slot.fit_type == "baseline": + base_t_ind = slot.selection.get("base_t_ind") + if not base_t_ind: + return None + return np.asarray( + np.mean(data[int(base_t_ind[0]) : int(base_t_ind[1]), :], axis=0) + ) + + if slot.fit_type == "spectrum": + time = getattr(provider, "time", None) + if time is None: + return None + time = np.asarray(time) + time_point = slot.selection.get("time_point") + time_range = slot.selection.get("time_range") + time_type = slot.selection.get("time_type", "abs") + try: + if time_point is not None: + ind = resolve_time_selection( + time, time_point, time_point, time_type=time_type + ) + return np.asarray(data[ind[0], :]) + if time_range is not None: + ind = resolve_time_selection( + time, time_range[0], time_range[1], time_type=time_type + ) + return np.asarray(np.mean(data[ind[0] : ind[1], :], axis=0)) + except ValueError: + return None + return None + + return None + + # + @staticmethod + def _pad_axis( + cropped: np.ndarray, full_len: int, lim: list[int] | None, *, axis: int + ) -> np.ndarray: + """ + Return ``cropped`` embedded in a ``NaN``-filled array of length + ``full_len`` along ``axis``, at the ``lim`` (``[start, stop)``) + window. ``lim is None`` means that axis is already full range + (the fit ran on the whole axis) — ``cropped`` is returned as-is. + + Used to place a slot's cropped ``fit``/``components`` into the + full-range grid without fabricating values outside the fit + window — the padding is ``NaN``, never a placeholder. + """ + + if lim is None: + return cropped + shape = list(cropped.shape) + shape[axis] = full_len + full = np.full(tuple(shape), np.nan, dtype=np.result_type(cropped, float)) + idx: list[slice] = [slice(None)] * cropped.ndim + idx[axis] = slice(int(lim[0]), int(lim[1])) + full[tuple(idx)] = cropped + return full + # def _config_for(self, slot: SavedFitSlot, config: Any) -> Any: """ @@ -244,7 +356,7 @@ def _config_for(self, slot: SavedFitSlot, config: Any) -> Any: if config is not None: return config - provider = self._files_by_fp.get(_fp_key(slot.file_fingerprint)) + provider = self._provider_for(slot) live_config = getattr(provider, "plot_config", None) return live_config if live_config is not None else PlotConfig() @@ -625,6 +737,7 @@ def plot_fit( fit_type: FitType = "baseline", config: Any = None, show_plot: bool = True, + full_range: bool | None = None, ) -> None: """ Plot the latest matching fit: observed, fit, and residual. @@ -650,6 +763,18 @@ def plot_fit( when available, else ``PlotConfig()``. show_plot : bool, default True Set ``False`` to build without displaying (tests / batch use). + full_range : bool, optional + Show the full, uncropped data range (re-derived from the + file's persisted raw data) instead of just the fit-limits + window. Fit/residual/components are drawn only inside the + fit window (``NaN`` outside — never a fabricated value), + with dashed lines marking the window boundary, matching + ``describe_model``'s pre-fit view. Falls back to the + cropped, fit-limits-only view when reconstruction isn't + possible (e.g. no axes provider for this slot's file). + Default: ``config.full_range`` (``PlotConfig`` field, + itself ``Project.full_range``-backed; ``True`` out of the + box) — pass explicitly to override for one call. Raises ------ @@ -659,20 +784,69 @@ def plot_fit( slot = self._latest_slot(file=file, model=model, fit_type=fit_type) cfg = self._config_for(slot, config) - energy, time = self._axes_for(slot) + if full_range is None: + full_range = bool(getattr(cfg, "full_range", False)) + observed = np.asarray(slot.observed) + fit = np.asarray(slot.fit) + components = slot.components + e_lim: list[int] | None = None + t_lim: list[int] | None = None + + if full_range: + provider = self._provider_for(slot) + full_observed = ( + self._full_observed_for(slot, provider) + if provider is not None + else None + ) + energy, time = self._axes_for(slot, full_range=True) + if full_observed is not None and energy is not None: + n_e_full = energy.shape[0] + e_lim = slot.selection.get("e_lim") + if slot.fit_type == "2d": + t_lim = slot.selection.get("t_lim") + n_t_full = time.shape[0] if time is not None else fit.shape[0] + fit = self._pad_axis(fit, n_t_full, t_lim, axis=0) + fit = self._pad_axis(fit, n_e_full, e_lim, axis=1) + elif slot.fit_type == "sbs": + fit = self._pad_axis(fit, n_e_full, e_lim, axis=1) + else: # baseline / spectrum + fit = self._pad_axis(fit, n_e_full, e_lim, axis=0) + if components is not None: + comp_axis = 2 if slot.fit_type == "sbs" else 1 + components = self._pad_axis( + components, n_e_full, e_lim, axis=comp_axis + ) + observed = full_observed + else: + energy, time = self._axes_for(slot) + else: + energy, time = self._axes_for(slot) + if slot.fit_type in ("2d", "sbs"): from trspecfit import fitlib fitlib.plt_fit_res_2d( - data=np.asarray(slot.observed), - fit=np.asarray(slot.fit), + data=observed, + fit=fit, x=energy, y=time, + x_lim=e_lim, + y_lim=t_lim, config=cfg, save_img=0 if show_plot else -2, ) return - self._plot_fit_1d(slot, energy=energy, config=cfg, show_plot=show_plot) + self._plot_fit_1d( + slot, + energy=energy, + config=cfg, + show_plot=show_plot, + observed=observed, + fit=fit, + components=components, + roi=e_lim, + ) # @staticmethod @@ -682,13 +856,26 @@ def _plot_fit_1d( energy: np.ndarray | None, config: Any, show_plot: bool, + observed: np.ndarray | None = None, + fit: np.ndarray | None = None, + components: np.ndarray | None = None, + roi: list[int] | None = None, ) -> Any: - """Observed + fit (with components, when persisted) over a residual panel.""" + """ + Observed + fit (with components, when persisted) over a residual panel. + + ``observed``/``fit``/``components`` default to the slot's own + (cropped) arrays; pass overrides to render a full-range + reconstruction instead (see ``FitResults.plot_fit``'s + ``full_range``). ``roi`` draws dashed boundary lines at the given + ``[start, stop)`` index window (full-range mode only). + """ import matplotlib.pyplot as plt - obs = np.asarray(slot.observed).ravel() - fit = np.asarray(slot.fit).ravel() + obs = np.asarray(observed if observed is not None else slot.observed).ravel() + fit_arr = np.asarray(fit if fit is not None else slot.fit).ravel() + comps = components if components is not None else slot.components if energy is not None and energy.size == obs.size: x = energy x_label = getattr(config, "x_label", "energy") @@ -703,31 +890,39 @@ def _plot_fit_1d( height_ratios=[3, 1], ) ax_fit.plot(x, obs, "k.", ms=3, label="observed") - if slot.components is not None: + if comps is not None: # schema >= 4: render the persisted per-component decomposition, - # matching fitlib.plt_fit_res_1d's live visual style. + # matching fitlib.plt_fit_res_1d's live visual style. NaN + # entries (full-range mode, outside the fit window) leave a + # gap rather than a fabricated value. colors = list( plt.rcParams["axes.prop_cycle"].by_key().get("color", ["#1f77b4"]) ) names = slot.component_names or [ - f"component {i}" for i in range(slot.components.shape[0]) + f"component {i}" for i in range(comps.shape[0]) ] - for p, (peak, name) in enumerate(zip(slot.components, names, strict=True)): + for p, (peak, name) in enumerate(zip(comps, names, strict=True)): color = colors[p % len(colors)] ax_fit.plot( x, peak, color=color, linestyle="-", linewidth=2, label=name ) ax_fit.fill_between(x, 0, peak, facecolor=color, alpha=0.5) - ax_fit.plot(x, fit, "-", lw=1.5, color="#000000", label="fit") + ax_fit.plot(x, fit_arr, "-", lw=1.5, color="#000000", label="fit") else: - ax_fit.plot(x, fit, "-", lw=1.5, label="fit") + ax_fit.plot(x, fit_arr, "-", lw=1.5, label="fit") ax_fit.set_ylabel("intensity") ax_fit.legend(fontsize="small") - ax_fit.set_title(f"{slot.model_name} ({slot.fit_type})") - ax_res.plot(x, obs - fit, "-", lw=1.0) + ax_fit.set_title(_slot_title(slot)) + ax_res.plot(x, obs - fit_arr, "-", lw=1.0) ax_res.axhline(0, color="gray", lw=0.5) ax_res.set_xlabel(x_label) ax_res.set_ylabel("residual") + if roi is not None and len(roi) == 2 and x.size: + x_start = x[roi[0]] + x_end = x[roi[1] - 1] if roi[1] > 0 else x[-1] + for ax in (ax_fit, ax_res): + ax.axvline(x_start, color="#A9A9A9", linestyle="--") + ax.axvline(x_end, color="#A9A9A9", linestyle="--") if getattr(config, "x_dir", "def") == "rev": ax_res.invert_xaxis() fig.tight_layout() diff --git a/src/trspecfit/fitlib.py b/src/trspecfit/fitlib.py index f2504a6..392f7f9 100644 --- a/src/trspecfit/fitlib.py +++ b/src/trspecfit/fitlib.py @@ -1464,7 +1464,12 @@ def plt_fit_res_2d( # Determine color scale ranges # Data and fit share the same scale for comparison if z_lim_top is None: - range_dat_fit = [min(np.min(data), np.min(fit)), max(np.max(data), np.max(fit))] + # nanmin/nanmax: full_range mode's fit array carries NaN outside + # the fit window; identical to min/max when no NaN is present. + range_dat_fit = [ + min(np.min(data), np.nanmin(fit)), + max(np.max(data), np.nanmax(fit)), + ] else: range_dat_fit = z_lim_top @@ -1513,9 +1518,9 @@ def plt_fit_res_2d( ) axs["right"].set_title( "Fit [min: " - + str(f"{np.min(fit):.3E}") + + str(f"{np.nanmin(fit):.3E}") + ", max: " - + str(f"{np.max(fit):.3E}") + + str(f"{np.nanmax(fit):.3E}") + "]" ) diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index e355115..ad7634b 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -85,6 +85,7 @@ from trspecfit.functions import energy as fcts_energy from trspecfit.functions import profile as fcts_profile from trspecfit.functions import time as fcts_time +from trspecfit.utils import arrays as uarrays from trspecfit.utils import fit_io from trspecfit.utils import lmfit as ulmfit from trspecfit.utils import parsing as uparsing @@ -243,6 +244,7 @@ def _set_defaults(self) -> None: self.dpi_plt = 100 self.dpi_save = 300 self.res_mult = 5 + self.full_range = True self.title = "" self.x_lim = None self.y_lim = None @@ -2647,11 +2649,6 @@ def fit_baseline( "Run define_baseline() first to extract the baseline region." ) - # get initial guess - initial_guess = ulmfit.par_extract( - self.model_base.lmfit_pars, return_type="list" - ) - # const = (x, data, package, fnctn string, unpack, energy limits, time limits) _fun_str = self.p.spec_fun_str self.model_base.const = ( @@ -2692,28 +2689,8 @@ def fit_baseline( ), ) - # display baseline fit summary - title_base = ( - f"File: {self.path}, " - f'Model: "{model_name}" (from "{self.model_base.yaml_f_name}.yaml")' - ) - if self.p.show_output >= 1: - fitlib.plt_fit_res_1d( - x=self.energy, - y=self.data_base, - fit_fun_str=self.p.spec_fun_str, - par_ini=initial_guess, - par_fin=fit_out.par_fin, - args=self.model_base.args, - plot_sum=False, - show_init=True, - title=title_base, - fit_lim=self.e_lim, - config=self.plot_config, - legend=[comp.name for comp in self.model_base.components], - save_img=0, - ) + self.plot_fit(model=model_name, fit_type="baseline") if stages >= 1 and self.p.show_output >= 1: fitlib.time_display( @@ -2873,10 +2850,6 @@ def fit_spectrum( ) assert self.data_spec is not None # type guard - # get initial guess - initial_guess = ulmfit.par_extract( - self.model_spec.lmfit_pars, return_type="list" - ) # const = (x, data, fnctn string, unpack, energy limits, time limits) _fun_str = self.p.spec_fun_str @@ -2919,34 +2892,8 @@ def fit_spectrum( ), ) - # display spectrum fit summary - time_label = ( - f"t = {self.spec_t_abs[0]:.4g}" - if self.spec_t_abs[0] == self.spec_t_abs[1] - else f"t in [{self.spec_t_abs[0]:.4g}, {self.spec_t_abs[1]:.4g}]" - ) - title_spec = ( - f"File: {self.path}, {time_label}, " - f'Model: "{model_name}" ' - f'(from "{self.model_spec.yaml_f_name}.yaml")' - ) - if show_plot and self.p.show_output >= 1: - fitlib.plt_fit_res_1d( - x=self.energy, - y=self.data_spec, - fit_fun_str=self.p.spec_fun_str, - par_ini=initial_guess, - par_fin=fit_out.par_fin, - args=self.model_spec.args, - plot_sum=False, - show_init=True, - title=title_spec, - fit_lim=self.e_lim, - config=self.plot_config, - legend=[comp.name for comp in self.model_spec.components], - save_img=0, - ) + self.plot_fit(model=model_name, fit_type="spectrum") if stages >= 1 and self.p.show_output >= 1: fitlib.time_display( @@ -3807,30 +3754,9 @@ def _resolve_time_selection( if self.time is None: raise ValueError("Time axis is not set.") - n = len(self.time) - if time_type == "abs": - if t_start == t_stop: - ind_start = int(np.searchsorted(self.time, t_start, side="left")) - ind_stop = ind_start + 1 - else: - ind_start = int(np.searchsorted(self.time, t_start, side="left")) - ind_stop = int(np.searchsorted(self.time, t_stop, side="right")) - elif time_type == "ind": - ind_start = int(t_start) - ind_stop = int(t_stop) + 1 if t_start == t_stop else int(t_stop + 1) - else: - raise ValueError( - f"Unknown time_type '{time_type}'. Expected 'abs' or 'ind'." - ) - if ind_start >= ind_stop or ind_start >= n or ind_stop <= 0: - raise ValueError( - f"Time selection resolves to an empty or out-of-range slice " - f"[{ind_start}:{ind_stop}). " - f"Time axis has {n} points [{self.time[0]}, {self.time[-1]}]." - ) - ind_start = max(ind_start, 0) - ind_stop = min(ind_stop, n) - return [ind_start, ind_stop] + return uarrays.resolve_time_selection( + self.time, t_start, t_stop, time_type=time_type + ) # def add_time_dependence( @@ -4283,6 +4209,7 @@ def plot_fit( fit_type: Literal["baseline", "spectrum", "sbs", "2d"] = "baseline", config: PlotConfig | None = None, show_plot: bool = True, + full_range: bool | None = None, ) -> None: """ Plot the latest matching fit: observed, fit, and residual. @@ -4302,6 +4229,10 @@ def plot_fit( Styling override; defaults to this file's ``plot_config``. show_plot : bool, default True Set ``False`` to build without displaying. + full_range : bool, optional + Show the full, uncropped data range instead of just the fit + window. Default: ``config.full_range``. See + :meth:`FitResults.plot_fit`. """ self.p.results.plot_fit( @@ -4310,6 +4241,7 @@ def plot_fit( fit_type=fit_type, config=config, show_plot=show_plot, + full_range=full_range, ) # diff --git a/src/trspecfit/utils/arrays.py b/src/trspecfit/utils/arrays.py index e00f33c..a8fffc3 100644 --- a/src/trspecfit/utils/arrays.py +++ b/src/trspecfit/utils/arrays.py @@ -4,6 +4,7 @@ This module provides utilities for: - Scientific number formatting with consistent width - Pandas DataFrame item extraction +- Time-axis index resolution - Sign change detection with zero handling - Array padding and convolution for signal processing - Angular normalization @@ -203,6 +204,66 @@ def get_item( # +# +def resolve_time_selection( + time: ArrayLike, + t_start: float, + t_stop: float, + *, + time_type: str = "abs", +) -> list[int]: + """ + Convert time bounds to validated ``[ind_start, ind_stop)`` slice indices. + + For a single time point pass ``t_start == t_stop``. Both bounds are + inclusive in the input; the returned stop is exclusive. + + Parameters + ---------- + time : array_like + Time axis to resolve against. + t_start, t_stop : float + Time bounds (absolute values or indices, see ``time_type``). + time_type : {'abs', 'ind'}, default='abs' + 'abs': absolute time stamps. 'ind': time array indices. + + Returns + ------- + list[int] + ``[ind_start, ind_stop)``. + + Raises + ------ + ValueError + If the result is out of range or empty, or ``time_type`` is + unrecognized. + """ + + time_arr = np.asarray(time) + n = len(time_arr) + if time_type == "abs": + if t_start == t_stop: + ind_start = int(np.searchsorted(time_arr, t_start, side="left")) + ind_stop = ind_start + 1 + else: + ind_start = int(np.searchsorted(time_arr, t_start, side="left")) + ind_stop = int(np.searchsorted(time_arr, t_stop, side="right")) + elif time_type == "ind": + ind_start = int(t_start) + ind_stop = int(t_stop) + 1 if t_start == t_stop else int(t_stop + 1) + else: + raise ValueError(f"Unknown time_type '{time_type}'. Expected 'abs' or 'ind'.") + if ind_start >= ind_stop or ind_start >= n or ind_stop <= 0: + raise ValueError( + f"Time selection resolves to an empty or out-of-range slice " + f"[{ind_start}:{ind_stop}). " + f"Time axis has {n} points [{time_arr[0]}, {time_arr[-1]}]." + ) + ind_start = max(ind_start, 0) + ind_stop = min(ind_stop, n) + return [ind_start, ind_stop] + + # def sign_change(array: ArrayLike, *, ignore_zeros: bool = True) -> NDArray[np.int_]: """ diff --git a/tests/test_arrays.py b/tests/test_arrays.py index 29d7c06..07a8c6d 100644 --- a/tests/test_arrays.py +++ b/tests/test_arrays.py @@ -1,5 +1,5 @@ """Tests for trspecfit.utils.arrays — running_mean, kernel-matrix -convolution, sign_change, my_conv.""" +convolution, sign_change, my_conv, resolve_time_selection.""" import numpy as np import pytest @@ -17,6 +17,7 @@ conv_matrix_apply, conv_matrix_operator, my_conv, + resolve_time_selection, running_mean, sign_change, ) @@ -497,3 +498,48 @@ def test_smoothing_reduces_variance(self): y = np.sin(x / 10) + rng.normal(scale=0.5, size=100) smoothed = running_mean(x, y, n=7) assert np.var(smoothed) < np.var(y) + + +# +class TestResolveTimeSelection: + """resolve_time_selection — extracted from File._resolve_time_selection + so archive-side reconstruction can resolve a raw time selection with no + live File (see FitResults._full_observed_for).""" + + # + def test_abs_single_point(self): + time = np.linspace(-2, 10, 24) + assert resolve_time_selection(time, 1.5, 1.5, time_type="abs") == [ + int(np.searchsorted(time, 1.5)), + int(np.searchsorted(time, 1.5)) + 1, + ] + + # + def test_abs_range(self): + time = np.linspace(-2, 10, 24) + ind = resolve_time_selection(time, 0.0, 2.0, time_type="abs") + assert ind[0] < ind[1] + assert time[ind[0]] >= 0.0 + assert time[ind[1] - 1] <= 2.0 + + # + def test_ind_single_point(self): + time = np.linspace(-2, 10, 24) + assert resolve_time_selection(time, 5, 5, time_type="ind") == [5, 6] + + # + def test_ind_range(self): + time = np.linspace(-2, 10, 24) + assert resolve_time_selection(time, 5, 8, time_type="ind") == [5, 9] + + # + def test_unknown_time_type_raises(self): + time = np.linspace(-2, 10, 24) + with pytest.raises(ValueError, match="Unknown time_type"): + resolve_time_selection(time, 0, 1, time_type="bogus") + + # + def test_out_of_range_raises(self): + time = np.linspace(-2, 10, 24) + with pytest.raises(ValueError, match="empty or out-of-range"): + resolve_time_selection(time, 100, 100, time_type="ind") diff --git a/tests/test_fit_history.py b/tests/test_fit_history.py index 9c8a70a..b40709e 100644 --- a/tests/test_fit_history.py +++ b/tests/test_fit_history.py @@ -1660,6 +1660,69 @@ def test_plot_fit_1d_falls_back_to_lean_when_components_none(self): finally: plt.close(fig) + # + def test_plot_fit_1d_full_range_masks_outside_roi_and_draws_boundary(self): + """full_range overrides: NaN outside the ROI leaves a gap (never a + fabricated value); roi draws dashed boundary lines on both panels.""" + + import matplotlib.pyplot as plt + + x = np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + obs_full = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]) + fit_full = np.array([np.nan, np.nan, 2.9, 3.9, np.nan, np.nan]) + slot = _slot_stub() + fig = FitResults._plot_fit_1d( + slot, + energy=x, + config=None, + show_plot=False, + observed=obs_full, + fit=fit_full, + roi=[2, 4], + ) + try: + ax_fit, ax_res = fig.axes + fit_line = next(line for line in ax_fit.lines if line.get_label() == "fit") + np.testing.assert_array_equal(fit_line.get_ydata(), fit_full) + # residual is NaN wherever fit is NaN (obs - NaN = NaN), no + # special-cased padding logic needed; residual is plotted first, + # before the boundary vlines + res_line = ax_res.lines[0] + np.testing.assert_array_equal( + np.isnan(res_line.get_ydata()), np.isnan(fit_full) + ) + # dashed boundary lines at the ROI edges, on both panels + dashed_x = { + float(line.get_xdata()[0]) + for line in ax_fit.lines + if line.get_linestyle() == "--" + } + assert dashed_x == {x[2], x[3]} + finally: + plt.close(fig) + + # + def test_plot_fit_1d_full_range_defaults_match_cropped_view(self): + """Omitting the overrides (full_range=False path) renders exactly + the slot's own cropped arrays — unchanged from before full_range + existed.""" + + import dataclasses + + import matplotlib.pyplot as plt + + obs = np.array([1.0, 2.0, 3.0]) + fit = np.array([1.1, 1.9, 3.2]) + slot = dataclasses.replace(_slot_stub(), observed=obs, fit=fit) + fig = FitResults._plot_fit_1d(slot, energy=None, config=None, show_plot=False) + try: + ax_fit, ax_res = fig.axes + fit_line = next(line for line in ax_fit.lines if line.get_label() == "fit") + np.testing.assert_array_equal(fit_line.get_ydata(), fit) + assert not any(line.get_linestyle() == "--" for line in ax_fit.lines) + finally: + plt.close(fig) + # @staticmethod def _fake_sbs_results(*, vary=(True, False, True)): diff --git a/tests/test_fit_side_effects.py b/tests/test_fit_side_effects.py index d9fd0f4..240fc81 100644 --- a/tests/test_fit_side_effects.py +++ b/tests/test_fit_side_effects.py @@ -22,7 +22,7 @@ import pytest from _utils import simulate_noisy -from trspecfit import File, Project, fitlib +from trspecfit import File, FitResults, Project, fitlib from trspecfit.utils import lmfit as ulmfit from trspecfit.utils.lmfit import MC @@ -210,15 +210,17 @@ def test_save_fits_writes(self, tmp_path, monkeypatch): # class TestPlotHelperSkipped: - """Silent mode must skip ``plt_fit_res_1d`` entirely — not just + """Silent mode must skip the post-fit render entirely — not just suppress its display. Guards against future regressions where figures get built and immediately closed (the SbS hot path is the expensive - case).""" + case). fit_baseline/fit_spectrum route their post-fit display through + self.plot_fit -> FitResults._plot_fit_1d (not fitlib.plt_fit_res_1d, + which describe_model still uses).""" # def test_baseline_skips_plot_when_silent(self, tmp_path, monkeypatch): mock = MagicMock() - monkeypatch.setattr(fitlib, "plt_fit_res_1d", mock) + monkeypatch.setattr(FitResults, "_plot_fit_1d", mock) project, file = _baseline_setup(tmp_path, monkeypatch) # show_output defaults to 0 (silent) in _baseline_setup. @@ -229,7 +231,7 @@ def test_baseline_skips_plot_when_silent(self, tmp_path, monkeypatch): # def test_baseline_plots_when_verbose(self, tmp_path, monkeypatch): mock = MagicMock() - monkeypatch.setattr(fitlib, "plt_fit_res_1d", mock) + monkeypatch.setattr(FitResults, "_plot_fit_1d", mock) project, file = _baseline_setup(tmp_path, monkeypatch, show_output=1) file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) @@ -305,6 +307,73 @@ def test_sbs_never_plots_per_slice_during_fit(self, tmp_path, monkeypatch): assert mock.call_count == 0 +# +class TestFullRangeConfigResolution: + """full_range is a real PlotConfig field (Project.full_range-backed, + True out of the box), not a value hardcoded at each live call site. + Every live post-fit display call (fit_baseline/fit_spectrum/ + fit_slice_by_slice/fit_2d/Project.fit_baselines/Project.fit_2d) now + omits full_range entirely and inherits whatever FitResults.plot_fit + resolves from config — so a project-wide override (or a per-call + explicit full_range=) actually takes effect everywhere, instead of a + hardcoded True silently overriding it. (PlotConfig field-existence + coverage itself lives in test_plotting.py's generic + test_every_field_settable_via_project.)""" + + # + def test_project_default_is_true(self): + project = _make_abs_project() + assert project.full_range is True + + # + def test_live_baseline_display_uses_full_range_when_config_true( + self, tmp_path, monkeypatch + ): + import matplotlib.pyplot as plt + + project, file = _baseline_setup(tmp_path, monkeypatch, show_output=1) + e = file.energy + file.set_fit_limits([float(e[5]), float(e[-6])], show_plot=False) + file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) + try: + line_x = plt.gcf().axes[0].lines[0].get_xdata() + assert len(line_x) == len(e) + finally: + plt.close("all") + + # + def test_live_baseline_display_uses_cropped_view_when_config_false( + self, tmp_path, monkeypatch + ): + import matplotlib.pyplot as plt + + project, file = _baseline_setup(tmp_path, monkeypatch, show_output=1) + project.full_range = False + e = file.energy + file.set_fit_limits([float(e[5]), float(e[-6])], show_plot=False) + file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) + try: + line_x = plt.gcf().axes[0].lines[0].get_xdata() + assert len(line_x) < len(e) + finally: + plt.close("all") + + # + def test_per_call_override_wins_over_config(self, tmp_path, monkeypatch): + import matplotlib.pyplot as plt + + project, file = _baseline_setup(tmp_path, monkeypatch, show_output=0) + e = file.energy + file.set_fit_limits([float(e[5]), float(e[-6])], show_plot=False) + file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) + try: + file.plot_fit(fit_type="baseline", full_range=False) + line_x = plt.gcf().axes[0].lines[0].get_xdata() + assert len(line_x) < len(e) + finally: + plt.close("all") + + # class TestPlotSbsSlices: """File.plot_sbs_slices: on-demand per-slice diagnostics from the live diff --git a/tests/test_fitlib.py b/tests/test_fitlib.py index 3e93a3b..84104a1 100644 --- a/tests/test_fitlib.py +++ b/tests/test_fitlib.py @@ -1,5 +1,10 @@ """Unit tests for fitlib bridge functions.""" +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt import numpy as np import pandas as pd import pytest @@ -72,3 +77,34 @@ def test_missing_parameter_column_raises(self): fitlib.results_to_fit_2d( df_missing, const, args, parameter_names=model.parameter_names ) + + +# +class TestPltFitRes2dNanAware: + """plt_fit_res_2d must handle a NaN-padded fit array (full_range mode) + without warning/crashing, and report min/max from the real values.""" + + # + def test_nan_padded_fit_renders_without_warning(self, recwarn): + rng = np.random.default_rng(0) + data = rng.random((6, 8)) + fit = np.full((6, 8), np.nan) + fit[2:4, 3:6] = data[2:4, 3:6] * 0.9 # the "fit window" + + fitlib.plt_fit_res_2d( + data=data, + fit=fit, + x_lim=[3, 6], + y_lim=[2, 4], + save_img=0, + ) + fig = plt.gcf() + try: + assert not any("All-NaN" in str(w.message) for w in recwarn.list) + fit_ax = next( + ax for ax in fig.axes if ax.get_title().startswith("Fit [min:") + ) + expected_min = np.nanmin(fit) + assert f"{expected_min:.3E}" in fit_ax.get_title() + finally: + plt.close("all") diff --git a/tests/test_full_range_plot.py b/tests/test_full_range_plot.py new file mode 100644 index 0000000..2c81d68 --- /dev/null +++ b/tests/test_full_range_plot.py @@ -0,0 +1,288 @@ +""" +Integration tests for ``FitResults.plot_fit(full_range=True)``. + +Builds a real fit for each fit type with a fit window strictly inside the +full energy (and time, for 2d) axis, saves to an archive, reloads with no +live ``File`` in memory, then verifies the full-range reconstruction: +real data across the whole persisted axis, ``fit``/``components`` equal +to the slot's own cropped arrays inside the window and ``NaN`` (never a +fabricated value) outside it. +""" + +from __future__ import annotations + +import matplotlib + +matplotlib.use("Agg") + +from typing import Any + +import numpy as np +import pytest +from _utils import make_project, simulate_noisy +from roundtrip.families import FAMILIES + +from trspecfit import FitResults +from trspecfit.utils.arrays import resolve_time_selection + + +# +def _build_fit_file(family_id: str, *, spec_fun_str: str = "fit_model_gir"): + """(truth_file, fit_file, family) for a family, with noisy data. + + Mirrors the setup pattern in test_fit_archive_roundtrip.py. + """ + + family = FAMILIES[family_id] + truth_project = make_project(name="fr_truth", spec_fun_str=spec_fun_str) + truth_file = family.build_truth(truth_project, variant="default") + data = simulate_noisy(truth_file.model_active, noise_level=0.01) + + fit_project = make_project(name="fr_fit", spec_fun_str=spec_fun_str) + fit_project.show_output = 0 + fit_kwargs: dict[str, Any] = { + "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 _narrow_energy_limits(fit_file) -> list[float]: + """A fit window strictly inside the full energy axis (exercises NaN padding).""" + + e = fit_file.energy + return [float(e[5]), float(e[-6])] + + +# +def _narrow_time_limits(fit_file) -> list[float]: + t = fit_file.time + return [float(t[2]), float(t[-3])] + + +# --------------------------------------------------------------------------- +# baseline +# --------------------------------------------------------------------------- + + +# +def test_baseline_full_range_reconstructs_real_data(tmp_path) -> None: + _, fit_file, family = _build_fit_file("F1") + fit_file.set_fit_limits(_narrow_energy_limits(fit_file), show_plot=False) + fit_file.fit_baseline(model_name=family.model_name("default"), stages=1, try_ci=0) + + archive_path = tmp_path / "baseline.fit.h5" + fit_file.p.save_fits(archive_path, show_output=0) + loaded = FitResults.load(archive_path) # no live File + slot = next(iter(loaded)) + provider = next(iter(loaded._files_by_fp.values())) + + e_lim = slot.selection["e_lim"] + b0, b1 = slot.selection["base_t_ind"] + expected_obs = np.mean(provider.data[b0:b1, :], axis=0) + + full_obs = loaded._full_observed_for(slot, provider) + np.testing.assert_allclose(full_obs, expected_obs) + + energy_full, _ = loaded._axes_for(slot, full_range=True) + assert energy_full is not None + assert len(energy_full) == len(provider.energy) + assert len(energy_full) > slot.observed.shape[0] # window is strictly narrower + + fit_padded = loaded._pad_axis(np.asarray(slot.fit), len(energy_full), e_lim, axis=0) + assert np.all(np.isnan(fit_padded[: e_lim[0]])) + assert np.all(np.isnan(fit_padded[e_lim[1] :])) + np.testing.assert_array_equal(fit_padded[e_lim[0] : e_lim[1]], slot.fit) + + comp_padded = loaded._pad_axis(slot.components, len(energy_full), e_lim, axis=1) + assert np.all(np.isnan(comp_padded[:, : e_lim[0]])) + np.testing.assert_array_equal(comp_padded[:, e_lim[0] : e_lim[1]], slot.components) + + # full end-to-end call must not raise + loaded.plot_fit( + model=family.model_name("default"), + fit_type="baseline", + full_range=True, + show_plot=False, + ) + + +# --------------------------------------------------------------------------- +# spectrum +# --------------------------------------------------------------------------- + + +# +@pytest.mark.parametrize( + ("kwargs", "expected_ref"), + [ + pytest.param({"time_point": 10, "time_type": "ind"}, "point", id="time_point"), + pytest.param({"time_type": "abs"}, "range", id="time_range"), + ], +) +def test_spectrum_full_range_reconstructs_real_data( + tmp_path, kwargs, expected_ref +) -> None: + _, fit_file, family = _build_fit_file("F1") + fit_file.set_fit_limits(_narrow_energy_limits(fit_file), show_plot=False) + if expected_ref == "range": + t = fit_file.time + kwargs = {**kwargs, "time_range": (float(t[2]), float(t[5]))} + fit_file.fit_spectrum( + family.model_name("default"), + stages=1, + try_ci=0, + show_plot=False, + **kwargs, + ) + + archive_path = tmp_path / "spectrum.fit.h5" + fit_file.p.save_fits(archive_path, show_output=0) + loaded = FitResults.load(archive_path) + slot = next(iter(loaded)) + provider = next(iter(loaded._files_by_fp.values())) + + if expected_ref == "point": + expected_obs = provider.data[10, :] + else: + ind = resolve_time_selection( + provider.time, + kwargs["time_range"][0], + kwargs["time_range"][1], + time_type="abs", + ) + expected_obs = np.mean(provider.data[ind[0] : ind[1], :], axis=0) + + full_obs = loaded._full_observed_for(slot, provider) + np.testing.assert_allclose(full_obs, expected_obs) + + loaded.plot_fit( + model=family.model_name("default"), + fit_type="spectrum", + full_range=True, + show_plot=False, + ) + + +# --------------------------------------------------------------------------- +# sbs +# --------------------------------------------------------------------------- + + +# +@pytest.mark.slow +def test_sbs_full_range_reconstructs_real_data(tmp_path) -> None: + _, fit_file, family = _build_fit_file("F1", spec_fun_str="fit_model_mcp") + fit_file.set_fit_limits(_narrow_energy_limits(fit_file), show_plot=False) + fit_file.fit_baseline(model_name=family.model_name("default"), stages=1, try_ci=0) + fit_file.fit_slice_by_slice( + family.model_name("default"), + n_workers=1, + seed_source="model", + seed_adapt=None, + try_ci=0, + ) + + archive_path = tmp_path / "sbs.fit.h5" + fit_file.p.save_fits(archive_path, show_output=0) + loaded = FitResults.load(archive_path) + slot = next(s for s in loaded if s.fit_type == "sbs") + provider = next(iter(loaded._files_by_fp.values())) + + full_obs = loaded._full_observed_for(slot, provider) + np.testing.assert_array_equal(full_obs, provider.data) + + e_lim = slot.selection["e_lim"] + fit_padded = loaded._pad_axis( + np.asarray(slot.fit), provider.data.shape[1], e_lim, axis=1 + ) + assert np.all(np.isnan(fit_padded[:, : e_lim[0]])) + np.testing.assert_array_equal(fit_padded[:, e_lim[0] : e_lim[1]], slot.fit) + + loaded.plot_fit( + model=family.model_name("default"), + fit_type="sbs", + full_range=True, + show_plot=False, + ) + + +# --------------------------------------------------------------------------- +# 2d +# --------------------------------------------------------------------------- + + +# +def test_2d_full_range_reconstructs_real_data(tmp_path) -> None: + _, fit_file, family = _build_fit_file("F3", spec_fun_str="fit_model_mcp") + fit_file.set_fit_limits( + _narrow_energy_limits(fit_file), + time_limits=_narrow_time_limits(fit_file), + show_plot=False, + ) + fit_file.fit_baseline(model_name=family.model_name("default"), stages=1, try_ci=0) + assert family.add_dynamics is not None # type guard + family.add_dynamics(fit_file, "default") + fit_file.fit_2d(family.model_name("default"), stages=1, try_ci=0) + + archive_path = tmp_path / "2d.fit.h5" + fit_file.p.save_fits(archive_path, fit_type="2d", show_output=0) + loaded = FitResults.load(archive_path) + slot = next(iter(loaded)) + provider = next(iter(loaded._files_by_fp.values())) + + full_obs = loaded._full_observed_for(slot, provider) + np.testing.assert_array_equal(full_obs, provider.data) + + e_lim = slot.selection["e_lim"] + t_lim = slot.selection["t_lim"] + fit_padded = loaded._pad_axis( + np.asarray(slot.fit), provider.data.shape[0], t_lim, axis=0 + ) + fit_padded = loaded._pad_axis(fit_padded, provider.data.shape[1], e_lim, axis=1) + assert np.all(np.isnan(fit_padded[: t_lim[0], :])) + np.testing.assert_array_equal( + fit_padded[t_lim[0] : t_lim[1], e_lim[0] : e_lim[1]], slot.fit + ) + + loaded.plot_fit( + model=family.model_name("default"), + fit_type="2d", + full_range=True, + show_plot=False, + ) + + +# --------------------------------------------------------------------------- +# graceful fallback +# --------------------------------------------------------------------------- + + +# +def test_full_range_falls_back_to_cropped_view_without_provider(tmp_path) -> None: + """No axes provider for the slot's file -> full_range=True degrades to + the cropped view instead of raising.""" + + _, fit_file, family = _build_fit_file("F1") + fit_file.set_fit_limits(_narrow_energy_limits(fit_file), show_plot=False) + fit_file.fit_baseline(model_name=family.model_name("default"), stages=1, try_ci=0) + + archive_path = tmp_path / "baseline.fit.h5" + fit_file.p.save_fits(archive_path, show_output=0) + loaded = FitResults.load(archive_path) + slot = next(iter(loaded)) + + orphan = FitResults(slots=[slot], files=None) + # must not raise + orphan.plot_fit( + model=family.model_name("default"), + fit_type="baseline", + full_range=True, + show_plot=False, + ) From 850f1d4caaa1185fa71646f263e4a6e1592b9868 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Tue, 21 Jul 2026 03:09:23 -0700 Subject: [PATCH 23/29] fix: persisted init_value reflected stage-1 output for two-stage fits While discussing whether the initial parameter guess should count as fit-result provenance (a different seed can land the same algorithm on a different local minimum), found that SavedFitSlot.params' init_value column (baseline/spectrum/2d) was silently wrong for stages=2 fits. lmfit's Minimizer.prepare_fit() unconditionally resets Parameter.init_value = Parameter.value at the start of every stage. fit_wrapper's stage 2 starts from stage 1's *output* Parameters object, so a two-stage result's init_value ends up reflecting stage 1's output, not the true original seed - verified directly against the installed lmfit source. stages=1 fits were already correct. - Add utils.lmfit.restore_true_init_values(result_params, par_ini). Fixed at the source, in fitlib.fit_wrapper itself, right after stage 2's mini.minimize() and before the result is printed or returned: _result_params() returns result.params by reference (no copy), so this is the same object that becomes FitOutput.par_fin.params, which means the fix reaches lmfit.report_fit's own printed "(init = ...)" annotation and any direct consumer of FitOutput.par_fin - not just code that happens to pass through slot construction. An earlier pass applied the correction downstream in _append_baseline_slot/ _append_spectrum_slot/_append_2d_slot instead; moved here after review because the live printed report would otherwise still show stage 1's output, contradicting the archive. - Regression tests confirm a stages=2 fit's persisted init_value matches the true seed rather than stage 1's output, and - more directly - that both FitOutput.par_fin.params[name].init_value and report_fit's printed "(init = ...)" (captured via capsys, matching lmfit's own .7g format) show the true seed for the local-optimization stage. Verified each fails without its fix via temporary-revert round-trips. - docs/design/fit_archive_schema.md: clarified init_value's semantics and pointed at fitlib.fit_wrapper as the fix location. No schema/ wire-format change - same column, same shape, corrected values, no version bump. - Out of scope (separately noted in PLAN.md): SbS initial-guess persistence and the seed-as-provenance schema extension for baseline/spectrum/2d. --- PLAN.md | 62 ++++++++++++++++++++ docs/design/fit_archive_schema.md | 8 ++- src/trspecfit/fitlib.py | 7 +++ src/trspecfit/utils/lmfit.py | 27 +++++++++ tests/test_fit_history.py | 95 +++++++++++++++++++++++++++++++ tests/test_lmfit_utils.py | 62 ++++++++++++++++++++ 6 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 tests/test_lmfit_utils.py diff --git a/PLAN.md b/PLAN.md index a20bd72..b352776 100644 --- a/PLAN.md +++ b/PLAN.md @@ -95,3 +95,65 @@ new default. **Out of scope**: no schema/wire-format change; no model rehydration / extrapolated-fit-curve reconstruction outside the ROI (declined already for schema-4); no reintroduction of `show_init`. + +## Fix: persisted `init_value` is wrong for two-stage fits + +Full design/rationale: `/home/yoyo/.claude/plans/hm-i-guess-the-eventual-simon.md` +(session-local; overwritten with this fix's plan, contents mirrored here). + +**Goal**: while discussing whether the initial guess should be treated as +fit-result provenance (a different seed can land the same algorithm on a +different local minimum), found that the already-persisted +`SavedFitSlot.params` `init_value` column (baseline/spectrum/2d) is +silently wrong for `stages=2` fits — lmfit's `Minimizer.prepare_fit()` +unconditionally resets `Parameter.init_value = Parameter.value` at the +start of every stage, and stage 2 starts from stage 1's *output*, so a +two-stage result's `init_value` reflects stage 1's output, not the true +original seed. Verified directly against the installed `lmfit` source. +`stages=1` fits were already correct. + +- [x] **`utils/lmfit.py`**: added `restore_true_init_values(result_params, + par_ini)` — corrects `result_params`' `init_value` in place from + the true seed. +- [x] **`fitlib.fit_wrapper`** (not the slot extractors — moved after + review): calls `restore_true_init_values(par_fin_params, par_ini)` + right after stage 2's `mini.minimize()`, before `par_fin_params` is + printed via `lmfit.report_fit` or returned in `FitOutput`. + `_result_params` returns `result.params` by reference (no copy), so + this is the same object that later becomes `FitOutput.par_fin.params` + — fixing it once at the source means the live printed report *and* + every direct consumer of `FitOutput.par_fin` (not just code that + passes through `_append_baseline_slot`/`_append_spectrum_slot`/ + `_append_2d_slot`) sees the true seed consistently. The three + downstream calls in `trspecfit.py` from the first pass were removed + as redundant. +- [x] **Tests**: `tests/test_lmfit_utils.py` (new) — direct unit tests + for `restore_true_init_values`. `test_fit_history.py` — two + regression tests via the persisted slot (`stages=2` seed correctly + persisted, not stage 1's output; `stages=1` companion confirming no + change), plus a new direct test asserting both + `FitOutput.par_fin.params[name].init_value` and `report_fit`'s + printed `"(init = ...)"` (captured via `capsys`, matching lmfit's + own `.7g` format) show the true seed for the local-optimization + stage — verified each regression test actually fails without its + fix via temporary-revert round-trips. Full suite (1200+ tests incl. + slow), mypy, pyright, ruff all clean. +- [x] **Docs**: `docs/design/fit_archive_schema.md` — clarified the + `init_value` column description, pointing at `fitlib.fit_wrapper` + as the fix location. No schema/wire-format change (same column, + same shape, corrected values) — no version bump. Verified with a + `sphinx -W` build. + +**Status**: implementation complete, verified 2026-07-21. + +**Out of scope (deliberately deferred)**: SbS initial-guess persistence +(schema-new, not a correctness fix — candidate: slice-0's true +`init_value` in `params_meta`, mirroring how `correl`/`mcmc` are already +slice-0-only); the project-level joint-fit `par_ini=None` case; any new +schema field. + +**Next**: plan the seed-as-provenance schema extension — persist the true +initial guess for baseline/spectrum/2d (e.g. a `fit_ini`/`components_ini` +evaluated at `par_ini`, mirroring the schema-4 `components` pattern, so +`plot_fit` can render an initial-guess overlay archive-side) and decide +whether to fold in SbS slice-0 `init_value` at the same time. diff --git a/docs/design/fit_archive_schema.md b/docs/design/fit_archive_schema.md index 7fcf216..48049e5 100644 --- a/docs/design/fit_archive_schema.md +++ b/docs/design/fit_archive_schema.md @@ -308,7 +308,13 @@ Mirrors the DataFrame returned by `par_to_df(..., col_type="min")` in `utils/lmfit.py`. `stderr` is the only float column that legitimately holds `NaN`-as-`None` — the others must always have a real value. `min`/`max` may carry IEEE `-inf`/`+inf` (unbounded parameters); those -are written verbatim. +are written verbatim. `init_value` is the true pre-fit seed regardless +of `stages` (1 or 2) — `fitlib.fit_wrapper` calls +`restore_true_init_values` (`utils/lmfit.py`) on the stage-2 result +before returning it, since lmfit's `prepare_fit` otherwise resets +`init_value` to stage 1's output at the start of stage 2. Fixed once at +the source, so every consumer of `FitOutput.par_fin` sees the true seed +consistently — not just this persisted column. ### sbs — wide format (one row per slice, one column per parameter) diff --git a/src/trspecfit/fitlib.py b/src/trspecfit/fitlib.py index 392f7f9..4f9cdf9 100644 --- a/src/trspecfit/fitlib.py +++ b/src/trspecfit/fitlib.py @@ -813,6 +813,13 @@ def _method_kws(method: str) -> dict[str, Any]: method=fit_alg_2, params=par_fin_gm_params, **_method_kws(fit_alg_2) ) par_fin_params = _result_params(par_fin) + # Stage 2 starts from stage 1's output, and lmfit's prepare_fit() + # unconditionally resets init_value = value at the start of every + # stage — restore the true pre-fit seed before it's printed below + # or reaches any consumer of FitOutput.par_fin (report_fit's own + # "(init = ...)" annotation would otherwise show stage 1's output, + # contradicting the persisted archive). + ulmfit.restore_true_init_values(par_fin_params, par_ini) if show_output >= 1: print(f"\nResults local optimization fit (method={fit_alg_2}): ") lmfit.report_fit(par_fin_params) diff --git a/src/trspecfit/utils/lmfit.py b/src/trspecfit/utils/lmfit.py index d8a5f8c..4029b4b 100644 --- a/src/trspecfit/utils/lmfit.py +++ b/src/trspecfit/utils/lmfit.py @@ -446,6 +446,33 @@ def par_to_df( return pd.DataFrame(data=par_info_list, columns=cols) +# +def restore_true_init_values( + result_params: lmfit.Parameters, par_ini: lmfit.Parameters +) -> None: + """ + Correct ``result_params``' ``init_value`` in place to the true + pre-fit seed (``par_ini``). + + Two-stage fitting (``fitlib.fit_wrapper`` stages=2) starts its second + stage from stage-1's output, and lmfit's ``prepare_fit`` unconditionally + resets ``Parameter.init_value = Parameter.value`` at the start of every + stage — so a two-stage result's ``init_value`` reflects stage-1's + output, not the true original seed, unless corrected here. + + Parameters + ---------- + result_params : lmfit.Parameters + A completed fit's ``result.params`` (mutated in place). + par_ini : lmfit.Parameters + The true pre-fit seed (``FitOutput.par_ini``). + """ + + for name, par in result_params.items(): + if name in par_ini: + par.init_value = par_ini[name].value + + # def correl_to_df(lmfit_params: lmfit.Parameters) -> pd.DataFrame: """ diff --git a/tests/test_fit_history.py b/tests/test_fit_history.py index b40709e..07a9b73 100644 --- a/tests/test_fit_history.py +++ b/tests/test_fit_history.py @@ -257,6 +257,101 @@ def test_history_key_is_stable(self): ) assert k == slot.history_key + # + def test_stages2_init_value_is_true_seed_not_stage1_output(self): + """Regression: lmfit resets init_value at the start of every + optimization stage, and fit_wrapper's stage 2 starts from stage + 1's output — so a two-stage result's init_value would silently + become stage 1's output unless restore_true_init_values corrects + it before the slot is built. _setup_baseline_fit already uses + stages=2.""" + + truth_project = make_project(name="truth") + truth = _make_truth_file(truth_project) + data = simulate_noisy(truth.model_active, noise_level=0.01) + + project = make_project(name="fit") + file = _make_fit_file(project, data, truth.energy, truth.time) + file.define_baseline( + time_start=0, time_stop=3, time_type="ind", show_plot=False + ) + + model = next(m for m in file.models if m.name == "single_glp") + true_seed = {name: par.value for name, par in model.lmfit_pars.items()} + file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) + + slot = project._fit_history[0] + persisted_init = dict( + zip(slot.params["name"], slot.params["init_value"], strict=True) + ) + assert persisted_init.keys() == true_seed.keys() + for name, seed_value in true_seed.items(): + assert persisted_init[name] == pytest.approx(seed_value) + + # + def test_stages1_init_value_is_true_seed(self): + """stages=1 already had correct init_value (no intermediate stage to + taint it); confirm the added correction call doesn't change that.""" + + truth_project = make_project(name="truth") + truth = _make_truth_file(truth_project) + data = simulate_noisy(truth.model_active, noise_level=0.01) + + project = make_project(name="fit") + file = _make_fit_file(project, data, truth.energy, truth.time) + file.define_baseline( + time_start=0, time_stop=3, time_type="ind", show_plot=False + ) + + model = next(m for m in file.models if m.name == "single_glp") + true_seed = {name: par.value for name, par in model.lmfit_pars.items()} + file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) + + slot = project._fit_history[0] + persisted_init = dict( + zip(slot.params["name"], slot.params["init_value"], strict=True) + ) + for name, seed_value in true_seed.items(): + assert persisted_init[name] == pytest.approx(seed_value) + + # + def test_stages2_fit_wrapper_result_and_report_show_true_seed(self, capsys): + """The fix lives in fitlib.fit_wrapper itself (not the slot + extractors), so it must be visible on two things the slot layer + doesn't touch: FitOutput.par_fin.params directly (any direct + consumer, not just code that passes through + _append_baseline_slot), and lmfit.report_fit's own printed + "(init = ...)" annotation for the local-optimization stage + (previously showed stage 1's output, contradicting the archive).""" + + truth_project = make_project(name="truth") + truth = _make_truth_file(truth_project) + data = simulate_noisy(truth.model_active, noise_level=0.01) + + project = make_project(name="fit", show_output=1) + file = _make_fit_file(project, data, truth.energy, truth.time) + file.define_baseline( + time_start=0, time_stop=3, time_type="ind", show_plot=False + ) + + model = next(m for m in file.models if m.name == "single_glp") + true_seed = {name: par.value for name, par in model.lmfit_pars.items()} + capsys.readouterr() # drop setup output + file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) + printed = capsys.readouterr().out + + result = file.model_base.result + assert result is not None + for name, seed_value in true_seed.items(): + assert result.par_fin.params[name].init_value == pytest.approx(seed_value) + + # lmfit.report_fit's "(init = )" for the *local optimization* + # stage must show the true seed, not stage 1's output. lmfit + # formats it with .7g (printfuncs.py:177). + local_section = printed.split("Results local optimization fit")[1] + for seed_value in true_seed.values(): + assert f"(init = {seed_value:.7g})" in local_section + # # --- spectrum slot extraction ------------------------------------------------ diff --git a/tests/test_lmfit_utils.py b/tests/test_lmfit_utils.py new file mode 100644 index 0000000..0900d6a --- /dev/null +++ b/tests/test_lmfit_utils.py @@ -0,0 +1,62 @@ +"""Unit tests for trspecfit.utils.lmfit helpers not covered elsewhere.""" + +import lmfit + +from trspecfit.utils.lmfit import restore_true_init_values + + +# +class TestRestoreTrueInitValues: + """restore_true_init_values — corrects a two-stage fit result's + init_value back to the true pre-fit seed (fitlib.fit_wrapper's + par_ini), which lmfit otherwise resets to stage 1's output.""" + + # + def test_overwrites_init_value_from_par_ini(self): + result_params = lmfit.Parameters() + result_params.add("A", value=5.0) + result_params.add("B", value=10.0) + # simulate lmfit's prepare_fit() stamping stage-1's output as + # stage-2's init_value + result_params["A"].init_value = 5.0 + result_params["B"].init_value = 10.0 + + par_ini = lmfit.Parameters() + par_ini.add("A", value=1.0) + par_ini.add("B", value=2.0) + + restore_true_init_values(result_params, par_ini) + + assert result_params["A"].init_value == 1.0 + assert result_params["B"].init_value == 2.0 + + # + def test_leaves_value_and_stderr_untouched(self): + result_params = lmfit.Parameters() + result_params.add("A", value=5.0) + result_params["A"].init_value = 5.0 + result_params["A"].stderr = 0.1 + + par_ini = lmfit.Parameters() + par_ini.add("A", value=1.0) + + restore_true_init_values(result_params, par_ini) + + assert result_params["A"].value == 5.0 + assert result_params["A"].stderr == 0.1 + + # + def test_ignores_names_absent_from_par_ini(self): + """A parameter present in result_params but not par_ini is left alone + (defensive; shouldn't happen in practice since both come from the + same fit_wrapper call).""" + + result_params = lmfit.Parameters() + result_params.add("A", value=5.0) + result_params["A"].init_value = 5.0 + + par_ini = lmfit.Parameters() # empty + + restore_true_init_values(result_params, par_ini) + + assert result_params["A"].init_value == 5.0 From a46140426fe7e1500fac7935d7128484ef7bf1b1 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Tue, 21 Jul 2026 14:25:28 -0700 Subject: [PATCH 24/29] persist fit_ini / params_init in SavedFitSlot (schema 5 -> 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial guess is fit-result provenance, not incidental — a different seed can land the same optimizer on a different local minimum. SavedFitSlot now persists fit_ini (model evaluated at the true pre-fit seed, all 4 fit types) and, for SbS, per-slice params_init (every slice, not just slice 0 — the seed was already available at no extra cost). FitResults.plot_fit renders the archive-side "initial guess" overlay via new PlotConfig.show_init (Project-backed, default True, resolved the same way as full_range). --- PLAN.md | 85 ++++++++++++++++++++++++++ TODO.md | 2 +- docs/design/fit_archive_schema.md | 85 ++++++++++++++++++++++---- src/trspecfit/config/plot.py | 9 +++ src/trspecfit/fit_results.py | 32 +++++++++- src/trspecfit/trspecfit.py | 86 +++++++++++++++++++++++++++ src/trspecfit/utils/fit_io.py | 63 ++++++++++++++++++-- src/trspecfit/utils/lmfit.py | 33 +++++++++++ tests/test_fit_archive_roundtrip.py | 80 +++++++++++++++++++++++++ tests/test_fit_history.py | 92 +++++++++++++++++++++++++++++ tests/test_fit_side_effects.py | 56 ++++++++++++++++++ tests/test_lmfit_utils.py | 63 +++++++++++++++++++- 12 files changed, 667 insertions(+), 19 deletions(-) diff --git a/PLAN.md b/PLAN.md index b352776..72a637d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -157,3 +157,88 @@ initial guess for baseline/spectrum/2d (e.g. a `fit_ini`/`components_ini` evaluated at `par_ini`, mirroring the schema-4 `components` pattern, so `plot_fit` can render an initial-guess overlay archive-side) and decide whether to fold in SbS slice-0 `init_value` at the same time. + +## Persist the true initial guess (`fit_ini`) for all 4 fit types (schema 5 → 6) + +Full design/rationale: `/home/yoyo/.claude/plans/hm-i-guess-the-eventual-simon.md` +(session-local; contents mirrored here for repo persistence). + +**Goal**: the direct sequel to the two sections above — persist an +*evaluated* initial-guess curve so `FitResults.plot_fit` can render the +archive-side overlay that live `plt_fit_res_1d`/`plot_sbs_slices` already +show (`show_init=True`, dotted-gold line). Same shape of change as the +schema-4 `components` work: persist the data now, richer viewers later. + +- [x] **`utils/lmfit.py`**: added `list_of_par_ini_to_df(results)` — + per-slice true initial-guess values (rows=fits, columns=parameters), + mirrors `list_of_par_stderr_to_df` but reads `result.par_ini` + directly (unaffected by the stage-2 `init_value` fix above). +- [x] **`utils/fit_io.py`**: schema `"5"` → `"6"` (additive). `SavedFitSlot` + gained `fit_ini: np.ndarray | None` (all 4 fit types; `None` on the + project-level joint-fit path, same case `components` already + handles) and `params_init: pd.DataFrame | None` (sbs only, mirrors + `params_stderr`'s shape — every slice, not slice-0-representative, + since `results_sbs[i].par_ini` is already available at no extra + plumbing cost). `_write_slot`/`_read_slot` conditional write/read; + `_slot_from_*` helpers and `_build_slot` thread the new kwargs + through. +- [x] **`trspecfit.py`**: `_append_baseline_slot`/`_append_spectrum_slot`/ + `_append_2d_slot` evaluate a second `fitlib.residual_fun(..., + par=fit_out.par_ini, res_type="fit")` alongside the existing + final-params evaluation, cropped identically to `fit_arr`. + `_append_sbs_slot` does the same per-slice inside the existing + per-slice loop (using `self.results_sbs[s_i].par_ini`), plus + `params_init = ulmfit.list_of_par_ini_to_df(self.results_sbs)` + after the loop. +- [x] **`config/plot.py` / `Project._set_defaults`**: `PlotConfig` gained + `show_init: bool = True` (docstring entry, placed near + `full_range`); `Project._set_defaults` gained `self.show_init = True`. +- [x] **`fit_results.py`**: `FitResults.plot_fit`/`File.plot_fit` gained + `show_init: bool | None = None`, resolved via `cfg.show_init` the + same `None` = "use config" way `full_range` was done. + `_plot_fit_1d` gained a `fit_ini` override param (mirrors `fit`/ + `components`/`roi`); renders the dotted-gold "initial guess" line + (`color="#FFD700", linestyle=":"`, matching `plt_fit_res_1d`'s live + style) when `show_init` resolves `True` and `fit_ini` is not + `None`. `full_range` mode `NaN`-pads `fit_ini` via the existing + `_pad_axis` helper, same honesty principle as `fit`/`components`. + Rendering is 1D-only (baseline/spectrum); 2D persists `fit_ini` for + completeness/symmetry but doesn't render it (no live precedent for + a 2D init overlay); SbS's archive view still routes through the + heatmap-style `plt_fit_res_2d` (no per-slice viewer yet — deferred, + but `fit_ini`/`params_init` are now available for it). +- [x] **Tests**: `test_fit_archive_roundtrip.py` — `_assert_slot_round_tripped` + extended with `fit_ini`/`params_init` checks across the F1/F6/F8 + family matrix; sbs cross-checks `params_init` against each slice's + true seed straight from the live `results_sbs` (joint validation + with the `fitlib.fit_wrapper` fix); new `_downgrade_archive_to_v5` + + `test_reader_accepts_schema_v5_archive`. `test_lmfit_utils.py` — new + `TestListOfParIniToDf` unit tests. `test_fit_history.py` — 4 new + `_plot_fit_1d` direct-call tests (renders when present+shown; omitted + when `show_init=False`; omitted when absent; `NaN`-padded in + `full_range` mode). `test_fit_side_effects.py` — new + `TestShowInitConfigResolution` class mirroring + `TestFullRangeConfigResolution` exactly (project default `True`; a + live baseline fit's display honors `config.show_init` in both + directions; per-call override wins). Full suite (1041 + 171 slow), + mypy, pyright, ruff all clean (whole-tree sweep; the 5 pre-existing + mypy errors in `test_full_range_plot.py`/`test_fit_archive_roundtrip.py` + were confirmed present on the base branch, unrelated to this work). +- [x] **Docs**: `docs/design/fit_archive_schema.md` — bumped documented + `schema_version` to `"6"`, added the 5→6 version-history entry, new + `fit_ini`/`params_init` dataset sections, updated the slot-group + layout diagram, reader→object-model mapping table, and per-fit-type + cheat sheet. `config/plot.py`'s `PlotConfig` docstring gained a + `show_init` entry. Verified with a `sphinx -W` build. +- [x] **Manual verification**: a `stages=2` baseline fit with + `show_output=1` shows stage-2's printed `init_value` matching the + true seed; `save_fits` → reload with no live `Model` → + `FitResults.plot_fit(..., full_range=True)` renders the "initial + guess" line, `NaN`-masked outside the fit window. + +**Status**: implementation complete, verified 2026-07-21. + +**Out of scope (deliberately deferred)**: no SbS per-slice archive viewer +yet (`fit_ini`/`params_init` make it buildable later without another +schema bump); no 2D visual initial-guess overlay; no model rehydration +for an initial-guess curve extrapolated beyond the fit window. diff --git a/TODO.md b/TODO.md index e6a7c13..97e6430 100644 --- a/TODO.md +++ b/TODO.md @@ -11,7 +11,7 @@ ## Plotting & results -- [ ] **Archive-portable SbS per-slice viewer**: once `SavedFitSlot` persists per-slice components (see `PLAN.md`), add a `FitResults` method mirroring `File.plot_sbs_slices`'s per-slice panel layout but sourced from the persisted slot instead of live `results_sbs`, so it works from a loaded archive with no live session. Note it will remain a strict subset of the live version — the live diagnostic also overlays the per-slice seeded initial guess, which is never part of a fit result and can't be persisted. +- [ ] **Archive-portable SbS per-slice viewer**: `SavedFitSlot` now persists both per-slice components (schema 4) and per-slice `fit_ini`/`params_init` (schema 6, `docs/design/fit_archive_schema.md`) — add a `FitResults` method mirroring `File.plot_sbs_slices`'s per-slice panel layout, sourced from the persisted slot instead of live `results_sbs`, so it works from a loaded archive with no live session. The seeded-initial-guess overlay (once thought unpersistable) can now be included too. - [ ] **Compiled-plan (GIR/`ScheduledPlan`) persistence for archive-portable diagnostics**: considered (2026-07-19) as a mechanism for reconstructing 1D component decomposition from an archive without a live `Model`, and declined in favor of directly persisting component curves (see `PLAN.md`) because it needs real new work first: neither `evaluate_1d` nor `evaluate_2d` supports per-component retention today (both fold every op into one accumulator; `spectra.fit_model_gir` already falls back to the live `mcp.Model` interpreter whenever components are requested), `ScheduledPlan1D`/`2D` drop component names at compile time (would need a side-channel), and there's no existing serializer for the plan's ~30 heterogeneous array fields. Worth revisiting as a general "resume analysis on any persisted fit" capability, independent of the component-visibility problem it was first proposed for. ## Performance & architecture diff --git a/docs/design/fit_archive_schema.md b/docs/design/fit_archive_schema.md index 48049e5..45d0f76 100644 --- a/docs/design/fit_archive_schema.md +++ b/docs/design/fit_archive_schema.md @@ -1,4 +1,4 @@ -# Fit-archive HDF5 schema (schema_version 5) +# Fit-archive HDF5 schema (schema_version 6) On-disk layout for the fit-results archive written by `Project.save_fits()` and read by `FitResults.load()` / `Project.load_fits()`. The object model @@ -87,7 +87,7 @@ dtypes. │ project_name : str # Project.name; set on first write │ timestamp_created : str # ISO 8601 UTC, first write │ timestamp_updated : str # ISO 8601 UTC, most recent write -│ schema_version : str # "5"; bump on incompatible change +│ schema_version : str # "6"; bump on incompatible change └── files/ # group; one subgroup per file ├── 000000/ # SavedFile (see "File group") └── 000001/... @@ -101,7 +101,7 @@ must not recreate the archive on subsequent saves unless the caller explicitly asks for that; the canonical way to start fresh is to choose a new path. -`schema_version` is currently `"5"`. Version history: +`schema_version` is currently `"6"`. Version history: - `"1"` → `"2"`: the σ-calibrated chi-square columns and per-slot sigma metadata changed the stored fields — a clean break, so schema-1 archives @@ -130,6 +130,19 @@ a new path. `utils/fit_io.py`); pre-5 archives and files with no auxiliary axis both load with `aux_axis` as `None` on `SavedFile`. The writer still refuses to append to an archive whose version differs from its own. +- `"5"` → `"6"` (2026-07): **additive** — slot `fit_ini` dataset for all 4 + fit types (the model evaluated at the true pre-fit seed, + `FitOutput.par_ini`; `None` on the project-level joint-fit path, where + no per-file `par_ini` exists), plus the sbs-only `params_init` dataset + (per-slice true initial-guess values, mirroring `params_stderr`'s + shape). Unlike `correl`/`mcmc`/`params_meta` (slice-0-representative), + sbs `fit_ini`/`params_init` cover every slice — the per-slice seed is + already available in the worker's `FitOutput` at no extra cost. The + reader accepts `"2"` through `"6"` (`SUPPORTED_READ_VERSIONS` in + `utils/fit_io.py`); pre-6 archives load both fields as `None`, and + `FitResults.plot_fit` simply omits the initial-guess overlay in that + case. The writer still refuses to append to an archive whose version + differs from its own. Future incompatible changes (e.g. project-scoped joint-result slots or `keep_history=True` full-log save — both deferred, see "What's *not* in @@ -236,8 +249,10 @@ files/000000/slots/000000/ ├── params # see "params dataset" below; layout depends on fit_type ├── params_meta (opt) # heterogeneous-DataFrame dataset; sbs only; schema ≥ 3 ├── params_stderr (opt) # all-numeric DataFrame dataset; sbs only; schema ≥ 3 +├── params_init (opt) # all-numeric DataFrame dataset; sbs only; schema ≥ 6 ├── observed # 1D or 2D dataset; preserves source dtype ├── fit # 1D or 2D dataset; preserves source dtype; observed.shape == fit.shape +├── fit_ini (opt) # same shape as fit; see "fit_ini dataset"; schema ≥ 6 ├── metrics_per_slice (opt) # 1D structured dataset; sbs only ├── conf_ci (opt) # heterogeneous-DataFrame dataset; see "conf_ci dataset" ├── correl (opt) # all-numeric DataFrame dataset; see "correl dataset" @@ -251,9 +266,11 @@ files/000000/slots/000000/ scalars. `(opt)` = present iff the corresponding `SavedFitSlot` field is non-`None` -(`conf_ci`, `correl`, `mcmc`, `components`, `component_names`) or -applicable to the fit type (`metrics_per_slice` is sbs-only; `components` -/ `component_names` are never present for `fit_type == "2d"`). +(`conf_ci`, `correl`, `mcmc`, `components`, `component_names`, `fit_ini`, +`params_init`) or applicable to the fit type (`metrics_per_slice` and +`params_init` are sbs-only; `components` / `component_names` are never +present for `fit_type == "2d"`; `fit_ini` is `None` on the project-level +joint-fit path, where no per-file `par_ini` exists). ### `archive_slot_key` vs `history_key` @@ -370,6 +387,23 @@ params_stderr : 2D float64 dataset, shape (n_slices, n_par) data (no None mapping). Mirrors `list_of_par_stderr_to_df(results)` in `utils/lmfit.py`. +## `params_init` dataset (sbs only, optional; schema ≥ 6) + +Per-slice true initial-guess values, mirroring the wide `params` / +`params_stderr` layout: + +``` +params_init : 2D float64 dataset, shape (n_slices, n_par) + attrs: + columns : vlen str[n_par] # parameter names; axis-1 order +``` + +Unlike `correl`/`mcmc`/`params_meta` (captured from slice 0 only as a +representative), `params_init` covers **every** slice — the per-slice +seed (`FitOutput.par_ini`) is already available in the sbs worker's +result at no extra plumbing cost. Mirrors +`list_of_par_ini_to_df(results)` in `utils/lmfit.py`. + ## `fit_settings` attr (optional; schema ≥ 3) JSON-encoded dict on the slot `metadata` group recording the optimizer @@ -503,6 +537,31 @@ Absent in schema-2/3 archives; the reader maps absence to `None` for both fields, and `FitResults.plot_fit` falls back to the pre-schema-4 sum-only 1D rendering when `components is None`. +## `fit_ini` dataset (optional; schema ≥ 6) + +Model evaluated at the true pre-fit seed (`FitOutput.par_ini`), on the +same grid as `fit`, for all 4 fit types: + +``` +fit_ini : ndarray (preserves source dtype) + baseline / spectrum : shape (n_e_view,) + 2d : shape (n_t_view, n_e_view) + sbs : shape (n_slices, n_e_view) — every slice +``` + +`None` on the project-level joint-fit path (`Project.fit_2d()`), where +per-file results are projections of one joint optimization and no +per-file initial guess exists — same case `components` already handles +for baseline/spectrum/2d. Absent in schema < 6 archives; the reader maps +absence to `None`, and `FitResults.plot_fit` simply omits the +initial-guess overlay in that case. Rendered (1D fit types only) as the +dotted-gold "initial guess" line by `FitResults._plot_fit_1d` when +`PlotConfig.show_init` resolves `True`; `NaN`-padded outside the fit +window in `full_range` mode, same honesty principle as `fit`/`components` +(never a fabricated extrapolation). 2D persists `fit_ini` for +completeness/symmetry but does not render it (no live precedent for a 2D +initial-guess overlay either). + ## Reader → object-model mapping Per slot, the reader produces a `SavedFitSlot` with: @@ -520,10 +579,12 @@ Per slot, the reader produces a `SavedFitSlot` with: | `params` | `params` dataset (+ its `columns` attr) → DataFrame | | `params_meta` | `params_meta` dataset → DataFrame, or `None` if absent | | `params_stderr` | `params_stderr` dataset → DataFrame, or `None` if absent | +| `params_init` | `params_init` dataset → DataFrame, or `None` if absent | | `fit_settings` | `metadata.fit_settings` attr (JSON) → dict, or `None` if absent | | `metrics` | scalar attrs (non-sbs) or `metrics_per_slice` (sbs) → dict | | `observed` | `observed` dataset | | `fit` | `fit` dataset | +| `fit_ini` | `fit_ini` dataset → ndarray, or `None` if absent | | `fit_alg` | slot `metadata.fit_alg` attr | | `yaml_filename` | slot `metadata.yaml_filename` attr (None if absent) | | `timestamp` | slot `metadata.timestamp` attr | @@ -540,12 +601,12 @@ on the returned `SavedFitSlot` always comes from the live recompute. ## Per-fit-type cheat sheet -| fit_type | `observed.shape` | `params` layout | metrics location | `components.shape` | sbs-only datasets | t_lim applied | -|------------|-------------------------|--------------------------------------|---------------------------|----------------------------------------|-----------------------|---------------| -| baseline | `(n_e_view,)` | structured (long, named columns) | scalar attrs | `(n_components, n_e_view)` | — | n/a | -| spectrum | `(n_e_view,)` | structured (long, named columns) | scalar attrs | `(n_components, n_e_view)` | — | n/a | -| sbs | `(n_t_full, n_e_view)` | 2D float64 + `columns` attr (wide) | `metrics_per_slice` | `(n_t_full, n_components, n_e_view)` | `metrics_per_slice` | **no** | -| 2d | `(n_t_view, n_e_view)` | structured (long, named columns) | scalar attrs | always `None` | — | yes | +| fit_type | `observed.shape` | `params` layout | metrics location | `components.shape` | `fit_ini.shape` | sbs-only datasets | t_lim applied | +|------------|-------------------------|--------------------------------------|---------------------------|----------------------------------------|-------------------------|---------------------------------------|---------------| +| baseline | `(n_e_view,)` | structured (long, named columns) | scalar attrs | `(n_components, n_e_view)` | `(n_e_view,)` or `None` | — | n/a | +| spectrum | `(n_e_view,)` | structured (long, named columns) | scalar attrs | `(n_components, n_e_view)` | `(n_e_view,)` or `None` | — | n/a | +| sbs | `(n_t_full, n_e_view)` | 2D float64 + `columns` attr (wide) | `metrics_per_slice` | `(n_t_full, n_components, n_e_view)` | `(n_t_full, n_e_view)` | `metrics_per_slice`, `params_init` | **no** | +| 2d | `(n_t_view, n_e_view)` | structured (long, named columns) | scalar attrs | always `None` | `(n_t_view, n_e_view)` or `None` | — | yes | `n_e_view` denotes the energy axis cropped by `e_lim`; `n_t_view` denotes the time axis cropped by `t_lim`. `n_t_full` is the file's full diff --git a/src/trspecfit/config/plot.py b/src/trspecfit/config/plot.py index 00e3222..4146a58 100644 --- a/src/trspecfit/config/plot.py +++ b/src/trspecfit/config/plot.py @@ -58,6 +58,10 @@ class PlotConfig: ``FitResults.plot_fit``: show the full, uncropped data range (fit/residual/components ``NaN``-masked outside the fit window) instead of just the fit-limits window. Overridable per call. + show_init : bool + ``FitResults.plot_fit``: draw the dotted-gold initial-guess + overlay (1D fit types only) when the slot has a persisted + ``fit_ini``. Overridable per call. z_colormap : str Colormap name for 2D plots z_colormap_res : str @@ -152,6 +156,11 @@ class PlotConfig: # fit-limits window. A per-call full_range= argument overrides this. full_range: bool = True + # FitResults.plot_fit: draw the dotted-gold initial-guess overlay (1D fit + # types only) when the slot has a persisted fit_ini. A per-call + # show_init= argument overrides this. + show_init: bool = True + # 2D plot settings z_colormap: str = "viridis" # Residual maps are signed and centered on 0 -> diverging colormap diff --git a/src/trspecfit/fit_results.py b/src/trspecfit/fit_results.py index 6bac6e8..028b7a5 100644 --- a/src/trspecfit/fit_results.py +++ b/src/trspecfit/fit_results.py @@ -738,6 +738,7 @@ def plot_fit( config: Any = None, show_plot: bool = True, full_range: bool | None = None, + show_init: bool | None = None, ) -> None: """ Plot the latest matching fit: observed, fit, and residual. @@ -775,6 +776,12 @@ def plot_fit( Default: ``config.full_range`` (``PlotConfig`` field, itself ``Project.full_range``-backed; ``True`` out of the box) — pass explicitly to override for one call. + show_init : bool, optional + Draw the dotted-gold initial-guess overlay (1D fit types + only — baseline/spectrum) when the slot has a persisted + ``fit_ini``. Default: ``config.show_init`` (``PlotConfig`` + field, itself ``Project.show_init``-backed; ``True`` out of + the box) — pass explicitly to override for one call. Raises ------ @@ -786,9 +793,12 @@ def plot_fit( cfg = self._config_for(slot, config) if full_range is None: full_range = bool(getattr(cfg, "full_range", False)) + if show_init is None: + show_init = bool(getattr(cfg, "show_init", False)) observed = np.asarray(slot.observed) fit = np.asarray(slot.fit) components = slot.components + fit_ini = slot.fit_ini e_lim: list[int] | None = None t_lim: list[int] | None = None @@ -812,6 +822,8 @@ def plot_fit( fit = self._pad_axis(fit, n_e_full, e_lim, axis=1) else: # baseline / spectrum fit = self._pad_axis(fit, n_e_full, e_lim, axis=0) + if fit_ini is not None: + fit_ini = self._pad_axis(fit_ini, n_e_full, e_lim, axis=0) if components is not None: comp_axis = 2 if slot.fit_type == "sbs" else 1 components = self._pad_axis( @@ -846,6 +858,8 @@ def plot_fit( fit=fit, components=components, roi=e_lim, + fit_ini=fit_ini, + show_init=show_init, ) # @@ -860,6 +874,8 @@ def _plot_fit_1d( fit: np.ndarray | None = None, components: np.ndarray | None = None, roi: list[int] | None = None, + fit_ini: np.ndarray | None = None, + show_init: bool = True, ) -> Any: """ Observed + fit (with components, when persisted) over a residual panel. @@ -868,7 +884,9 @@ def _plot_fit_1d( (cropped) arrays; pass overrides to render a full-range reconstruction instead (see ``FitResults.plot_fit``'s ``full_range``). ``roi`` draws dashed boundary lines at the given - ``[start, stop)`` index window (full-range mode only). + ``[start, stop)`` index window (full-range mode only). ``fit_ini`` + (default: the slot's own, when ``show_init`` and persisted) draws + the dotted-gold initial-guess overlay. """ import matplotlib.pyplot as plt @@ -876,6 +894,7 @@ def _plot_fit_1d( obs = np.asarray(observed if observed is not None else slot.observed).ravel() fit_arr = np.asarray(fit if fit is not None else slot.fit).ravel() comps = components if components is not None else slot.components + ini = fit_ini if fit_ini is not None else slot.fit_ini if energy is not None and energy.size == obs.size: x = energy x_label = getattr(config, "x_label", "energy") @@ -890,6 +909,17 @@ def _plot_fit_1d( height_ratios=[3, 1], ) ax_fit.plot(x, obs, "k.", ms=3, label="observed") + if show_init and ini is not None: + # NaN entries (full-range mode, outside the fit window) leave a + # gap rather than a fabricated value, matching fit/components. + ax_fit.plot( + x, + np.asarray(ini).ravel(), + color="#FFD700", + linestyle=":", + linewidth=2, + label="initial guess", + ) if comps is not None: # schema >= 4: render the persisted per-component decomposition, # matching fitlib.plt_fit_res_1d's live visual style. NaN diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index ad7634b..e86213d 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -245,6 +245,7 @@ def _set_defaults(self) -> None: self.dpi_save = 300 self.res_mult = 5 self.full_range = True + self.show_init = True self.title = "" self.x_lim = None self.y_lim = None @@ -3333,6 +3334,25 @@ def _append_baseline_slot( ) else: components = np.stack([np.asarray(c) for c in components_full], axis=0) + # Model evaluated at the true pre-fit seed, same grid as fit_arr. + # None on the project-level joint-fit path (no per-file par_ini). + fit_ini_arr = None + if fit_out.par_ini is not None: + fit_ini_full = np.asarray( + fitlib.residual_fun( + par=fit_out.par_ini, + x=self.energy, + data=self.data_base, + fit_fun_str=fit_fun_str, + args=self.model_base.args, + res_type="fit", + ) + ) + fit_ini_arr = ( + fit_ini_full[e_lim[0] : e_lim[1]].copy() + if e_lim + else fit_ini_full.copy() + ) params_df = ulmfit.par_to_df( result_fin.params, col_type="min", @@ -3370,6 +3390,7 @@ def _append_baseline_slot( fit_settings=fit_settings, components=components, component_names=component_names, + fit_ini=fit_ini_arr, ) self.p._fit_history.append(slot) return slot @@ -3428,6 +3449,25 @@ def _append_spectrum_slot( ) else: components = np.stack([np.asarray(c) for c in components_full], axis=0) + # Model evaluated at the true pre-fit seed, same grid as fit_arr. + # None on the project-level joint-fit path (no per-file par_ini). + fit_ini_arr = None + if fit_out.par_ini is not None: + fit_ini_full = np.asarray( + fitlib.residual_fun( + par=fit_out.par_ini, + x=self.energy, + data=self.data_spec, + fit_fun_str=fit_fun_str, + args=self.model_spec.args, + res_type="fit", + ) + ) + fit_ini_arr = ( + fit_ini_full[e_lim[0] : e_lim[1]].copy() + if e_lim + else fit_ini_full.copy() + ) params_df = ulmfit.par_to_df( result_fin.params, col_type="min", @@ -3464,6 +3504,7 @@ def _append_spectrum_slot( fit_settings=fit_settings, components=components, component_names=component_names, + fit_ini=fit_ini_arr, ) self.p._fit_history.append(slot) return slot @@ -3501,6 +3542,7 @@ def _append_sbs_slot( observed_rows = [] fit_rows = [] component_rows = [] + fit_ini_rows = [] component_names = [comp.name for comp in self.model_sbs.components] for s_i in range(n_slices): slice_data = self.data[s_i] @@ -3519,6 +3561,19 @@ def _append_sbs_slot( components_full = getattr(spectra, fit_fun_str)( self.energy, slice_par_vals, False, *self.model_sbs.args ) + # Per-slice seed is already available at no extra cost (the + # worker returns the full FitOutput, not a stripped payload). + slice_par_ini = self.results_sbs[s_i].par_ini + fit_ini_full = np.asarray( + fitlib.residual_fun( + par=slice_par_ini, + x=self.energy, + data=slice_data, + fit_fun_str=fit_fun_str, + args=self.model_sbs.args, + res_type="fit", + ) + ) if e_lim: observed_rows.append(slice_data[e_lim[0] : e_lim[1]].copy()) fit_rows.append(fit_full[e_lim[0] : e_lim[1]].copy()) @@ -3528,17 +3583,21 @@ def _append_sbs_slot( axis=0, ) ) + fit_ini_rows.append(fit_ini_full[e_lim[0] : e_lim[1]].copy()) else: observed_rows.append(slice_data.copy()) fit_rows.append(fit_full.copy()) component_rows.append( np.stack([np.asarray(c) for c in components_full], axis=0) ) + fit_ini_rows.append(fit_ini_full.copy()) observed = np.stack(observed_rows, axis=0) fit_arr = np.stack(fit_rows, axis=0) components = np.stack(component_rows, axis=0) + fit_ini = np.stack(fit_ini_rows, axis=0) # Per-slice DataFrame (one row per slice, columns = parameter values). params_df = ulmfit.list_of_par_to_df(self.results_sbs) + params_init = ulmfit.list_of_par_ini_to_df(self.results_sbs) # n_free_pars + fit_alg captured from slice 0 (consistent across slices # because the same model_sbs is used for every slice). slice0_result = self.results_sbs[0].par_fin @@ -3587,6 +3646,8 @@ def _append_sbs_slot( fit_settings=fit_settings, components=components, component_names=component_names, + fit_ini=fit_ini, + params_init=params_init, ) self.p._fit_history.append(slot) return slot @@ -3633,6 +3694,25 @@ def _append_2d_slot( fit_arr = fit_arr[:, e_lim[0] : e_lim[1]] observed = observed.copy() fit_arr = fit_arr.copy() + # Model evaluated at the true pre-fit seed, same grid as fit_arr. + # None on the project-level joint-fit path (no per-file par_ini). + fit_ini_arr = None + if fit_out.par_ini is not None: + fit_ini_full = np.asarray( + fitlib.residual_fun( + par=fit_out.par_ini, + x=self.energy, + data=self.data, + fit_fun_str=fit_fun_str, + args=self.model_2d.args, + res_type="fit", + ) + ) + if t_lim: + fit_ini_full = fit_ini_full[t_lim[0] : t_lim[1], :] + if e_lim: + fit_ini_full = fit_ini_full[:, e_lim[0] : e_lim[1]] + fit_ini_arr = fit_ini_full.copy() params_df = ulmfit.par_to_df( result_fin.params, col_type="min", @@ -3668,6 +3748,7 @@ def _append_2d_slot( correl=correl, mcmc=mcmc, fit_settings=fit_settings, + fit_ini=fit_ini_arr, ) self.p._fit_history.append(slot) return slot @@ -4210,6 +4291,7 @@ def plot_fit( config: PlotConfig | None = None, show_plot: bool = True, full_range: bool | None = None, + show_init: bool | None = None, ) -> None: """ Plot the latest matching fit: observed, fit, and residual. @@ -4233,6 +4315,9 @@ def plot_fit( Show the full, uncropped data range instead of just the fit window. Default: ``config.full_range``. See :meth:`FitResults.plot_fit`. + show_init : bool, optional + Draw the dotted-gold initial-guess overlay. Default: + ``config.show_init``. See :meth:`FitResults.plot_fit`. """ self.p.results.plot_fit( @@ -4242,6 +4327,7 @@ def plot_fit( config=config, show_plot=show_plot, full_range=full_range, + show_init=show_init, ) # diff --git a/src/trspecfit/utils/fit_io.py b/src/trspecfit/utils/fit_io.py index 831e32a..c78a6c1 100644 --- a/src/trspecfit/utils/fit_io.py +++ b/src/trspecfit/utils/fit_io.py @@ -44,14 +44,18 @@ from trspecfit.utils.hdf5 import require_dataset, require_group FitType = Literal["baseline", "spectrum", "sbs", "2d"] -SCHEMA_VERSION = "5" +SCHEMA_VERSION = "6" # Schema 3 is additive over 2 (slot `correl` dataset, mcmc # `acceptance_fraction` dataset). Schema 4 is additive over 3 (slot # `components` / `component_names` datasets for 1D fit types — baseline, # spectrum, sbs; never present for 2d). Schema 5 is additive over 4 (per-file, -# not per-slot, optional `aux_axis` dataset). The reader accepts all four; the -# writer still refuses cross-version appends (see _classify_archive_for_write). -SUPPORTED_READ_VERSIONS = ("2", "3", "4", "5") +# not per-slot, optional `aux_axis` dataset). Schema 6 is additive over 5 +# (slot `fit_ini` dataset for all 4 fit types — the model evaluated at the +# true pre-fit seed, `None` on the project-level joint-fit path; sbs-only +# `params_init` dataset, per-slice true seed values mirroring +# `params_stderr`'s shape). The reader accepts all five; the writer still +# refuses cross-version appends (see _classify_archive_for_write). +SUPPORTED_READ_VERSIONS = ("2", "3", "4", "5", "6") # Default noise metadata used when no σ has been set on the File. Mirrors the # project.yaml defaults defined in ``Project._set_defaults``; if you change one, @@ -274,6 +278,20 @@ class SavedFitSlot: adding a distinct ``model.components`` entry, breaking any prefix-based re-derivation. ``None`` exactly when ``components`` is ``None``. + fit_ini : np.ndarray | None + Model evaluated at the true pre-fit seed (``FitOutput.par_ini``), + on the same grid as ``fit``. ``None`` on the project-level + joint-fit path (no per-file ``par_ini`` there) and for slots + loaded from schema < 6 archives. Shape matches ``fit``: for sbs, + ``(n_slices, energy_in_lim)`` (every slice, not just slice 0 — + the per-slice seed is already available at slot-construction + time at no extra cost). + params_init : pd.DataFrame | None + SbS only: per-slice true initial-guess values, same shape/columns + as the wide ``params`` frame's value columns (mirrors + ``params_stderr``). ``None`` for other fit types (long-form + ``params`` already has an ``init_value`` column) and for slots + loaded from schema < 6 archives. """ file_fingerprint: dict[str, Any] @@ -304,6 +322,8 @@ class SavedFitSlot: fit_settings: dict[str, Any] | None = None components: np.ndarray | None = None component_names: list[str] | None = None + fit_ini: np.ndarray | None = None + params_init: pd.DataFrame | None = None # @@ -634,6 +654,7 @@ def _slot_from_baseline( fit_settings: dict[str, Any] | None = None, components: np.ndarray | None = None, component_names: list[str] | None = None, + fit_ini: np.ndarray | None = None, ) -> SavedFitSlot: """ Build a SavedFitSlot for a completed baseline fit. @@ -670,6 +691,7 @@ def _slot_from_baseline( sigma_data=sigma_data, components=components, component_names=component_names, + fit_ini=fit_ini, ) @@ -699,6 +721,7 @@ def _slot_from_spectrum( fit_settings: dict[str, Any] | None = None, components: np.ndarray | None = None, component_names: list[str] | None = None, + fit_ini: np.ndarray | None = None, ) -> SavedFitSlot: """Build a SavedFitSlot for a completed spectrum fit. @@ -735,6 +758,7 @@ def _slot_from_spectrum( sigma_data=sigma_data, components=components, component_names=component_names, + fit_ini=fit_ini, ) @@ -764,6 +788,8 @@ def _slot_from_sbs( fit_settings: dict[str, Any] | None = None, components: np.ndarray | None = None, component_names: list[str] | None = None, + fit_ini: np.ndarray | None = None, + params_init: pd.DataFrame | None = None, ) -> SavedFitSlot: """ Build a SavedFitSlot for a completed slice-by-slice fit. @@ -822,6 +848,8 @@ def _slot_from_sbs( fit_settings=fit_settings, components=components, component_names=component_names, + fit_ini=fit_ini, + params_init=params_init, ) @@ -847,6 +875,7 @@ def _slot_from_2d( correl: pd.DataFrame | None = None, mcmc: dict[str, Any] | None = None, fit_settings: dict[str, Any] | None = None, + fit_ini: np.ndarray | None = None, ) -> SavedFitSlot: """Build a SavedFitSlot for a completed 2D global fit.""" @@ -874,6 +903,7 @@ def _slot_from_2d( sigma_source=sigma_source, sigma_type=sigma_type, sigma_data=sigma_data, + fit_ini=fit_ini, ) @@ -906,6 +936,7 @@ def _build_slot( sigma_data: float, components: np.ndarray | None = None, component_names: list[str] | None = None, + fit_ini: np.ndarray | None = None, ) -> SavedFitSlot: """Shared scalar-metric path for baseline / spectrum / 2d.""" @@ -951,6 +982,7 @@ def _build_slot( fit_settings=fit_settings, components=components, component_names=component_names, + fit_ini=fit_ini, ) @@ -1482,6 +1514,13 @@ def _write_slot( slot.params_stderr, type_tags=_all_float64_tags(len(slot.params_stderr.columns)), ) + if slot.params_init is not None: + _encode_dataframe( + slot_group, + "params_init", + slot.params_init, + type_tags=_all_float64_tags(len(slot.params_init.columns)), + ) if slot.conf_ci is not None: _encode_dataframe(slot_group, "conf_ci", slot.conf_ci) if slot.correl is not None: @@ -1504,6 +1543,8 @@ def _write_slot( "component_names", data=np.array(slot.component_names, dtype=_VLEN_STR), ) + if slot.fit_ini is not None: + slot_group.create_dataset("fit_ini", data=np.ascontiguousarray(slot.fit_ini)) # @@ -1802,6 +1843,12 @@ def _read_slot( if params_stderr_obj is not None else None ) + params_init_obj = slot_group.get("params_init") + params_init = ( + _decode_dataframe(require_dataset(params_init_obj, "params_init")) + if params_init_obj is not None + else None + ) fit_settings = ( json.loads(_attr_str(a["fit_settings"])) if "fit_settings" in a else None ) @@ -1818,6 +1865,12 @@ def _read_slot( components = np.asarray(require_dataset(components_obj, "components")[...]) names_obj = require_dataset(slot_group["component_names"], "component_names") component_names = [_to_str_value(v) for v in names_obj[...]] + fit_ini_obj = slot_group.get("fit_ini") + fit_ini = ( + np.asarray(require_dataset(fit_ini_obj, "fit_ini")[...]) + if fit_ini_obj is not None + else None + ) yaml_filename = _attr_str(a["yaml_filename"]) if "yaml_filename" in a else None selection = json.loads(selection_json) @@ -1859,6 +1912,8 @@ def _read_slot( fit_settings=fit_settings, components=components, component_names=component_names, + fit_ini=fit_ini, + params_init=params_init, ) diff --git a/src/trspecfit/utils/lmfit.py b/src/trspecfit/utils/lmfit.py index 4029b4b..d51852d 100644 --- a/src/trspecfit/utils/lmfit.py +++ b/src/trspecfit/utils/lmfit.py @@ -589,6 +589,39 @@ def list_of_par_stderr_to_df(results: list[FitOutput]) -> pd.DataFrame: return pd.DataFrame(rows, columns=param_names) +# +def list_of_par_ini_to_df(results: list[FitOutput]) -> pd.DataFrame: + """ + Extract per-fit true initial-guess values into a DataFrame. + + Companion to :func:`list_of_par_stderr_to_df` with the same shape + contract (rows=fits, columns=parameters): collects each fit's true + pre-fit seed (``FitOutput.par_ini``) rather than the optimized value. + Reads the seed directly, so it is unaffected by + ``fitlib.fit_wrapper``'s two-stage ``init_value`` correction. + + Parameters + ---------- + results : list of FitOutput + Fit results from ``fitlib.fit_wrapper``; each ``par_ini`` holds + the pre-fit ``lmfit.Parameters`` seed (never ``None`` for + per-slice SbS results, the only caller of this function). + + Returns + ------- + pd.DataFrame + DataFrame with rows=individual fits, columns=parameter init values. + """ + + param_names = list(results[0].par_fin.params.keys()) + rows = [] + for result in results: + par_ini = result.par_ini + assert par_ini is not None # type guard + rows.append([par_ini[name].value for name in param_names]) + return pd.DataFrame(rows, columns=param_names) + + # # Configuration and compatibility classes # diff --git a/tests/test_fit_archive_roundtrip.py b/tests/test_fit_archive_roundtrip.py index 69ed431..26167c3 100644 --- a/tests/test_fit_archive_roundtrip.py +++ b/tests/test_fit_archive_roundtrip.py @@ -205,6 +205,24 @@ def _assert_slot_round_tripped(loaded: SavedFitSlot, original: SavedFitSlot) -> # Components must sum back to the persisted fit curve. np.testing.assert_allclose(recon, loaded.fit, rtol=1e-8, atol=1e-8) + # --- fit_ini / params_init (schema 6) ------------------------------- + # None only on the project-level joint-fit path; every family/fit_type + # exercised here goes through a per-File fit method, so fit_ini is + # always populated. + assert original.fit_ini is not None + assert loaded.fit_ini is not None + np.testing.assert_array_equal(loaded.fit_ini, original.fit_ini) + assert loaded.fit_ini.shape == loaded.fit.shape + if original.fit_type == "sbs": + assert original.params_init is not None + assert loaded.params_init is not None + _assert_optional_df_equal( + loaded.params_init, original.params_init, label="params_init" + ) + else: + assert original.params_init is None + assert loaded.params_init is None + # --- residual reconstruction (design invariant) -------------------- # chi2_raw is the lmfit-unweighted SSE diagnostic; chi2 is σ-calibrated # and NaN when no σ was set on the file, so we cross-check against the raw @@ -383,6 +401,17 @@ def test_sbs_roundtrip(family_id: str, tmp_path) -> None: assert loaded_slot.metrics["chi2"].shape == (len(fit_file.time),) _assert_slot_round_tripped(loaded_slot, original) + # Cross-check params_init against each slice's true seed straight from + # the live SbS results — joint validation that this feature and the + # fitlib.fit_wrapper stage-2 init_value fix agree with each other. + assert loaded_slot.params_init is not None # type guard + for i, result in enumerate(fit_file.results_sbs): + assert result.par_ini is not None # type guard + for name in loaded_slot.params_init.columns: + assert loaded_slot.params_init.iloc[i][name] == pytest.approx( + result.par_ini[name].value + ) + # --------------------------------------------------------------------------- # 2D round-trip @@ -627,6 +656,31 @@ def _downgrade_archive_to_v4(archive_path) -> None: del fg["aux_axis"] +# +def _downgrade_archive_to_v5(archive_path) -> None: + """Rewrite a schema-6 archive as schema 5 in place: relabel the version + and delete only the schema-6 additions (slot ``fit_ini`` / + ``params_init``), keeping every schema-5 field intact.""" + + import h5py + + from trspecfit.utils.hdf5 import require_group + + with h5py.File(archive_path, "r+") as h5: + require_group(h5["metadata"], "metadata").attrs["schema_version"] = "5" + files_group = require_group(h5["files"], "files") + for f_key in files_group: + slots_obj = require_group(files_group[f_key], f_key).get("slots") + if slots_obj is None: + continue + slots = require_group(slots_obj, "slots") + for s_key in slots: + sg = require_group(slots[s_key], s_key) + for ds in ("fit_ini", "params_init"): + if ds in sg: + del sg[ds] + + # def test_reader_accepts_schema_v2_archive(tmp_path) -> None: """Schema 3 is additive, so v2 archives must still load — with the @@ -697,6 +751,32 @@ def test_reader_accepts_schema_v4_archive(tmp_path) -> None: assert provider.aux_axis is None +# +def test_reader_accepts_schema_v5_archive(tmp_path) -> None: + """Schema 6 is additive, so v5 archives must still load — with + fit_ini/params_init as None, and FitResults.plot_fit simply omitting + the initial-guess overlay in that case.""" + + _, fit_file, family = _build_fit_file("F1") + fit_file.fit_baseline(model_name=family.model_name("default"), stages=1, try_ci=0) + archive_path = tmp_path / "v5.fit.h5" + fit_file.p.save_fits(archive_path, show_output=0) + _downgrade_archive_to_v5(archive_path) + + loaded = FitResults.load(archive_path) + assert len(loaded) == 1 + slot = next(iter(loaded)) + assert slot.fit_ini is None + assert slot.params_init is None + + import matplotlib.pyplot as plt + + try: + loaded.plot_fit(file=slot.file_name, fit_type="baseline", show_plot=False) + finally: + plt.close("all") + + # def test_reader_rejects_unknown_schema_version(tmp_path) -> None: """Versions outside SUPPORTED_READ_VERSIONS raise a clear ValueError.""" diff --git a/tests/test_fit_history.py b/tests/test_fit_history.py index 07a9b73..4e36279 100644 --- a/tests/test_fit_history.py +++ b/tests/test_fit_history.py @@ -1818,6 +1818,98 @@ def test_plot_fit_1d_full_range_defaults_match_cropped_view(self): finally: plt.close(fig) + # + def test_plot_fit_1d_renders_initial_guess_when_present_and_shown(self): + """fit_ini + show_init=True (default) -> dotted-gold "initial + guess" line, drawn alongside observed/fit.""" + + import dataclasses + + import matplotlib.pyplot as plt + + obs = np.array([1.0, 2.0, 3.0]) + fit = np.array([1.1, 1.9, 3.2]) + fit_ini = np.array([0.5, 1.0, 1.5]) + slot = dataclasses.replace(_slot_stub(), observed=obs, fit=fit, fit_ini=fit_ini) + fig = FitResults._plot_fit_1d(slot, energy=None, config=None, show_plot=False) + try: + ax_fit = fig.axes[0] + labels = [line.get_label() for line in ax_fit.lines] + assert "initial guess" in labels + ini_line = next( + line for line in ax_fit.lines if line.get_label() == "initial guess" + ) + np.testing.assert_array_equal(ini_line.get_ydata(), fit_ini) + assert ini_line.get_linestyle() == ":" + assert ini_line.get_color() == "#FFD700" + finally: + plt.close(fig) + + # + def test_plot_fit_1d_omits_initial_guess_when_show_init_false(self): + """A persisted fit_ini is not drawn when show_init=False.""" + + import dataclasses + + import matplotlib.pyplot as plt + + slot = dataclasses.replace(_slot_stub(), fit_ini=np.array([0.5, 1.0, 1.5])) + fig = FitResults._plot_fit_1d( + slot, energy=None, config=None, show_plot=False, show_init=False + ) + try: + labels = [line.get_label() for line in fig.axes[0].lines] + assert "initial guess" not in labels + finally: + plt.close(fig) + + # + def test_plot_fit_1d_omits_initial_guess_when_absent(self): + """show_init=True (default) with no persisted fit_ini draws nothing + extra — schema < 6 slots keep the pre-schema-6 rendering.""" + + import matplotlib.pyplot as plt + + slot = _slot_stub() + assert slot.fit_ini is None + fig = FitResults._plot_fit_1d(slot, energy=None, config=None, show_plot=False) + try: + labels = [line.get_label() for line in fig.axes[0].lines] + assert "initial guess" not in labels + finally: + plt.close(fig) + + # + def test_plot_fit_1d_full_range_pads_fit_ini_with_nan(self): + """full_range mode: an explicit fit_ini override (NaN outside the + fit window, mirroring fit/components) renders with the same gaps.""" + + import matplotlib.pyplot as plt + + x = np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + obs_full = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]) + fit_full = np.array([np.nan, np.nan, 2.9, 3.9, np.nan, np.nan]) + fit_ini_full = np.array([np.nan, np.nan, 0.8, 1.6, np.nan, np.nan]) + slot = _slot_stub() + fig = FitResults._plot_fit_1d( + slot, + energy=x, + config=None, + show_plot=False, + observed=obs_full, + fit=fit_full, + fit_ini=fit_ini_full, + roi=[2, 4], + ) + try: + ax_fit = fig.axes[0] + ini_line = next( + line for line in ax_fit.lines if line.get_label() == "initial guess" + ) + np.testing.assert_array_equal(ini_line.get_ydata(), fit_ini_full) + finally: + plt.close(fig) + # @staticmethod def _fake_sbs_results(*, vary=(True, False, True)): diff --git a/tests/test_fit_side_effects.py b/tests/test_fit_side_effects.py index 240fc81..58ae0f4 100644 --- a/tests/test_fit_side_effects.py +++ b/tests/test_fit_side_effects.py @@ -374,6 +374,62 @@ def test_per_call_override_wins_over_config(self, tmp_path, monkeypatch): plt.close("all") +# +class TestShowInitConfigResolution: + """show_init mirrors full_range's PlotConfig-field precedent exactly: + Project.show_init-backed, True out of the box, resolved once by + FitResults.plot_fit rather than hardcoded at a live call site. A + baseline fit now persists fit_ini (schema 6), so the live post-fit + display can render the dotted-gold "initial guess" overlay.""" + + # + def test_project_default_is_true(self): + project = _make_abs_project() + assert project.show_init is True + + # + def test_live_baseline_display_shows_initial_guess_when_config_true( + self, tmp_path, monkeypatch + ): + import matplotlib.pyplot as plt + + project, file = _baseline_setup(tmp_path, monkeypatch, show_output=1) + file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) + try: + labels = [line.get_label() for line in plt.gcf().axes[0].lines] + assert "initial guess" in labels + finally: + plt.close("all") + + # + def test_live_baseline_display_omits_initial_guess_when_config_false( + self, tmp_path, monkeypatch + ): + import matplotlib.pyplot as plt + + project, file = _baseline_setup(tmp_path, monkeypatch, show_output=1) + project.show_init = False + file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) + try: + labels = [line.get_label() for line in plt.gcf().axes[0].lines] + assert "initial guess" not in labels + finally: + plt.close("all") + + # + def test_per_call_override_wins_over_config(self, tmp_path, monkeypatch): + import matplotlib.pyplot as plt + + project, file = _baseline_setup(tmp_path, monkeypatch, show_output=0) + file.fit_baseline(model_name="single_glp", stages=1, try_ci=0) + try: + file.plot_fit(fit_type="baseline", show_init=False) + labels = [line.get_label() for line in plt.gcf().axes[0].lines] + assert "initial guess" not in labels + finally: + plt.close("all") + + # class TestPlotSbsSlices: """File.plot_sbs_slices: on-demand per-slice diagnostics from the live diff --git a/tests/test_lmfit_utils.py b/tests/test_lmfit_utils.py index 0900d6a..ce5e31b 100644 --- a/tests/test_lmfit_utils.py +++ b/tests/test_lmfit_utils.py @@ -1,8 +1,42 @@ """Unit tests for trspecfit.utils.lmfit helpers not covered elsewhere.""" +import typing + import lmfit +import pandas as pd +from lmfit.minimizer import MinimizerResult + +from trspecfit.utils import lmfit as ulmfit +from trspecfit.utils.lmfit import ( + FitOutput, + list_of_par_ini_to_df, + restore_true_init_values, +) -from trspecfit.utils.lmfit import restore_true_init_values + +# +def _make_fit_output( + fin_values: dict[str, float], ini_values: dict[str, float] +) -> FitOutput: + """Minimal FitOutput with real lmfit.Parameters for par_ini/par_fin.params.""" + + par_fin_params = lmfit.Parameters() + for name, value in fin_values.items(): + par_fin_params.add(name, value=value) + par_fin = typing.cast("ulmfit.TypedMinimizerResult", MinimizerResult()) + par_fin.params = par_fin_params + + par_ini = lmfit.Parameters() + for name, value in ini_values.items(): + par_ini.add(name, value=value) + + return FitOutput( + par_ini=par_ini, + par_fin=par_fin, + conf_ci=pd.DataFrame(), + emcee_fin=None, + emcee_ci=pd.DataFrame(), + ) # @@ -60,3 +94,30 @@ def test_ignores_names_absent_from_par_ini(self): restore_true_init_values(result_params, par_ini) assert result_params["A"].init_value == 5.0 + + +# +class TestListOfParIniToDf: + """list_of_par_ini_to_df — per-slice true initial-guess values, the + SbS companion to list_of_par_stderr_to_df; reads FitOutput.par_ini + directly (unaffected by fitlib.fit_wrapper's init_value correction).""" + + # + def test_shape_and_columns_match_par_fin(self): + results = [ + _make_fit_output({"A": 5.0, "B": 10.0}, {"A": 1.0, "B": 2.0}), + _make_fit_output({"A": 6.0, "B": 11.0}, {"A": 1.5, "B": 2.5}), + ] + + df = list_of_par_ini_to_df(results) + + assert list(df.columns) == ["A", "B"] + assert len(df) == 2 + + # + def test_values_are_the_true_seed_not_the_fit_result(self): + results = [_make_fit_output({"A": 5.0}, {"A": 1.0})] + + df = list_of_par_ini_to_df(results) + + assert df.iloc[0]["A"] == 1.0 From 34a8a7845e47e65cc2f7af11733fa4a057238d27 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Tue, 21 Jul 2026 15:47:11 -0700 Subject: [PATCH 25/29] document schema-4/5/6 plotting features in CHANGELOG, clear PLAN.md The components/aux_axis/full_range/init_value-fix/fit_ini work (commits 23c9383..a461404) had no CHANGELOG entries yet. All three PLAN.md sections are complete and verified; per the archival rule, the changelog is documentation enough here, so PLAN.md is cleared. --- CHANGELOG.md | 8 ++ PLAN.md | 243 +-------------------------------------------------- 2 files changed, 9 insertions(+), 242 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34983ed..bfbf483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ This file is maintained using the shared changelog workflow in - **Explicit plotting API**: `FitResults.plot_fit` (observed/fit/residual for any fit type) and `FitResults.plot_param_evolution` (SbS per-parameter evolution, varied parameters by default), with `File.plot_fit` / `File.plot_param_evolution` sugar. `FitResults` now carries fingerprint-matched axes providers (live `File`s via `Project.results`, `SavedFile`s via `FitResults.load`), so plots — including the upgraded `plot_residuals` — use real energy/time axes on live sessions *and* loaded archives, falling back to array indices only when no provider matches. Styling resolves as explicit `config=` > the live file's `plot_config` > defaults (styling is deliberately not persisted in archives). The fit methods' inline display now routes through this same API, so the figure shown at fit time is exactly the figure the API reproduces later. - `FitResults.plot_mcmc` (with `File.plot_mcmc` sugar): re-renders the MCMC diagnostics — per-walker acceptance fraction and the corner plot — from the persisted slot payload, on live sessions and loaded archives alike. - `File.plot_sbs_slices`: on-demand per-slice fit panels for the most recent Slice-by-Slice fit (slice data, per-slice seeded initial guess, final fit, component decomposition — more than the old auto-written per-slice PNGs showed). Live-session only (reads `results_sbs`); `save_path=None` means display-only, pass a directory to also write one PNG per slice. +- **Fit slots persist per-component 1D curves** (archive schema 4): baseline/spectrum/SbS slots gain `components` (per-component fit curves, SbS per-slice) and `component_names`, so `FitResults.plot_fit` can render the component decomposition from a loaded archive with no live `Model`. +- **Fit slots persist each file's full, uncropped axes** (archive schema 5): `SavedFile` gains `aux_axis` (the full energy or time array, whichever the fit didn't crop), enabling `FitResults.plot_fit`'s new `full_range` display mode below without re-deriving it from the fit window. +- **`FitResults.plot_fit` gains a `full_range` display mode** (`PlotConfig.full_range`, default `True`): shows the real full data/axis for the file, with fit/residual/components drawn only inside the fit window (`NaN` outside — never a fabricated value) and dashed ROI boundary lines, matching `describe_model`'s visual language. All 4 fit methods' post-fit display now uses this mode by default. +- **Fit slots persist the true initial guess** (archive schema 6): `SavedFitSlot.fit_ini` (model evaluated at the pre-fit seed, all 4 fit types) and, for Slice-by-Slice, per-slice `params_init` (every slice's true seed values, mirroring `params_stderr`'s shape). `FitResults.plot_fit` renders the archive-side dotted-gold "initial guess" overlay via new `PlotConfig.show_init` (default `True`), matching the live fit display. + +### Fixed + +- **`init_value` was silently wrong for `stages=2` fits**: lmfit resets `Parameter.init_value` to `Parameter.value` at the start of every stage, so a two-stage fit's persisted `init_value` (in `SavedFitSlot.params` and the printed `lmfit.fit_report`) reflected stage 1's output rather than the true original seed. `fitlib.fit_wrapper` now restores the true seed after fitting; `stages=1` fits were unaffected. ### Changed diff --git a/PLAN.md b/PLAN.md index 72a637d..84bf2d8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,244 +1,3 @@ # Active Plan -## `full_range` toggle for `FitResults.plot_fit` - -Full design/rationale: `/home/yoyo/.claude/plans/hm-i-guess-the-eventual-simon.md` -(session-local; contents mirrored here for repo persistence). - -**Goal**: close the live-vs-archive display-range gap honestly. The -archive already persists the full, uncropped `data`/`energy`/`time` once -per file (`SavedFile`). Give `FitResults.plot_fit` a `full_range=True` -mode that shows the real full data, with fit/residual/components drawn -only inside the fit window (`NaN` outside — never a fabricated padding -value) plus dashed ROI boundary lines, matching `describe_model`'s -existing visual language. `fit_baseline`/`fit_spectrum`/`fit_2d`/ -`fit_slice_by_slice`'s own post-fit live display switch to this as the -new default. - -- [x] **`utils/arrays.py`**: extracted `File._resolve_time_selection`'s - body into a standalone `resolve_time_selection` function (time - array as a plain arg); `File._resolve_time_selection` is now a - 3-line wrapper. Unit-tested in `test_arrays.py`. -- [x] **`FitResults`** (`src/trspecfit/fit_results.py`): - `_axes_for` gained `full_range: bool = False`; added - `_provider_for`, `_full_observed_for` (baseline: `np.mean` over - persisted `base_t_ind`; spectrum: resolves raw time selection via - `resolve_time_selection`; sbs/2d: `provider.data` directly), and - `_pad_axis` (axis-parametrized `NaN`-padding helper reused for - `fit` and `components` across all 4 fit types). `plot_fit` gained - `full_range: bool = False` — reconstructs when possible, falls - back to the cropped view otherwise (never raises); 2d/sbs reuse - `fitlib.plt_fit_res_2d`'s existing `x_lim`/`y_lim` support - unchanged. `_plot_fit_1d` stays a staticmethod, now accepts - precomputed `observed`/`fit`/`components`/`roi` overrides (default - path unchanged); draws dashed ROI boundary lines when `roi` is - given; title enhanced via new `_slot_title` (file name, yaml stem, - spectrum's time selection). -- [x] **`fitlib.plt_fit_res_2d`**: "Fit" panel title + `range_dat_fit` - scale calc switched to `np.nanmin`/`np.nanmax` (needed for - `full_range` mode's `NaN`-padded `fit`; identical result when no - `NaN` present). -- [x] **`trspecfit.py`**: `fit_baseline`/`fit_spectrum` — deleted the dead - `initial_guess` extraction + bespoke title, replaced the direct - `fitlib.plt_fit_res_1d` call with `self.plot_fit(...)`. - `fit_2d`/`fit_slice_by_slice`/`Project.fit_2d`/`Project.fit_baselines` - all call `plot_fit` too (the batch `fit_baselines` call was missed - in an earlier pass and fixed). `File.plot_fit` sugar forwards - `full_range`. -- [x] **`full_range` promoted to a real `PlotConfig` member** (raised as a - design question before committing: project.yaml `full_range: ...` - was silently ignored — `_load_config` only applies known `Project` - attributes, and neither `PlotConfig` nor `Project._set_defaults` - had one). `config/plot.py` gained `full_range: bool = True` - (existing precedent: `data_slice`/`x_lim`/`y_lim` are already - "what to show" fields here, not just style); - `Project._set_defaults` gained `self.full_range = True`. - `FitResults.plot_fit`/`File.plot_fit`'s `full_range` param changed - `bool = False` → `bool | None = None` (`None` = "use config"), - resolved once via `cfg.full_range` right after `_config_for`. All 6 - live call sites now call `plot_fit`/`self.plot_fit` with **no** - `full_range` kwarg at all — they inherit the resolved default - instead of hardcoding `True`, so a project-wide override (or a - per-call explicit `full_range=`) actually takes effect everywhere. - Field-existence coverage comes free from `test_plotting.py`'s - existing generic `test_every_field_settable_via_project` (iterates - `dataclasses.fields(PlotConfig)`). -- [x] **Tests**: `test_fit_history.py` — 2 new `_plot_fit_1d` direct-call - tests (NaN-gap + roi boundary lines; default-path parity); retargeted - `test_baseline_plots_when_verbose`/`_skips_plot_when_silent` to - `FitResults._plot_fit_1d`. New `tests/test_full_range_plot.py` — - real-fit integration tests for all 4 fit types (reload with no live - `File`, reconstructed-observed correctness, `NaN` outside ROI, - values match `slot.fit` inside), plus graceful-fallback-without- - provider. `test_arrays.py` — `resolve_time_selection` unit tests. - `test_fitlib.py` — `plt_fit_res_2d` NaN-aware rendering test. - `test_fit_side_effects.py::TestFullRangeConfigResolution` (replaces - an earlier, now-obsolete systemic mock-and-assert-kwargs test class - that checked all 6 call sites hardcoded `full_range=True` — moot - once nothing hardcodes it): `Project.full_range` defaults `True`; - a live baseline fit's post-fit display shows the full axis when - config is `True` and the cropped window when set to `False`; an - explicit per-call `full_range=False` overrides config. Verified - the `fit_baselines` gap and the config-resolution logic each fail - without their respective fix (temporary-revert round-trips). Full - suite (1200 tests incl. slow), mypy, pyright, ruff all clean - (whole-tree sweep). -- [x] **Docs**: `docs/design/fit_archive_schema.md` — noted - `full_range=True` as a concrete consumer of the already-documented - full-data-duplication design decision. `config/plot.py`'s - `PlotConfig` docstring gained a `full_range` entry. No schema/ - version change (reuses already-persisted fields). Verified with a - `sphinx -W` build. - -**Status**: implementation complete, verified 2026-07-21. - -**Out of scope**: no schema/wire-format change; no model rehydration / -extrapolated-fit-curve reconstruction outside the ROI (declined already -for schema-4); no reintroduction of `show_init`. - -## Fix: persisted `init_value` is wrong for two-stage fits - -Full design/rationale: `/home/yoyo/.claude/plans/hm-i-guess-the-eventual-simon.md` -(session-local; overwritten with this fix's plan, contents mirrored here). - -**Goal**: while discussing whether the initial guess should be treated as -fit-result provenance (a different seed can land the same algorithm on a -different local minimum), found that the already-persisted -`SavedFitSlot.params` `init_value` column (baseline/spectrum/2d) is -silently wrong for `stages=2` fits — lmfit's `Minimizer.prepare_fit()` -unconditionally resets `Parameter.init_value = Parameter.value` at the -start of every stage, and stage 2 starts from stage 1's *output*, so a -two-stage result's `init_value` reflects stage 1's output, not the true -original seed. Verified directly against the installed `lmfit` source. -`stages=1` fits were already correct. - -- [x] **`utils/lmfit.py`**: added `restore_true_init_values(result_params, - par_ini)` — corrects `result_params`' `init_value` in place from - the true seed. -- [x] **`fitlib.fit_wrapper`** (not the slot extractors — moved after - review): calls `restore_true_init_values(par_fin_params, par_ini)` - right after stage 2's `mini.minimize()`, before `par_fin_params` is - printed via `lmfit.report_fit` or returned in `FitOutput`. - `_result_params` returns `result.params` by reference (no copy), so - this is the same object that later becomes `FitOutput.par_fin.params` - — fixing it once at the source means the live printed report *and* - every direct consumer of `FitOutput.par_fin` (not just code that - passes through `_append_baseline_slot`/`_append_spectrum_slot`/ - `_append_2d_slot`) sees the true seed consistently. The three - downstream calls in `trspecfit.py` from the first pass were removed - as redundant. -- [x] **Tests**: `tests/test_lmfit_utils.py` (new) — direct unit tests - for `restore_true_init_values`. `test_fit_history.py` — two - regression tests via the persisted slot (`stages=2` seed correctly - persisted, not stage 1's output; `stages=1` companion confirming no - change), plus a new direct test asserting both - `FitOutput.par_fin.params[name].init_value` and `report_fit`'s - printed `"(init = ...)"` (captured via `capsys`, matching lmfit's - own `.7g` format) show the true seed for the local-optimization - stage — verified each regression test actually fails without its - fix via temporary-revert round-trips. Full suite (1200+ tests incl. - slow), mypy, pyright, ruff all clean. -- [x] **Docs**: `docs/design/fit_archive_schema.md` — clarified the - `init_value` column description, pointing at `fitlib.fit_wrapper` - as the fix location. No schema/wire-format change (same column, - same shape, corrected values) — no version bump. Verified with a - `sphinx -W` build. - -**Status**: implementation complete, verified 2026-07-21. - -**Out of scope (deliberately deferred)**: SbS initial-guess persistence -(schema-new, not a correctness fix — candidate: slice-0's true -`init_value` in `params_meta`, mirroring how `correl`/`mcmc` are already -slice-0-only); the project-level joint-fit `par_ini=None` case; any new -schema field. - -**Next**: plan the seed-as-provenance schema extension — persist the true -initial guess for baseline/spectrum/2d (e.g. a `fit_ini`/`components_ini` -evaluated at `par_ini`, mirroring the schema-4 `components` pattern, so -`plot_fit` can render an initial-guess overlay archive-side) and decide -whether to fold in SbS slice-0 `init_value` at the same time. - -## Persist the true initial guess (`fit_ini`) for all 4 fit types (schema 5 → 6) - -Full design/rationale: `/home/yoyo/.claude/plans/hm-i-guess-the-eventual-simon.md` -(session-local; contents mirrored here for repo persistence). - -**Goal**: the direct sequel to the two sections above — persist an -*evaluated* initial-guess curve so `FitResults.plot_fit` can render the -archive-side overlay that live `plt_fit_res_1d`/`plot_sbs_slices` already -show (`show_init=True`, dotted-gold line). Same shape of change as the -schema-4 `components` work: persist the data now, richer viewers later. - -- [x] **`utils/lmfit.py`**: added `list_of_par_ini_to_df(results)` — - per-slice true initial-guess values (rows=fits, columns=parameters), - mirrors `list_of_par_stderr_to_df` but reads `result.par_ini` - directly (unaffected by the stage-2 `init_value` fix above). -- [x] **`utils/fit_io.py`**: schema `"5"` → `"6"` (additive). `SavedFitSlot` - gained `fit_ini: np.ndarray | None` (all 4 fit types; `None` on the - project-level joint-fit path, same case `components` already - handles) and `params_init: pd.DataFrame | None` (sbs only, mirrors - `params_stderr`'s shape — every slice, not slice-0-representative, - since `results_sbs[i].par_ini` is already available at no extra - plumbing cost). `_write_slot`/`_read_slot` conditional write/read; - `_slot_from_*` helpers and `_build_slot` thread the new kwargs - through. -- [x] **`trspecfit.py`**: `_append_baseline_slot`/`_append_spectrum_slot`/ - `_append_2d_slot` evaluate a second `fitlib.residual_fun(..., - par=fit_out.par_ini, res_type="fit")` alongside the existing - final-params evaluation, cropped identically to `fit_arr`. - `_append_sbs_slot` does the same per-slice inside the existing - per-slice loop (using `self.results_sbs[s_i].par_ini`), plus - `params_init = ulmfit.list_of_par_ini_to_df(self.results_sbs)` - after the loop. -- [x] **`config/plot.py` / `Project._set_defaults`**: `PlotConfig` gained - `show_init: bool = True` (docstring entry, placed near - `full_range`); `Project._set_defaults` gained `self.show_init = True`. -- [x] **`fit_results.py`**: `FitResults.plot_fit`/`File.plot_fit` gained - `show_init: bool | None = None`, resolved via `cfg.show_init` the - same `None` = "use config" way `full_range` was done. - `_plot_fit_1d` gained a `fit_ini` override param (mirrors `fit`/ - `components`/`roi`); renders the dotted-gold "initial guess" line - (`color="#FFD700", linestyle=":"`, matching `plt_fit_res_1d`'s live - style) when `show_init` resolves `True` and `fit_ini` is not - `None`. `full_range` mode `NaN`-pads `fit_ini` via the existing - `_pad_axis` helper, same honesty principle as `fit`/`components`. - Rendering is 1D-only (baseline/spectrum); 2D persists `fit_ini` for - completeness/symmetry but doesn't render it (no live precedent for - a 2D init overlay); SbS's archive view still routes through the - heatmap-style `plt_fit_res_2d` (no per-slice viewer yet — deferred, - but `fit_ini`/`params_init` are now available for it). -- [x] **Tests**: `test_fit_archive_roundtrip.py` — `_assert_slot_round_tripped` - extended with `fit_ini`/`params_init` checks across the F1/F6/F8 - family matrix; sbs cross-checks `params_init` against each slice's - true seed straight from the live `results_sbs` (joint validation - with the `fitlib.fit_wrapper` fix); new `_downgrade_archive_to_v5` + - `test_reader_accepts_schema_v5_archive`. `test_lmfit_utils.py` — new - `TestListOfParIniToDf` unit tests. `test_fit_history.py` — 4 new - `_plot_fit_1d` direct-call tests (renders when present+shown; omitted - when `show_init=False`; omitted when absent; `NaN`-padded in - `full_range` mode). `test_fit_side_effects.py` — new - `TestShowInitConfigResolution` class mirroring - `TestFullRangeConfigResolution` exactly (project default `True`; a - live baseline fit's display honors `config.show_init` in both - directions; per-call override wins). Full suite (1041 + 171 slow), - mypy, pyright, ruff all clean (whole-tree sweep; the 5 pre-existing - mypy errors in `test_full_range_plot.py`/`test_fit_archive_roundtrip.py` - were confirmed present on the base branch, unrelated to this work). -- [x] **Docs**: `docs/design/fit_archive_schema.md` — bumped documented - `schema_version` to `"6"`, added the 5→6 version-history entry, new - `fit_ini`/`params_init` dataset sections, updated the slot-group - layout diagram, reader→object-model mapping table, and per-fit-type - cheat sheet. `config/plot.py`'s `PlotConfig` docstring gained a - `show_init` entry. Verified with a `sphinx -W` build. -- [x] **Manual verification**: a `stages=2` baseline fit with - `show_output=1` shows stage-2's printed `init_value` matching the - true seed; `save_fits` → reload with no live `Model` → - `FitResults.plot_fit(..., full_range=True)` renders the "initial - guess" line, `NaN`-masked outside the fit window. - -**Status**: implementation complete, verified 2026-07-21. - -**Out of scope (deliberately deferred)**: no SbS per-slice archive viewer -yet (`fit_ini`/`params_init` make it buildable later without another -schema bump); no 2D visual initial-guess overlay; no model rehydration -for an initial-guess curve extrapolated beyond the fit window. +No active multi-step feature in progress. See `TODO.md` for longer-term goals. From 462df806a8e3a6217852b4f5a9b7c0a96740802c Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Tue, 21 Jul 2026 15:48:03 -0700 Subject: [PATCH 26/29] uniform "initial guess" titling across describe_model(detail=1) The energy-resolved (1D) branch already said "...: initial guess" in its title; the Dynamics and 2D branches didn't, so a live model preview could be mistaken for a saved fit result. Hoist the title string once and thread it through all three; plt_fit_res_2d gains an optional figure-level title= (suptitle), additive and unused by existing callers. --- CHANGELOG.md | 1 + src/trspecfit/fitlib.py | 7 +++++++ src/trspecfit/trspecfit.py | 15 +++++++++------ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfbf483..ecebace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ This file is maintained using the shared changelog workflow in ### Changed +- **`describe_model(detail=1)` labels every panel as an initial guess.** The energy-resolved (1D) case already titled its figure `"...: initial guess"`; the Dynamics and 2D cases now do too (`fitlib.plt_fit_res_2d` gains an optional `title=` figure-level suptitle), so none of the three can be mistaken for a saved fit result at a glance. - **Breaking: fits never write to disk.** `fit_baseline` / `fit_spectrum` / `fit_slice_by_slice` / `fit_2d` and `Project.fit_2d` compute, display (per `show_output`), and record fit slots — persistence is always an explicit `save_fits` (HDF5 archive) or `export_fits` (grouped CSV/PNG tree) call, both fed from the slot history, with the single default output root `./fit_results/{Project.name}/`. Everything the automatic writes used to produce is reproducible: parameter tables, confidence intervals, and MCMC chains are persisted in the slot and exported by `export_fits`; the MCMC walker/corner PNGs via `plot_mcmc`; the SbS per-slice PNGs via `plot_sbs_slices`; the component-decomposed `fit_1d.csv` via `save_baseline_fit` / `save_spectrum_fit` (kept, no longer auto-called). Accepted losses: per-slice `par_ini` CSVs (re-derivable from the persisted `fit_settings` seeding recipe) and the `lmfit.fit_report` text dumps (all their contents are persisted). Interactive display renders from the captured fit slot, so the figure shown equals the figure the plot API reproduces later; `Project.fit_2d` / `Project.fit_baselines` show per-file plots instead of PNGs read back from disk. - **Breaking: `File.get_correlations` raises for covariance-less fits** (e.g. Nelder without numdifftools, project joint fits) instead of returning an identity-with-zeros matrix that misread as "uncorrelated". - **Breaking: `Project.name` defaults to `"my_project"`** (was `"test"`), so a bare `save_fits()` / `export_fits()` lands in a clearly-placeholder `fit_results/my_project/` instead of colliding with test-suite naming. diff --git a/src/trspecfit/fitlib.py b/src/trspecfit/fitlib.py index 4f9cdf9..a63f891 100644 --- a/src/trspecfit/fitlib.py +++ b/src/trspecfit/fitlib.py @@ -1376,6 +1376,8 @@ def plt_fit_res_2d( x: ArrayLike | None = None, y: ArrayLike | None = None, config: PlotConfig | None = None, + *, + title: str = "", **kwargs: Any, ) -> None: """ @@ -1397,6 +1399,9 @@ def plt_fit_res_2d( Y-axis (time) coordinates. If None, uses row indices. config : PlotConfig, optional Plot configuration object. If None, uses defaults. + title : str, default='' + Figure-level title (e.g. file/model identification). Empty means no + suptitle, matching prior behavior. **kwargs : dict Override config attributes for this plot. @@ -1494,6 +1499,8 @@ def plt_fit_res_2d( constrained_layout=True, figsize=(9, 12), ) + if title: + fig.suptitle(title) # Data panel (uses shared scale) axs["left"].pcolormesh( diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index e86213d..97567fd 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -2145,9 +2145,16 @@ def describe_model( # parameter list mod.describe(detail=0) + title_mod = ( + f"File: {self.path}, " + f'Model: "{model_info}" (from "{mod.yaml_f_name}.yaml")' + ": initial guess" + ) + if detail == 1 and isinstance(mod, mcp.Dynamics): mod.create_value_1d(store_1d=1) # update individual component spectra - mod.plot_1d(plot_sum=False) # plot guess only (individual components) + # plot guess only (individual components) + mod.plot_1d(plot_sum=False, title=title_mod) if detail == 1 and mod.dim == 1: if self.energy is None or self.data_base is None: @@ -2159,11 +2166,6 @@ def describe_model( return mod.create_value_1d(store_1d=1) # update individual component spectra # plot initial guess (individual components), data, and residual - title_mod = ( - f"File: {self.path}, " - f'Model: "{model_info}" (from "{mod.yaml_f_name}.yaml")' - ": initial guess" - ) fitlib.plt_fit_res_1d( x=self.energy, y=self.data_base, @@ -2196,6 +2198,7 @@ def describe_model( config=self.plot_config, x_lim=self.e_lim, y_lim=self.t_lim, + title=title_mod, ) # From 8222355b6b4ce53b989b8e37c2c48209df07bc61 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Tue, 21 Jul 2026 15:51:15 -0700 Subject: [PATCH 27/29] fix describe_model title to use the resolved model name, not model_info model_info is None on the (most common) default active-model path and an int on index-based selection, so the hoisted title_mod literally rendered `Model: "None"` or `Model: "0"`. mod is already guaranteed non-None at that point; mod.name is the real resolved name regardless of how the caller identified the model. --- src/trspecfit/trspecfit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index 97567fd..b27613b 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -2147,7 +2147,7 @@ def describe_model( title_mod = ( f"File: {self.path}, " - f'Model: "{model_info}" (from "{mod.yaml_f_name}.yaml")' + f'Model: "{mod.name}" (from "{mod.yaml_f_name}.yaml")' ": initial guess" ) From f4745e230f1d7a33368c9090587d2e5c4766c448 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Tue, 21 Jul 2026 15:57:50 -0700 Subject: [PATCH 28/29] fix two stale/incorrect CHANGELOG bullets - schema-5 bullet wrongly described aux_axis as "the full energy or time array" and claimed it enables full_range; aux_axis is a separate auxiliary physical axis (e.g. depth) for par_profile models, and full_range doesn't reference it at all -- the full data/energy/time were already unconditionally persisted before this schema bump (confirmed against commit 5c97fb6 and a grep for aux_axis in fit_results.py). - the "fits never write to disk" bullet's accepted-losses note predates schema 6 and called per-slice par_ini "re-derivable from fit_settings"; it's now directly persisted (params_init/fit_ini), just not exported to the legacy CSV tree (confirmed: export_fits writes no such CSV today). --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecebace..704e2fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ This file is maintained using the shared changelog workflow in - `FitResults.plot_mcmc` (with `File.plot_mcmc` sugar): re-renders the MCMC diagnostics — per-walker acceptance fraction and the corner plot — from the persisted slot payload, on live sessions and loaded archives alike. - `File.plot_sbs_slices`: on-demand per-slice fit panels for the most recent Slice-by-Slice fit (slice data, per-slice seeded initial guess, final fit, component decomposition — more than the old auto-written per-slice PNGs showed). Live-session only (reads `results_sbs`); `save_path=None` means display-only, pass a directory to also write one PNG per slice. - **Fit slots persist per-component 1D curves** (archive schema 4): baseline/spectrum/SbS slots gain `components` (per-component fit curves, SbS per-slice) and `component_names`, so `FitResults.plot_fit` can render the component decomposition from a loaded archive with no live `Model`. -- **Fit slots persist each file's full, uncropped axes** (archive schema 5): `SavedFile` gains `aux_axis` (the full energy or time array, whichever the fit didn't crop), enabling `FitResults.plot_fit`'s new `full_range` display mode below without re-deriving it from the fit window. +- **Fit slots persist each file's auxiliary physical axis** (archive schema 5): `SavedFile` gains `aux_axis` (`File.aux_axis`, e.g. depth, used by `par_profile`-attached models) — the one array in the already-persisted full-data family (`data`/`energy`/`time`) that reloading an archive with no live `File` previously lost entirely. - **`FitResults.plot_fit` gains a `full_range` display mode** (`PlotConfig.full_range`, default `True`): shows the real full data/axis for the file, with fit/residual/components drawn only inside the fit window (`NaN` outside — never a fabricated value) and dashed ROI boundary lines, matching `describe_model`'s visual language. All 4 fit methods' post-fit display now uses this mode by default. - **Fit slots persist the true initial guess** (archive schema 6): `SavedFitSlot.fit_ini` (model evaluated at the pre-fit seed, all 4 fit types) and, for Slice-by-Slice, per-slice `params_init` (every slice's true seed values, mirroring `params_stderr`'s shape). `FitResults.plot_fit` renders the archive-side dotted-gold "initial guess" overlay via new `PlotConfig.show_init` (default `True`), matching the live fit display. @@ -29,7 +29,7 @@ This file is maintained using the shared changelog workflow in ### Changed - **`describe_model(detail=1)` labels every panel as an initial guess.** The energy-resolved (1D) case already titled its figure `"...: initial guess"`; the Dynamics and 2D cases now do too (`fitlib.plt_fit_res_2d` gains an optional `title=` figure-level suptitle), so none of the three can be mistaken for a saved fit result at a glance. -- **Breaking: fits never write to disk.** `fit_baseline` / `fit_spectrum` / `fit_slice_by_slice` / `fit_2d` and `Project.fit_2d` compute, display (per `show_output`), and record fit slots — persistence is always an explicit `save_fits` (HDF5 archive) or `export_fits` (grouped CSV/PNG tree) call, both fed from the slot history, with the single default output root `./fit_results/{Project.name}/`. Everything the automatic writes used to produce is reproducible: parameter tables, confidence intervals, and MCMC chains are persisted in the slot and exported by `export_fits`; the MCMC walker/corner PNGs via `plot_mcmc`; the SbS per-slice PNGs via `plot_sbs_slices`; the component-decomposed `fit_1d.csv` via `save_baseline_fit` / `save_spectrum_fit` (kept, no longer auto-called). Accepted losses: per-slice `par_ini` CSVs (re-derivable from the persisted `fit_settings` seeding recipe) and the `lmfit.fit_report` text dumps (all their contents are persisted). Interactive display renders from the captured fit slot, so the figure shown equals the figure the plot API reproduces later; `Project.fit_2d` / `Project.fit_baselines` show per-file plots instead of PNGs read back from disk. +- **Breaking: fits never write to disk.** `fit_baseline` / `fit_spectrum` / `fit_slice_by_slice` / `fit_2d` and `Project.fit_2d` compute, display (per `show_output`), and record fit slots — persistence is always an explicit `save_fits` (HDF5 archive) or `export_fits` (grouped CSV/PNG tree) call, both fed from the slot history, with the single default output root `./fit_results/{Project.name}/`. Everything the automatic writes used to produce is reproducible: parameter tables, confidence intervals, and MCMC chains are persisted in the slot and exported by `export_fits`; the MCMC walker/corner PNGs via `plot_mcmc`; the SbS per-slice PNGs via `plot_sbs_slices`; the component-decomposed `fit_1d.csv` via `save_baseline_fit` / `save_spectrum_fit` (kept, no longer auto-called). Accepted losses: per-slice `par_ini` CSVs (not exported to the legacy CSV tree, but the true seed values/curves are preserved in the archive itself — `SavedFitSlot.params_init`/`fit_ini`, schema 6) and the `lmfit.fit_report` text dumps (all their contents are persisted). Interactive display renders from the captured fit slot, so the figure shown equals the figure the plot API reproduces later; `Project.fit_2d` / `Project.fit_baselines` show per-file plots instead of PNGs read back from disk. - **Breaking: `File.get_correlations` raises for covariance-less fits** (e.g. Nelder without numdifftools, project joint fits) instead of returning an identity-with-zeros matrix that misread as "uncorrelated". - **Breaking: `Project.name` defaults to `"my_project"`** (was `"test"`), so a bare `save_fits()` / `export_fits()` lands in a clearly-placeholder `fit_results/my_project/` instead of colliding with test-suite naming. - **Breaking (advanced API): fit results are a typed `FitOutput` object.** `fitlib.fit_wrapper` returns a frozen `FitOutput` dataclass (fields `par_ini`, `par_fin`, `conf_ci`, `emcee_fin`, `emcee_ci`) instead of the raw five-element list, and `Model.result` / the per-slice entries of `File.results_sbs` hold it. Positional indexing (`model.result[1].params`) becomes attribute access (`model.result.par_fin.params`); an unfitted model's `result` is now `None` instead of `[]`, and a skipped MCMC yields `emcee_fin=None`. `Project.fit_2d`'s per-file stand-in result is a real (minimal) `lmfit` `MinimizerResult` instead of a `SimpleNamespace`. In the same naming consolidation, `fitlib.plt_fit_res_1d`'s `par_init` parameter is now `par_ini`, matching the `par_ini`/`par_fin` field pair. The persisted `SavedFitSlot` record and all `FitResults` accessors are unchanged. From 7bae7ed40ed0d4b841de7ccab79ffe4df37c102b Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Tue, 21 Jul 2026 17:15:39 -0700 Subject: [PATCH 29/29] FitResults.plot_fit: title the 2D/SbS panels like the 1D path already does _plot_fit_1d has used _slot_title (file, model, fit type, yaml stem) since the explicit plotting API landed; the 2D/SbS branch returns early into plt_fit_res_2d with no title at all, so multi-file loops (e.g. Project.fit_2d()) show visually identical, unlabeled panels. plt_fit_res_2d already gained an optional title= kwarg for the describe_model fix; reuse it here with the same _slot_title used by the 1D path. --- CHANGELOG.md | 1 + src/trspecfit/fit_results.py | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 704e2fb..4bdb7fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ This file is maintained using the shared changelog workflow in ### Changed - **`describe_model(detail=1)` labels every panel as an initial guess.** The energy-resolved (1D) case already titled its figure `"...: initial guess"`; the Dynamics and 2D cases now do too (`fitlib.plt_fit_res_2d` gains an optional `title=` figure-level suptitle), so none of the three can be mistaken for a saved fit result at a glance. +- **`FitResults.plot_fit`'s 2D/SbS figures now identify the file and model.** The 1D path already set a rich title (`_slot_title`: file, model, fit type, yaml stem); the 2D/SbS path rendered generic, unlabeled panels, so e.g. looping `Project.fit_2d()` over several files showed visually identical figures with no way to tell them apart. - **Breaking: fits never write to disk.** `fit_baseline` / `fit_spectrum` / `fit_slice_by_slice` / `fit_2d` and `Project.fit_2d` compute, display (per `show_output`), and record fit slots — persistence is always an explicit `save_fits` (HDF5 archive) or `export_fits` (grouped CSV/PNG tree) call, both fed from the slot history, with the single default output root `./fit_results/{Project.name}/`. Everything the automatic writes used to produce is reproducible: parameter tables, confidence intervals, and MCMC chains are persisted in the slot and exported by `export_fits`; the MCMC walker/corner PNGs via `plot_mcmc`; the SbS per-slice PNGs via `plot_sbs_slices`; the component-decomposed `fit_1d.csv` via `save_baseline_fit` / `save_spectrum_fit` (kept, no longer auto-called). Accepted losses: per-slice `par_ini` CSVs (not exported to the legacy CSV tree, but the true seed values/curves are preserved in the archive itself — `SavedFitSlot.params_init`/`fit_ini`, schema 6) and the `lmfit.fit_report` text dumps (all their contents are persisted). Interactive display renders from the captured fit slot, so the figure shown equals the figure the plot API reproduces later; `Project.fit_2d` / `Project.fit_baselines` show per-file plots instead of PNGs read back from disk. - **Breaking: `File.get_correlations` raises for covariance-less fits** (e.g. Nelder without numdifftools, project joint fits) instead of returning an identity-with-zeros matrix that misread as "uncorrelated". - **Breaking: `Project.name` defaults to `"my_project"`** (was `"test"`), so a bare `save_fits()` / `export_fits()` lands in a clearly-placeholder `fit_results/my_project/` instead of colliding with test-suite naming. diff --git a/src/trspecfit/fit_results.py b/src/trspecfit/fit_results.py index 028b7a5..9a9aec9 100644 --- a/src/trspecfit/fit_results.py +++ b/src/trspecfit/fit_results.py @@ -847,6 +847,7 @@ def plot_fit( y_lim=t_lim, config=cfg, save_img=0 if show_plot else -2, + title=_slot_title(slot), ) return self._plot_fit_1d(