Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ This file is maintained using the shared changelog workflow in
### Fixed

- **MCMC `workers > 1`**: `lmfit.emcee(workers=N)` via `ulmfit.MC(workers=N)` previously failed with `TypeError: cannot pickle 'module' object` because the residual closure carried a live module reference. The pickleable-model work plus the `spec_lib` removal close both sources of the error; MCMC parallel sampling now works end-to-end.
- **Cross-component expressions across the pickle boundary**: `Model.__getstate__` nulled `parent_model` on every `Par`, which broke `Par._evaluate_dynamic_expression` because it resolves expression references through `parent_model.get_all_parameters()`. Any model whose expression on one Par references a `t_vary` or `p_vary` Par on a different component (e.g. roundtrip family F12) raised `NameError` after unpickling. `Model.__setstate__` now rewires the intra-Model `parent_model` back-refs (Components, Pars, and any attached `Par.t_model` / `Par.p_model` sub-Models) from `self`, so `lmfit.emcee(workers > 1)` and `fit_slice_by_slice(n_workers > 1)` work on those models too.

## [0.8.0] - 2026-04-20

Expand Down
2 changes: 0 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ Note: `fitlib.py` hardcodes `__lnsigma` value/min/max for MCMC sampling — make
- [ ] **Project-level fit backend**: `Project.fit_2d()` already supports `Project`/`File`/`Static` vary levels, but it currently evaluates through `fit_project_mcp()` and `Model.create_value_2d()` rather than the GIR scheduler/evaluator path. Decide whether to lower the multi-file residual to GIR or explicitly prefer project-managed per-file loops when we want maximum graph-IR speedups.
- [ ] **JAX backend / Jacobian follow-on**: if we revisit a JAX evaluator, analytic Jacobians, or optimizer replacement, use [docs/design/jax-planning.md](docs/design/jax-planning.md) as the roadmap for scope, sequencing, and open technical constraints.
- [ ] **MCMC multiprocessing context**: `lmfit.emcee(workers=N)` currently inherits Python's default multiprocessing start method, which triggers a Python 3.12 `fork()` deprecation warning in multithreaded test runs. Investigate whether we can supply a `spawn`-backed worker pool or otherwise steer emcee/lmfit away from raw `fork`.
- [ ] **Evaluation order correctness**: component eval order depends on coincidental list position; make it explicit. One option: build a directed acyclic graph (DAG) at model construction and topological-sort.
- [ ] **Freeze non-varying pars**: pars without time-dependence (or profile dependence) are re-evaluated at every aux-axis point; could evaluate once and reuse.

## Testing

Expand Down
58 changes: 26 additions & 32 deletions docs/design/roundtrip_test_matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ be parameterized under the same row instead of getting separate rows.
| ID | Model family | Representative fixture(s) |
| --- | --- | --- |
| `F1` | Plain energy model | `single_glp`, `glp_only` |
| `F2` | Static expressions in energy model: direct refs, fan-out, forward refs, static chains | `two_glp_expr_amplitude`, `expression_fan_out`, `energy_expression_forward_reference`, `expression_chain`, `glp_expression` |
| `F2` | Static expressions in energy model: direct refs, fan-out, forward refs | `two_glp_expr_amplitude`, `expression_fan_out`, `energy_expression_forward_reference`, `glp_expression` |
| `F3` | Top-level standard dynamics | `single_glp` + `MonoExpPos` |
| `F4` | Top-level dynamics with IRF / convolution | `single_glp` + `MonoExpPosIRF` and other lowerable IRF kernels |
| `F5` | Top-level subcycle / multi-cycle dynamics | `single_glp` + `["ModelNone", "MonoExpNeg", "MonoExpPosExpr"]`, `frequency=10` |
Expand Down Expand Up @@ -103,7 +103,7 @@ apply cleanly yet.
| --- | --- | --- |
| `PF1` Shared plain dynamics across files | `M` | Current core project roundtrip surface |
| `PF2` Project-level expressions | `M` | Includes file/project prefix rewriting and shared refs |
| `PF3` Shared dynamics with IRF | `M` | Add once project fixtures exist |
| `PF3` Shared dynamics with IRF | `M` | Covered with `BiExpProject` + `gaussCONV` |
| `PF4` Shared subcycle dynamics | `M` | Add once project fixtures exist |

Future:
Expand Down Expand Up @@ -140,18 +140,16 @@ Rationale:

### SbS worker requirements

Yes, `SbS` should eventually distinguish `W1` and `W2`, but only after
parallel SbS exists as a real API.
Yes, `SbS` should distinguish `W1` and `W2` because `n_workers=1` uses the
serial path and `n_workers>1` crosses a process boundary.

Current status:

- today `File.fit_slice_by_slice()` does not expose a worker-count API, so only
serial `SbS` roundtrips are testable
- the main `SbS` matrix runs with `n_workers=1`
- one focused `W2` test covers `F1` with `n_workers=2`

Future requirement after `n_workers` lands:
Future requirement if worker-specific risk grows:

- `W1`: one canonical `SbS` roundtrip on `F1`
- `W2`: the same canonical `SbS` roundtrip on `F1`
- `W2`: one expression/profile-sensitive `SbS` case, likely `F2` or `F6`

### Project worker requirements
Expand Down Expand Up @@ -198,35 +196,31 @@ clean matrix above is the baseline contract.
This is the current high-level state of the suite, not a substitute for the
table above.

- Covered reasonably well today:
- `F1` on `B`, `Sp`, `SbS`, and `2D` for GIR-path or compare-mode smoke
- `F3` on `2D` for GIR roundtrip and compare-mode
- `F4` on `2D` for parity / compare-mode
- `F5` on `2D` for parity / compare-mode
- `F6` on `B` for GIR roundtrip
- `F8` on `2D` for GIR roundtrip
- project-level `M` roundtrips for plain shared-dynamics fits
- Covered today:
- the full single-file clean matrix above for `M/G/C`
- `F2` variants for direct, fan-out, and forward-reference expressions
- noisy second-layer checks for `F3`, `F6`, and `F8` on the GIR path
- focused MCMC checks for `MC1`, `MC2`, expression-sensitive `MC2`, and 2D `MC2`
- focused `W2` coverage for `fit_slice_by_slice()`
- project-level `M` roundtrips for `PF1`, `PF2`, and `PF3`

- Thin or missing today:
- forced `M` roundtrip coverage for almost every family
- full workflow roundtrips for `F2`, `F5`, `F7`, `F9`, `F10`, `F11`, `F12`
- explicit `SbS` roundtrips outside the plain-energy family
- expression-heavy roundtrips through serialization-sensitive paths
- MCMC coverage beyond a simple plain-model smoke case
- any worker-mode matrix for `SbS` because parallel `SbS` does not exist yet
- project-level coverage for expression/subcycle/IRF families
- project-level `PF4` shared subcycle dynamics
- project-level `G/C` coverage, because project fitting is still MCP-only
- expression/profile-sensitive `W2` coverage for `fit_slice_by_slice()`
- MCMC assertions beyond no-crash / process-boundary coverage
- exhaustive noisy coverage, intentionally kept out of the main matrix

## Suggested implementation order

If we fill this incrementally, the highest-value order is:
The original single-file matrix is implemented. Highest-value next steps:

1. Add forced-`M` twins for the existing plain and profile roundtrips.
2. Add `F9` and `F10` because expression + varying-parameter interactions are a known bug surface.
3. Add `MC2` coverage for one expression-heavy case and one nested-model case.
4. Add one canonical `F5` subcycle roundtrip through `fit_2d`.
5. Add one canonical `F4` IRF roundtrip through `fit_2d`.
6. Add `F7`, `F11`, and `F12` as the mixed-feature stress cases.
7. Expand the separate project-level matrix, starting with project expressions.
1. Add `PF4` once a shared project-subcycle fixture exists.
2. Add a focused expression/profile-sensitive `W2` `SbS` test if process-boundary
risk shows up beyond the plain `F1` case.
3. Add lightweight recovery or constraint-preservation assertions to focused
MCMC tests when runtime allows.
4. Upgrade project-level cells from `M` to `M/G/C` if project-level GIR lands.

## Non-goals

Expand Down
8 changes: 5 additions & 3 deletions docs/design/supported_models.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ The sections above describe model semantics. The graph intermediate representati
- Project-level fitting is still wired through ``fit_project_mcp`` even when
the underlying per-file models are lowerable.

## Notes/ Future Changes
## Notes

We may choose to disallow all transitive expression chains in the future. Static transitive expression chains in energy models are currently allowed. However there is a user experience issue: a chain that works in the static case can become invalid once dynamics or a profile is added. This is surprising and hard to document/ communicate clearly.
If this change is implemented the recommended pattern would be direct fan-out expressions from the base parameter rather than multi-step chains.
Static energy-only expression chains are handled by lmfit, so they may continue to work in interpreter-backed workflows. They are not the model shape we use as
the backend-portability contract, because the same chain pattern becomes invalid
or ambiguous once dynamics or profiles enter the dependency path. The recommended
pattern is direct fan-out from base parameters instead of multi-step chains.
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "trspecfit"
version = "0.8.2"
version = "0.8.3"
authors = [
{name = "Johannes Mahl", email = "johannes.a.mahl@gmail.com"},
]
Expand Down Expand Up @@ -100,7 +100,7 @@ reportMissingImports = true
typeCheckingMode = "basic"

[tool.pytest.ini_options]
pythonpath = ["src"]
pythonpath = ["src", "tests"]
testpaths = ["tests"]
addopts = "-m 'not slow'"
markers = ["slow: long-running round-trip tests (skipped by default, use -m slow)"]
Expand Down
25 changes: 25 additions & 0 deletions src/trspecfit/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,31 @@ def __getstate__(self) -> dict[str, Any]:
state[key] = None
return state

#
def __setstate__(self, state: dict[str, Any]) -> None:
"""Pickle protocol: restore intra-Model ``parent_model`` back-refs.

``parent_file`` stays ``None`` (caller must re-attach if needed —
see ``__getstate__``). ``parent_model`` is different: the back-ref
is internal to this Model graph, since this Model owns its
Components, their Pars, and any Dynamics/Profile sub-Models
attached to those Pars. Rewire from ``self`` so expression-on-Par
evaluation paths that traverse ``Par.get_all_parameters`` keep
working post-unpickle. Sub-Models (Dynamics/Profile) inherit this
method, so their internal back-refs are restored by the same
recursion.
"""

self.__dict__.update(state)
for comp in self.components:
comp.parent_model = self
for par in comp.pars:
par.parent_model = self
if par.t_model is not None:
par.t_model.parent_model = self
if par.p_model is not None:
par.p_model.parent_model = self

@property
def plot_config(self) -> PlotConfig:
"""
Expand Down
118 changes: 118 additions & 0 deletions tests/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Shared helpers across the test suite.

Plain module-level functions, not pytest fixtures. Promote a helper here
when the same setup logic is duplicated in two or more test files.
"""

from __future__ import annotations

import matplotlib

matplotlib.use("Agg")

from typing import Any

import numpy as np

from trspecfit import Project, Simulator


#
def make_project(
*,
name: str = "test",
spec_fun_str: str = "fit_model_gir",
show_output: int = 0,
):
"""Create a Project pointing at tests/ for YAML access.

Defaults to ``show_output=0`` (silent) so test output stays clean. Pass
``show_output=1`` for tests that exercise display/plot behavior.
"""

project = Project(path="tests", name=name)
project.show_output = show_output
project.spec_fun_str = spec_fun_str
return project


#
def extract_truth_pars(model) -> dict[str, float]:
"""Return ``{name: value}`` for all non-expression parameters."""

return {
name: model.lmfit_pars[name].value
for name in model.parameter_names
if model.lmfit_pars[name].expr is None
}


#
def simulate_clean(model, *, seed: int = 42) -> np.ndarray:
"""Simulate noiseless 2D data from a truth model."""

sim = Simulator(
model=model,
detection="analog",
noise_level=0.0,
noise_type="none",
seed=seed,
)
clean, _, _ = sim.simulate_2d()
return clean


#
def simulate_noisy(model, *, noise_level: float = 0.01, seed: int = 42) -> np.ndarray:
"""Simulate Gaussian-noisy 2D data from a truth model."""

sim = Simulator(
model=model,
detection="analog",
noise_level=noise_level,
noise_type="gaussian",
seed=seed,
)
_, noisy, _ = sim.simulate_2d()
return noisy


#
def assert_recovery_exact(
truth_pars: dict[str, float],
fitted_pars: Any,
*,
rtol: float = 1e-10,
atol: float = 1e-12,
) -> None:
"""Assert exact (clean-data) parameter recovery.

``fitted_pars`` is an ``lmfit.Parameters`` object or anything indexable
by name returning an object with a ``.value`` attribute.
"""

for name, true_val in truth_pars.items():
fit_val = fitted_pars[name].value
assert np.isclose(true_val, fit_val, rtol=rtol, atol=atol), (
f"{name}: true={true_val:.6f}, fit={fit_val:.6f}"
)


#
def assert_recovery_within(
truth_pars: dict[str, float],
fitted_pars: Any,
*,
rel_tol: float = 0.05,
skip_zero_threshold: float = 1e-6,
) -> None:
"""Assert noisy-fit recovery within ``rel_tol``, skipping near-zero truth values."""

for name, true_val in truth_pars.items():
if abs(true_val) < skip_zero_threshold:
continue
fit_val = fitted_pars[name].value
rel_err = abs(fit_val - true_val) / abs(true_val)
assert rel_err < rel_tol, (
f"{name}: true={true_val:.4f}, fit={fit_val:.4f}, rel_err={rel_err:.1%}"
)
14 changes: 14 additions & 0 deletions tests/models/project_energy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ project_glp:
F: [1.0, "static", 0.75, 2.5]
m: [0.3, "static", 0, 1]

# Two-peak baseline for project_glp_expr — all vary=True so baseline
# fit can pin the static (F, m) parameters of both peaks.
project_glp_expr_base:
GLP:
A: [20, True, 5, 25]
x0: [85.0, True, 82, 88]
F: [1.0, True, 0.75, 2.5]
m: [0.3, True, 0, 1]
GLP:
A: ["GLP_01_A * 0.5"]
x0: [86.5, True, 84, 89]
F: [1.0, True, 0.75, 2.5]
m: [0.3, True, 0, 1]

# Two GLP peaks — second amplitude constrained by expression.
# Tests that expressions are rewritten with file prefixes.
project_glp_expr:
Expand Down
Empty file added tests/roundtrip/__init__.py
Empty file.
Loading