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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 18 additions & 12 deletions src/runtime/dlw_interactive_fit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -61,6 +65,7 @@ def make_computer(
# Common pretty parameter labels (Greek letters etc.).
_PRETTY_PARAMS: dict[str, str] = {
"amp": "A",
"amplitude": "A",
"sigma": "σ",
"x0": "μ",
"y0": "y₀",
Expand Down Expand Up @@ -108,6 +113,7 @@ def make_computer(
"piecewiseexponential_fit",
"Piecewise exponential fit",
_fit.DoubleExponentialFitComputer,
fit_type="doubleexponential",
),
}

Expand Down Expand Up @@ -286,10 +292,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 {
Expand All @@ -315,19 +318,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(
kind.fit_type,
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)
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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…"));
Expand Down
10 changes: 6 additions & 4 deletions src/runtime/shims/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, VersionSource> = {
guidata: "pypi",
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/workerBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 48 additions & 2 deletions tests/python/test_interactive_fit.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@
import dlw_interactive_fit as ifit
import numpy as np
import pytest
from sigima.tools.signal import fitting as _fit


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
Expand Down Expand Up @@ -64,6 +65,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.
Expand Down Expand Up @@ -105,7 +109,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):
Expand All @@ -117,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)
Loading