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/components/JobView.tsx b/frontend/src/components/JobView.tsx index 12b3f57c..3b3eb7e1 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 || ""; @@ -37,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); @@ -90,6 +93,12 @@ 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), []); + + // 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; @@ -206,10 +215,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 +405,7 @@ export const JobView = ({ title={win.name} subtitle={getPlotTitle( jobRunInfo?.scheduled_time, - experimentMetadata?.constructor_kwargs.name, + experimentMetadata?.constructor_kwargs?.name, )} /> )} @@ -435,19 +444,32 @@ 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={showFitPanel && is1D ? experimentData.fits : undefined} + onChartClick={showFitPanel && is1D ? handleChartClick : undefined} /> )} ))} + {showFitPanel && is1D && jobId && jobInfo?.status === JobStatus.PROCESSED && ( + + + + )} + diff --git a/frontend/src/components/ResultChannelPlot.tsx b/frontend/src/components/ResultChannelPlot.tsx index cbf5cd49..7b2e783c 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"; @@ -19,6 +19,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 +102,8 @@ const ResultChannelPlot = ({ scanParameters = [], windowSize = null, yRange, + fits = {}, + onChartClick, }: ResultChannelPlotProps) => { const [chart, setChart] = useState(null); const notifications = useNotifications(); @@ -340,6 +344,28 @@ const ResultChannelPlot = ({ }; } + // Add fit curve overlays for 1D scans + if (scanParameters.length === 1 && fits) { + for (const [channelName, fitResult] of Object.entries(fits)) { + 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`, + type: "line", + data: fitData, + showSymbol: false, + lineStyle: { type: "dashed", width: 2 }, + tooltip: { show: false }, + }); + } + } + return { title, textStyle: { fontFamily: "sans-serif", fontSize: 12 }, @@ -381,6 +407,8 @@ const ResultChannelPlot = ({ showRepetitions, windowSize, yRange, + fits, + channelNames, ]); const updateChart = useCallback( @@ -390,6 +418,24 @@ const ResultChannelPlot = ({ [setChart], ); + useEffect(() => { + if (!chart || !onChartClick) return; + 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]); + } + } + }; + zr.on("click", handler); + return () => { + zr.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..d5c1960c --- /dev/null +++ b/frontend/src/components/jobView/FitPanel.tsx @@ -0,0 +1,362 @@ +import { useEffect, 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 { useNotifications } from "@toolpad/core"; +import { ExperimentData, FitResult } from "../../types/ExperimentData"; +import { ScanParameter } from "../../types/ScanParameter"; +import { runMethod } from "../../socket"; +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, + scanParameters = [], +}: FitPanelProps) { + const notifications = useNotifications(); + 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 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] ?? []; + + // 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") && !("x0" in initOverrides)) { + 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; + 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", + }), + ); + }; + + 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 && paramNames.includes("x0") && ( + + 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/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/types/ExperimentData.ts b/frontend/src/types/ExperimentData.ts index cad018f4..a699d623 100644 --- a/frontend/src/types/ExperimentData.ts +++ b/frontend/src/types/ExperimentData.ts @@ -28,6 +28,18 @@ 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; + fit_curve?: { x: number[]; y: number[] }; +} + export interface ExperimentData { plot_windows: PlotWindows; shot_channels: Record>; @@ -37,4 +49,5 @@ export interface ExperimentData { json_sequences: [number, string][]; parameters: Record; total_data_points: number; + fits: Record; } diff --git a/frontend/src/utils/__tests__/fitFunctions.test.ts b/frontend/src/utils/__tests__/fitFunctions.test.ts new file mode 100644 index 00000000..24577d52 --- /dev/null +++ b/frontend/src/utils/__tests__/fitFunctions.test.ts @@ -0,0 +1,24 @@ +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); + } + }); + + it("every FIT_TYPE has a default update param", () => { + for (const ft of FIT_TYPES) { + expect(FIT_DEFAULT_UPDATE_PARAM[ft]).toBeDefined(); + } + }); + + 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 new file mode 100644 index 00000000..4cf4bca5 --- /dev/null +++ b/frontend/src/utils/fitFunctions.ts @@ -0,0 +1,26 @@ +/** 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"], +}; + +/** 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", + "gaussian", + "poly2", + "harmonic", + "damped_harmonic", +] as const; diff --git a/pyproject.toml b/pyproject.toml index 2f966787..89728777 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ server = [ "alembic>=1.14.0", "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/api/experiment_data_controller.py b/src/icon/server/api/experiment_data_controller.py index be5287d5..f7e2f4c6 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"] @@ -41,3 +46,99 @@ 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 5f3aeefb..51a77b26 100644 --- a/src/icon/server/data_access/repositories/experiment_data_repository.py +++ b/src/icon/server/data_access/repositories/experiment_data_repository.py @@ -20,6 +20,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.web_server.socketio_emit_queue import emit_queue if TYPE_CHECKING: @@ -126,6 +127,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: @@ -611,6 +614,7 @@ def get_experiment_data_by_job_id( # noqa: C901 realtime_scan=False, parameters={}, total_data_points=0, + fits={}, ) filename = get_filename_by_job_id(job_id) @@ -746,6 +750,7 @@ def get_experiment_data_by_job_id( # noqa: C901 for entry in sequence_json_dataset ] data.parameters = extract_parameter_values(h5file) + data.fits = _read_fits_from_hdf5(h5file) return data @@ -819,3 +824,75 @@ def h5_open(path: Path, mode: str, **kwargs: Any) -> Iterator[h5py.File]: break except (OSError, FileNotFoundError): time.sleep(POLL_INTERVAL) + + +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) + h5_path = Path(get_config().data.results_dir) / filename + with h5_open(h5_path, "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) + h5_path = Path(get_config().data.results_dir) / filename + if not h5_path.exists(): + return {} + + with h5_open(h5_path, "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) + h5_path = Path(get_config().data.results_dir) / filename + with h5_open(h5_path, "a") as h5file: + if "fits" in h5file and result_channel in h5file["fits"]: + del h5file["fits"][result_channel] 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/auto_fit.py b/src/icon/server/fitting/auto_fit.py new file mode 100644 index 00000000..8ae66924 --- /dev/null +++ b/src/icon/server/fitting/auto_fit.py @@ -0,0 +1,142 @@ +"""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/fitting/fit_runner.py b/src/icon/server/fitting/fit_runner.py new file mode 100644 index 00000000..2ff16f8d --- /dev/null +++ b/src/icon/server/fitting/fit_runner.py @@ -0,0 +1,172 @@ +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 + + +_FIT_CURVE_POINTS = 200 + + +@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 + fit_curve: dict[str, list[float]] | None = None + + +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: 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, 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_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)) + + 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, + x_range=x_range, + init=init_dict, + result=result_dict, + 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 new file mode 100644 index 00000000..7ee8f257 --- /dev/null +++ b/src/icon/server/fitting/models.py @@ -0,0 +1,233 @@ +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]] + derived_params: Callable[[dict[str, float]], dict[str, float]] | None = None + + +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( + 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]] + + +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, + 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, + derived_params=_poly2_derived, + ), + "harmonic": FitModel( + func=_harmonic, + param_names=["y0", "a", "omega", "phi"], + 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="f", + guess=_damped_harmonic_guess, + derived_params=_harmonic_derived, + ), +} 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=" + diff --git a/src/icon/server/pre_processing/worker.py b/src/icon/server/pre_processing/worker.py index d0fddb43..f93a38c2 100644 --- a/src/icon/server/pre_processing/worker.py +++ b/src/icon/server/pre_processing/worker.py @@ -33,6 +33,7 @@ from icon.server.data_access.repositories.parameters_repository import ( ParametersRepository, ) +from icon.server.fitting.auto_fit import try_auto_fit from icon.server.hardware_processing.task import HardwareProcessingTask if TYPE_CHECKING: @@ -214,6 +215,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", pre_processing_task.job_run.id diff --git a/tests/generate_test_hdf5.py b/tests/generate_test_hdf5.py new file mode 100644 index 00000000..fa265f4d --- /dev/null +++ b/tests/generate_test_hdf5.py @@ -0,0 +1,491 @@ +"""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 +import logging +import sys +from datetime import datetime, timedelta, timezone + +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 + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# 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, + name=variable_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) + logger.info("[%s] job_id=%s file=%s", label, job_id, 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, tzinfo=timezone.utc) + + +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", + ) + + +def _insert_job_and_run_2d( + 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( + 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, + ) + logger.info("[2d_gaussian] job_id=%s file=%s", job_id, filepath) + return job_id + + +if __name__ == "__main__": + 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() + generate_w_shape() + generate_gaussian() + generate_poly2() + generate_harmonic() + generate_damped_harmonic() + generate_2d_gaussian() + + logger.info( + "\nDone. Open http://localhost:8004 and go to the Data page to see the jobs." + ) 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..88319798 --- /dev/null +++ b/tests/server/fitting/test_fit_runner.py @@ -0,0 +1,114 @@ +import numpy as np +import pytest + +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, + 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"] > MIN_GOOD_R2 + + 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) diff --git a/uv.lock b/uv.lock index e8395d6b..20ca98ad 100644 --- a/uv.lock +++ b/uv.lock @@ -139,6 +139,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, ] +[[package]] +name = "anytree" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/a8/eb55fab589c56f9b6be2b3fd6997aa04bb6f3da93b01154ce6fc8e799db2/anytree-2.13.0.tar.gz", hash = "sha256:c9d3aa6825fdd06af7ebb05b4ef291d2db63e62bb1f9b7d9b71354be9d362714", size = 48389, upload-time = "2025-04-08T21:06:30.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/98/f6aa7fe0783e42be3093d8ef1b0ecdc22c34c0d69640dfb37f56925cb141/anytree-2.13.0-py3-none-any.whl", hash = "sha256:4cbcf10df36b1f1cba131b7e487ff3edafc9d6e932a3c70071b5b768bab901ff", size = 45077, upload-time = "2025-04-08T21:06:29.494Z" }, +] + [[package]] name = "appnope" version = "0.1.4" @@ -669,15 +678,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, ] -[[package]] -name = "filelock" -version = "3.19.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, -] - [[package]] name = "flexcache" version = "0.3" @@ -833,6 +833,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, @@ -843,6 +844,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, @@ -853,6 +855,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, @@ -946,12 +949,15 @@ client = [ { name = "notebook" }, { name = "pandas" }, ] +pycrystal = [ + { name = "pycrystal" }, +] server = [ { name = "alembic" }, - { name = "filelock" }, { name = "h5py" }, { name = "influxdb" }, { name = "psutil" }, + { name = "scipy" }, { name = "sqlalchemy" }, { name = "tables" }, ] @@ -986,7 +992,6 @@ docs = [ [package.metadata] requires-dist = [ { name = "alembic", marker = "extra == 'server'", specifier = ">=1.14.0" }, - { name = "filelock", marker = "extra == 'server'", specifier = ">=3.16.1" }, { name = "h5py", marker = "extra == 'server'", specifier = ">=3.12.1" }, { name = "influxdb", marker = "extra == 'server'", specifier = ">=5.3.2" }, { name = "ipympl", marker = "extra == 'client'", specifier = ">=0.9.5" }, @@ -994,13 +999,15 @@ requires-dist = [ { name = "notebook", marker = "extra == 'client'", specifier = ">=7.3.1" }, { name = "pandas", marker = "extra == 'client'", specifier = ">=2.2.3" }, { name = "psutil", marker = "extra == 'server'", specifier = ">=7.0.0" }, + { name = "pycrystal", marker = "extra == 'pycrystal'", git = "ssh://git@gitlab.phys.ethz.ch/tiqi-projects/ionpulse-python-sdk/pycrystal.git?rev=v2.3.0" }, { name = "pydase", specifier = ">=0.10.21" }, { name = "pytz", specifier = ">=2024.2" }, + { name = "scipy", marker = "extra == 'server'", specifier = ">=1.14.0" }, { name = "sqlalchemy", marker = "extra == 'server'", specifier = ">=2.0.36" }, { name = "tables", marker = "extra == 'server'", specifier = ">=3.10.1" }, { name = "tiqi-zedboard", marker = "extra == 'zedboard'", git = "ssh://git@gitlab.phys.ethz.ch/tiqi-projects/drivers/tiqi-zedboard.git?rev=v1.3.0" }, ] -provides-extras = ["server", "zedboard", "client"] +provides-extras = ["server", "zedboard", "client", "pycrystal"] [package.metadata.requires-dev] build = [{ name = "pyinstaller", specifier = ">=6.15.0" }] @@ -1058,6 +1065,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "ionpulse-sequence-generator" +version = "2.3.0" +source = { git = "ssh://git@gitlab.phys.ethz.ch/tiqi-projects/ionpulse-python-sdk/ionpulse_sequence_generator.git?tag=v2.3.0#fe3112287911836b17f0d68b201bb449f0d87cbe" } +dependencies = [ + { name = "click" }, + { name = "msgpack" }, + { name = "numpy" }, + { name = "orjson" }, + { name = "scipy" }, +] + [[package]] name = "ipykernel" version = "6.30.1" @@ -2129,6 +2148,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/9e/1652778bce745a67b5fe05adde60ed362d38eb17d919a540e813d30f6874/numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631", size = 10544226, upload-time = "2025-07-24T20:56:34.509Z" }, ] +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -2457,6 +2529,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] +[[package]] +name = "pycrystal" +version = "2.3.0" +source = { git = "ssh://git@gitlab.phys.ethz.ch/tiqi-projects/ionpulse-python-sdk/pycrystal.git?rev=v2.3.0#338e626d8130dce398c4a8616a02b2835e3c8e53" } +dependencies = [ + { name = "anytree" }, + { name = "ionpulse-sequence-generator" }, + { name = "numpy" }, +] + [[package]] name = "pydantic" version = "2.11.7" @@ -2998,6 +3080,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" }, ] +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + [[package]] name = "send2trash" version = "1.8.3"