From 6527122719e7674cab95ff32d60533f57b032616 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:11:51 +0200 Subject: [PATCH 1/3] fix: update Gaussian, Lorentzian, and Voigt fits to standardize amplitude definition and enhance metadata storage --- CHANGELOG.md | 4 ++++ requirements-dev.txt | 2 +- src/runtime/dlw_interactive_fit.py | 25 +++++++++++---------- src/runtime/runtime.ts | 2 +- src/runtime/workerBase.ts | 2 +- tests/python/test_interactive_fit.py | 33 ++++++++++++++++++++++++++-- 6 files changed, 51 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a8bc0f..5960757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +### Changed + +- **Gaussian, Lorentzian and Voigt interactive fits**: amplitude now consistently means signed peak height above the baseline, in the signal's Y units. Committed fit curves store versioned Sigima parameters, and both the main Pyodide runtime and computation workers require Sigima 1.1.6 or later so an older area-based model cannot be loaded silently. + ## [0.6.3] - 2026-07-18 ### Changed in 0.6.3 diff --git a/requirements-dev.txt b/requirements-dev.txt index f963bbf..7af0fc0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -20,7 +20,7 @@ pre-commit>=3.7 # Sigima (= computation engine) and its mandatory dependencies. # Pinning floors only — exact versions come from each project's pyproject. -sigima>=1.1.4 +sigima>=1.1.6 guidata>=3.13.4 numpy>=1.22 scipy>=1.10.1 diff --git a/src/runtime/dlw_interactive_fit.py b/src/runtime/dlw_interactive_fit.py index 2414d1b..5b87f82 100644 --- a/src/runtime/dlw_interactive_fit.py +++ b/src/runtime/dlw_interactive_fit.py @@ -61,6 +61,7 @@ def make_computer( # Common pretty parameter labels (Greek letters etc.). _PRETTY_PARAMS: dict[str, str] = { "amp": "A", + "amplitude": "A", "sigma": "σ", "x0": "μ", "y0": "y₀", @@ -286,10 +287,7 @@ def auto_fit_interactive( x_roi, y_roi = _roi_xy(obj) computer = kind.make_computer(x_roi, y_roi, extras) _y, params = computer.optimize_fit_with_scipy() - # Strip housekeeping keys added by ``create_params``. - clean = { - k: float(v) for k, v in params.items() if k not in {"fit_type", "residual_rms"} - } + clean = {name: float(params[name]) for name in computer.get_params_names()} x_full = np.asarray(obj.x, dtype=float) y_fit = _evaluate(kind, x_full, clean) return { @@ -315,19 +313,22 @@ def commit_interactive_fit( kind = _INTERACTIVE_FITS[fit_id] src = model.get(oid) x_full = np.asarray(src.x, dtype=float) - y_fit = _evaluate(kind, x_full, {k: float(v) for k, v in values.items()}) + float_values = {k: float(v) for k, v in values.items()} + y_fit = _evaluate(kind, x_full, float_values) dst = src.copy() dst.set_xydata(x_full, y_fit) # Title carries the fit kind and the parameter values for traceability. pretty = ", ".join(f"{_pretty_label(k)}={v:g}" for k, v in values.items()) dst.title = f"{kind.label}({pretty})" - # Store fit metadata so the Properties tab can surface it (matches the - # auto-fit convention from sigima.proc.signal.fitting). - dst.metadata["fit_params"] = { - **{k: float(v) for k, v in values.items()}, - "fit_type": fit_id.replace("_fit", ""), - "interactive": True, - } + x_roi, y_roi = _roi_xy(src) + y_fit_roi = _evaluate(kind, x_roi, float_values) + residual_rms = np.sqrt(np.mean((y_roi - y_fit_roi) ** 2)) + dst.metadata["fit_params"] = _fit.create_fit_params( + fit_id.removesuffix("_fit"), + float_values, + residual_rms=residual_rms, + interactive=True, + ) panel = model.panel("signal") group = panel.find_group_of(oid) return model.add_object("signal", dst, group_id=group.gid) diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index a842507..0c83fef 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -1107,7 +1107,7 @@ os.environ["LANGUAGE"] = ${JSON.stringify(lang)} // ``PYODIDE_VERSION`` bumps to a build with numpy>=2.1. await py.runPythonAsync(` import micropip -await micropip.install(["sigima", "guidata", "tifffile<2025"]) +await micropip.install(["sigima>=1.1.6", "guidata", "tifffile<2025"]) `); onProgress?.(t("Initialising Sigima namespace…")); diff --git a/src/runtime/workerBase.ts b/src/runtime/workerBase.ts index b22c9f7..3514207 100644 --- a/src/runtime/workerBase.ts +++ b/src/runtime/workerBase.ts @@ -92,7 +92,7 @@ os.environ["LANGUAGE"] = ${JSON.stringify(opts.lang)} // Pyodide ships numpy 1.26.4 (lift when Pyodide bumps numpy). await py.runPythonAsync(` import micropip -await micropip.install(["sigima", "guidata", "tifffile<2025"]) +await micropip.install(["sigima>=1.1.6", "guidata", "tifffile<2025"]) `); // Install Sigima's ``PlaceholderTitleFormatter`` so titles produced in diff --git a/tests/python/test_interactive_fit.py b/tests/python/test_interactive_fit.py index 771098b..d664c9b 100644 --- a/tests/python/test_interactive_fit.py +++ b/tests/python/test_interactive_fit.py @@ -19,7 +19,7 @@ def _gaussian_xy(): - """Return a clean Gaussian (amp=3, mu=0.5, sigma=0.8, y0=0.1).""" + """Return a clean Gaussian (amplitude=3, mu=0.5, sigma=0.8, y0=0.1).""" x = np.linspace(-5.0, 5.0, 200) y = 3.0 * np.exp(-((x - 0.5) ** 2) / (2.0 * 0.8**2)) + 0.1 return x, y @@ -64,6 +64,9 @@ def test_init_interactive_fit_returns_session(fit_session): assert len(session["x"]) == len(session["y"]) == 200 assert len(session["y_fit"]) == 200 assert session["params"] # at least one slider row + names = {row["name"] for row in session["params"]} + assert "amplitude" in names + assert "amp" not in names for row in session["params"]: assert set(row) >= {"name", "label", "value", "min", "max"} # The initial value always lies within the slider bounds. @@ -105,7 +108,33 @@ def test_commit_interactive_fit_adds_signal(fit_session): assert new_oid in after # The committed signal carries interactive-fit metadata. obj = bs._MODEL.get(new_oid) - assert obj.metadata["fit_params"]["interactive"] is True + fit_params = obj.metadata["fit_params"] + assert fit_params["interactive"] is True + assert fit_params["fit_params_version"] == 2 + assert fit_params["peak_parameterization"] == "height" + assert "amplitude" in fit_params + assert "amp" not in fit_params + + +def test_commit_residual_uses_fit_roi_while_curve_keeps_full_axis(fit_session): + """Committed residuals describe the ROI used by the optimiser.""" + bs, oid = fit_session + src = bs._MODEL.get(oid) + outside_roi = (src.x < -2.0) | (src.x > 3.0) + src.y[outside_roi] += 50.0 + bs.set_signal_roi(oid, [{"xmin": -2.0, "xmax": 3.0}]) + + fitted = ifit.auto_fit_interactive(oid, "gaussian_fit") + new_oid = ifit.commit_interactive_fit(oid, "gaussian_fit", fitted["values"]) + committed = bs._MODEL.get(new_oid) + fit_params = committed.metadata["fit_params"] + + assert len(committed.x) == len(src.x) + assert fit_params["residual_rms"] == pytest.approx( + fitted["residual_rms"], rel=1e-9, abs=1e-12 + ) + full_rms = np.sqrt(np.mean((src.y - committed.y) ** 2)) + assert full_rms > fit_params["residual_rms"] + 10.0 def test_evaluate_polynomial_uses_degree(fit_session): From 64e24155bc7423b3255db2769f1ba5ee44993c58 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:01:31 +0200 Subject: [PATCH 2/3] fix(interactive-fit): map piecewise exponential to its sigima fit type Stripping the _fit suffix produced an unknown 'piecewiseexponential' fit type, so committed signals could not be re-evaluated by sigima. Closes #12 Assisted-by: Claude Opus 5 --- src/runtime/dlw_interactive_fit.py | 7 ++++++- tests/python/test_interactive_fit.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/runtime/dlw_interactive_fit.py b/src/runtime/dlw_interactive_fit.py index 5b87f82..7f5b716 100644 --- a/src/runtime/dlw_interactive_fit.py +++ b/src/runtime/dlw_interactive_fit.py @@ -41,12 +41,16 @@ def __init__( computer: type[_fit.FitComputer], param_labels: dict[str, str] | None = None, needs_degree: bool = False, + fit_type: str | None = None, ) -> None: self.fit_id = fit_id self.label = label self.computer = computer self.param_labels = param_labels or {} self.needs_degree = needs_degree + # Sigima's ``FIT_TYPE_MAPPING`` key. It usually matches the fit id + # without its ``_fit`` suffix, but not always. + self.fit_type = fit_type or fit_id[: -len("_fit")] def make_computer( self, x: np.ndarray, y: np.ndarray, extras: dict[str, Any] | None @@ -109,6 +113,7 @@ def make_computer( "piecewiseexponential_fit", "Piecewise exponential fit", _fit.DoubleExponentialFitComputer, + fit_type="doubleexponential", ), } @@ -324,7 +329,7 @@ def commit_interactive_fit( y_fit_roi = _evaluate(kind, x_roi, float_values) residual_rms = np.sqrt(np.mean((y_roi - y_fit_roi) ** 2)) dst.metadata["fit_params"] = _fit.create_fit_params( - fit_id.removesuffix("_fit"), + kind.fit_type, float_values, residual_rms=residual_rms, interactive=True, diff --git a/tests/python/test_interactive_fit.py b/tests/python/test_interactive_fit.py index d664c9b..e2f0cb8 100644 --- a/tests/python/test_interactive_fit.py +++ b/tests/python/test_interactive_fit.py @@ -16,6 +16,7 @@ import dlw_interactive_fit as ifit import numpy as np import pytest +from sigima.tools.signal import fitting as _fit def _gaussian_xy(): @@ -146,3 +147,19 @@ def test_evaluate_polynomial_uses_degree(fit_session): ) assert len(y_eval) == len(session["x"]) assert all(np.isfinite(y_eval)) + + +@pytest.mark.parametrize("fit_id", sorted(ifit._INTERACTIVE_FITS)) +def test_commit_writes_a_fit_type_sigima_understands(fit_session, fit_id): + """Every interactive fit must commit metadata Sigima can re-evaluate. + + ``piecewiseexponential_fit`` used to derive its fit type by stripping the + ``_fit`` suffix, which produced an unknown ``"piecewiseexponential"`` type + and made the committed signal unreadable. + """ + bs, oid = fit_session + fitted = ifit.auto_fit_interactive(oid, fit_id) + new_oid = ifit.commit_interactive_fit(oid, fit_id, fitted["values"]) + fit_params = bs._MODEL.get(new_oid).metadata["fit_params"] + assert fit_params["fit_type"] in _fit.FIT_TYPE_MAPPING + _fit.validate_fit_params(fit_params) From 2ec8cb237ab19601ff1c01d1f2112921c1804276 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:01:39 +0200 Subject: [PATCH 3/3] chore(shims): correct the stale sigima pin comment Refs #13 Assisted-by: Claude Opus 5 --- src/runtime/shims/registry.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/runtime/shims/registry.ts b/src/runtime/shims/registry.ts index 2ded87e..9abdf10 100644 --- a/src/runtime/shims/registry.ts +++ b/src/runtime/shims/registry.ts @@ -74,10 +74,12 @@ export interface ShimDescriptor { /** * Version-resolution strategy per target package. * - * ``guidata``/``sigima`` are installed by ``micropip`` without a pin, so - * the runtime gets the latest PyPI release. ``numpy``/``scipy``/``h5py`` - * are bundled with Pyodide, so their version is fixed by the pinned - * Pyodide build and read from its ``pyodide-lock.json``. + * ``guidata`` is installed by ``micropip`` without a pin, so the runtime gets + * the latest PyPI release. ``sigima`` carries a lower-bound pin (see + * ``runtime.ts`` and ``workerBase.ts``), so the resolved version is the latest + * PyPI release satisfying it. ``numpy``/``scipy``/``h5py`` are bundled with + * Pyodide, so their version is fixed by the pinned Pyodide build and read from + * its ``pyodide-lock.json``. */ export const PACKAGE_VERSION_SOURCES: Record = { guidata: "pypi",