From b6ea80a1580f8b009fcda205506342adba523b39 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Sun, 12 Jul 2026 15:47:54 -0700 Subject: [PATCH 01/13] warn on subcycle-boundary time samples in normalize_time Subcycle assignment flips discretely at boundaries, so samples within floating-point noise of one are sensitive to the representation of the time axis (e.g. %.6e text round-trips). Warn with a relative, unit-free tolerance (boundary_eps, default 1e-6) scaled by elapsed subcycles. --- TODO.md | 1 - pyproject.toml | 5 +++- src/trspecfit/mcp.py | 54 ++++++++++++++++++++++++++++++++++++++- tests/test_mcp_library.py | 47 +++++++++++++++++++++++++++++++--- 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index ad2aabc..740b18d 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,6 @@ ## Noise and simulation - [ ] **Simulator noise-language cleanup**: align simulator docs/metadata with the fit-results noise schema. Keep simulator `noise_type` meaning "noise distribution / random generator" (`gaussian`, `poisson`, `none`), not sigma shape (the stale `set_noise_type` docstring mentioning `uniform` was fixed and the setter validated, 2026-07-10). Clarify `detection` vs. `noise_type` vs. `noise_level`; and, for analog Gaussian simulations, consider saving the derived `sigma_data = noise_level * max(abs(clean_data))` alongside existing metadata. For parameter sweeps, store derived `sigma_data` per configuration when it depends on each clean dataset. -- [ ] **Warn on subcycle-boundary time samples**: in `Dynamics.normalize_time`, emit a warning when a time sample lands within epsilon of a subcycle boundary (`|t * f_eff - round(t * f_eff)| < eps`). Subcycle masks switch discretely there, so the sample's assignment — and hence the model prediction for that whole row — is sensitive to floating-point representation of the time axis. This silently biased a multi-cycle fit when synthetic data was generated on `np.arange` axes but fit against their `%.6e`-rounded CSV reload (see `03_multi_cycle_dynamics/data/generate_data.ipynb`, 2026-06-11). A warning turns that silent bias into a one-line diagnosis; it also flags the physically ambiguous case of measuring exactly at the switching instant. - [ ] **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. ## Performance & architecture diff --git a/pyproject.toml b/pyproject.toml index bdd490f..9bf161d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trspecfit" -version = "0.12.0" +version = "0.12.1" authors = [ {name = "Johannes Mahl", email = "infinitymonkeyatwork@gmail.com"}, ] @@ -118,6 +118,9 @@ testpaths = ["tests"] addopts = "-m 'not slow'" markers = ["slow: long-running round-trip tests (skipped by default, use -m slow)"] filterwarnings = [ + # test fixtures often sample exactly on subcycle boundaries (integer-step + # axes); the knife-edge warning is asserted explicitly via pytest.warns + "ignore:.*subcycle boundary:UserWarning", "ignore:FigureCanvasAgg is non-interactive:UserWarning", "ignore:invalid value encountered in scalar divide:RuntimeWarning:lmfit", "ignore:This process .* use of fork\\(\\) may lead to deadlocks in the child\\.:DeprecationWarning", diff --git a/src/trspecfit/mcp.py b/src/trspecfit/mcp.py index 2555957..54bc8a1 100644 --- a/src/trspecfit/mcp.py +++ b/src/trspecfit/mcp.py @@ -2621,7 +2621,13 @@ def set_frequency(self, frequency: float) -> None: # parameter level uses index to refer to par.t_model=Dynamics # - def normalize_time(self, time_unit: int = 0, *, show_plot: bool = False) -> None: + def normalize_time( + self, + time_unit: int = 0, + *, + boundary_eps: float = 1e-6, + show_plot: bool = False, + ) -> None: """ Normalize time axis for multi-cycle dynamics with subcycles. @@ -2633,9 +2639,25 @@ def normalize_time(self, time_unit: int = 0, *, show_plot: bool = False) -> None ---------- time_unit : int, default=0 Power of 10 for time units (currently unused) + boundary_eps : float, default=1e-6 + Relative tolerance for the subcycle-boundary warning (see Warns). + Measured in subcycle units, scaled by the number of subcycles + elapsed at each sample, so it is independent of the absolute + time scale (fs vs. us). show_plot : bool, default=False If True, plot normalized time arrays + Warns + ----- + UserWarning + If any time sample t > 0 lies within ``boundary_eps`` (relative) + of a subcycle boundary. Subcycle assignment switches discretely + at boundaries, so the model prediction for such samples is + sensitive to the floating-point representation of the time axis + (it can silently change when the axis is written to text and + reloaded). It also flags the physically ambiguous case of + sampling exactly at the switching instant. + Examples -------- >>> t_model = Dynamics('param') @@ -2712,6 +2734,36 @@ def normalize_time(self, time_unit: int = 0, *, show_plot: bool = False) -> None self.n_sub = np.where(mask, np.floor(n_temp % self.subcycles) + 1, 0.0) self.n_counter = np.where(mask, n_temp + 1, 0.0) + # subcycle_pos = number of subcycles elapsed at t (dimensionless, + # so the check is independent of the time unit); floor() switches + # subcycle assignment at integer values, making samples near a + # boundary sensitive to the float representation of the time axis + subcycle_pos = t / norm + dist = np.abs(subcycle_pos - np.round(subcycle_pos)) + # relative eps: representation error in t scales with |t|; + # t=0 is exempt (exactly representable, assignment is stable) + knife_edge = (t > 0) & ( + dist < boundary_eps * np.maximum(np.abs(subcycle_pos), 1.0) + ) + if np.any(knife_edge): + import warnings + + idx = np.flatnonzero(knife_edge) + shown = ", ".join(f"{t[i]:g}" for i in idx[:5]) + if len(idx) > 5: + shown += ", ..." + warnings.warn( + f"{len(idx)} time sample(s) lie within a relative " + f"tolerance of {boundary_eps:g} of a subcycle boundary " + f"(t = {shown}). Subcycle assignment switches discretely " + "there, so the model prediction for these samples can " + "silently change with the floating-point representation " + "of the time axis (e.g. after saving it to text and " + "reloading). Shift these samples off the boundaries or " + "sample strictly inside subcycles.", + stacklevel=2, + ) + if show_plot: legends = ["normalized time", "subcycle counter", "cummulative counter"] uplt.plot_1d( diff --git a/tests/test_mcp_library.py b/tests/test_mcp_library.py index bcc7bee..b1d2187 100644 --- a/tests/test_mcp_library.py +++ b/tests/test_mcp_library.py @@ -2,6 +2,8 @@ Test MCP (Model/Component/Parameter) library functionality """ +import warnings + import numpy as np import pandas as pd import pytest @@ -516,7 +518,9 @@ def test_two_subcycles_values(self): """Test exact values for 2-subcycle normalization (freq=10, subcycles=2).""" time = np.array([0.0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3]) - t_norm, n_sub, n_counter = self._normalize(time, frequency=10, subcycles=2) + # samples sit exactly on boundaries, so the knife-edge warning fires + with pytest.warns(UserWarning, match="subcycle boundary"): + t_norm, n_sub, n_counter = self._normalize(time, frequency=10, subcycles=2) # norm = 1/(10*2) = 0.05, so subcycle boundaries at 0, 0.05, 0.10, ... assert len(t_norm) == len(time) @@ -531,7 +535,7 @@ def test_two_subcycles_values(self): def test_negative_times_are_zero(self): """Negative times produce zero for all output arrays.""" - time = np.array([-0.1, -0.05, -0.001, 0.0, 0.05]) + time = np.array([-0.1, -0.05, -0.001, 0.0, 0.03]) t_norm, n_sub, n_counter = self._normalize(time, frequency=10, subcycles=2) assert np.allclose(t_norm[:3], 0.0) @@ -549,7 +553,9 @@ def test_three_subcycles(self): freq, nsub = 30, 3 norm = 1.0 / freq / nsub time = np.array([0, norm, 2 * norm, 3 * norm, 4 * norm, 5 * norm]) - t_norm, n_sub, _ = self._normalize(time, frequency=freq, subcycles=nsub) + # samples sit exactly on boundaries, so the knife-edge warning fires + with pytest.warns(UserWarning, match="subcycle boundary"): + t_norm, n_sub, _ = self._normalize(time, frequency=freq, subcycles=nsub) assert np.allclose(t_norm, 0.0, atol=1e-12) assert np.allclose(n_sub, [1, 2, 3, 1, 2, 3]) @@ -565,6 +571,41 @@ def test_no_repetition(self): assert np.allclose(n_sub, 0.0) assert np.allclose(n_counter, 0.0) + # + def test_boundary_warning_near_boundary(self): + """A sample perturbed off a boundary by text round-trip error warns.""" + + # emulate a %.6e save/reload of a boundary sample (rel error ~1e-7) + time = np.array([0.02, 0.05 * (1 + 1e-7)]) + with pytest.warns(UserWarning, match="subcycle boundary"): + self._normalize(time, frequency=10, subcycles=2) + + # + def test_no_boundary_warning_clean_samples(self): + """Interior samples, t=0, and negative times do not warn.""" + + time = np.array([-0.05, 0.0, 0.02, 0.07, 0.11]) + with warnings.catch_warnings(): + warnings.simplefilter("error") + self._normalize(time, frequency=10, subcycles=2) + + # + def test_boundary_eps_scales_with_elapsed_subcycles(self): + """Tolerance is relative to elapsed subcycles: the same absolute + distance to a boundary warns late in a long series, not early on.""" + + norm = 0.05 # subcycle duration for frequency=10, subcycles=2 + offset = 0.01 # distance to nearest boundary in subcycle units + # threshold at 1e6 elapsed subcycles = 1e-6 * 1e6 = 1 > offset + late = np.array([(1e6 + offset) * norm]) + with pytest.warns(UserWarning, match="subcycle boundary"): + self._normalize(late, frequency=10, subcycles=2) + # threshold at 2 elapsed subcycles ~ 2e-6 < offset + early = np.array([(2 + offset) * norm]) + with warnings.catch_warnings(): + warnings.simplefilter("error") + self._normalize(early, frequency=10, subcycles=2) + # def test_many_parameter_combinations(self): """Sweep freq × subcycles and verify shapes and value ranges.""" From fa34fdc63122a522f48bb9fe3b076cb12588673f Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Sun, 12 Jul 2026 17:53:56 -0700 Subject: [PATCH 02/13] add pre-1.0 stability and deprecation policy docs page Pre-1.0: no deprecation cycle, but a complete changelog, versioned schemas that refuse rather than misread, and patch/minor semantics. Post-1.0: deprecation cycle committed for the user API; the advanced tier is deferred to the planned API-tier guide (TODO road-item 3). --- TODO.md | 5 ++--- docs/index.rst | 1 + docs/stability.md | 41 +++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 4 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 docs/stability.md diff --git a/TODO.md b/TODO.md index 740b18d..5f6a1e9 100644 --- a/TODO.md +++ b/TODO.md @@ -45,9 +45,8 @@ 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. +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. 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. -5. [ ] **State a pre-1.0 stability & deprecation policy**: short docs note establishing the social contract for early adopters — pre-1.0 public API may change, but only with a `DeprecationWarning` and a changelog entry; versioned schemas (`.fit.h5` archive, model YAML) refuse or migrate rather than misread. Cheap to write, and it is what makes early adoption safe for both sides. -6. [ ] **`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. +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/index.rst b/docs/index.rst index 838555f..a29019e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,6 +11,7 @@ A Python library for fitting multi-component spectral models to time-resolved sp quickstart examples/index design/supported_models + stability .. toctree:: :maxdepth: 2 diff --git a/docs/stability.md b/docs/stability.md new file mode 100644 index 0000000..b690599 --- /dev/null +++ b/docs/stability.md @@ -0,0 +1,41 @@ +# Stability and Deprecation Policy + +`trspecfit` is pre-1.0: the public API is still being shaped by real usage. +This page states what can change and what you can rely on, so adopting the +package early is safe on both sides. + +## Before v1.0.0 + +Any part of the API may change between minor releases, without a deprecation +cycle. What you can rely on: + +- **The changelog is complete.** Renames, removals, and behavior changes are + listed in `CHANGELOG.md` under the release that made them; skim it before + upgrading. +- **Schemas refuse rather than misread.** The fit archive (`.fit.h5`) carries + a schema version and refuses to read or append across mismatched versions. + A format-version key for model YAML files is planned so future syntax + changes can warn or migrate instead of silently misparsing old files. +- **Version numbers signal risk.** Patch releases (`0.x.y` → `0.x.y+1`) + contain only fixes and backwards-compatible additions; anything breaking + lands in a minor release (`0.x` → `0.x+1`). + +Pin to a minor version (e.g. `trspecfit>=0.12,<0.13`) where you need +reproducibility. Model YAML files and `.fit.h5` archives you accumulate are +treated as long-lived artifacts on our side. + +## From v1.0.0 on + +The user API — `Project`, `File`, `FitResults`, `Simulator`, `PlotConfig` +(the top-level exports) and the YAML model format — gains a deprecation +cycle: before a public name is removed or renamed, the old name keeps +working and emits a `DeprecationWarning` pointing to the replacement for +at least six months and at least one intervening minor release, whichever +is longer. + +The rest of the importable surface (e.g. the model-building layer in +`trspecfit.mcp`) is not yet classified. An API-tier guide separating stable, +advanced, and internal modules is planned before v1.0.0; once it exists, the +stability commitment for the advanced tier will be stated there. Compiled +internals (`graph_ir`, `eval_1d`, `eval_2d`, `eval_jax`, and low-level +parsing/HDF5 helpers) carry no stability guarantees at any version. diff --git a/pyproject.toml b/pyproject.toml index 9bf161d..cfbca7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trspecfit" -version = "0.12.1" +version = "0.12.2" authors = [ {name = "Johannes Mahl", email = "infinitymonkeyatwork@gmail.com"}, ] From 3e92f4ffed0759924b399f43f60895d18f0c6458 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Sun, 12 Jul 2026 18:25:08 -0700 Subject: [PATCH 03/13] add AGENTS.md: tool-neutral orientation for repo-developing agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure pointer file (CLAUDE.md, TODO/PLAN conventions, design docs, docs/ai recipes) plus four stable quick facts, kept enumeration-free so it needs no maintenance. The complementary concern — orienting agents that *use* trspecfit for analysis — is captured as a new TODO item under "User and AI ergonomics". --- AGENTS.md | 26 ++++++++++++++++++++++++++ TODO.md | 2 +- pyproject.toml | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..79e5936 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,26 @@ +# Agent Orientation + +Orientation for AI coding agents working on this repository — developing +`trspecfit` itself, not using it for analysis. If your tool does not load +these files automatically, read them before making changes. + +- [CLAUDE.md](CLAUDE.md) — authoritative: behavior rules, architecture + guardrails, code style, and testing conventions. Follow it even if you + are not Claude. +- [TODO.md](TODO.md) — high-level project goals; the `[ACTIVE]` tag marks + the feature currently in progress. +- [PLAN.md](PLAN.md) — live plan for active multi-step feature work (states + when nothing is in progress). +- [docs/design/repo_architecture.md](docs/design/repo_architecture.md) — + module map and the two-layer design (readable authoring layer vs. + compiled hot path). +- [docs/design/supported_models.md](docs/design/supported_models.md) — + source of truth for supported model combinations, expressions, and + compositions. +- [docs/ai/](docs/ai/index.md) — step-by-step recipes for common repo + tasks; consult the matching recipe before hand-rolling one. + +Quick facts: run tests with `pytest -q`; Ruff is the linter and formatter; +never commit without explicit user approval of the exact message; on +renames or public-API changes, grep the entire repo — notebooks, YAML, +tests, and docs all reference the public API. diff --git a/TODO.md b/TODO.md index 5f6a1e9..7cce274 100644 --- a/TODO.md +++ b/TODO.md @@ -32,7 +32,7 @@ ## User and AI ergonomics -- [ ] **Add tool-neutral agent orientation**: add `AGENTS.md` or `docs/ai/agent-orientation.md` pointing agents to `CLAUDE.md`, `TODO.md`, `PLAN.md`, `docs/design/repo_architecture.md`, supported-model docs, common commands, and API-change guardrails. Keep it concise so any LLM can quickly find the intended workflow and repo boundaries. +- [ ] **Agent orientation for package *users***: `AGENTS.md` (added 2026-07-12) orients agents developing the repo; the complementary concern is agents driving `trspecfit` inside analysis notebooks/scripts. Provide a concise entry point to the public workflow surface (load data → load model → fit → inspect), YAML model syntax, and common pitfalls — likely overlaps with the "minimal runnable workflow examples" item below and could double as an `llms.txt`-style doc. - [ ] **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. - [ ] **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. diff --git a/pyproject.toml b/pyproject.toml index cfbca7a..827cc7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trspecfit" -version = "0.12.2" +version = "0.12.3" authors = [ {name = "Johannes Mahl", email = "infinitymonkeyatwork@gmail.com"}, ] From 8a741a7fa209c13fc411a0a2765b1a0b4ed7a593 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Sun, 12 Jul 2026 18:38:32 -0700 Subject: [PATCH 04/13] add llms.txt: usage-facing orientation for agents driving trspecfit Core workflow, YAML model syntax, naming/composition rules, and headless-use pitfalls in one fetchable file, served at the docs-site root via html_extra_path. AGENTS.md now routes usage-focused agents here. Bump version to 0.12.4. --- AGENTS.md | 6 ++- TODO.md | 1 - docs/conf.py | 3 ++ llms.txt | 122 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 5 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 llms.txt diff --git a/AGENTS.md b/AGENTS.md index 79e5936..c331139 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,10 @@ # Agent Orientation Orientation for AI coding agents working on this repository — developing -`trspecfit` itself, not using it for analysis. If your tool does not load -these files automatically, read them before making changes. +`trspecfit` itself, not using it for analysis. If you want to *use* +`trspecfit` inside notebooks or scripts, read [llms.txt](llms.txt) instead. +If your tool does not load these files automatically, read them before +making changes. - [CLAUDE.md](CLAUDE.md) — authoritative: behavior rules, architecture guardrails, code style, and testing conventions. Follow it even if you diff --git a/TODO.md b/TODO.md index 7cce274..8f6bd07 100644 --- a/TODO.md +++ b/TODO.md @@ -32,7 +32,6 @@ ## User and AI ergonomics -- [ ] **Agent orientation for package *users***: `AGENTS.md` (added 2026-07-12) orients agents developing the repo; the complementary concern is agents driving `trspecfit` inside analysis notebooks/scripts. Provide a concise entry point to the public workflow surface (load data → load model → fit → inspect), YAML model syntax, and common pitfalls — likely overlaps with the "minimal runnable workflow examples" item below and could double as an `llms.txt`-style doc. - [ ] **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. - [ ] **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. diff --git a/docs/conf.py b/docs/conf.py index 008e0f3..bb91953 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -42,6 +42,9 @@ # -- Options for HTML output ------------------------------------------------- html_theme = "sphinx_rtd_theme" html_static_path = [] +# serve the repo-root llms.txt (agent/LLM orientation for package users) +# at the root of the built docs site +html_extra_path = ["../llms.txt"] # -- Extension configuration ------------------------------------------------- diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..e055cb3 --- /dev/null +++ b/llms.txt @@ -0,0 +1,122 @@ +# trspecfit + +> Python package for fitting 1D energy-resolved and 2D time-and-energy-resolved +> spectroscopy data. Models are composed from named components (peaks, +> backgrounds, convolution kernels) declared in YAML; parameters can be linked +> by expressions, evolve in time (dynamics), or vary along an auxiliary axis +> (profiles). Fitting builds on lmfit, with confidence intervals and optional +> MCMC. Install: `pip install trspecfit` (Python >= 3.12). + +This file orients AI agents (and humans in a hurry) who want to *use* +trspecfit for analysis. If you are developing the package itself, read +`AGENTS.md` in the repository root instead. + +## Core workflow + +```python +from trspecfit import Project, File + +project = Project(path='my_project', name='my_experiment') +file = File(parent_project=project, path='my_dataset', + data=data2d, energy=energy_axis, time=time_axis) + +# 1) fit a static "baseline" model on the pre-trigger region +file.define_baseline(time_start=0, time_stop=3) # absolute time values +file.load_model('models_energy.yaml', 'base') +file.set_fit_limits(energy_limits=[...], time_limits=[...]) +file.fit_baseline('base') + +# 2) fit the full 2D dataset with time-dependent parameters +file.load_model('models_energy.yaml', '2D') +file.add_time_dependence('2D', 'GLP_01_x0', 'models_time.yaml', 'shift') +file.fit_2d('2D') + +# 3) inspect results +df = file.get_fit_results(fit_type='2d') # pandas DataFrame +file.save_fit(...) # HDF5 fit archive +``` + +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. + +## YAML model format + +One file holds multiple named models; each model lists components in order, +each component lists its parameters: + +```yaml +base: + LinBack: + m: [1E-2, True, -1, 1] # [value, vary, min, max] + b: [0, False] # fixed parameter (short form) + xStart: [0, False] + xStop: [20, False] + GLP: + A: [10, True, 5, 15] + x0: [8, True, 5, 15] + F: [1.5, True, 0.75, 2.5] + m: [0.3, True, 0, 1] + GLP: + A: ["3/4*GLP_01_A"] # expression: linked to first GLP + x0: ["GLP_01_x0 + 3.67"] # fixed energy splitting + F: ["GLP_01_F"] + m: ["GLP_01_m"] +``` + +Component types (see the API reference for signatures): + +- Peaks: `Gauss`, `GaussAsym`, `Lorentz`, `Voigt`, `GLP`, `GLS`, `DS` +- Backgrounds: `LinBack`, `Shirley`, `Offset` +- Time dynamics: `linFun`, `expFun`, `sinFun`, `sinDivX`, `erfFun`, `sqrtFun` +- Convolution kernels: `gaussCONV`, `expSymCONV`, `expDecayCONV`, + `expRiseCONV`, `boxCONV` +- Profile functions (auxiliary axis, always `p`-prefixed): `pExpDecay`, + `pLinear`, `pGauss` + +## Naming and composition rules + +- Repeated components are auto-numbered in YAML order: `GLP_01`, `GLP_02`. + Full parameter names join with underscores — `GLP_01_x0` — which is why + function and parameter names themselves never contain underscores. +- Attached dynamics/profile parameters extend the chain: + `GLP_01_A_expFun_01_tau` is the `tau` of the `expFun` dynamics on the `A` + of the first `GLP`. Use these full names in expressions and results. +- Supported composition: base parameter -> profile, then profile parameter + -> dynamics. Disallowed: profile + dynamics on the same base parameter, + and expression chains that pass through a time-dependent parameter. +- The full composition contract lives in the Supported Models page below — + treat it as the source of truth. + +## 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. +- 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. +- Pre-1.0, any API may change between minor releases; pin a minor version + (e.g. `trspecfit>=0.12,<0.13`) and read the changelog before upgrading. + +## Docs + +- [Quick start](https://time-resolved-spectroscopy-fit.readthedocs.io/en/latest/quickstart.html): + install, first notebook, the typical workflow step by step +- [Examples](https://time-resolved-spectroscopy-fit.readthedocs.io/en/latest/examples/index.html): + runnable notebooks — basic fitting, linked parameters, multi-cycle + dynamics, profiles, model comparison, save/load, uncertainty/MCMC, + multi-file workspaces, synthetic data +- [Supported models](https://time-resolved-spectroscopy-fit.readthedocs.io/en/latest/design/supported_models.html): + source of truth for model combinations, expressions, and compositions +- [API reference](https://time-resolved-spectroscopy-fit.readthedocs.io/en/latest/api/index.html): + all public classes and component-function signatures +- [Stability policy](https://time-resolved-spectroscopy-fit.readthedocs.io/en/latest/stability.html): + what can change before/after v1.0.0 + +## Optional + +- [Repository](https://github.com/InfinityMonkeyAtWork/time-resolved-spectroscopy-fit/): + source, issues, and Q&A discussions +- [Architecture](https://time-resolved-spectroscopy-fit.readthedocs.io/en/latest/design/repo_architecture.html): + module map, only needed when reading the source diff --git a/pyproject.toml b/pyproject.toml index 827cc7e..cdf1b57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trspecfit" -version = "0.12.3" +version = "0.12.4" authors = [ {name = "Johannes Mahl", email = "infinitymonkeyatwork@gmail.com"}, ] From c33c7ce89ae0c736642d34bce2aab5bea935fac2 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Sun, 12 Jul 2026 19:22:48 -0700 Subject: [PATCH 05/13] run the test suite in parallel by default (pytest-xdist) Add pytest-xdist==3.8.0 to the dev pins and "-n auto --dist worksteal" to pytest addopts; tests/conftest.py pins BLAS/OpenMP to one thread per worker before numpy import (per-worker thread pools oversubscribed the machine, costing 2x wall time). Not-slow suite drops 72s -> 18s, full suite incl. slow runs in 4min wall. Pass -n 0 for a sequential run. Bump the CI uv pin alongside the dev pins, remove the superseded "fast verification slices" TODO item, and bump version to 0.12.5. --- .github/workflows/ci.yaml | 2 +- TODO.md | 1 - pyproject.toml | 7 +++++-- tests/conftest.py | 10 ++++++++++ 4 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 tests/conftest.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 92a7e1f..0999a99 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -85,7 +85,7 @@ jobs: # transitive deps out of the constraints file so pip resolves those # itself below — mirroring a user whose direct deps sit at our floors. run: | - python -m pip install uv==0.11.27 # bump alongside the [dev] pins + python -m pip install uv==0.11.28 # bump alongside the [dev] pins uv pip compile --resolution lowest-direct --no-deps \ -o min-constraints.txt pyproject.toml cat min-constraints.txt diff --git a/TODO.md b/TODO.md index 8f6bd07..638ca4e 100644 --- a/TODO.md +++ b/TODO.md @@ -36,7 +36,6 @@ - [ ] **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. - [ ] **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). -- [ ] **Document fast verification slices**: add focused pytest commands for common edits (public workflow/API, YAML parser, functions, GIR/evaluator, plotting) so contributors and agents can validate changes quickly before running the full suite. ## Road to v1.0.0 & first adopters diff --git a/pyproject.toml b/pyproject.toml index cdf1b57..c2d5667 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trspecfit" -version = "0.12.4" +version = "0.12.5" authors = [ {name = "Johannes Mahl", email = "infinitymonkeyatwork@gmail.com"}, ] @@ -62,6 +62,7 @@ dev = [ "nbformat==5.10.4", # used by scripts/normalize_notebooks.py (pre-commit hook) "nbstripout==0.9.1", "pytest==9.1.1", + "pytest-xdist==3.8.0", "pyright==1.1.411", "ruff==0.15.20", "build==1.5.0", @@ -115,7 +116,9 @@ typeCheckingMode = "basic" [tool.pytest.ini_options] pythonpath = ["src", "tests"] testpaths = ["tests"] -addopts = "-m 'not slow'" +# parallel by default (pytest-xdist); tests/conftest.py pins BLAS to one +# thread per worker. For a sequential run (e.g. --pdb debugging) pass -n 0. +addopts = "-m 'not slow' -n auto --dist worksteal" markers = ["slow: long-running round-trip tests (skipped by default, use -m slow)"] filterwarnings = [ # test fixtures often sample exactly on subcycle boundaries (integer-step diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..79ae2dc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +# Pin BLAS/OpenMP to one thread per process BEFORE numpy is imported +# anywhere: the suite runs parallel by default (pytest-xdist, see addopts +# in pyproject.toml), and per-worker BLAS thread pools oversubscribe the +# machine (measured 2x wall-time cost). setdefault keeps explicit user +# overrides working. +import os + +os.environ.setdefault("OMP_NUM_THREADS", "1") +os.environ.setdefault("OPENBLAS_NUM_THREADS", "1") +os.environ.setdefault("MKL_NUM_THREADS", "1") From f2a31314aeea63cbba7409af2aa7ba976d122671 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Sun, 12 Jul 2026 21:34:34 -0700 Subject: [PATCH 06/13] pin Accelerate threads too in the test conftest Apple Accelerate (numpy >= 2.0 macOS arm64 wheels) ignores OMP/OPENBLAS/MKL_NUM_THREADS; VECLIB_MAXIMUM_THREADS=1 closes the same oversubscription gap for parallel test runs on Apple Silicon. --- tests/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 79ae2dc..69355ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,3 +8,5 @@ os.environ.setdefault("OMP_NUM_THREADS", "1") os.environ.setdefault("OPENBLAS_NUM_THREADS", "1") os.environ.setdefault("MKL_NUM_THREADS", "1") +# Apple Accelerate (numpy >= 2.0 macOS arm64 wheels) ignores the above +os.environ.setdefault("VECLIB_MAXIMUM_THREADS", "1") From 4d33b2152c725719da59a023efcccec719fd2914 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Mon, 13 Jul 2026 07:35:20 -0700 Subject: [PATCH 07/13] clarify simulator noise semantics and save derived sigma_data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the detection / noise_type / noise_level split precisely (pathway vs. distribution vs. amplitude, with per-type sigma formulas) and fix the stale absolute-units claim in set_noise_level. Add the Simulator.sigma_data property (constant sigma for analog gaussian simulations, aligned with the fit-side noise schema and File.set_sigma) and store it in saved HDF5 metadata — per config group in parameter sweeps, where it depends on each config's clean data. SweepDataset surfaces it separately from swept parameters. Bump version to 0.12.6. --- TODO.md | 1 - pyproject.toml | 2 +- src/trspecfit/simulator.py | 90 ++++++++++++++++++++++++--- src/trspecfit/utils/sweep.py | 17 +++-- tests/test_parameter_sweep.py | 113 ++++++++++++++++++++++++++++++++++ 5 files changed, 207 insertions(+), 16 deletions(-) diff --git a/TODO.md b/TODO.md index 638ca4e..977838e 100644 --- a/TODO.md +++ b/TODO.md @@ -6,7 +6,6 @@ ## Noise and simulation -- [ ] **Simulator noise-language cleanup**: align simulator docs/metadata with the fit-results noise schema. Keep simulator `noise_type` meaning "noise distribution / random generator" (`gaussian`, `poisson`, `none`), not sigma shape (the stale `set_noise_type` docstring mentioning `uniform` was fixed and the setter validated, 2026-07-10). Clarify `detection` vs. `noise_type` vs. `noise_level`; and, for analog Gaussian simulations, consider saving the derived `sigma_data = noise_level * max(abs(clean_data))` alongside existing metadata. For parameter sweeps, store derived `sigma_data` per configuration when it depends on each clean dataset. - [ ] **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. ## Performance & architecture diff --git a/pyproject.toml b/pyproject.toml index c2d5667..625d0c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trspecfit" -version = "0.12.5" +version = "0.12.6" authors = [ {name = "Johannes Mahl", email = "infinitymonkeyatwork@gmail.com"}, ] diff --git a/src/trspecfit/simulator.py b/src/trspecfit/simulator.py index a1393ec..272d583 100644 --- a/src/trspecfit/simulator.py +++ b/src/trspecfit/simulator.py @@ -85,17 +85,32 @@ class Simulator: Model instance from trspecfit.mcp with defined components and parameters. Must have energy and time axes set before simulation. detection : {'analog', 'photon_counting'}, default='analog' - Detection technique to simulate: - - 'analog': Continuous signal with additive noise - - 'photon_counting': Discrete photon events with Poisson statistics + Detection technique to simulate — selects the noise pathway: + + - 'analog': continuous clean signal plus additive noise; the + distribution is set by noise_type, the amplitude by noise_level + - 'photon_counting': discrete photon sampling under a count budget + (counts_per_delay, or count_rate x integration_time); + noise_level and noise_type are ignored noise_level : float, default=0.05 - Noise amplitude for analog detectors (0.0-1.0 for relative noise). + Dimensionless noise amplitude for the analog pathway. Larger values = more noise. Ignored for photon_counting. + + - 'gaussian': constant sigma = noise_level * max(abs(clean_data)), + i.e. the noise-to-peak ratio; exposed as ``sigma_data`` + - 'poisson': the clean signal is scaled by 1/noise_level, + Poisson-sampled, and scaled back, giving per-pixel + sigma = sqrt(noise_level * abs(signal)) — the relative noise + therefore depends on the absolute model amplitudes noise_type : {'poisson', 'gaussian', 'none'}, default='poisson' - Type of noise for analog detectors: - - 'poisson': Shot noise (realistic for low light) - - 'gaussian': White noise (simpler, faster) + Noise distribution (random generator) for the analog pathway: + + - 'poisson': signal-dependent shot-like noise added to the + continuous signal (unlike detection='photon_counting', which + samples actual photon counts) + - 'gaussian': white noise with constant sigma - 'none': No noise (testing and debugging) + Ignored for photon_counting (always Poisson). counts_per_delay : int, optional Total photon count per time delay (photon_counting only). @@ -155,12 +170,16 @@ class Simulator: **Noise Level Selection:** - For analog detectors, noise_level is relative to signal: + For analog gaussian noise, noise_level is the noise-to-peak ratio: - 0.01 (1%): Very clean, ideal conditions - 0.05 (5%): Typical good data - 0.10 (10%): Moderate noise, still fittable - 0.20 (20%): Challenging, may need averaging + For analog poisson noise, the per-pixel sigma is signal-dependent + (sqrt(noise_level * abs(signal))), so the same noise_level values + give stronger relative noise unless model amplitudes are large. + For photon counting, SNR set by counts_per_delay: - 100 counts: SNR ~ 10 (marginal) - 1000 counts: SNR ~ 32 (good) @@ -923,7 +942,8 @@ def set_noise_level(self, noise_level: float) -> None: Parameters ---------- noise_level : float - Standard deviation of Gaussian noise (absolute units). + Dimensionless noise amplitude, relative to the clean signal + (see the class docstring for per-noise_type semantics). """ if self.detection != "analog": @@ -1004,6 +1024,38 @@ def set_count_rate( " counts_per_delay from count_rate" ) + # + def _constant_sigma(self, clean_data: np.ndarray) -> float | None: + """ + Constant per-pixel sigma for clean_data under the current noise + settings: noise_level * max(abs(clean_data)) for analog gaussian + noise, None otherwise (poisson and photon-counting sigmas are + signal-dependent; 'none' has no noise). + """ + + if self.detection != "analog" or self.noise_type != "gaussian": + return None + return float(self.noise_level * np.max(np.abs(clean_data))) + + # + @property + def sigma_data(self) -> float | None: + """ + Constant noise sigma implied by the most recent simulation. + + Only analog detection with ``noise_type='gaussian'`` has a constant + per-pixel sigma: ``noise_level * max(abs(data_clean))``. Returns + None for other noise settings or before any data was simulated. + Pass the value to ``File.set_sigma`` when fitting simulated data so + chi-square and derived uncertainties use the true noise scale. + Evaluated from the current noise settings, so re-simulate (or + re-read) after changing ``noise_level``. + """ + + if self.data_clean is None: + return None + return self._constant_sigma(self.data_clean) + # def set_seed(self, seed: int | None) -> None: """ @@ -1418,6 +1470,9 @@ def save_data( ├── detection ('analog' or 'photon_counting') ├── noise_level (analog noise level) ├── noise_type (analog noise type) + ├── sigma_data ([optional] constant noise sigma, + │ analog gaussian only — pass to + │ File.set_sigma when fitting) ├── counts_per_delay (photon counting counts) ├── count_rate ([optional] photon counting rate) ├── integration_time ([optional] photon counting integration time) @@ -1552,7 +1607,14 @@ def _write_axes_hdf5(self, f: h5py.File) -> None: # def _write_detection_metadata(self, meta: h5py.Group) -> None: - """Write detection/noise settings and seed as metadata attributes.""" + """ + Write detection/noise settings and seed as metadata attributes. + + The derived ``sigma_data`` is intentionally not written here: it + depends on the clean data, so it is written where that data is + known — the metadata group for single saves, the per-config groups + for parameter sweeps. + """ meta.attrs["detection"] = self.detection if self.detection == "analog": @@ -1623,6 +1685,8 @@ def _save_hdf5(self, filepath: str, n_data: list[np.ndarray] | None = None) -> N # Save metadata group at root level meta = f.create_group("metadata") self._write_detection_metadata(meta) + if (sigma_data := self.sigma_data) is not None: + meta.attrs["sigma_data"] = sigma_data # Determine dimensionality from clean data if self.data_clean is not None: @@ -1818,6 +1882,7 @@ def _initialize_sweep_hdf5( │ ├── config_000000/ (group) │ │ ├── attrs: GLP_01_A, GLP_01_x0, ... │ │ ├── attrs: all_parameter_values (JSON) + │ │ ├── attrs: sigma_data # Analog gaussian only │ │ └── clean (dataset) # Clean data for this config │ ├── config_000001/ (group) │ └── ... @@ -1925,6 +1990,11 @@ def _append_config_to_hdf5( } config_group.attrs["all_parameter_values"] = json.dumps(param_values) + # Constant noise sigma for this config (analog gaussian only) — + # per config because it derives from each config's clean data + if (sigma_data := self._constant_sigma(clean)) is not None: + config_group.attrs["sigma_data"] = sigma_data + # Save clean data for this configuration config_group.create_dataset("clean", data=clean) diff --git a/src/trspecfit/utils/sweep.py b/src/trspecfit/utils/sweep.py index 2f465f8..ae2f27b 100644 --- a/src/trspecfit/utils/sweep.py +++ b/src/trspecfit/utils/sweep.py @@ -566,11 +566,12 @@ def get_parameter_summary(self) -> pd.DataFrame: configs_group[config_name], f"parameter_configs/{config_name}" ) - # Get swept parameters (exclude 'all_parameters') + # Get swept parameters (exclude the derived attributes) parameters = { key: value for key, value in config_group.attrs.items() - if key not in ("all_parameters", "all_parameter_values") + if key + not in ("all_parameters", "all_parameter_values", "sigma_data") } param_data.append(parameters) @@ -605,6 +606,8 @@ def load_config( Dictionary with keys: - parameters: Dict of swept parameter values - all_parameter_values: Dict of all model parameter values (if available) + - sigma_data: Constant noise sigma for this config's clean data + (analog gaussian simulations only; pass to File.set_sigma) - clean: Clean data (if load_clean=True) - noisy: List of noisy realizations (if load_noisy=True) @@ -626,14 +629,20 @@ def load_config( configs_group[config_name], f"parameter_configs/{config_name}" ) - # Get swept parameters (all attributes except 'all_parameters') + # Get swept parameters (all attributes except the derived ones) parameters = { key: value for key, value in config_group.attrs.items() - if key not in ("all_parameters", "all_parameter_values") + if key not in ("all_parameters", "all_parameter_values", "sigma_data") } result["parameters"] = parameters + # Derived constant noise sigma (analog gaussian sims only) + if "sigma_data" in config_group.attrs: + result["sigma_data"] = float( + cast("float", config_group.attrs["sigma_data"]) + ) + # Get all parameter values (JSON) if "all_parameter_values" in config_group.attrs: result["all_parameter_values"] = json_loads_attr( diff --git a/tests/test_parameter_sweep.py b/tests/test_parameter_sweep.py index d8e7b5b..69979e5 100644 --- a/tests/test_parameter_sweep.py +++ b/tests/test_parameter_sweep.py @@ -299,6 +299,119 @@ def test_constructor_unknown_noise_type_raises(self): ) +# +# +class TestSimulatorSigmaData: + """sigma_data: derived constant sigma for analog gaussian simulations.""" + + # + def _make_model(self): + """Minimal 1D energy model for sigma tests. + + The energy axis covers the model's peaks (x0 = 84.5/88.1) so that + the peak amplitude drives max(abs(clean)). + """ + + project = make_project(name="test") + file = File( + parent_project=project, + energy=np.arange(80, 95, 0.5), + time=np.arange(-10, 100, 5), + ) + file.load_model( + model_yaml="models/file_energy.yaml", model_info="simple_energy" + ) + assert file.model_active is not None # type guard + return file.model_active + + # + def _make_simulator(self, **kwargs): + defaults = { + "detection": "analog", + "noise_level": 0.1, + "noise_type": "gaussian", + "seed": 42, + } + return Simulator(model=self._make_model(), **{**defaults, **kwargs}) + + # + def test_gaussian_sigma_matches_definition(self): + sim = self._make_simulator() + clean, _noisy, _noise = sim.simulate_1d() + assert sim.sigma_data == pytest.approx(0.1 * np.max(np.abs(clean))) + + # + def test_sigma_none_before_simulation(self): + sim = self._make_simulator() + assert sim.sigma_data is None + + # + def test_sigma_none_for_non_constant_noise(self): + for kwargs in ( + {"noise_type": "poisson"}, + {"noise_type": "none"}, + {"detection": "photon_counting", "counts_per_delay": 1000}, + ): + sim = self._make_simulator(**kwargs) + sim.simulate_1d() + assert sim.sigma_data is None + + # + def test_saved_metadata_contains_sigma(self, tmp_path, monkeypatch): + sim = self._make_simulator() + clean, _noisy, _noise = sim.simulate_1d() + # save_data always writes below cwd/simulated_data + monkeypatch.chdir(tmp_path) + sim.save_data(filepath="gaussian.h5", show_output=0) + with h5py.File(tmp_path / "simulated_data" / "gaussian.h5", "r") as f: + sigma_saved = f["metadata"].attrs["sigma_data"] + assert sigma_saved == pytest.approx(0.1 * np.max(np.abs(clean))) + + # + def test_saved_metadata_omits_sigma_for_poisson(self, tmp_path, monkeypatch): + sim = self._make_simulator(noise_type="poisson") + sim.simulate_1d() + monkeypatch.chdir(tmp_path) + sim.save_data(filepath="poisson.h5", show_output=0) + with h5py.File(tmp_path / "simulated_data" / "poisson.h5", "r") as f: + assert "sigma_data" not in f["metadata"].attrs + + # + def test_sweep_saves_sigma_per_config(self, tmp_path): + # sweeping the peak amplitude changes max(abs(clean)), so each + # config must get its own sigma_data + sweep = ParameterSweep(strategy="grid", seed=42) + sweep.add_range("GLP_01_A", [10, 20]) + + sim = self._make_simulator() + filepath = tmp_path / "sweep_sigma.h5" + sim.simulate_parameter_sweep( + parameter_sweep=sweep, + n_realizations=1, + dim=1, + filepath=str(filepath), + show_progress=False, + ) + + sigma_per_config = [] + with h5py.File(filepath, "r") as f: + assert "sigma_data" not in f["metadata"].attrs + for config_name in ("config_000000", "config_000001"): + config_group = f["parameter_configs"][config_name] + clean = config_group["clean"][:] + sigma_saved = config_group.attrs["sigma_data"] + assert sigma_saved == pytest.approx(0.1 * np.max(np.abs(clean))) + sigma_per_config.append(float(sigma_saved)) + assert sigma_per_config[0] != sigma_per_config[1] + + # loader surfaces sigma_data as its own key, not as a parameter + dataset = SweepDataset(str(filepath)) + config = dataset.load_config(0) + assert config["sigma_data"] == pytest.approx(sigma_per_config[0]) + assert "sigma_data" not in config["parameters"] + assert "sigma_data" not in dataset.get_parameter_summary().columns + + # # class TestSimulatorParameterSweep: From 68d366ade7b8b5aa2abe47fe8a14c2ac0cad5cab Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Mon, 13 Jul 2026 09:55:33 -0700 Subject: [PATCH 08/13] use a spawn-backed pool for MCMC workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lmfit.emcee(workers=N) built a default-context multiprocessing.Pool, which fork()s on Linux before 3.14 — deprecated and deadlock-prone in multithreaded processes. Hand lmfit a spawn-context pool instead (matching the slice-by-slice executor) and drop the fork-warning ignore from the pytest filters. The regression test records warnings rather than escalating them: CPython emits the fork warning after the fork succeeded and swallows filterwarnings("error"). Bump version to 0.12.7. --- TODO.md | 1 - pyproject.toml | 3 +-- src/trspecfit/fitlib.py | 33 +++++++++++++++++++++----------- src/trspecfit/utils/lmfit.py | 7 +++++-- tests/test_mcp_library.py | 37 ++++++++++++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 16 deletions(-) diff --git a/TODO.md b/TODO.md index 977838e..cfab1a2 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). - Project-level fused shared fits with joint analytic Jacobian — 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. -- [ ] **MCMC multiprocessing context**: `lmfit.emcee(workers=N)` currently inherits Python's default multiprocessing start method, which triggers a Python 3.12 `fork()` deprecation warning in multithreaded test runs. Investigate whether we can supply a `spawn`-backed worker pool or otherwise steer emcee/lmfit away from raw `fork`. - [ ] **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). diff --git a/pyproject.toml b/pyproject.toml index 625d0c3..0ca6f99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trspecfit" -version = "0.12.6" +version = "0.12.7" authors = [ {name = "Johannes Mahl", email = "infinitymonkeyatwork@gmail.com"}, ] @@ -126,7 +126,6 @@ filterwarnings = [ "ignore:.*subcycle boundary:UserWarning", "ignore:FigureCanvasAgg is non-interactive:UserWarning", "ignore:invalid value encountered in scalar divide:RuntimeWarning:lmfit", - "ignore:This process .* use of fork\\(\\) may lead to deadlocks in the child\\.:DeprecationWarning", ] [tool.ruff] diff --git a/src/trspecfit/fitlib.py b/src/trspecfit/fitlib.py index b79cf13..e82651c 100644 --- a/src/trspecfit/fitlib.py +++ b/src/trspecfit/fitlib.py @@ -21,6 +21,7 @@ import copy import math +import multiprocessing import pathlib import time from collections.abc import Callable, Sequence @@ -842,17 +843,27 @@ def _method_kws(method: str) -> dict[str, Any]: ) # burn necessary if starting point not close to max(probability distribution) # i.e. not close to the optimized parameter set, so burn=0 is ok here! - emcee_fin = mini.emcee( - params=par_fin_params, - steps=mc_settings.steps, - nwalkers=mc_settings.nwalkers, - burn=mc_settings.burn, - thin=mc_settings.thin, - ntemps=mc_settings.ntemps, - workers=mc_settings.workers, - is_weighted=mc_settings.is_weighted, - progress=show_output >= 1, - ) + emcee_kwargs: dict[str, Any] = { + "params": par_fin_params, + "steps": mc_settings.steps, + "nwalkers": mc_settings.nwalkers, + "burn": mc_settings.burn, + "thin": mc_settings.thin, + "ntemps": mc_settings.ntemps, + "is_weighted": mc_settings.is_weighted, + "progress": show_output >= 1, + } + if isinstance(mc_settings.workers, int) and mc_settings.workers > 1: + # lmfit would build a default-context Pool, which fork()s on + # 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. + ctx = multiprocessing.get_context("spawn") + with 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: + emcee_fin = mini.emcee(workers=mc_settings.workers, **emcee_kwargs) emcee_fin_params = _result_params(emcee_fin) emcee_flatchain = cast( "pd.DataFrame", getattr(emcee_fin, "flatchain", pd.DataFrame()) diff --git a/src/trspecfit/utils/lmfit.py b/src/trspecfit/utils/lmfit.py index cef3b01..6efb578 100644 --- a/src/trspecfit/utils/lmfit.py +++ b/src/trspecfit/utils/lmfit.py @@ -502,7 +502,9 @@ class MC: ntemps : int, default=1 Number of temperatures for parallel tempering workers : int, default=1 - Number of parallel workers (1 = serial) + Number of parallel workers (1 = serial). Workers > 1 run the + sampling in a spawn-backed process pool (safe in multithreaded + processes, unlike fork), costing ~1-2 s of pool startup per fit. is_weighted : bool, default=False Whether to use weighted samples sigma_ini : float, default=0.1 @@ -554,7 +556,8 @@ class MC: - burn-in needed if starting point far from optimum (set burn=0 if starting from fit) - thin > 1 reduces autocorrelation in samples - - workers > 1 enables parallel sampling (requires multiprocessing support) + - workers > 1 enables parallel sampling in a spawn-backed process pool + (not supported on the JAX evaluator path) See Also -------- diff --git a/tests/test_mcp_library.py b/tests/test_mcp_library.py index b1d2187..a953667 100644 --- a/tests/test_mcp_library.py +++ b/tests/test_mcp_library.py @@ -2,6 +2,7 @@ Test MCP (Model/Component/Parameter) library functionality """ +import threading import warnings import numpy as np @@ -1064,6 +1065,42 @@ def test_mcmc_workers_2_does_not_pickle_error(self): pytest.fail(f"MCMC workers=2 hit a pickle error: {e}") raise + # + @pytest.mark.slow + def test_mcmc_workers_2_does_not_fork(self): + """lmfit.emcee(workers=2) must not fork() a multithreaded process. + + CPython 3.12 deprecated fork() in multithreaded processes because + the child can deadlock on locks held by threads that do not + survive the fork, so the MCMC worker pool must use the spawn + start method (like the slice-by-slice executor). The live helper + thread makes the fork warning deterministic. The warning must be + *recorded*, not escalated: CPython emits it after the fork already + succeeded and swallows a raised exception, so filterwarnings + ("error") can never catch it. + """ + + from trspecfit.utils.lmfit import MC + + file = self._make_fittable_file() + mc = MC(use_mc=1, steps=20, nwalkers=32, burn=5, thin=1, workers=2) + + stop = threading.Event() + helper = threading.Thread(target=stop.wait) + helper.start() + try: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + file.fit_baseline( + model_name="single_glp", stages=1, try_ci=0, mc_settings=mc + ) + finally: + stop.set() + helper.join() + + forked = [w for w in caught if "use of fork" in str(w.message)] + assert not forked, "MCMC worker pool fork()ed a multithreaded process" + # def test_mc_sigma_settings_validate(self): """MC rejects inconsistent sigma_ini/sigma_min/sigma_max.""" From 57df10a69c1777ee13531e67a6d0f599a9616bd1 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Mon, 13 Jul 2026 10:21:14 -0700 Subject: [PATCH 09/13] type-check tests at reduced pyright strictness Add tests/ to the pyright include set with an executionEnvironments entry: Optional-driven rules are off (File/Model attributes are `X | None` until a fit populates them, which tests access post-fit constantly), and the three residual rules are warnings so the ~25 real annotation gaps stay visible in editors without failing CI/pre-commit. Replaces the several-hundred-error Pylance noise; burn-down of the residual warnings is tracked in TODO.md. --- TODO.md | 2 +- pyproject.toml | 21 +++++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index cfab1a2..bb4d02d 100644 --- a/TODO.md +++ b/TODO.md @@ -26,7 +26,7 @@ ## Testing -- [ ] **Pylance/Pyright `| None` noise in tests**: several hundred Pyright errors across tests, mostly from accessing `File`/`Model` attributes typed as `ndarray | None` (`[tool.pyright]` only includes `src`, so these surface in editors, not CI). Current `# type guard` asserts (~110) are inconsistent and don't propagate through helper methods. Find a cleaner pattern (e.g. `TypeGuard`, narrowing wrapper, or Pyright config) and apply consistently. +- [ ] **Burn down the ~25 pyright warnings in tests**: tests are now type-checked at reduced strictness (`[tool.pyright]` executionEnvironments, 2026-07-13) — the `| None` noise is gone, and the residual warnings point at real annotation gaps, mostly in src return types: `File.load_model` annotated `Model` but returning `Dynamics` for `model_type="dynamics"` (test_graph_ir.py `set_frequency` warning), a `list[ndarray]`-annotated return used as an array (test_gir_integration.py `.shape` warnings), a `float`-annotated return with `.shape` access (test_file.py), an `ndarray`-annotated return used as a DataFrame (test_fit_history.py `.iloc`). Fix the annotations in src (or the call sites where the test is wrong), then consider promoting the three warning-level rules back to errors. ## User and AI ergonomics diff --git a/pyproject.toml b/pyproject.toml index 0ca6f99..e295112 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trspecfit" -version = "0.12.7" +version = "0.12.8" authors = [ {name = "Johannes Mahl", email = "infinitymonkeyatwork@gmail.com"}, ] @@ -106,13 +106,30 @@ warn_unused_configs = true ignore_missing_imports = true [tool.pyright] -include = ["src"] +include = ["src", "tests"] venvPath = "." venv = ".venv" pythonVersion = "3.12" reportMissingImports = true typeCheckingMode = "basic" +# Tests run at reduced strictness: File/Model attributes are `X | None` until +# a fit populates them, and tests access them post-fit constantly — narrowing +# every site would mean hundreds of asserts. Optional-driven rules are off; +# the remaining rules are warnings (real annotation gaps, tracked in TODO.md) +# so they stay visible in editors without failing CI/pre-commit. +[[tool.pyright.executionEnvironments]] +root = "tests" +reportOptionalMemberAccess = "none" +reportOptionalSubscript = "none" +reportOptionalOperand = "none" +reportOptionalIterable = "none" +reportArgumentType = "none" +reportCallIssue = "none" +reportAttributeAccessIssue = "warning" +reportOperatorIssue = "warning" +reportIndexIssue = "warning" + [tool.pytest.ini_options] pythonpath = ["src", "tests"] testpaths = ["tests"] From 3a6e168c7a1b0a459cb72d3e2ab7e2978c144c96 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Mon, 13 Jul 2026 10:43:22 -0700 Subject: [PATCH 10/13] fix the pyright warnings in tests, restore full rule severity Fix the 25 annotation gaps surfaced by type-checking tests: overload the spectra.fit_model_* functions (plot_sum=True returns ndarray, not ndarray | list) and File.load_model (model_type maps to the concrete Model subclass, so Dynamics/Profile methods type-check at call sites); narrow or cast the remaining test-side stub quirks (numpy .real on dtype[Any], pandas 3.0 __getitem__ unions, h5py group access). The three rules downgraded to warnings in the previous commit return to error severity. --- TODO.md | 4 -- pyproject.toml | 10 ++--- src/trspecfit/spectra.py | 63 +++++++++++++++++++++++++++++++- src/trspecfit/trspecfit.py | 28 +++++++++++++- tests/test_evaluate_jax.py | 2 +- tests/test_export_fits_parity.py | 2 +- tests/test_file.py | 4 +- tests/test_fit_history.py | 2 +- tests/test_mcp_eval.py | 2 + tests/test_parameter_sweep.py | 6 ++- 10 files changed, 104 insertions(+), 19 deletions(-) diff --git a/TODO.md b/TODO.md index bb4d02d..cdc79ee 100644 --- a/TODO.md +++ b/TODO.md @@ -24,10 +24,6 @@ - [ ] **Disentangle plotting from saving/conversion in the fit pipeline**: figure rendering is currently entangled with data conversion and file IO. `fitlib.results_to_df` (results → DataFrame) and `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). Blocks the legacy-shim removal item in the v1.0.0 checklist, since the `_save_*_legacy` impls are the live SbS/2D plotting path. Split into (a) compute/convert, (b) explicit save/export, (c) an explicit plotting API — cf. the `_save_img_flag` helper and `FitResults.plot_residuals`. 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. - [ ] **Decouple directory creation from path computation (`create_model_path`)**: `Project.create_model_path` ([trspecfit.py](src/trspecfit/trspecfit.py) ~L2167) `mkdir`s the `{path_results}/{file}/{fit_type}/{model}/` tree as a side effect of *computing* the path. Every fit method calls it to build the `save_path` handed to `fit_wrapper` (`fit_baseline` ~L2572, `fit_2d` ~L3983, spectrum ~L2804, sbs ~L3120), and the project plot-grid display builds image paths through it too (`fit_baselines` ~L1070, `fit_2d` ~L1432) — so empty `*_fits/` dir trees appear even when `auto_export: False` and nothing is written (observed in 21, 2026-06-27; every fitting example already does this). `fit_wrapper` only writes when `save_output=1 if auto_export else 0`. Fix: make `create_model_path` return the path *without* `mkdir`, and `mkdir`-on-write at the actual write sites (`fit_wrapper`'s save branch + the explicit `save_*`/`export_*` functions); the plot-grid readers then just build a path and `.exists()`-check. Do **not** gate on `auto_export` directly — explicit `save_fit()`/`export_fit()` legitimately need the dir and aren't `auto_export`-driven. Cosmetic only (the dirs are gitignored, no functional bug); blast radius spans baseline/spectrum/sbs/2d + savers, needs no-regression tests. Related to the plotting/saving disentanglement item above; scope: beyond the examples-upgrade branch. -## Testing - -- [ ] **Burn down the ~25 pyright warnings in tests**: tests are now type-checked at reduced strictness (`[tool.pyright]` executionEnvironments, 2026-07-13) — the `| None` noise is gone, and the residual warnings point at real annotation gaps, mostly in src return types: `File.load_model` annotated `Model` but returning `Dynamics` for `model_type="dynamics"` (test_graph_ir.py `set_frequency` warning), a `list[ndarray]`-annotated return used as an array (test_gir_integration.py `.shape` warnings), a `float`-annotated return with `.shape` access (test_file.py), an `ndarray`-annotated return used as a DataFrame (test_fit_history.py `.iloc`). Fix the annotations in src (or the call sites where the test is wrong), then consider promoting the three warning-level rules back to errors. - ## 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. diff --git a/pyproject.toml b/pyproject.toml index e295112..291bea8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trspecfit" -version = "0.12.8" +version = "0.12.9" authors = [ {name = "Johannes Mahl", email = "infinitymonkeyatwork@gmail.com"}, ] @@ -115,9 +115,8 @@ typeCheckingMode = "basic" # Tests run at reduced strictness: File/Model attributes are `X | None` until # a fit populates them, and tests access them post-fit constantly — narrowing -# every site would mean hundreds of asserts. Optional-driven rules are off; -# the remaining rules are warnings (real annotation gaps, tracked in TODO.md) -# so they stay visible in editors without failing CI/pre-commit. +# every site would mean hundreds of asserts. Only Optional-driven rules are +# off; everything else stays at basic-mode severity. [[tool.pyright.executionEnvironments]] root = "tests" reportOptionalMemberAccess = "none" @@ -126,9 +125,6 @@ reportOptionalOperand = "none" reportOptionalIterable = "none" reportArgumentType = "none" reportCallIssue = "none" -reportAttributeAccessIssue = "warning" -reportOperatorIssue = "warning" -reportIndexIssue = "warning" [tool.pytest.ini_options] pythonpath = ["src", "tests"] diff --git a/src/trspecfit/spectra.py b/src/trspecfit/spectra.py index 6294c3f..791607f 100644 --- a/src/trspecfit/spectra.py +++ b/src/trspecfit/spectra.py @@ -28,7 +28,7 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Any +from typing import Any, Literal, overload import numpy as np @@ -39,6 +39,25 @@ # +# plot_sum=True always returns the summed spectrum; only plot_sum=False +# (1D component extraction) returns a list. Mirrored by the other +# fit_model_* overloads below. +@overload +def fit_model_mcp( + x: Sequence[float] | np.ndarray, + par: Sequence[float] | np.ndarray, + plot_sum: Literal[True], + model: Model, + dim: int, +) -> np.ndarray: ... +@overload +def fit_model_mcp( + x: Sequence[float] | np.ndarray, + par: Sequence[float] | np.ndarray, + plot_sum: bool, + model: Model, + dim: int, +) -> np.ndarray | list[np.ndarray]: ... def fit_model_mcp( x: Sequence[float] | np.ndarray, par: Sequence[float] | np.ndarray, @@ -151,6 +170,20 @@ def fit_model_mcp( # +@overload +def fit_model_gir( + x: Sequence[float] | np.ndarray, + par: Sequence[float] | np.ndarray, + plot_sum: Literal[True], + *args: Any, +) -> np.ndarray: ... +@overload +def fit_model_gir( + x: Sequence[float] | np.ndarray, + par: Sequence[float] | np.ndarray, + plot_sum: bool, + *args: Any, +) -> np.ndarray | list[np.ndarray]: ... def fit_model_gir( x: Sequence[float] | np.ndarray, par: Sequence[float] | np.ndarray, @@ -202,6 +235,20 @@ def fit_model_gir( # +@overload +def fit_model_jax( + x: Sequence[float] | np.ndarray, + par: Sequence[float] | np.ndarray, + plot_sum: Literal[True], + *args: Any, +) -> np.ndarray: ... +@overload +def fit_model_jax( + x: Sequence[float] | np.ndarray, + par: Sequence[float] | np.ndarray, + plot_sum: bool, + *args: Any, +) -> np.ndarray | list[np.ndarray]: ... def fit_model_jax( x: Sequence[float] | np.ndarray, par: Sequence[float] | np.ndarray, @@ -246,6 +293,20 @@ def fit_model_jax( # +@overload +def fit_model_compare( + x: Sequence[float] | np.ndarray, + par: Sequence[float] | np.ndarray, + plot_sum: Literal[True], + *args: Any, +) -> np.ndarray: ... +@overload +def fit_model_compare( + x: Sequence[float] | np.ndarray, + par: Sequence[float] | np.ndarray, + plot_sum: bool, + *args: Any, +) -> np.ndarray | list[np.ndarray]: ... def fit_model_compare( x: Sequence[float] | np.ndarray, par: Sequence[float] | np.ndarray, diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index 71ec7e3..192f577 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -61,7 +61,7 @@ import types import warnings from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, cast, overload # TYPE_CHECKING is False at runtime so this import is skipped during execution. # Exists only for type checkers (mypy/pyright) to resolve types.ModuleType annotations. @@ -1846,6 +1846,32 @@ def set_active_model(self, model_info: ModelRef) -> None: self.model_active = self.select_model(model_info) # + # the overloads map model_type to the concrete subclass returned at + # runtime, so e.g. Dynamics-only methods type-check at call sites + @overload + def load_model( + self, + model_yaml: PathLike, + model_info: str | list[str], + par_name: str = ..., + model_type: Literal["energy"] = ..., + ) -> mcp.Model: ... + @overload + def load_model( + self, + model_yaml: PathLike, + model_info: str | list[str], + par_name: str = ..., + model_type: Literal["dynamics"] = ..., + ) -> mcp.Dynamics: ... + @overload + def load_model( + self, + model_yaml: PathLike, + model_info: str | list[str], + par_name: str = ..., + model_type: Literal["profile"] = ..., + ) -> mcp.Profile: ... def load_model( self, model_yaml: PathLike, diff --git a/tests/test_evaluate_jax.py b/tests/test_evaluate_jax.py index fb2d4f4..d6ee2f8 100644 --- a/tests/test_evaluate_jax.py +++ b/tests/test_evaluate_jax.py @@ -184,7 +184,7 @@ def test_against_scipy(self): dx = np.linspace(-200.0, 200.0, 2001) for im in [1e-3, 1e-1, 1.0, 10.0, 100.0]: z = dx + 1j * im - got = np.asarray(_wofz(z)) + got = np.asarray(_wofz(z), dtype=np.complex128) ref = scipy_wofz(z) np.testing.assert_allclose(got.real, ref.real, rtol=1e-12, atol=1e-15) diff --git a/tests/test_export_fits_parity.py b/tests/test_export_fits_parity.py index 89dfce9..96aa138 100644 --- a/tests/test_export_fits_parity.py +++ b/tests/test_export_fits_parity.py @@ -125,7 +125,7 @@ def test_sbs_export_parity(tmp_path): # 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 legacy_fp.columns[0].startswith("Unnamed"): + if str(legacy_fp.columns[0]).startswith("Unnamed"): legacy_fp = legacy_fp.drop(columns=legacy_fp.columns[0]) new_fp = pd.read_csv(new_dir / "fit_pars.csv") assert list(legacy_fp.columns) == list(new_fp.columns), ( diff --git a/tests/test_file.py b/tests/test_file.py index 21e5d3f..3a6b4ec 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -795,12 +795,14 @@ def _call_residual(self, file, *, res_type="res"): file.t_lim if file.time is not None else [], ) args = (model, dim) - return fitlib.residual_fun( + residual = fitlib.residual_fun( model.lmfit_pars, *const, res_type=res_type, args=args, ) + assert isinstance(residual, np.ndarray) # type guard + return residual # def test_residual_no_limits_full_shape_1d(self): diff --git a/tests/test_fit_history.py b/tests/test_fit_history.py index b6184fe..3c39b0e 100644 --- a/tests/test_fit_history.py +++ b/tests/test_fit_history.py @@ -867,7 +867,7 @@ def test_sbs_long_mode_emits_per_slice_rows(self): assert len(sbs_rows) == 2 assert list(sbs_rows["slice_index"]) == [0, 1] assert sbs_rows["aic"].tolist() == [10.0, 20.0] - baseline_rows = df[df["model"] == "m_base"] + baseline_rows = df.loc[df["model"] == "m_base"] assert len(baseline_rows) == 1 assert pd.isna(baseline_rows["slice_index"].iloc[0]) diff --git a/tests/test_mcp_eval.py b/tests/test_mcp_eval.py index b27e4f6..dbb5021 100644 --- a/tests/test_mcp_eval.py +++ b/tests/test_mcp_eval.py @@ -554,6 +554,7 @@ def test_eval_expression_refs_profiled_par_per_aux(self): # t_ind=0 (before t0): dynamics=0, slope = base (-0.5) spec_early = model.create_value_1d(t_ind=0, return_1d=1) assert spec_early is not None # type guard + assert profile.value_1d is not None # type guard A1_eff_early = 20.0 + profile.value_1d expected_early = GLP(file.energy, np.mean(A1_eff_early), 85.0, 1.0, 0.3) + GLP( file.energy, np.mean(A1_eff_early * 0.5), 87.0, 1.0, 0.3 @@ -563,6 +564,7 @@ def test_eval_expression_refs_profiled_par_per_aux(self): # t_ind=15 (after t0): slope changed by dynamics spec_late = model.create_value_1d(t_ind=15, return_1d=1) assert spec_late is not None # type guard + assert profile.value_1d is not None # type guard A1_eff_late = 20.0 + profile.value_1d expected_late = GLP(file.energy, np.mean(A1_eff_late), 85.0, 1.0, 0.3) + GLP( file.energy, np.mean(A1_eff_late * 0.5), 87.0, 1.0, 0.3 diff --git a/tests/test_parameter_sweep.py b/tests/test_parameter_sweep.py index 69979e5..d3eb02d 100644 --- a/tests/test_parameter_sweep.py +++ b/tests/test_parameter_sweep.py @@ -4,6 +4,7 @@ import tempfile from pathlib import Path +from typing import cast import h5py import numpy as np @@ -396,9 +397,10 @@ def test_sweep_saves_sigma_per_config(self, tmp_path): sigma_per_config = [] with h5py.File(filepath, "r") as f: assert "sigma_data" not in f["metadata"].attrs + configs = cast("h5py.Group", f["parameter_configs"]) for config_name in ("config_000000", "config_000001"): - config_group = f["parameter_configs"][config_name] - clean = config_group["clean"][:] + config_group = cast("h5py.Group", configs[config_name]) + clean = cast("h5py.Dataset", config_group["clean"])[:] sigma_saved = config_group.attrs["sigma_data"] assert sigma_saved == pytest.approx(0.1 * np.max(np.abs(clean))) sigma_per_config.append(float(sigma_saved)) From 576e3a8d66733391dfdf308bf967ee5b50161041 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Mon, 13 Jul 2026 11:05:28 -0700 Subject: [PATCH 11/13] create dirs on write, not on path computation; rename to model_path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_model_path mkdir'd the {path_results}/{file}/{fit_type}/{model}/ tree as a side effect of computing the path, littering empty dir trees even when auto_export=False and nothing was written. The method (renamed File.model_path) now only builds the path; the write sites create their own directory with mkdir(parents=True, exist_ok=True) — img_save covers all figures (including sbs slices/), and the four CSV writers cover the rest. The auto_export=False tests now assert the results tree does not exist at all. --- TODO.md | 1 - docs/ai/check-example.md | 5 ++-- docs/api/trspecfit.rst | 2 +- pyproject.toml | 2 +- src/trspecfit/fitlib.py | 4 +++ src/trspecfit/trspecfit.py | 50 +++++++++++--------------------- src/trspecfit/utils/plot.py | 4 ++- tests/test_auto_export.py | 13 +++++---- tests/test_export_fits_parity.py | 2 +- 9 files changed, 36 insertions(+), 47 deletions(-) diff --git a/TODO.md b/TODO.md index cdc79ee..48ccf16 100644 --- a/TODO.md +++ b/TODO.md @@ -22,7 +22,6 @@ - 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` (results → DataFrame) and `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). Blocks the legacy-shim removal item in the v1.0.0 checklist, since the `_save_*_legacy` impls are the live SbS/2D plotting path. Split into (a) compute/convert, (b) explicit save/export, (c) an explicit plotting API — cf. the `_save_img_flag` helper and `FitResults.plot_residuals`. 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. -- [ ] **Decouple directory creation from path computation (`create_model_path`)**: `Project.create_model_path` ([trspecfit.py](src/trspecfit/trspecfit.py) ~L2167) `mkdir`s the `{path_results}/{file}/{fit_type}/{model}/` tree as a side effect of *computing* the path. Every fit method calls it to build the `save_path` handed to `fit_wrapper` (`fit_baseline` ~L2572, `fit_2d` ~L3983, spectrum ~L2804, sbs ~L3120), and the project plot-grid display builds image paths through it too (`fit_baselines` ~L1070, `fit_2d` ~L1432) — so empty `*_fits/` dir trees appear even when `auto_export: False` and nothing is written (observed in 21, 2026-06-27; every fitting example already does this). `fit_wrapper` only writes when `save_output=1 if auto_export else 0`. Fix: make `create_model_path` return the path *without* `mkdir`, and `mkdir`-on-write at the actual write sites (`fit_wrapper`'s save branch + the explicit `save_*`/`export_*` functions); the plot-grid readers then just build a path and `.exists()`-check. Do **not** gate on `auto_export` directly — explicit `save_fit()`/`export_fit()` legitimately need the dir and aren't `auto_export`-driven. Cosmetic only (the dirs are gitignored, no functional bug); blast radius spans baseline/spectrum/sbs/2d + savers, needs no-regression tests. Related to the plotting/saving disentanglement item above; scope: beyond the examples-upgrade branch. ## User and AI ergonomics diff --git a/docs/ai/check-example.md b/docs/ai/check-example.md index df937a9..d64f83f 100644 --- a/docs/ai/check-example.md +++ b/docs/ai/check-example.md @@ -102,9 +102,8 @@ notebook's actual topic — say so. Artifact severity: **committed** CSV/PNG/`.fit.h5` fit outputs FAIL (they pollute the repo). **Untracked/gitignored** outputs are reported INFO, not a failure — they are transient (left by a local run, or expected for the export -demos) as long as they are gitignored. Empty `*_fits/` directory trees from -`create_model_path` are the known eager-mkdir quirk (see TODO.md) and are -ignored entirely. `data/*.csv` inputs are never counted as artifacts. +demos) as long as they are gitignored. `data/*.csv` inputs are never counted +as artifacts. ## 5. One main message, why-driven narrative & roadmap-as-TOC diff --git a/docs/api/trspecfit.rst b/docs/api/trspecfit.rst index b3bc555..0a049f8 100644 --- a/docs/api/trspecfit.rst +++ b/docs/api/trspecfit.rst @@ -66,7 +66,7 @@ Utility Methods Most users won't need to call these directly. .. automethod:: trspecfit.trspecfit.File.model_list_to_name -.. automethod:: trspecfit.trspecfit.File.create_model_path +.. 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/pyproject.toml b/pyproject.toml index 291bea8..2d500c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trspecfit" -version = "0.12.9" +version = "0.12.10" authors = [ {name = "Johannes Mahl", email = "infinitymonkeyatwork@gmail.com"}, ] diff --git a/src/trspecfit/fitlib.py b/src/trspecfit/fitlib.py index e82651c..d20e1c2 100644 --- a/src/trspecfit/fitlib.py +++ b/src/trspecfit/fitlib.py @@ -952,6 +952,8 @@ def _method_kws(method: str) -> dict[str, Any]: # 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", @@ -1103,6 +1105,7 @@ def results_to_df( 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", @@ -1234,6 +1237,7 @@ def results_to_fit_2d( 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 ) diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index 192f577..032600b 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -1075,8 +1075,7 @@ def fit_baselines( images = [] for f in self.files: img_path = ( - f.create_model_path(model_name, fit_type="baseline") - / "base_fit.png" + f.model_path(model_name, fit_type="baseline") / "base_fit.png" ) if img_path.exists(): images.append(mpimg.imread(str(img_path))) @@ -1421,7 +1420,7 @@ def fit_2d( try: for f in self.files: if f.model_2d is not None: - path_2d = f.create_model_path(model_name, fit_type="2d") + path_2d = f.model_path(model_name, fit_type="2d") f._save_2d_fit_legacy(save_path=path_2d) finally: self.show_output = saved @@ -1437,8 +1436,7 @@ def fit_2d( images = [] for f in self.files: img_path = ( - f.create_model_path(model_name, fit_type="2d") - / "2D_data_fit_res.png" + f.model_path(model_name, fit_type="2d") / "2D_data_fit_res.png" ) if img_path.exists(): images.append(mpimg.imread(str(img_path))) @@ -2173,18 +2171,18 @@ def fingerprint(self) -> dict[str, Any]: ) # - def create_model_path( + def model_path( self, model_name: str, *, fit_type: Literal["baseline", "spectrum", "sbs", "2d"], - subfolders: list[str] | None = None, ) -> pathlib.Path: """ - Create directory structure for saving model fit results. + Build the path where model fit results are saved. Layout: ``{Project.path_results}/{File.name}/{fit_type}/{model_name}/``. - Creates directories if they don't exist. + Only computes the path — directories are created by the write sites + when a file is actually saved. Parameters ---------- @@ -2192,8 +2190,6 @@ def create_model_path( Name of model (must exist in self.models) fit_type : {"baseline", "spectrum", "sbs", "2d"} Fit type segment in the output path. - subfolders : list of str, default=[] - Additional subdirs to create (e.g., ['slices'] for Slice-by-Slice fits) Returns ------- @@ -2201,14 +2197,7 @@ def create_model_path( Path to model results directory """ - path_model = self.p.path_results / self.name / fit_type / model_name - path_model.mkdir(parents=True, exist_ok=True) - if subfolders is None: - subfolders = [] - for subfolder in subfolders: - (path_model / subfolder).mkdir(parents=True, exist_ok=True) - - return path_model + return self.p.path_results / self.name / fit_type / model_name # def _apply_corrections(self) -> None: @@ -2604,8 +2593,8 @@ def fit_baseline( initial_guess = ulmfit.par_extract( self.model_base.lmfit_pars, return_type="list" ) - # define (and create) path where basline fit results will be saved to - path_base_results = self.create_model_path(model_name, fit_type="baseline") + # 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 @@ -2705,6 +2694,7 @@ def _save_1d_fit(self, model: mcp.Model | None, save_path: PathLike) -> None: } for comp, arr in zip(model.components, model.component_spectra, strict=True): columns[comp.name] = np.asarray(arr) + pathlib.Path(save_path).mkdir(parents=True, exist_ok=True) pd.DataFrame(columns).to_csv( pathlib.Path(save_path) / "fit_1d.csv", index=False, @@ -2836,8 +2826,8 @@ def fit_spectrum( initial_guess = ulmfit.par_extract( self.model_spec.lmfit_pars, return_type="list" ) - # define (and create) path where spectrum fit results will be saved to - path_spec_results = self.create_model_path(model_name, fit_type="spectrum") + # 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 @@ -3152,14 +3142,8 @@ def fit_slice_by_slice( "run define_baseline() first or use seed_adapt=None." ) - # define (and create) path where SbS fit results will be saved to - path_sbs_results = self.create_model_path( - model_name, - fit_type="sbs", - subfolders=[ - "slices", - ], - ) + # define path where SbS fit results will be saved to + path_sbs_results = self.model_path(model_name, fit_type="sbs") if seed_source == "model": seed_template = ulmfit.par_extract( @@ -4018,8 +4002,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 (and create) path where 2D fit results will be saved to - path_2d_results = self.create_model_path(model_name, fit_type="2d") + # define path where 2D fit results will be saved to + 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") diff --git a/src/trspecfit/utils/plot.py b/src/trspecfit/utils/plot.py index 0916f46..9565931 100644 --- a/src/trspecfit/utils/plot.py +++ b/src/trspecfit/utils/plot.py @@ -870,7 +870,8 @@ def img_save(save_path: PathLike, dpi: int = 300) -> None: Save current matplotlib figure with sensible defaults. Wrapper around plt.savefig with tight bounding box to minimize whitespace, - small padding (0.05 inches), white background, auto edge color. + small padding (0.05 inches), white background, auto edge color. Creates + the parent directory if it does not exist. Parameters ---------- @@ -880,6 +881,7 @@ def img_save(save_path: PathLike, dpi: int = 300) -> None: Resolution in dots per inch """ + pathlib.Path(save_path).parent.mkdir(parents=True, exist_ok=True) plt.savefig( save_path, dpi=dpi, diff --git a/tests/test_auto_export.py b/tests/test_auto_export.py index cbedcfe..10f0cf1 100644 --- a/tests/test_auto_export.py +++ b/tests/test_auto_export.py @@ -106,8 +106,9 @@ def test_baseline_writes_nothing(self, tmp_path): assert len(project._fit_history) == 1 assert project._fit_history[0].fit_type == "baseline" - # No CSV / PNG hit disk (create_model_path makes empty dirs only). - assert _list_files(tmp_path / "auto") == set() + # 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): @@ -124,7 +125,7 @@ def test_2d_writes_nothing(self, tmp_path): 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 / "auto") == set() + assert not (tmp_path / "auto").exists() # @@ -150,7 +151,7 @@ def test_export_fits_writes_under_auto_export_false(self, tmp_path): file.fit_baseline(model_name="single_glp", stages=2, try_ci=0) # Auto path stayed silent. - assert _list_files(tmp_path / "auto") == set() + assert not (tmp_path / "auto").exists() # Explicit CSV/PNG export still writes. explicit_root = tmp_path / "explicit_csv" @@ -319,7 +320,7 @@ def test_sbs_displays_but_writes_nothing(self, tmp_path, monkeypatch): # Display branch ran (maps shown) but nothing hit disk. assert mock_2d.call_count == 1 - assert _list_files(tmp_path / "auto") == set() + assert not (tmp_path / "auto").exists() # def test_2d_displays_but_writes_nothing(self, tmp_path, monkeypatch): @@ -337,4 +338,4 @@ def test_2d_displays_but_writes_nothing(self, tmp_path, monkeypatch): file.fit_2d("single_glp", stages=1, try_ci=0) assert mock_2d.call_count == 1 - assert _list_files(tmp_path / "auto") == set() + assert not (tmp_path / "auto").exists() diff --git a/tests/test_export_fits_parity.py b/tests/test_export_fits_parity.py index 96aa138..eeb8872 100644 --- a/tests/test_export_fits_parity.py +++ b/tests/test_export_fits_parity.py @@ -68,7 +68,7 @@ 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 (``create_model_path`` builds paths under + 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. From 97327859b4b069518281bd86b4536ae19f60f844 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Mon, 13 Jul 2026 11:12:45 -0700 Subject: [PATCH 12/13] TODO: record scoping of the plotting/saving disentanglement item 2026-07-13 scoping session: the disentangled writers already exist in fit_io (slot-based export); the legacy path survives only for the byte-for-byte auto-export layout, so the real work is the v1.0.0 layout/API decision. A 2D-only partial hoist was considered and declined. Recorded the three load-bearing decisions in the entry. --- TODO.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 48ccf16..88894a5 100644 --- a/TODO.md +++ b/TODO.md @@ -21,7 +21,11 @@ - **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` (results → DataFrame) and `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). Blocks the legacy-shim removal item in the v1.0.0 checklist, since the `_save_*_legacy` impls are the live SbS/2D plotting path. Split into (a) compute/convert, (b) explicit save/export, (c) an explicit plotting API — cf. the `_save_img_flag` helper and `FitResults.plot_residuals`. 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. +- [ ] **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 From 6ba9b4fc4b20d75939800f6e529ae8cbe37bef03 Mon Sep 17 00:00:00 2001 From: Johannes Mahl Date: Mon, 13 Jul 2026 11:51:41 -0700 Subject: [PATCH 13/13] fix legacy 2D/SbS savers writing sidecars into a missing directory The mkdir-on-write change (0.12.10) missed the np.savetxt axis-sidecar writes in _save_2d_fit_legacy / _save_sbs_fit_legacy. File.fit_2d was unaffected (fit_wrapper's CSVs create the directory first), but Project.fit_2d's per-file save loop and save_2d_fit reach the savers with a fresh directory and raised FileNotFoundError on a clean tree (CI caught it; leftover local tests_fits/ dirs masked it). Create the directory at the top of each save_files branch, and add a fast regression test that exports through save_2d_fit into a fresh path. --- pyproject.toml | 2 +- src/trspecfit/trspecfit.py | 2 ++ tests/test_auto_export.py | 24 ++++++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2d500c4..a4ab547 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trspecfit" -version = "0.12.10" +version = "0.12.11" authors = [ {name = "Johannes Mahl", email = "infinitymonkeyatwork@gmail.com"}, ] diff --git a/src/trspecfit/trspecfit.py b/src/trspecfit/trspecfit.py index 032600b..d2c0f7d 100644 --- a/src/trspecfit/trspecfit.py +++ b/src/trspecfit/trspecfit.py @@ -3377,6 +3377,7 @@ def _save_sbs_fit_legacy( "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", @@ -4144,6 +4145,7 @@ def _save_2d_fit_legacy( "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", diff --git a/tests/test_auto_export.py b/tests/test_auto_export.py index 10f0cf1..f05b0e0 100644 --- a/tests/test_auto_export.py +++ b/tests/test_auto_export.py @@ -140,6 +140,30 @@ def test_baseline_writes_files(self, tmp_path): # 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_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.""" + + 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) + + fresh = tmp_path / "fresh_export" + with pytest.warns(DeprecationWarning): + file.save_2d_fit(fresh) + assert (fresh / "fit_2d.csv").exists() + # class TestExplicitPathsStillWrite: