From 228c18da9f717bef7d42538f6a64933755d9d54b Mon Sep 17 00:00:00 2001 From: Antti Luomi Date: Thu, 19 Mar 2026 10:24:33 +0000 Subject: [PATCH 01/10] HDF5/SQLite test data generator --- tests/generate_test_hdf5.py | 303 ++++++++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 tests/generate_test_hdf5.py diff --git a/tests/generate_test_hdf5.py b/tests/generate_test_hdf5.py new file mode 100644 index 00000000..d5eadfc2 --- /dev/null +++ b/tests/generate_test_hdf5.py @@ -0,0 +1,303 @@ +"""Generate synthetic HDF5 files visible to the running ICON server. + +Creates HDF5 files in the configured results directory and inserts the +matching SQLite records (experiment_source, job, scan_parameter, job_run) +so the server can find the data by job ID. + +Usage:: + + uv run python tests/generate_test_hdf5.py + +The script prints the job ID for each created entry so you can navigate to +it in the UI at http://localhost:8004. + +Requirements: +- The ICON database must already exist (run the server at least once so + Alembic has applied all migrations). +- The results directory must be writable. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta + +import h5py # type: ignore +import numpy as np +import sqlalchemy +import sqlalchemy.orm + +from icon.config.config import get_config +from icon.server.data_access.db_context.sqlite import engine +from icon.server.data_access.models.enums import JobRunStatus, JobStatus +from icon.server.data_access.models.sqlite.experiment_source import ExperimentSource +from icon.server.data_access.models.sqlite.job import Job +from icon.server.data_access.models.sqlite.job_run import JobRun +from icon.server.data_access.models.sqlite.scan_parameter import ScanParameter + +# --------------------------------------------------------------------------- +# Shared experiment source — reused across all generated jobs +# --------------------------------------------------------------------------- + +_EXPERIMENT_ID = "test.SyntheticExperiment (SyntheticExperiment)" + + +def _get_or_create_experiment_source(session: sqlalchemy.orm.Session) -> ExperimentSource: + existing = session.execute( + sqlalchemy.select(ExperimentSource).where( + ExperimentSource.experiment_id == _EXPERIMENT_ID + ) + ).scalar_one_or_none() + if existing: + return existing + source = ExperimentSource(experiment_id=_EXPERIMENT_ID) + session.add(source) + session.flush() # populate source.id without committing + return source + + +def _insert_job_and_run( + session: sqlalchemy.orm.Session, + source: ExperimentSource, + scan_values: list[float], + variable_id: str, + scheduled_time: datetime, +) -> tuple[Job, JobRun]: + """Insert a Job, ScanParameter, and JobRun; return both ORM objects.""" + job = Job( + experiment_source_id=source.id, + status=JobStatus.PROCESSED, + repetitions=1, + number_of_shots=1, + ) + session.add(job) + session.flush() # populate job.id + + scan_param = ScanParameter( + job_id=job.id, + variable_id=variable_id, + scan_values=scan_values, + ) + session.add(scan_param) + + run = JobRun( + job_id=job.id, + scheduled_time=scheduled_time, + status=JobRunStatus.DONE, + ) + session.add(run) + session.flush() + + return job, run + + +def _hdf5_path(scheduled_time: datetime) -> str: + """Return the HDF5 file path for a given scheduled time. + + Must match exactly how ``get_filename_by_job_id`` builds the name: + ``f"{scheduled_time}.h5"`` + """ + results_dir = get_config().data.results_dir + return f"{results_dir}/{scheduled_time}.h5" + + +def _write_hdf5( + filepath: str, + job_id: int, + x: np.ndarray, + y: np.ndarray, + variable_id: str, + result_channel: str, +) -> None: + """Write a minimal ICON-compatible HDF5 file.""" + n = len(x) + with h5py.File(filepath, "w") as h5: + h5.attrs["number_of_data_points"] = n + h5.attrs["number_of_shots"] = 1 + h5.attrs["experiment_id"] = _EXPERIMENT_ID + h5.attrs["job_id"] = job_id + h5.attrs["repetitions"] = 1 + h5.attrs["realtime_scan"] = False + + scan_dtype = [("timestamp", "S26"), (variable_id, np.float64)] + scan_ds = h5.create_dataset( + "scan_parameters", + shape=(n, 1), + dtype=scan_dtype, + compression="gzip", + ) + for i in range(n): + scan_ds[i] = (b"2025-01-01T00:00:00.000000", x[i]) + + result_dtype = [(result_channel, np.float64)] + result_ds = h5.create_dataset( + "result_channels", + shape=(n,), + dtype=result_dtype, + compression="gzip", + ) + result_ds[result_channel] = y + result_ds.attrs["Plot window metadata"] = json.dumps([ + {"name": "Results", "index": 0, "type": "readout", + "channel_names": [result_channel]}, + ]) + + shot_group = h5.create_group("shot_channels") + shot_group.attrs["Plot window metadata"] = json.dumps([]) + vector_group = h5.create_group("vector_channels") + vector_group.attrs["Plot window metadata"] = json.dumps([]) + h5.create_group("parameters") + + +def _create_job( + x: np.ndarray, + y: np.ndarray, + variable_id: str, + result_channel: str, + scheduled_time: datetime, + label: str, +) -> int: + """Create SQLite records + HDF5 file; return the job ID.""" + with sqlalchemy.orm.Session(engine) as session: + source = _get_or_create_experiment_source(session) + job, run = _insert_job_and_run( + session, + source, + scan_values=x.tolist(), + variable_id=variable_id, + scheduled_time=scheduled_time, + ) + session.commit() + job_id = job.id + + filepath = _hdf5_path(scheduled_time) + _write_hdf5(filepath, job_id, x, y, variable_id, result_channel) + print(f"[{label}] job_id={job_id} file={filepath}") + return job_id + + +# --------------------------------------------------------------------------- +# Curve generators — one per scenario +# --------------------------------------------------------------------------- + +# Use a fixed base time and offset each scenario by a minute so filenames +# don't collide even if the script is re-run on the same second. +_BASE_TIME = datetime(2025, 6, 1, 12, 0, 0) + + +def generate_clean_lorentzian() -> int: + """Clean Lorentzian peak (y0=1, A=5, x0=150 MHz, gamma=5 MHz).""" + x = np.linspace(100, 200, 100) + y = 1.0 + 5.0 / (1 + ((x - 150.0) / 5.0) ** 2) + y += np.random.default_rng(42).normal(0, 0.05, len(x)) + return _create_job( + x, y, + variable_id="frequency", + result_channel="counts", + scheduled_time=_BASE_TIME, + label="lorentzian_clean", + ) + + +def generate_noisy_lorentzian() -> int: + """Noisy Lorentzian peak (same params, noise=1.0).""" + x = np.linspace(100, 200, 100) + y = 1.0 + 5.0 / (1 + ((x - 150.0) / 5.0) ** 2) + y += np.random.default_rng(42).normal(0, 1.0, len(x)) + return _create_job( + x, y, + variable_id="frequency", + result_channel="counts", + scheduled_time=_BASE_TIME + timedelta(minutes=1), + label="lorentzian_noisy", + ) + + +def generate_w_shape() -> int: + """W-shaped curve: two Lorentzian dips at 130 and 170 MHz.""" + x = np.linspace(100, 200, 200) + y = ( + 10.0 + - 5.0 / (1 + ((x - 130.0) / 3.0) ** 2) + - 5.0 / (1 + ((x - 170.0) / 3.0) ** 2) + ) + y += np.random.default_rng(42).normal(0, 0.1, len(x)) + return _create_job( + x, y, + variable_id="frequency", + result_channel="counts", + scheduled_time=_BASE_TIME + timedelta(minutes=2), + label="w_shape", + ) + + +def generate_gaussian() -> int: + """Gaussian peak (y0=2, A=3, x0=5, sigma=0.8).""" + x = np.linspace(0, 10, 100) + y = 2.0 + 3.0 * np.exp(-((x - 5.0) ** 2) / (2 * 0.8**2)) + y += np.random.default_rng(42).normal(0, 0.05, len(x)) + return _create_job( + x, y, + variable_id="detuning", + result_channel="fluorescence", + scheduled_time=_BASE_TIME + timedelta(minutes=3), + label="gaussian", + ) + + +def generate_poly2() -> int: + """Quadratic curve (a=2, b=-3, c=1).""" + x = np.linspace(-5, 5, 50) + y = 2.0 * x**2 - 3.0 * x + 1.0 + y += np.random.default_rng(42).normal(0, 0.5, len(x)) + return _create_job( + x, y, + variable_id="voltage", + result_channel="counts", + scheduled_time=_BASE_TIME + timedelta(minutes=4), + label="poly2", + ) + + +def generate_harmonic() -> int: + """Harmonic oscillation (y0=1, A=2, omega=4, phi=0.5).""" + x = np.linspace(0, 10, 200) + y = 1.0 + 2.0 * np.cos(4.0 * x + 0.5) + y += np.random.default_rng(42).normal(0, 0.1, len(x)) + return _create_job( + x, y, + variable_id="time", + result_channel="population", + scheduled_time=_BASE_TIME + timedelta(minutes=5), + label="harmonic", + ) + + +def generate_damped_harmonic() -> int: + """Damped harmonic (y0=1, A=2, k=-0.3, omega=4, phi=0.5).""" + x = np.linspace(0, 10, 200) + y = 1.0 + np.exp(-0.3 * x) * 2.0 * np.cos(4.0 * x + 0.5) + y += np.random.default_rng(42).normal(0, 0.05, len(x)) + return _create_job( + x, y, + variable_id="time", + result_channel="population", + scheduled_time=_BASE_TIME + timedelta(minutes=6), + label="damped_harmonic", + ) + + +if __name__ == "__main__": + print(f"Results dir : {get_config().data.results_dir}") + print(f"Database : {get_config().databases.sqlite.file}") + print() + + generate_clean_lorentzian() + generate_noisy_lorentzian() + generate_w_shape() + generate_gaussian() + generate_poly2() + generate_harmonic() + generate_damped_harmonic() + + print("\nDone. Open http://localhost:8004 and go to the Data page to see the jobs.") From 4cf61fb63b4627004ab20b40e37a56bb184ad3ce Mon Sep 17 00:00:00 2001 From: Antti Luomi Date: Thu, 19 Mar 2026 14:47:12 +0000 Subject: [PATCH 02/10] Curve fitting engine --- pyproject.toml | 1 + src/icon/server/fitting/__init__.py | 10 ++ src/icon/server/fitting/fit_runner.py | 150 +++++++++++++++++++ src/icon/server/fitting/models.py | 203 ++++++++++++++++++++++++++ 4 files changed, 364 insertions(+) create mode 100644 src/icon/server/fitting/__init__.py create mode 100644 src/icon/server/fitting/fit_runner.py create mode 100644 src/icon/server/fitting/models.py diff --git a/pyproject.toml b/pyproject.toml index e7d147d8..acccbc4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ server = [ "alembic>=1.14.0", "filelock>=3.16.1", "influxdb>=5.3.2", + "scipy>=1.14.0", "sqlalchemy>=2.0.36", "tables>=3.10.1", "h5py>=3.12.1", diff --git a/src/icon/server/fitting/__init__.py b/src/icon/server/fitting/__init__.py new file mode 100644 index 00000000..14a4531a --- /dev/null +++ b/src/icon/server/fitting/__init__.py @@ -0,0 +1,10 @@ +from icon.server.fitting.fit_runner import FitResult, run_curve_fit +from icon.server.fitting.models import FIT_MODELS, FitFunctionType, FitModel + +__all__ = [ + "FIT_MODELS", + "FitFunctionType", + "FitModel", + "FitResult", + "run_curve_fit", +] diff --git a/src/icon/server/fitting/fit_runner.py b/src/icon/server/fitting/fit_runner.py new file mode 100644 index 00000000..d50b3eb0 --- /dev/null +++ b/src/icon/server/fitting/fit_runner.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass + +import numpy as np +import numpy.typing as npt +from scipy.optimize import curve_fit # type: ignore[import-untyped] + +from icon.server.fitting.models import FIT_MODELS, FitFunctionType + +logger = logging.getLogger(__name__) + +_EXPECTED_RANGE_LEN = 2 +_MAX_FIT_EVALS = 10000 + + +@dataclass +class FitResult: + """Result of a curve fit operation.""" + + result_channel: str + func_type: str + x_range: list[float] | None + init: dict[str, float] + result: dict[str, float] + goodness: dict[str, float] + success: bool + message: str + + +def _filter_valid( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], +) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: + mask = np.isfinite(x) & np.isfinite(y) + return x[mask], y[mask] + + +def _compute_goodness( + y: npt.NDArray[np.float64], + y_fit: npt.NDArray[np.float64], + n_params: int, +) -> dict[str, float]: + n = len(y) + ss_res = float(np.sum((y - y_fit) ** 2)) + ss_tot = float(np.sum((y - np.mean(y)) ** 2)) + + r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0 + + dof = n - n_params + chi2_red = ss_res / dof if dof > 0 else float("inf") + + log_term = float(n * np.log(ss_res / n)) if ss_res > 0 and n > 0 else 0.0 + aic = log_term + 2.0 * n_params + bic = log_term + n_params * float(np.log(n)) if n > 0 else 0.0 + + return {"r2": r2, "chi2_red": chi2_red, "aic": aic, "bic": bic} + + +def _make_error( + result_channel: str, + func_type: str, + x_range: list[float] | None, + init: dict[str, float], + message: str, +) -> FitResult: + return FitResult( + result_channel=result_channel, + func_type=func_type, + x_range=x_range, + init=init, + result={}, + goodness={}, + success=False, + message=message, + ) + + +def _apply_range( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + x_range: list[float] | None, +) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: + if x_range is not None and len(x_range) == _EXPECTED_RANGE_LEN: + mask = (x >= x_range[0]) & (x <= x_range[1]) + return x[mask], y[mask] + return x, y + + +def run_curve_fit( # noqa: PLR0913, C901 + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + result_channel: str, + func_type: FitFunctionType, + x_range: list[float] | None = None, + init: dict[str, float] | None = None, +) -> FitResult: + """Run a curve fit on the given data.""" + if func_type not in FIT_MODELS: + return _make_error( + result_channel, func_type, x_range, init or {}, + f"Unknown fit function: {func_type}", + ) + + model = FIT_MODELS[func_type] + x, y = _apply_range(x, y, x_range) + x, y = _filter_valid(x, y) + + min_points = len(model.param_names) + 1 + if len(x) < min_points: + return _make_error( + result_channel, func_type, x_range, init or {}, + f"Insufficient data points: {len(x)} (need at least {min_points})", + ) + + # Build initial guess: merge user overrides on top of auto-guess + p0 = list(model.guess(x, y)) + if init: + for i, name in enumerate(model.param_names): + if name in init: + p0[i] = init[name] + + init_dict = dict(zip(model.param_names, p0)) + + try: + popt, _ = curve_fit(model.func, x, y, p0=p0, maxfev=_MAX_FIT_EVALS) + except (RuntimeError, ValueError) as exc: + return _make_error( + result_channel, func_type, x_range, init_dict, str(exc), + ) + + result_dict = dict(zip(model.param_names, (float(v) for v in popt))) + + if func_type == "poly2" and result_dict.get("a", 0) != 0: + result_dict["vertex"] = -result_dict["b"] / (2.0 * result_dict["a"]) + + y_fit = model.func(x, *popt) + goodness = _compute_goodness(y, y_fit, len(model.param_names)) + + return FitResult( + result_channel=result_channel, + func_type=func_type, + x_range=x_range, + init=init_dict, + result=result_dict, + goodness=goodness, + success=True, + message="Fit converged", + ) diff --git a/src/icon/server/fitting/models.py b/src/icon/server/fitting/models.py new file mode 100644 index 00000000..0a035d0d --- /dev/null +++ b/src/icon/server/fitting/models.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +import numpy as np +import numpy.typing as npt + +if TYPE_CHECKING: + from collections.abc import Callable + +FitFunctionType = Literal[ + "gaussian", + "lorentzian", + "poly2", + "harmonic", + "damped_harmonic", +] + +_MIN_FWHM_POINTS = 2 + + +@dataclass(frozen=True) +class FitModel: + """Definition of a curve-fitting model.""" + + func: Callable[..., npt.NDArray[np.float64]] + param_names: list[str] + default_update_param: str + guess: Callable[[npt.NDArray[np.float64], npt.NDArray[np.float64]], list[float]] + + +def _lorentzian( + x: npt.NDArray[np.float64], + y0: float, + a: float, + x0: float, + gamma: float, +) -> npt.NDArray[np.float64]: + return np.asarray(y0 + a / (1.0 + ((x - x0) / gamma) ** 2)) + + +def _lorentzian_guess( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], +) -> list[float]: + n = len(y) + n10 = max(1, n // 10) + sorted_y = np.sort(y) + baseline = float(np.median(np.concatenate([sorted_y[:n10], sorted_y[-n10:]]))) + + peak_idx = int(np.argmax(np.abs(y - baseline))) + a = float(y[peak_idx] - baseline) + x0 = float(x[peak_idx]) + + above = np.where(np.abs(y - baseline) >= np.abs(a) / 2.0)[0] + if len(above) >= _MIN_FWHM_POINTS: + gamma = float(abs(x[above[-1]] - x[above[0]])) / 2.0 + else: + gamma = float(abs(x[-1] - x[0])) / 4.0 + + gamma = max(gamma, 1e-12) + return [baseline, a, x0, gamma] + + +def _gaussian( + x: npt.NDArray[np.float64], + y0: float, + a: float, + x0: float, + sigma: float, +) -> npt.NDArray[np.float64]: + return np.asarray(y0 + a * np.exp(-((x - x0) ** 2) / (2.0 * sigma**2))) + + +def _gaussian_guess( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], +) -> list[float]: + n = len(y) + n20 = max(1, n // 5) + baseline = float(np.mean(np.sort(y)[:n20])) + + peak_idx = int(np.argmax(np.abs(y - baseline))) + a = float(y[peak_idx] - baseline) + x0 = float(x[peak_idx]) + + half_max = np.abs(a) / 2.0 + above = np.where(np.abs(y - baseline) >= half_max)[0] + if len(above) >= _MIN_FWHM_POINTS: + fwhm = float(abs(x[above[-1]] - x[above[0]])) + sigma = fwhm / (2.0 * np.sqrt(2.0 * np.log(2.0))) + else: + sigma = float(abs(x[-1] - x[0])) / 10.0 + + sigma = max(sigma, 1e-12) + return [baseline, a, x0, sigma] + + +def _poly2( + x: npt.NDArray[np.float64], + a: float, + b: float, + c: float, +) -> npt.NDArray[np.float64]: + return np.asarray(a * x**2 + b * x + c) + + +def _poly2_guess( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], +) -> list[float]: + coeffs = np.polyfit(x, y, 2) + return coeffs.tolist() + + +def _harmonic( + x: npt.NDArray[np.float64], + y0: float, + a: float, + omega: float, + phi: float, +) -> npt.NDArray[np.float64]: + return np.asarray(y0 + a * np.cos(omega * x + phi)) + + +_MIN_FFT_POINTS = 3 + + +def _harmonic_guess( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], +) -> list[float]: + y0 = float(np.mean(y)) + a = float(np.max(y) - np.min(y)) / 2.0 + n = len(y) + if n >= _MIN_FFT_POINTS: + dx = float(np.mean(np.diff(x))) + fft_vals = np.abs(np.fft.rfft(y - y0)) + fft_vals[0] = 0 + if len(fft_vals) > 1: + peak_freq_idx = int(np.argmax(fft_vals)) + freq = peak_freq_idx / (n * dx) if dx > 0 else 1.0 + omega = 2.0 * np.pi * freq + else: + omega = 1.0 + else: + omega = 1.0 + phi = 0.0 + return [y0, a, omega, phi] + + +def _damped_harmonic( # noqa: PLR0913 + x: npt.NDArray[np.float64], + y0: float, + a: float, + k: float, + omega: float, + phi: float, +) -> npt.NDArray[np.float64]: + return np.asarray(y0 + np.exp(k * x) * a * np.cos(omega * x + phi)) + + +def _damped_harmonic_guess( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], +) -> list[float]: + hg = _harmonic_guess(x, y) + return [hg[0], hg[1], 0.0, hg[2], hg[3]] + + +FIT_MODELS: dict[str, FitModel] = { + "lorentzian": FitModel( + func=_lorentzian, + param_names=["y0", "a", "x0", "gamma"], + default_update_param="x0", + guess=_lorentzian_guess, + ), + "gaussian": FitModel( + func=_gaussian, + param_names=["y0", "a", "x0", "sigma"], + default_update_param="x0", + guess=_gaussian_guess, + ), + "poly2": FitModel( + func=_poly2, + param_names=["a", "b", "c"], + default_update_param="vertex", + guess=_poly2_guess, + ), + "harmonic": FitModel( + func=_harmonic, + param_names=["y0", "a", "omega", "phi"], + default_update_param="omega", + guess=_harmonic_guess, + ), + "damped_harmonic": FitModel( + func=_damped_harmonic, + param_names=["y0", "a", "k", "omega", "phi"], + default_update_param="omega", + guess=_damped_harmonic_guess, + ), +} From 6311196d0fe716be9219cdc24bdd6b66119d6359 Mon Sep 17 00:00:00 2001 From: Antti Luomi Date: Fri, 20 Mar 2026 09:15:41 +0000 Subject: [PATCH 03/10] Persist fit results in HDF5 and expose run_fit/delete_fit API --- .../server/api/experiment_data_controller.py | 95 +++++++++++++++++++ .../experiment_data_repository.py | 89 +++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/src/icon/server/api/experiment_data_controller.py b/src/icon/server/api/experiment_data_controller.py index 86abf682..95637ef1 100644 --- a/src/icon/server/api/experiment_data_controller.py +++ b/src/icon/server/api/experiment_data_controller.py @@ -2,11 +2,16 @@ from dataclasses import asdict from typing import Any +import numpy as np import pydase from icon.server.data_access.repositories.experiment_data_repository import ( ExperimentDataRepository, + delete_fit_result_by_job_id, + write_fit_result_by_job_id, ) +from icon.server.fitting import run_curve_fit +from icon.server.web_server.socketio_emit_queue import emit_queue __all__ = ["ExperimentDataController"] @@ -42,3 +47,93 @@ async def get_experiment_data_by_job_id( max_transfer_bytes=max_transfer_bytes, ) return asdict(result) + + async def run_fit( + self, + job_id: int, + result_channel: str, + func_type: str, + x_range: list[float] | None = None, + init: dict[str, float] | None = None, + ) -> dict[str, Any]: + """Run a curve fit on a result channel of a finished job. + + Args: + job_id: Job identifier. + result_channel: Name of the result channel to fit. + func_type: Fit model name (e.g. "lorentzian"). + x_range: Optional [min, max] to restrict fit domain. + init: Optional initial parameter overrides. + + Returns: + Serialised FitResult dict. + """ + data = await asyncio.to_thread( + ExperimentDataRepository.get_experiment_data_by_job_id, + job_id=job_id, + ) + + # Find the first non-timestamp scan parameter for x-values + scan_param_name = next( + (p for p in data.scan_parameters if p != "timestamp"), None + ) + if scan_param_name is None: + return asdict(run_curve_fit( + x=np.array([]), + y=np.array([]), + result_channel=result_channel, + func_type=func_type, # type: ignore[arg-type] + )) + + scan_values = data.scan_parameters[scan_param_name] + channel_values = data.result_channels.get(result_channel, {}) + + # Build aligned x, y arrays sorted by index + indices = sorted(set(scan_values.keys()) & set(channel_values.keys())) + x = np.array([float(scan_values[i]) for i in indices]) + y = np.array([float(channel_values[i]) for i in indices]) + + fit_result = await asyncio.to_thread( + run_curve_fit, + x=x, + y=y, + result_channel=result_channel, + func_type=func_type, # type: ignore[arg-type] + x_range=x_range, + init=init, + ) + + if fit_result.success: + await asyncio.to_thread( + write_fit_result_by_job_id, + job_id=job_id, + fit_result=fit_result, + ) + + result_dict = asdict(fit_result) + emit_queue.put({ + "event": f"experiment_fit_{job_id}", + "data": result_dict, + }) + return result_dict + + async def delete_fit( + self, + job_id: int, + result_channel: str, + ) -> None: + """Delete a fit result for a result channel. + + Args: + job_id: Job identifier. + result_channel: Name of the result channel whose fit to remove. + """ + await asyncio.to_thread( + delete_fit_result_by_job_id, + job_id=job_id, + result_channel=result_channel, + ) + emit_queue.put({ + "event": f"experiment_fit_{job_id}", + "data": {"result_channel": result_channel, "deleted": True}, + }) diff --git a/src/icon/server/data_access/repositories/experiment_data_repository.py b/src/icon/server/data_access/repositories/experiment_data_repository.py index a1418e4a..8e4ba4a4 100644 --- a/src/icon/server/data_access/repositories/experiment_data_repository.py +++ b/src/icon/server/data_access/repositories/experiment_data_repository.py @@ -18,6 +18,7 @@ ) from icon.server.data_access.repositories.job_repository import JobRepository from icon.server.data_access.repositories.job_run_repository import JobRunRepository +from icon.server.fitting.fit_runner import FitResult from icon.server.utils.h5py import get_hdf5_dtype, get_result_channels_dataset from icon.server.web_server.socketio_emit_queue import emit_queue @@ -125,6 +126,8 @@ class ExperimentData: """Mapping of parameter id to time series (tuple of timestamp str and value).""" total_data_points: int """Total number of data points in the HDF5 file (before truncation).""" + fits: dict[str, dict[str, object]] + """Fit results keyed by result channel name.""" def get_filename_by_job_id(job_id: int) -> str: @@ -630,6 +633,7 @@ def get_experiment_data_by_job_id( realtime_scan=False, parameters={}, total_data_points=0, + fits={}, ) filename = get_filename_by_job_id(job_id) @@ -752,6 +756,7 @@ def get_experiment_data_by_job_id( for entry in sequence_json_dataset ] data.parameters = extract_parameter_values(h5file) + data.fits = _read_fits_from_hdf5(h5file) return data @@ -763,3 +768,87 @@ def last_value(d: h5py.Dataset) -> ParameterValue: return ParameterValue(timestamp=ts.decode(), value=val) return {key: last_value(dataset) for key, dataset in h5file["parameters"].items()} + + +def _read_fits_from_hdf5( + h5file: h5py.File, +) -> dict[str, dict[str, object]]: + """Read all fit results from an HDF5 file.""" + if "fits" not in h5file: + return {} + + fits: dict[str, dict[str, object]] = {} + fits_group = cast("h5py.Group", h5file["fits"]) + for channel_name in fits_group: + channel_group = cast("h5py.Group", fits_group[channel_name]) + fit_data = json.loads(cast("str", channel_group.attrs["fit_result"])) + fits[channel_name] = fit_data + return fits + + +def write_fit_result_by_job_id( + *, + job_id: int, + fit_result: FitResult, +) -> None: + """Write a fit result into the HDF5 file for a job. + + Creates or overwrites the ``fits/`` group. + + Args: + job_id: Job identifier. + fit_result: The fit result to persist. + """ + filename = get_filename_by_job_id(job_id) + file = f"{get_config().data.results_dir}/{filename}" + lock_path = ( + f"{get_config().data.results_dir}/.{filename}" + f"{ExperimentDataRepository.LOCK_EXTENSION}" + ) + with FileLock(lock_path), h5py.File(file, "a") as h5file: + fits_group = h5file.require_group("fits") + channel = fit_result.result_channel + if channel in fits_group: + del fits_group[channel] + grp = fits_group.create_group(channel) + grp.attrs["fit_result"] = json.dumps(asdict(fit_result)) + + +def get_fit_results_by_job_id(*, job_id: int) -> dict[str, dict[str, object]]: + """Read all fit results for a job from its HDF5 file. + + Args: + job_id: Job identifier. + + Returns: + Dict mapping result channel names to their fit result dicts. + """ + filename = get_filename_by_job_id(job_id) + file = f"{get_config().data.results_dir}/{filename}" + if not os.path.exists(file): + return {} + + lock_path = ( + f"{get_config().data.results_dir}/.{filename}" + f"{ExperimentDataRepository.LOCK_EXTENSION}" + ) + with FileLock(lock_path), h5py.File(file, "r") as h5file: + return _read_fits_from_hdf5(h5file) + + +def delete_fit_result_by_job_id(*, job_id: int, result_channel: str) -> None: + """Delete a fit result for a specific channel from the HDF5 file. + + Args: + job_id: Job identifier. + result_channel: Name of the result channel whose fit to delete. + """ + filename = get_filename_by_job_id(job_id) + file = f"{get_config().data.results_dir}/{filename}" + lock_path = ( + f"{get_config().data.results_dir}/.{filename}" + f"{ExperimentDataRepository.LOCK_EXTENSION}" + ) + with FileLock(lock_path), h5py.File(file, "a") as h5file: + if "fits" in h5file and result_channel in h5file["fits"]: + del h5file["fits"][result_channel] From 5228c8093f083c1cf28d7bde6859fd79d16553b7 Mon Sep 17 00:00:00 2001 From: Antti Luomi Date: Fri, 20 Mar 2026 16:32:08 +0000 Subject: [PATCH 04/10] Fit panel UI with plot overlay and click-to-guide --- frontend/src/components/JobView.tsx | 26 +- frontend/src/components/ResultChannelPlot.tsx | 49 ++- frontend/src/components/jobView/FitPanel.tsx | 315 ++++++++++++++++++ frontend/src/hooks/useExperimentData.tsx | 19 ++ frontend/src/types/ExperimentData.ts | 12 + frontend/src/utils/fitFunctions.ts | 61 ++++ 6 files changed, 476 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/jobView/FitPanel.tsx create mode 100644 frontend/src/utils/fitFunctions.ts diff --git a/frontend/src/components/JobView.tsx b/frontend/src/components/JobView.tsx index 12b3f57c..9f9df5e4 100644 --- a/frontend/src/components/JobView.tsx +++ b/frontend/src/components/JobView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Alert, Button, @@ -27,6 +27,7 @@ import { deserialize } from "../utils/deserializer"; import { updateJobParams } from "../utils/updateJobParams"; import { cancelJob } from "../utils/cancelJob"; import HistogramPlot from "./jobView/HistogramPlot"; +import FitPanel from "./jobView/FitPanel"; function getPlotTitle(scheduledTime?: string, experimentName?: string): string { if (!scheduledTime) return experimentName || ""; @@ -90,6 +91,9 @@ export const JobView = ({ experimentData.total_data_points > 0 && loadedDataPoints < experimentData.total_data_points; + const [clickedX, setClickedX] = useState(null); + const handleChartClick = useCallback((x: number) => setClickedX(x), []); + const [showRepetitions, setShowRepetitions] = useState(() => { const v = localStorage.getItem("showRepetitions"); return v ? JSON.parse(v) : false; @@ -206,10 +210,10 @@ export const JobView = ({ {jobId} - {experimentMetadata?.constructor_kwargs.name && ( + {experimentMetadata?.constructor_kwargs?.name && ( <> {" "} - - {experimentMetadata?.constructor_kwargs.name} ( + - {experimentMetadata?.constructor_kwargs?.name} ( {experimentMetadata?.class_name}) )} @@ -396,7 +400,7 @@ export const JobView = ({ title={win.name} subtitle={getPlotTitle( jobRunInfo?.scheduled_time, - experimentMetadata?.constructor_kwargs.name, + experimentMetadata?.constructor_kwargs?.name, )} /> )} @@ -435,19 +439,31 @@ export const JobView = ({ title={win.name} subtitle={getPlotTitle( jobRunInfo?.scheduled_time, - experimentMetadata?.constructor_kwargs.name, + experimentMetadata?.constructor_kwargs?.name, )} repetitions={jobInfo?.repetitions} showRepetitions={showRepetitions} scanParameters={jobInfo?.scan_parameters} windowSize={windowSize} yRange={{ min: yMin, max: yMax }} + fits={is1D ? experimentData.fits : undefined} + onChartClick={is1D ? handleChartClick : undefined} /> )} ))} + {is1D && jobId && ( + + + + )} + diff --git a/frontend/src/components/ResultChannelPlot.tsx b/frontend/src/components/ResultChannelPlot.tsx index cbf5cd49..43274f69 100644 --- a/frontend/src/components/ResultChannelPlot.tsx +++ b/frontend/src/components/ResultChannelPlot.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { ExperimentData } from "../types/ExperimentData"; +import { ExperimentData, FitResult } from "../types/ExperimentData"; import { ReactECharts, ReactEChartsProps } from "./ReactEcharts"; import { EChartsOption } from "echarts"; import type { ECharts } from "echarts/core"; @@ -7,6 +7,7 @@ import { useNotifications } from "@toolpad/core"; import { copyEChartsToClipboard } from "../utils/copyEChartsToClipboard"; import { ScanParameter } from "../types/ScanParameter"; import { buildResultChannelChartSeries } from "../utils/buildResultChannelChartSeries"; +import { evaluateFit } from "../utils/fitFunctions"; interface ResultChannelPlotProps { experimentData: ExperimentData; @@ -19,6 +20,8 @@ interface ResultChannelPlotProps { scanParameters: ScanParameter[] | undefined; windowSize?: number | null; yRange?: { min: number | null; max: number | null }; + fits?: Record; + onChartClick?: (xValue: number) => void; } const formatAxisLabel = (value: string): string => { @@ -100,6 +103,8 @@ const ResultChannelPlot = ({ scanParameters = [], windowSize = null, yRange, + fits = {}, + onChartClick, }: ResultChannelPlotProps) => { const [chart, setChart] = useState(null); const notifications = useNotifications(); @@ -340,6 +345,33 @@ const ResultChannelPlot = ({ }; } + // Add fit curve overlays for 1D scans + if (scanParameters.length === 1 && fits) { + const ordinaryEntry = scanInfo.find((p) => p.name !== "timestamp"); + const fitXArr = (ordinaryEntry?.scanValues ?? []) as number[]; + + for (const [channelName, fitResult] of Object.entries(fits)) { + if (!fitResult.success || !channelNames.includes(channelName)) continue; + + const xMinVal = fitResult.x_range ? fitResult.x_range[0] : Math.min(...fitXArr); + const xMaxVal = fitResult.x_range ? fitResult.x_range[1] : Math.max(...fitXArr); + const nPoints = 200; + const step = (xMaxVal - xMinVal) / (nPoints - 1); + const fitX = Array.from({ length: nPoints }, (_, i) => xMinVal + i * step); + const fitY = evaluateFit(fitResult.func_type, fitResult.result, fitX); + const fitData = fitX.map((x, i) => [x, fitY[i]]); + + (chartSeries as unknown[]).push({ + name: `${channelName} fit`, + type: "line", + data: fitData, + showSymbol: false, + lineStyle: { type: "dashed", width: 2 }, + tooltip: { show: false }, + }); + } + } + return { title, textStyle: { fontFamily: "sans-serif", fontSize: 12 }, @@ -381,6 +413,8 @@ const ResultChannelPlot = ({ showRepetitions, windowSize, yRange, + fits, + channelNames, ]); const updateChart = useCallback( @@ -390,6 +424,19 @@ const ResultChannelPlot = ({ [setChart], ); + useEffect(() => { + if (!chart || !onChartClick) return; + const handler = (params: { data?: unknown[] | unknown }) => { + if (Array.isArray(params.data) && typeof params.data[0] === "number") { + onChartClick(params.data[0]); + } + }; + chart.on("click", handler); + return () => { + chart.off("click", handler); + }; + }, [chart, onChartClick]); + useEffect(() => { if (!is2D || !chart) return; diff --git a/frontend/src/components/jobView/FitPanel.tsx b/frontend/src/components/jobView/FitPanel.tsx new file mode 100644 index 00000000..90520780 --- /dev/null +++ b/frontend/src/components/jobView/FitPanel.tsx @@ -0,0 +1,315 @@ +import { useState } from "react"; +import { + Button, + Card, + CardContent, + FormControl, + Grid, + InputLabel, + MenuItem, + Select, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, + Accordion, + AccordionSummary, + AccordionDetails, + Alert, +} from "@mui/material"; +import { ExpandMore } from "@mui/icons-material"; +import { ExperimentData, FitResult } from "../../types/ExperimentData"; +import { runMethod } from "../../socket"; +import { FIT_PARAM_NAMES, FIT_TYPES } from "../../utils/fitFunctions"; + +interface FitPanelProps { + jobId: string; + experimentData: ExperimentData; + clickedX: number | null; +} + +export default function FitPanel({ jobId, experimentData, clickedX }: FitPanelProps) { + const channelNames = Object.keys(experimentData.result_channels); + const [selectedChannel, setSelectedChannel] = useState(channelNames[0] ?? ""); + const [funcType, setFuncType] = useState("lorentzian"); + const [xMin, setXMin] = useState(""); + const [xMax, setXMax] = useState(""); + const [initOverrides, setInitOverrides] = useState>({}); + const [fitting, setFitting] = useState(false); + + const [updateParamId, setUpdateParamId] = useState(""); + const [updateValue, setUpdateValue] = useState(""); + + const fit: FitResult | undefined = experimentData.fits[selectedChannel]; + const paramNames = FIT_PARAM_NAMES[funcType] ?? []; + + // Pre-fill x0 from click + const effectiveInit = { ...initOverrides }; + if (clickedX !== null && paramNames.includes("x0") && !effectiveInit["x0"]) { + effectiveInit["x0"] = String(clickedX); + } + + const handleFit = () => { + setFitting(true); + const xRange = + xMin !== "" && xMax !== "" ? [parseFloat(xMin), parseFloat(xMax)] : null; + + const init: Record = {}; + for (const [key, val] of Object.entries(effectiveInit)) { + if (val !== "") init[key] = parseFloat(val); + } + + runMethod( + "data.run_fit", + [], + { + job_id: Number(jobId), + result_channel: selectedChannel, + func_type: funcType, + x_range: xRange, + init: Object.keys(init).length > 0 ? init : null, + }, + () => setFitting(false), + ); + }; + + const handleDelete = () => { + runMethod("data.delete_fit", [], { + job_id: Number(jobId), + result_channel: selectedChannel, + }); + }; + + const handleUpdateParameter = () => { + if (!updateParamId || updateValue === "") return; + runMethod("parameters.update_parameter_by_id", [ + updateParamId, + parseFloat(updateValue), + ]); + }; + + // Get scan parameter names for "update parameter" dropdown + const scanParamKeys = Object.keys(experimentData.scan_parameters).filter( + (k) => k !== "timestamp", + ); + const allParamIds = Object.keys(experimentData.parameters); + + return ( + + + + Curve Fitting + + + + + + Channel + + + + + + + Model + + + + + + setXMin(e.target.value)} + /> + + + setXMax(e.target.value)} + /> + + + + + }> + Initial parameter overrides + + + + {paramNames.map((name) => ( + + + setInitOverrides((prev) => ({ + ...prev, + [name]: e.target.value, + })) + } + /> + + ))} + + {clickedX !== null && ( + + Clicked x = {clickedX.toFixed(4)} (used as x0 hint if not overridden) + + )} + + + +
+ + {fit && ( + + )} +
+ + {fit && ( +
+ {!fit.success && ( + + {fit.message} + + )} + {fit.success && ( + <> + + Fit Results ({fit.func_type}) + + + + + Parameter + Value + + + + {Object.entries(fit.result).map(([key, val]) => ( + + {key} + {val.toFixed(6)} + + ))} + +
+ + + Goodness of Fit + + + + {Object.entries(fit.goodness).map(([key, val]) => ( + + {key} + {val.toFixed(6)} + + ))} + +
+ + + Update Parameter + + + + + Parameter + + + + + setUpdateValue(e.target.value)} + /> + + + + + + + )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/hooks/useExperimentData.tsx b/frontend/src/hooks/useExperimentData.tsx index 1d3b1c63..c7354a01 100644 --- a/frontend/src/hooks/useExperimentData.tsx +++ b/frontend/src/hooks/useExperimentData.tsx @@ -3,6 +3,7 @@ import { runMethod, socket } from "../socket"; import { ExperimentData, ExperimentDataPoint, + FitResult, ParameterValue, } from "../types/ExperimentData"; import { SerializedObject } from "../types/SerializedObject"; @@ -21,6 +22,7 @@ const emptyExperimentData: ExperimentData = { json_sequences: [], parameters: {}, total_data_points: 0, + fits: {}, }; /** @@ -109,9 +111,25 @@ export function useExperimentData(jobId: string | undefined) { }); }; + const fitEvent = `experiment_fit_${jobId}`; + const handleFitEvent = (data: FitResult & { deleted?: boolean }) => { + setExperimentData((prev) => { + if (data.deleted) { + const { [data.result_channel]: _removed, ...rest } = prev.fits; + void _removed; + return { ...prev, fits: rest }; + } + return { + ...prev, + fits: { ...prev.fits, [data.result_channel]: data }, + }; + }); + }; + socket.on(dataPointEvent, handleNewDataPoint); socket.on(metadataEvent, handleMetadata); socket.on(parameterValueEvent, handleValueEvent); + socket.on(fitEvent, handleFitEvent); runMethod("data.get_experiment_data_by_job_id", [], { job_id: jobId }, (ack) => { const deserialized = deserialize(ack as SerializedObject) as @@ -131,6 +149,7 @@ export function useExperimentData(jobId: string | undefined) { socket.off(dataPointEvent, handleNewDataPoint); socket.off(metadataEvent, handleMetadata); socket.off(parameterValueEvent, handleValueEvent); + socket.off(fitEvent, handleFitEvent); }; }, [jobId]); diff --git a/frontend/src/types/ExperimentData.ts b/frontend/src/types/ExperimentData.ts index cad018f4..005fecca 100644 --- a/frontend/src/types/ExperimentData.ts +++ b/frontend/src/types/ExperimentData.ts @@ -28,6 +28,17 @@ export interface ParameterValue { value: string | number | boolean; } +export interface FitResult { + result_channel: string; + func_type: string; + x_range: [number, number] | null; + init: Record; + result: Record; + goodness: Record; + success: boolean; + message: string; +} + export interface ExperimentData { plot_windows: PlotWindows; shot_channels: Record>; @@ -37,4 +48,5 @@ export interface ExperimentData { json_sequences: [number, string][]; parameters: Record; total_data_points: number; + fits: Record; } diff --git a/frontend/src/utils/fitFunctions.ts b/frontend/src/utils/fitFunctions.ts new file mode 100644 index 00000000..2a151772 --- /dev/null +++ b/frontend/src/utils/fitFunctions.ts @@ -0,0 +1,61 @@ +/** + * Evaluate a fit function at the given x-values using fitted parameters. + */ +export function evaluateFit( + funcType: string, + params: Record, + xValues: number[], +): number[] { + switch (funcType) { + case "lorentzian": + return xValues.map((x) => { + const { y0, a, x0, gamma } = params; + return y0 + a / (1 + ((x - x0) / gamma) ** 2); + }); + + case "gaussian": + return xValues.map((x) => { + const { y0, a, x0, sigma } = params; + return y0 + a * Math.exp(-((x - x0) ** 2) / (2 * sigma ** 2)); + }); + + case "poly2": + return xValues.map((x) => { + const { a, b, c } = params; + return a * x ** 2 + b * x + c; + }); + + case "harmonic": + return xValues.map((x) => { + const { y0, a, omega, phi } = params; + return y0 + a * Math.cos(omega * x + phi); + }); + + case "damped_harmonic": + return xValues.map((x) => { + const { y0, a, k, omega, phi } = params; + return y0 + Math.exp(k * x) * a * Math.cos(omega * x + phi); + }); + + default: + return xValues.map(() => NaN); + } +} + +/** Names of the fit parameters for each model type. */ +export const FIT_PARAM_NAMES: Record = { + lorentzian: ["y0", "a", "x0", "gamma"], + gaussian: ["y0", "a", "x0", "sigma"], + poly2: ["a", "b", "c"], + harmonic: ["y0", "a", "omega", "phi"], + damped_harmonic: ["y0", "a", "k", "omega", "phi"], +}; + +/** Available fit model types. */ +export const FIT_TYPES = [ + "lorentzian", + "gaussian", + "poly2", + "harmonic", + "damped_harmonic", +] as const; From bda5d4e2aad674c8ae6e6e6c5979e39285d26036 Mon Sep 17 00:00:00 2001 From: Antti Luomi Date: Mon, 23 Mar 2026 10:21:16 +0000 Subject: [PATCH 05/10] Tests for curve fitting --- frontend/jest.config.js | 7 ++ .../src/utils/__tests__/fitFunctions.test.ts | 72 ++++++++++++ tests/server/fitting/__init__.py | 0 tests/server/fitting/test_fit_runner.py | 109 ++++++++++++++++++ tests/server/fitting/test_models.py | 83 +++++++++++++ 5 files changed, 271 insertions(+) create mode 100644 frontend/jest.config.js create mode 100644 frontend/src/utils/__tests__/fitFunctions.test.ts create mode 100644 tests/server/fitting/__init__.py create mode 100644 tests/server/fitting/test_fit_runner.py create mode 100644 tests/server/fitting/test_models.py diff --git a/frontend/jest.config.js b/frontend/jest.config.js new file mode 100644 index 00000000..c44ef36e --- /dev/null +++ b/frontend/jest.config.js @@ -0,0 +1,7 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} **/ +export default { + testEnvironment: "node", + transform: { + "^.+.tsx?$": ["ts-jest", {}], + }, +}; diff --git a/frontend/src/utils/__tests__/fitFunctions.test.ts b/frontend/src/utils/__tests__/fitFunctions.test.ts new file mode 100644 index 00000000..92679d90 --- /dev/null +++ b/frontend/src/utils/__tests__/fitFunctions.test.ts @@ -0,0 +1,72 @@ +import { evaluateFit } from "../fitFunctions"; + +describe("evaluateFit", () => { + describe("lorentzian", () => { + it("should return peak value at x0", () => { + const params = { y0: 0, a: 10, x0: 5, gamma: 1 }; + const result = evaluateFit("lorentzian", params, [5]); + expect(result[0]).toBeCloseTo(10, 5); + }); + + it("should return baseline far from peak", () => { + const params = { y0: 3, a: 10, x0: 0, gamma: 1 }; + const result = evaluateFit("lorentzian", params, [1e6]); + expect(result[0]).toBeCloseTo(3, 1); + }); + + it("should be symmetric around x0", () => { + const params = { y0: 0, a: 1, x0: 5, gamma: 1 }; + const [left] = evaluateFit("lorentzian", params, [3]); + const [right] = evaluateFit("lorentzian", params, [7]); + expect(left).toBeCloseTo(right, 10); + }); + }); + + describe("gaussian", () => { + it("should return peak value at x0", () => { + const params = { y0: 0, a: 5, x0: 3, sigma: 1 }; + const result = evaluateFit("gaussian", params, [3]); + expect(result[0]).toBeCloseTo(5, 5); + }); + }); + + describe("poly2", () => { + it("should compute quadratic correctly", () => { + const params = { a: 1, b: 2, c: 3 }; + const result = evaluateFit("poly2", params, [0, 1, 2]); + expect(result[0]).toBeCloseTo(3); + expect(result[1]).toBeCloseTo(6); + expect(result[2]).toBeCloseTo(11); + }); + }); + + describe("harmonic", () => { + it("should return y0 + A at phase 0", () => { + const params = { y0: 1, a: 2, omega: Math.PI, phi: 0 }; + const result = evaluateFit("harmonic", params, [0]); + expect(result[0]).toBeCloseTo(3); + }); + }); + + describe("damped_harmonic", () => { + it("should match harmonic with k=0", () => { + const x = [0, 1, 2, 3, 4, 5]; + const harmonicParams = { y0: 1, a: 2, omega: 3, phi: 0.5 }; + const dampedParams = { y0: 1, a: 2, k: 0, omega: 3, phi: 0.5 }; + + const harmonic = evaluateFit("harmonic", harmonicParams, x); + const damped = evaluateFit("damped_harmonic", dampedParams, x); + + for (let i = 0; i < x.length; i++) { + expect(damped[i]).toBeCloseTo(harmonic[i], 10); + } + }); + }); + + describe("unknown function", () => { + it("should return NaN for unknown types", () => { + const result = evaluateFit("unknown", {}, [1, 2, 3]); + expect(result.every((v) => isNaN(v))).toBe(true); + }); + }); +}); diff --git a/tests/server/fitting/__init__.py b/tests/server/fitting/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/server/fitting/test_fit_runner.py b/tests/server/fitting/test_fit_runner.py new file mode 100644 index 00000000..0db625b4 --- /dev/null +++ b/tests/server/fitting/test_fit_runner.py @@ -0,0 +1,109 @@ +import numpy as np +import pytest + +from icon.server.fitting.fit_runner import FitResult, run_curve_fit + + +def _synthetic_lorentzian( + n: int = 100, + noise: float = 0.0, +) -> tuple[np.ndarray, np.ndarray, dict[str, float]]: + """Generate synthetic Lorentzian data.""" + true_params = {"y0": 1.0, "a": 5.0, "x0": 5.0, "gamma": 0.5} + x = np.linspace(0, 10, n) + y = true_params["y0"] + true_params["a"] / ( + 1.0 + ((x - true_params["x0"]) / true_params["gamma"]) ** 2 + ) + if noise > 0: + rng = np.random.default_rng(42) + y = y + rng.normal(0, noise, n) + return x, y, true_params + + +class TestRunCurveFit: + def test_lorentzian_clean(self) -> None: + x, y, true = _synthetic_lorentzian(noise=0.01) + result = run_curve_fit(x, y, "ch1", "lorentzian") + assert result.success + assert result.result["x0"] == pytest.approx(true["x0"], abs=0.1) + assert result.result["a"] == pytest.approx(true["a"], abs=0.5) + assert result.goodness["r2"] > 0.99 + + def test_lorentzian_noisy(self) -> None: + x, y, true = _synthetic_lorentzian(noise=0.5) + result = run_curve_fit(x, y, "ch1", "lorentzian") + assert result.success + assert result.result["x0"] == pytest.approx(true["x0"], abs=1.0) + + def test_unknown_func_type(self) -> None: + x = np.linspace(0, 10, 50) + y = np.ones(50) + result = run_curve_fit(x, y, "ch1", "nonexistent") # type: ignore[arg-type] + assert not result.success + assert "Unknown" in result.message + + def test_insufficient_points(self) -> None: + x = np.array([1.0, 2.0]) + y = np.array([1.0, 2.0]) + result = run_curve_fit(x, y, "ch1", "lorentzian") + assert not result.success + assert "Insufficient" in result.message + + def test_nan_data_filtered(self) -> None: + x, y, _ = _synthetic_lorentzian(n=100, noise=0.01) + y[10] = np.nan + y[20] = np.inf + x[30] = np.nan + result = run_curve_fit(x, y, "ch1", "lorentzian") + assert result.success + + def test_x_range_filter(self) -> None: + x, y, true = _synthetic_lorentzian(n=200, noise=0.01) + result = run_curve_fit(x, y, "ch1", "lorentzian", x_range=[3.0, 7.0]) + assert result.success + assert result.result["x0"] == pytest.approx(true["x0"], abs=0.2) + + def test_init_override(self) -> None: + x, y, true = _synthetic_lorentzian(noise=0.01) + result = run_curve_fit(x, y, "ch1", "lorentzian", init={"x0": 4.9}) + assert result.success + assert result.result["x0"] == pytest.approx(true["x0"], abs=0.2) + + def test_w_shape_with_init(self) -> None: + """Two Lorentzian dips; init selects correct peak.""" + x = np.linspace(0, 20, 200) + y = 10.0 - 5.0 / (1 + ((x - 5.0) / 0.5) ** 2) - 5.0 / ( + 1 + ((x - 15.0) / 0.5) ** 2 + ) + # Guide to the second peak + result = run_curve_fit(x, y, "ch1", "lorentzian", init={"x0": 15.0}) + assert result.success + assert result.result["x0"] == pytest.approx(15.0, abs=1.0) + + def test_gaussian_fit(self) -> None: + x = np.linspace(-5, 5, 100) + y = 2.0 + 3.0 * np.exp(-((x - 1.0) ** 2) / (2.0 * 0.5**2)) + rng = np.random.default_rng(42) + y += rng.normal(0, 0.05, len(y)) + result = run_curve_fit(x, y, "ch1", "gaussian") + assert result.success + assert result.result["x0"] == pytest.approx(1.0, abs=0.3) + + def test_poly2_fit(self) -> None: + x = np.linspace(-5, 5, 50) + y = 2.0 * x**2 - 3.0 * x + 1.0 + result = run_curve_fit(x, y, "ch1", "poly2") + assert result.success + assert result.result["a"] == pytest.approx(2.0, abs=0.1) + assert "vertex" in result.result + + def test_fit_result_fields(self) -> None: + x, y, _ = _synthetic_lorentzian(noise=0.01) + result = run_curve_fit(x, y, "ch1", "lorentzian") + assert isinstance(result, FitResult) + assert result.func_type == "lorentzian" + assert result.result_channel == "ch1" + assert "r2" in result.goodness + assert "chi2_red" in result.goodness + assert "aic" in result.goodness + assert "bic" in result.goodness diff --git a/tests/server/fitting/test_models.py b/tests/server/fitting/test_models.py new file mode 100644 index 00000000..feecc930 --- /dev/null +++ b/tests/server/fitting/test_models.py @@ -0,0 +1,83 @@ +import numpy as np +import pytest + +from icon.server.fitting.models import FIT_MODELS + + +class TestLorentzianFunction: + def test_peak_at_x0(self) -> None: + model = FIT_MODELS["lorentzian"] + x = np.array([5.0]) + result = model.func(x, 0.0, 10.0, 5.0, 1.0) + assert result[0] == pytest.approx(10.0) + + def test_baseline(self) -> None: + model = FIT_MODELS["lorentzian"] + x = np.array([1e6]) + result = model.func(x, 3.0, 10.0, 0.0, 1.0) + assert result[0] == pytest.approx(3.0, abs=0.01) + + def test_symmetry(self) -> None: + model = FIT_MODELS["lorentzian"] + x0 = 5.0 + left = model.func(np.array([x0 - 2.0]), 0.0, 1.0, x0, 1.0) + right = model.func(np.array([x0 + 2.0]), 0.0, 1.0, x0, 1.0) + assert left[0] == pytest.approx(right[0]) + + +class TestLorentzianGuess: + def test_reasonable_peak_guess(self) -> None: + model = FIT_MODELS["lorentzian"] + x = np.linspace(0, 10, 100) + y0, a, x0, gamma = 1.0, 5.0, 5.0, 0.5 + y = y0 + a / (1.0 + ((x - x0) / gamma) ** 2) + guess = model.guess(x, y) + # x0 guess should be near the peak + assert abs(guess[2] - x0) < 1.0 + + def test_dip_guess(self) -> None: + model = FIT_MODELS["lorentzian"] + x = np.linspace(0, 10, 100) + y = 10.0 - 5.0 / (1.0 + ((x - 5.0) / 0.5) ** 2) + guess = model.guess(x, y) + # A should be negative for a dip + assert guess[1] < 0 + + +class TestGaussianFunction: + def test_peak_at_x0(self) -> None: + model = FIT_MODELS["gaussian"] + x = np.array([3.0]) + result = model.func(x, 0.0, 5.0, 3.0, 1.0) + assert result[0] == pytest.approx(5.0) + + +class TestPoly2Function: + def test_known_values(self) -> None: + model = FIT_MODELS["poly2"] + x = np.array([0.0, 1.0, 2.0]) + result = model.func(x, 1.0, 2.0, 3.0) + np.testing.assert_array_almost_equal(result, [3.0, 6.0, 11.0]) + + def test_guess(self) -> None: + model = FIT_MODELS["poly2"] + x = np.linspace(-5, 5, 50) + y = 2.0 * x**2 - 3.0 * x + 1.0 + guess = model.guess(x, y) + assert guess[0] == pytest.approx(2.0, abs=0.1) + + +class TestHarmonicFunction: + def test_at_zero_phase(self) -> None: + model = FIT_MODELS["harmonic"] + x = np.array([0.0]) + result = model.func(x, 1.0, 2.0, np.pi, 0.0) + assert result[0] == pytest.approx(3.0) + + +class TestDampedHarmonicFunction: + def test_no_damping_matches_harmonic(self) -> None: + x = np.linspace(0, 10, 50) + harmonic = FIT_MODELS["harmonic"].func(x, 1.0, 2.0, 3.0, 0.5) + damped = FIT_MODELS["damped_harmonic"].func(x, 1.0, 2.0, 0.0, 3.0, 0.5) + np.testing.assert_array_almost_equal(harmonic, damped) From af221cf4f390b5d5c40b045ac562140ec003652a Mon Sep 17 00:00:00 2001 From: Antti Luomi Date: Mon, 23 Mar 2026 20:21:39 +0000 Subject: [PATCH 06/10] Improve fit panel --- frontend/src/components/JobView.tsx | 12 +- frontend/src/components/ResultChannelPlot.tsx | 15 +- frontend/src/components/jobView/FitPanel.tsx | 107 ++++++++---- frontend/src/pages/data.tsx | 2 +- frontend/src/utils/fitFunctions.ts | 9 ++ .../experiment_data_repository.py | 61 ++++--- src/icon/server/fitting/fit_runner.py | 3 + tests/generate_test_hdf5.py | 152 ++++++++++++++++++ 8 files changed, 303 insertions(+), 58 deletions(-) diff --git a/frontend/src/components/JobView.tsx b/frontend/src/components/JobView.tsx index 9f9df5e4..3b3eb7e1 100644 --- a/frontend/src/components/JobView.tsx +++ b/frontend/src/components/JobView.tsx @@ -38,9 +38,11 @@ function getPlotTitle(scheduledTime?: string, experimentName?: string): string { export const JobView = ({ jobId, onLoaded, + showFitPanel = false, }: { jobId: string | undefined; onLoaded?: () => void; + showFitPanel?: boolean; }) => { const [experimentMetadata, setExperimentMetadata] = useState(null); @@ -94,6 +96,9 @@ export const JobView = ({ const [clickedX, setClickedX] = useState(null); const handleChartClick = useCallback((x: number) => setClickedX(x), []); + // Reset clicked position when switching jobs + useEffect(() => setClickedX(null), [jobId]); + const [showRepetitions, setShowRepetitions] = useState(() => { const v = localStorage.getItem("showRepetitions"); return v ? JSON.parse(v) : false; @@ -446,20 +451,21 @@ export const JobView = ({ scanParameters={jobInfo?.scan_parameters} windowSize={windowSize} yRange={{ min: yMin, max: yMax }} - fits={is1D ? experimentData.fits : undefined} - onChartClick={is1D ? handleChartClick : undefined} + fits={showFitPanel && is1D ? experimentData.fits : undefined} + onChartClick={showFitPanel && is1D ? handleChartClick : undefined} /> )}
))} - {is1D && jobId && ( + {showFitPanel && is1D && jobId && jobInfo?.status === JobStatus.PROCESSED && ( )} diff --git a/frontend/src/components/ResultChannelPlot.tsx b/frontend/src/components/ResultChannelPlot.tsx index 43274f69..42b60d2e 100644 --- a/frontend/src/components/ResultChannelPlot.tsx +++ b/frontend/src/components/ResultChannelPlot.tsx @@ -426,14 +426,19 @@ const ResultChannelPlot = ({ useEffect(() => { if (!chart || !onChartClick) return; - const handler = (params: { data?: unknown[] | unknown }) => { - if (Array.isArray(params.data) && typeof params.data[0] === "number") { - onChartClick(params.data[0]); + const zr = chart.getZr(); + const handler = (params: { offsetX: number; offsetY: number }) => { + const point = [params.offsetX, params.offsetY]; + if (chart.containPixel("grid", point)) { + const dataPoint = chart.convertFromPixel("grid", point); + if (dataPoint && typeof dataPoint[0] === "number" && isFinite(dataPoint[0])) { + onChartClick(dataPoint[0]); + } } }; - chart.on("click", handler); + zr.on("click", handler); return () => { - chart.off("click", handler); + zr.off("click", handler); }; }, [chart, onChartClick]); diff --git a/frontend/src/components/jobView/FitPanel.tsx b/frontend/src/components/jobView/FitPanel.tsx index 90520780..d5c1960c 100644 --- a/frontend/src/components/jobView/FitPanel.tsx +++ b/frontend/src/components/jobView/FitPanel.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Button, Card, @@ -21,17 +21,30 @@ import { Alert, } from "@mui/material"; import { ExpandMore } from "@mui/icons-material"; +import { useNotifications } from "@toolpad/core"; import { ExperimentData, FitResult } from "../../types/ExperimentData"; +import { ScanParameter } from "../../types/ScanParameter"; import { runMethod } from "../../socket"; -import { FIT_PARAM_NAMES, FIT_TYPES } from "../../utils/fitFunctions"; +import { + FIT_DEFAULT_UPDATE_PARAM, + FIT_PARAM_NAMES, + FIT_TYPES, +} from "../../utils/fitFunctions"; interface FitPanelProps { jobId: string; experimentData: ExperimentData; clickedX: number | null; + scanParameters?: ScanParameter[]; } -export default function FitPanel({ jobId, experimentData, clickedX }: FitPanelProps) { +export default function FitPanel({ + jobId, + experimentData, + clickedX, + scanParameters = [], +}: FitPanelProps) { + const notifications = useNotifications(); const channelNames = Object.keys(experimentData.result_channels); const [selectedChannel, setSelectedChannel] = useState(channelNames[0] ?? ""); const [funcType, setFuncType] = useState("lorentzian"); @@ -43,12 +56,53 @@ export default function FitPanel({ jobId, experimentData, clickedX }: FitPanelPr const [updateParamId, setUpdateParamId] = useState(""); const [updateValue, setUpdateValue] = useState(""); + const channelKey = channelNames.join(","); + useEffect(() => { + if ( + channelNames.length > 0 && + (!selectedChannel || !channelNames.includes(selectedChannel)) + ) { + setSelectedChannel(channelNames[0]); + } + }, [channelKey]); + + // Reset form state when switching jobs + useEffect(() => { + setInitOverrides({}); + setXMin(""); + setXMax(""); + setUpdateParamId(""); + setUpdateValue(""); + }, [jobId]); + const fit: FitResult | undefined = experimentData.fits[selectedChannel]; const paramNames = FIT_PARAM_NAMES[funcType] ?? []; - // Pre-fill x0 from click + // Non-realtime scan parameters (the ones relevant for fitting) + const ordinaryScanParams = scanParameters.filter((sp) => !sp.realtime); + + // Pre-fill update parameter and value when a new fit arrives + const defaultParam = fit?.success + ? FIT_DEFAULT_UPDATE_PARAM[fit.func_type] + : undefined; + const defaultValue = + defaultParam && fit?.result && defaultParam in fit.result + ? fit.result[defaultParam] + : undefined; + + useEffect(() => { + if (!fit?.success) return; + if (ordinaryScanParams.length > 0 && !updateParamId) { + setUpdateParamId(ordinaryScanParams[0].variable_id); + } + if (defaultValue !== undefined) { + setUpdateValue(String(defaultValue)); + } + }, [fit?.success, defaultValue]); + + // Pre-fill x0 from click (only if user hasn't touched x0 at all) const effectiveInit = { ...initOverrides }; - if (clickedX !== null && paramNames.includes("x0") && !effectiveInit["x0"]) { + if (clickedX !== null && paramNames.includes("x0") && !("x0" in initOverrides)) { effectiveInit["x0"] = String(clickedX); } @@ -85,18 +139,21 @@ export default function FitPanel({ jobId, experimentData, clickedX }: FitPanelPr const handleUpdateParameter = () => { if (!updateParamId || updateValue === "") return; - runMethod("parameters.update_parameter_by_id", [ - updateParamId, - parseFloat(updateValue), - ]); + const displayName = + ordinaryScanParams.find((sp) => sp.variable_id === updateParamId)?.name ?? + updateParamId; + runMethod( + "parameters.update_parameter_by_id", + [updateParamId, parseFloat(updateValue)], + {}, + () => + notifications.show(`Updated ${displayName} to ${updateValue}`, { + autoHideDuration: 3000, + severity: "success", + }), + ); }; - // Get scan parameter names for "update parameter" dropdown - const scanParamKeys = Object.keys(experimentData.scan_parameters).filter( - (k) => k !== "timestamp", - ); - const allParamIds = Object.keys(experimentData.parameters); - return ( @@ -188,7 +245,7 @@ export default function FitPanel({ jobId, experimentData, clickedX }: FitPanelPr ))} - {clickedX !== null && ( + {clickedX !== null && paramNames.includes("x0") && ( Clicked x = {clickedX.toFixed(4)} (used as x0 hint if not overridden) @@ -264,23 +321,13 @@ export default function FitPanel({ jobId, experimentData, clickedX }: FitPanelPr diff --git a/frontend/src/pages/data.tsx b/frontend/src/pages/data.tsx index e04b13ed..cbd71aca 100644 --- a/frontend/src/pages/data.tsx +++ b/frontend/src/pages/data.tsx @@ -107,7 +107,7 @@ export function DataPage() { {selectedJobId ? (
{layoutReady ? ( - + ) : (
Loading...
)} diff --git a/frontend/src/utils/fitFunctions.ts b/frontend/src/utils/fitFunctions.ts index 2a151772..3bd11403 100644 --- a/frontend/src/utils/fitFunctions.ts +++ b/frontend/src/utils/fitFunctions.ts @@ -51,6 +51,15 @@ export const FIT_PARAM_NAMES: Record = { damped_harmonic: ["y0", "a", "k", "omega", "phi"], }; +/** Default result parameter to use for "Update Parameter" per model. */ +export const FIT_DEFAULT_UPDATE_PARAM: Record = { + lorentzian: "x0", + gaussian: "x0", + poly2: "vertex", + harmonic: "f", + damped_harmonic: "f", +}; + /** Available fit model types. */ export const FIT_TYPES = [ "lorentzian", diff --git a/src/icon/server/data_access/repositories/experiment_data_repository.py b/src/icon/server/data_access/repositories/experiment_data_repository.py index 8e4ba4a4..807d0828 100644 --- a/src/icon/server/data_access/repositories/experiment_data_repository.py +++ b/src/icon/server/data_access/repositories/experiment_data_repository.py @@ -655,9 +655,12 @@ def get_experiment_data_by_job_id( # Estimate bytes per data point from HDF5 metadata bytes_per_point = 0 - shot_group = cast("h5py.Group", h5file["shot_channels"]) - for ds in shot_group.values(): - bytes_per_point += ds.shape[1] * ds.dtype.itemsize + if "shot_channels" in h5file: + shot_group = cast( + "h5py.Group", h5file["shot_channels"] + ) + for ds in shot_group.values(): + bytes_per_point += ds.shape[1] * ds.dtype.itemsize bytes_per_point += cast( "h5py.Dataset", h5file["result_channels"] ).dtype.itemsize @@ -665,13 +668,14 @@ def get_experiment_data_by_job_id( "h5py.Dataset", h5file["scan_parameters"] ).dtype.itemsize # Add vector channel size (average across all data points) - vector_group = cast("h5py.Group", h5file["vector_channels"]) - total_vector_bytes = 0 - for channel_group in vector_group.values(): - for dataset in cast("h5py.Group", channel_group).values(): - total_vector_bytes += dataset.shape[0] * dataset.dtype.itemsize - if total > 0: - bytes_per_point += total_vector_bytes // total + if "vector_channels" in h5file: + vector_group = cast("h5py.Group", h5file["vector_channels"]) + total_vector_bytes = 0 + for channel_group in vector_group.values(): + for dataset in cast("h5py.Group", channel_group).values(): + total_vector_bytes += dataset.shape[0] * dataset.dtype.itemsize + if total > 0: + bytes_per_point += total_vector_bytes // total # JSON serialisation roughly doubles the raw size bytes_per_point = max(bytes_per_point * 2, 1) @@ -716,31 +720,50 @@ def get_experiment_data_by_job_id( # Convert shot channels into dicts with index as key if "shot_channels" in h5file: - shot_channels_group = cast("h5py.Group", h5file["shot_channels"]) - data.plot_windows["shot_channels"] = json.loads( - cast("str", shot_channels_group.attrs["Plot window metadata"]) + shot_channels_group = cast( + "h5py.Group", h5file["shot_channels"] ) + if "Plot window metadata" in shot_channels_group.attrs: + data.plot_windows["shot_channels"] = json.loads( + cast( + "str", + shot_channels_group.attrs[ + "Plot window metadata" + ], + ) + ) data.shot_channels = { key: dict(enumerate(value[start_index:].tolist(), start=start_index)) # type: ignore for key, value in cast( - "Sequence[tuple[str, h5py.Dataset]]", shot_channels_group.items() + "Sequence[tuple[str, h5py.Dataset]]", + shot_channels_group.items(), ) } if "vector_channels" in h5file: - vector_channels_group = cast("h5py.Group", h5file["vector_channels"]) - data.plot_windows["vector_channels"] = json.loads( - cast("str", vector_channels_group.attrs["Plot window metadata"]) + vector_channels_group = cast( + "h5py.Group", h5file["vector_channels"] ) + if "Plot window metadata" in vector_channels_group.attrs: + data.plot_windows["vector_channels"] = json.loads( + cast( + "str", + vector_channels_group.attrs[ + "Plot window metadata" + ], + ) + ) data.vector_channels = { channel_name: { int(data_point): vector_dataset[:].tolist() for data_point, vector_dataset in cast( - "Sequence[tuple[str, h5py.Dataset]]", vector_group.items() + "Sequence[tuple[str, h5py.Dataset]]", + vector_group.items(), ) } for channel_name, vector_group in cast( - "Sequence[tuple[str, h5py.Group]]", vector_channels_group.items() + "Sequence[tuple[str, h5py.Group]]", + vector_channels_group.items(), ) } diff --git a/src/icon/server/fitting/fit_runner.py b/src/icon/server/fitting/fit_runner.py index d50b3eb0..3fd182fc 100644 --- a/src/icon/server/fitting/fit_runner.py +++ b/src/icon/server/fitting/fit_runner.py @@ -135,6 +135,9 @@ def run_curve_fit( # noqa: PLR0913, C901 if func_type == "poly2" and result_dict.get("a", 0) != 0: result_dict["vertex"] = -result_dict["b"] / (2.0 * result_dict["a"]) + if func_type in ("harmonic", "damped_harmonic") and "omega" in result_dict: + result_dict["f"] = result_dict["omega"] / (2.0 * np.pi) + y_fit = model.func(x, *popt) goodness = _compute_goodness(y, y_fit, len(model.param_names)) diff --git a/tests/generate_test_hdf5.py b/tests/generate_test_hdf5.py index d5eadfc2..a017f29c 100644 --- a/tests/generate_test_hdf5.py +++ b/tests/generate_test_hdf5.py @@ -75,6 +75,7 @@ def _insert_job_and_run( scan_param = ScanParameter( job_id=job.id, + name=variable_id, variable_id=variable_id, scan_values=scan_values, ) @@ -287,6 +288,156 @@ def generate_damped_harmonic() -> int: ) +def _insert_job_and_run_2d( # noqa: PLR0913 + session: sqlalchemy.orm.Session, + source: ExperimentSource, + scan_values_x: list[float], + variable_id_x: str, + scan_values_y: list[float], + variable_id_y: str, + scheduled_time: datetime, +) -> tuple[Job, JobRun]: + """Insert a Job with two ScanParameters and a JobRun; return both ORM objects.""" + job = Job( + experiment_source_id=source.id, + status=JobStatus.PROCESSED, + repetitions=1, + number_of_shots=1, + ) + session.add(job) + session.flush() + + scan_param_x = ScanParameter( + job_id=job.id, + name=variable_id_x, + variable_id=variable_id_x, + scan_values=scan_values_x, + ) + scan_param_y = ScanParameter( + job_id=job.id, + name=variable_id_y, + variable_id=variable_id_y, + scan_values=scan_values_y, + ) + session.add(scan_param_x) + session.add(scan_param_y) + + run = JobRun( + job_id=job.id, + scheduled_time=scheduled_time, + status=JobRunStatus.DONE, + ) + session.add(run) + session.flush() + + return job, run + + +def _write_hdf5_2d( # noqa: PLR0913 + filepath: str, + job_id: int, + x_flat: np.ndarray, + y_flat: np.ndarray, + z: np.ndarray, + variable_id_x: str, + variable_id_y: str, + result_channel: str, +) -> None: + """Write a minimal ICON-compatible HDF5 file for a 2D scan.""" + n = len(z) + with h5py.File(filepath, "w") as h5: + h5.attrs["number_of_data_points"] = n + h5.attrs["number_of_shots"] = 1 + h5.attrs["experiment_id"] = _EXPERIMENT_ID + h5.attrs["job_id"] = job_id + h5.attrs["repetitions"] = 1 + h5.attrs["realtime_scan"] = False + + scan_dtype = [ + ("timestamp", "S26"), + (variable_id_x, np.float64), + (variable_id_y, np.float64), + ] + scan_ds = h5.create_dataset( + "scan_parameters", + shape=(n, 1), + dtype=scan_dtype, + compression="gzip", + ) + for i in range(n): + scan_ds[i] = (b"2025-01-01T00:00:00.000000", x_flat[i], y_flat[i]) + + result_dtype = [(result_channel, np.float64)] + result_ds = h5.create_dataset( + "result_channels", + shape=(n,), + dtype=result_dtype, + compression="gzip", + ) + result_ds[result_channel] = z + result_ds.attrs["Plot window metadata"] = json.dumps([ + {"name": "Results", "index": 0, "type": "readout", + "channel_names": [result_channel]}, + ]) + + shot_group = h5.create_group("shot_channels") + shot_group.attrs["Plot window metadata"] = json.dumps([]) + vector_group = h5.create_group("vector_channels") + vector_group.attrs["Plot window metadata"] = json.dumps([]) + h5.create_group("parameters") + + +def generate_2d_gaussian() -> int: + """2D Gaussian peak over frequency x amplitude grid. + + z = A * exp(-((x-x0)^2/(2*sx^2) + (y-y0)^2/(2*sy^2))) + offset + with A=5, x0=150, y0=5, sx=15, sy=2, offset=1. + """ + variable_id_x = "frequency" + variable_id_y = "amplitude" + result_channel = "counts" + scheduled_time = _BASE_TIME + timedelta(minutes=7) + + nx, ny = 20, 15 + x_vals = np.linspace(100, 200, nx) + y_vals = np.linspace(0, 10, ny) + + # Outer loop over x, inner loop over y + x_grid, y_grid = np.meshgrid(x_vals, y_vals, indexing="ij") + x_flat = x_grid.ravel() + y_flat = y_grid.ravel() + + # 2D Gaussian + amp, x0, y0, sx, sy, offset = 5.0, 150.0, 5.0, 15.0, 2.0, 1.0 + z = amp * np.exp(-( + (x_flat - x0) ** 2 / (2 * sx**2) + + (y_flat - y0) ** 2 / (2 * sy**2) + )) + offset + z += np.random.default_rng(42).normal(0, 0.1, len(z)) + + with sqlalchemy.orm.Session(engine) as session: + source = _get_or_create_experiment_source(session) + job, run = _insert_job_and_run_2d( + session, + source, + scan_values_x=x_vals.tolist(), + variable_id_x=variable_id_x, + scan_values_y=y_vals.tolist(), + variable_id_y=variable_id_y, + scheduled_time=scheduled_time, + ) + session.commit() + job_id = job.id + + filepath = _hdf5_path(scheduled_time) + _write_hdf5_2d( + filepath, job_id, x_flat, y_flat, z, + variable_id_x, variable_id_y, result_channel, + ) + print(f"[2d_gaussian] job_id={job_id} file={filepath}") + return job_id + + if __name__ == "__main__": print(f"Results dir : {get_config().data.results_dir}") print(f"Database : {get_config().databases.sqlite.file}") @@ -299,5 +450,6 @@ def generate_damped_harmonic() -> int: generate_poly2() generate_harmonic() generate_damped_harmonic() + generate_2d_gaussian() print("\nDone. Open http://localhost:8004 and go to the Data page to see the jobs.") From b4702e9460e2de10e8f911f1ed70322a7147a0f2 Mon Sep 17 00:00:00 2001 From: Antti Luomi Date: Tue, 14 Apr 2026 06:10:04 +0000 Subject: [PATCH 07/10] Move fit curve generation to backend, remove frontend model evaluation --- frontend/src/components/ResultChannelPlot.tsx | 20 ++--- frontend/src/types/ExperimentData.ts | 1 + .../src/utils/__tests__/fitFunctions.test.ts | 84 ++++--------------- frontend/src/utils/fitFunctions.ts | 44 ---------- src/icon/server/fitting/fit_runner.py | 19 +++-- src/icon/server/fitting/models.py | 36 +++++++- 6 files changed, 74 insertions(+), 130 deletions(-) diff --git a/frontend/src/components/ResultChannelPlot.tsx b/frontend/src/components/ResultChannelPlot.tsx index 42b60d2e..7b2e783c 100644 --- a/frontend/src/components/ResultChannelPlot.tsx +++ b/frontend/src/components/ResultChannelPlot.tsx @@ -7,7 +7,6 @@ import { useNotifications } from "@toolpad/core"; import { copyEChartsToClipboard } from "../utils/copyEChartsToClipboard"; import { ScanParameter } from "../types/ScanParameter"; import { buildResultChannelChartSeries } from "../utils/buildResultChannelChartSeries"; -import { evaluateFit } from "../utils/fitFunctions"; interface ResultChannelPlotProps { experimentData: ExperimentData; @@ -347,19 +346,14 @@ const ResultChannelPlot = ({ // Add fit curve overlays for 1D scans if (scanParameters.length === 1 && fits) { - const ordinaryEntry = scanInfo.find((p) => p.name !== "timestamp"); - const fitXArr = (ordinaryEntry?.scanValues ?? []) as number[]; - for (const [channelName, fitResult] of Object.entries(fits)) { - if (!fitResult.success || !channelNames.includes(channelName)) continue; - - const xMinVal = fitResult.x_range ? fitResult.x_range[0] : Math.min(...fitXArr); - const xMaxVal = fitResult.x_range ? fitResult.x_range[1] : Math.max(...fitXArr); - const nPoints = 200; - const step = (xMaxVal - xMinVal) / (nPoints - 1); - const fitX = Array.from({ length: nPoints }, (_, i) => xMinVal + i * step); - const fitY = evaluateFit(fitResult.func_type, fitResult.result, fitX); - const fitData = fitX.map((x, i) => [x, fitY[i]]); + if (!fitResult.success || !fitResult.fit_curve) continue; + if (!channelNames.includes(channelName)) continue; + + const fitData = fitResult.fit_curve.x.map((x, i) => [ + x, + fitResult.fit_curve!.y[i], + ]); (chartSeries as unknown[]).push({ name: `${channelName} fit`, diff --git a/frontend/src/types/ExperimentData.ts b/frontend/src/types/ExperimentData.ts index 005fecca..a699d623 100644 --- a/frontend/src/types/ExperimentData.ts +++ b/frontend/src/types/ExperimentData.ts @@ -37,6 +37,7 @@ export interface FitResult { goodness: Record; success: boolean; message: string; + fit_curve?: { x: number[]; y: number[] }; } export interface ExperimentData { diff --git a/frontend/src/utils/__tests__/fitFunctions.test.ts b/frontend/src/utils/__tests__/fitFunctions.test.ts index 92679d90..24577d52 100644 --- a/frontend/src/utils/__tests__/fitFunctions.test.ts +++ b/frontend/src/utils/__tests__/fitFunctions.test.ts @@ -1,72 +1,24 @@ -import { evaluateFit } from "../fitFunctions"; - -describe("evaluateFit", () => { - describe("lorentzian", () => { - it("should return peak value at x0", () => { - const params = { y0: 0, a: 10, x0: 5, gamma: 1 }; - const result = evaluateFit("lorentzian", params, [5]); - expect(result[0]).toBeCloseTo(10, 5); - }); - - it("should return baseline far from peak", () => { - const params = { y0: 3, a: 10, x0: 0, gamma: 1 }; - const result = evaluateFit("lorentzian", params, [1e6]); - expect(result[0]).toBeCloseTo(3, 1); - }); - - it("should be symmetric around x0", () => { - const params = { y0: 0, a: 1, x0: 5, gamma: 1 }; - const [left] = evaluateFit("lorentzian", params, [3]); - const [right] = evaluateFit("lorentzian", params, [7]); - expect(left).toBeCloseTo(right, 10); - }); +import { FIT_DEFAULT_UPDATE_PARAM, FIT_PARAM_NAMES, FIT_TYPES } from "../fitFunctions"; + +describe("fit model metadata", () => { + it("every FIT_TYPE has param names defined", () => { + for (const ft of FIT_TYPES) { + expect(FIT_PARAM_NAMES[ft]).toBeDefined(); + expect(FIT_PARAM_NAMES[ft].length).toBeGreaterThan(0); + } }); - describe("gaussian", () => { - it("should return peak value at x0", () => { - const params = { y0: 0, a: 5, x0: 3, sigma: 1 }; - const result = evaluateFit("gaussian", params, [3]); - expect(result[0]).toBeCloseTo(5, 5); - }); - }); - - describe("poly2", () => { - it("should compute quadratic correctly", () => { - const params = { a: 1, b: 2, c: 3 }; - const result = evaluateFit("poly2", params, [0, 1, 2]); - expect(result[0]).toBeCloseTo(3); - expect(result[1]).toBeCloseTo(6); - expect(result[2]).toBeCloseTo(11); - }); - }); - - describe("harmonic", () => { - it("should return y0 + A at phase 0", () => { - const params = { y0: 1, a: 2, omega: Math.PI, phi: 0 }; - const result = evaluateFit("harmonic", params, [0]); - expect(result[0]).toBeCloseTo(3); - }); - }); - - describe("damped_harmonic", () => { - it("should match harmonic with k=0", () => { - const x = [0, 1, 2, 3, 4, 5]; - const harmonicParams = { y0: 1, a: 2, omega: 3, phi: 0.5 }; - const dampedParams = { y0: 1, a: 2, k: 0, omega: 3, phi: 0.5 }; - - const harmonic = evaluateFit("harmonic", harmonicParams, x); - const damped = evaluateFit("damped_harmonic", dampedParams, x); - - for (let i = 0; i < x.length; i++) { - expect(damped[i]).toBeCloseTo(harmonic[i], 10); - } - }); + it("every FIT_TYPE has a default update param", () => { + for (const ft of FIT_TYPES) { + expect(FIT_DEFAULT_UPDATE_PARAM[ft]).toBeDefined(); + } }); - describe("unknown function", () => { - it("should return NaN for unknown types", () => { - const result = evaluateFit("unknown", {}, [1, 2, 3]); - expect(result.every((v) => isNaN(v))).toBe(true); - }); + it("FIT_TYPES contains expected models", () => { + expect(FIT_TYPES).toContain("lorentzian"); + expect(FIT_TYPES).toContain("gaussian"); + expect(FIT_TYPES).toContain("poly2"); + expect(FIT_TYPES).toContain("harmonic"); + expect(FIT_TYPES).toContain("damped_harmonic"); }); }); diff --git a/frontend/src/utils/fitFunctions.ts b/frontend/src/utils/fitFunctions.ts index 3bd11403..4cf4bca5 100644 --- a/frontend/src/utils/fitFunctions.ts +++ b/frontend/src/utils/fitFunctions.ts @@ -1,47 +1,3 @@ -/** - * Evaluate a fit function at the given x-values using fitted parameters. - */ -export function evaluateFit( - funcType: string, - params: Record, - xValues: number[], -): number[] { - switch (funcType) { - case "lorentzian": - return xValues.map((x) => { - const { y0, a, x0, gamma } = params; - return y0 + a / (1 + ((x - x0) / gamma) ** 2); - }); - - case "gaussian": - return xValues.map((x) => { - const { y0, a, x0, sigma } = params; - return y0 + a * Math.exp(-((x - x0) ** 2) / (2 * sigma ** 2)); - }); - - case "poly2": - return xValues.map((x) => { - const { a, b, c } = params; - return a * x ** 2 + b * x + c; - }); - - case "harmonic": - return xValues.map((x) => { - const { y0, a, omega, phi } = params; - return y0 + a * Math.cos(omega * x + phi); - }); - - case "damped_harmonic": - return xValues.map((x) => { - const { y0, a, k, omega, phi } = params; - return y0 + Math.exp(k * x) * a * Math.cos(omega * x + phi); - }); - - default: - return xValues.map(() => NaN); - } -} - /** Names of the fit parameters for each model type. */ export const FIT_PARAM_NAMES: Record = { lorentzian: ["y0", "a", "x0", "gamma"], diff --git a/src/icon/server/fitting/fit_runner.py b/src/icon/server/fitting/fit_runner.py index 3fd182fc..d8b7519d 100644 --- a/src/icon/server/fitting/fit_runner.py +++ b/src/icon/server/fitting/fit_runner.py @@ -15,6 +15,9 @@ _MAX_FIT_EVALS = 10000 +_FIT_CURVE_POINTS = 200 + + @dataclass class FitResult: """Result of a curve fit operation.""" @@ -27,6 +30,7 @@ class FitResult: goodness: dict[str, float] success: bool message: str + fit_curve: dict[str, list[float]] | None = None def _filter_valid( @@ -132,15 +136,19 @@ def run_curve_fit( # noqa: PLR0913, C901 result_dict = dict(zip(model.param_names, (float(v) for v in popt))) - if func_type == "poly2" and result_dict.get("a", 0) != 0: - result_dict["vertex"] = -result_dict["b"] / (2.0 * result_dict["a"]) - - if func_type in ("harmonic", "damped_harmonic") and "omega" in result_dict: - result_dict["f"] = result_dict["omega"] / (2.0 * np.pi) + if model.derived_params: + result_dict.update(model.derived_params(result_dict)) y_fit = model.func(x, *popt) goodness = _compute_goodness(y, y_fit, len(model.param_names)) + # Generate smooth fit curve for plotting + x_min, x_max = float(x.min()), float(x.max()) + step = (x_max - x_min) / (_FIT_CURVE_POINTS - 1) + curve_x = [x_min + i * step for i in range(_FIT_CURVE_POINTS)] + curve_y = model.func(np.array(curve_x), *popt).tolist() + fit_curve = {"x": curve_x, "y": curve_y} + return FitResult( result_channel=result_channel, func_type=func_type, @@ -150,4 +158,5 @@ def run_curve_fit( # noqa: PLR0913, C901 goodness=goodness, success=True, message="Fit converged", + fit_curve=fit_curve, ) diff --git a/src/icon/server/fitting/models.py b/src/icon/server/fitting/models.py index 0a035d0d..91c25851 100644 --- a/src/icon/server/fitting/models.py +++ b/src/icon/server/fitting/models.py @@ -28,6 +28,9 @@ class FitModel: param_names: list[str] default_update_param: str guess: Callable[[npt.NDArray[np.float64], npt.NDArray[np.float64]], list[float]] + derived_params: ( + Callable[[dict[str, float]], dict[str, float]] | None + ) = None def _lorentzian( @@ -169,6 +172,32 @@ def _damped_harmonic_guess( return [hg[0], hg[1], 0.0, hg[2], hg[3]] +def _poly2_derived(result: dict[str, float]) -> dict[str, float]: + a = result.get("a", 0) + if a != 0: + return {"vertex": -result["b"] / (2.0 * a)} + return {} + + +def _harmonic_derived(result: dict[str, float]) -> dict[str, float]: + if "omega" in result: + return {"f": result["omega"] / (2.0 * np.pi)} + return {} + + +# Adding a new fit model +# ---------------------- +# 1. Define _my_model(x, p1, p2, ...) -> NDArray (the model function). +# 2. Define _my_model_guess(x, y) -> list[float] (initial parameter estimator). +# 3. (Optional) Define _my_model_derived(result) -> dict if the model has +# useful derived quantities (e.g. vertex from quadratic coefficients). +# 4. Add an entry to FIT_MODELS below. The param_names order must match the +# function signature. default_update_param is the fitted value pre-filled +# in the "Update Parameter" UI (can be a derived param name). +# 5. Add the model name to FIT_TYPES, FIT_PARAM_NAMES, and +# FIT_DEFAULT_UPDATE_PARAM in frontend/src/utils/fitFunctions.ts. +# 6. Add tests in tests/server/fitting/. + FIT_MODELS: dict[str, FitModel] = { "lorentzian": FitModel( func=_lorentzian, @@ -187,17 +216,20 @@ def _damped_harmonic_guess( param_names=["a", "b", "c"], default_update_param="vertex", guess=_poly2_guess, + derived_params=_poly2_derived, ), "harmonic": FitModel( func=_harmonic, param_names=["y0", "a", "omega", "phi"], - default_update_param="omega", + default_update_param="f", guess=_harmonic_guess, + derived_params=_harmonic_derived, ), "damped_harmonic": FitModel( func=_damped_harmonic, param_names=["y0", "a", "k", "omega", "phi"], - default_update_param="omega", + default_update_param="f", guess=_damped_harmonic_guess, + derived_params=_harmonic_derived, ), } From d5eef5dc0bf1c354352280e69a273ea3d0156178 Mon Sep 17 00:00:00 2001 From: Antti Luomi Date: Tue, 14 Apr 2026 06:36:03 +0000 Subject: [PATCH 08/10] Auto-fit new jobs using the fit model from previous run of same experiment (if exists) --- src/icon/server/fitting/auto_fit.py | 144 +++++++++++++++++++++++ src/icon/server/pre_processing/worker.py | 6 + 2 files changed, 150 insertions(+) create mode 100644 src/icon/server/fitting/auto_fit.py diff --git a/src/icon/server/fitting/auto_fit.py b/src/icon/server/fitting/auto_fit.py new file mode 100644 index 00000000..760c9156 --- /dev/null +++ b/src/icon/server/fitting/auto_fit.py @@ -0,0 +1,144 @@ +"""Auto-fit: repeat the previous fit when a new job finishes.""" + +from __future__ import annotations + +import logging +from dataclasses import asdict +from typing import Any + +import numpy as np + +from icon.server.data_access.models.enums import JobStatus +from icon.server.data_access.repositories.experiment_data_repository import ( + ExperimentData, + ExperimentDataRepository, + get_fit_results_by_job_id, + write_fit_result_by_job_id, +) +from icon.server.data_access.repositories.job_repository import JobRepository +from icon.server.fitting.fit_runner import run_curve_fit +from icon.server.web_server.socketio_emit_queue import emit_queue + +logger = logging.getLogger(__name__) + + +def try_auto_fit(job_id: int, experiment_source_id: int) -> None: + """Auto-fit the new job using the fit config from the previous job. + + Looks up the most recent previous PROCESSED job for the same experiment + source that has a stored fit, then runs the same model on the new data + with fresh initial guesses. Failures are logged but never raised. + + Args: + job_id: The newly completed job. + experiment_source_id: The experiment source to find previous jobs. + """ + try: + _auto_fit(job_id, experiment_source_id) + except Exception: + logger.exception("Auto-fit failed for job %d", job_id) + + +def _auto_fit(job_id: int, experiment_source_id: int) -> None: + previous_job_id = _find_previous_job_with_fit( + experiment_source_id, exclude_job_id=job_id + ) + if previous_job_id is None: + return + + previous_fits = get_fit_results_by_job_id(job_id=previous_job_id) + if not previous_fits: + return + + data = ExperimentDataRepository.get_experiment_data_by_job_id( + job_id=job_id, + max_transfer_bytes=2**62, + ) + + scan_param_name = next( + (p for p in data.scan_parameters if p != "timestamp"), None + ) + if scan_param_name is None: + return + + for channel_name, fit_data in previous_fits.items(): + if fit_data.get("success") and fit_data.get("func_type"): + _fit_channel(job_id, data, scan_param_name, channel_name, fit_data) + + +def _fit_channel( + job_id: int, + data: ExperimentData, + scan_param_name: str, + channel_name: str, + fit_data: dict[str, Any], +) -> None: + """Run a single auto-fit for one channel and persist the result.""" + channel_values = data.result_channels.get(channel_name, {}) + if not channel_values: + return + + scan_values = data.scan_parameters[scan_param_name] + indices = sorted( + set(scan_values.keys()) & set(channel_values.keys()) + ) + x = np.array([float(scan_values[i]) for i in indices]) + y = np.array([float(channel_values[i]) for i in indices]) + + func_type = fit_data["func_type"] + fit_result = run_curve_fit( + x=x, + y=y, + result_channel=channel_name, + func_type=func_type, # type: ignore[arg-type] + ) + + if fit_result.success: + write_fit_result_by_job_id(job_id=job_id, fit_result=fit_result) + emit_queue.put({ + "event": f"experiment_fit_{job_id}", + "data": asdict(fit_result), + }) + logger.info( + "Auto-fit %s on job %d channel '%s' succeeded", + func_type, + job_id, + channel_name, + ) + else: + logger.warning( + "Auto-fit %s on job %d channel '%s' failed: %s", + func_type, + job_id, + channel_name, + fit_result.message, + ) + + +_MAX_PREVIOUS_JOBS_TO_CHECK = 10 + + +def _find_previous_job_with_fit( + experiment_source_id: int, + exclude_job_id: int, +) -> int | None: + """Find the most recent PROCESSED job with a stored fit. + + Only checks the last few jobs to avoid scanning the entire history. + """ + rows = JobRepository.get_job_by_experiment_source_and_status( + experiment_source_id=experiment_source_id, + status=JobStatus.PROCESSED, + ) + # Rows are ordered by (priority asc, created asc), so iterate in reverse + checked = 0 + for (job,) in reversed(rows): + if job.id == exclude_job_id: + continue + fits = get_fit_results_by_job_id(job_id=job.id) + if fits: + return job.id + checked += 1 + if checked >= _MAX_PREVIOUS_JOBS_TO_CHECK: + break + return None diff --git a/src/icon/server/pre_processing/worker.py b/src/icon/server/pre_processing/worker.py index a4cccc39..720a349c 100644 --- a/src/icon/server/pre_processing/worker.py +++ b/src/icon/server/pre_processing/worker.py @@ -38,6 +38,7 @@ from icon.server.data_access.repositories.pycrystal_library_repository import ( PycrystalLibraryRepository, ) +from icon.server.fitting.auto_fit import try_auto_fit from icon.server.hardware_processing.task import HardwareProcessingTask if TYPE_CHECKING: @@ -224,6 +225,11 @@ def run(self) -> None: run_id=pre_processing_task.job_run.id, status=JobRunStatus.DONE, ) + + try_auto_fit( + job_id=pre_processing_task.job.id, + experiment_source_id=pre_processing_task.job.experiment_source_id, + ) except Exception as e: logger.exception( "JobRun with id '%s' failed with error: %s", From 5c7bad2d781e1188ec9ae67e481e543c7789b814 Mon Sep 17 00:00:00 2001 From: Antti Luomi Date: Wed, 27 May 2026 08:49:50 +0000 Subject: [PATCH 09/10] npm run build --- .../server/frontend/assets/index-Ciiw6jzV.js | 45 ------------------- .../server/frontend/assets/index-DJ2kKtuL.js | 45 +++++++++++++++++++ src/icon/server/frontend/index.html | 2 +- 3 files changed, 46 insertions(+), 46 deletions(-) delete mode 100644 src/icon/server/frontend/assets/index-Ciiw6jzV.js create mode 100644 src/icon/server/frontend/assets/index-DJ2kKtuL.js diff --git a/src/icon/server/frontend/assets/index-Ciiw6jzV.js b/src/icon/server/frontend/assets/index-Ciiw6jzV.js deleted file mode 100644 index f1b5e5a7..00000000 --- a/src/icon/server/frontend/assets/index-Ciiw6jzV.js +++ /dev/null @@ -1,45 +0,0 @@ -import{r as db,a as hb,b as y,c as rt,j as d,u as Mr,S as mb,d as pb,B as vb,A as Sv,e as yb,f as $t,I as xt,g as gb,h as Jc,T as Ev,i as bb,C as wv,k as Cv,l as il,m as xb,n as $p,o as bt,s as Uf,p as be,q as gn,t as _v,v as Wn,P as Sb,D as Or,w as vf,x as Eb,L as wb,y as Cb,z as wr,G as _b,E as Bf,F as Tb,H as Cr,J as _r,K as Tr,M as Fc,N as Tv,O as jv,Q as So,R as rl,U as Dv,V as Rn,W as sl,X as ol,Y as ea,Z as jb,_ as Rv,$ as Db,a0 as ti,a1 as ni,a2 as yn,a3 as vr,a4 as ft,a5 as Rb,a6 as Ab,a7 as yf,a8 as gf,a9 as bf,aa as Mb,ab as Av,ac as Ob,ad as Nb,ae as zb,af as Lb,ag as Ub,ah as Da}from"./mui-bAivgoRt.js";import{u as Bb,i as Hb,a as Vb,b as kb,c as qb,d as Yb,e as Gb,f as Xb,g as Qb,h as Pb,j as Zb,k as Kb,l as $b}from"./echarts-D1BCANXe.js";import"./zrender-Co-hdh87.js";(function(){const l=document.createElement("link").relList;if(l&&l.supports&&l.supports("modulepreload"))return;for(const u of document.querySelectorAll('link[rel="modulepreload"]'))s(u);new MutationObserver(u=>{for(const f of u)if(f.type==="childList")for(const h of f.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&s(h)}).observe(document,{childList:!0,subtree:!0});function r(u){const f={};return u.integrity&&(f.integrity=u.integrity),u.referrerPolicy&&(f.referrerPolicy=u.referrerPolicy),u.crossOrigin==="use-credentials"?f.credentials="include":u.crossOrigin==="anonymous"?f.credentials="omit":f.credentials="same-origin",f}function s(u){if(u.ep)return;u.ep=!0;const f=r(u);fetch(u.href,f)}})();var Ic={exports:{}},cr={},Wc={exports:{}},ef={};/** - * @license React - * scheduler.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Jp;function Jb(){return Jp||(Jp=1,(function(a){function l(A,P){var te=A.length;A.push(P);e:for(;0>>1,ee=A[xe];if(0>>1;xeu(ce,te))Ueu(pt,ce)?(A[xe]=pt,A[Ue]=te,xe=Ue):(A[xe]=ce,A[Te]=te,xe=Te);else if(Ueu(pt,te))A[xe]=pt,A[Ue]=te,xe=Ue;else break e}}return P}function u(A,P){var te=A.sortIndex-P.sortIndex;return te!==0?te:A.id-P.id}if(a.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var f=performance;a.unstable_now=function(){return f.now()}}else{var h=Date,m=h.now();a.unstable_now=function(){return h.now()-m}}var p=[],g=[],b=1,S=null,E=3,w=!1,T=!1,O=!1,C=!1,N=typeof setTimeout=="function"?setTimeout:null,V=typeof clearTimeout=="function"?clearTimeout:null,G=typeof setImmediate<"u"?setImmediate:null;function K(A){for(var P=r(g);P!==null;){if(P.callback===null)s(g);else if(P.startTime<=A)s(g),P.sortIndex=P.expirationTime,l(p,P);else break;P=r(g)}}function J(A){if(O=!1,K(A),!T)if(r(p)!==null)T=!0,D||(D=!0,W());else{var P=r(g);P!==null&&ie(J,P.startTime-A)}}var D=!1,I=-1,ne=5,ae=-1;function le(){return C?!0:!(a.unstable_now()-aeA&&le());){var xe=S.callback;if(typeof xe=="function"){S.callback=null,E=S.priorityLevel;var ee=xe(S.expirationTime<=A);if(A=a.unstable_now(),typeof ee=="function"){S.callback=ee,K(A),P=!0;break t}S===r(p)&&s(p),K(A)}else s(p);S=r(p)}if(S!==null)P=!0;else{var Ee=r(g);Ee!==null&&ie(J,Ee.startTime-A),P=!1}}break e}finally{S=null,E=te,w=!1}P=void 0}}finally{P?W():D=!1}}}var W;if(typeof G=="function")W=function(){G(pe)};else if(typeof MessageChannel<"u"){var q=new MessageChannel,$=q.port2;q.port1.onmessage=pe,W=function(){$.postMessage(null)}}else W=function(){N(pe,0)};function ie(A,P){I=N(function(){A(a.unstable_now())},P)}a.unstable_IdlePriority=5,a.unstable_ImmediatePriority=1,a.unstable_LowPriority=4,a.unstable_NormalPriority=3,a.unstable_Profiling=null,a.unstable_UserBlockingPriority=2,a.unstable_cancelCallback=function(A){A.callback=null},a.unstable_forceFrameRate=function(A){0>A||125xe?(A.sortIndex=te,l(g,A),r(p)===null&&A===r(g)&&(O?(V(I),I=-1):O=!0,ie(J,te-xe))):(A.sortIndex=ee,l(p,A),T||w||(T=!0,D||(D=!0,W()))),A},a.unstable_shouldYield=le,a.unstable_wrapCallback=function(A){var P=E;return function(){var te=E;E=P;try{return A.apply(this,arguments)}finally{E=te}}}})(ef)),ef}var Fp;function Fb(){return Fp||(Fp=1,Wc.exports=Jb()),Wc.exports}/** - * @license React - * react-dom-client.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Ip;function Ib(){if(Ip)return cr;Ip=1;var a=Fb(),l=db(),r=hb();function s(e){var t="https://react.dev/errors/"+e;if(1ee||(e.current=xe[ee],xe[ee]=null,ee--)}function ce(e,t){ee++,xe[ee]=e.current,e.current=t}var Ue=Ee(null),pt=Ee(null),Mt=Ee(null),dl=Ee(null);function hl(e,t){switch(ce(Mt,t),ce(pt,e),ce(Ue,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?wp(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=wp(t),e=Cp(t,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}Te(Ue),ce(Ue,e)}function na(){Te(Ue),Te(pt),Te(Mt)}function st(e){e.memoizedState!==null&&ce(dl,e);var t=Ue.current,n=Cp(t,e.type);t!==n&&(ce(pt,e),ce(Ue,n))}function cn(e){pt.current===e&&(Te(Ue),Te(pt)),dl.current===e&&(Te(dl),ir._currentValue=te)}var ml=Object.prototype.hasOwnProperty,mi=a.unstable_scheduleCallback,fn=a.unstable_cancelCallback,Go=a.unstable_shouldYield,Xo=a.unstable_requestPaint,Vt=a.unstable_now,Qo=a.unstable_getCurrentPriorityLevel,kr=a.unstable_ImmediatePriority,qr=a.unstable_UserBlockingPriority,pl=a.unstable_NormalPriority,Nn=a.unstable_LowPriority,aa=a.unstable_IdlePriority,Yr=a.log,pi=a.unstable_setDisableYieldValue,Ot=null,Je=null;function dn(e){if(typeof Yr=="function"&&pi(e),Je&&typeof Je.setStrictMode=="function")try{Je.setStrictMode(Ot,e)}catch{}}var St=Math.clz32?Math.clz32:Gr,Po=Math.log,xn=Math.LN2;function Gr(e){return e>>>=0,e===0?32:31-(Po(e)/xn|0)|0}var Ba=256,Ha=4194304;function zn(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Va(e,t,n){var i=e.pendingLanes;if(i===0)return 0;var o=0,c=e.suspendedLanes,v=e.pingedLanes;e=e.warmLanes;var x=i&134217727;return x!==0?(i=x&~c,i!==0?o=zn(i):(v&=x,v!==0?o=zn(v):n||(n=x&~e,n!==0&&(o=zn(n))))):(x=i&~c,x!==0?o=zn(x):v!==0?o=zn(v):n||(n=i&~e,n!==0&&(o=zn(n)))),o===0?0:t!==0&&t!==o&&(t&c)===0&&(c=o&-o,n=t&-t,c>=n||c===32&&(n&4194048)!==0)?t:o}function Sn(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Xr(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function vl(){var e=Ba;return Ba<<=1,(Ba&4194048)===0&&(Ba=256),e}function Qr(){var e=Ha;return Ha<<=1,(Ha&62914560)===0&&(Ha=4194304),e}function yl(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function ka(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Pr(e,t,n,i,o,c){var v=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var x=e.entanglements,_=e.expirationTimes,U=e.hiddenUpdates;for(n=v&~n;0)":-1o||_[i]!==U[o]){var Y=` -`+_[i].replace(" at new "," at ");return e.displayName&&Y.includes("")&&(Y=Y.replace("",e.displayName)),Y}while(1<=i&&0<=o);break}}}finally{Fe=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:"")?Nt(n):""}function Zr(e){switch(e.tag){case 26:case 27:case 5:return Nt(e.type);case 16:return Nt("Lazy");case 13:return Nt("Suspense");case 19:return Nt("SuspenseList");case 0:case 15:return ia(e.type,!1);case 11:return ia(e.type.render,!1);case 1:return ia(e.type,!0);case 31:return Nt("Activity");default:return""}}function Kr(e){try{var t="";do t+=Zr(e),e=e.return;while(e);return t}catch(n){return` -Error generating stack: `+n.message+` -`+n.stack}}function Ft(e){switch(typeof e){case"bigint":case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function gd(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function sg(e){var t=gd(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),i=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var o=n.get,c=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return o.call(this)},set:function(v){i=""+v,c.call(this,v)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return i},setValue:function(v){i=""+v},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function $r(e){e._valueTracker||(e._valueTracker=sg(e))}function bd(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),i="";return e&&(i=gd(e)?e.checked?"true":"false":e.value),e=i,e!==n?(t.setValue(e),!0):!1}function Jr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var og=/[\n"\\]/g;function It(e){return e.replace(og,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function Zo(e,t,n,i,o,c,v,x){e.name="",v!=null&&typeof v!="function"&&typeof v!="symbol"&&typeof v!="boolean"?e.type=v:e.removeAttribute("type"),t!=null?v==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+Ft(t)):e.value!==""+Ft(t)&&(e.value=""+Ft(t)):v!=="submit"&&v!=="reset"||e.removeAttribute("value"),t!=null?Ko(e,v,Ft(t)):n!=null?Ko(e,v,Ft(n)):i!=null&&e.removeAttribute("value"),o==null&&c!=null&&(e.defaultChecked=!!c),o!=null&&(e.checked=o&&typeof o!="function"&&typeof o!="symbol"),x!=null&&typeof x!="function"&&typeof x!="symbol"&&typeof x!="boolean"?e.name=""+Ft(x):e.removeAttribute("name")}function xd(e,t,n,i,o,c,v,x){if(c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"&&(e.type=c),t!=null||n!=null){if(!(c!=="submit"&&c!=="reset"||t!=null))return;n=n!=null?""+Ft(n):"",t=t!=null?""+Ft(t):n,x||t===e.value||(e.value=t),e.defaultValue=t}i=i??o,i=typeof i!="function"&&typeof i!="symbol"&&!!i,e.checked=x?e.checked:!!i,e.defaultChecked=!!i,v!=null&&typeof v!="function"&&typeof v!="symbol"&&typeof v!="boolean"&&(e.name=v)}function Ko(e,t,n){t==="number"&&Jr(e.ownerDocument)===e||e.defaultValue===""+n||(e.defaultValue=""+n)}function bl(e,t,n,i){if(e=e.options,t){t={};for(var o=0;o"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Wo=!1;if(Vn)try{var bi={};Object.defineProperty(bi,"passive",{get:function(){Wo=!0}}),window.addEventListener("test",bi,bi),window.removeEventListener("test",bi,bi)}catch{Wo=!1}var ra=null,eu=null,Ir=null;function jd(){if(Ir)return Ir;var e,t=eu,n=t.length,i,o="value"in ra?ra.value:ra.textContent,c=o.length;for(e=0;e=Ei),Nd=" ",zd=!1;function Ld(e,t){switch(e){case"keyup":return Ug.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Ud(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var wl=!1;function Hg(e,t){switch(e){case"compositionend":return Ud(t);case"keypress":return t.which!==32?null:(zd=!0,Nd);case"textInput":return e=t.data,e===Nd&&zd?null:e;default:return null}}function Vg(e,t){if(wl)return e==="compositionend"||!iu&&Ld(e,t)?(e=jd(),Ir=eu=ra=null,wl=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=i}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=Xd(n)}}function Pd(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Pd(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Zd(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Jr(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Jr(e.document)}return t}function ou(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}var Zg=Vn&&"documentMode"in document&&11>=document.documentMode,Cl=null,uu=null,Ti=null,cu=!1;function Kd(e,t,n){var i=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;cu||Cl==null||Cl!==Jr(i)||(i=Cl,"selectionStart"in i&&ou(i)?i={start:i.selectionStart,end:i.selectionEnd}:(i=(i.ownerDocument&&i.ownerDocument.defaultView||window).getSelection(),i={anchorNode:i.anchorNode,anchorOffset:i.anchorOffset,focusNode:i.focusNode,focusOffset:i.focusOffset}),Ti&&_i(Ti,i)||(Ti=i,i=Ys(uu,"onSelect"),0>=v,o-=v,qn=1<<32-St(t)+o|n<c?c:8;var v=A.T,x={};A.T=x,$u(e,!1,t,n);try{var _=o(),U=A.S;if(U!==null&&U(x,_),_!==null&&typeof _=="object"&&typeof _.then=="function"){var Y=n1(_,i);qi(e,t,Y,Pt(e))}else qi(e,t,i,Pt(e))}catch(Q){qi(e,t,{then:function(){},status:"rejected",reason:Q},Pt())}finally{P.p=c,A.T=v}}function s1(){}function Zu(e,t,n,i){if(e.tag!==5)throw Error(s(476));var o=$h(e).queue;Kh(e,o,t,te,n===null?s1:function(){return Jh(e),n(i)})}function $h(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:te,baseState:te,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Qn,lastRenderedState:te},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Qn,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function Jh(e){var t=$h(e).next.queue;qi(e,t,{},Pt())}function Ku(){return Tt(ir)}function Fh(){return ct().memoizedState}function Ih(){return ct().memoizedState}function o1(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=Pt();e=ua(n);var i=ca(t,e,n);i!==null&&(Zt(i,t,n),Li(i,t,n)),t={cache:Cu()},e.payload=t;return}t=t.return}}function u1(e,t,n){var i=Pt();n={lane:i,revertLane:0,action:n,hasEagerState:!1,eagerState:null,next:null},Es(e)?em(t,n):(n=mu(e,t,n,i),n!==null&&(Zt(n,e,i),tm(n,t,i)))}function Wh(e,t,n){var i=Pt();qi(e,t,n,i)}function qi(e,t,n,i){var o={lane:i,revertLane:0,action:n,hasEagerState:!1,eagerState:null,next:null};if(Es(e))em(t,o);else{var c=e.alternate;if(e.lanes===0&&(c===null||c.lanes===0)&&(c=t.lastRenderedReducer,c!==null))try{var v=t.lastRenderedState,x=c(v,n);if(o.hasEagerState=!0,o.eagerState=x,qt(x,v))return is(e,t,o,0),Pe===null&&ls(),!1}catch{}finally{}if(n=mu(e,t,o,i),n!==null)return Zt(n,e,i),tm(n,t,i),!0}return!1}function $u(e,t,n,i){if(i={lane:2,revertLane:jc(),action:i,hasEagerState:!1,eagerState:null,next:null},Es(e)){if(t)throw Error(s(479))}else t=mu(e,n,i,2),t!==null&&Zt(t,e,2)}function Es(e){var t=e.alternate;return e===De||t!==null&&t===De}function em(e,t){zl=vs=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function tm(e,t,n){if((n&4194048)!==0){var i=t.lanes;i&=e.pendingLanes,n|=i,t.lanes=n,Ya(e,n)}}var ws={readContext:Tt,use:gs,useCallback:nt,useContext:nt,useEffect:nt,useImperativeHandle:nt,useLayoutEffect:nt,useInsertionEffect:nt,useMemo:nt,useReducer:nt,useRef:nt,useState:nt,useDebugValue:nt,useDeferredValue:nt,useTransition:nt,useSyncExternalStore:nt,useId:nt,useHostTransitionStatus:nt,useFormState:nt,useActionState:nt,useOptimistic:nt,useMemoCache:nt,useCacheRefresh:nt},nm={readContext:Tt,use:gs,useCallback:function(e,t){return Lt().memoizedState=[e,t===void 0?null:t],e},useContext:Tt,useEffect:Vh,useImperativeHandle:function(e,t,n){n=n!=null?n.concat([e]):null,Ss(4194308,4,Gh.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Ss(4194308,4,e,t)},useInsertionEffect:function(e,t){Ss(4,2,e,t)},useMemo:function(e,t){var n=Lt();t=t===void 0?null:t;var i=e();if(el){dn(!0);try{e()}finally{dn(!1)}}return n.memoizedState=[i,t],i},useReducer:function(e,t,n){var i=Lt();if(n!==void 0){var o=n(t);if(el){dn(!0);try{n(t)}finally{dn(!1)}}}else o=t;return i.memoizedState=i.baseState=o,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:o},i.queue=e,e=e.dispatch=u1.bind(null,De,e),[i.memoizedState,e]},useRef:function(e){var t=Lt();return e={current:e},t.memoizedState=e},useState:function(e){e=Gu(e);var t=e.queue,n=Wh.bind(null,De,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:Qu,useDeferredValue:function(e,t){var n=Lt();return Pu(n,e,t)},useTransition:function(){var e=Gu(!1);return e=Kh.bind(null,De,e.queue,!0,!1),Lt().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var i=De,o=Lt();if(He){if(n===void 0)throw Error(s(407));n=n()}else{if(n=t(),Pe===null)throw Error(s(349));(ze&124)!==0||wh(i,t,n)}o.memoizedState=n;var c={value:n,getSnapshot:t};return o.queue=c,Vh(_h.bind(null,i,c,e),[e]),i.flags|=2048,Ul(9,xs(),Ch.bind(null,i,c,n,t),null),n},useId:function(){var e=Lt(),t=Pe.identifierPrefix;if(He){var n=Yn,i=qn;n=(i&~(1<<32-St(i)-1)).toString(32)+n,t="«"+t+"R"+n,n=ys++,0ge?(gt=he,he=null):gt=he.sibling;var Le=B(M,he,L[ge],X);if(Le===null){he===null&&(he=gt);break}e&&he&&Le.alternate===null&&t(M,he),R=c(Le,R,ge),Re===null?re=Le:Re.sibling=Le,Re=Le,he=gt}if(ge===L.length)return n(M,he),He&&Ka(M,ge),re;if(he===null){for(;gege?(gt=he,he=null):gt=he.sibling;var ja=B(M,he,Le.value,X);if(ja===null){he===null&&(he=gt);break}e&&he&&ja.alternate===null&&t(M,he),R=c(ja,R,ge),Re===null?re=ja:Re.sibling=ja,Re=ja,he=gt}if(Le.done)return n(M,he),He&&Ka(M,ge),re;if(he===null){for(;!Le.done;ge++,Le=L.next())Le=Q(M,Le.value,X),Le!==null&&(R=c(Le,R,ge),Re===null?re=Le:Re.sibling=Le,Re=Le);return He&&Ka(M,ge),re}for(he=i(he);!Le.done;ge++,Le=L.next())Le=H(he,M,ge,Le.value,X),Le!==null&&(e&&Le.alternate!==null&&he.delete(Le.key===null?ge:Le.key),R=c(Le,R,ge),Re===null?re=Le:Re.sibling=Le,Re=Le);return e&&he.forEach(function(fb){return t(M,fb)}),He&&Ka(M,ge),re}function Xe(M,R,L,X){if(typeof L=="object"&&L!==null&&L.type===T&&L.key===null&&(L=L.props.children),typeof L=="object"&&L!==null){switch(L.$$typeof){case E:e:{for(var re=L.key;R!==null;){if(R.key===re){if(re=L.type,re===T){if(R.tag===7){n(M,R.sibling),X=o(R,L.props.children),X.return=M,M=X;break e}}else if(R.elementType===re||typeof re=="object"&&re!==null&&re.$$typeof===ne&&lm(re)===R.type){n(M,R.sibling),X=o(R,L.props),Gi(X,L),X.return=M,M=X;break e}n(M,R);break}else t(M,R);R=R.sibling}L.type===T?(X=Pa(L.props.children,M.mode,X,L.key),X.return=M,M=X):(X=ss(L.type,L.key,L.props,null,M.mode,X),Gi(X,L),X.return=M,M=X)}return v(M);case w:e:{for(re=L.key;R!==null;){if(R.key===re)if(R.tag===4&&R.stateNode.containerInfo===L.containerInfo&&R.stateNode.implementation===L.implementation){n(M,R.sibling),X=o(R,L.children||[]),X.return=M,M=X;break e}else{n(M,R);break}else t(M,R);R=R.sibling}X=yu(L,M.mode,X),X.return=M,M=X}return v(M);case ne:return re=L._init,L=re(L._payload),Xe(M,R,L,X)}if(ie(L))return Se(M,R,L,X);if(W(L)){if(re=W(L),typeof re!="function")throw Error(s(150));return L=re.call(L),ye(M,R,L,X)}if(typeof L.then=="function")return Xe(M,R,Cs(L),X);if(L.$$typeof===G)return Xe(M,R,fs(M,L),X);_s(M,L)}return typeof L=="string"&&L!==""||typeof L=="number"||typeof L=="bigint"?(L=""+L,R!==null&&R.tag===6?(n(M,R.sibling),X=o(R,L),X.return=M,M=X):(n(M,R),X=vu(L,M.mode,X),X.return=M,M=X),v(M)):n(M,R)}return function(M,R,L,X){try{Yi=0;var re=Xe(M,R,L,X);return Bl=null,re}catch(he){if(he===Ni||he===hs)throw he;var Re=Yt(29,he,null,M.mode);return Re.lanes=X,Re.return=M,Re}finally{}}}var Hl=im(!0),rm=im(!1),an=Ee(null),Cn=null;function da(e){var t=e.alternate;ce(mt,mt.current&1),ce(an,e),Cn===null&&(t===null||Nl.current!==null||t.memoizedState!==null)&&(Cn=e)}function sm(e){if(e.tag===22){if(ce(mt,mt.current),ce(an,e),Cn===null){var t=e.alternate;t!==null&&t.memoizedState!==null&&(Cn=e)}}else ha()}function ha(){ce(mt,mt.current),ce(an,an.current)}function Pn(e){Te(an),Cn===e&&(Cn=null),Te(mt)}var mt=Ee(0);function Ts(e){for(var t=e;t!==null;){if(t.tag===13){var n=t.memoizedState;if(n!==null&&(n=n.dehydrated,n===null||n.data==="$?"||Vc(n)))return t}else if(t.tag===19&&t.memoizedProps.revealOrder!==void 0){if((t.flags&128)!==0)return t}else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}function Ju(e,t,n,i){t=e.memoizedState,n=n(i,t),n=n==null?t:b({},t,n),e.memoizedState=n,e.lanes===0&&(e.updateQueue.baseState=n)}var Fu={enqueueSetState:function(e,t,n){e=e._reactInternals;var i=Pt(),o=ua(i);o.payload=t,n!=null&&(o.callback=n),t=ca(e,o,i),t!==null&&(Zt(t,e,i),Li(t,e,i))},enqueueReplaceState:function(e,t,n){e=e._reactInternals;var i=Pt(),o=ua(i);o.tag=1,o.payload=t,n!=null&&(o.callback=n),t=ca(e,o,i),t!==null&&(Zt(t,e,i),Li(t,e,i))},enqueueForceUpdate:function(e,t){e=e._reactInternals;var n=Pt(),i=ua(n);i.tag=2,t!=null&&(i.callback=t),t=ca(e,i,n),t!==null&&(Zt(t,e,n),Li(t,e,n))}};function om(e,t,n,i,o,c,v){return e=e.stateNode,typeof e.shouldComponentUpdate=="function"?e.shouldComponentUpdate(i,c,v):t.prototype&&t.prototype.isPureReactComponent?!_i(n,i)||!_i(o,c):!0}function um(e,t,n,i){e=t.state,typeof t.componentWillReceiveProps=="function"&&t.componentWillReceiveProps(n,i),typeof t.UNSAFE_componentWillReceiveProps=="function"&&t.UNSAFE_componentWillReceiveProps(n,i),t.state!==e&&Fu.enqueueReplaceState(t,t.state,null)}function tl(e,t){var n=t;if("ref"in t){n={};for(var i in t)i!=="ref"&&(n[i]=t[i])}if(e=e.defaultProps){n===t&&(n=b({},n));for(var o in e)n[o]===void 0&&(n[o]=e[o])}return n}var js=typeof reportError=="function"?reportError:function(e){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var t=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof e=="object"&&e!==null&&typeof e.message=="string"?String(e.message):String(e),error:e});if(!window.dispatchEvent(t))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",e);return}console.error(e)};function cm(e){js(e)}function fm(e){console.error(e)}function dm(e){js(e)}function Ds(e,t){try{var n=e.onUncaughtError;n(t.value,{componentStack:t.stack})}catch(i){setTimeout(function(){throw i})}}function hm(e,t,n){try{var i=e.onCaughtError;i(n.value,{componentStack:n.stack,errorBoundary:t.tag===1?t.stateNode:null})}catch(o){setTimeout(function(){throw o})}}function Iu(e,t,n){return n=ua(n),n.tag=3,n.payload={element:null},n.callback=function(){Ds(e,t)},n}function mm(e){return e=ua(e),e.tag=3,e}function pm(e,t,n,i){var o=n.type.getDerivedStateFromError;if(typeof o=="function"){var c=i.value;e.payload=function(){return o(c)},e.callback=function(){hm(t,n,i)}}var v=n.stateNode;v!==null&&typeof v.componentDidCatch=="function"&&(e.callback=function(){hm(t,n,i),typeof o!="function"&&(ba===null?ba=new Set([this]):ba.add(this));var x=i.stack;this.componentDidCatch(i.value,{componentStack:x!==null?x:""})})}function f1(e,t,n,i,o){if(n.flags|=32768,i!==null&&typeof i=="object"&&typeof i.then=="function"){if(t=n.alternate,t!==null&&Ai(t,n,o,!0),n=an.current,n!==null){switch(n.tag){case 13:return Cn===null?Ec():n.alternate===null&&tt===0&&(tt=3),n.flags&=-257,n.flags|=65536,n.lanes=o,i===ju?n.flags|=16384:(t=n.updateQueue,t===null?n.updateQueue=new Set([i]):t.add(i),Cc(e,i,o)),!1;case 22:return n.flags|=65536,i===ju?n.flags|=16384:(t=n.updateQueue,t===null?(t={transitions:null,markerInstances:null,retryQueue:new Set([i])},n.updateQueue=t):(n=t.retryQueue,n===null?t.retryQueue=new Set([i]):n.add(i)),Cc(e,i,o)),!1}throw Error(s(435,n.tag))}return Cc(e,i,o),Ec(),!1}if(He)return t=an.current,t!==null?((t.flags&65536)===0&&(t.flags|=256),t.flags|=65536,t.lanes=o,i!==xu&&(e=Error(s(422),{cause:i}),Ri(Wt(e,n)))):(i!==xu&&(t=Error(s(423),{cause:i}),Ri(Wt(t,n))),e=e.current.alternate,e.flags|=65536,o&=-o,e.lanes|=o,i=Wt(i,n),o=Iu(e.stateNode,i,o),Au(e,o),tt!==4&&(tt=2)),!1;var c=Error(s(520),{cause:i});if(c=Wt(c,n),Ji===null?Ji=[c]:Ji.push(c),tt!==4&&(tt=2),t===null)return!0;i=Wt(i,n),n=t;do{switch(n.tag){case 3:return n.flags|=65536,e=o&-o,n.lanes|=e,e=Iu(n.stateNode,i,e),Au(n,e),!1;case 1:if(t=n.type,c=n.stateNode,(n.flags&128)===0&&(typeof t.getDerivedStateFromError=="function"||c!==null&&typeof c.componentDidCatch=="function"&&(ba===null||!ba.has(c))))return n.flags|=65536,o&=-o,n.lanes|=o,o=mm(o),pm(o,e,n,i),Au(n,o),!1}n=n.return}while(n!==null);return!1}var vm=Error(s(461)),vt=!1;function Et(e,t,n,i){t.child=e===null?rm(t,null,n,i):Hl(t,e.child,n,i)}function ym(e,t,n,i,o){n=n.render;var c=t.ref;if("ref"in i){var v={};for(var x in i)x!=="ref"&&(v[x]=i[x])}else v=i;return Ia(t),i=Lu(e,t,n,v,c,o),x=Uu(),e!==null&&!vt?(Bu(e,t,o),Zn(e,t,o)):(He&&x&&gu(t),t.flags|=1,Et(e,t,i,o),t.child)}function gm(e,t,n,i,o){if(e===null){var c=n.type;return typeof c=="function"&&!pu(c)&&c.defaultProps===void 0&&n.compare===null?(t.tag=15,t.type=c,bm(e,t,c,i,o)):(e=ss(n.type,null,i,t,t.mode,o),e.ref=t.ref,e.return=t,t.child=e)}if(c=e.child,!rc(e,o)){var v=c.memoizedProps;if(n=n.compare,n=n!==null?n:_i,n(v,i)&&e.ref===t.ref)return Zn(e,t,o)}return t.flags|=1,e=kn(c,i),e.ref=t.ref,e.return=t,t.child=e}function bm(e,t,n,i,o){if(e!==null){var c=e.memoizedProps;if(_i(c,i)&&e.ref===t.ref)if(vt=!1,t.pendingProps=i=c,rc(e,o))(e.flags&131072)!==0&&(vt=!0);else return t.lanes=e.lanes,Zn(e,t,o)}return Wu(e,t,n,i,o)}function xm(e,t,n){var i=t.pendingProps,o=i.children,c=e!==null?e.memoizedState:null;if(i.mode==="hidden"){if((t.flags&128)!==0){if(i=c!==null?c.baseLanes|n:n,e!==null){for(o=t.child=e.child,c=0;o!==null;)c=c|o.lanes|o.childLanes,o=o.sibling;t.childLanes=c&~i}else t.childLanes=0,t.child=null;return Sm(e,t,i,n)}if((n&536870912)!==0)t.memoizedState={baseLanes:0,cachePool:null},e!==null&&ds(t,c!==null?c.cachePool:null),c!==null?bh(t,c):Ou(),sm(t);else return t.lanes=t.childLanes=536870912,Sm(e,t,c!==null?c.baseLanes|n:n,n)}else c!==null?(ds(t,c.cachePool),bh(t,c),ha(),t.memoizedState=null):(e!==null&&ds(t,null),Ou(),ha());return Et(e,t,o,n),t.child}function Sm(e,t,n,i){var o=Tu();return o=o===null?null:{parent:ht._currentValue,pool:o},t.memoizedState={baseLanes:n,cachePool:o},e!==null&&ds(t,null),Ou(),sm(t),e!==null&&Ai(e,t,i,!0),null}function Rs(e,t){var n=t.ref;if(n===null)e!==null&&e.ref!==null&&(t.flags|=4194816);else{if(typeof n!="function"&&typeof n!="object")throw Error(s(284));(e===null||e.ref!==n)&&(t.flags|=4194816)}}function Wu(e,t,n,i,o){return Ia(t),n=Lu(e,t,n,i,void 0,o),i=Uu(),e!==null&&!vt?(Bu(e,t,o),Zn(e,t,o)):(He&&i&&gu(t),t.flags|=1,Et(e,t,n,o),t.child)}function Em(e,t,n,i,o,c){return Ia(t),t.updateQueue=null,n=Sh(t,i,n,o),xh(e),i=Uu(),e!==null&&!vt?(Bu(e,t,c),Zn(e,t,c)):(He&&i&&gu(t),t.flags|=1,Et(e,t,n,c),t.child)}function wm(e,t,n,i,o){if(Ia(t),t.stateNode===null){var c=Dl,v=n.contextType;typeof v=="object"&&v!==null&&(c=Tt(v)),c=new n(i,c),t.memoizedState=c.state!==null&&c.state!==void 0?c.state:null,c.updater=Fu,t.stateNode=c,c._reactInternals=t,c=t.stateNode,c.props=i,c.state=t.memoizedState,c.refs={},Du(t),v=n.contextType,c.context=typeof v=="object"&&v!==null?Tt(v):Dl,c.state=t.memoizedState,v=n.getDerivedStateFromProps,typeof v=="function"&&(Ju(t,n,v,i),c.state=t.memoizedState),typeof n.getDerivedStateFromProps=="function"||typeof c.getSnapshotBeforeUpdate=="function"||typeof c.UNSAFE_componentWillMount!="function"&&typeof c.componentWillMount!="function"||(v=c.state,typeof c.componentWillMount=="function"&&c.componentWillMount(),typeof c.UNSAFE_componentWillMount=="function"&&c.UNSAFE_componentWillMount(),v!==c.state&&Fu.enqueueReplaceState(c,c.state,null),Bi(t,i,c,o),Ui(),c.state=t.memoizedState),typeof c.componentDidMount=="function"&&(t.flags|=4194308),i=!0}else if(e===null){c=t.stateNode;var x=t.memoizedProps,_=tl(n,x);c.props=_;var U=c.context,Y=n.contextType;v=Dl,typeof Y=="object"&&Y!==null&&(v=Tt(Y));var Q=n.getDerivedStateFromProps;Y=typeof Q=="function"||typeof c.getSnapshotBeforeUpdate=="function",x=t.pendingProps!==x,Y||typeof c.UNSAFE_componentWillReceiveProps!="function"&&typeof c.componentWillReceiveProps!="function"||(x||U!==v)&&um(t,c,i,v),oa=!1;var B=t.memoizedState;c.state=B,Bi(t,i,c,o),Ui(),U=t.memoizedState,x||B!==U||oa?(typeof Q=="function"&&(Ju(t,n,Q,i),U=t.memoizedState),(_=oa||om(t,n,_,i,B,U,v))?(Y||typeof c.UNSAFE_componentWillMount!="function"&&typeof c.componentWillMount!="function"||(typeof c.componentWillMount=="function"&&c.componentWillMount(),typeof c.UNSAFE_componentWillMount=="function"&&c.UNSAFE_componentWillMount()),typeof c.componentDidMount=="function"&&(t.flags|=4194308)):(typeof c.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=i,t.memoizedState=U),c.props=i,c.state=U,c.context=v,i=_):(typeof c.componentDidMount=="function"&&(t.flags|=4194308),i=!1)}else{c=t.stateNode,Ru(e,t),v=t.memoizedProps,Y=tl(n,v),c.props=Y,Q=t.pendingProps,B=c.context,U=n.contextType,_=Dl,typeof U=="object"&&U!==null&&(_=Tt(U)),x=n.getDerivedStateFromProps,(U=typeof x=="function"||typeof c.getSnapshotBeforeUpdate=="function")||typeof c.UNSAFE_componentWillReceiveProps!="function"&&typeof c.componentWillReceiveProps!="function"||(v!==Q||B!==_)&&um(t,c,i,_),oa=!1,B=t.memoizedState,c.state=B,Bi(t,i,c,o),Ui();var H=t.memoizedState;v!==Q||B!==H||oa||e!==null&&e.dependencies!==null&&cs(e.dependencies)?(typeof x=="function"&&(Ju(t,n,x,i),H=t.memoizedState),(Y=oa||om(t,n,Y,i,B,H,_)||e!==null&&e.dependencies!==null&&cs(e.dependencies))?(U||typeof c.UNSAFE_componentWillUpdate!="function"&&typeof c.componentWillUpdate!="function"||(typeof c.componentWillUpdate=="function"&&c.componentWillUpdate(i,H,_),typeof c.UNSAFE_componentWillUpdate=="function"&&c.UNSAFE_componentWillUpdate(i,H,_)),typeof c.componentDidUpdate=="function"&&(t.flags|=4),typeof c.getSnapshotBeforeUpdate=="function"&&(t.flags|=1024)):(typeof c.componentDidUpdate!="function"||v===e.memoizedProps&&B===e.memoizedState||(t.flags|=4),typeof c.getSnapshotBeforeUpdate!="function"||v===e.memoizedProps&&B===e.memoizedState||(t.flags|=1024),t.memoizedProps=i,t.memoizedState=H),c.props=i,c.state=H,c.context=_,i=Y):(typeof c.componentDidUpdate!="function"||v===e.memoizedProps&&B===e.memoizedState||(t.flags|=4),typeof c.getSnapshotBeforeUpdate!="function"||v===e.memoizedProps&&B===e.memoizedState||(t.flags|=1024),i=!1)}return c=i,Rs(e,t),i=(t.flags&128)!==0,c||i?(c=t.stateNode,n=i&&typeof n.getDerivedStateFromError!="function"?null:c.render(),t.flags|=1,e!==null&&i?(t.child=Hl(t,e.child,null,o),t.child=Hl(t,null,n,o)):Et(e,t,n,o),t.memoizedState=c.state,e=t.child):e=Zn(e,t,o),e}function Cm(e,t,n,i){return Di(),t.flags|=256,Et(e,t,n,i),t.child}var ec={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function tc(e){return{baseLanes:e,cachePool:fh()}}function nc(e,t,n){return e=e!==null?e.childLanes&~n:0,t&&(e|=ln),e}function _m(e,t,n){var i=t.pendingProps,o=!1,c=(t.flags&128)!==0,v;if((v=c)||(v=e!==null&&e.memoizedState===null?!1:(mt.current&2)!==0),v&&(o=!0,t.flags&=-129),v=(t.flags&32)!==0,t.flags&=-33,e===null){if(He){if(o?da(t):ha(),He){var x=et,_;if(_=x){e:{for(_=x,x=wn;_.nodeType!==8;){if(!x){x=null;break e}if(_=pn(_.nextSibling),_===null){x=null;break e}}x=_}x!==null?(t.memoizedState={dehydrated:x,treeContext:Za!==null?{id:qn,overflow:Yn}:null,retryLane:536870912,hydrationErrors:null},_=Yt(18,null,null,0),_.stateNode=x,_.return=t,t.child=_,Rt=t,et=null,_=!0):_=!1}_||Ja(t)}if(x=t.memoizedState,x!==null&&(x=x.dehydrated,x!==null))return Vc(x)?t.lanes=32:t.lanes=536870912,null;Pn(t)}return x=i.children,i=i.fallback,o?(ha(),o=t.mode,x=As({mode:"hidden",children:x},o),i=Pa(i,o,n,null),x.return=t,i.return=t,x.sibling=i,t.child=x,o=t.child,o.memoizedState=tc(n),o.childLanes=nc(e,v,n),t.memoizedState=ec,i):(da(t),ac(t,x))}if(_=e.memoizedState,_!==null&&(x=_.dehydrated,x!==null)){if(c)t.flags&256?(da(t),t.flags&=-257,t=lc(e,t,n)):t.memoizedState!==null?(ha(),t.child=e.child,t.flags|=128,t=null):(ha(),o=i.fallback,x=t.mode,i=As({mode:"visible",children:i.children},x),o=Pa(o,x,n,null),o.flags|=2,i.return=t,o.return=t,i.sibling=o,t.child=i,Hl(t,e.child,null,n),i=t.child,i.memoizedState=tc(n),i.childLanes=nc(e,v,n),t.memoizedState=ec,t=o);else if(da(t),Vc(x)){if(v=x.nextSibling&&x.nextSibling.dataset,v)var U=v.dgst;v=U,i=Error(s(419)),i.stack="",i.digest=v,Ri({value:i,source:null,stack:null}),t=lc(e,t,n)}else if(vt||Ai(e,t,n,!1),v=(n&e.childLanes)!==0,vt||v){if(v=Pe,v!==null&&(i=n&-n,i=(i&42)!==0?1:vi(i),i=(i&(v.suspendedLanes|n))!==0?0:i,i!==0&&i!==_.retryLane))throw _.retryLane=i,jl(e,i),Zt(v,e,i),vm;x.data==="$?"||Ec(),t=lc(e,t,n)}else x.data==="$?"?(t.flags|=192,t.child=e.child,t=null):(e=_.treeContext,et=pn(x.nextSibling),Rt=t,He=!0,$a=null,wn=!1,e!==null&&(tn[nn++]=qn,tn[nn++]=Yn,tn[nn++]=Za,qn=e.id,Yn=e.overflow,Za=t),t=ac(t,i.children),t.flags|=4096);return t}return o?(ha(),o=i.fallback,x=t.mode,_=e.child,U=_.sibling,i=kn(_,{mode:"hidden",children:i.children}),i.subtreeFlags=_.subtreeFlags&65011712,U!==null?o=kn(U,o):(o=Pa(o,x,n,null),o.flags|=2),o.return=t,i.return=t,i.sibling=o,t.child=i,i=o,o=t.child,x=e.child.memoizedState,x===null?x=tc(n):(_=x.cachePool,_!==null?(U=ht._currentValue,_=_.parent!==U?{parent:U,pool:U}:_):_=fh(),x={baseLanes:x.baseLanes|n,cachePool:_}),o.memoizedState=x,o.childLanes=nc(e,v,n),t.memoizedState=ec,i):(da(t),n=e.child,e=n.sibling,n=kn(n,{mode:"visible",children:i.children}),n.return=t,n.sibling=null,e!==null&&(v=t.deletions,v===null?(t.deletions=[e],t.flags|=16):v.push(e)),t.child=n,t.memoizedState=null,n)}function ac(e,t){return t=As({mode:"visible",children:t},e.mode),t.return=e,e.child=t}function As(e,t){return e=Yt(22,e,null,t),e.lanes=0,e.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},e}function lc(e,t,n){return Hl(t,e.child,null,n),e=ac(t,t.pendingProps.children),e.flags|=2,t.memoizedState=null,e}function Tm(e,t,n){e.lanes|=t;var i=e.alternate;i!==null&&(i.lanes|=t),Eu(e.return,t,n)}function ic(e,t,n,i,o){var c=e.memoizedState;c===null?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:i,tail:n,tailMode:o}:(c.isBackwards=t,c.rendering=null,c.renderingStartTime=0,c.last=i,c.tail=n,c.tailMode=o)}function jm(e,t,n){var i=t.pendingProps,o=i.revealOrder,c=i.tail;if(Et(e,t,i.children,n),i=mt.current,(i&2)!==0)i=i&1|2,t.flags|=128;else{if(e!==null&&(e.flags&128)!==0)e:for(e=t.child;e!==null;){if(e.tag===13)e.memoizedState!==null&&Tm(e,n,t);else if(e.tag===19)Tm(e,n,t);else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===t)break e;for(;e.sibling===null;){if(e.return===null||e.return===t)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}i&=1}switch(ce(mt,i),o){case"forwards":for(n=t.child,o=null;n!==null;)e=n.alternate,e!==null&&Ts(e)===null&&(o=n),n=n.sibling;n=o,n===null?(o=t.child,t.child=null):(o=n.sibling,n.sibling=null),ic(t,!1,o,n,c);break;case"backwards":for(n=null,o=t.child,t.child=null;o!==null;){if(e=o.alternate,e!==null&&Ts(e)===null){t.child=o;break}e=o.sibling,o.sibling=n,n=o,o=e}ic(t,!0,n,null,c);break;case"together":ic(t,!1,null,null,void 0);break;default:t.memoizedState=null}return t.child}function Zn(e,t,n){if(e!==null&&(t.dependencies=e.dependencies),ga|=t.lanes,(n&t.childLanes)===0)if(e!==null){if(Ai(e,t,n,!1),(n&t.childLanes)===0)return null}else return null;if(e!==null&&t.child!==e.child)throw Error(s(153));if(t.child!==null){for(e=t.child,n=kn(e,e.pendingProps),t.child=n,n.return=t;e.sibling!==null;)e=e.sibling,n=n.sibling=kn(e,e.pendingProps),n.return=t;n.sibling=null}return t.child}function rc(e,t){return(e.lanes&t)!==0?!0:(e=e.dependencies,!!(e!==null&&cs(e)))}function d1(e,t,n){switch(t.tag){case 3:hl(t,t.stateNode.containerInfo),sa(t,ht,e.memoizedState.cache),Di();break;case 27:case 5:st(t);break;case 4:hl(t,t.stateNode.containerInfo);break;case 10:sa(t,t.type,t.memoizedProps.value);break;case 13:var i=t.memoizedState;if(i!==null)return i.dehydrated!==null?(da(t),t.flags|=128,null):(n&t.child.childLanes)!==0?_m(e,t,n):(da(t),e=Zn(e,t,n),e!==null?e.sibling:null);da(t);break;case 19:var o=(e.flags&128)!==0;if(i=(n&t.childLanes)!==0,i||(Ai(e,t,n,!1),i=(n&t.childLanes)!==0),o){if(i)return jm(e,t,n);t.flags|=128}if(o=t.memoizedState,o!==null&&(o.rendering=null,o.tail=null,o.lastEffect=null),ce(mt,mt.current),i)break;return null;case 22:case 23:return t.lanes=0,xm(e,t,n);case 24:sa(t,ht,e.memoizedState.cache)}return Zn(e,t,n)}function Dm(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps)vt=!0;else{if(!rc(e,n)&&(t.flags&128)===0)return vt=!1,d1(e,t,n);vt=(e.flags&131072)!==0}else vt=!1,He&&(t.flags&1048576)!==0&&lh(t,us,t.index);switch(t.lanes=0,t.tag){case 16:e:{e=t.pendingProps;var i=t.elementType,o=i._init;if(i=o(i._payload),t.type=i,typeof i=="function")pu(i)?(e=tl(i,e),t.tag=1,t=wm(null,t,i,e,n)):(t.tag=0,t=Wu(null,t,i,e,n));else{if(i!=null){if(o=i.$$typeof,o===K){t.tag=11,t=ym(null,t,i,e,n);break e}else if(o===I){t.tag=14,t=gm(null,t,i,e,n);break e}}throw t=$(i)||i,Error(s(306,t,""))}}return t;case 0:return Wu(e,t,t.type,t.pendingProps,n);case 1:return i=t.type,o=tl(i,t.pendingProps),wm(e,t,i,o,n);case 3:e:{if(hl(t,t.stateNode.containerInfo),e===null)throw Error(s(387));i=t.pendingProps;var c=t.memoizedState;o=c.element,Ru(e,t),Bi(t,i,null,n);var v=t.memoizedState;if(i=v.cache,sa(t,ht,i),i!==c.cache&&wu(t,[ht],n,!0),Ui(),i=v.element,c.isDehydrated)if(c={element:i,isDehydrated:!1,cache:v.cache},t.updateQueue.baseState=c,t.memoizedState=c,t.flags&256){t=Cm(e,t,i,n);break e}else if(i!==o){o=Wt(Error(s(424)),t),Ri(o),t=Cm(e,t,i,n);break e}else{switch(e=t.stateNode.containerInfo,e.nodeType){case 9:e=e.body;break;default:e=e.nodeName==="HTML"?e.ownerDocument.body:e}for(et=pn(e.firstChild),Rt=t,He=!0,$a=null,wn=!0,n=rm(t,null,i,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling}else{if(Di(),i===o){t=Zn(e,t,n);break e}Et(e,t,i,n)}t=t.child}return t;case 26:return Rs(e,t),e===null?(n=Op(t.type,null,t.pendingProps,null))?t.memoizedState=n:He||(n=t.type,e=t.pendingProps,i=Xs(Mt.current).createElement(n),i[Z]=t,i[F]=e,Ct(i,n,e),Ce(i),t.stateNode=i):t.memoizedState=Op(t.type,e.memoizedProps,t.pendingProps,e.memoizedState),null;case 27:return st(t),e===null&&He&&(i=t.stateNode=Rp(t.type,t.pendingProps,Mt.current),Rt=t,wn=!0,o=et,Ea(t.type)?(kc=o,et=pn(i.firstChild)):et=o),Et(e,t,t.pendingProps.children,n),Rs(e,t),e===null&&(t.flags|=4194304),t.child;case 5:return e===null&&He&&((o=i=et)&&(i=k1(i,t.type,t.pendingProps,wn),i!==null?(t.stateNode=i,Rt=t,et=pn(i.firstChild),wn=!1,o=!0):o=!1),o||Ja(t)),st(t),o=t.type,c=t.pendingProps,v=e!==null?e.memoizedProps:null,i=c.children,Uc(o,c)?i=null:v!==null&&Uc(o,v)&&(t.flags|=32),t.memoizedState!==null&&(o=Lu(e,t,l1,null,null,n),ir._currentValue=o),Rs(e,t),Et(e,t,i,n),t.child;case 6:return e===null&&He&&((e=n=et)&&(n=q1(n,t.pendingProps,wn),n!==null?(t.stateNode=n,Rt=t,et=null,e=!0):e=!1),e||Ja(t)),null;case 13:return _m(e,t,n);case 4:return hl(t,t.stateNode.containerInfo),i=t.pendingProps,e===null?t.child=Hl(t,null,i,n):Et(e,t,i,n),t.child;case 11:return ym(e,t,t.type,t.pendingProps,n);case 7:return Et(e,t,t.pendingProps,n),t.child;case 8:return Et(e,t,t.pendingProps.children,n),t.child;case 12:return Et(e,t,t.pendingProps.children,n),t.child;case 10:return i=t.pendingProps,sa(t,t.type,i.value),Et(e,t,i.children,n),t.child;case 9:return o=t.type._context,i=t.pendingProps.children,Ia(t),o=Tt(o),i=i(o),t.flags|=1,Et(e,t,i,n),t.child;case 14:return gm(e,t,t.type,t.pendingProps,n);case 15:return bm(e,t,t.type,t.pendingProps,n);case 19:return jm(e,t,n);case 31:return i=t.pendingProps,n=t.mode,i={mode:i.mode,children:i.children},e===null?(n=As(i,n),n.ref=t.ref,t.child=n,n.return=t,t=n):(n=kn(e.child,i),n.ref=t.ref,t.child=n,n.return=t,t=n),t;case 22:return xm(e,t,n);case 24:return Ia(t),i=Tt(ht),e===null?(o=Tu(),o===null&&(o=Pe,c=Cu(),o.pooledCache=c,c.refCount++,c!==null&&(o.pooledCacheLanes|=n),o=c),t.memoizedState={parent:i,cache:o},Du(t),sa(t,ht,o)):((e.lanes&n)!==0&&(Ru(e,t),Bi(t,null,null,n),Ui()),o=e.memoizedState,c=t.memoizedState,o.parent!==i?(o={parent:i,cache:i},t.memoizedState=o,t.lanes===0&&(t.memoizedState=t.updateQueue.baseState=o),sa(t,ht,i)):(i=c.cache,sa(t,ht,i),i!==o.cache&&wu(t,[ht],n,!0))),Et(e,t,t.pendingProps.children,n),t.child;case 29:throw t.pendingProps}throw Error(s(156,t.tag))}function Kn(e){e.flags|=4}function Rm(e,t){if(t.type!=="stylesheet"||(t.state.loading&4)!==0)e.flags&=-16777217;else if(e.flags|=16777216,!Bp(t)){if(t=an.current,t!==null&&((ze&4194048)===ze?Cn!==null:(ze&62914560)!==ze&&(ze&536870912)===0||t!==Cn))throw zi=ju,dh;e.flags|=8192}}function Ms(e,t){t!==null&&(e.flags|=4),e.flags&16384&&(t=e.tag!==22?Qr():536870912,e.lanes|=t,Yl|=t)}function Xi(e,t){if(!He)switch(e.tailMode){case"hidden":t=e.tail;for(var n=null;t!==null;)t.alternate!==null&&(n=t),t=t.sibling;n===null?e.tail=null:n.sibling=null;break;case"collapsed":n=e.tail;for(var i=null;n!==null;)n.alternate!==null&&(i=n),n=n.sibling;i===null?t||e.tail===null?e.tail=null:e.tail.sibling=null:i.sibling=null}}function Ie(e){var t=e.alternate!==null&&e.alternate.child===e.child,n=0,i=0;if(t)for(var o=e.child;o!==null;)n|=o.lanes|o.childLanes,i|=o.subtreeFlags&65011712,i|=o.flags&65011712,o.return=e,o=o.sibling;else for(o=e.child;o!==null;)n|=o.lanes|o.childLanes,i|=o.subtreeFlags,i|=o.flags,o.return=e,o=o.sibling;return e.subtreeFlags|=i,e.childLanes=n,t}function h1(e,t,n){var i=t.pendingProps;switch(bu(t),t.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Ie(t),null;case 1:return Ie(t),null;case 3:return n=t.stateNode,i=null,e!==null&&(i=e.memoizedState.cache),t.memoizedState.cache!==i&&(t.flags|=2048),Xn(ht),na(),n.pendingContext&&(n.context=n.pendingContext,n.pendingContext=null),(e===null||e.child===null)&&(ji(t)?Kn(t):e===null||e.memoizedState.isDehydrated&&(t.flags&256)===0||(t.flags|=1024,sh())),Ie(t),null;case 26:return n=t.memoizedState,e===null?(Kn(t),n!==null?(Ie(t),Rm(t,n)):(Ie(t),t.flags&=-16777217)):n?n!==e.memoizedState?(Kn(t),Ie(t),Rm(t,n)):(Ie(t),t.flags&=-16777217):(e.memoizedProps!==i&&Kn(t),Ie(t),t.flags&=-16777217),null;case 27:cn(t),n=Mt.current;var o=t.type;if(e!==null&&t.stateNode!=null)e.memoizedProps!==i&&Kn(t);else{if(!i){if(t.stateNode===null)throw Error(s(166));return Ie(t),null}e=Ue.current,ji(t)?ih(t):(e=Rp(o,i,n),t.stateNode=e,Kn(t))}return Ie(t),null;case 5:if(cn(t),n=t.type,e!==null&&t.stateNode!=null)e.memoizedProps!==i&&Kn(t);else{if(!i){if(t.stateNode===null)throw Error(s(166));return Ie(t),null}if(e=Ue.current,ji(t))ih(t);else{switch(o=Xs(Mt.current),e){case 1:e=o.createElementNS("http://www.w3.org/2000/svg",n);break;case 2:e=o.createElementNS("http://www.w3.org/1998/Math/MathML",n);break;default:switch(n){case"svg":e=o.createElementNS("http://www.w3.org/2000/svg",n);break;case"math":e=o.createElementNS("http://www.w3.org/1998/Math/MathML",n);break;case"script":e=o.createElement("div"),e.innerHTML=" + From 64c5ceeb429e6fcf8909614390fbc672b9b39474 Mon Sep 17 00:00:00 2001 From: Antti Luomi Date: Wed, 27 May 2026 09:20:43 +0000 Subject: [PATCH 10/10] Make fitting feature pass new ruff CI checks --- .../server/api/experiment_data_controller.py | 34 +++--- src/icon/server/fitting/auto_fit.py | 18 ++- src/icon/server/fitting/fit_runner.py | 22 +++- src/icon/server/fitting/models.py | 6 +- tests/generate_test_hdf5.py | 104 ++++++++++++------ tests/server/fitting/test_fit_runner.py | 11 +- 6 files changed, 124 insertions(+), 71 deletions(-) diff --git a/src/icon/server/api/experiment_data_controller.py b/src/icon/server/api/experiment_data_controller.py index c5d13563..f7e2f4c6 100644 --- a/src/icon/server/api/experiment_data_controller.py +++ b/src/icon/server/api/experiment_data_controller.py @@ -77,12 +77,14 @@ async def run_fit( (p for p in data.scan_parameters if p != "timestamp"), None ) if scan_param_name is None: - return asdict(run_curve_fit( - x=np.array([]), - y=np.array([]), - result_channel=result_channel, - func_type=func_type, # type: ignore[arg-type] - )) + return asdict( + run_curve_fit( + x=np.array([]), + y=np.array([]), + result_channel=result_channel, + func_type=func_type, # type: ignore[arg-type] + ) + ) scan_values = data.scan_parameters[scan_param_name] channel_values = data.result_channels.get(result_channel, {}) @@ -110,10 +112,12 @@ async def run_fit( ) result_dict = asdict(fit_result) - emit_queue.put({ - "event": f"experiment_fit_{job_id}", - "data": result_dict, - }) + emit_queue.put( + { + "event": f"experiment_fit_{job_id}", + "data": result_dict, + } + ) return result_dict async def delete_fit( @@ -132,7 +136,9 @@ async def delete_fit( job_id=job_id, result_channel=result_channel, ) - emit_queue.put({ - "event": f"experiment_fit_{job_id}", - "data": {"result_channel": result_channel, "deleted": True}, - }) + emit_queue.put( + { + "event": f"experiment_fit_{job_id}", + "data": {"result_channel": result_channel, "deleted": True}, + } + ) diff --git a/src/icon/server/fitting/auto_fit.py b/src/icon/server/fitting/auto_fit.py index 760c9156..8ae66924 100644 --- a/src/icon/server/fitting/auto_fit.py +++ b/src/icon/server/fitting/auto_fit.py @@ -55,9 +55,7 @@ def _auto_fit(job_id: int, experiment_source_id: int) -> None: max_transfer_bytes=2**62, ) - scan_param_name = next( - (p for p in data.scan_parameters if p != "timestamp"), None - ) + scan_param_name = next((p for p in data.scan_parameters if p != "timestamp"), None) if scan_param_name is None: return @@ -79,9 +77,7 @@ def _fit_channel( return scan_values = data.scan_parameters[scan_param_name] - indices = sorted( - set(scan_values.keys()) & set(channel_values.keys()) - ) + indices = sorted(set(scan_values.keys()) & set(channel_values.keys())) x = np.array([float(scan_values[i]) for i in indices]) y = np.array([float(channel_values[i]) for i in indices]) @@ -95,10 +91,12 @@ def _fit_channel( if fit_result.success: write_fit_result_by_job_id(job_id=job_id, fit_result=fit_result) - emit_queue.put({ - "event": f"experiment_fit_{job_id}", - "data": asdict(fit_result), - }) + emit_queue.put( + { + "event": f"experiment_fit_{job_id}", + "data": asdict(fit_result), + } + ) logger.info( "Auto-fit %s on job %d channel '%s' succeeded", func_type, diff --git a/src/icon/server/fitting/fit_runner.py b/src/icon/server/fitting/fit_runner.py index d8b7519d..2ff16f8d 100644 --- a/src/icon/server/fitting/fit_runner.py +++ b/src/icon/server/fitting/fit_runner.py @@ -92,7 +92,7 @@ def _apply_range( return x, y -def run_curve_fit( # noqa: PLR0913, C901 +def run_curve_fit( # noqa: C901 x: npt.NDArray[np.float64], y: npt.NDArray[np.float64], result_channel: str, @@ -103,7 +103,10 @@ def run_curve_fit( # noqa: PLR0913, C901 """Run a curve fit on the given data.""" if func_type not in FIT_MODELS: return _make_error( - result_channel, func_type, x_range, init or {}, + result_channel, + func_type, + x_range, + init or {}, f"Unknown fit function: {func_type}", ) @@ -114,7 +117,10 @@ def run_curve_fit( # noqa: PLR0913, C901 min_points = len(model.param_names) + 1 if len(x) < min_points: return _make_error( - result_channel, func_type, x_range, init or {}, + result_channel, + func_type, + x_range, + init or {}, f"Insufficient data points: {len(x)} (need at least {min_points})", ) @@ -125,16 +131,20 @@ def run_curve_fit( # noqa: PLR0913, C901 if name in init: p0[i] = init[name] - init_dict = dict(zip(model.param_names, p0)) + init_dict = dict(zip(model.param_names, p0, strict=True)) try: popt, _ = curve_fit(model.func, x, y, p0=p0, maxfev=_MAX_FIT_EVALS) except (RuntimeError, ValueError) as exc: return _make_error( - result_channel, func_type, x_range, init_dict, str(exc), + result_channel, + func_type, + x_range, + init_dict, + str(exc), ) - result_dict = dict(zip(model.param_names, (float(v) for v in popt))) + result_dict = dict(zip(model.param_names, (float(v) for v in popt), strict=True)) if model.derived_params: result_dict.update(model.derived_params(result_dict)) diff --git a/src/icon/server/fitting/models.py b/src/icon/server/fitting/models.py index 91c25851..7ee8f257 100644 --- a/src/icon/server/fitting/models.py +++ b/src/icon/server/fitting/models.py @@ -28,9 +28,7 @@ class FitModel: param_names: list[str] default_update_param: str guess: Callable[[npt.NDArray[np.float64], npt.NDArray[np.float64]], list[float]] - derived_params: ( - Callable[[dict[str, float]], dict[str, float]] | None - ) = None + derived_params: Callable[[dict[str, float]], dict[str, float]] | None = None def _lorentzian( @@ -153,7 +151,7 @@ def _harmonic_guess( return [y0, a, omega, phi] -def _damped_harmonic( # noqa: PLR0913 +def _damped_harmonic( x: npt.NDArray[np.float64], y0: float, a: float, diff --git a/tests/generate_test_hdf5.py b/tests/generate_test_hdf5.py index a017f29c..fa265f4d 100644 --- a/tests/generate_test_hdf5.py +++ b/tests/generate_test_hdf5.py @@ -20,7 +20,9 @@ from __future__ import annotations import json -from datetime import datetime, timedelta +import logging +import sys +from datetime import datetime, timedelta, timezone import h5py # type: ignore import numpy as np @@ -35,6 +37,8 @@ from icon.server.data_access.models.sqlite.job_run import JobRun from icon.server.data_access.models.sqlite.scan_parameter import ScanParameter +logger = logging.getLogger(__name__) + # --------------------------------------------------------------------------- # Shared experiment source — reused across all generated jobs # --------------------------------------------------------------------------- @@ -42,7 +46,9 @@ _EXPERIMENT_ID = "test.SyntheticExperiment (SyntheticExperiment)" -def _get_or_create_experiment_source(session: sqlalchemy.orm.Session) -> ExperimentSource: +def _get_or_create_experiment_source( + session: sqlalchemy.orm.Session, +) -> ExperimentSource: existing = session.execute( sqlalchemy.select(ExperimentSource).where( ExperimentSource.experiment_id == _EXPERIMENT_ID @@ -138,10 +144,16 @@ def _write_hdf5( compression="gzip", ) result_ds[result_channel] = y - result_ds.attrs["Plot window metadata"] = json.dumps([ - {"name": "Results", "index": 0, "type": "readout", - "channel_names": [result_channel]}, - ]) + result_ds.attrs["Plot window metadata"] = json.dumps( + [ + { + "name": "Results", + "index": 0, + "type": "readout", + "channel_names": [result_channel], + }, + ] + ) shot_group = h5.create_group("shot_channels") shot_group.attrs["Plot window metadata"] = json.dumps([]) @@ -161,7 +173,7 @@ def _create_job( """Create SQLite records + HDF5 file; return the job ID.""" with sqlalchemy.orm.Session(engine) as session: source = _get_or_create_experiment_source(session) - job, run = _insert_job_and_run( + job, _run = _insert_job_and_run( session, source, scan_values=x.tolist(), @@ -173,7 +185,7 @@ def _create_job( filepath = _hdf5_path(scheduled_time) _write_hdf5(filepath, job_id, x, y, variable_id, result_channel) - print(f"[{label}] job_id={job_id} file={filepath}") + logger.info("[%s] job_id=%s file=%s", label, job_id, filepath) return job_id @@ -183,7 +195,7 @@ def _create_job( # Use a fixed base time and offset each scenario by a minute so filenames # don't collide even if the script is re-run on the same second. -_BASE_TIME = datetime(2025, 6, 1, 12, 0, 0) +_BASE_TIME = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc) def generate_clean_lorentzian() -> int: @@ -192,7 +204,8 @@ def generate_clean_lorentzian() -> int: y = 1.0 + 5.0 / (1 + ((x - 150.0) / 5.0) ** 2) y += np.random.default_rng(42).normal(0, 0.05, len(x)) return _create_job( - x, y, + x, + y, variable_id="frequency", result_channel="counts", scheduled_time=_BASE_TIME, @@ -206,7 +219,8 @@ def generate_noisy_lorentzian() -> int: y = 1.0 + 5.0 / (1 + ((x - 150.0) / 5.0) ** 2) y += np.random.default_rng(42).normal(0, 1.0, len(x)) return _create_job( - x, y, + x, + y, variable_id="frequency", result_channel="counts", scheduled_time=_BASE_TIME + timedelta(minutes=1), @@ -224,7 +238,8 @@ def generate_w_shape() -> int: ) y += np.random.default_rng(42).normal(0, 0.1, len(x)) return _create_job( - x, y, + x, + y, variable_id="frequency", result_channel="counts", scheduled_time=_BASE_TIME + timedelta(minutes=2), @@ -238,7 +253,8 @@ def generate_gaussian() -> int: y = 2.0 + 3.0 * np.exp(-((x - 5.0) ** 2) / (2 * 0.8**2)) y += np.random.default_rng(42).normal(0, 0.05, len(x)) return _create_job( - x, y, + x, + y, variable_id="detuning", result_channel="fluorescence", scheduled_time=_BASE_TIME + timedelta(minutes=3), @@ -252,7 +268,8 @@ def generate_poly2() -> int: y = 2.0 * x**2 - 3.0 * x + 1.0 y += np.random.default_rng(42).normal(0, 0.5, len(x)) return _create_job( - x, y, + x, + y, variable_id="voltage", result_channel="counts", scheduled_time=_BASE_TIME + timedelta(minutes=4), @@ -266,7 +283,8 @@ def generate_harmonic() -> int: y = 1.0 + 2.0 * np.cos(4.0 * x + 0.5) y += np.random.default_rng(42).normal(0, 0.1, len(x)) return _create_job( - x, y, + x, + y, variable_id="time", result_channel="population", scheduled_time=_BASE_TIME + timedelta(minutes=5), @@ -280,7 +298,8 @@ def generate_damped_harmonic() -> int: y = 1.0 + np.exp(-0.3 * x) * 2.0 * np.cos(4.0 * x + 0.5) y += np.random.default_rng(42).normal(0, 0.05, len(x)) return _create_job( - x, y, + x, + y, variable_id="time", result_channel="population", scheduled_time=_BASE_TIME + timedelta(minutes=6), @@ -288,7 +307,7 @@ def generate_damped_harmonic() -> int: ) -def _insert_job_and_run_2d( # noqa: PLR0913 +def _insert_job_and_run_2d( session: sqlalchemy.orm.Session, source: ExperimentSource, scan_values_x: list[float], @@ -333,7 +352,7 @@ def _insert_job_and_run_2d( # noqa: PLR0913 return job, run -def _write_hdf5_2d( # noqa: PLR0913 +def _write_hdf5_2d( filepath: str, job_id: int, x_flat: np.ndarray, @@ -375,10 +394,16 @@ def _write_hdf5_2d( # noqa: PLR0913 compression="gzip", ) result_ds[result_channel] = z - result_ds.attrs["Plot window metadata"] = json.dumps([ - {"name": "Results", "index": 0, "type": "readout", - "channel_names": [result_channel]}, - ]) + result_ds.attrs["Plot window metadata"] = json.dumps( + [ + { + "name": "Results", + "index": 0, + "type": "readout", + "channel_names": [result_channel], + }, + ] + ) shot_group = h5.create_group("shot_channels") shot_group.attrs["Plot window metadata"] = json.dumps([]) @@ -409,15 +434,16 @@ def generate_2d_gaussian() -> int: # 2D Gaussian amp, x0, y0, sx, sy, offset = 5.0, 150.0, 5.0, 15.0, 2.0, 1.0 - z = amp * np.exp(-( - (x_flat - x0) ** 2 / (2 * sx**2) - + (y_flat - y0) ** 2 / (2 * sy**2) - )) + offset + z = ( + amp + * np.exp(-((x_flat - x0) ** 2 / (2 * sx**2) + (y_flat - y0) ** 2 / (2 * sy**2))) + + offset + ) z += np.random.default_rng(42).normal(0, 0.1, len(z)) with sqlalchemy.orm.Session(engine) as session: source = _get_or_create_experiment_source(session) - job, run = _insert_job_and_run_2d( + job, _run = _insert_job_and_run_2d( session, source, scan_values_x=x_vals.tolist(), @@ -431,17 +457,25 @@ def generate_2d_gaussian() -> int: filepath = _hdf5_path(scheduled_time) _write_hdf5_2d( - filepath, job_id, x_flat, y_flat, z, - variable_id_x, variable_id_y, result_channel, + filepath, + job_id, + x_flat, + y_flat, + z, + variable_id_x, + variable_id_y, + result_channel, ) - print(f"[2d_gaussian] job_id={job_id} file={filepath}") + logger.info("[2d_gaussian] job_id=%s file=%s", job_id, filepath) return job_id if __name__ == "__main__": - print(f"Results dir : {get_config().data.results_dir}") - print(f"Database : {get_config().databases.sqlite.file}") - print() + logging.basicConfig(level=logging.INFO, stream=sys.stdout, format="%(message)s") + + logger.info("Results dir : %s", get_config().data.results_dir) + logger.info("Database : %s", get_config().databases.sqlite.file) + logger.info("") generate_clean_lorentzian() generate_noisy_lorentzian() @@ -452,4 +486,6 @@ def generate_2d_gaussian() -> int: generate_damped_harmonic() generate_2d_gaussian() - print("\nDone. Open http://localhost:8004 and go to the Data page to see the jobs.") + logger.info( + "\nDone. Open http://localhost:8004 and go to the Data page to see the jobs." + ) diff --git a/tests/server/fitting/test_fit_runner.py b/tests/server/fitting/test_fit_runner.py index 0db625b4..88319798 100644 --- a/tests/server/fitting/test_fit_runner.py +++ b/tests/server/fitting/test_fit_runner.py @@ -3,6 +3,9 @@ from icon.server.fitting.fit_runner import FitResult, run_curve_fit +# Minimum R^2 for a fit on clean synthetic data to be considered good. +MIN_GOOD_R2 = 0.99 + def _synthetic_lorentzian( n: int = 100, @@ -27,7 +30,7 @@ def test_lorentzian_clean(self) -> None: assert result.success assert result.result["x0"] == pytest.approx(true["x0"], abs=0.1) assert result.result["a"] == pytest.approx(true["a"], abs=0.5) - assert result.goodness["r2"] > 0.99 + assert result.goodness["r2"] > MIN_GOOD_R2 def test_lorentzian_noisy(self) -> None: x, y, true = _synthetic_lorentzian(noise=0.5) @@ -72,8 +75,10 @@ def test_init_override(self) -> None: def test_w_shape_with_init(self) -> None: """Two Lorentzian dips; init selects correct peak.""" x = np.linspace(0, 20, 200) - y = 10.0 - 5.0 / (1 + ((x - 5.0) / 0.5) ** 2) - 5.0 / ( - 1 + ((x - 15.0) / 0.5) ** 2 + y = ( + 10.0 + - 5.0 / (1 + ((x - 5.0) / 0.5) ** 2) + - 5.0 / (1 + ((x - 15.0) / 0.5) ** 2) ) # Guide to the second peak result = run_curve_fit(x, y, "ch1", "lorentzian", init={"x0": 15.0})