From 23b553222c7cf9eaa95deed9cdcc8ee2d9dad928 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Wed, 28 Oct 2026 15:49:17 +0000 Subject: [PATCH 01/32] add fit gaussian --- src/sm_bluesky/common/math/analysis.py | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/sm_bluesky/common/math/analysis.py diff --git a/src/sm_bluesky/common/math/analysis.py b/src/sm_bluesky/common/math/analysis.py new file mode 100644 index 00000000..8eb762f2 --- /dev/null +++ b/src/sm_bluesky/common/math/analysis.py @@ -0,0 +1,55 @@ +"""Data analysis and fitting routines for scan data.""" + +from collections.abc import Sequence + +import numpy as np +from lmfit.model import ModelResult +from lmfit.models import GaussianModel, LinearModel + + +def fit_gaussian( + x: Sequence[float] | np.ndarray, + y: Sequence[float] | np.ndarray, + guess: tuple | dict[str, float] | None = None, +) -> ModelResult: + """Fit a 1D Gaussian to (x, y) data. + + Model: y(x) = baseline + amplitude * exp(-(x - center)^2 / (2 * sigma^2)) + + Parameters + ---------- + x : Sequence[float] | np.ndarray + Independent variable array. + y : Sequence[float] | np.ndarray + Dependent variable array. + guess : tuple or dict, optional + Initial parameter guess. If tuple: (amplitude, center, sigma, intercept,slope). + If dict: keys {"amplitude", "center", "sigma", "baseline"}. + If None, initial guesses are estimated automatically. + + Returns + ------- + GaussianFitResult + Result containing fitted parameters, COM, FWHM, and success flag. + """ + if len(x) < 4 or len(y) < 4: + raise ValueError("4 data points are needed") + if len(x) != len(y): + raise ValueError("x, y array must be same length") + peak_0 = GaussianModel() + line = LinearModel() + model = peak_0 + line + if not guess: + params = peak_0.guess(data=y, x=x) + params.update(line.guess(data=y, x=x)) + elif isinstance(guess, dict): + params = model.make_params(**guess) + else: + amp, center, sigma, intercept = guess[:4] + slope = guess[4] if len(guess) > 4 else 0.0 + params = model.make_params( + amplitude=amp, center=center, sigma=sigma, intercept=intercept, slope=slope + ) + output = model.fit(y, params=params, x=x) + print(output.fit_report()) + return output From d22fd5f1a57266a5455001819c5b65afdc24488f Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Wed, 28 Oct 2026 15:51:00 +0000 Subject: [PATCH 02/32] add test --- tests/common/math/test_analysis.py | 99 ++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/common/math/test_analysis.py diff --git a/tests/common/math/test_analysis.py b/tests/common/math/test_analysis.py new file mode 100644 index 00000000..bb020812 --- /dev/null +++ b/tests/common/math/test_analysis.py @@ -0,0 +1,99 @@ +import numpy as np +import pytest +from lmfit.model import ModelResult + +from sm_bluesky.common.math.analysis import ( + fit_gaussian, +) + + +def test_fit_gaussian_clean_peak() -> None: + """Fit a clean synthetic Gaussian peak and verify parameter recovery.""" + true_x0 = 12.4 + true_sigma = 1.8 + true_amplitude = 150.0 + true_baseline = 10.0 + true_fwhm = 2.354820045 * true_sigma + + x = np.linspace(5, 20, 151) + y = true_baseline + (true_amplitude / (true_sigma * np.sqrt(2 * np.pi))) * np.exp( + -((x - true_x0) ** 2) / (2 * true_sigma**2) + ) + + res = fit_gaussian(x, y) + + assert isinstance(res, ModelResult) + assert res.success is True + assert res.best_values["center"] == pytest.approx(true_x0, abs=1e-2) + assert res.best_values["sigma"] == pytest.approx(true_sigma, abs=1e-2) + assert res.best_values["amplitude"] == pytest.approx(true_amplitude, abs=0.1) + assert res.params["fwhm"].value == pytest.approx(true_fwhm, abs=1e-2) + assert res.best_values["intercept"] == pytest.approx(true_baseline, abs=0.1) + + +def test_fit_gaussian_custom_guess_tuple() -> None: + """Initial guess supplied as a tuple (amplitude, center, sigma, baseline).""" + x0, sigma, amp, base = 5.0, 1.2, 80.0, 5.0 + x = np.linspace(0, 10, 101) + y = base + (amp / (sigma * np.sqrt(2 * np.pi))) * np.exp( + -((x - x0) ** 2) / (2 * sigma**2) + ) + guess = ( + 90.0, + 4.2, + 1.0, + 4.0, + 0.1, + 0.1, + ) + res = fit_gaussian(x, y, guess) + + assert res.success is True + assert res.best_values["intercept"] == pytest.approx(base, abs=1e-2) + assert res.best_values["center"] == pytest.approx(5.0, abs=1e-2) + assert res.best_values["sigma"] == pytest.approx(sigma, abs=1e-2) + + +def test_fit_gaussian_custom_guess_dict() -> None: + """Initial guess supplied as a dictionary.""" + x = np.linspace(0, 10, 101) + y = 5.0 + 100.0 * np.exp(-((x - 4.5) ** 2) / (2 * 0.9**2)) + + guess = { + "amplitude": 90.0, + "center": 4.2, + "sigma": 1.0, + "intercept": 0.1, + "slope": 0.1, + } + res = fit_gaussian(x, y, guess=guess) + + assert res.success is True + assert res.best_values["center"] == pytest.approx(4.5, abs=1e-2) + assert res.best_values["sigma"] == pytest.approx(0.9, abs=0.1) + + +def test_fit_gaussian_noisy_data() -> None: + """Fit a Gaussian with additive Gaussian noise.""" + np.random.seed(42) + x = np.linspace(0, 20, 101) + clean_y = 10.0 + 80.0 * np.exp(-((x - 10.0) ** 2) / (2 * 2.0**2)) + noise = np.random.normal(0, 1.5, size=x.shape) + y = clean_y + noise + + res = fit_gaussian(x, y) + assert res.success is True + assert res.best_values["center"] == pytest.approx(10.0, abs=0.15) + assert res.best_values["sigma"] == pytest.approx(2.0, abs=0.2) + + +def test_fit_gaussian_insufficient_points_raises() -> None: + """Fitting a 4-parameter model requires at least 4 data points.""" + with pytest.raises(ValueError, match="4 data points are needed"): + fit_gaussian([1.0, 2.0, 3.0], [10.0, 20.0, 10.0]) + + +def test_fit_gaussian_dimension_mismatch_raises() -> None: + """Mismatched x and y lengths must raise ValueError.""" + with pytest.raises(ValueError, match="x, y array must be same length"): + fit_gaussian([1.0, 2.0, 3.0, 4.0], [1.0, 2.0, 3.0, 2.0, 3.0]) From e26132eeec6c06774fd9141e06b714448d92a40f Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Wed, 28 Oct 2026 15:54:46 +0000 Subject: [PATCH 03/32] add lmfit --- pyproject.toml | 2 +- uv.lock | 267 +++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 240 insertions(+), 29 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 284cceed..6f2a7378 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ requires-python = ">=3.11" [project.optional-dependencies] testing = ["jupyter", "pyepics"] -client = ["ipython", "matplotlib", "blueapi"] +client = ["ipython", "matplotlib", "blueapi", "scipy", "lmfit"] server = ["pyserial", "zhinst-core", "blueapi"] [dependency-groups] dev = [ diff --git a/uv.lock b/uv.lock index a0d97be7..2d19d77e 100644 --- a/uv.lock +++ b/uv.lock @@ -6,16 +6,16 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.11.5' and sys_platform == 'win32'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'emscripten'", - "python_full_version < '3.11.5' and sys_platform == 'emscripten'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11.5' and sys_platform == 'win32'", + "python_full_version < '3.11.5' and sys_platform == 'emscripten'", "python_full_version < '3.11.5' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] @@ -375,6 +375,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/b0/ec211e1f7966247cb6469158d856bac9aa4c2fe13621bd6a352e4f850453/asgiref-3.13.0-py3-none-any.whl", hash = "sha256:f9596a2434512686473d50f96ff6207fbba49ae1a785dfac614df4aeb085674d", size = 27593, upload-time = "2026-10-23T08:13:13.394Z" }, ] +[[package]] +name = "asteval" +version = "1.0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/9a/e10e062c83de2219c5f9ef9d684dac39323c052de140e257afd0f60f8ca7/asteval-1.0.10.tar.gz", hash = "sha256:46a4ed13cc2e4a29a21418f89dedd57db70d98eb96db0fb4c441d4edb2f5afed", size = 59581, upload-time = "2026-08-21T21:07:06.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/16/e78d6e60ecd1b3cf446fad45985ad6cda22f46ad2cffabb644d77b317bc4/asteval-1.0.10-py3-none-any.whl", hash = "sha256:8d805fa8084aa12204715f799430f0012a0db38b42e997f9f11a9637ad59855f", size = 23466, upload-time = "2026-08-21T21:07:05.678Z" }, +] + [[package]] name = "asttokens" version = "3.0.2" @@ -1165,10 +1174,10 @@ version = "1.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.11.5' and sys_platform == 'win32'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'emscripten'", - "python_full_version < '3.11.5' and sys_platform == 'emscripten'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11.5' and sys_platform == 'win32'", + "python_full_version < '3.11.5' and sys_platform == 'emscripten'", "python_full_version < '3.11.5' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ @@ -1258,10 +1267,10 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ @@ -1681,6 +1690,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/fe/a245e1d579e7abf47208f7c1ce5684caa2cd956ede5ff646d66d0299d9e8/detect_installer-0.2.1-py3-none-any.whl", hash = "sha256:87e2ce7d05cb108b178f8e45bb80d3d88c0cb4b397983345c18ad6562e02e620", size = 5371, upload-time = "2026-09-02T14:14:38.106Z" }, ] +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + [[package]] name = "distlib" version = "0.4.4" @@ -3419,6 +3437,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/73/72553170eada174775d9a738c471c7be4ab3dc2c06368beeee89e002345c/llvmlite-0.50.0-cp315-cp315t-win_amd64.whl", hash = "sha256:4da0e8c6e6f144b433672a632f75d6b4da7bd4fdb5c3e9981d6ea6741319aeae", size = 42986722, upload-time = "2026-09-29T18:44:44.491Z" }, ] +[[package]] +name = "lmfit" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asteval" }, + { name = "dill" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "uncertainties" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/e5/a35942aed2de95e228728c34609b51fe3ec9182398eac50d288eef313aa2/lmfit-1.3.4.tar.gz", hash = "sha256:3c22c28c43f717f6c5b4a3bd81e893a2149739c26a592c046f2e33c23cfbe497", size = 630720, upload-time = "2025-07-19T20:09:01.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/7e/7b91c89a4cf0f543a83be978657afb20c86af6d725253e319589dcc4ce52/lmfit-1.3.4-py3-none-any.whl", hash = "sha256:afce1593b42324d37ae2908249b0c55445e2f4c1a0474ff706a8e2f7b5d949fa", size = 97662, upload-time = "2025-07-19T20:09:00.32Z" }, +] + [[package]] name = "locket" version = "1.0.0" @@ -4174,10 +4210,10 @@ version = "3.6.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.11.5' and sys_platform == 'win32'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'emscripten'", - "python_full_version < '3.11.5' and sys_platform == 'emscripten'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11.5' and sys_platform == 'win32'", + "python_full_version < '3.11.5' and sys_platform == 'emscripten'", "python_full_version < '3.11.5' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } @@ -4194,10 +4230,10 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/dc/76/3af777226b63a5e64a6b36b1ec5855c14e2b94a37096d4760e595fc43511/networkx-3.7.tar.gz", hash = "sha256:fd77a511bd90f39f3d016351345b52cf5319b813bdca01de3f755d3cca62e96a", size = 1866482, upload-time = "2026-09-21T16:45:16.974Z" } @@ -4355,10 +4391,10 @@ version = "2.4.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.11.5' and sys_platform == 'win32'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'emscripten'", - "python_full_version < '3.11.5' and sys_platform == 'emscripten'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11.5' and sys_platform == 'win32'", + "python_full_version < '3.11.5' and sys_platform == 'emscripten'", "python_full_version < '3.11.5' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } @@ -4445,10 +4481,10 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } @@ -5168,10 +5204,10 @@ version = "0.25.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.11.5' and sys_platform == 'win32'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'emscripten'", - "python_full_version < '3.11.5' and sys_platform == 'emscripten'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11.5' and sys_platform == 'win32'", + "python_full_version < '3.11.5' and sys_platform == 'emscripten'", "python_full_version < '3.11.5' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ @@ -5194,10 +5230,10 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ @@ -6602,6 +6638,167 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/b9/f44b139096467996b4d85589d9880fd55f0c80a204d298a1b73d0adc2654/scanspec-1.0.0-py3-none-any.whl", hash = "sha256:4ad6dccd6ac19dbd8af888c80c5db53c70d6b8aee9d26c0ca174cebd3c396730", size = 37825, upload-time = "2026-04-28T12:12:53.084Z" }, ] +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11.5' and sys_platform == 'win32'", + "python_full_version < '3.11.5' and sys_platform == 'emscripten'", + "python_full_version < '3.11.5' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" }, + { url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" }, + { url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" }, + { url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" }, + { url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" }, + { url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, + { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" }, + { url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" }, + { url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" }, + { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, +] + [[package]] name = "send2trash" version = "2.1.0" @@ -6679,7 +6876,10 @@ dependencies = [ client = [ { name = "blueapi" }, { name = "ipython" }, + { name = "lmfit" }, { name = "matplotlib" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] server = [ { name = "blueapi" }, @@ -6719,12 +6919,14 @@ requires-dist = [ { name = "dls-dodal", specifier = "==3.0.5" }, { name = "ipython", marker = "extra == 'client'" }, { name = "jupyter", marker = "extra == 'testing'" }, + { name = "lmfit", marker = "extra == 'client'" }, { name = "matplotlib", marker = "extra == 'client'" }, { name = "numpy" }, { name = "ophyd-async", extras = ["sim"] }, { name = "pyepics", marker = "extra == 'testing'" }, { name = "pyserial", marker = "extra == 'server'" }, { name = "scanspec" }, + { name = "scipy", marker = "extra == 'client'" }, { name = "zhinst-core", marker = "extra == 'server'" }, ] provides-extras = ["testing", "client", "server"] @@ -6792,10 +6994,10 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'win32'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'emscripten'", @@ -6826,10 +7028,10 @@ version = "9.0.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.11.5' and sys_platform == 'win32'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'emscripten'", - "python_full_version < '3.11.5' and sys_platform == 'emscripten'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11.5' and sys_platform == 'win32'", + "python_full_version < '3.11.5' and sys_platform == 'emscripten'", "python_full_version < '3.11.5' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ @@ -7375,6 +7577,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, ] +[[package]] +name = "uncertainties" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/0c/cb09f33b26955399c675ab378e4063ed7e48422d3d49f96219ab0be5eba9/uncertainties-3.2.3.tar.gz", hash = "sha256:76a5653e686f617a42922d546a239e9efce72e6b35411b7750a1d12dcba03031", size = 160492, upload-time = "2025-04-21T19:58:28.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/5e/f1e1dd319e35e962a4e00b33150a8868b6329cc1d19fd533436ba5488f09/uncertainties-3.2.3-py3-none-any.whl", hash = "sha256:313353900d8f88b283c9bad81e7d2b2d3d4bcc330cbace35403faaed7e78890a", size = 60118, upload-time = "2025-04-21T19:58:26.864Z" }, +] + [[package]] name = "uri-template" version = "1.3.0" @@ -7803,10 +8014,10 @@ version = "3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.11.5' and sys_platform == 'win32'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform == 'emscripten'", - "python_full_version < '3.11.5' and sys_platform == 'emscripten'", "python_full_version >= '3.11.5' and python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11.5' and sys_platform == 'win32'", + "python_full_version < '3.11.5' and sys_platform == 'emscripten'", "python_full_version < '3.11.5' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ @@ -7832,10 +8043,10 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ From fc8f673b1d68c610e6b020bee0996616c337cf25 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 29 Oct 2026 10:55:20 +0000 Subject: [PATCH 04/32] add com --- src/sm_bluesky/common/math/analysis.py | 49 ++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/src/sm_bluesky/common/math/analysis.py b/src/sm_bluesky/common/math/analysis.py index 8eb762f2..97b37731 100644 --- a/src/sm_bluesky/common/math/analysis.py +++ b/src/sm_bluesky/common/math/analysis.py @@ -7,6 +7,44 @@ from lmfit.models import GaussianModel, LinearModel +def _check_xy_size(x: Sequence[float] | np.ndarray, y: Sequence[float] | np.ndarray): + if len(x) != len(y): + raise ValueError("x, y array must be same length") + if len(x) < 4 or len(y) < 4: + raise ValueError("At least 4 data points are needed") + + +def calculate_com( + x: Sequence[float] | np.ndarray, + y: Sequence[float] | np.ndarray, + subtract_baseline: bool = True, +) -> float: + """Calculate Center of Mass (COM) of 1D data. + + Parameters + ---------- + x : Sequence[float] | np.ndarray + Independent coordinate array. + y : Sequence[float] | np.ndarray + Dependent intensity/signal array. + subtract_baseline : bool, optional + Whether to subtract minimum value from y before calculation (default True). + + Returns + ------- + float + The calculated Center of Mass along the x axis. + """ + _check_xy_size(x, y) + x_arr = np.asarray(x, dtype=float) + y_weight = y - np.min(y) if subtract_baseline else y + y_arr = np.asarray(y_weight, dtype=float) + if np.allclose(y_weight, y[0]) or np.allclose(y_weight, 0.0): + raise ValueError("Y is flat/Zero") + + return np.average(x_arr, weights=y_arr) + + def fit_gaussian( x: Sequence[float] | np.ndarray, y: Sequence[float] | np.ndarray, @@ -24,18 +62,15 @@ def fit_gaussian( Dependent variable array. guess : tuple or dict, optional Initial parameter guess. If tuple: (amplitude, center, sigma, intercept,slope). - If dict: keys {"amplitude", "center", "sigma", "baseline"}. + If dict: keys {"amplitude", "center", "sigma", , intercept, slope(optional)}. If None, initial guesses are estimated automatically. Returns ------- - GaussianFitResult + ModelResult Result containing fitted parameters, COM, FWHM, and success flag. """ - if len(x) < 4 or len(y) < 4: - raise ValueError("4 data points are needed") - if len(x) != len(y): - raise ValueError("x, y array must be same length") + _check_xy_size(x, y) peak_0 = GaussianModel() line = LinearModel() model = peak_0 + line @@ -43,7 +78,7 @@ def fit_gaussian( params = peak_0.guess(data=y, x=x) params.update(line.guess(data=y, x=x)) elif isinstance(guess, dict): - params = model.make_params(**guess) + params = model.make_params(False, **guess) else: amp, center, sigma, intercept = guess[:4] slope = guess[4] if len(guess) > 4 else 0.0 From e2bbe3d659697169ed943b285d1a23df964565d2 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 29 Oct 2026 10:56:55 +0000 Subject: [PATCH 05/32] add test --- tests/common/math/test_analysis.py | 57 ++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/common/math/test_analysis.py b/tests/common/math/test_analysis.py index bb020812..4c40ae9d 100644 --- a/tests/common/math/test_analysis.py +++ b/tests/common/math/test_analysis.py @@ -3,6 +3,7 @@ from lmfit.model import ModelResult from sm_bluesky.common.math.analysis import ( + calculate_com, fit_gaussian, ) @@ -97,3 +98,59 @@ def test_fit_gaussian_dimension_mismatch_raises() -> None: """Mismatched x and y lengths must raise ValueError.""" with pytest.raises(ValueError, match="x, y array must be same length"): fit_gaussian([1.0, 2.0, 3.0, 4.0], [1.0, 2.0, 3.0, 2.0, 3.0]) + + +def test_calculate_com_symmetric_peak() -> None: + """A symmetric Gaussian peak centered at 0.0 should have COM at 0.0.""" + x = np.linspace(-5, 5, 101) + y = np.exp(-(x**2) / (2 * 1.0**2)) + com = calculate_com(x, y) + assert com == pytest.approx(0.0, abs=1e-4) + + +def test_calculate_com_shifted_peak() -> None: + """A shifted Gaussian peak centered at 7.5 should have COM near 7.5.""" + x = np.linspace(0, 20, 201) + y = 10.0 * np.exp(-((x - 7.5) ** 2) / (2 * 1.5**2)) + com = calculate_com(x, y) + assert com == pytest.approx(7.5, abs=0.05) + + +def test_calculate_com_with_constant_background() -> None: + """Baseline subtraction should correctly isolate the peak COM.""" + x = np.linspace(0, 10, 101) + y = 50.0 + 25.0 * np.exp(-((x - 4.0) ** 2) / (2 * 0.8**2)) + + com_subtracted = calculate_com(x, y, subtract_baseline=True) + assert com_subtracted == pytest.approx(4.0, abs=0.05) + + com_raw = calculate_com(x, y, subtract_baseline=False) + assert abs(com_raw - 4.0) > abs(com_subtracted - 4.0) + + +def test_calculate_com_uneven_spacing() -> None: + """COM calculation should handle non-uniformly spaced coordinates.""" + x = np.array([0.0, 1.0, 2.0, 5.0, 10.0]) + y = np.array([0.0, 10.0, 10.0, 0.0, 0.0]) + com = calculate_com(x, y, subtract_baseline=True) + assert com == pytest.approx(1.5, abs=1e-4) + + +def test_calculate_com_flat_data_raises() -> None: + """Uniform flat data has no distinct COM and should raise ValueError.""" + x = np.linspace(0, 10, 20) + y = np.full_like(x, 5.0) + with pytest.raises(ValueError, match="Y is flat/Zero"): + calculate_com(x, y) + + +def test_calculate_com_dimension_mismatch_raises() -> None: + """Mismatched x and y lengths must raise ValueError.""" + with pytest.raises(ValueError, match="x, y array must be same length"): + calculate_com([1.0, 2.0, 3.0], [1.0, 2.0]) + + +def test_calculate_com_empty_raises() -> None: + """Empty inputs must raise ValueError.""" + with pytest.raises(ValueError, match="At least 4 data points are needed"): + calculate_com([], []) From 2e3ec4cd03c5b2940ac48fc22ed92f877b5ca377 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 29 Oct 2026 12:29:24 +0000 Subject: [PATCH 06/32] add derivative --- src/sm_bluesky/common/math/analysis.py | 43 +++++++++++++++++++++++ tests/common/math/test_analysis.py | 48 +++++++++++++++++++------- 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/src/sm_bluesky/common/math/analysis.py b/src/sm_bluesky/common/math/analysis.py index 97b37731..57dcf8e4 100644 --- a/src/sm_bluesky/common/math/analysis.py +++ b/src/sm_bluesky/common/math/analysis.py @@ -1,6 +1,7 @@ """Data analysis and fitting routines for scan data.""" from collections.abc import Sequence +from dataclasses import dataclass import numpy as np from lmfit.model import ModelResult @@ -88,3 +89,45 @@ def fit_gaussian( output = model.fit(y, params=params, x=x) print(output.fit_report()) return output + + +@dataclass +class DerivativeResult: + """Result of 1st and 2nd derivative calculations.""" + + d1: np.ndarray + d2: np.ndarray + zero_crossings: list[float] + + +def calculate_derivatives( + x: Sequence[float] | np.ndarray, + y: Sequence[float] | np.ndarray, +) -> DerivativeResult: + """Calculate 1st and 2nd derivatives of (x, y). + + Parameters + ---------- + x : Sequence[float] | np.ndarray + Independent variable array. + y : Sequence[float] | np.ndarray + Dependent variable array. + + Returns + ------- + DerivativeResult + Contains d1 (dy/dx), d2 (d^2y/dx^2) and d2 zero_crossings. + """ + _check_xy_size(x, y) + d1 = np.gradient(y, x, edge_order=1) + d2 = np.gradient(d1, x, edge_order=2) + print(d1) + zero_crossings = [] + for i, value in enumerate(d1[:-1]): + if value == 0.0: + zero_crossings.append(x[i]) + elif d1[i] * d1[i + 1] < 0: + zero_crossings.append((x[i] + x[i + 1]) / 2.0) + + print(zero_crossings) + return DerivativeResult(d1=d1, d2=d2, zero_crossings=zero_crossings) diff --git a/tests/common/math/test_analysis.py b/tests/common/math/test_analysis.py index 4c40ae9d..9203f12b 100644 --- a/tests/common/math/test_analysis.py +++ b/tests/common/math/test_analysis.py @@ -3,13 +3,14 @@ from lmfit.model import ModelResult from sm_bluesky.common.math.analysis import ( + DerivativeResult, calculate_com, + calculate_derivatives, fit_gaussian, ) def test_fit_gaussian_clean_peak() -> None: - """Fit a clean synthetic Gaussian peak and verify parameter recovery.""" true_x0 = 12.4 true_sigma = 1.8 true_amplitude = 150.0 @@ -33,7 +34,6 @@ def test_fit_gaussian_clean_peak() -> None: def test_fit_gaussian_custom_guess_tuple() -> None: - """Initial guess supplied as a tuple (amplitude, center, sigma, baseline).""" x0, sigma, amp, base = 5.0, 1.2, 80.0, 5.0 x = np.linspace(0, 10, 101) y = base + (amp / (sigma * np.sqrt(2 * np.pi))) * np.exp( @@ -56,7 +56,6 @@ def test_fit_gaussian_custom_guess_tuple() -> None: def test_fit_gaussian_custom_guess_dict() -> None: - """Initial guess supplied as a dictionary.""" x = np.linspace(0, 10, 101) y = 5.0 + 100.0 * np.exp(-((x - 4.5) ** 2) / (2 * 0.9**2)) @@ -75,7 +74,6 @@ def test_fit_gaussian_custom_guess_dict() -> None: def test_fit_gaussian_noisy_data() -> None: - """Fit a Gaussian with additive Gaussian noise.""" np.random.seed(42) x = np.linspace(0, 20, 101) clean_y = 10.0 + 80.0 * np.exp(-((x - 10.0) ** 2) / (2 * 2.0**2)) @@ -89,19 +87,16 @@ def test_fit_gaussian_noisy_data() -> None: def test_fit_gaussian_insufficient_points_raises() -> None: - """Fitting a 4-parameter model requires at least 4 data points.""" with pytest.raises(ValueError, match="4 data points are needed"): fit_gaussian([1.0, 2.0, 3.0], [10.0, 20.0, 10.0]) def test_fit_gaussian_dimension_mismatch_raises() -> None: - """Mismatched x and y lengths must raise ValueError.""" with pytest.raises(ValueError, match="x, y array must be same length"): fit_gaussian([1.0, 2.0, 3.0, 4.0], [1.0, 2.0, 3.0, 2.0, 3.0]) def test_calculate_com_symmetric_peak() -> None: - """A symmetric Gaussian peak centered at 0.0 should have COM at 0.0.""" x = np.linspace(-5, 5, 101) y = np.exp(-(x**2) / (2 * 1.0**2)) com = calculate_com(x, y) @@ -109,7 +104,6 @@ def test_calculate_com_symmetric_peak() -> None: def test_calculate_com_shifted_peak() -> None: - """A shifted Gaussian peak centered at 7.5 should have COM near 7.5.""" x = np.linspace(0, 20, 201) y = 10.0 * np.exp(-((x - 7.5) ** 2) / (2 * 1.5**2)) com = calculate_com(x, y) @@ -117,7 +111,6 @@ def test_calculate_com_shifted_peak() -> None: def test_calculate_com_with_constant_background() -> None: - """Baseline subtraction should correctly isolate the peak COM.""" x = np.linspace(0, 10, 101) y = 50.0 + 25.0 * np.exp(-((x - 4.0) ** 2) / (2 * 0.8**2)) @@ -129,7 +122,6 @@ def test_calculate_com_with_constant_background() -> None: def test_calculate_com_uneven_spacing() -> None: - """COM calculation should handle non-uniformly spaced coordinates.""" x = np.array([0.0, 1.0, 2.0, 5.0, 10.0]) y = np.array([0.0, 10.0, 10.0, 0.0, 0.0]) com = calculate_com(x, y, subtract_baseline=True) @@ -137,7 +129,6 @@ def test_calculate_com_uneven_spacing() -> None: def test_calculate_com_flat_data_raises() -> None: - """Uniform flat data has no distinct COM and should raise ValueError.""" x = np.linspace(0, 10, 20) y = np.full_like(x, 5.0) with pytest.raises(ValueError, match="Y is flat/Zero"): @@ -145,12 +136,43 @@ def test_calculate_com_flat_data_raises() -> None: def test_calculate_com_dimension_mismatch_raises() -> None: - """Mismatched x and y lengths must raise ValueError.""" with pytest.raises(ValueError, match="x, y array must be same length"): calculate_com([1.0, 2.0, 3.0], [1.0, 2.0]) def test_calculate_com_empty_raises() -> None: - """Empty inputs must raise ValueError.""" with pytest.raises(ValueError, match="At least 4 data points are needed"): calculate_com([], []) + + +def test_derivatives_polynomial() -> None: + """Verify 1st and 2nd derivatives of y = x^2 (d1 = 2x, d2 = 2).""" + x = np.linspace(-5, 5, 101) + y = x**2 + + res = calculate_derivatives(x, y) + + assert isinstance(res, DerivativeResult) + assert res.d1[1:-1] == pytest.approx(2 * x[1:-1], abs=0.05) + assert res.d2[2:-2] == pytest.approx(np.full_like(x[2:-2], 2.0), abs=0.05) + assert any(zc == pytest.approx(0.0, abs=0.15) for zc in res.zero_crossings) + + +def test_derivatives_non_uniform_grid() -> None: + """Verify derivatives on a non-uniform grid for y = 3x + 1 (d1 = 3, d2 = 0).""" + x = np.array([0.0, 1.0, 3.0, 7.0, 15.0, 30.0]) + y = 3.0 * x + 1.0 + + res = calculate_derivatives(x, y) + assert res.d1 == pytest.approx(np.full_like(x, 3.0), abs=1e-3) + assert res.d2 == pytest.approx(np.zeros_like(x), abs=1e-3) + + +def test_derivatives_insufficient_points_raises() -> None: + with pytest.raises(ValueError, match="At least 4 data points are needed"): + calculate_derivatives([1.0], [5.0]) + + +def test_derivatives_dimension_mismatch_raises() -> None: + with pytest.raises(ValueError, match="x, y array must be same length"): + calculate_derivatives([1.0, 2.0, 3.0], [1.0, 2.0]) From 48c7f151223cd0688a3d486d802fc1e8d9fdae4c Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 29 Oct 2026 12:30:08 +0000 Subject: [PATCH 07/32] correct mat import --- src/sm_bluesky/common/math/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/sm_bluesky/common/math/__init__.py b/src/sm_bluesky/common/math/__init__.py index 0786c462..d50d96c2 100644 --- a/src/sm_bluesky/common/math/__init__.py +++ b/src/sm_bluesky/common/math/__init__.py @@ -1,3 +1,9 @@ +from .analysis import ( + DerivativeResult, + calculate_com, + calculate_derivatives, + fit_gaussian, +) from .conversion import cal_range_num, step_size_to_step_num, to_float from .rebinning import ( binned_axis, @@ -8,6 +14,10 @@ ) __all__ = [ + "DerivativeResult", + "calculate_com", + "calculate_derivatives", + "fit_gaussian", "cal_range_num", "edges_from_centres", "step_size_to_step_num", From 21f3fcfb6313e91cfd512ce1a40bf9463107645a Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 29 Oct 2026 12:30:43 +0000 Subject: [PATCH 08/32] update test --- tests/common/math/test_analysis.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/common/math/test_analysis.py b/tests/common/math/test_analysis.py index 9203f12b..fd3f1725 100644 --- a/tests/common/math/test_analysis.py +++ b/tests/common/math/test_analysis.py @@ -146,7 +146,6 @@ def test_calculate_com_empty_raises() -> None: def test_derivatives_polynomial() -> None: - """Verify 1st and 2nd derivatives of y = x^2 (d1 = 2x, d2 = 2).""" x = np.linspace(-5, 5, 101) y = x**2 @@ -159,7 +158,6 @@ def test_derivatives_polynomial() -> None: def test_derivatives_non_uniform_grid() -> None: - """Verify derivatives on a non-uniform grid for y = 3x + 1 (d1 = 3, d2 = 0).""" x = np.array([0.0, 1.0, 3.0, 7.0, 15.0, 30.0]) y = 3.0 * x + 1.0 From c9f75bca7666ba7c7db8f6214a17653f3e15bfeb Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 29 Oct 2026 12:32:27 +0000 Subject: [PATCH 09/32] removed debug --- src/sm_bluesky/common/math/analysis.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sm_bluesky/common/math/analysis.py b/src/sm_bluesky/common/math/analysis.py index 57dcf8e4..19d68e62 100644 --- a/src/sm_bluesky/common/math/analysis.py +++ b/src/sm_bluesky/common/math/analysis.py @@ -129,5 +129,4 @@ def calculate_derivatives( elif d1[i] * d1[i + 1] < 0: zero_crossings.append((x[i] + x[i + 1]) / 2.0) - print(zero_crossings) return DerivativeResult(d1=d1, d2=d2, zero_crossings=zero_crossings) From 623546eadec5bcdd60d2041c1fb097f124fa266b Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 29 Oct 2026 12:33:47 +0000 Subject: [PATCH 10/32] remove print --- src/sm_bluesky/common/math/analysis.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sm_bluesky/common/math/analysis.py b/src/sm_bluesky/common/math/analysis.py index 19d68e62..839804ab 100644 --- a/src/sm_bluesky/common/math/analysis.py +++ b/src/sm_bluesky/common/math/analysis.py @@ -121,7 +121,6 @@ def calculate_derivatives( _check_xy_size(x, y) d1 = np.gradient(y, x, edge_order=1) d2 = np.gradient(d1, x, edge_order=2) - print(d1) zero_crossings = [] for i, value in enumerate(d1[:-1]): if value == 0.0: From d874ebc4764f58a67dde5fe9b11051e2eff80e52 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 29 Oct 2026 12:50:03 +0000 Subject: [PATCH 11/32] add non zero crossing test --- tests/common/math/test_analysis.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/common/math/test_analysis.py b/tests/common/math/test_analysis.py index fd3f1725..dfe33eb5 100644 --- a/tests/common/math/test_analysis.py +++ b/tests/common/math/test_analysis.py @@ -145,16 +145,20 @@ def test_calculate_com_empty_raises() -> None: calculate_com([], []) -def test_derivatives_polynomial() -> None: +@pytest.mark.parametrize("zero_crossing", [0, 3, -0.55]) +def test_derivatives_polynomial(zero_crossing: float) -> None: x = np.linspace(-5, 5, 101) - y = x**2 + y = (x + zero_crossing) ** 2 res = calculate_derivatives(x, y) assert isinstance(res, DerivativeResult) - assert res.d1[1:-1] == pytest.approx(2 * x[1:-1], abs=0.05) + assert res.d1[1:-1] == pytest.approx(2 * (x[1:-1] + zero_crossing), abs=0.05) assert res.d2[2:-2] == pytest.approx(np.full_like(x[2:-2], 2.0), abs=0.05) - assert any(zc == pytest.approx(0.0, abs=0.15) for zc in res.zero_crossings) + print(res.zero_crossings) + assert any( + zc == pytest.approx(-1 * zero_crossing, abs=0.15) for zc in res.zero_crossings + ) def test_derivatives_non_uniform_grid() -> None: From 0c096ca7c5e71e4329a1e97e0adb5e6cb760bf77 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 29 Oct 2026 12:50:26 +0000 Subject: [PATCH 12/32] remove print --- tests/common/math/test_analysis.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/common/math/test_analysis.py b/tests/common/math/test_analysis.py index dfe33eb5..6bdf029a 100644 --- a/tests/common/math/test_analysis.py +++ b/tests/common/math/test_analysis.py @@ -155,7 +155,6 @@ def test_derivatives_polynomial(zero_crossing: float) -> None: assert isinstance(res, DerivativeResult) assert res.d1[1:-1] == pytest.approx(2 * (x[1:-1] + zero_crossing), abs=0.05) assert res.d2[2:-2] == pytest.approx(np.full_like(x[2:-2], 2.0), abs=0.05) - print(res.zero_crossings) assert any( zc == pytest.approx(-1 * zero_crossing, abs=0.15) for zc in res.zero_crossings ) From 94fe989d0206f50b2463489bf41115b535f81e65 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 29 Oct 2026 13:00:02 +0000 Subject: [PATCH 13/32] remove debug --- src/sm_bluesky/common/math/analysis.py | 1 - tests/common/math/test_analysis.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/sm_bluesky/common/math/analysis.py b/src/sm_bluesky/common/math/analysis.py index 839804ab..ebe4ff92 100644 --- a/src/sm_bluesky/common/math/analysis.py +++ b/src/sm_bluesky/common/math/analysis.py @@ -87,7 +87,6 @@ def fit_gaussian( amplitude=amp, center=center, sigma=sigma, intercept=intercept, slope=slope ) output = model.fit(y, params=params, x=x) - print(output.fit_report()) return output diff --git a/tests/common/math/test_analysis.py b/tests/common/math/test_analysis.py index 6bdf029a..44830de3 100644 --- a/tests/common/math/test_analysis.py +++ b/tests/common/math/test_analysis.py @@ -148,15 +148,15 @@ def test_calculate_com_empty_raises() -> None: @pytest.mark.parametrize("zero_crossing", [0, 3, -0.55]) def test_derivatives_polynomial(zero_crossing: float) -> None: x = np.linspace(-5, 5, 101) - y = (x + zero_crossing) ** 2 + y = (x - zero_crossing) ** 2 res = calculate_derivatives(x, y) assert isinstance(res, DerivativeResult) - assert res.d1[1:-1] == pytest.approx(2 * (x[1:-1] + zero_crossing), abs=0.05) + assert res.d1[1:-1] == pytest.approx(2 * (x[1:-1] - zero_crossing), abs=0.05) assert res.d2[2:-2] == pytest.approx(np.full_like(x[2:-2], 2.0), abs=0.05) assert any( - zc == pytest.approx(-1 * zero_crossing, abs=0.15) for zc in res.zero_crossings + zc == pytest.approx(zero_crossing, abs=0.15) for zc in res.zero_crossings ) From f131372ff518e519b94578f2bea7bda6c249b2a1 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 30 Oct 2026 14:19:44 +0000 Subject: [PATCH 14/32] add fitting to blueapi_session --- .../common/clients/blueapi_session.py | 60 ++++++++++ src/sm_bluesky/common/math/analysis.py | 52 ++++----- .../clients/test_blueapi_session_analysis.py | 106 ++++++++++++++++++ 3 files changed, 193 insertions(+), 25 deletions(-) create mode 100644 tests/common/clients/test_blueapi_session_analysis.py diff --git a/src/sm_bluesky/common/clients/blueapi_session.py b/src/sm_bluesky/common/clients/blueapi_session.py index 68cc625d..f3b98422 100644 --- a/src/sm_bluesky/common/clients/blueapi_session.py +++ b/src/sm_bluesky/common/clients/blueapi_session.py @@ -3,6 +3,7 @@ import time from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass from datetime import datetime from functools import wraps from pathlib import Path @@ -14,10 +15,25 @@ from blueapi.client.event_bus import AnyEvent from blueapi.config import ApplicationConfig from blueapi.core import DataEvent +from lmfit.model import ModelResult + +from sm_bluesky.common.math import ( + DerivativeResult, + calculate_com, + calculate_derivatives, + fit_gaussian, +) from .interactive_plot import DEFAULT_CMAP, InteractivePlotWindow +@dataclass +class FitResult: + gaussian: ModelResult + com: float + df: DerivativeResult + + class BlueAPISession: def __init__( self, config: ApplicationConfig, instrument_session: str | None = None @@ -26,6 +42,7 @@ def __init__( self.data: dict[str, dict[str, list[Any]]] = {} self.current_scan_id: Any | None = None self._active_plot_windows: list[InteractivePlotWindow] = [] + self.auto_analyze: bool = False click.echo("Connecting to BlueAPI service...") self.bc = BlueapiClient.from_config(self.config) self.bc.login() @@ -114,6 +131,7 @@ def start_shell(self) -> None: "scan_data": self.data, "plot": self.plot, "run": self.run, + "fit_scan": self.fit_scan, }, ) @@ -160,6 +178,34 @@ def plot(self, scan_id: Any | None = None, cmap: str = DEFAULT_CMAP) -> None: window = InteractivePlotWindow(self.data, scan_id, cmap=cmap) self._active_plot_windows.append(window) + def fit_scan( + self, + scan_id: Any | None = None, + x: str | None = None, + y: str | None = None, + guess: Any | None = None, + ) -> FitResult: + """Perform Gaussian fit and Center of Mass calculation on scan data.""" + if scan_id is None: + scan_id = next(reversed(self.data)) + if x and y: + x_data, y_data = self.data[str(scan_id)][x], self.data[str(scan_id)][y] + else: + scan = self.data[str(scan_id)] + if len(scan.keys()) > 2: + x_data, y_data = list(scan.values())[1:3] + else: + x_data, y_data = list(scan.values())[0:2] + + g_res = fit_gaussian(x_data, y_data, guess) + com_res = calculate_com(x_data, y_data) + df_res = calculate_derivatives(x_data, y_data) + click.echo( + f"Fit (scan {scan_id}): center={g_res.best_values['center']:.4f}, " + f"FWHM={g_res.params['fwhm'].value:.4f}, COM={com_res:.4f}" + ) + return FitResult(gaussian=g_res, com=com_res, df=df_res) + def print_plans(self) -> None: click.echo("\nPlans available:") for plan in self.bc.plans: @@ -240,6 +286,20 @@ def feedback(event: AnyEvent) -> None: ) if self.current_scan_id is not None: self._save_scan_to_cache(str(self.current_scan_id)) + if self.auto_analyze: + try: + fit_res = self.fit_scan(self.current_scan_id) + p_cen = fit_res.gaussian.best_values["centre"] + com = fit_res.com + zc = fit_res.df.zero_crossings + self.data[self.current_scan_id] = { + "centre": [p_cen], + "com": [com], + "zero_crossing": zc, + } + + except Exception as e: + click.echo(f"Auto-fit failed: {e}") self.current_scan_id = None case DataEvent( diff --git a/src/sm_bluesky/common/math/analysis.py b/src/sm_bluesky/common/math/analysis.py index ebe4ff92..3a8aa3c1 100644 --- a/src/sm_bluesky/common/math/analysis.py +++ b/src/sm_bluesky/common/math/analysis.py @@ -6,13 +6,26 @@ import numpy as np from lmfit.model import ModelResult from lmfit.models import GaussianModel, LinearModel +from numpy.typing import NDArray -def _check_xy_size(x: Sequence[float] | np.ndarray, y: Sequence[float] | np.ndarray): +@dataclass +class DerivativeResult: + """Result of 1st and 2nd derivative calculations.""" + + d1: NDArray[np.float64] + d2: NDArray[np.float64] + zero_crossings: list[float] + + +def _get_xy_ndarray( + x: Sequence[float] | np.ndarray, y: Sequence[float] | np.ndarray +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: if len(x) != len(y): raise ValueError("x, y array must be same length") if len(x) < 4 or len(y) < 4: raise ValueError("At least 4 data points are needed") + return np.asarray(x, dtype=float), np.asarray(y, dtype=float) def calculate_com( @@ -36,14 +49,12 @@ def calculate_com( float The calculated Center of Mass along the x axis. """ - _check_xy_size(x, y) - x_arr = np.asarray(x, dtype=float) - y_weight = y - np.min(y) if subtract_baseline else y - y_arr = np.asarray(y_weight, dtype=float) - if np.allclose(y_weight, y[0]) or np.allclose(y_weight, 0.0): + x_arr, y_arr = _get_xy_ndarray(x, y) + y_weight = y_arr - np.min(y_arr) if subtract_baseline else y_arr + if np.allclose(y_weight, y_arr[0]) or np.allclose(y_weight, 0.0): raise ValueError("Y is flat/Zero") - return np.average(x_arr, weights=y_arr) + return float(np.average(x_arr, weights=y_weight)) def fit_gaussian( @@ -71,13 +82,13 @@ def fit_gaussian( ModelResult Result containing fitted parameters, COM, FWHM, and success flag. """ - _check_xy_size(x, y) + x_arr, y_arr = _get_xy_ndarray(x, y) peak_0 = GaussianModel() line = LinearModel() model = peak_0 + line if not guess: - params = peak_0.guess(data=y, x=x) - params.update(line.guess(data=y, x=x)) + params = peak_0.guess(data=y_arr, x=x_arr) + params.update(line.guess(data=y_arr, x=x_arr)) elif isinstance(guess, dict): params = model.make_params(False, **guess) else: @@ -86,19 +97,10 @@ def fit_gaussian( params = model.make_params( amplitude=amp, center=center, sigma=sigma, intercept=intercept, slope=slope ) - output = model.fit(y, params=params, x=x) + output = model.fit(y_arr, params=params, x=x_arr) return output -@dataclass -class DerivativeResult: - """Result of 1st and 2nd derivative calculations.""" - - d1: np.ndarray - d2: np.ndarray - zero_crossings: list[float] - - def calculate_derivatives( x: Sequence[float] | np.ndarray, y: Sequence[float] | np.ndarray, @@ -117,14 +119,14 @@ def calculate_derivatives( DerivativeResult Contains d1 (dy/dx), d2 (d^2y/dx^2) and d2 zero_crossings. """ - _check_xy_size(x, y) - d1 = np.gradient(y, x, edge_order=1) - d2 = np.gradient(d1, x, edge_order=2) + x_arr, y_arr = _get_xy_ndarray(x, y) + d1 = np.gradient(y_arr, x_arr, edge_order=1) + d2 = np.gradient(d1, x_arr, edge_order=2) zero_crossings = [] for i, value in enumerate(d1[:-1]): if value == 0.0: - zero_crossings.append(x[i]) + zero_crossings.append(float(x_arr[i])) elif d1[i] * d1[i + 1] < 0: - zero_crossings.append((x[i] + x[i + 1]) / 2.0) + zero_crossings.append(float((x_arr[i] + x_arr[i + 1]) / 2.0)) return DerivativeResult(d1=d1, d2=d2, zero_crossings=zero_crossings) diff --git a/tests/common/clients/test_blueapi_session_analysis.py b/tests/common/clients/test_blueapi_session_analysis.py new file mode 100644 index 00000000..ee3ac4f4 --- /dev/null +++ b/tests/common/clients/test_blueapi_session_analysis.py @@ -0,0 +1,106 @@ +from unittest.mock import MagicMock, patch + +import pytest +from blueapi.config import ApplicationConfig + +from sm_bluesky.common.clients.blueapi_session import BlueAPISession, FitResult + + +@pytest.fixture +def mock_client_class(): + with patch("sm_bluesky.common.clients.blueapi_session.BlueapiClient") as mock_cls: + yield mock_cls + + +@pytest.fixture +def mock_secho(): + with patch("sm_bluesky.common.clients.blueapi_session.click.secho") as mock: + yield mock + + +@pytest.fixture +def mock_echo(): + with patch("sm_bluesky.common.clients.blueapi_session.click.echo") as mock: + yield mock + + +@pytest.fixture +def session( + mock_client_class: MagicMock, mock_secho: MagicMock, mock_echo: MagicMock +) -> BlueAPISession: + session = BlueAPISession(config=ApplicationConfig()) + mock_secho.reset_mock() + mock_echo.reset_mock() + return session + + +@pytest.fixture +def populated_session(session: BlueAPISession) -> BlueAPISession: + """Session populated with a synthetic 1D scan containing a clear peak.""" + x_data = [float(i) for i in range(1, 11)] + y_data = [2.0, 5.0, 15.0, 45.0, 100.0, 45.0, 15.0, 5.0, 2.0, 1.0] + session.data["scan_1"] = { + "Point number": list(range(1, 11)), + "motor1": x_data, + "det1": y_data, + } + session.current_scan_id = "scan_1" + return session + + +def test_fit_scan_defaults_to_latest_scan_and_auto_resolves_channels( + populated_session: BlueAPISession, mock_echo: MagicMock +) -> None: + """Calling fit_scan() without args fits latest scan and prints results.""" + res = populated_session.fit_scan() + + assert isinstance(res, FitResult) + assert res.gaussian.success is True + assert res.gaussian.best_values["center"] == pytest.approx(5.0, abs=0.2) + assert res.gaussian.params["height"] == pytest.approx(100.0, abs=10.0) + assert res.com == pytest.approx(5.0, abs=0.2) + assert res.df.zero_crossings == [pytest.approx(5.0, abs=0.2)] + assert any( + "Fit" in str(c) or "centre" in str(c) or "COM" in str(c) + for c in mock_echo.call_args_list + ) + + +def test_fit_scan_explicit_scan_id_and_channels( + populated_session: BlueAPISession, +) -> None: + res = populated_session.fit_scan(scan_id="scan_1", x="motor1", y="det1") + assert isinstance(res, FitResult) + assert res.gaussian.best_values["center"] == pytest.approx(5.0, abs=0.2) + + +def test_fit_scan_with_user_guess( + populated_session: BlueAPISession, +) -> None: + """User can pass an initial guess tuple or dict to fit_scan().""" + guess = (90.0, 4.8, 1.0, 1.0) + res = populated_session.fit_scan(scan_id="scan_1", guess=guess) + assert isinstance(res, FitResult) + assert isinstance(res, FitResult) + assert res.gaussian.best_values["center"] == pytest.approx(5.0, abs=0.2) + + +def test_fit_scan_missing_scan_id_raises_or_warns( + populated_session: BlueAPISession, +) -> None: + """Attempting to fit a non-existent scan ID handles cleanly.""" + with pytest.raises((ValueError, KeyError), match="non_existent_999"): + populated_session.fit_scan(scan_id="non_existent_999") + + +@patch("IPython.start_ipython") +def test_start_shell_exports_analysis_utilities( + mock_start: MagicMock, session: BlueAPISession +) -> None: + """IPython shell must expose analysis utilities in user_ns.""" + session.start_shell() + mock_start.assert_called_once() + user_ns = mock_start.call_args.kwargs["user_ns"] + + assert "fit_scan" in user_ns + assert user_ns["fit_scan"] == session.fit_scan From ba26221ff9bc6dccaeb3d8a91250d20cf08f4d3c Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 30 Oct 2026 16:11:03 +0000 Subject: [PATCH 15/32] add edges for --- .../common/clients/blueapi_session.py | 24 ++++--- src/sm_bluesky/common/math/analysis.py | 26 +++++--- .../clients/test_blueapi_session_analysis.py | 64 +++++++++++++++++-- tests/common/math/test_analysis.py | 21 ++++++ 4 files changed, 112 insertions(+), 23 deletions(-) diff --git a/src/sm_bluesky/common/clients/blueapi_session.py b/src/sm_bluesky/common/clients/blueapi_session.py index f3b98422..5f0e47af 100644 --- a/src/sm_bluesky/common/clients/blueapi_session.py +++ b/src/sm_bluesky/common/clients/blueapi_session.py @@ -42,7 +42,7 @@ def __init__( self.data: dict[str, dict[str, list[Any]]] = {} self.current_scan_id: Any | None = None self._active_plot_windows: list[InteractivePlotWindow] = [] - self.auto_analyze: bool = False + self.auto_analyse: bool = True click.echo("Connecting to BlueAPI service...") self.bc = BlueapiClient.from_config(self.config) self.bc.login() @@ -185,7 +185,7 @@ def fit_scan( y: str | None = None, guess: Any | None = None, ) -> FitResult: - """Perform Gaussian fit and Center of Mass calculation on scan data.""" + """Perform Gaussian fit and Centre of Mass calculation on scan data.""" if scan_id is None: scan_id = next(reversed(self.data)) if x and y: @@ -201,7 +201,7 @@ def fit_scan( com_res = calculate_com(x_data, y_data) df_res = calculate_derivatives(x_data, y_data) click.echo( - f"Fit (scan {scan_id}): center={g_res.best_values['center']:.4f}, " + f"Fit (scan {scan_id}): centre={g_res.best_values['center']:.4f}, " f"FWHM={g_res.params['fwhm'].value:.4f}, COM={com_res:.4f}" ) return FitResult(gaussian=g_res, com=com_res, df=df_res) @@ -286,17 +286,21 @@ def feedback(event: AnyEvent) -> None: ) if self.current_scan_id is not None: self._save_scan_to_cache(str(self.current_scan_id)) - if self.auto_analyze: + if self.auto_analyse: try: fit_res = self.fit_scan(self.current_scan_id) - p_cen = fit_res.gaussian.best_values["centre"] + p_cen = fit_res.gaussian.best_values["center"] com = fit_res.com zc = fit_res.df.zero_crossings - self.data[self.current_scan_id] = { - "centre": [p_cen], - "com": [com], - "zero_crossing": zc, - } + self.data[self.current_scan_id].update( + { + "centre": [p_cen], + "com": [com], + "zero_crossing": zc, + "max_slope_x": [fit_res.df.max_slope_x], + "min_slope_x": [fit_res.df.min_slope_x], + } + ) except Exception as e: click.echo(f"Auto-fit failed: {e}") diff --git a/src/sm_bluesky/common/math/analysis.py b/src/sm_bluesky/common/math/analysis.py index 3a8aa3c1..132af129 100644 --- a/src/sm_bluesky/common/math/analysis.py +++ b/src/sm_bluesky/common/math/analysis.py @@ -15,6 +15,8 @@ class DerivativeResult: d1: NDArray[np.float64] d2: NDArray[np.float64] + max_slope_x: float + min_slope_x: float zero_crossings: list[float] @@ -33,7 +35,7 @@ def calculate_com( y: Sequence[float] | np.ndarray, subtract_baseline: bool = True, ) -> float: - """Calculate Center of Mass (COM) of 1D data. + """Calculate Centre of Mass (COM) of 1D data. Parameters ---------- @@ -47,7 +49,7 @@ def calculate_com( Returns ------- float - The calculated Center of Mass along the x axis. + The calculated Centre of Mass along the x axis. """ x_arr, y_arr = _get_xy_ndarray(x, y) y_weight = y_arr - np.min(y_arr) if subtract_baseline else y_arr @@ -64,7 +66,7 @@ def fit_gaussian( ) -> ModelResult: """Fit a 1D Gaussian to (x, y) data. - Model: y(x) = baseline + amplitude * exp(-(x - center)^2 / (2 * sigma^2)) + Model: y(x) = baseline + amplitude * exp(-(x - centre)^2 / (2 * sigma^2)) Parameters ---------- @@ -73,8 +75,8 @@ def fit_gaussian( y : Sequence[float] | np.ndarray Dependent variable array. guess : tuple or dict, optional - Initial parameter guess. If tuple: (amplitude, center, sigma, intercept,slope). - If dict: keys {"amplitude", "center", "sigma", , intercept, slope(optional)}. + Initial parameter guess. If tuple: (amplitude, centre, sigma, intercept,slope). + If dict: keys {"amplitude", "centre", "sigma", , intercept, slope(optional)}. If None, initial guesses are estimated automatically. Returns @@ -92,10 +94,10 @@ def fit_gaussian( elif isinstance(guess, dict): params = model.make_params(False, **guess) else: - amp, center, sigma, intercept = guess[:4] + amp, centre, sigma, intercept = guess[:4] slope = guess[4] if len(guess) > 4 else 0.0 params = model.make_params( - amplitude=amp, center=center, sigma=sigma, intercept=intercept, slope=slope + amplitude=amp, center=centre, sigma=sigma, intercept=intercept, slope=slope ) output = model.fit(y_arr, params=params, x=x_arr) return output @@ -122,6 +124,8 @@ def calculate_derivatives( x_arr, y_arr = _get_xy_ndarray(x, y) d1 = np.gradient(y_arr, x_arr, edge_order=1) d2 = np.gradient(d1, x_arr, edge_order=2) + max_slope_x = float(x_arr[np.argmax(d1)]) + min_slope_x = float(x_arr[np.argmin(d1)]) zero_crossings = [] for i, value in enumerate(d1[:-1]): if value == 0.0: @@ -129,4 +133,10 @@ def calculate_derivatives( elif d1[i] * d1[i + 1] < 0: zero_crossings.append(float((x_arr[i] + x_arr[i + 1]) / 2.0)) - return DerivativeResult(d1=d1, d2=d2, zero_crossings=zero_crossings) + return DerivativeResult( + d1=d1, + d2=d2, + max_slope_x=max_slope_x, + min_slope_x=min_slope_x, + zero_crossings=zero_crossings, + ) diff --git a/tests/common/clients/test_blueapi_session_analysis.py b/tests/common/clients/test_blueapi_session_analysis.py index ee3ac4f4..c6c9c7ac 100644 --- a/tests/common/clients/test_blueapi_session_analysis.py +++ b/tests/common/clients/test_blueapi_session_analysis.py @@ -2,6 +2,7 @@ import pytest from blueapi.config import ApplicationConfig +from blueapi.core import DataEvent from sm_bluesky.common.clients.blueapi_session import BlueAPISession, FitResult @@ -36,7 +37,6 @@ def session( @pytest.fixture def populated_session(session: BlueAPISession) -> BlueAPISession: - """Session populated with a synthetic 1D scan containing a clear peak.""" x_data = [float(i) for i in range(1, 11)] y_data = [2.0, 5.0, 15.0, 45.0, 100.0, 45.0, 15.0, 5.0, 2.0, 1.0] session.data["scan_1"] = { @@ -51,7 +51,6 @@ def populated_session(session: BlueAPISession) -> BlueAPISession: def test_fit_scan_defaults_to_latest_scan_and_auto_resolves_channels( populated_session: BlueAPISession, mock_echo: MagicMock ) -> None: - """Calling fit_scan() without args fits latest scan and prints results.""" res = populated_session.fit_scan() assert isinstance(res, FitResult) @@ -77,7 +76,6 @@ def test_fit_scan_explicit_scan_id_and_channels( def test_fit_scan_with_user_guess( populated_session: BlueAPISession, ) -> None: - """User can pass an initial guess tuple or dict to fit_scan().""" guess = (90.0, 4.8, 1.0, 1.0) res = populated_session.fit_scan(scan_id="scan_1", guess=guess) assert isinstance(res, FitResult) @@ -88,7 +86,6 @@ def test_fit_scan_with_user_guess( def test_fit_scan_missing_scan_id_raises_or_warns( populated_session: BlueAPISession, ) -> None: - """Attempting to fit a non-existent scan ID handles cleanly.""" with pytest.raises((ValueError, KeyError), match="non_existent_999"): populated_session.fit_scan(scan_id="non_existent_999") @@ -97,10 +94,67 @@ def test_fit_scan_missing_scan_id_raises_or_warns( def test_start_shell_exports_analysis_utilities( mock_start: MagicMock, session: BlueAPISession ) -> None: - """IPython shell must expose analysis utilities in user_ns.""" session.start_shell() mock_start.assert_called_once() user_ns = mock_start.call_args.kwargs["user_ns"] assert "fit_scan" in user_ns assert user_ns["fit_scan"] == session.fit_scan + + +def test_auto_analyse_disabled_by_default(session: BlueAPISession) -> None: + assert session.auto_analyse is True + + +def test_auto_analyse_triggers_on_scan_stop( + session: BlueAPISession, mock_client_class: MagicMock, mock_echo: MagicMock +) -> None: + """When auto_analyse is enabled, completing a scan triggers analysis.""" + session.auto_analyse = True + callback = mock_client_class.from_config.return_value.add_callback.call_args[0][0] + + callback( + DataEvent( + name="start", + doc={"scan_id": "auto_scan_1", "uid": "uid-1", "time": 0}, + task_id="task-1", + ) + ) + + x_vals = [float(i) for i in range(1, 10)] + y_vals = [ + 1.0, + 4.0, + 20.0, + 20.0, + 20.0, + 4.0, + 1.0, + 0.5, + 0.2, + ] + for pt, (x, y) in enumerate(zip(x_vals, y_vals, strict=False), start=1): + callback( + DataEvent( + name="event", + doc={"seq_num": pt, "data": {"motor": x, "detector": y}, "time": pt}, + task_id="task-1", + ) + ) + callback( + DataEvent( + name="stop", + doc={"exit_status": "success", "time": 10, "uid": "uid-1"}, + task_id="task-1", + ) + ) + + assert any( + "Centre" in str(c) or "COM" in str(c) or "Fit" in str(c) + for c in mock_echo.call_args_list + ) + assert session.data["auto_scan_1"]["centre"][0] == pytest.approx(4.0, rel=0.01) + assert session.data["auto_scan_1"]["com"][0] == pytest.approx(4.0, rel=0.01) + assert session.data["auto_scan_1"]["zero_crossing"] == [4.0] + assert session.data["auto_scan_1"]["max_slope_x"] == [2.0] + assert session.data["auto_scan_1"]["min_slope_x"] == [6.0] diff --git a/tests/common/math/test_analysis.py b/tests/common/math/test_analysis.py index 44830de3..e6567bd7 100644 --- a/tests/common/math/test_analysis.py +++ b/tests/common/math/test_analysis.py @@ -169,6 +169,27 @@ def test_derivatives_non_uniform_grid() -> None: assert res.d2 == pytest.approx(np.zeros_like(x), abs=1e-3) +def test_derivatives_square_wave_edges() -> None: + x = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]) + y = np.array([1.0, 4.0, 20.0, 20.0, 20.0, 4.0, 1.0, 0.5, 0.2]) + + res = calculate_derivatives(x, y) + assert res.max_slope_x == pytest.approx(2.0) + assert res.min_slope_x == pytest.approx(6.0) + + +def test_derivatives_step_function() -> None: + x = np.linspace(0, 10, 201) + y_rising = 1.0 / (1.0 + np.exp(-10.0 * (x - 4.5))) + y_falling = 1.0 - y_rising + + res_rising = calculate_derivatives(x, y_rising) + assert res_rising.max_slope_x == pytest.approx(4.5, abs=0.05) + + res_falling = calculate_derivatives(x, y_falling) + assert res_falling.min_slope_x == pytest.approx(4.5, abs=0.05) + + def test_derivatives_insufficient_points_raises() -> None: with pytest.raises(ValueError, match="At least 4 data points are needed"): calculate_derivatives([1.0], [5.0]) From d3971dcb53ed062001418c82b328d33cb0fea42e Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 30 Oct 2026 16:12:13 +0000 Subject: [PATCH 16/32] correct docstring --- src/sm_bluesky/common/math/analysis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sm_bluesky/common/math/analysis.py b/src/sm_bluesky/common/math/analysis.py index 132af129..c5c00593 100644 --- a/src/sm_bluesky/common/math/analysis.py +++ b/src/sm_bluesky/common/math/analysis.py @@ -119,7 +119,7 @@ def calculate_derivatives( Returns ------- DerivativeResult - Contains d1 (dy/dx), d2 (d^2y/dx^2) and d2 zero_crossings. + Contains d1 (dy/dx), d2 (d^2y/dx^2), edges and zero_crossings. """ x_arr, y_arr = _get_xy_ndarray(x, y) d1 = np.gradient(y_arr, x_arr, edge_order=1) From 9b6d9dfe973ec3546219866f4608762a6f9b6a4c Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 30 Oct 2026 16:22:33 +0000 Subject: [PATCH 17/32] lazy fix for plotting fit result --- src/sm_bluesky/common/clients/blueapi_session.py | 3 +++ tests/common/clients/test_blueapi_session_analysis.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/sm_bluesky/common/clients/blueapi_session.py b/src/sm_bluesky/common/clients/blueapi_session.py index 5f0e47af..53192a55 100644 --- a/src/sm_bluesky/common/clients/blueapi_session.py +++ b/src/sm_bluesky/common/clients/blueapi_session.py @@ -294,6 +294,9 @@ def feedback(event: AnyEvent) -> None: zc = fit_res.df.zero_crossings self.data[self.current_scan_id].update( { + "fit": fit_res.gaussian.best_fit.tolist(), + "d1": fit_res.df.d1.tolist(), + "d2": fit_res.df.d2.tolist(), "centre": [p_cen], "com": [com], "zero_crossing": zc, diff --git a/tests/common/clients/test_blueapi_session_analysis.py b/tests/common/clients/test_blueapi_session_analysis.py index c6c9c7ac..4912c28f 100644 --- a/tests/common/clients/test_blueapi_session_analysis.py +++ b/tests/common/clients/test_blueapi_session_analysis.py @@ -158,3 +158,6 @@ def test_auto_analyse_triggers_on_scan_stop( assert session.data["auto_scan_1"]["zero_crossing"] == [4.0] assert session.data["auto_scan_1"]["max_slope_x"] == [2.0] assert session.data["auto_scan_1"]["min_slope_x"] == [6.0] + assert len(session.data["auto_scan_1"]["fit"]) == len(x_vals) + assert len(session.data["auto_scan_1"]["d1"]) == len(x_vals) + assert len(session.data["auto_scan_1"]["d2"]) == len(x_vals) From 4b80e2f5072f5a0d528085b655c8c306a8b4a5ee Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 30 Oct 2026 17:05:43 +0000 Subject: [PATCH 18/32] correct test to match real world data --- src/sm_bluesky/common/clients/blueapi_session.py | 12 +++++++----- .../common/clients/test_blueapi_session_analysis.py | 1 - 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/sm_bluesky/common/clients/blueapi_session.py b/src/sm_bluesky/common/clients/blueapi_session.py index 53192a55..36762030 100644 --- a/src/sm_bluesky/common/clients/blueapi_session.py +++ b/src/sm_bluesky/common/clients/blueapi_session.py @@ -191,12 +191,14 @@ def fit_scan( if x and y: x_data, y_data = self.data[str(scan_id)][x], self.data[str(scan_id)][y] else: - scan = self.data[str(scan_id)] - if len(scan.keys()) > 2: - x_data, y_data = list(scan.values())[1:3] + vals = list(self.data[str(scan_id)].values()) + if len(vals) >= 2: + x_data, y_data = vals[0], vals[1] + elif len(vals) == 1: + y_data = vals[0] + x_data = list(range(1, len(y_data) + 1)) else: - x_data, y_data = list(scan.values())[0:2] - + raise ValueError("Scan has no data") g_res = fit_gaussian(x_data, y_data, guess) com_res = calculate_com(x_data, y_data) df_res = calculate_derivatives(x_data, y_data) diff --git a/tests/common/clients/test_blueapi_session_analysis.py b/tests/common/clients/test_blueapi_session_analysis.py index 4912c28f..3abc9454 100644 --- a/tests/common/clients/test_blueapi_session_analysis.py +++ b/tests/common/clients/test_blueapi_session_analysis.py @@ -40,7 +40,6 @@ def populated_session(session: BlueAPISession) -> BlueAPISession: x_data = [float(i) for i in range(1, 11)] y_data = [2.0, 5.0, 15.0, 45.0, 100.0, 45.0, 15.0, 5.0, 2.0, 1.0] session.data["scan_1"] = { - "Point number": list(range(1, 11)), "motor1": x_data, "det1": y_data, } From c33c0707c8c6f6c6eacc3ecd7eca17c710b32ba3 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 30 Oct 2026 17:25:17 +0000 Subject: [PATCH 19/32] add preferred axis --- .../common/clients/blueapi_session.py | 29 +++++++++++------ .../clients/test_blueapi_session_analysis.py | 31 +++++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/sm_bluesky/common/clients/blueapi_session.py b/src/sm_bluesky/common/clients/blueapi_session.py index 36762030..475f0664 100644 --- a/src/sm_bluesky/common/clients/blueapi_session.py +++ b/src/sm_bluesky/common/clients/blueapi_session.py @@ -43,6 +43,8 @@ def __init__( self.current_scan_id: Any | None = None self._active_plot_windows: list[InteractivePlotWindow] = [] self.auto_analyse: bool = True + self.preferred_x: str | None = None + self.preferred_y: str | None = None click.echo("Connecting to BlueAPI service...") self.bc = BlueapiClient.from_config(self.config) self.bc.login() @@ -132,6 +134,7 @@ def start_shell(self) -> None: "plot": self.plot, "run": self.run, "fit_scan": self.fit_scan, + "set_fit_axes": self.set_fit_axes, }, ) @@ -178,6 +181,11 @@ def plot(self, scan_id: Any | None = None, cmap: str = DEFAULT_CMAP) -> None: window = InteractivePlotWindow(self.data, scan_id, cmap=cmap) self._active_plot_windows.append(window) + def set_fit_axes(self, x: str | None = None, y: str | None = None) -> None: + """Set preferred X and Y channel names for fit_scan and auto-analysis.""" + self.preferred_x = x + self.preferred_y = y + def fit_scan( self, scan_id: Any | None = None, @@ -188,17 +196,18 @@ def fit_scan( """Perform Gaussian fit and Centre of Mass calculation on scan data.""" if scan_id is None: scan_id = next(reversed(self.data)) - if x and y: - x_data, y_data = self.data[str(scan_id)][x], self.data[str(scan_id)][y] + scan = self.data[str(scan_id)] + x_col = x or (self.preferred_x if self.preferred_x in scan else None) + y_col = y or (self.preferred_y if self.preferred_y in scan else None) + vals = list(scan.values()) + if len(vals) >= 2: + x_data = scan[x_col] if x_col else vals[0] + y_data = scan[y_col] if y_col else vals[1] + elif len(vals) == 1: + y_data = scan[y_col] if y_col else vals[0] + x_data = scan[x_col] if x_col else list(range(1, len(y_data) + 1)) else: - vals = list(self.data[str(scan_id)].values()) - if len(vals) >= 2: - x_data, y_data = vals[0], vals[1] - elif len(vals) == 1: - y_data = vals[0] - x_data = list(range(1, len(y_data) + 1)) - else: - raise ValueError("Scan has no data") + raise ValueError("Scan has no data") g_res = fit_gaussian(x_data, y_data, guess) com_res = calculate_com(x_data, y_data) df_res = calculate_derivatives(x_data, y_data) diff --git a/tests/common/clients/test_blueapi_session_analysis.py b/tests/common/clients/test_blueapi_session_analysis.py index 3abc9454..7e3bbd13 100644 --- a/tests/common/clients/test_blueapi_session_analysis.py +++ b/tests/common/clients/test_blueapi_session_analysis.py @@ -89,6 +89,35 @@ def test_fit_scan_missing_scan_id_raises_or_warns( populated_session.fit_scan(scan_id="non_existent_999") +def test_fit_scan_with_set_fit_axes(session: BlueAPISession) -> None: + x_data = [float(i) for i in range(1, 11)] + y_wrong = [1.0] * 10 + y_peak = [2.0, 5.0, 15.0, 45.0, 100.0, 45.0, 15.0, 5.0, 2.0, 1.0] + session.data["scan_multi"] = { + "motor1": x_data, + "det_noise": y_wrong, + "det_roi": y_peak, + } + session.set_fit_axes(y="det_roi") + res = session.fit_scan(scan_id="scan_multi") + assert res.gaussian.best_values["center"] == pytest.approx(5.0, abs=0.2) + + +def test_fit_scan_single_channel_falls_back_to_point_number( + session: BlueAPISession, +) -> None: + y_peak = [2.0, 5.0, 15.0, 45.0, 100.0, 45.0, 15.0, 5.0, 2.0, 1.0] + session.data["scan_1ch"] = {"det1": y_peak} + res = session.fit_scan(scan_id="scan_1ch") + assert res.gaussian.best_values["center"] == pytest.approx(5.0, abs=0.2) + + +def test_fit_scan_empty_scan_raises(session: BlueAPISession) -> None: + session.data["scan_empty"] = {} + with pytest.raises(ValueError, match="Scan has no data"): + session.fit_scan(scan_id="scan_empty") + + @patch("IPython.start_ipython") def test_start_shell_exports_analysis_utilities( mock_start: MagicMock, session: BlueAPISession @@ -99,6 +128,8 @@ def test_start_shell_exports_analysis_utilities( assert "fit_scan" in user_ns assert user_ns["fit_scan"] == session.fit_scan + assert "set_fit_axes" in user_ns + assert user_ns["set_fit_axes"] == session.set_fit_axes def test_auto_analyse_disabled_by_default(session: BlueAPISession) -> None: From 91519013f7a9f3233e9b53311f276fc9ee2bb01f Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 30 Oct 2026 17:42:01 +0000 Subject: [PATCH 20/32] add test and fixes bugs --- .../common/clients/blueapi_session.py | 2 +- .../common/clients/interactive_plot.py | 19 +++++++++++-- tests/common/clients/test_interactive_plot.py | 28 +++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/sm_bluesky/common/clients/blueapi_session.py b/src/sm_bluesky/common/clients/blueapi_session.py index 475f0664..81514912 100644 --- a/src/sm_bluesky/common/clients/blueapi_session.py +++ b/src/sm_bluesky/common/clients/blueapi_session.py @@ -296,7 +296,6 @@ def feedback(event: AnyEvent) -> None: f"uid: {uid}): {status}" ) if self.current_scan_id is not None: - self._save_scan_to_cache(str(self.current_scan_id)) if self.auto_analyse: try: fit_res = self.fit_scan(self.current_scan_id) @@ -318,6 +317,7 @@ def feedback(event: AnyEvent) -> None: except Exception as e: click.echo(f"Auto-fit failed: {e}") + self._save_scan_to_cache(str(self.current_scan_id)) self.current_scan_id = None case DataEvent( diff --git a/src/sm_bluesky/common/clients/interactive_plot.py b/src/sm_bluesky/common/clients/interactive_plot.py index b2639365..6e478a8c 100644 --- a/src/sm_bluesky/common/clients/interactive_plot.py +++ b/src/sm_bluesky/common/clients/interactive_plot.py @@ -26,6 +26,14 @@ class PlotMode(StrEnum): DEFAULT_CMAP = "viridis" NO_FIELDS = "(no data)" +DERIVED_Y_FIELDS = ("fit", "d1", "d2") +SCALAR_META_FIELDS = { + "centre", + "com", + "zero_crossing", + "max_slope_x", + "min_slope_x", +} class InteractivePlotWindow: @@ -143,7 +151,10 @@ def get_active_fields(self) -> list[str]: fields = set() for s_id in self.active_scans: fields.update(self.data_source[s_id].keys()) - return sorted(fields) + fields -= SCALAR_META_FIELDS + device_fields = sorted(fields - set(DERIVED_Y_FIELDS)) + derived_fields = [f for f in DERIVED_Y_FIELDS if f in fields] + return device_fields + derived_fields def get_visible_scans(self) -> list[Any]: """Return up to 10 active and most recent scan IDs in scan order.""" @@ -172,7 +183,8 @@ def get_2d_fields(self) -> list[str]: s_id = self.get_2d_scan() if s_id is None: return [] - return list(self.data_source[s_id].keys()) + excluded = SCALAR_META_FIELDS | set(DERIVED_Y_FIELDS) + return [k for k in self.data_source[s_id].keys() if k not in excluded] def _clear_widgets(self, *names: str) -> None: """Remove and disconnect the specified Matplotlib widgets.""" @@ -252,7 +264,8 @@ def _build_controls_1d(self, old_x: str | None = None) -> None: """Build X-axis radio buttons and Y-channel checkboxes for 1D mode.""" self.ax_x.set_title("X Axis", fontsize=10, fontweight="bold") fields = self.get_active_fields() - x_options = ["Point number"] + fields + x_fields = [f for f in fields if f not in DERIVED_Y_FIELDS] + x_options = ["Point number"] + x_fields active_x_idx = 0 if old_x in x_options: diff --git a/tests/common/clients/test_interactive_plot.py b/tests/common/clients/test_interactive_plot.py index 353e7b71..e4368ccc 100644 --- a/tests/common/clients/test_interactive_plot.py +++ b/tests/common/clients/test_interactive_plot.py @@ -598,3 +598,31 @@ def test_invalid_cmap_fallback(grid_data): "Unknown colormap 'not_a_colormap', falling back to 'viridis'.", fg="yellow", ) + + +def test_analysis_fields_filtered_in_1d_and_2d(): + plt.close("all") + data = { + "scan1": { + "pgm-energy": [1.0, 2.0, 3.0, 4.0], + "det1": [10.0, 50.0, 20.0, 5.0], + "fit": [11.0, 48.0, 21.0, 6.0], + "d1": [40.0, 5.0, -22.5, -15.0], + "d2": [-35.0, -31.25, -10.0, 7.5], + "centre": [2.2], + "com": [2.3], + "zero_crossing": [2.2], + "max_slope_x": [1.0], + "min_slope_x": [3.0], + } + } + win = InteractivePlotWindow(data, initial_scan_id="scan1") + + active_fields = win.get_active_fields() + assert active_fields == ["det1", "pgm-energy", "fit", "d1", "d2"] + assert win.active_y_states["det1"] is True + assert win.active_y_states["fit"] is False + + x_labels = [t.get_text() for t in win.widgets["radio_x"].labels] + assert x_labels == ["Point number", "det1", "pgm-energy"] + assert win.get_2d_fields() == ["pgm-energy", "det1"] From 7ba21ef3d51f3fd05396a9441ba39163339c8888 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 2 Nov 2026 10:31:27 +0000 Subject: [PATCH 21/32] make fit_data store the last fit and clean up tests --- .../common/clients/blueapi_session.py | 16 +- tests/common/clients/test_blueapi_session.py | 282 ++++++++++++------ 2 files changed, 210 insertions(+), 88 deletions(-) diff --git a/src/sm_bluesky/common/clients/blueapi_session.py b/src/sm_bluesky/common/clients/blueapi_session.py index 81514912..ddfbf8f2 100644 --- a/src/sm_bluesky/common/clients/blueapi_session.py +++ b/src/sm_bluesky/common/clients/blueapi_session.py @@ -34,6 +34,14 @@ class FitResult: df: DerivativeResult +@dataclass +class LastFit: + centre: float | None = None + com: float | None = None + max_slope_x: float | None = None + min_slope_x: float | None = None + + class BlueAPISession: def __init__( self, config: ApplicationConfig, instrument_session: str | None = None @@ -45,6 +53,7 @@ def __init__( self.auto_analyse: bool = True self.preferred_x: str | None = None self.preferred_y: str | None = None + self.last_fit: LastFit = LastFit() click.echo("Connecting to BlueAPI service...") self.bc = BlueapiClient.from_config(self.config) self.bc.login() @@ -135,6 +144,7 @@ def start_shell(self) -> None: "run": self.run, "fit_scan": self.fit_scan, "set_fit_axes": self.set_fit_axes, + "last_fit": self.last_fit, }, ) @@ -215,6 +225,10 @@ def fit_scan( f"Fit (scan {scan_id}): centre={g_res.best_values['center']:.4f}, " f"FWHM={g_res.params['fwhm'].value:.4f}, COM={com_res:.4f}" ) + self.last_fit.centre = float(g_res.best_values["center"]) + self.last_fit.com = com_res + self.last_fit.max_slope_x = df_res.max_slope_x + self.last_fit.min_slope_x = df_res.min_slope_x return FitResult(gaussian=g_res, com=com_res, df=df_res) def print_plans(self) -> None: @@ -304,7 +318,7 @@ def feedback(event: AnyEvent) -> None: zc = fit_res.df.zero_crossings self.data[self.current_scan_id].update( { - "fit": fit_res.gaussian.best_fit.tolist(), + "gaussian": fit_res.gaussian.best_fit.tolist(), "d1": fit_res.df.d1.tolist(), "d2": fit_res.df.d2.tolist(), "centre": [p_cen], diff --git a/tests/common/clients/test_blueapi_session.py b/tests/common/clients/test_blueapi_session.py index 586cfa41..62750f5b 100644 --- a/tests/common/clients/test_blueapi_session.py +++ b/tests/common/clients/test_blueapi_session.py @@ -3,12 +3,17 @@ from pathlib import Path from unittest.mock import MagicMock, patch +import numpy as np import pytest from blueapi.config import ApplicationConfig from blueapi.core import DataEvent from sm_bluesky.common.cli import load_config -from sm_bluesky.common.clients.blueapi_session import BlueAPISession +from sm_bluesky.common.clients.blueapi_session import ( + BlueAPISession, + FitResult, + LastFit, +) def test_load_config_with_path(tmp_path: Path) -> None: @@ -63,6 +68,51 @@ def session( return session +@pytest.fixture +def callback(session: BlueAPISession, mock_client_class: MagicMock): + mock_client = mock_client_class.from_config.return_value + return mock_client.add_callback.call_args[0][0] + + +def _start_event( + scan_id: str | int = "scan1", + uid: str = "uid1", + time: float = 0, + task_id: str = "task1", +) -> DataEvent: + return DataEvent( + name="start", + doc={"scan_id": scan_id, "uid": uid, "time": time}, + task_id=task_id, + ) + + +def _data_event( + data: dict, + seq_num: int = 1, + time: float = 1, + task_id: str = "task1", +) -> DataEvent: + return DataEvent( + name="event", + doc={"seq_num": seq_num, "data": data, "time": time}, + task_id=task_id, + ) + + +def _stop_event( + status: str = "success", + uid: str = "uid1", + time: float = 2, + task_id: str = "task1", +) -> DataEvent: + return DataEvent( + name="stop", + doc={"exit_status": status, "time": time, "uid": uid}, + task_id=task_id, + ) + + def test_blueapi_session_initialisation_with_instrument_session( mock_echo: MagicMock, mock_secho: MagicMock, mock_client_class: MagicMock ) -> None: @@ -100,50 +150,36 @@ def test_blueapi_session_initialisation_without_instrument_session( def test_start_shell(mock_start: MagicMock, session: BlueAPISession) -> None: session.start_shell() mock_start.assert_called_once() - kwargs = mock_start.call_args.kwargs - assert "bc" in kwargs["user_ns"] - assert "pl" in kwargs["user_ns"] - assert "dev" in kwargs["user_ns"] - assert "scan_data" in kwargs["user_ns"] - assert "run" in kwargs["user_ns"] - assert kwargs["user_ns"]["bc"] is session.bc - assert kwargs["user_ns"]["run"] == session.run + assert mock_start.call_args.kwargs["user_ns"] == { + "bc": session.bc, + "pl": session.bc.plans, + "show_plan": session.print_plans, + "dev": session.bc.devices, + "show_devices": session.print_devices, + "scan_data": session.data, + "plot": session.plot, + "run": session.run, + "fit_scan": session.fit_scan, + "set_fit_axes": session.set_fit_axes, + "last_fit": session.last_fit, + } def test_callbacks_handling( - mock_echo: MagicMock, session: BlueAPISession, mock_client_class: MagicMock + mock_echo: MagicMock, session: BlueAPISession, callback ) -> None: - mock_client = mock_client_class.from_config.return_value - mock_client.add_callback.assert_called_once() - callback = mock_client.add_callback.call_args[0][0] - - start_event = DataEvent( - name="start", - doc={"scan_id": "scan1", "uid": "uid1", "time": 0}, - task_id="task1", - ) - callback(start_event) + callback(_start_event()) assert session.current_scan_id == "scan1" assert "scan1" in session.data mock_echo.assert_any_call( "1970-01-01 00:00:00 - Run started (scan_id=scan1, uid=uid1)" ) - event = DataEvent( - name="event", - doc={"seq_num": 1, "data": {"motor1": 10.5}, "time": 1}, - task_id="task1", - ) - callback(event) + callback(_data_event({"motor1": 10.5})) assert session.data["scan1"]["motor1"] == [10.5] mock_echo.assert_any_call("1970-01-01 00:00:01 - Point 1: motor1=10.5") - stop_event = DataEvent( - name="stop", - doc={"exit_status": "success", "time": 2, "uid": "uid1"}, - task_id="task1", - ) - callback(stop_event) + callback(_stop_event()) assert session.current_scan_id is None mock_echo.assert_any_call( "1970-01-01 00:00:02 - Run complete (scan_id=scan1, uid: uid1): success" @@ -151,19 +187,10 @@ def test_callbacks_handling( def test_callback_scan_id_collision( - mock_echo: MagicMock, session: BlueAPISession, mock_client_class: MagicMock + mock_echo: MagicMock, session: BlueAPISession, callback ) -> None: - mock_client = mock_client_class.from_config.return_value - callback = mock_client.add_callback.call_args[0][0] - for i, expected_id in enumerate(("scan1", "scan1_1", "scan1_2")): - callback( - DataEvent( - name="start", - doc={"scan_id": "scan1", "uid": f"uid{i}", "time": 0}, - task_id=f"task{i}", - ) - ) + callback(_start_event(scan_id="scan1", uid=f"uid{i}", task_id=f"task{i}")) assert session.current_scan_id == expected_id assert expected_id in session.data mock_echo.assert_any_call( @@ -172,18 +199,10 @@ def test_callback_scan_id_collision( def test_callback_event_without_scan_id( - mock_echo: MagicMock, session: BlueAPISession, mock_client_class: MagicMock + mock_echo: MagicMock, session: BlueAPISession, callback ) -> None: - mock_client = mock_client_class.from_config.return_value - callback = mock_client.add_callback.call_args[0][0] session.current_scan_id = None - - event = DataEvent( - name="event", - doc={"seq_num": 1, "data": {"motor1": 10.5}, "time": 1}, - task_id="task1", - ) - callback(event) + callback(_data_event({"motor1": 10.5})) assert "1970-01-01 00:00:01 - Point 1: motor1=10.5" not in [ call.args[0] for call in mock_echo.call_args_list @@ -191,19 +210,11 @@ def test_callback_event_without_scan_id( def test_dead_plot_window_removed(session: BlueAPISession) -> None: - class FakePlot: - class FakeFig: - number = 1 - - fig = FakeFig() - - class FakeDeadPlot: - class FakeFig: - number = 2 - - fig = FakeFig() - - session._active_plot_windows = [FakePlot(), FakeDeadPlot()] # type: ignore + active_win = MagicMock() + active_win.fig.number = 1 + dead_win = MagicMock() + dead_win.fig.number = 2 + session._active_plot_windows = [active_win, dead_win] with ( patch( @@ -228,31 +239,13 @@ def test_blueapi_session_scan_caching( callback = mock_client.add_callback.call_args[0][0] - callback( - DataEvent( - name="start", - doc={"scan_id": 888, "uid": "uid1", "time": 0.0}, - task_id="task1", - ) - ) + callback(_start_event(scan_id=888, time=0.0)) assert session1.data["888"] == {} - callback( - DataEvent( - name="event", - doc={"seq_num": 1, "data": {"motor": 10}, "time": 0.0}, - task_id="task1", - ) - ) + callback(_data_event({"motor": 10}, time=0.0)) assert session1.data["888"]["motor"] == [10] - callback( - DataEvent( - name="stop", - doc={"exit_status": "success", "uid": "uid1", "time": 0.0}, - task_id="task1", - ) - ) + callback(_stop_event(time=0.0)) cache_file = tmp_path / ".sm-bluesky" / "cache" / "test-session_scans.jsonl" assert cache_file.exists() assert json.loads(cache_file.read_text().strip()) == {"888": {"motor": [10]}} @@ -432,3 +425,118 @@ def test_run_script_no_active_session( f"Error: Cannot execute script {script.resolve()}: No active session found.", fg="red", ) + + +def test_set_fit_axes(session: BlueAPISession) -> None: + assert session.preferred_x is None + assert session.preferred_y is None + session.set_fit_axes("motor1", "det1") + assert session.preferred_x == "motor1" + assert session.preferred_y == "det1" + + +@pytest.fixture +def gaussian_peak() -> tuple[list[float], list[float]]: + x = np.linspace(0, 10, 21) + y = 5.0 + 50.0 * np.exp(-((x - 5.0) ** 2) / (2 * 1.0**2)) + return x.tolist(), y.tolist() + + +def test_fit_scan_two_columns_and_default_scan_id( + mock_echo: MagicMock, + session: BlueAPISession, + gaussian_peak: tuple[list[float], list[float]], +) -> None: + x, y = gaussian_peak + session.data["1"] = {"motor": x, "det": y} + initial_last_fit = session.last_fit + + res = session.fit_scan() + + assert isinstance(res, FitResult) + assert session.last_fit is initial_last_fit + assert session.last_fit.centre == pytest.approx(5.0, abs=1e-2) + assert session.last_fit.com == pytest.approx(5.0, abs=1e-2) + assert session.last_fit.max_slope_x == pytest.approx(res.df.max_slope_x) + assert session.last_fit.min_slope_x == pytest.approx(res.df.min_slope_x) + assert res.gaussian.best_values["center"] == pytest.approx(5.0, abs=1e-2) + assert res.com == pytest.approx(5.0, abs=1e-2) + assert len(res.df.d1) == len(x) + assert any("Fit (scan 1):" in call.args[0] for call in mock_echo.call_args_list) + + +def test_fit_scan_preferred_and_explicit_axes( + session: BlueAPISession, + gaussian_peak: tuple[list[float], list[float]], +) -> None: + x, y = gaussian_peak + session.data["10"] = {"other": [1.0] * len(x), "energy": x, "signal": y} + + session.set_fit_axes("energy", "signal") + res_pref = session.fit_scan("10") + assert res_pref.gaussian.best_values["center"] == pytest.approx(5.0, abs=1e-2) + + res_explicit = session.fit_scan("10", x="energy", y="signal") + assert res_explicit.gaussian.best_values["center"] == pytest.approx(5.0, abs=1e-2) + + +def test_fit_scan_single_column_uses_point_numbers( + session: BlueAPISession, + gaussian_peak: tuple[list[float], list[float]], +) -> None: + _, y = gaussian_peak + session.data["2"] = {"det": y} + + res = session.fit_scan("2") + assert isinstance(res, FitResult) + assert res.gaussian.best_values["center"] == pytest.approx(11.0, abs=1e-1) + + +def test_fit_scan_empty_raises(session: BlueAPISession) -> None: + session.data["empty"] = {} + with pytest.raises(ValueError, match="Scan has no data"): + session.fit_scan("empty") + + +def test_auto_analyse_stores_fit_and_updates_last_fit( + session: BlueAPISession, + callback, + gaussian_peak: tuple[list[float], list[float]], +) -> None: + assert session.last_fit == LastFit() + + callback(_start_event(scan_id=42, uid="uid42", task_id="task42")) + + xs, ys = gaussian_peak + for i, (xv, yv) in enumerate(zip(xs, ys, strict=True), start=1): + callback( + _data_event({"motor": xv, "det": yv}, seq_num=i, time=i, task_id="task42") + ) + + callback(_stop_event(uid="uid42", time=100, task_id="task42")) + + assert isinstance(session.last_fit, LastFit) + assert session.last_fit.centre == pytest.approx(5.0, abs=1e-2) + assert session.last_fit.com == pytest.approx(5.0, abs=1e-2) + assert session.last_fit.max_slope_x is not None + assert session.last_fit.min_slope_x is not None + + scan_data = session.data["42"] + assert "gaussian" in scan_data + assert "d1" in scan_data + assert "d2" in scan_data + assert scan_data["centre"] == [pytest.approx(5.0, abs=1e-2)] + assert scan_data["com"] == [pytest.approx(5.0, abs=1e-2)] + assert "zero_crossing" in scan_data + assert "max_slope_x" in scan_data + assert "min_slope_x" in scan_data + + +def test_auto_analyse_disabled_skips_fit(session: BlueAPISession, callback) -> None: + session.auto_analyse = False + + callback(_start_event(scan_id=43, uid="uid43", task_id="task43")) + callback(_stop_event(uid="uid43", time=1, task_id="task43")) + + assert session.last_fit == LastFit() + assert "gaussian" not in session.data["43"] From 59d96fb25cf4662317238b9e413a47521314e610 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 2 Nov 2026 10:38:02 +0000 Subject: [PATCH 22/32] add docs --- docs/tutorials/3_blueapi_session.rst | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/tutorials/3_blueapi_session.rst b/docs/tutorials/3_blueapi_session.rst index 499cdd96..cb42a783 100644 --- a/docs/tutorials/3_blueapi_session.rst +++ b/docs/tutorials/3_blueapi_session.rst @@ -57,6 +57,9 @@ Once the interactive IPython shell is running, you have access to: * ``scan_data``: A dictionary containing all collected event data from your scans. * ``plot(scan_id=None, cmap="viridis")``: Opens an interactive visualisation window. * ``run(script)``: Executes a Python script inside the active session namespace (a shortcut for ``%run -i