diff --git a/README.md b/README.md index 32cb6fe..e44cc85 100644 --- a/README.md +++ b/README.md @@ -36,15 +36,26 @@ Everything is a plain [ipywidgets](https://ipywidgets.readthedocs.io/)/ [anywidget](https://anywidget.dev/) object: build one, call `.display()`, and keep using its Python attributes from other cells. +### Import layers + +| Import from | What it holds | +| --- | --- | +| `cadetgui` | the ready-made benches: `WorkbenchWidget`, `CharacterizationWorkbenchWidget` | +| `cadetgui.widgets.composite` | task widgets (configuration, simulation, measurements, steps, ...) | +| `cadetgui.widgets.elements` | atomic input controls and charts | +| `cadetgui.widgets` | the bench kit (`SidebarShell`, `bench_root`, `resolve_step`, `validate_steps`, `collect_panes`, `system_pane_label`, `SYSTEM_GROUP`) and HTML helpers (`status_html`, `term_html`, `info_box_html`, `checklist_html`, `style_tag`) | +| `cadetgui.characterization` | headless characterization core: `Study`, `Comparison`, `ParameterStore`, step setups and fits | +| `cadetgui.io` | file I/O: measured data, saved configurations, simulation runs | + ### The workbench -`WorkbenchWidget` is the top-level entry point — a sidebar with four steps -(System Configuration, Process Configuration, Simulation, Parameter -Estimation), each also reachable as its own attribute: `.instrument`, -`.configuration`, `.solution`, `.parameter_estimation`. +`WorkbenchWidget` is a sidebar with four steps (System Configuration, Process +Configuration, Simulation, Parameter Estimation), each also reachable as its +own attribute: `.instrument`, `.configuration`, `.solution`, +`.parameter_estimation`. ```python -from cadetgui.widgets.composite import WorkbenchWidget +from cadetgui import WorkbenchWidget workbench = WorkbenchWidget() workbench.display() @@ -52,6 +63,19 @@ workbench.display() See [`examples/workbench.ipynb`](examples/workbench.ipynb). +### The characterization workbench + +`CharacterizationWorkbenchWidget` walks a study from the system and its +measurements through step-by-step parameter fits. + +```python +from cadetgui import CharacterizationWorkbenchWidget + +CharacterizationWorkbenchWidget().display() +``` + +See [`examples/characterization.ipynb`](examples/characterization.ipynb). + ### Composing widgets yourself Build and wire the same widgets by hand for a custom layout or a subset of @@ -68,6 +92,9 @@ config.display() solution.display() ``` +To frame your own selection as a bench, hand `(label, widget)` panes to +`cadetgui.widgets.SidebarShell` and wrap it with `cadetgui.widgets.bench_root`. + See [`examples/configuration_and_solution.ipynb`](examples/configuration_and_solution.ipynb) and [`examples/custom_sidebar.ipynb`](examples/custom_sidebar.ipynb). @@ -80,8 +107,9 @@ files. ### Running as a web app -[`examples/webapp/`](examples/webapp/) deploys `WorkbenchWidget` as a -standalone page via [Voilà](https://voila.readthedocs.io/): +[`examples/webapp/`](examples/webapp/) deploys the instrument, configuration, +simulation and parameter-estimation widgets as a standalone tabbed page via +[Voilà](https://voila.readthedocs.io/): ```bash pip install voila diff --git a/cadetgui/__init__.py b/cadetgui/__init__.py index 5bc40f5..a2eef2f 100644 --- a/cadetgui/__init__.py +++ b/cadetgui/__init__.py @@ -1,3 +1,21 @@ -"""Widget-based GUI layer on top of CADET-Process.""" +"""Widget-based GUI layer on top of CADET-Process. + +`WorkbenchWidget` and `CharacterizationWorkbenchWidget` are the ready-made benches; +they load lazily, so `import cadetgui` stays cheap. +""" +from typing import TYPE_CHECKING, Any __version__ = "0.1.0" + +if TYPE_CHECKING: + from .widgets.composite import CharacterizationWorkbenchWidget, WorkbenchWidget + +__all__ = ["WorkbenchWidget", "CharacterizationWorkbenchWidget"] + + +def __getattr__(name: str) -> Any: + if name in __all__: + from .widgets import composite + + return getattr(composite, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/cadetgui/cadetprocessadapter.py b/cadetgui/cadetprocessadapter.py index b40de48..28d4076 100644 --- a/cadetgui/cadetprocessadapter.py +++ b/cadetgui/cadetprocessadapter.py @@ -84,35 +84,38 @@ def parse_float_list(v: Any) -> list[float]: CONCENTRATION_UNITS = r"\frac{\mathrm{mol}}{\mathrm{m}^{3}_{\mathrm{IV}}}" +# 1 mL/min in m^3/s, written at the 10 significant figures the event sliders round to. +DEFAULT_FLOW_RATE = 1.666666667e-8 + # Shared scalar process fields (m^3/s, s). Per-component concentration fields are built # by `_concentration_field` instead, since they depend on the component system. PARAMS: dict[str, FieldSpec] = { "flow_rate": FieldSpec( - "flow_rate", "float", "Flow rate", 1.0e-6, + "flow_rate", "float", "Flow rate", DEFAULT_FLOW_RATE, validate=require_positive, units=r"\frac{\mathrm{m}^{3}}{\mathrm{s}}", ), "flow_rate_wash": FieldSpec( - "flow_rate_wash", "float", "Flow rate", 1.0e-6, + "flow_rate_wash", "float", "Flow rate", DEFAULT_FLOW_RATE, validate=require_positive, units=r"\frac{\mathrm{m}^{3}}{\mathrm{s}}", ), "cycle_time": FieldSpec( - "cycle_time", "float", "Cycle time", 6000.0, + "cycle_time", "float", "Cycle time", 600.0, validate=require_positive, units=r"\mathrm{s}", ), "pulse_duration": FieldSpec( - "pulse_duration", "float", "Pulse duration", 60.0, + "pulse_duration", "float", "Pulse duration", 10.0, validate=require_positive, units=r"\mathrm{s}", ), "delta_t_wash": FieldSpec( - "delta_t_wash", "float", "Wash duration", 600.0, + "delta_t_wash", "float", "Wash duration", 300.0, validate=require_positive, units=r"\mathrm{s}", ), "delta_t_elute": FieldSpec( - "delta_t_elute", "float", "Elution duration", 1200.0, + "delta_t_elute", "float", "Elution duration", 900.0, validate=require_positive, units=r"\mathrm{s}", ), "delta_t_final_wash": FieldSpec( - "delta_t_final_wash", "float", "Final wash duration", 600.0, + "delta_t_final_wash", "float", "Final wash duration", 300.0, validate=require_positive, units=r"\mathrm{s}", ), } @@ -406,6 +409,24 @@ def template_required_units(template: Callable[..., ModelSpec] | None) -> frozen "column", "tubing_post_column", "tubing_detectors", ) +# Mixer/tubing units with their own parameter form and dead-volume seeding. The column's +# own form belongs to ConfigurationWidget; this set only covers the instrument's hardware. +CONFIGURABLE_UNITS: tuple[str, ...] = ( + "mixer", "tubing_pre_injection", "tubing_pre_column", "tubing_post_column", "tubing_detectors", +) + +# Non-degenerate starting values; CADET-Core has no canonical default for these. +_TUBING_SEED_DEFAULTS: Dict[str, float] = { + "diameter": 0.5e-3, "length": 0.1, "axial_dispersion": 1e-7, +} +UNIT_SEED_DEFAULTS: Dict[str, Dict[str, float]] = { + "mixer": {"init_liquid_volume": 1e-6}, + "tubing_pre_injection": _TUBING_SEED_DEFAULTS, + "tubing_pre_column": _TUBING_SEED_DEFAULTS, + "tubing_post_column": _TUBING_SEED_DEFAULTS, + "tubing_detectors": _TUBING_SEED_DEFAULTS, +} + # Plain names for every LCFlowSheet unit. UNIT_LABELS: dict[str, str] = { "buffer_a": "Buffer A", @@ -513,15 +534,16 @@ def _infer_kind(x: Any) -> str: # Starting values so a blank form doesn't apply a degenerate column. A `None` model slot # is shared across column models; a model name overrides it. _GUI_SEED_DEFAULTS: dict[tuple[str, Optional[str], str], float] = { - ("column", None, "diameter"): 0.024, - ("column", None, "length"): 0.5, + ("column", None, "diameter"): 0.005, + ("column", None, "length"): 0.05, ("column", None, "axial_dispersion"): 1e-8, - ("column", None, "bed_porosity"): 0.72, - ("column", None, "total_porosity"): 0.72, + ("column", None, "bed_porosity"): 0.37, + ("column", None, "total_porosity"): 0.75, ("column", None, "particle_porosity"): 0.6, ("column", None, "particle_radius"): 5.0e-6, ("column", None, "film_diffusion"): 1e-3, ("column", None, "pore_diffusion"): 1e-10, + ("column", "Cstr", "init_liquid_volume"): 1e-6, } diff --git a/cadetgui/characterization/__init__.py b/cadetgui/characterization/__init__.py new file mode 100644 index 0000000..ee91171 --- /dev/null +++ b/cadetgui/characterization/__init__.py @@ -0,0 +1,40 @@ +"""Characterization core: studies, measurements, parameter stores and step fits. + +Names below load lazily from their modules, so importing one module of this +package does not import the others. +""" +from importlib import import_module +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .comparison import Comparison + from .parameter_store import ParameterStore + from .runner import StepResult, StepSetup + from .step_checks import StepStatus, study_steps_status + from .study import Study + +_EXPORTS = { + "Study": "study", + "Comparison": "comparison", + "ParameterStore": "parameter_store", + "StepSetup": "runner", + "StepResult": "runner", + "StepStatus": "step_checks", + "study_steps_status": "step_checks", +} + +__all__ = [ + "Study", + "Comparison", + "ParameterStore", + "StepSetup", + "StepResult", + "StepStatus", + "study_steps_status", +] + + +def __getattr__(name: str) -> Any: + if name in _EXPORTS: + return getattr(import_module(f".{_EXPORTS[name]}", __name__), name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/cadetgui/characterization/comparison.py b/cadetgui/characterization/comparison.py new file mode 100644 index 0000000..f182f8e --- /dev/null +++ b/cadetgui/characterization/comparison.py @@ -0,0 +1,336 @@ +"""One measured run paired with the process recipe that should reproduce it. + +A `Comparison` holds the recipe, the measured channel, the observation point, +the clock alignment, the per-trace baseline/normalization and the metric. It +builds a fresh process from the recipe plus a `ParameterStore`, an aligned +`ReferenceIO`, and a CADET-Process `Comparator`. Headless (no ipywidgets). +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Mapping, Optional + +import numpy as np +from CADETProcess import calibration +from CADETProcess.characterization import setup_comparators +from CADETProcess.comparison import Comparator, difference +from CADETProcess.processModel import ComponentSystem, Process +from CADETProcess.reference import ReferenceIO +from CADETProcess.solution import slice_solution + +from .. import process_builder +from ..cadetprocessadapter import process_phase_spans, sample_injection_times +from ..io.configuration_store import ConfigurationState, InstrumentState +from ..io.experimental_data import ExperimentalRun, read_experimental_csv, to_time +from ..simulation import run_process +from .parameter_store import ParameterStore, apply_store + +__all__ = ["Comparison", "ComparisonPreview", "recipe_from_dict"] + +_NORMALIZATION_KINDS = ("none", "area") + + +def recipe_from_dict(raw: Mapping[str, Any]) -> ConfigurationState: + """Rebuild a `ConfigurationState` from its `dataclasses.asdict` form.""" + raw = dict(raw) + instrument = raw.pop("instrument", None) + return ConfigurationState( + **raw, instrument=InstrumentState(**instrument) if instrument else None + ) + + +BASELINE_NOT_FOUND = "No flat baseline found in the measured trace." + + +@dataclass(frozen=True) +class ComparisonPreview: + """One simulation at a given store, set against the aligned reference.""" + + name: str + simulated_time: np.ndarray + simulated_values: np.ndarray + reference_time: np.ndarray + reference_values: np.ndarray + metric: str + value: float + + +@dataclass +class Comparison: + """One measured run + recipe + channel + observation point + alignment + metric. + + `run` is the loaded `ExperimentalRun`; it is attached at runtime and never + serialized. `baseline_window` and `measured_injection` are in the run's own + x unit (mL or s); `window` is in seconds on the simulated clock. + `experiment_type` is an id from `guide.EXPERIMENT_TYPES`, or `None`. + """ + + name: str + data_file: str + channel: str + recipe: ConfigurationState + solution_path: str + flow_rate: Optional[float] = None + overrides: dict = field(default_factory=dict) + injection_marker: Optional[str] = None + measured_injection: Optional[float] = None + baseline_window: Optional[tuple] = None + normalization: dict = field(default_factory=lambda: {"kind": "none"}) + components: Optional[list] = None + metric: str = "NRMSE" + window: tuple = (None, None) + probe: Optional[str] = None + experiment_type: Optional[str] = None + run: Optional[ExperimentalRun] = field(default=None, repr=False, compare=False) + + def to_dict(self) -> dict[str, Any]: + """Return the JSON form (everything but `run`).""" + return { + "name": self.name, + "data_file": self.data_file, + "channel": self.channel, + "flow_rate": self.flow_rate, + "recipe": asdict(self.recipe), + "overrides": dict(self.overrides), + "injection_marker": self.injection_marker, + "measured_injection": self.measured_injection, + "baseline_window": ( + list(self.baseline_window) if self.baseline_window is not None else None + ), + "normalization": dict(self.normalization), + "solution_path": self.solution_path, + "components": list(self.components) if self.components is not None else None, + "metric": self.metric, + "window": list(self.window), + "probe": self.probe, + "experiment_type": self.experiment_type, + } + + @classmethod + def from_dict( + cls, raw: Mapping[str, Any], base_dir: "Path | str | None" = None + ) -> "Comparison": + """Rebuild from `to_dict`'s form; with `base_dir`, also load `data_file` into `run`.""" + raw = dict(raw) + raw["recipe"] = recipe_from_dict(raw["recipe"]) + if raw.get("baseline_window") is not None: + raw["baseline_window"] = tuple(raw["baseline_window"]) + raw["window"] = tuple(raw.get("window") or (None, None)) + comparison = cls(**raw) + if base_dir is not None: + content = (Path(base_dir) / comparison.data_file).read_bytes() + comparison.run = read_experimental_csv(content, comparison.name) + return comparison + + @property + def resolved_flow_rate(self) -> Optional[float]: + """`flow_rate`, else the recipe's `flow_rate` model value (overrides first).""" + if self.flow_rate is not None: + return float(self.flow_rate) + value = {**self.recipe.model_values, **self.overrides}.get("flow_rate") + return float(value) if value is not None else None + + def build_process(self, store: ParameterStore) -> Process: + """Build the recipe's process, named after this comparison, with `store` applied.""" + process = process_builder.build_process(self.recipe, overrides=self.overrides) + process.name = self.name + apply_store(process, store) + return process + + def _to_seconds(self, x: float) -> float: + if self.run.x_basis == "time": + return float(x) + flow_rate = self.resolved_flow_rate + if flow_rate is None: + raise ValueError( + f"Comparison {self.name!r}: a flow rate is required to convert the " + "run's volume axis to time." + ) + return float(x) * 1e-6 / flow_rate + + def measured_injection_x(self) -> Optional[float]: + """Return the injection position in run x units: marker, else `measured_injection`.""" + if self.injection_marker is None: + return self.measured_injection + for x, text in self.run.markers: + if text.startswith(self.injection_marker): + return x + raise ValueError( + f"Comparison {self.name!r}: no marker starting with " + f"{self.injection_marker!r}. Available markers: " + f"{[text for _, text in self.run.markers]}" + ) + + @staticmethod + def simulated_injection_time(process: Process) -> float: + """First sample-loop injection, else the start of the first phase, else 0.""" + injections = sample_injection_times(process) + if injections: + return injections[0] + spans = process_phase_spans(process) + return spans[0]["start"] if spans else 0.0 + + def build_reference(self, process: Process) -> ReferenceIO: + """Return the measured channel on `process`'s clock, baseline-corrected and scaled. + + Without a marker or `measured_injection`, the run's clock is taken as the + simulated one. + """ + if self.run is None: + raise ValueError(f"Comparison {self.name!r}: no experimental run loaded.") + if self.channel not in self.run.channels: + raise ValueError( + f"Comparison {self.name!r}: unknown channel {self.channel!r}. " + f"Available: {list(self.run.channels)}" + ) + flow_rate = self.resolved_flow_rate + time, values = to_time(self.run, self.channel, flow_rate) + order = np.argsort(time) + reference = ReferenceIO(self.name, time[order], values[order], flow_rate=flow_rate) + + baseline = {} + if self.baseline_window is not None: + start, end = self.baseline_window + baseline = { + "start": self._to_seconds(start) if start is not None else None, + "end": self._to_seconds(end) if end is not None else None, + } + + kind = self.normalization.get("kind", "none") + if kind not in ("area", "none"): + raise ValueError(f"Comparison {self.name!r}: unknown normalization {kind!r}.") + try: + if kind == "area": + reference = calibration.correct_baseline_and_normalize( + reference, + float(self.normalization["target_area"]), + start_baseline=baseline.get("start"), + end_baseline=baseline.get("end"), + ) + elif baseline: + reference = calibration.correct_baseline( + reference, baseline["start"], baseline["end"] + ) + except ValueError as exc: + if "baseline points" not in str(exc): + raise + raise ValueError( + f"{BASELINE_NOT_FOUND} The baseline is taken from the points closest to the " + "trace's minimum, so a single dip or a drifting signal hides the flat part." + ) from exc + + injection_x = self.measured_injection_x() + shift = 0.0 + if injection_x is not None: + shift = self.simulated_injection_time(process) - self._to_seconds(injection_x) + time = reference.time + shift + valid = (time >= 0) & (time <= process.cycle_time) + + names = self.components if self.components and len(self.components) == 1 else None + return ReferenceIO( + self.name, + time[valid], + reference.solution[valid], + flow_rate=flow_rate, + component_system=ComponentSystem(list(names)) if names else None, + ) + + def build(self, store: ParameterStore) -> tuple[Process, Comparator]: + """Return this comparison's process (with `store` applied) and its comparator.""" + process = self.build_process(store) + reference = self.build_reference(process) + single = self.components is not None and len(self.components) == 1 + start, end = self.window + (comparator,) = setup_comparators( + process, + reference, + solution_path=self.solution_path, + metrics=[self.metric], + components=list(self.components) if single else None, + start=start, + end=end, + ) + if not single: + for metric in comparator.metrics: + _sum_components(metric, self.components) + return process, comparator + + def evaluate(self, store: ParameterStore) -> ComparisonPreview: + """Simulate once at `store` and return the simulated and reference traces and metric.""" + process, comparator = self.build(store) + results = run_process(process) + (metric,) = comparator.metrics + simulated = slice_solution( + comparator.extract_solution(results, metric), + metric.components, + metric.use_total_concentration, + metric.use_total_concentration_components, + ) + reference = metric.reference_original + return ComparisonPreview( + name=self.name, + simulated_time=np.asarray(simulated.time), + simulated_values=np.asarray(simulated.solution)[:, 0], + reference_time=np.asarray(reference.time), + reference_values=np.asarray(reference.solution)[:, 0], + metric=self.metric, + value=float(comparator.evaluate(results)[0]), + ) + + def problems(self) -> list[str]: + """Return reasons this comparison cannot be built or run; empty if none found.""" + found = [] + if self.run is None: + found.append(f"No data loaded for {self.data_file!r}.") + else: + if self.channel not in self.run.channels: + found.append( + f"Unknown channel {self.channel!r}; available: {list(self.run.channels)}." + ) + if self.injection_marker is not None and not any( + text.startswith(self.injection_marker) for _, text in self.run.markers + ): + found.append(f"No run-log marker starts with {self.injection_marker!r}.") + if self.run.x_basis == "volume" and self.resolved_flow_rate is None: + found.append("A flow rate is required for a volume-axis run.") + + kind = self.normalization.get("kind", "none") + if kind not in _NORMALIZATION_KINDS: + found.append(f"Unknown normalization {kind!r}.") + elif kind == "area" and self.normalization.get("target_area") is None: + found.append("Area normalization needs a target_area.") + if kind == "area" and self.resolved_flow_rate is None: + found.append("Area normalization needs a flow rate.") + + if not hasattr(difference, self.metric): + found.append(f"Unknown metric {self.metric!r}.") + + unknown = [c for c in self.components or () if c not in self.recipe.components] + if unknown: + found.append(f"Components {unknown} are not in the recipe {self.recipe.components}.") + + check = process_builder.check_recipe(self.recipe, self.overrides) + if check.error is not None: + found.append(f"Recipe cannot be built: {check.error}") + else: + unit, _, port = self.solution_path.partition(".") + if unit not in check.units or port.split(".")[0] not in ("inlet", "outlet"): + found.append( + f"Solution path {self.solution_path!r} is not in the process; units: " + f"{list(check.units)}." + ) + return found + + +def _sum_components(metric: Any, components: Optional[list]) -> None: + """Compare `metric` against the summed concentration of `components` (all if `None`). + + `setup_comparators` exposes no total-concentration switch. The reference is a + single measured trace, so it is re-sliced before `components` is set, which from + then on only selects the simulated components. + """ + metric.use_total_concentration = True + metric.reference = metric.reference_original + metric.components = list(components) if components is not None else None diff --git a/cadetgui/characterization/guide.py b/cadetgui/characterization/guide.py new file mode 100644 index 0000000..9cdae2c --- /dev/null +++ b/cadetgui/characterization/guide.py @@ -0,0 +1,1215 @@ +"""Lab-language guide to the characterization chain: experiment types, default chain, glossary. + +An `ExperimentType` says what a lab run implies for the model: which units are in the +flow path, which process template, which component, where the detector sits, which +chain step it feeds and which values it fixes by assumption. `DEFAULT_CHAIN` is the +recommended order of steps with plain-language docs per step. Headless (no ipywidgets). +""" + +from __future__ import annotations + +import inspect +from dataclasses import dataclass, replace +from types import SimpleNamespace +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +from CADETProcess.processModel import ComponentSystem + +from ..cadetprocessadapter import ( + BYPASSABLE_UNITS, + INSTRUMENT_TEMPLATES, + PARAMS, + UNIT_LABELS, +) +from ..io.configuration_store import ConfigurationState, InstrumentState +from ..io.experimental_data import ExperimentalRun +from ..parameters import get_parameters +from ..process_builder import check_recipe +from ..starting_values import with_starting_values +from .comparison import Comparison +from .parameter_store import ( + Entry, + ParameterSpec, + ParameterStore, + Provenance, + has_parameter, + read_process, + spec_for, +) +from .runner import StepSetup +from .stages import STAGES, VariableDef, describe_stage + +__all__ = [ + "parameter_unit", + "SALT", + "COMPONENT_ROLES", + "SYSTEM_TRACER", + "SMALL_TRACER", + "LARGE_TRACER", + "PROTEIN", + "SALT_ROLE", + "Determined", + "ImpliedValue", + "ExperimentType", + "EXPERIMENT_TYPES", + "ChainStepGuide", + "DEFAULT_CHAIN", + "CHAIN_BY_ID", + "GLOSSARY", + "GLOSSARY_ALIASES", + "WORKFLOW_GUIDE", + "new_step_setup", + "with_implied_values", + "missing_assumptions", + "record_assumptions", + "measurement_from_run", + "map_species", + "find_injection_marker", + "guide_for_step", + "parameter_label", + "species_gap_messages", + "guide_measurements", + "role_fix", + "option_label", + "option_help", + "STEP_TYPE_HELP", + "step_type_help", + "step_summary", + "StartingValue", + "starting_values", +] + +SALT = "Salt" + +SYSTEM_TRACER = "system tracer" +SMALL_TRACER = "small tracer" +LARGE_TRACER = "large tracer" +PROTEIN = "protein" +SALT_ROLE = "salt" + +COMPONENT_ROLES: Dict[str, str] = { + SYSTEM_TRACER: "A non-interacting tracer used only for system pulses (column replaced " + "by a union). A small tracer such as acetone does both jobs: declare it as a small " + "tracer instead.", + SMALL_TRACER: "A small non-binding tracer that enters the bead pores (e.g. acetone or " + "salt pulse). Also serves as the tracer of system pulses.", + LARGE_TRACER: "A large non-binding tracer that is excluded from the pores (e.g. " + "dextran).", + PROTEIN: "The protein you characterize: non-binding pulses, gradient elution and " + "breakthrough.", + SALT_ROLE: "The salt of gradient and breakthrough runs; component 0 of steric mass " + "action. Its name is fixed to \"" + SALT + "\".", +} + +_LWE = "Load–Wash–Elute (LWE)" +_INITIAL_STATE = ("c", "cp", "q") +_SMA = "Steric Mass Action (SMA)" + + +@dataclass(frozen=True) +class ImpliedValue: + """A value an experiment type fixes by assumption; species-indexed ones go to the probe.""" + + path: str + value: float + species_indexed: bool + note: str + + +@dataclass(frozen=True) +class ExperimentType: + """One kind of lab run and what it implies for the recipe, observation and store. + + `bypass` units are taken out of the flow path, `in_line` units are put back even if + the base recipe bypassed them. `binding_key` replaces the base recipe's binding model. + `with_salt` puts `SALT` in front of the probe component (SMA needs salt as component 0). + The base recipe's initial column state is dropped; binding values that are missing + or zero and the method of a salt-first binding model come from the shared starting + values (`starting_values.with_starting_values`). + `marker_keywords` are searched, in order, in the run-log markers to find the injection. + `probe_roles` are the `COMPONENT_ROLES` a probe of this type may have; the first is + the one a new component gets. + """ + + id: str + label: str + what_you_run: str + step_id: str + probe_role: str + detector: str + template_key: str + solution_path: str + normalization: str + bypass: Tuple[str, ...] + in_line: Tuple[str, ...] + binding_key: str + with_salt: bool = False + sample_loop: bool = True + marker_keywords: Tuple[str, ...] = ("inject", "sample", "elution") + help: str = "" + implied: Tuple[ImpliedValue, ...] = () + probe_roles: Tuple[str, ...] = () + + def components(self, component: str) -> List[str]: + """Return the recipe's component list for probe `component`.""" + if component == SALT and self.with_salt: + raise ValueError(f"{SALT!r} is reserved for the salt component of {self.label!r}.") + return [SALT, component] if self.with_salt else [component] + + def apply(self, base_recipe: ConfigurationState, *, component: str) -> ConfigurationState: + """Return `base_recipe` set up for this experiment with the single probe `component`.""" + components = self.components(component) + instrument = base_recipe.instrument or InstrumentState() + bypass = (set(instrument.bypass_units) | set(self.bypass)) - set(self.in_line) + instrument = replace( + instrument, + bypass_units=[u for u in BYPASSABLE_UNITS if u in bypass], + include_sample_loop=self.sample_loop, + ) + same_binding = self.binding_key == base_recipe.binding_key + return with_starting_values(replace( + base_recipe, + components=components, + binding_key=self.binding_key, + template_key=self.template_key, + instrument=instrument, + column_values={ + k: v for k, v in map_species( + base_recipe.column_values, base_recipe.components, components + ).items() if k not in _INITIAL_STATE + }, + binding_values=( + map_species(base_recipe.binding_values, base_recipe.components, components) + if same_binding else {} + ), + model_values=self._model_values(base_recipe, components), + )) + + def _model_values( + self, base: ConfigurationState, components: List[str] + ) -> Dict[str, Any]: + base_values = dict(base.model_values) + if "flow_rate" in base_values: + base_values.setdefault("flow_rate_wash", base_values["flow_rate"]) + if "flow_rate_wash" in base_values: + base_values.setdefault("flow_rate", base_values["flow_rate_wash"]) + + fields = {f.name: f for f in _template_fields(self.template_key, components)} + values = {k: v for k, v in base_values.items() if k in fields and not _is_list(v)} + probe = components[-1] + + def species(key: str, name: str, fallback: float) -> float: + return _species_value(base, key, name, fallback) + + def default(key: str) -> float: + return float(fields[key].default[0]) + + for key in ("c_buffer_a", "c_buffer_b", "c_sample"): + if key not in fields: + continue + if self.with_salt: + if key == "c_sample": + fallback = values["c_buffer_a"][0] if "c_buffer_a" in values else default(key) + values[key] = [species(key, SALT, fallback), species(key, probe, 1.0)] + else: + values[key] = [species(key, SALT, default(key)), species(key, probe, 0.0)] + elif key == "c_sample": + values[key] = [species(key, probe, _first_positive(base, key, 1.0))] + else: + values[key] = [0.0] + return values + + def store_entries(self, component: str) -> Dict[str, Entry]: + """Return the values this experiment type implies for probe `component`.""" + return { + item.path: Entry( + value={component: item.value} if item.species_indexed else item.value, + provenance=Provenance( + step=f"assumed: {self.id}", probe=component, note=item.note + ), + ) + for item in self.implied + } + + +def _is_list(value: Any) -> bool: + return isinstance(value, (list, tuple)) + + +def _template_fields(template_key: str, components: Sequence[str]) -> list: + """Return the instrument template's form fields for `components`, without a flow sheet.""" + stub = SimpleNamespace(component_system=ComponentSystem(list(components))) + return INSTRUMENT_TEMPLATES[template_key](stub).fields + + +def _species_value(base: ConfigurationState, key: str, name: str, fallback: float) -> float: + values = base.model_values.get(key) + if _is_list(values) and name in base.components and len(values) == len(base.components): + return float(values[base.components.index(name)]) + return fallback + + +def _first_positive(base: ConfigurationState, key: str, fallback: float) -> float: + values = base.model_values.get(key) + positive = [float(v) for v in values if float(v) > 0] if _is_list(values) else [] + return positive[-1] if positive else fallback + + +def map_species( + values: Mapping[str, Any], old: Sequence[str], new: Sequence[str] +) -> Dict[str, Any]: + """Re-key per-component lists from `old` to `new` components by name. + + A single-component list is broadcast; a list that can't be mapped is dropped so the + field falls back to its default. + """ + out: Dict[str, Any] = {} + for key, value in values.items(): + if not _is_list(value) or len(value) != len(old): + out[key] = value + elif all(name in old for name in new): + out[key] = [value[list(old).index(name)] for name in new] + elif len(value) == 1: + out[key] = [value[0]] * len(new) + return out + + +_PERIPHERY_LUMPING = ( + "Every dead volume between the sample loop and the detector (pre-column tubing, " + "valves, the empty column holder or union, post-column tubing) is lumped into one " + "tube, the pre-column tubing. Its fitted length and dispersion therefore stand for " + "the whole extra-column volume, not for a physical piece of tubing." +) + +_COLUMN_PULSE = ("tubing_pre_injection", "tubing_post_column") + +EXPERIMENT_TYPES: Dict[str, ExperimentType] = { + t.id: t for t in ( + ExperimentType( + id="system_pulse", + label="System pulse (no column in line)", + what_you_run=( + "Replace the column by a zero-dead-volume union and inject a small, non-" + "interacting tracer (e.g. acetone or salt) from the sample loop. Record the " + "detector signal of the tracer." + ), + step_id="system_periphery", + probe_roles=(SMALL_TRACER, SYSTEM_TRACER), + probe_role="tracer", + detector="UV", + template_key="Pulse Injection", + solution_path="tubing_pre_column.outlet", + normalization="area", + bypass=("tubing_pre_injection", "column", "tubing_post_column", "tubing_detectors"), + in_line=("tubing_pre_column",), + binding_key="None", + help=_PERIPHERY_LUMPING, + ), + ExperimentType( + id="column_pulse_small_tracer", + label="Column pulse, small tracer (conductivity)", + what_you_run=( + "With the column in line, inject a small tracer that enters the pores but " + "does not bind (e.g. salt or acetone). Read it on the conductivity cell, " + "which sits after the UV cell." + ), + step_id="column_packing", + probe_roles=(SMALL_TRACER,), + probe_role="small tracer (enters pores)", + detector="conductivity", + template_key="Pulse Injection", + solution_path="tubing_detectors.outlet", + normalization="area", + bypass=_COLUMN_PULSE, + in_line=("tubing_pre_column", "column", "tubing_detectors"), + binding_key="None", + help=( + "The conductivity cell is observed after the detector tubing, the short " + "volume between the UV and conductivity cells. Its size is known hardware " + "and belongs in the parameter store before this step." + ), + ), + ExperimentType( + id="column_pulse_large_tracer", + label="Column pulse, large tracer (UV)", + what_you_run=( + "With the column in line, inject a large tracer that is excluded from the " + "pores (e.g. dextran or a large non-binding protein). Read it on the UV cell " + "directly after the column." + ), + step_id="column_packing", + probe_roles=(LARGE_TRACER,), + probe_role="large tracer (excluded from pores)", + detector="UV", + template_key="Pulse Injection", + solution_path="column.outlet", + normalization="area", + bypass=(*_COLUMN_PULSE, "tubing_detectors"), + in_line=("tubing_pre_column", "column"), + binding_key="None", + help=( + "A pore-excluded tracer sees only the space between the beads. The model " + "expresses this by setting its film diffusion to zero, which is an " + "assumption of this experiment type, not a fitted value." + ), + implied=( + ImpliedValue( + "flow_sheet.column.film_diffusion", 0.0, True, + "assumed by the experiment type: a large tracer is excluded from the pores", + ), + ), + ), + ExperimentType( + id="nonbinding_protein_pulse", + label="Non-binding protein pulse", + what_you_run=( + "With the column in line, inject the protein under conditions where it does " + "not bind (e.g. high salt). Read it on the UV cell after the column." + ), + step_id="particle_transport", + probe_roles=(PROTEIN,), + probe_role="non-binding protein", + detector="UV", + template_key="Pulse Injection", + solution_path="column.outlet", + normalization="area", + bypass=(*_COLUMN_PULSE, "tubing_detectors"), + in_line=("tubing_pre_column", "column"), + binding_key="None", + help=( + "Peak broadening beyond what the packing explains comes from mass transfer " + "into the beads, which this run determines as film diffusion." + ), + ), + ExperimentType( + id="linear_gradient_elution", + label="Linear gradient elution", + what_you_run=( + "Load a small amount of protein at low salt, wash, then elute with a linear " + "salt gradient from buffer A to buffer B. Read the protein on UV." + ), + step_id="binding", + probe_roles=(PROTEIN,), + probe_role="binding protein", + detector="UV", + template_key=_LWE, + solution_path="column.outlet", + normalization="area", + bypass=(*_COLUMN_PULSE, "tubing_detectors"), + in_line=("tubing_pre_column", "column"), + binding_key=_SMA, + with_salt=True, + marker_keywords=("inject", "sample", "load"), + help=( + "Binding is modelled with steric mass action, so salt is component 0 and " + "the protein component 1. Where the protein elutes in the gradient " + "determines its characteristic charge and equilibrium constant." + ), + ), + ExperimentType( + id="breakthrough", + label="Breakthrough", + what_you_run=( + "Pump protein feed continuously onto the equilibrated column until the " + "outlet reaches the feed concentration. Read the protein on UV." + ), + step_id="capacity", + probe_roles=(PROTEIN,), + probe_role="binding protein", + detector="UV", + template_key="Breakthrough", + solution_path="column.outlet", + normalization="none", + bypass=(*_COLUMN_PULSE, "tubing_detectors"), + in_line=("tubing_pre_column", "column"), + binding_key=_SMA, + with_salt=True, + sample_loop=False, + marker_keywords=("load", "sample", "feed"), + help=( + "The breakthrough curve is compared in concentration units, so the UV " + "signal must be converted to concentration; area normalization does not " + "apply to a curve that never returns to baseline." + ), + ), + ) +} + + +def with_implied_values( + store: ParameterStore, experiment_type: ExperimentType, component: str +) -> ParameterStore: + """Return `store` plus the values `experiment_type` implies for `component`.""" + for path, entry in experiment_type.store_entries(component).items(): + item = next(i for i in experiment_type.implied if i.path == path) + specs = {} if path in store.specs else {path: ParameterSpec(path, item.species_indexed)} + store = store.updated({path: entry.value}, entry.provenance, specs=specs) + return store + + +def missing_assumptions( + setup: StepSetup, store: ParameterStore +) -> List[Tuple[Comparison, str, float]]: + """Return `(comparison, path, value)` for experiment-type assumptions `store` lacks.""" + missing = [] + for comparison in setup.comparisons: + experiment = EXPERIMENT_TYPES.get(comparison.experiment_type or "") + if experiment is None or not comparison.probe: + continue + for item in experiment.implied: + value = store.entries[item.path].value if item.path in store else None + if item.species_indexed: + present = isinstance(value, dict) and comparison.probe in value + else: + present = value is not None + if not present: + missing.append((comparison, item.path, item.value)) + return missing + + +def record_assumptions(setup: StepSetup, store: ParameterStore) -> ParameterStore: + """Return `store` plus the values the step's experiment types assume and it lacks. + + Species gaps are judged on this store, so an assumed value is never covered by a + species transfer. + """ + for comparison, _path, _value in missing_assumptions(setup, store): + store = with_implied_values( + store, EXPERIMENT_TYPES[comparison.experiment_type], comparison.probe + ) + return store + + +@dataclass(frozen=True) +class Determined: + """One parameter a step determines: its name, what it means physically, and its unit.""" + + name: str + meaning: str + unit: str + + +@dataclass(frozen=True) +class ChainStepGuide: + """One recommended chain step, described in lab terms. + + `system_units` are the flow-sheet units whose parameters this step determines; + `requires` are parameter paths earlier steps must have provided; `known_values` are + hardware parameter paths that must be known (in the parameter store or set in the + recipe) before the fit. Both are checked live (`step_checks.step_checks`); `advice` + holds what the software cannot check. + """ + + id: str + title: str + purpose: str + stage: str + options: Mapping[str, Any] + determines: Tuple[Determined, ...] + system_units: Tuple[str, ...] + experiment_types: Tuple[str, ...] + advice: Tuple[str, ...] + reading_results: str + requires: Tuple[str, ...] = () + known_values: Tuple[str, ...] = () + + +_TUBING = ("flow_sheet.tubing_pre_column.length", "flow_sheet.tubing_pre_column.axial_dispersion") +_BED = ( + "flow_sheet.column.bed_porosity", + "flow_sheet.column.particle_porosity", + "flow_sheet.column.axial_dispersion", +) +_FILM = ("flow_sheet.column.film_diffusion",) +_SMA_EQ = ( + "flow_sheet.column.binding_model.characteristic_charge", + "flow_sheet.column.binding_model.adsorption_rate", +) + +_READING_PARETO = ( + "Each measurement is one objective, so the fit returns a set of Pareto candidates: " + "parameter sets where no measurement can be matched better without matching another " + "worse. 'Best average' has the lowest mean NRMSE and is the usual choice when the " + "replicates agree. If one candidate is best for one run only, look at that run's " + "chart: an outlier run (bad baseline, wrong marker) pulls its own candidate away, " + "and fixing the measurement is better than accepting it." +) + +DEFAULT_CHAIN: Tuple[ChainStepGuide, ...] = ( + ChainStepGuide( + id="system_periphery", + title="Extra-column volume", + purpose="How much the system itself delays and spreads a pulse, without a column.", + stage="tubing", + options={"tubing": "tubing_pre_column"}, + determines=( + Determined( + "Pre-column tubing length", + "Dead volume between sample loop and detector, expressed as a tubing length", + "m", + ), + Determined("Tubing axial dispersion", "Band broadening in that dead volume", "m²/s"), + ), + system_units=("tubing_pre_column",), + experiment_types=("system_pulse",), + advice=( + "The baseline before the peak is flat, or a baseline window is set in the " + "measurement.", + "The tracer does not interact with the tubing (a symmetric, fully recovered peak).", + ), + reading_results=_READING_PARETO, + known_values=("flow_sheet.tubing_pre_column.diameter",), + ), + ChainStepGuide( + id="column_packing", + title="Column packing", + purpose="How the packed bed is built: space between beads, space inside beads, " + "and band broadening along the column.", + stage="bed", + options={"include_particle_porosity": True}, + determines=( + Determined("Bed porosity", "Volume fraction between the beads", "–"), + Determined("Particle porosity", "Pore volume fraction inside a bead", "–"), + Determined("Column axial dispersion", "Band broadening in the packed bed", "m²/s"), + ), + system_units=("column",), + experiment_types=("column_pulse_small_tracer", "column_pulse_large_tracer"), + advice=( + "Baselines are flat, or a baseline window is set in each measurement.", + "The large tracer is really excluded from the pores (e.g. a dextran well above " + "the resin's exclusion limit).", + ), + reading_results=_READING_PARETO + ( + " The small tracer sets the total porosity, the large tracer the bed porosity; " + "a candidate that fits one tracer only usually trades one porosity for the other." + ), + requires=_TUBING, + known_values=( + "flow_sheet.column.length", + "flow_sheet.column.diameter", + "flow_sheet.tubing_detectors.length", + "flow_sheet.tubing_detectors.diameter", + ), + ), + ChainStepGuide( + id="particle_transport", + title="Particle transport", + purpose="How fast a protein moves from the liquid between the beads into the pores.", + stage="particles", + options={"include_film_diffusion": True}, + determines=( + Determined("Film diffusion", "Mass transfer of the protein into the beads", "m/s"), + ), + system_units=("column",), + experiment_types=("nonbinding_protein_pulse",), + advice=( + "The protein does not bind under the run conditions (full recovery).", + "Baselines are flat, or a baseline window is set in each measurement.", + ), + reading_results=_READING_PARETO, + requires=(*_TUBING, *_BED), + known_values=("flow_sheet.column.particle_radius",), + ), + ChainStepGuide( + id="binding", + title="Binding", + purpose="How strongly the protein binds and how its binding depends on salt.", + stage="adsorption", + options={"component_index": 1}, + determines=( + Determined("Characteristic charge", "Number of binding sites the protein uses", "–"), + Determined( + "Equilibrium constant", + "Binding strength, as adsorption rate with desorption fixed at 1", + "–", + ), + ), + system_units=("column",), + experiment_types=("linear_gradient_elution",), + advice=( + "Gradients of different slopes are attached; one slope cannot separate charge " + "from equilibrium constant.", + "The protein is loaded well below capacity, so the peak position does not " + "depend on the load.", + ), + reading_results=_READING_PARETO + ( + " Compare peak positions first; peak shape depends on transport values fitted " + "earlier." + ), + requires=(*_TUBING, *_BED, *_FILM), + known_values=("flow_sheet.column.binding_model.capacity",), + ), + ChainStepGuide( + id="capacity", + title="Capacity", + purpose="How much protein the column holds before it breaks through.", + stage="capacity", + options={}, + determines=( + Determined("Ionic capacity", "Binding sites of the resin", "mol/m³ solid phase"), + ), + system_units=("column",), + experiment_types=("breakthrough",), + advice=( + "The UV signal is converted to concentration (or the detector is in its linear " + "range and scaled).", + "The run continues until the outlet reaches the feed concentration.", + ), + reading_results=_READING_PARETO, + requires=(*_TUBING, *_BED, *_FILM, *_SMA_EQ), + ), +) + +CHAIN_BY_ID: Dict[str, ChainStepGuide] = {g.id: g for g in DEFAULT_CHAIN} + +GLOSSARY: Dict[str, str] = { + "measurement": "One recorded run: a data file with one or more detector channels.", + "characterization step": "One fit of one part of the system (e.g. the extra-column " + "volume or the column packing) to the measurements that belong to it. Steps run in " + "chain order; each starts from the values accepted before it.", + "step type": "What a step fits and how the model represents it, e.g. the length and " + "dispersion of one tubing segment or the porosities of the column bed. A step of the " + "recommended chain sets it for you.", + "extra-column volume": "All the liquid volume a sample passes outside the packed bed " + "(tubing, valves, mixer, detector cells). It delays and broadens every peak, so it is " + "characterized first, with a system pulse and no column in line.", + "comparison": "A measurement paired with the recipe that should reproduce it, the " + "channel and observation point to compare, and how to align and scale the trace. The " + "workbench lists each comparison as a measurement.", + "experiment type": "The kind of lab run (e.g. system pulse, breakthrough); it fixes the " + "flow path, process template, observation point and which step the run feeds.", + "recipe": "Everything a fresh simulation of one run is built from: the system (hardware, " + "flow rate, column and binding model), the components and the method (process " + "template) of the run's experiment type. It holds no fitted values.", + "flow path": "The units the liquid passes through in a run; units not used in that run " + "are bypassed.", + "observation point": "Where in the flow path the simulated signal is read, matching the " + "detector's position (e.g. column outlet for UV).", + "injection marker": "A run-log entry (e.g. 'Phase Elution') marking when the sample was " + "injected; used to align the measured clock to the simulated one.", + "alignment": "Shifting the measured trace in time so its injection coincides with the " + "simulated injection.", + "baseline window": "A stretch of the run without signal from the probe, used to " + "estimate and subtract the detector baseline.", + "normalization": "Scaling the measured trace, e.g. so its peak area equals the injected " + "amount; removes the unknown detector response factor.", + "prior": "The parameter values a step starts from: everything accepted before it.", + "posterior": "The parameter values a step ends with, once a candidate is accepted.", + "parameter store": "The accepted and known parameter values of the whole chain, each " + "with its provenance.", + "provenance": "Where a stored value came from: the step, the probe, the runs, the " + "metric, or a note if it was assumed.", + "probe": "The substance injected to measure something (tracer, protein).", + "component": "A molecule you inject or elute with (tracer, protein, salt), declared " + "once for the study with its role. Each measurement picks its probe from the declared " + "components, and fitted values are stored under the component's name, so the same " + "name links values across steps.", + "component role": "What a component is in the lab: system tracer, small tracer, large " + "tracer, protein or salt. The role decides which experiment types may use it.", + **COMPONENT_ROLES, + "species transfer": "Explicitly reusing a value measured with one probe for another " + "(e.g. system dispersion measured with acetone, reused for a protein); recorded as an " + "assumption.", + "Pareto candidate": "A fitted parameter set that no other set beats on every " + "measurement at once.", + "best average": "The Pareto candidate with the lowest mean error over all measurements.", + "fitted variable": "A parameter the optimizer searches between a lower and an upper " + "bound; the value of the candidate you accept is written to the parameter store.", + "fixed variable": "A parameter a step could fit but is told to keep at its current " + "value (from an earlier step, the starting parameters or the recipe): its Fit box is " + "unticked. Use it when the measurements cannot separate two parameters.", + "NRMSE": "Normalized root-mean-square error between simulated and measured traces; " + "0 is a perfect match.", +} + +GLOSSARY_ALIASES: Dict[str, str] = {"frozen variable": "fixed variable"} +"""Older glossary terms and the entry they resolve to.""" + +WORKFLOW_GUIDE = """\ +# Characterizing a column, step by step + +1. **System.** Describe the instrument once: which tubing, mixer and sample loop are + installed, the known hardware values (tubing diameters, loop volume) and the flow rate + your runs use; under Advanced configuration, the column and binding model with their + starting values. This is the base every run starts from. The method (which inlets + pump, when the loop injects, which units are bypassed) is not part of the system: it + comes with each measurement's experiment type. +2. **Components.** On the System page, above the hardware, declare the molecules you + inject or elute with and give each a role (system tracer, small tracer, large tracer, + protein, salt). Use the same name for the same molecule in every step: fitted values + are kept per component and reused only under that name. To use a value for another + molecule (e.g. a tracer's dispersion for your protein), copy it on the step page with + "Transfer to this step's species" (a species transfer). +3. **Measurements.** Load each run's export. For every run choose its experiment type + (e.g. "System pulse (no column in line)"), its component and the detector channel; + the experiment type sets the method (process template), the units the run bypasses + and the observation point for you. + Check the injection marker, baseline window and normalization on its measured trace. +4. **Steps, in order.** Work through the chain: Extra-column volume, Column packing, + Particle transport, Binding, Capacity. For each step, add its measurements first, + then set the step up from Setup. A step fits one part of the system from its + measurements; its page lists what it determines and which experiment types feed it. + Steps run in order, each starting from the values accepted before it, so only start a + step once the steps before it are accepted. +5. **Before each fit.** Go through the step's "Before you run" checks; each open item + has a button to fix it, e.g. carrying values measured with one probe over to this + step's probes (species transfer). +6. **Fit and choose.** Run the fit. Each measurement is one objective; from the Pareto + candidates pick one, usually "best average", after checking the per-run charts. +7. **Accept.** Accepting writes the chosen values into the parameter store with their + provenance; the next step starts from there. +8. **Result.** After the last step, the parameter store holds the accepted parameters of + the whole system, ready to simulate new processes. +""" + + +def new_step_setup(guide: ChainStepGuide, comparisons: Sequence[Comparison]) -> StepSetup: + """Return a `StepSetup` named `guide.title` for `guide` fitting `comparisons`. + + Raises if a comparison's experiment type feeds a different step. + """ + wrong = [ + c.name for c in comparisons + if c.experiment_type is not None and c.experiment_type not in guide.experiment_types + ] + if wrong: + raise ValueError( + f"Measurements {wrong} are experiment types that do not feed {guide.title!r}." + ) + return StepSetup( + name=guide.title, + stage=guide.stage, + options=dict(guide.options), + comparisons=list(comparisons), + requires=tuple(guide.requires), + ) + + +def find_injection_marker( + run: ExperimentalRun, keywords: Sequence[str] = ("inject", "sample", "elution") +) -> Optional[str]: + """Return the first run-log marker containing a keyword, trying keywords in order.""" + for keyword in keywords: + for _, text in run.markers: + if keyword in text.lower(): + return text + return None + + +def measurement_from_run( + name: str, + run: ExperimentalRun, + channel: str, + experiment_type: ExperimentType, + base_recipe: ConfigurationState, + *, + component: str, + flow_rate: Optional[float] = None, + injection_marker: Optional[str] = None, +) -> Comparison: + """Build a comparison from lab choices: run, channel, experiment type and probe name. + + Without `injection_marker`, the first marker matching the experiment type's keywords is + used. Area normalization targets the injected amount (sample concentration times loop + volume). + """ + recipe = experiment_type.apply(base_recipe, component=component) + if flow_rate is None and "flow_rate" not in recipe.model_values: + flow_rate = float( + recipe.model_values.get("flow_rate_wash", PARAMS["flow_rate_wash"].default) + ) + if injection_marker is None: + injection_marker = find_injection_marker(run, experiment_type.marker_keywords) + + normalization: Dict[str, Any] = {"kind": "none"} + if experiment_type.normalization == "area": + c_sample = recipe.model_values["c_sample"][recipe.components.index(component)] + normalization = { + "kind": "area", + "target_area": float(c_sample) * float(recipe.instrument.sample_loop_volume), + } + + return Comparison( + name=name, + data_file=run.label, + channel=channel, + recipe=recipe, + solution_path=experiment_type.solution_path, + flow_rate=flow_rate, + injection_marker=injection_marker, + normalization=normalization, + components=[component], + probe=component, + experiment_type=experiment_type.id, + run=run, + ) + + +def _resolved_options(stage: str, options: Mapping[str, Any]) -> Dict[str, Any]: + params = STAGES[stage].options + return { + name: options.get(name, param.default) + for name, param in params.items() + if name in options or param.default is not inspect.Parameter.empty + } + + +def guide_for_step(step: StepSetup) -> Optional[ChainStepGuide]: + """Return the chain guide `step` follows. + + Matched by name (the guide's id or title), else by the same stage and options. + """ + if step.name in CHAIN_BY_ID: + return CHAIN_BY_ID[step.name] + by_title = next((g for g in DEFAULT_CHAIN if g.title == step.name), None) + if by_title is not None: + return by_title + options = _resolved_options(step.stage, step.options) + for guide in DEFAULT_CHAIN: + if guide.stage == step.stage and _resolved_options(guide.stage, guide.options) == options: + return guide + return None + + +def _step_title(step: StepSetup) -> str: + guide = guide_for_step(step) + return guide.title if guide is not None else step.name + + +def parameter_label(path: str) -> str: + """Return a lab label for a store path, e.g. "film diffusion (Column)".""" + parts = path.split(".") + name = parts[-1].replace("_", " ") + if len(parts) > 2 and parts[0] == "flow_sheet": + return f"{name} ({UNIT_LABELS.get(parts[1], parts[1])})" + return name + + +def _source_text(study: Any, step: Optional[str]) -> str: + if not step or step == "initial": + return "the starting values hold it" + if step.startswith("assumed: "): + experiment = EXPERIMENT_TYPES.get(step.partition(": ")[2]) + return f"{experiment.label if experiment else step} assumes it" + setup = next((s for s in study.steps if s.name == step), None) + if setup is not None: + return f"{_step_title(setup)} fitted it" + return f"step {step} set it" + + +def species_gap_messages( + study: Any, step: StepSetup, gaps: Mapping[str, Sequence[str]], store: ParameterStore +) -> List[str]: + """Return one line per gap naming the components that lack a value and who set it. + + E.g. "Binding needs film diffusion (Column) for IgG; Particle transport fitted it for + BSA", without a closing period. + """ + title = _step_title(step) + lines = [] + for path, names in gaps.items(): + entry = store.entries[path] + lines.append( + f"{title} needs {parameter_label(path)} for {', '.join(names)}; " + f"{_source_text(study, entry.provenance.step)} for " + f"{', '.join(sorted(entry.value)) or 'no component'}" + ) + return lines + + +def guide_measurements(study: Any, guide: ChainStepGuide) -> List[Comparison]: + """Return the study's measurements whose experiment type feeds `guide`.""" + return [c for c in study.comparisons if c.experiment_type in guide.experiment_types] + + +def _a(role: str) -> str: + return f"{'an' if role[:1] in 'aeiou' else 'a'} {role}" + + +def role_fix( + study: Any, name: str, experiment_type: ExperimentType +) -> Tuple[str, Optional[str]]: + """Return why component `name` does not fit `experiment_type` and the role to give it. + + The role is `experiment_type`'s first probe role, offered only when every other + measurement using `name` as its probe accepts it too; otherwise the role is None and + the text says which measurements need the current one. Returns `("", None)` when + `name` fits or is not declared. + """ + component = study.component(name) + roles = experiment_type.probe_roles + if component is None or not roles or component.role in roles: + return "", None + target = roles[0] + text = ( + f"{name} is declared as {_a(component.role)}. This experiment needs {_a(target)}." + ) + if component.role == SALT_ROLE: + return text + " The salt keeps its role; pick another component.", None + blockers = [] + for comparison in study.comparisons: + used_as = EXPERIMENT_TYPES.get(comparison.experiment_type or "") + if comparison.probe == name and used_as is not None and target not in used_as.probe_roles: + blockers.append(f"{comparison.name} ({used_as.label})") + if not blockers: + return text, target + return text + ( + f" Its role cannot change: {', '.join(blockers)} use{'s' * (len(blockers) == 1)} it " + f"as {_a(component.role)} and need{'s' * (len(blockers) == 1)} that role. Pick " + f"another component, or name a new one (e.g. \"{name} {target}\")." + ), None + + +_OPTION_LABELS: Dict[str, str] = { + "tubing": "Tubing segment", + "component_index": "Component", + "is_kinetic": "Kinetic binding", + "include_particle_porosity": "Fit particle porosity", + "include_axial_dispersion": "Fit column axial dispersion", + "include_film_diffusion": "Fit film diffusion", + "include_pore_diffusion": "Fit pore diffusion", + "include_steric_factor": "Fit steric factor", +} + +_OPTION_HELP: Dict[str, str] = { + "tubing": "The tubing whose length and axial dispersion are fitted. One segment stands " + "in for the whole extra-column volume: every dead volume between sample loop and " + "detector is lumped into it, because a pulse cannot tell apart where along the flow " + "path it was delayed and spread. Its fitted length is therefore not a physical length.", + "component_index": "Which component's parameters are fitted. In steric mass action the " + "salt is component 0, so the protein is usually component 1.", + "is_kinetic": "Fit adsorption and desorption rates separately instead of only the " + "equilibrium constant. Needs runs whose peak shape depends on binding kinetics.", + "include_particle_porosity": "Also fit the pore volume fraction inside the beads. Needs " + "a small tracer that enters the pores; a pore-excluded tracer alone cannot see it.", + "include_axial_dispersion": "Also fit the band broadening along the column for this " + "component; otherwise it keeps the value from an earlier step.", + "include_film_diffusion": "Fit the film diffusion: how fast the component moves from " + "the liquid between the beads into the pores.", + "include_pore_diffusion": "Fit the pore diffusion: transport inside the pores. Only " + "column models with pore diffusion (e.g. the general rate model) have it.", + "include_steric_factor": "Also fit the steric shielding factor, which matters at high " + "loading (e.g. breakthrough runs).", +} + + +STEP_TYPE_HELP: Dict[str, str] = { + "tubing": "Delay and band broadening of one tubing segment (its length and axial " + "dispersion), from pulses without a column.", + "pre_injection": "Dead volume before the injection point, as the length of the " + "pre-injection tubing and the volume of the mixer.", + "bed": "How the column is packed: bed porosity, axial dispersion and optionally " + "particle porosity, from non-binding tracer pulses through the column.", + "particles": "How one component moves between and into the beads (film, pore or " + "axial dispersion, as ticked), from non-binding pulses of that component.", + "capacity": "The resin's ionic capacity, from breakthrough curves.", + "adsorption": "Steric mass action binding of one component (characteristic charge and " + "equilibrium constant), from gradient elutions of different slopes.", +} +"""One line per step type (`stages.STAGES` id) saying what it fits.""" + + +def step_type_help(stage: str) -> str: + """Return one line saying what a step of type `stage` fits.""" + return STEP_TYPE_HELP.get(stage, "") + + +def option_label(name: str) -> str: + """Return a readable label for step-type option `name`.""" + return _OPTION_LABELS.get(name, name.replace("_", " ").capitalize()) + + +def option_help(stage: str, name: str) -> str: + """Return a one-line explanation of option `name` of step type `stage`.""" + if name in _OPTION_HELP: + return _OPTION_HELP[name] + label = STAGES[stage].label if stage in STAGES else stage + return f"Setting {name!r} of the {label} fit, passed on to CADET-Process." + + +_UNIT_NOUNS: Dict[str, str] = { + "tubing_pre_column": "pre-column tubing", + "tubing_pre_injection": "pre-injection tubing", + "tubing_post_column": "post-column tubing", + "tubing_detectors": "tubing between the detectors", +} + +_STAGE_SUMMARIES: Dict[str, str] = { + "pre_injection": "Fits the dead volume before the injection point, represented as the " + "length of the pre-injection tubing and the volume of the mixer.", + "bed": "Fits how the column is packed: {parameters} of the column, from tracer pulses " + "through the column.", + "particles": "Fits how {component} moves between and into the beads: {parameters}.", + "adsorption": "Fits the steric mass action binding of {component}: {parameters}.", + "capacity": "Fits the resin's ionic capacity from breakthrough curves.", +} + + +def _joined(items: Sequence[str]) -> str: + items = list(items) + return items[0] if len(items) == 1 else ", ".join(items[:-1]) + " and " + items[-1] + + +def _component_name(setup: StepSetup, index: Any) -> str: + components = setup.comparisons[0].recipe.components if setup.comparisons else [] + index = int(index or 0) + return components[index] if index < len(components) else f"component {index}" + + +def step_summary(setup: StepSetup) -> str: + """Return one plain sentence saying what `setup` fits and how the model represents it.""" + options = setup.options + if setup.stage == "tubing": + unit = options.get("tubing") + noun = _UNIT_NOUNS.get(unit, UNIT_LABELS.get(unit, str(unit)).lower()) + if unit == "tubing_pre_column": + return ( + "Fits the extra-column dead volume and its band broadening, represented as " + f"the length and axial dispersion of the {noun}." + ) + if not unit: + return "Fits the length and axial dispersion of one tubing segment; pick it below." + return ( + f"Fits the dead volume and band broadening of the {noun}, as its length and " + "axial dispersion." + ) + try: + variables = describe_stage(setup.stage, **options) + except (KeyError, ValueError) as exc: + return f"Cannot describe this fit yet: {exc}" + names = [] + for var in variables: + if var.parameter_path is None: + continue + name = var.parameter_path.rsplit(".", 1)[-1].replace("_", " ") + if var.name == "adsorption_rate" and not options.get("is_kinetic"): + name = "equilibrium constant (adsorption rate at a desorption rate of 1)" + if name not in names: + names.append(name) + template = _STAGE_SUMMARIES.get(setup.stage, "Fits {parameters}.") + return template.format( + parameters=_joined(names) if names else "nothing", + component=_component_name(setup, options.get("component_index")), + ) + + +@dataclass(frozen=True) +class StartingValue: + """The value a variable starts from: formatted `text`, LaTeX `unit` and its `source`.""" + + text: str + unit: str + source: str + + +FROM_PROCESS = "from the process setup" +STARTING_VALUE = "starting value" +ASSUMED_BY_TYPE = "assumed by experiment type" + + +def _value_text(value: Any) -> str: + if isinstance(value, dict): + texts = {name: _value_text(v) for name, v in value.items()} + if len(set(texts.values())) == 1: + return next(iter(texts.values())) + return "; ".join(f"{name}: {text}" for name, text in texts.items()) + if isinstance(value, (list, tuple)): + return "[" + ", ".join(_value_text(v) for v in value) + "]" + if hasattr(value, "tolist"): + return _value_text(value.tolist()) + if isinstance(value, float): + return f"{value:.4g}" + return str(value) + + +def _owner(process: Any, path: str) -> Any: + obj = process + for part in path.rpartition(".")[0].split("."): + if hasattr(obj, "__getitem__") and not hasattr(obj, part): + obj = obj[part] + else: + obj = getattr(obj, part) + return obj + + +def parameter_unit(process: Any, path: str) -> str: + """Return the LaTeX unit CADET-Process declares for `path` on `process`, or "".""" + owner = _owner(process, path) + attribute = path.rpartition(".")[2] + category = "binding" if ".binding_model." in f".{path}" else "column" + try: + return get_parameters(category, type(owner).__name__)[attribute].get("unit") or "" + except KeyError: + descriptor = getattr(type(owner), attribute, None) + if isinstance(descriptor, property): + descriptor = getattr(type(owner), f"_{attribute}", None) + return getattr(descriptor, "unit", None) or "" + + +def _provenance_source(study: Any, step: Optional[str]) -> str: + if not step or step == "initial": + return STARTING_VALUE + if step.startswith("assumed: "): + return ASSUMED_BY_TYPE + setup = next((s for s in study.steps if s.name == step), None) + return f"from {_step_title(setup) if setup is not None else step}" + + +def _species_subset(value: Any, species: Sequence[str]) -> Any: + if not isinstance(value, dict) or not species: + return value + picked = {name: value[name] for name in species if name in value} + return picked or None + + +def starting_values( + study: Any, setup: StepSetup, variables: Optional[Sequence[VariableDef]] = None +) -> Dict[str, StartingValue]: + """Return, per variable of `setup` with a parameter path, the value its fit starts from. + + The value comes from the step's prior (`study.prior_for`, or the current store for a + step not in the study), restricted to the measurements' components, labelled by its + provenance ("from ", `STARTING_VALUE`, `ASSUMED_BY_TYPE`). A path the + prior lacks is read off the first measurement's built process (`FROM_PROCESS`). + """ + if variables is None: + variables = describe_stage(setup.stage, **setup.options) + names = [s.name for s in study.steps] + prior = study.prior_for(setup.name) if setup.name in names else study.current_store + probes = list(dict.fromkeys(c.probe for c in setup.comparisons if c.probe)) + processes = [] + for comparison in setup.comparisons: + check = check_recipe(comparison.recipe, comparison.overrides) + if check.error is None: + processes.append(check.process) + + values: Dict[str, StartingValue] = {} + for var in variables: + path = var.parameter_path + if path is None: + continue + process = next((p for p in processes if has_parameter(p, path)), None) + unit = parameter_unit(process, path) if process is not None else "" + value, source = None, "" + entry = prior.entries.get(path) + if entry is not None: + spec = prior.specs.get(path) + value = entry.value + if spec is not None and spec.species_indexed: + value = _species_subset(value, probes) + source = _provenance_source(study, entry.provenance.step) + if value is None and process is not None: + try: + reader = ParameterStore(specs={path: spec_for(process, path)}) + value = read_process(process, [path], reader)[path] + except KeyError: + value = None + else: + picked = _species_subset(value, probes) + value = value if picked is None else picked + source = FROM_PROCESS + if value is None: + values[var.name] = StartingValue("—", unit, "not set") + else: + values[var.name] = StartingValue(_value_text(value), unit, source) + return values diff --git a/cadetgui/characterization/parameter_store.py b/cadetgui/characterization/parameter_store.py new file mode 100644 index 0000000..4d3f1f5 --- /dev/null +++ b/cadetgui/characterization/parameter_store.py @@ -0,0 +1,487 @@ +"""Species-aware parameter store for chained characterization steps. + +A characterization run maps a prior parameter set and measured data to a +posterior parameter set; the posterior is written once and never edited, so +the store is a value passed along a chain rather than a file steps mutate. +Two rules follow from that: whether a parameter is species-indexed is +declared (via `ParameterSpec`), never inferred from a value's shape, and +species are addressed by name in storage, translated to CADET's positional +indices only when a value is applied to or read from a process. +""" +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field, replace +from pathlib import Path +from typing import Any, Iterable, Mapping, Optional, Sequence + +from CADETProcess.dataStructure import Sized +from CADETProcess.processModel import ComponentSystem + +__all__ = [ + "ParameterSpec", + "Provenance", + "Entry", + "ParameterStore", + "species_index", + "spec_for", + "apply_store", + "has_parameter", + "read_process", + "Step", + "ChainError", + "check_chain", + "missing_requirements", + "transfer_species", +] + + +def species_index(name: str, component_system: ComponentSystem) -> int: + """Return the position of species `name` in CADET parameter arrays. + + CADET indexes parameter arrays by species, not by component; resolving + against component names instead only works while every component carries + exactly one species. + + Raises + ------ + KeyError + If `name` is not part of `component_system`. + """ + indices = component_system.species_indices + if name not in indices: + raise KeyError(f"Species '{name}' is not in the component system {list(indices)}.") + return indices[name] + + +@dataclass(frozen=True) +class ParameterSpec: + """Declaration of how one model parameter is stored. + + `path` is a CADET-Process parameter path (e.g. + `"flow_sheet.column.bed_porosity"`), the same string + `OptimizationProblem.add_variable` takes. `species_indexed` says whether + CADET stores one value per species for this parameter. + """ + + path: str + species_indexed: bool = False + + +@dataclass(frozen=True) +class Provenance: + """Record of how a stored value came to be. + + `probe` names the species used to probe the parameter (relevant when a + parameter is apparent and probe-dependent), `source` identifies the + measured data a fit used, `model` a model variant, `metric` the achieved + objective values, and `note` is free text for assumed (not fitted) + values. + """ + + step: str + probe: Optional[str] = None + source: Optional[str] = None + model: Optional[str] = None + metric: Optional[dict] = None + note: Optional[str] = None + + +@dataclass(frozen=True) +class Entry: + """A stored value together with its provenance. + + `value` is a scalar, or one value per species keyed by species name; + which applies is fixed by the parameter's `ParameterSpec`, not inferred + from this field. + """ + + value: Any + provenance: Provenance + + +@dataclass(frozen=True) +class ParameterStore: + """Immutable snapshot of model parameters at one point in a chain. + + `updated` returns a new store rather than modifying this one, so a + step's input stays readable after it has run. + """ + + entries: Mapping[str, Entry] = field(default_factory=dict) + specs: Mapping[str, ParameterSpec] = field(default_factory=dict) + step: Optional[str] = None + prior: Optional[str] = None + + def spec(self, path: str) -> ParameterSpec: + """Return the declaration for `path`, raising if it was never declared.""" + if path not in self.specs: + raise KeyError( + f"Parameter '{path}' is not declared. Add a ParameterSpec instead of " + "relying on the value's shape." + ) + return self.specs[path] + + def __contains__(self, path: str) -> bool: + """Return whether a value is stored for `path`.""" + return path in self.entries + + def value(self, path: str, species: Optional[str] = None) -> Any: + """Return the stored value for `path`; `species` is required iff species-indexed.""" + spec = self.spec(path) + entry = self.entries[path] + + if not spec.species_indexed: + if species is not None: + raise ValueError(f"'{path}' is not species-indexed.") + return entry.value + + if species is None: + return dict(entry.value) + return entry.value[species] + + def to_cadet(self, path: str, component_system: ComponentSystem) -> Any: + """Return the value for `path` in the layout CADET expects. + + Species-indexed parameters become a list ordered by species index; + scalars are returned unchanged. A species of `component_system` + without a stored value is an error rather than something to pad with + a default. + """ + spec = self.spec(path) + entry = self.entries[path] + + if not spec.species_indexed: + return entry.value + + missing = [s for s in component_system.species if s not in entry.value] + if missing: + raise KeyError(f"'{path}' has no value for species {missing}.") + return [ + entry.value[name] + for name in sorted( + component_system.species, + key=lambda n: species_index(n, component_system), + ) + ] + + def from_cadet(self, path: str, value: Any, component_system: ComponentSystem) -> Any: + """Return `value`, as read off a CADET object at `path`, in the store's own layout. + + Inverse of `to_cadet`: turns a species-index-ordered list back into a + dict keyed by species name. Scalars pass through unchanged. + """ + spec = self.spec(path) + if not spec.species_indexed: + return value + return { + name: value[species_index(name, component_system)] + for name in component_system.species + } + + def updated( + self, + values: Mapping[str, Any], + provenance: Provenance, + specs: Optional[Mapping[str, ParameterSpec]] = None, + ) -> "ParameterStore": + """Return a new store with `values` written on top of this one. + + Species-indexed parameters take a dict keyed by species name, merged + into any existing entry so that a step fitting one species leaves + the others intact. `specs` declares `ParameterSpec`s for paths this + store has not seen before, so a caller can add paths lazily instead + of pre-declaring every path upfront. + """ + all_specs = dict(self.specs) + if specs: + all_specs.update(specs) + + entries = dict(self.entries) + for path, value in values.items(): + if path not in all_specs: + raise KeyError( + f"Parameter '{path}' is not declared. Pass its ParameterSpec via `specs`." + ) + spec = all_specs[path] + if spec.species_indexed: + if not isinstance(value, Mapping): + raise TypeError( + f"'{path}' is species-indexed and needs a mapping keyed by " + f"species name, got {type(value).__name__}." + ) + merged = dict(entries[path].value) if path in entries else {} + merged.update(value) + value = merged + elif isinstance(value, Mapping): + raise TypeError( + f"'{path}' is not species-indexed but was given a mapping. Probe " + "dependence is recorded in provenance, not in the value." + ) + entries[path] = Entry(value=value, provenance=provenance) + + return replace( + self, entries=entries, specs=all_specs, step=provenance.step, prior=self.step + ) + + def to_dict(self) -> dict[str, Any]: + """Return a JSON-serialisable representation of this store.""" + return { + "step": self.step, + "prior": self.prior, + "specs": { + path: {"species_indexed": spec.species_indexed} + for path, spec in sorted(self.specs.items()) + }, + "entries": { + path: { + "value": entry.value, + "provenance": { + k: v for k, v in asdict(entry.provenance).items() if v is not None + }, + } + for path, entry in sorted(self.entries.items()) + }, + } + + @classmethod + def from_dict( + cls, raw: Mapping[str, Any], specs: Optional[Mapping[str, ParameterSpec]] = None + ) -> "ParameterStore": + """Rebuild a store from `to_dict`'s output. + + Without `specs`, the declarations saved in `raw` are used. With `specs`, a + saved declaration that disagrees with the given one raises, so reading an old + snapshot against today's declarations surfaces a parameter whose meaning + changed. + """ + saved = { + path: ParameterSpec(path, bool(item.get("species_indexed", False))) + for path, item in (raw.get("specs") or {}).items() + } + if specs is None: + specs = saved + else: + changed = [p for p, s in saved.items() if p in specs and specs[p] != s] + if changed: + raise ValueError(f"Saved declarations disagree with the given specs: {changed}.") + specs = {**saved, **specs} + entries = { + key: Entry(value=item["value"], provenance=Provenance(**item["provenance"])) + for key, item in raw["entries"].items() + } + return cls(entries=entries, specs=specs, step=raw.get("step"), prior=raw.get("prior")) + + def save(self, path: "Path | str") -> Path: + """Write this store to `path` as JSON and return the path.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as handle: + json.dump(self.to_dict(), handle, indent=4) + handle.write("\n") + return path + + @classmethod + def load( + cls, path: "Path | str", specs: Optional[Mapping[str, ParameterSpec]] = None + ) -> "ParameterStore": + """Read a store written by `save`.""" + with open(path) as handle: + raw = json.load(handle) + return cls.from_dict(raw, specs) + + +def has_parameter(process: Any, path: str) -> bool: + """Return whether `process` carries the parameter at `path` (bypassed units don't).""" + target, _, attribute = path.rpartition(".") + obj = _resolve(process, target) + return obj is not None and hasattr(obj, attribute) + + +def _resolve(root: Any, path: str) -> Any: + """Walk a dotted path from `root`; return `None` if any step is absent.""" + obj = root + for part in path.split("."): + if not part: + continue + try: + obj = obj[part] if _is_indexable(obj, part) else getattr(obj, part) + except (AttributeError, KeyError): + return None + return obj + + +def _is_indexable(obj: Any, key: str) -> bool: + """Return whether `key` should be looked up by item rather than by attribute.""" + return hasattr(obj, "__getitem__") and not hasattr(obj, key) + + +def _descriptor(cls: type, name: str) -> Any | None: + """Return the CADET-Process parameter descriptor backing `name` on `cls`, or `None`. + + Some parameters are properties over a differently named private + descriptor (e.g. binding-model `q`, `cp`); fall back to `_name`. + """ + desc = getattr(cls, name, None) + if isinstance(desc, property): + desc = getattr(cls, f"_{name}", None) + return desc if hasattr(desc, "default") else None + + +def spec_for(process: Any, path: str) -> ParameterSpec: + """Infer a `ParameterSpec` for `path` from the CADET-Process descriptor it targets. + + `species_indexed` is read off the descriptor's declared size (a `Sized` + descriptor with `size == ("n_comp",)`), never off a runtime value. + + Raises + ------ + KeyError + If `path` does not resolve to a described parameter on `process`. + """ + target, _, attribute = path.rpartition(".") + obj = _resolve(process, target) + if obj is None or not hasattr(obj, attribute): + raise KeyError(f"'{path}' is not a parameter on {process!r}.") + + descriptor = _descriptor(type(obj), attribute) + if descriptor is None: + raise KeyError(f"'{path}' has no CADET-Process descriptor to read.") + + species_indexed = isinstance(descriptor, Sized) and descriptor.size == ("n_comp",) + return ParameterSpec(path=path, species_indexed=species_indexed) + + +def apply_store(process: Any, store: ParameterStore) -> list[str]: + """Set every parameter of `store` that `process` actually has; return the applied paths. + + A path the process doesn't carry is skipped rather than raising: a + characterization run bypasses parts of the system, so a store holding + the whole model is routinely applied to a process that only contains + part of it. + """ + component_system = process.component_system + applied = [] + + for path in store.entries: + target, _, attribute = path.rpartition(".") + obj = _resolve(process, target) + if obj is None or not hasattr(obj, attribute): + continue + setattr(obj, attribute, store.to_cadet(path, component_system)) + applied.append(path) + + return applied + + +def read_process(process: Any, paths: Iterable[str], store: ParameterStore) -> dict[str, Any]: + """Read `paths` off `process`, translated back into the store's own layout. + + Raises + ------ + KeyError + If `process` does not carry one of `paths`. + """ + component_system = process.component_system + values = {} + for path in paths: + target, _, attribute = path.rpartition(".") + obj = _resolve(process, target) + if obj is None or not hasattr(obj, attribute): + raise KeyError(f"'{path}' is not a parameter on {process!r}.") + values[path] = store.from_cadet(path, getattr(obj, attribute), component_system) + return values + + +class ChainError(Exception): + """Raised when a chain of steps is inconsistent, before anything runs.""" + + +@dataclass(frozen=True) +class Step: + """One characterization step: what it needs already fixed, what it determines.""" + + name: str + requires: Sequence[str] = () + provides: Sequence[str] = () + + +def check_chain(steps: Sequence[Step], initial: ParameterStore) -> None: + """Validate that every step's `requires` are met by the time it runs. + + Raises + ------ + ChainError + If a step requires a parameter that neither `initial` nor an earlier + step provides, if a path is undeclared in `initial`, or if two steps + share a name. + """ + seen = set(initial.entries) + names: set[str] = set() + + for position, step in enumerate(steps, start=1): + if step.name in names: + raise ChainError(f"Duplicate step name '{step.name}'.") + names.add(step.name) + + for path in (*step.requires, *step.provides): + try: + initial.spec(path) + except KeyError as error: + raise ChainError(f"Step {position} ('{step.name}'): {error}") from error + + missing = [path for path in step.requires if path not in seen] + if missing: + raise ChainError( + f"Step {position} ('{step.name}') requires {missing}, which no earlier " + "step provides." + ) + seen.update(step.provides) + + +def missing_requirements(step: Step, store: ParameterStore) -> list[str]: + """Return `step.requires` entries absent from `store`, without raising.""" + return [path for path in step.requires if path not in store] + + +def transfer_species( + store: ParameterStore, paths: Iterable[str], species: Sequence[str], note: Optional[str] = None +) -> ParameterStore: + """Copy species-indexed values probed with one species onto `species`. + + A value determined with one probe (a tubing dispersion fitted on one tracer) says + nothing about another species by itself; carrying it over is a modelling assumption, + so it is an explicit step that keeps the original provenance and appends a note + naming the transfer. + + Raises + ------ + KeyError + If a path is not stored, not species-indexed, or was probed with more than one + species, which leaves no single value to transfer. + """ + for path in paths: + if path not in store: + raise KeyError(f"'{path}' has no stored value to transfer.") + if not store.spec(path).species_indexed: + raise KeyError(f"'{path}' is not species-indexed.") + entry = store.entries[path] + probed = sorted(entry.value) + if len(probed) != 1: + raise KeyError( + f"'{path}' was probed with {probed}; transferring needs exactly one probe species." + ) + (probe,) = probed + targets = [name for name in species if name not in entry.value] + if not targets: + continue + text = f"probed with {probe}, transferred unchanged to {', '.join(targets)}" + if note: + text = f"{text}: {note}" + if entry.provenance.note: + text = f"{entry.provenance.note}; {text}" + store = store.updated( + {path: {name: entry.value[probe] for name in targets}}, + replace(entry.provenance, note=text), + ) + return store diff --git a/cadetgui/characterization/runner.py b/cadetgui/characterization/runner.py new file mode 100644 index 0000000..8e18943 --- /dev/null +++ b/cadetgui/characterization/runner.py @@ -0,0 +1,430 @@ +"""Run one characterization step over N comparisons and choose a posterior explicitly. + +A step fits one `CADETProcess.characterization` stage with one objective per +comparison. `run` returns the whole final front; nothing is written to a +`ParameterStore` until `posterior` is called with a chosen `Candidate`. +Headless (no ipywidgets). +""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass, field, replace +from typing import Any, Callable, Mapping, Optional, Sequence + +import numpy as np +from CADETProcess.optimization import OptimizationProblem + +from ..cadetprocessadapter import COLUMN_MODELS +from ..optimizer_runner import OPTIMIZERS, pareto_front, run_optimization +from ..process_builder import check_recipe +from ..simulation import Simulator +from .comparison import Comparison +from .parameter_store import ( + ParameterStore, + Provenance, + Step, + read_process, + spec_for, +) +from .parameter_store import ( + missing_requirements as _missing_requirements, +) +from .stages import ( + VariableDef, + build_problem, + describe_stage, + write_targets, +) + +__all__ = [ + "StepSetup", + "default_optimizer", + "default_optimizer_knobs", + "fitted_variables", + "estimated_simulations", + "BuiltStep", + "StepResult", + "Candidate", + "build", + "species_gaps", + "run", + "pareto_candidates", + "current_best_average", + "posterior", + "missing_requirements", +] + +AVERAGE_TAG = "best average" +DEFAULT_OPTIMIZER = "U-NSGA-III" + + +def default_optimizer_knobs(optimizer_name: str, n_variables: int) -> dict[str, int]: + """Return knob values sized for a step fitting `n_variables` variables. + + U-NSGA-III: `pop_size = max(16, 8 * n_variables)`, `n_max_gen = 12`. Nelder-Mead: + `maxiter = 200 * n_variables`, bounded to 200-1000 since every iteration simulates + each measurement. Optimizers without a rule get their `OPTIMIZERS` knob defaults. + """ + n = max(int(n_variables), 1) + if optimizer_name == "U-NSGA-III": + return {"pop_size": max(16, 8 * n), "n_max_gen": 12} + if optimizer_name == "Nelder-Mead": + return {"maxiter": min(max(200 * n, 200), 1000)} + return {knob.attr: knob.default for knob in OPTIMIZERS[optimizer_name].knobs} + + +def default_optimizer( + name: str = DEFAULT_OPTIMIZER, n_variables: Optional[int] = None, **knobs: int +) -> dict: + """Return `{"name": name, "knobs": {...}}`, `knobs` overriding the defaults. + + With `n_variables`, defaults come from `default_optimizer_knobs`; without, from the + `OPTIMIZERS` knob defaults, where 0 lets CADET-Process size population and generations. + """ + spec = OPTIMIZERS[name] + defaults = {knob.attr: knob.default for knob in spec.knobs} + unknown = set(knobs) - set(defaults) + if unknown: + raise ValueError(f"Unknown {name} setting(s) {sorted(unknown)}.") + if n_variables is not None: + defaults.update(default_optimizer_knobs(name, n_variables)) + return {"name": name, "knobs": {**defaults, **{k: int(v) for k, v in knobs.items()}}} + + +def fitted_variables(variables: Sequence[VariableDef], frozen: Sequence[str] = ()) -> list[str]: + """Return the names of the variables the optimizer searches: bounded and not frozen.""" + return [v.name for v in variables if v.lb is not None and v.name not in frozen] + + +def estimated_simulations(optimizer: Mapping[str, Any], n_comparisons: int) -> Optional[int]: + """Return the approximate number of simulations a run costs. + + None when CADET-Process sizes the run (a U-NSGA-III knob of 0). U-NSGA-III: + population x generations x measurements. Nelder-Mead: `maxiter` x measurements, + approximate since scipy's evaluations per iteration vary. + """ + knobs = optimizer["knobs"] + if optimizer["name"] == "U-NSGA-III": + if not knobs["pop_size"] or not knobs["n_max_gen"]: + return None + return knobs["pop_size"] * knobs["n_max_gen"] * n_comparisons + if optimizer["name"] == "Nelder-Mead": + return knobs["maxiter"] * n_comparisons + return None + + +@dataclass +class StepSetup: + """One step: a stage, its options, the comparisons it fits jointly, bounds, frozen names. + + `optimizer` is `{"name": , "knobs": {attr: int}}` (see `default_optimizer`); + left as None, it becomes the default optimizer sized for the fitted variables + (the smallest size while the options are invalid). `excluded` names the measurements + of the step's experiment types left out of the fit; None means not recorded. + """ + + name: str + stage: str + options: dict = field(default_factory=dict) + comparisons: list[Comparison] = field(default_factory=list) + bounds: dict = field(default_factory=dict) + frozen: Sequence[str] = () + requires: Sequence[str] = () + optimizer: Optional[dict] = None + excluded: Optional[Sequence[str]] = None + + def __post_init__(self) -> None: + """Size the default optimizer when none is given.""" + if self.optimizer is None: + self.optimizer = default_optimizer(n_variables=self.n_fitted_variables() or 0) + + @classmethod + def from_dict( + cls, raw: Mapping[str, Any], comparisons: Mapping[str, Comparison] + ) -> "StepSetup": + """Build from a manifest step entry; `comparisons` resolves its comparison names.""" + return cls( + name=raw["name"], + stage=raw["stage"], + options=dict(raw.get("options") or {}), + comparisons=[comparisons[name] for name in raw["comparisons"]], + bounds={k: tuple(v) for k, v in (raw.get("bounds") or {}).items()}, + frozen=tuple(raw.get("frozen") or ()), + requires=tuple(raw.get("requires") or ()), + optimizer=_optimizer_from_dict(raw.get("optimizer")), + excluded=tuple(raw["excluded"]) if "excluded" in raw else None, + ) + + def to_dict(self) -> dict[str, Any]: + """Return the manifest step entry; comparisons are referenced by name.""" + raw = { + "name": self.name, + "stage": self.stage, + "options": dict(self.options), + "comparisons": [c.name for c in self.comparisons], + "bounds": {k: list(v) for k, v in self.bounds.items()}, + "frozen": list(self.frozen), + "requires": list(self.requires), + "optimizer": {"name": self.optimizer["name"], "knobs": dict(self.optimizer["knobs"])}, + } + if self.excluded is not None: + raw["excluded"] = list(self.excluded) + return raw + + @property + def provides(self) -> list[str]: + """Parameter paths this step writes: the stage's write targets minus frozen ones.""" + targets = write_targets(describe_stage(self.stage, **self.options)) + return [path for name, path in targets.items() if name not in self.frozen] + + @property + def step(self) -> Step: + """The setup as a `parameter_store.Step`, for chain checks.""" + return Step(name=self.name, requires=tuple(self.requires), provides=tuple(self.provides)) + + def n_fitted_variables(self) -> Optional[int]: + """Return how many variables the optimizer searches; None while the options are invalid.""" + try: + variables = describe_stage(self.stage, **self.options) + except ValueError: + return None + return len(fitted_variables(variables, self.frozen)) + + @property + def objective_names(self) -> list[str]: + """One objective per comparison, in order.""" + return [c.name for c in self.comparisons] + + +def _optimizer_from_dict(raw: Optional[Mapping[str, Any]]) -> Optional[dict]: + if not raw: + return None + return default_optimizer(raw["name"], **dict(raw.get("knobs") or {})) + + +@dataclass(frozen=True) +class BuiltStep: + """The characterization problem and the processes it writes into (one per comparison).""" + + problem: OptimizationProblem + processes: list + + +@dataclass(frozen=True) +class StepResult: + """The final front of one step run. + + `x` rows are parallel to `variable_names` (the problem's independent variables), + `f` rows to `objective_names`. After a cancel, `x`/`f` hold the last full front. + """ + + step: str + variable_names: list[str] + objective_names: list[str] + x: np.ndarray + f: np.ndarray + success: bool + message: str + cancelled: bool + optimizer_name: str + built: BuiltStep + + +@dataclass(frozen=True) +class Candidate: + """One front member offered for posterior selection, with the criteria it wins.""" + + index: int + x: tuple[float, ...] + f: tuple[float, ...] + tags: tuple[str, ...] + + +def missing_requirements(setup: StepSetup, store: ParameterStore) -> list[str]: + """Return `setup.requires` paths `store` does not hold, without raising.""" + return _missing_requirements(setup.step, store) + + +def species_gaps(setup: StepSetup, store: ParameterStore) -> dict[str, list[str]]: + """Return `{path: missing species}` for stored species-indexed values the step can't apply. + + A path is listed when a comparison's process carries it but the store holds no value + for some of that process's species, e.g. a periphery dispersion probed with another + tracer. `parameter_store.transfer_species` closes such a gap explicitly. + """ + gaps: dict[str, list[str]] = {} + for comparison in setup.comparisons: + check = check_recipe(comparison.recipe, comparison.overrides) + if check.error is not None: + raise ValueError(f"Comparison {comparison.name!r}: {check.error}") + for path, entry in store.entries.items(): + if not store.spec(path).species_indexed or not check.has_parameter(path): + continue + for name in check.species: + if name not in entry.value and name not in gaps.setdefault(path, []): + gaps[path].append(name) + return {path: names for path, names in gaps.items() if names} + + +def build(setup: StepSetup, store: ParameterStore) -> BuiltStep: + """Build one process + comparator per comparison and the stage's problem over them.""" + names = setup.objective_names + duplicates = sorted({n for n in names if names.count(n) > 1}) + if duplicates: + raise ValueError(f"Step {setup.name!r}: duplicate measurement names {duplicates}.") + if not names: + raise ValueError(f"Step {setup.name!r} has no measurements.") + + processes, comparators = [], [] + for comparison in setup.comparisons: + process, comparator = comparison.build(store) + processes.append(process) + comparators.append(comparator) + + problem = build_problem( + setup.stage, processes, comparators, Simulator(), + options=setup.options, bounds=setup.bounds, frozen=setup.frozen, + ) + return BuiltStep(problem=problem, processes=processes) + + +def _start_point(problem: OptimizationProblem) -> list[float]: + """Return the independent variables' current process values, clipped into bounds.""" + lower = problem.lower_bounds_independent + upper = problem.upper_bounds_independent + x0 = [] + for name, lb, ub in zip(problem.independent_variable_names, lower, upper): + value = problem.get_variable_value(name) + if value is None or not np.isfinite(np.ravel(value)[0]): + value = (lb + ub) / 2.0 + x0.append(float(np.clip(np.ravel(value)[0], lb, ub))) + return x0 + + +def run( + setup: StepSetup, + store: ParameterStore, + optimizer_name: Optional[str] = None, + optimizer_kwargs: Optional[Mapping[str, int]] = None, + cancel_event: Optional[threading.Event] = None, + on_optimizer_ready: Optional[Callable[[Any], None]] = None, + built: Optional[BuiltStep] = None, +) -> StepResult: + """Fit `setup` starting from `store` and return the whole final front. + + `built` reuses an already built problem (e.g. one whose discretization the caller + adjusted); by default the step is built from `store`. Without `optimizer_name`, + `setup.optimizer` names the optimizer and supplies knobs `optimizer_kwargs` doesn't set. + """ + if optimizer_name is None: + optimizer_name = setup.optimizer["name"] + optimizer_kwargs = {**setup.optimizer["knobs"], **(optimizer_kwargs or {})} + built = built or build(setup, store) + problem = built.problem + outcome = run_optimization( + problem, optimizer_name, optimizer_kwargs or {}, _start_point(problem), + cancel_event=cancel_event, on_optimizer_ready=on_optimizer_ready, + ) + n_var = len(problem.independent_variable_names) + n_obj = len(setup.comparisons) + return StepResult( + step=setup.name, + variable_names=list(problem.independent_variable_names), + objective_names=setup.objective_names, + x=np.array(outcome.front_x, dtype=float).reshape(-1, n_var), + f=np.array(outcome.front_f, dtype=float).reshape(-1, n_obj), + success=outcome.success, + message=outcome.message, + cancelled=outcome.cancelled, + optimizer_name=optimizer_name, + built=built, + ) + + +def pareto_candidates(result: StepResult) -> list[Candidate]: + """Return the equal-weight-average winner and each objective's winner, merged by x.""" + if len(result.f) == 0: + return [] + picks = [(AVERAGE_TAG, int(np.argmin(result.f.mean(axis=1))))] + picks += [ + (f"best {name}", int(np.argmin(result.f[:, i]))) + for i, name in enumerate(result.objective_names) + ] + + merged: dict[tuple[float, ...], Candidate] = {} + for tag, index in picks: + x = tuple(float(v) for v in result.x[index]) + if x in merged: + merged[x] = replace(merged[x], tags=(*merged[x].tags, tag)) + else: + f = tuple(float(v) for v in result.f[index]) + merged[x] = Candidate(index=index, x=x, f=f, tags=(tag,)) + return list(merged.values()) + + +def current_best_average(optimizer: Any) -> Optional[Candidate]: + """Return the equal-weight-average winner of a running optimizer's current front. + + Reads `optimizer.results` only; None before the first generation has finished. + """ + front_x, front_f = pareto_front(optimizer) + if not front_x: + return None + index = int(np.argmin(np.asarray(front_f, dtype=float).mean(axis=1))) + return Candidate(index=index, x=front_x[index], f=front_f[index], tags=(AVERAGE_TAG,)) + + +def _probes(setup: StepSetup) -> list[str]: + return list(dict.fromkeys(c.probe for c in setup.comparisons if c.probe)) + + +def posterior( + setup: StepSetup, built: BuiltStep, store: ParameterStore, candidate: Candidate +) -> ParameterStore: + """Write `candidate` into the processes and return `store` updated with the fitted values. + + Species-indexed values are restricted to the comparisons' probe species when any + probe names a species, each read from a process that carries it; otherwise every + species of the first process is written. + """ + built.problem.set_variables(list(candidate.x)) + process = built.processes[0] + paths = setup.provides + + new_specs = {path: spec_for(process, path) for path in paths if path not in store.specs} + reader = replace(store, specs={**store.specs, **new_specs}) + values = read_process(process, paths, reader) + + probes = _probes(setup) + if any(s in p.component_system.species for p in built.processes for s in probes): + for path in values: + if not reader.spec(path).species_indexed: + continue + merged = {} + for other in built.processes: + present = [s for s in probes if s in other.component_system.species] + if present: + read = read_process(other, [path], reader)[path] + merged.update({s: read[s] for s in present if s not in merged}) + values[path] = merged + values = { + path: {s: np.asarray(v).tolist() for s, v in value.items()} + if isinstance(value, dict) else np.asarray(value).tolist() + for path, value in values.items() + } + + recipe = setup.comparisons[0].recipe + column_bypassed = recipe.instrument is not None and "column" in recipe.instrument.bypass_units + column = None if column_bypassed else COLUMN_MODELS.get(recipe.column_key) + provenance = Provenance( + step=setup.name, + probe=", ".join(probes) or None, + source=", ".join(setup.objective_names), + model=column.__name__ if column is not None else None, + metric={ + f"{c.name}_{c.metric}": float(value) + for c, value in zip(setup.comparisons, candidate.f) + }, + ) + return store.updated(values, provenance, specs=new_specs) diff --git a/cadetgui/characterization/stages.py b/cadetgui/characterization/stages.py new file mode 100644 index 0000000..f07135d --- /dev/null +++ b/cadetgui/characterization/stages.py @@ -0,0 +1,208 @@ +""" +Headless registry over CADETProcess.characterization's Characterize* classes. + +Stage identity, option parameters and variable schema are all derived from the +classes themselves (`inspect.signature` for constructor options, the private +`_default_variables()` for the variable list) rather than hand-listed, so the +registry tracks CADET-Process instead of duplicating it. No ipywidgets +dependency. + +Private API relied on: +- `CharacterizeBase._default_variables()`: no public equivalent exists upstream. +- The `self._` attribute convention every subclass's `__init__` + follows for its own constructor options (e.g. `CharacterizeTubing._tubing`, + `CharacterizeAdsorptionParameters._is_kinetic`): used to build a stage + instance via `cls.__new__` without processes/comparators/a simulator, which + `_default_variables()` needs no other instance state for. +- `OptimizationProblem.remove_variable` is not implemented upstream (raises + `NotImplementedError`), so freezing a variable overrides + `_default_variables()` in a subclass instead. +""" + +from __future__ import annotations + +import inspect +from dataclasses import dataclass +from typing import Any, Mapping, Optional, Sequence + +from CADETProcess.characterization import ( + CharacterizeAdsorptionParameters, + CharacterizeBase, + CharacterizeBed, + CharacterizeCapacity, + CharacterizeParticles, + CharacterizePreInjection, + CharacterizeTubing, +) +from CADETProcess.comparison import Comparator +from CADETProcess.instruments import LCProcess +from CADETProcess.simulator import SimulatorBase + + +@dataclass(frozen=True) +class StageDef: + """One characterization stage: its label, solver class and constructor options.""" + + id: str + label: str + characterize_cls: type[CharacterizeBase] + options: Mapping[str, inspect.Parameter] + + +@dataclass(frozen=True) +class VariableDef: + """One optimization variable, as `_default_variables()` describes it. + + `is_dependent` marks a variable with no direct process write of its own: + either a free helper variable (`targets=None`, e.g. kinetic mode's + `equilibrium_constant`) or one resolved from other variables via + `add_variable_dependency` after construction (e.g. `adsorption_rate` in + kinetic mode). + """ + + name: str + parameter_path: Optional[str] + lb: Optional[float] + ub: Optional[float] + transform: Optional[str] + indices: Optional[Any] + is_dependent: bool + + +_BASE_PARAMS = frozenset(inspect.signature(CharacterizeBase.__init__).parameters) - {"kwargs"} + + +def _discover_options(cls: type[CharacterizeBase]) -> dict[str, inspect.Parameter]: + """Return `cls`'s own constructor keyword parameters, beyond `CharacterizeBase`'s.""" + sig = inspect.signature(cls.__init__) + return { + name: param for name, param in sig.parameters.items() + if name not in _BASE_PARAMS and param.kind != inspect.Parameter.VAR_KEYWORD + } + + +def _stage_def(stage_id: str, label: str, cls: type[CharacterizeBase]) -> StageDef: + return StageDef(id=stage_id, label=label, characterize_cls=cls, options=_discover_options(cls)) + + +STAGES: dict[str, StageDef] = { + stage.id: stage for stage in ( + _stage_def("tubing", "Extra-column volume (tubing)", CharacterizeTubing), + _stage_def( + "pre_injection", "Extra-column volume (pre-injection tubing + mixer)", + CharacterizePreInjection, + ), + _stage_def("bed", "Column bed (porosity & axial dispersion)", CharacterizeBed), + _stage_def("particles", "Particle transport", CharacterizeParticles), + _stage_def("capacity", "Binding capacity", CharacterizeCapacity), + _stage_def("adsorption", "Binding (steric mass action)", CharacterizeAdsorptionParameters), + ) +} + + +def _resolve_options(stage: StageDef, options: Mapping[str, Any]) -> dict[str, Any]: + """Fill `options` with each option's constructor default; validate names.""" + unknown = set(options) - set(stage.options) + if unknown: + raise ValueError( + f"Unknown option(s) for step type {stage.label!r}: {sorted(unknown)}" + ) + + resolved = {} + for name, param in stage.options.items(): + if name in options: + resolved[name] = options[name] + elif param.default is not inspect.Parameter.empty: + resolved[name] = param.default + else: + raise ValueError(f"Step type {stage.label!r} needs the option {name!r}.") + return resolved + + +def _to_variable_def(var: Mapping[str, Any]) -> VariableDef: + is_dependent = var.get("targets", -1) is None or "lb" not in var + return VariableDef( + name=var["name"], + parameter_path=var.get("parameter_path"), + lb=var.get("lb"), + ub=var.get("ub"), + transform=var.get("transform"), + indices=var.get("indices"), + is_dependent=is_dependent, + ) + + +def describe_stage(stage_id: str, **options: Any) -> list[VariableDef]: + """Return the variable schema `stage_id` would fit for the given options. + + Builds a bare stage instance (`cls.__new__` plus the `_` + attributes its `__init__` would set) and reads its `_default_variables()` + -- no processes, comparators or simulator are needed for this. + """ + stage = STAGES[stage_id] + resolved = _resolve_options(stage, options) + + obj = stage.characterize_cls.__new__(stage.characterize_cls) + for name, value in resolved.items(): + setattr(obj, f"_{name}", value) + + return [_to_variable_def(var) for var in obj._default_variables()] + + +def write_targets(variables: Sequence[VariableDef]) -> dict[str, str]: + """Return `{variable name: parameter path}` for every variable with a path. + + Helper variables with no parameter path (kinetic mode's `equilibrium_constant` + and `kinetic_constant`) are omitted. + """ + return {v.name: v.parameter_path for v in variables if v.parameter_path is not None} + + +def build_problem( + stage_id: str, + processes: LCProcess | list[LCProcess], + comparators: Comparator | list[Comparator], + simulator: SimulatorBase, + *, + options: Optional[Mapping[str, Any]] = None, + bounds: Optional[Mapping[str, tuple[float, float]]] = None, + frozen: Optional[Sequence[str]] = None, +) -> CharacterizeBase: + """Build the characterization problem for `stage_id`. + + `frozen` variable names are dropped from `_default_variables()` before + construction, so the process keeps whatever value it already carries for + that parameter; `OptimizationProblem.remove_variable` is not implemented + upstream. Freezing a variable that a dependency chain references (e.g. + `kinetic_constant` in kinetic adsorption mode) raises, same as passing an + unknown variable name to `add_variable_dependency` would. + + The plot callback `CharacterizeBase` registers breaks under + `optimize(save_results=False)`; cleared here rather than left for callers + to remember. + """ + stage = STAGES[stage_id] + resolved_options = _resolve_options(stage, options or {}) + frozen_set = set(frozen or ()) + + cls = stage.characterize_cls + if frozen_set: + class _Filtered(cls): + def _default_variables(self) -> list[dict]: + return [ + var for var in super()._default_variables() + if var["name"] not in frozen_set + ] + _Filtered.__name__ = cls.__name__ + cls = _Filtered + + bound_overrides = { + name: {"lb": lb, "ub": ub} for name, (lb, ub) in (bounds or {}).items() + } + + problem = cls( + stage_id, processes, comparators=comparators, simulator=simulator, + **resolved_options, **bound_overrides, + ) + problem.callbacks.clear() + return problem diff --git a/cadetgui/characterization/step_checks.py b/cadetgui/characterization/step_checks.py new file mode 100644 index 0000000..c42845b --- /dev/null +++ b/cadetgui/characterization/step_checks.py @@ -0,0 +1,569 @@ +"""Live "before you run" checks of one characterization step. Headless (no ipywidgets). + +`step_checks` evaluates a step against its study: measurements and replicates, +injection markers, measurement problems (including baseline and normalization +errors found by building the reference), requirements from earlier steps, species +gaps, values the experiment types assume, and the known hardware values the chain +guide names. Each open item carries `CheckAction`s: a navigation target of the +characterization workbench (`"measurements"`, `"step"`, `"system"`, `"parameters"`) +with its context, or a step-local action (`TRANSFER`, `RECORD_ASSUMPTIONS`, +`CONFIRM_KNOWN`). A hardware value left at its default is a note, not a blocker: +`confirm_known_value` records it (or a corrected value) as known hardware. +""" + +from __future__ import annotations + +import json +import re +from collections import OrderedDict +from dataclasses import dataclass, field, replace +from typing import Any, Collection, Dict, List, Mapping, Optional, Sequence, Tuple + +from ..cadetprocessadapter import UNIT_LABELS +from ..process_builder import build_process, check_recipe, recipe_key +from .comparison import Comparison +from .guide import ( + EXPERIMENT_TYPES, + ChainStepGuide, + guide_for_step, + missing_assumptions, + parameter_label, + parameter_unit, + record_assumptions, + species_gap_messages, +) +from .parameter_store import ( + ParameterSpec, + ParameterStore, + Provenance, + Step, + missing_requirements, + read_process, + spec_for, +) +from .runner import StepSetup, species_gaps + +__all__ = [ + "TRANSFER", + "RECORD_ASSUMPTIONS", + "CONFIRM_KNOWN", + "CONFIRMED_DEFAULT", + "KNOWN_HARDWARE", + "CheckAction", + "StepCheck", + "step_checks", + "StepStatus", + "study_steps_status", + "reference_problem", + "known_value_source", + "known_value_label", + "quantity_text", + "confirm_known_value", +] + +TRANSFER = "transfer" +RECORD_ASSUMPTIONS = "record_assumptions" +CONFIRM_KNOWN = "confirm_known" +CONFIRMED_DEFAULT = "confirmed default" +KNOWN_HARDWARE = "known hardware" +_MARKER_PROBLEM = "No run-log marker starts with" + + +@dataclass(frozen=True) +class CheckAction: + """A fix for an open check: a navigation `target` with `context`, or a local action.""" + + label: str + target: str + context: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class StepCheck: + """One live check; `status` is "ok", "warn" or "info".""" + + id: str + status: str + text: str + actions: Tuple[CheckAction, ...] = () + + +def _open(comparison: Comparison) -> CheckAction: + return CheckAction( + f"Open {comparison.name}", "measurements", {"select": comparison.name} + ) + + +_REFERENCE_CACHE_SIZE = 64 +_reference_cache: "OrderedDict[tuple, Optional[str]]" = OrderedDict() + + +def reference_problem(comparison: Comparison) -> Optional[str]: + """Return why the measured trace cannot be prepared (alignment, baseline, scaling), or None. + + Builds the reference the fit would compare against; memoized by the comparison's + content and loaded run. + """ + if comparison.run is None: + return None + key = ( + json.dumps(comparison.to_dict(), sort_keys=True, default=str), + recipe_key(comparison.recipe, comparison.overrides), + id(comparison.run), + ) + if key in _reference_cache: + _reference_cache.move_to_end(key) + return _reference_cache[key] + try: + process = build_process(comparison.recipe, overrides=comparison.overrides) + reference = comparison.build_reference(process) + problem = None if len(reference.time) else ( + "No measured points fall inside the simulated run after alignment." + ) + except Exception as exc: # noqa: BLE001 -- reported as the check's text + problem = str(exc) + _reference_cache[key] = problem + while len(_reference_cache) > _REFERENCE_CACHE_SIZE: + _reference_cache.popitem(last=False) + return problem + + +def known_value_source( + store: ParameterStore, comparisons: Sequence[Comparison], path: str +) -> Optional[str]: + """Return where a known value of `path` comes from, or None if nothing sets it. + + "the parameter store" when the store holds it, "the system setup" when every + recipe that carries `path` sets it explicitly (unit, column or binding values). + """ + if path in store: + return "the parameter store" + parts = path.split(".") + if len(parts) < 3 or parts[0] != "flow_sheet": + return None + unit, name = parts[1], parts[-1] + for comparison in comparisons: + recipe = comparison.recipe + if unit == "column" and parts[2] == "binding_model": + values = recipe.binding_values + elif unit == "column": + values = recipe.column_values + else: + instrument = recipe.instrument + values = instrument.unit_values.get(unit, {}) if instrument is not None else {} + if name not in {**values, **comparison.overrides}: + return None + return "the system setup" + + +def _measurement_checks( + setup: StepSetup, guide: Optional[ChainStepGuide] +) -> List[StepCheck]: + step_context = {"step_id": guide.id} if guide is not None else {} + add = CheckAction("Add measurement for this step", "measurements", step_context) + n = len(setup.comparisons) + checks = [] + if n == 0: + checks.append(StepCheck("measurements", "warn", "No measurements attached.", (add,))) + elif n == 1: + checks.append(StepCheck( + "measurements", "info", + "1 measurement attached. A replicate run lets an outlier show up.", + (CheckAction("Add a replicate", "measurements", step_context),), + )) + else: + checks.append(StepCheck( + "measurements", "ok", f"{n} measurements attached (replicates)." + )) + if guide is not None and n: + attached = {c.experiment_type for c in setup.comparisons} + for type_id in guide.experiment_types: + if type_id not in attached: + label = EXPERIMENT_TYPES[type_id].label + checks.append(StepCheck( + f"type:{type_id}", "warn", f"No {label} measurement attached.", + (CheckAction( + f"Add {label}", "measurements", + {**step_context, "experiment_type": type_id}, + ),), + )) + return checks + + +def _marker_checks(setup: StepSetup) -> List[StepCheck]: + checks, loaded = [], [c for c in setup.comparisons if c.run is not None] + for comparison in loaded: + if comparison.injection_marker is None and comparison.measured_injection is None: + checks.append(StepCheck( + f"marker:{comparison.name}", "warn", + f"{comparison.name}: no injection marker picked; the measured clock is " + "taken as the simulated one.", (_open(comparison),), + )) + continue + try: + comparison.measured_injection_x() + except ValueError: + checks.append(StepCheck( + f"marker:{comparison.name}", "warn", + f"{comparison.name}: injection marker {comparison.injection_marker!r} not " + "found in the run log.", (_open(comparison),), + )) + if loaded and not checks: + checks.append(StepCheck( + "markers", "ok", f"Injection marker found in every measurement ({len(loaded)})." + )) + return checks + + +def _problem_checks( + study: Any, setup: StepSetup, unaligned: Sequence[str] +) -> List[StepCheck]: + checks = [] + for comparison in setup.comparisons: + problems = [ + p for p in study.measurement_problems(comparison) if not p.startswith(_MARKER_PROBLEM) + ] + if not problems and comparison.name not in unaligned: + reference = reference_problem(comparison) + problems = [reference] if reference is not None else [] + if problems: + checks.append(StepCheck( + f"problems:{comparison.name}", "warn", + f"{comparison.name}: " + " ".join(problems), (_open(comparison),), + )) + if setup.comparisons and not checks: + checks.append(StepCheck( + "problems", "ok", + f"Every measured curve is ready to fit ({len(setup.comparisons)}): file read, " + "detector signal found, baseline and scaling applied.", + )) + return checks + + +def _requirement_checks( + study: Any, setup: StepSetup, guide: Optional[ChainStepGuide], prior: ParameterStore +) -> List[StepCheck]: + requires = tuple(dict.fromkeys((*setup.requires, *(guide.requires if guide else ())))) + if not requires: + return [] + missing = missing_requirements(Step(setup.name, requires=requires), prior) + if not missing: + return [StepCheck( + "requires", "ok", + "Values from earlier steps are in the store: " + + ", ".join(parameter_label(p) for p in requires) + ".", + )] + names = [s.name for s in study.steps] + earlier = study.steps[: names.index(setup.name)] if setup.name in names else study.steps + by_provider: Dict[Optional[str], List[str]] = {} + for path in missing: + provider = None + for step in earlier: + try: + provides = step.provides + except ValueError: + continue + if path in provides: + provider = step.name + by_provider.setdefault(provider, []).append(path) + checks = [] + for provider, paths in by_provider.items(): + labels = ", ".join(parameter_label(p) for p in paths) + if provider is not None: + checks.append(StepCheck( + f"requires:{provider}", "warn", + f"Needs {labels}; accept {provider} first.", + (CheckAction(f"Open {provider}", "step", {"name": provider}),), + )) + else: + checks.append(StepCheck( + "requires:store", "warn", + f"Needs {labels}; no earlier step provides it. Add a known value to the " + "parameter store or add the step that fits it.", + (CheckAction("Open parameters", "parameters"),), + )) + return checks + + +def _species_checks( + study: Any, setup: StepSetup, prior: ParameterStore +) -> List[StepCheck]: + assumed = record_assumptions(setup, prior) + try: + gaps = species_gaps(setup, assumed) + except Exception: # noqa: BLE001 -- an unbuildable recipe is a measurement problem + return [] + if not gaps: + return [] + lines = species_gap_messages(study, setup, gaps, assumed) + return [StepCheck( + "species_gaps", "warn", + "Stored values lack this step's components: " + ". ".join(lines) + + ". Carry them over explicitly.", + (CheckAction("Transfer to this step's species", TRANSFER, {"gaps": gaps}),), + )] + + +def _assumption_checks(setup: StepSetup, prior: ParameterStore) -> List[StepCheck]: + missing = missing_assumptions(setup, prior) + if not missing: + return [] + items = list(dict.fromkeys( + f"{parameter_label(path)} = {value:g} for {comparison.probe} " + f"({EXPERIMENT_TYPES[comparison.experiment_type].label})" + for comparison, path, value in missing + )) + return [StepCheck( + "assumptions", "warn", + "The experiment types assume values the parameter store does not hold: " + + "; ".join(items) + ".", + (CheckAction("Record the assumptions", RECORD_ASSUMPTIONS),), + )] + + +_UNIT_NOUNS: Dict[str, str] = { + "tubing_pre_injection": "Pre-injection tubing", + "tubing_pre_column": "Pre-column tubing", + "tubing_post_column": "Post-column tubing", + "tubing_detectors": "Detector tubing", +} +_NAMED_PATHS: Dict[str, str] = { + "flow_sheet.column.particle_radius": "Particle radius", + "flow_sheet.column.binding_model.capacity": "Ionic capacity", +} +_LENGTH_SCALES = ((1.0, 1.0, "m"), (1e-4, 1e-3, "mm"), (0.0, 1e-6, "µm")) +"""(smallest value, scale, unit) for showing a length.""" + + +def known_value_label(path: str) -> str: + """Return a lab label for a hardware path, e.g. "Pre-column tubing diameter".""" + if path in _NAMED_PATHS: + return _NAMED_PATHS[path] + parts = path.split(".") + if len(parts) < 3 or parts[0] != "flow_sheet": + return parameter_label(path) + noun = _UNIT_NOUNS.get(parts[1], UNIT_LABELS.get(parts[1], parts[1])) + return f"{noun} {parts[-1].replace('_', ' ')}" + + +def _plain_unit(latex: str) -> str: + text = re.sub(r"\\mathrm\{([^{}]*)\}", r"\1", latex or "") + text = text.replace("^{3}", "³").replace("^{2}", "²").replace("^{-1}", "⁻¹") + text = re.sub(r"\\frac\{([^{}]*)\}\{([^{}]*)\}", r"\1/\2", text) + return text.replace("\\cdot", "·").replace("{", "").replace("}", "").replace("\\", "") + + +def quantity_text(value: float, latex_unit: str) -> Tuple[str, float, str]: + """Return `value` as readable text plus the display `(scale, unit)`, lengths in mm or µm.""" + unit = _plain_unit(latex_unit) + scale = 1.0 + if unit == "m" and value: + scale, unit = next((f, u) for low, f, u in _LENGTH_SCALES if abs(value) >= low) + text = f"{value / scale:.4g}" + return (f"{text} {unit}" if unit else text), scale, unit + + +def _scalar(value: Any) -> Any: + if isinstance(value, (list, tuple)) and len(value) == 1: + return value[0] + return value.item() if hasattr(value, "item") and getattr(value, "size", 0) == 1 else value + + +def _process_value(comparison: Comparison, path: str) -> Tuple[Optional[float], str]: + """Return the value `comparison`'s built process uses for `path` and its LaTeX unit.""" + check = check_recipe(comparison.recipe, comparison.overrides) + process = check.process + try: + reader = ParameterStore(specs={path: spec_for(process, path)}) + value = _scalar(read_process(process, [path], reader)[path]) + except KeyError: + return None, "" + if not isinstance(value, (int, float)): + return None, "" + return float(value), parameter_unit(process, path) + + +def confirm_known_value( + study: Any, path: str, value: float, note: str = CONFIRMED_DEFAULT +) -> ParameterStore: + """Record `value` of hardware `path` as known in the initial store; return that store. + + Accepted posteriors that lack `path` get the same entry, so every step's prior + holds it, as if it had been recorded before the first step. + """ + provenance = Provenance(step="initial", source=KNOWN_HARDWARE, note=note) + spec = {path: ParameterSpec(path=path)} + + def record(store: ParameterStore) -> ParameterStore: + updated = store.updated({path: float(value)}, provenance, specs=spec) + return replace(updated, step=store.step, prior=store.prior) + + study.initial_store = record(study.initial_store) + for name, posterior in list(study.posteriors.items()): + if path not in posterior: + study.posteriors[name] = record(posterior) + study.notify() + return study.initial_store + + +def _known_value_checks( + setup: StepSetup, guide: Optional[ChainStepGuide], prior: ParameterStore +) -> List[StepCheck]: + if guide is None: + return [] + checks = [] + for path in guide.known_values: + carriers = [] + for comparison in setup.comparisons: + check = check_recipe(comparison.recipe, comparison.overrides) + if check.error is None and check.has_parameter(path): + carriers.append(comparison) + if not carriers: + continue + source = known_value_source(prior, carriers, path) + label = known_value_label(path) + value, unit = _process_value(carriers[0], path) + if source == "the parameter store": + entry = prior.entries[path] + stored = _scalar(entry.value) + if isinstance(stored, (int, float)): + value = float(stored) + if entry.provenance.note == CONFIRMED_DEFAULT: + source = "confirmed default, in the parameter store" + else: + source = "in the parameter store" + elif source is not None: + source = "set in the system setup" + shown = quantity_text(value, unit) if value is not None else None + if source is None and shown is None: + checks.append(StepCheck( + f"known:{path}", "info", + f"{label}: using the default value. Is that your hardware?", + (CheckAction("Edit in System", "system", {"unit": path.split(".")[1]}),), + )) + elif source is None: + text, scale, display_unit = shown + checks.append(StepCheck( + f"known:{path}", "info", + f"{label}: using the default {text}. Is that your hardware?", + ( + CheckAction("Use this value", CONFIRM_KNOWN, { + "path": path, "value": value, "scale": scale, "unit": display_unit, + }), + CheckAction("Edit in System", "system", {"unit": path.split(".")[1]}), + ), + )) + else: + value_text = f" {shown[0]}" if shown is not None else "" + checks.append(StepCheck( + f"known:{path}", "ok", f"{label}:{value_text} ({source}).", + )) + return checks + + +def step_checks(study: Any, setup: StepSetup) -> List[StepCheck]: + """Return the live pre-run checks of `setup` in `study`, open items with their fixes.""" + guide = guide_for_step(setup) + names = [s.name for s in study.steps] + prior = study.prior_for(setup.name) if setup.name in names else study.current_store + checks = _measurement_checks(setup, guide) + markers = _marker_checks(setup) + unaligned = [c.id.partition(":")[2] for c in markers if c.status == "warn"] + checks += markers + checks += _problem_checks(study, setup, unaligned) + checks += _requirement_checks(study, setup, guide, prior) + if setup.comparisons and not any( + c.status == "warn" and c.id.startswith("problems:") for c in checks + ): + checks += _assumption_checks(setup, prior) + checks += _species_checks(study, setup, prior) + checks += _known_value_checks(setup, guide, prior) + return checks + + +@dataclass(frozen=True) +class StepStatus: + """Where one study step stands, for the Setup pane. + + `measurements` maps experiment type id (or "untagged") to comparison names; + `problems` are the texts of the open (warn) `checks`. + """ + + step_name: str + guide: Optional[ChainStepGuide] + measurements: Dict[str, List[str]] + checks: Tuple[StepCheck, ...] + fitted: bool + accepted: bool + next_action: str + + @property + def open_checks(self) -> List[StepCheck]: + """The checks that still need attention.""" + return [c for c in self.checks if c.status == "warn"] + + @property + def problems(self) -> List[str]: + """The texts of the open checks.""" + return [c.text for c in self.open_checks] + + @property + def ready(self) -> bool: + """Whether no check needs attention.""" + return not self.open_checks + + @property + def species_gaps(self) -> Dict[str, List[str]]: + """Stored values that lack this step's species, by path.""" + for check in self.open_checks: + for action in check.actions: + if action.target == TRANSFER: + return dict(action.context["gaps"]) + return {} + + +def _step_title(step: StepSetup) -> str: + guide = guide_for_step(step) + return guide.title if guide is not None else step.name + + +def _next_action( + study: Any, position: int, open_checks: Sequence[StepCheck], fitted: bool, accepted: bool +) -> str: + if accepted: + later = study.steps[position + 1:] + return ( + f"Accepted. Continue with {_step_title(later[0])}." if later + else "Accepted. The chain is complete." + ) + if open_checks: + return open_checks[0].text + return "Choose a Pareto candidate and accept it." if fitted else "Run the fit." + + +def study_steps_status(study: Any, fitted: Collection[str] = ()) -> List[StepStatus]: + """Return one `StepStatus` per step of `study`, in chain order, from `step_checks`. + + `fitted` names the steps that have a fit result waiting for a choice (results are + not part of the study). + """ + statuses = [] + for position, step in enumerate(study.steps): + measurements: Dict[str, List[str]] = {} + for comparison in step.comparisons: + key = comparison.experiment_type or "untagged" + measurements.setdefault(key, []).append(comparison.name) + checks = tuple(step_checks(study, step)) + accepted = step.name in study.posteriors + is_fitted = step.name in fitted + open_checks = [c for c in checks if c.status == "warn"] + statuses.append(StepStatus( + step_name=step.name, + guide=guide_for_step(step), + measurements=measurements, + checks=checks, + fitted=is_fitted, + accepted=accepted, + next_action=_next_action(study, position, open_checks, is_fitted, accepted), + )) + return statuses diff --git a/cadetgui/characterization/study.py b/cadetgui/characterization/study.py new file mode 100644 index 0000000..81bb4b0 --- /dev/null +++ b/cadetgui/characterization/study.py @@ -0,0 +1,450 @@ +"""A characterization study: initial parameter store, comparisons, chain steps and posteriors. + +The JSON form is the manifest written by `examples/generate_characterization_chain_data.py`, +plus an optional `posteriors` mapping of step name to the store that step produced and an +optional `components` list (name and role); without it the components are derived from +the measurements. +Data files are referenced relative to the study file; `Study.save` writes or copies every +comparison's data next to the study so the folder is self-contained. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple + +from ..io.experimental_data import write_experimental_csv +from .comparison import Comparison +from .guide import ( + COMPONENT_ROLES, + EXPERIMENT_TYPES, + PROTEIN, + SALT, + SALT_ROLE, + parameter_label, +) +from .parameter_store import ParameterStore +from .runner import StepSetup + +__all__ = ["Study", "StudyComponent", "derive_components"] + + +@dataclass(frozen=True) +class StudyComponent: + """A molecule the study's measurements inject or elute with, and its role.""" + + name: str + role: str + + +def _article(role: str) -> str: + return f"{'an' if role[:1] in 'aeiou' else 'a'} {role}" + + +def _roles_text(roles: Iterable[str]) -> str: + return " or ".join(_article(r) for r in roles) + + +def derive_components(comparisons: Iterable[Comparison]) -> List[StudyComponent]: + """Return the components `comparisons` use, with roles from their experiment types. + + A probe used by several types gets the first role all of them allow; an untyped + measurement's probe is a protein. `SALT` is declared first when a recipe carries it. + """ + allowed: Dict[str, List[Tuple[str, ...]]] = {} + salt = False + for comparison in comparisons: + salt = salt or SALT in comparison.recipe.components + if not comparison.probe or comparison.probe == SALT: + continue + experiment = EXPERIMENT_TYPES.get(comparison.experiment_type or "") + options = allowed.setdefault(comparison.probe, []) + if experiment is not None and experiment.probe_roles: + options.append(experiment.probe_roles) + derived = [StudyComponent(SALT, SALT_ROLE)] if salt else [] + for name, options in allowed.items(): + common = [r for r in options[0] if all(r in o for o in options)] if options else [] + role = (common or (options[0] if options else (PROTEIN,)))[0] + derived.append(StudyComponent(name, role)) + return derived + + +@dataclass +class Study: + """Mutable container the characterization widgets share. + + `posteriors` holds, per step name, the store accepted for that step. `current_store` + is the posterior of the last step (in `steps` order) that has one, else + `initial_store`, which is what the next step starts from. + + `components` are the declared molecules with their `COMPONENT_ROLES` role. Stored + values are keyed by component name, so the name is what links values across steps. + Adding or replacing a measurement declares its probe (and `SALT`) if missing. + """ + + initial_store: ParameterStore = field(default_factory=ParameterStore) + comparisons: List[Comparison] = field(default_factory=list) + steps: List[StepSetup] = field(default_factory=list) + posteriors: Dict[str, ParameterStore] = field(default_factory=dict) + description: str = "" + base_dir: Optional[Path] = None + components: List[StudyComponent] = field(default_factory=list) + _listeners: List[Callable[[], None]] = field(default_factory=list, repr=False) + + def __post_init__(self) -> None: + """Derive `components` from the measurements when none are given.""" + if not self.components: + self.components = derive_components(self.comparisons) + + def add_listener(self, fn: Callable[[], None]) -> None: + """Call `fn()` after every change made through this object's methods.""" + self._listeners.append(fn) + + def remove_listener(self, fn: Callable[[], None]) -> None: + """Stop calling `fn`; unknown listeners are ignored.""" + if fn in self._listeners: + self._listeners.remove(fn) + + def notify(self) -> None: + """Tell listeners the study changed; call after editing attributes directly.""" + for fn in list(self._listeners): + fn() + + def comparison(self, name: str) -> Comparison: + """Return the comparison called `name`.""" + for comparison in self.comparisons: + if comparison.name == name: + return comparison + raise KeyError(f"No comparison named {name!r}.") + + def upsert_comparison(self, comparison: Comparison) -> None: + """Add `comparison`, replacing one with the same name.""" + self.comparisons = [c for c in self.comparisons if c.name != comparison.name] + self.comparisons.append(comparison) + self._declare(comparison) + self.notify() + + def replace_comparison(self, name: str, comparison: Comparison) -> None: + """Put `comparison` in place of the one called `name`, in the list and in every step.""" + if comparison.name != name and any(c.name == comparison.name for c in self.comparisons): + raise ValueError(f"A comparison named {comparison.name!r} already exists.") + self.comparison(name) + self.comparisons = [comparison if c.name == name else c for c in self.comparisons] + for step in self.steps: + step.comparisons = [comparison if c.name == name else c for c in step.comparisons] + if step.excluded is not None: + step.excluded = tuple(comparison.name if n == name else n for n in step.excluded) + self._declare(comparison) + self.notify() + + def steps_using(self, name: str) -> List[str]: + """Return the names of the steps that fit the comparison called `name`.""" + return [s.name for s in self.steps if any(c.name == name for c in s.comparisons)] + + def remove_comparison(self, name: str, *, from_steps: bool = False) -> None: + """Remove the comparison called `name`. + + Raises while a step still fits it, unless `from_steps` takes it out of those steps + too (accepted posteriors are kept). Steps that leave it out forget it. + """ + users = self.steps_using(name) + if users and not from_steps: + raise ValueError(f"Comparison {name!r} is used by step(s) {', '.join(users)}.") + self.steps = [ + replace( + step, + comparisons=[c for c in step.comparisons if c.name != name], + excluded=step.excluded and tuple(n for n in step.excluded if n != name), + ) if step.name in users or name in (step.excluded or ()) else step + for step in self.steps + ] + self.comparisons = [c for c in self.comparisons if c.name != name] + self.notify() + + def component(self, name: str) -> Optional[StudyComponent]: + """Return the declared component called `name`, or None.""" + return next((c for c in self.components if c.name == name), None) + + def measurements_using(self, name: str) -> List[str]: + """Return the names of the measurements whose probe or recipe uses component `name`.""" + return [ + c.name for c in self.comparisons + if c.probe == name or name in c.recipe.components + ] + + def _declare(self, comparison: Comparison) -> None: + for derived in derive_components([comparison]): + if self.component(derived.name) is None: + if derived.role == SALT_ROLE: + self.components.insert(0, derived) + else: + self.components.append(derived) + + def _check_component(self, name: str, role: str) -> None: + if role not in COMPONENT_ROLES: + raise ValueError(f"Unknown role {role!r}; pick one of {', '.join(COMPONENT_ROLES)}.") + if not name: + raise ValueError("A component needs a name.") + if name == SALT and role != SALT_ROLE: + raise ValueError( + f"{SALT!r} is reserved for the salt of gradient and breakthrough runs." + ) + if role == SALT_ROLE and name != SALT: + raise ValueError( + f"The salt component is called {SALT!r}; gradient and breakthrough recipes " + "use that name." + ) + + def add_component(self, name: str, role: str) -> StudyComponent: + """Declare component `name` with `role`; raises if the name is taken.""" + name = name.strip() + self._check_component(name, role) + if self.component(name) is not None: + raise ValueError(f"A component called {name!r} is already declared.") + component = StudyComponent(name, role) + if role == SALT_ROLE: + self.components.insert(0, component) + else: + self.components.append(component) + self.notify() + return component + + def set_component_role(self, name: str, role: str) -> None: + """Give the declared component `name` another role.""" + self._check_component(name, role) + if self.component(name) is None: + raise KeyError(f"No component named {name!r}.") + self.components = [ + StudyComponent(name, role) if c.name == name else c for c in self.components + ] + self.notify() + + def stored_values_for(self, name: str) -> Dict[str, List[str]]: + """Return `{where: [parameter labels]}` for the stores holding values keyed by `name`.""" + stores = [("the starting values", self.initial_store)] + stores += [(f"accepted step {step}", store) for step, store in self.posteriors.items()] + found: Dict[str, List[str]] = {} + for where, store in stores: + labels = [ + parameter_label(path) for path, entry in store.entries.items() + if path in store.specs and store.specs[path].species_indexed + and name in entry.value + ] + if labels: + found[where] = labels + return found + + def rename_component(self, old: str, new: str) -> None: + """Rename component `old` in the declaration and in every measurement using it. + + Refused while any store holds values keyed by `old`: accepted results and their + provenance name the component, and re-keying them would silently change which + values a later step applies. + """ + new = new.strip() + current = self.component(old) + if current is None: + raise KeyError(f"No component named {old!r}.") + if new == old: + return + if current.role == SALT_ROLE: + raise ValueError(f"The salt component's name {SALT!r} is fixed.") + self._check_component(new, current.role) + if self.component(new) is not None: + raise ValueError(f"A component called {new!r} is already declared.") + held = self.stored_values_for(old) + if held: + where = "; ".join(f"{w}: {', '.join(labels)}" for w, labels in held.items()) + raise ValueError( + f"{old} cannot be renamed: values are stored under its name ({where}). " + "Renaming would detach them. Declare a new component instead." + ) + for comparison in self.comparisons: + if comparison.probe == old: + comparison.probe = new + if comparison.components: + comparison.components = [new if n == old else n for n in comparison.components] + if old in comparison.recipe.components: + comparison.recipe = replace( + comparison.recipe, + components=[new if n == old else n for n in comparison.recipe.components], + ) + self.components = [ + StudyComponent(new, c.role) if c.name == old else c for c in self.components + ] + self.notify() + + def remove_component(self, name: str) -> None: + """Remove component `name`; raises while a measurement uses it.""" + if self.component(name) is None: + raise KeyError(f"No component named {name!r}.") + users = self.measurements_using(name) + if users: + raise ValueError(f"{name} is used by measurement(s) {', '.join(users)}.") + self.components = [c for c in self.components if c.name != name] + self.notify() + + def component_problems(self, comparison: Comparison) -> List[str]: + """Return problems with `comparison`'s probe: undeclared, or a role its type forbids.""" + experiment = EXPERIMENT_TYPES.get(comparison.experiment_type or "") + roles = experiment.probe_roles if experiment is not None else () + probe = comparison.probe + if not probe: + return [f"No component picked; {experiment.label} needs {_roles_text(roles)}."] if ( + roles + ) else [] + component = self.component(probe) + if component is None: + return [f"{probe!r} is not a declared component."] + if roles and component.role not in roles: + return [ + f"{experiment.label} uses {probe!r} as {_roles_text(roles)}, but {probe} is " + f"declared as {_article(component.role)}." + ] + return [] + + def measurement_problems(self, comparison: Comparison) -> List[str]: + """Return `comparison.problems()` plus its component problems.""" + return comparison.problems() + self.component_problems(comparison) + + def upsert_step(self, step: StepSetup) -> None: + """Add `step` at the end of the chain, or replace the step with the same name in place.""" + names = [s.name for s in self.steps] + if step.name in names: + self.steps[names.index(step.name)] = step + else: + self.steps.append(step) + self.notify() + + def prior_for(self, step_name: str) -> ParameterStore: + """Return the store `step_name` starts from: the latest accepted posterior before it.""" + store = self.initial_store + for step in self.steps: + if step.name == step_name: + return store + store = self.posteriors.get(step.name, store) + raise KeyError(f"No step named {step_name!r}.") + + @property + def current_store(self) -> ParameterStore: + """The store after the last accepted step.""" + store = self.initial_store + for step in self.steps: + store = self.posteriors.get(step.name, store) + return store + + def accept(self, step_name: str, posterior: ParameterStore) -> None: + """Record `posterior` as the accepted result of `step_name`.""" + self.posteriors[step_name] = posterior + self.notify() + + def to_dict(self) -> Dict[str, Any]: + """Return the JSON form.""" + return { + "description": self.description, + "initial_store": self.initial_store.to_dict(), + "steps": [step.to_dict() for step in self.steps], + "comparisons": [comparison.to_dict() for comparison in self.comparisons], + "components": [{"name": c.name, "role": c.role} for c in self.components], + "posteriors": {name: store.to_dict() for name, store in self.posteriors.items()}, + } + + @classmethod + def from_dict(cls, raw: Dict[str, Any], base_dir: "Path | str | None" = None) -> "Study": + """Rebuild from `to_dict`'s form; with `base_dir`, comparisons load their data files.""" + comparisons = [Comparison.from_dict(c, base_dir=base_dir) for c in raw["comparisons"]] + by_name = {c.name: c for c in comparisons} + return cls( + initial_store=ParameterStore.from_dict(raw["initial_store"]), + comparisons=comparisons, + steps=[StepSetup.from_dict(s, by_name) for s in raw.get("steps", [])], + posteriors={ + name: ParameterStore.from_dict(store) + for name, store in (raw.get("posteriors") or {}).items() + }, + description=raw.get("description", ""), + base_dir=Path(base_dir) if base_dir is not None else None, + components=[StudyComponent(c["name"], c["role"]) for c in raw.get("components", [])], + ) + + @classmethod + def load(cls, path: "Path | str") -> "Study": + """Read a study file; data files resolve relative to it.""" + path = Path(path) + return cls.from_dict(json.loads(path.read_text()), base_dir=path.parent) + + def save(self, path: "Path | str") -> Path: + """Write the study to `path` as JSON, with every comparison's data next to it. + + See `place_data_files`; afterwards `base_dir` is `path`'s folder. + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + changed = self.place_data_files(path.parent) + path.write_text(json.dumps(self.to_dict(), indent=2) + "\n") + moved = self.base_dir is None or Path(self.base_dir).resolve() != path.parent.resolve() + self.base_dir = path.parent + if changed or moved: + self.notify() + return path + + def place_data_files(self, folder: "Path | str") -> List[str]: + """Make every comparison's `data_file` resolve inside `folder`. + + A file found under `base_dir` is copied; a comparison with only a loaded `run` + gets that run written with `write_experimental_csv`. An existing different file + in `folder` is never overwritten: the data goes to a fresh name instead and + `data_file` is rewritten. Comparisons sharing a source share the placed file. + Returns the names of comparisons whose `data_file` changed. + """ + folder = Path(folder) + placed: Dict[Any, str] = {} + changed: List[str] = [] + for comparison in self.comparisons: + source = self._source_file(comparison) + if source is not None: + key: Any = ("file", source.resolve()) + elif comparison.run is not None: + key = ("run", id(comparison.run)) + else: + continue + if key not in placed: + content = ( + source.read_bytes() if source is not None + else write_experimental_csv(comparison.run) + ) + placed[key] = _place(folder, comparison.data_file or comparison.name, content) + if comparison.data_file != placed[key]: + comparison.data_file = placed[key] + changed.append(comparison.name) + return changed + + def _source_file(self, comparison: Comparison) -> Optional[Path]: + if self.base_dir is None or not comparison.data_file: + return None + source = Path(self.base_dir) / comparison.data_file + return source if source.is_file() else None + + +def _place(folder: Path, name: str, content: bytes) -> str: + """Write `content` under `folder` as `name` (or a free variant); return the relative name.""" + relative = Path(name) + if relative.is_absolute() or ".." in relative.parts or not relative.name: + relative = Path(relative.name or "data") + stem = re.sub(r"[^\w.-]+", "_", relative.stem).strip("_") or "data" + parent = relative.parent + candidate = parent / f"{stem}.csv" if relative.suffix.lower() != ".csv" else relative + i = 2 + while True: + target = folder / candidate + if not target.exists(): + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + return candidate.as_posix() + if target.is_file() and target.read_bytes() == content: + return candidate.as_posix() + candidate = parent / f"{stem}_{i}.csv" + i += 1 diff --git a/cadetgui/io/__init__.py b/cadetgui/io/__init__.py new file mode 100644 index 0000000..08c4dff --- /dev/null +++ b/cadetgui/io/__init__.py @@ -0,0 +1,40 @@ +"""File I/O: measured data, saved configurations and simulation runs. + +Names below load lazily from their modules, so importing one module of this +package does not import the others. +""" +from importlib import import_module +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .configuration_store import ConfigurationState, InstrumentState + from .experimental_data import ( + ExperimentalRun, + read_experimental_csv, + write_experimental_csv, + ) + from .run_store import RunRecordState + +_EXPORTS = { + "ConfigurationState": "configuration_store", + "InstrumentState": "configuration_store", + "ExperimentalRun": "experimental_data", + "read_experimental_csv": "experimental_data", + "write_experimental_csv": "experimental_data", + "RunRecordState": "run_store", +} + +__all__ = [ + "ConfigurationState", + "InstrumentState", + "ExperimentalRun", + "read_experimental_csv", + "write_experimental_csv", + "RunRecordState", +] + + +def __getattr__(name: str) -> Any: + if name in _EXPORTS: + return getattr(import_module(f".{_EXPORTS[name]}", __name__), name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/cadetgui/_record_store.py b/cadetgui/io/_record_store.py similarity index 100% rename from cadetgui/_record_store.py rename to cadetgui/io/_record_store.py diff --git a/cadetgui/configuration_store.py b/cadetgui/io/configuration_store.py similarity index 92% rename from cadetgui/configuration_store.py rename to cadetgui/io/configuration_store.py index f5acd7e..6f07b0a 100644 --- a/cadetgui/configuration_store.py +++ b/cadetgui/io/configuration_store.py @@ -8,9 +8,10 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +import h5py from cadet import H5 -from . import __version__ as _cadetgui_version +from .. import __version__ as _cadetgui_version from ._record_store import user_store_dir __all__ = [ @@ -135,9 +136,9 @@ def save_h5( h5 = H5() if process is not None: try: - from CADETProcess.simulator import Cadet + from ..simulation import Simulator - h5.root.update(Cadet().get_process_config(process)) + h5.root.update(Simulator().get_process_config(process)) except Exception: # noqa: BLE001 pass @@ -244,9 +245,17 @@ def list_store(*, store_dir: Optional[Path] = None) -> List[Tuple[str, str]]: paths = sorted(store_dir.glob("*/config_*.h5"), key=lambda p: p.stat().st_mtime, reverse=True) entries = [] for path in paths: - try: - name, _ = load_h5(path) - except Exception: # noqa: BLE001 - continue - entries.append((name, path.stem.removeprefix("config_"))) + name = _read_name(path) + if name is not None: + entries.append((name, path.stem.removeprefix("config_"))) return entries + + +def _read_name(path: Path) -> Optional[str]: + """Return the saved configuration name in `path`, or None if it isn't a saved configuration.""" + try: + with h5py.File(path, "r") as handle: + value = handle["cadetgui/name"][()] + except Exception: # noqa: BLE001 + return None + return value.decode("utf-8") if isinstance(value, bytes) else str(value) diff --git a/cadetgui/io/experimental_data.py b/cadetgui/io/experimental_data.py new file mode 100644 index 0000000..20378be --- /dev/null +++ b/cadetgui/io/experimental_data.py @@ -0,0 +1,408 @@ +"""Import experimental chromatography runs: generic header CSVs and Äkta/Unicorn exports. + +Headless (no ipywidgets); `cadetgui/widgets/composite/data_import.py` is the UI over this. +""" + +from __future__ import annotations + +import csv +import io +import re +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Sequence, Tuple + +import numpy as np + +if TYPE_CHECKING: + from CADETProcess.reference import ReferenceIO + +__all__ = [ + "Channel", + "ExperimentalRun", + "ExperimentalDataset", + "ColumnRole", + "XBasis", + "detect_column_roles", + "is_akta_format", + "read_headers", + "read_generic_csv", + "read_akta_csv", + "read_experimental_csv", + "write_experimental_csv", + "to_time", + "to_reference", +] + +XBasis = Literal["time", "volume"] +ColumnRole = Literal["time_s", "time_min", "volume_ml", "signal", "skip"] + +_AKTA_HEADER_ROWS = 3 + + +@dataclass +class Channel: + """One signal trace: `x` (time or volume) paired with `values`, both 1D.""" + + name: str + x: np.ndarray + values: np.ndarray + unit: Optional[str] = None + + +@dataclass +class ExperimentalRun: + """One imported run: named channels sharing an x-basis, plus text event markers.""" + + label: str + channels: Dict[str, Channel] + x_basis: XBasis + x_unit: str + markers: List[Tuple[float, str]] = field(default_factory=list) + + +@dataclass +class ExperimentalDataset: + """One imported signal, time already normalized to minutes. + + Kept for backward compatibility with widgets that only need `label`/`time_min`/ + `signal`; `run`/`channel` identify the source `ExperimentalRun`/`Channel` when set. + """ + + label: str + time_min: np.ndarray + signal: np.ndarray + run: Optional[ExperimentalRun] = None + channel: Optional[str] = None + + +def _decode(content: bytes) -> str: + """Decode a CSV export, handling Unicorn's utf-16 (BOM) and latin-1 exports.""" + if content.startswith((b"\xff\xfe", b"\xfe\xff")): + return content.decode("utf-16") + try: + return content.decode("utf-8") + except UnicodeDecodeError: + return content.decode("latin-1") + + +_DECIMAL_COMMA = re.compile(r"^\s*[-+]?\d*,\d+(?:[eE][-+]?\d+)?\s*$") + + +def _rows(content: bytes) -> List[List[str]]: + """Split a delimited text export into rows of cells. + + The delimiter is a semicolon or a tab when most of the last lines have one, else a + comma; with semicolons or tabs a decimal comma (`1,5`) is read as a point. + """ + text = _decode(content) + tail = [line for line in text.splitlines() if line.strip()][-50:] + delimiter = next( + (d for d in (";", "\t") if 2 * sum(d in line for line in tail) > len(tail)), "," + ) + rows = list(csv.reader(io.StringIO(text), delimiter=delimiter)) + if delimiter != ",": + rows = [[c.replace(",", ".") if _DECIMAL_COMMA.match(c) else c for c in row] + for row in rows] + return rows + + +def _is_data_row(row: Sequence[str]) -> bool: + cells = [c for c in row if c.strip()] + numbers = sum(_is_number(c) for c in cells) + return numbers >= 2 and 2 * numbers >= len(cells) + + +def _table(rows: List[List[str]]) -> Tuple[List[str], List[List[str]]]: + """Return the header row and the data rows, skipping metadata lines above the header.""" + start = next((i for i, row in enumerate(rows) if _is_data_row(row)), None) + if start is None or start == 0: + raise ValueError("No header row followed by numeric data found.") + return rows[start - 1], rows[start:] + + +def _is_number(cell: str) -> bool: + cell = cell.strip() + if cell == "": + return True + try: + float(cell) + return True + except ValueError: + return False + + +def detect_column_roles(headers: Sequence[str]) -> List[ColumnRole]: + """Guess a role per column from its header text; unmatched columns become signal. + + Mirrors gosilizwo's `guessColumnRoles`: a header naming volume (`volume`, `ml`, + `m3`/`m³`) is preferred as the x-axis; otherwise a header naming `time` is used, in + `min` when the header says so and `s` otherwise, falling back to the first column. + """ + lower = [h.lower() for h in headers] + + def _is_volume(h: str) -> bool: + return any(k in h for k in ("volume", "m3", "m³", "ml")) + + volume_index = next((i for i, h in enumerate(lower) if _is_volume(h)), None) + if volume_index is not None: + x_index: int = volume_index + x_role: ColumnRole = "volume_ml" + else: + time_index = next((i for i, h in enumerate(lower) if "time" in h), 0) + x_index = time_index + x_role = "time_min" if "min" in lower[time_index] else "time_s" + + roles: List[ColumnRole] = ["signal"] * len(headers) + roles[x_index] = x_role + return roles + + +def is_akta_format(content: bytes) -> bool: + """Detect an Äkta/Unicorn export (3 header rows, channel names). + + Distinguishing signal: a generic CSV's second line is a data row (all-numeric); an + Äkta export's second line is the channel-name row, which is not. + """ + rows = _rows(content) + if len(rows) < _AKTA_HEADER_ROWS + 1: + return False + name_row, unit_row = rows[1], rows[2] + return ( + len(name_row) >= 2 and any(not _is_number(cell) for cell in name_row) + and bool(unit_row) and unit_row[0].strip().lower() in ("ml", "min") + and _is_number(rows[3][0] if rows[3] else "x") + ) + + +def read_headers(content: bytes) -> List[str]: + """Return the header row of a CSV (the row above the first numeric row), or [].""" + try: + return _table(_rows(content))[0] + except ValueError: + return [] + + +def read_generic_csv( + content: bytes, + label: str, + roles: Optional[Sequence[ColumnRole]] = None, +) -> ExperimentalRun: + """Parse a header-row CSV (one x column + N signal columns) into an `ExperimentalRun`. + + Comma, semicolon or tab separated; decimal commas and metadata lines above the + header row are handled (see `_rows`, `_table`). + + `roles` assigns exactly one x column (`"time_s"`/`"time_min"`/`"volume_ml"`) and any + number of `"signal"`/`"skip"` columns, one entry per CSV column; defaults to + `detect_column_roles(headers)`. + """ + rows = _rows(content) + if not rows: + raise ValueError("Empty CSV.") + headers, data = _table(rows) + if roles is None: + roles = detect_column_roles(headers) + if len(roles) != len(headers): + raise ValueError("roles must have one entry per column.") + + columns: List[List[float]] = [[] for _ in headers] + for row in data: + if len(row) < len(headers): + continue + try: + values = [float(v) for v in row[: len(headers)]] + except ValueError: + continue + for i, v in enumerate(values): + columns[i].append(v) + + arrays = [np.array(col) for col in columns] + + x_indices = [i for i, r in enumerate(roles) if r in ("time_s", "time_min", "volume_ml")] + if len(x_indices) != 1: + raise ValueError("Exactly one column must be assigned time or volume.") + x_index = x_indices[0] + x_role = roles[x_index] + + if x_role == "volume_ml": + x_basis: XBasis = "volume" + x_unit = "mL" + x = arrays[x_index] + else: + x_basis = "time" + x_unit = "s" + x = arrays[x_index] * 60.0 if x_role == "time_min" else arrays[x_index] + + channels = { + (headers[i].strip() or f"column {i}"): Channel( + name=headers[i].strip() or f"column {i}", x=x, values=arrays[i] + ) + for i, r in enumerate(roles) + if r == "signal" + } + if not channels: + raise ValueError("At least one signal column is required.") + + return ExperimentalRun(label=label, channels=channels, x_basis=x_basis, x_unit=x_unit) + + +def _to_float_array(values: Sequence[str]) -> np.ndarray: + out = np.full(len(values), np.nan) + for i, v in enumerate(values): + try: + out[i] = float(v) + except ValueError: + pass + return out + + +def _text_markers(volume: np.ndarray, text: Sequence[str], prefix: str) -> List[Tuple[float, str]]: + markers = [] + for v, t in zip(volume, text): + t = t.strip() + if np.isnan(v) or not t.startswith(prefix) or t == prefix: + continue + markers.append((float(v), t)) + return markers + + +def read_akta_csv(content: bytes, label: str) -> ExperimentalRun: + """Parse an Äkta/Unicorn export. + + 3 header rows, each channel an independent `(volume, value)` column pair. Text + channels (`Run Log`, `Fraction`) become markers instead of `Channel`s; `Run Log` + keeps only entries starting with `"Phase "`. + """ + rows = _rows(content) + if len(rows) <= _AKTA_HEADER_ROWS: + raise ValueError("Not an Äkta-style export.") + + name_row = rows[1] + names = [name_row[i].strip() for i in range(0, len(name_row), 2) if name_row[i].strip()] + + data_rows = rows[_AKTA_HEADER_ROWS:] + n_cols = 2 * len(names) + columns: List[List[str]] = [[] for _ in range(n_cols)] + for row in data_rows: + for i in range(n_cols): + columns[i].append(row[i] if i < len(row) else "") + + channels: Dict[str, Channel] = {} + markers: List[Tuple[float, str]] = [] + for k, name in enumerate(names): + volume = _to_float_array(columns[2 * k]) + text_col = columns[2 * k + 1] + if name == "Run Log": + markers.extend(_text_markers(volume, text_col, "Phase ")) + continue + if name == "Fraction": + markers.extend(_text_markers(volume, text_col, "")) + continue + + values = _to_float_array(text_col) + valid = ~(np.isnan(volume) | np.isnan(values)) + v, s = volume[valid], values[valid] + order = np.argsort(v) + channels[name] = Channel(name=name, x=v[order], values=s[order]) + + if not channels: + raise ValueError("No numeric channels found.") + + markers.sort(key=lambda m: m[0]) + return ExperimentalRun( + label=label, channels=channels, x_basis="volume", x_unit="mL", markers=markers + ) + + +def read_experimental_csv(content: bytes, label: str) -> ExperimentalRun: + """Auto-detect and parse an Äkta/Unicorn export or a generic header CSV.""" + if is_akta_format(content): + return read_akta_csv(content, label) + return read_generic_csv(content, label) + + +def _generic_headers(run: ExperimentalRun) -> Optional[List[str]]: + """Header row for the generic format, or None if `run` can't round-trip through it.""" + if run.markers: + return None + channels = list(run.channels.values()) + x = channels[0].x + if any(ch.x.shape != x.shape or not np.array_equal(ch.x, x) for ch in channels[1:]): + return None + x_header = "Volume (mL)" if run.x_basis == "volume" else "Time (s)" + headers = [x_header, *run.channels] + expected = ["volume_ml" if run.x_basis == "volume" else "time_s"] + ["signal"] * len(channels) + if detect_column_roles(headers) != expected or any(not name.strip() for name in run.channels): + return None + return headers + + +def write_experimental_csv(run: ExperimentalRun) -> bytes: + """Serialize `run` so `read_experimental_csv` reads back its channels, x basis and markers. + + Writes the generic one-header-row format when every channel shares one x axis and + there are no markers; otherwise an Äkta-style export (volume axis only), with markers + starting with `"Phase "` in a `Run Log` column and the rest in a `Fraction` column. + """ + if not run.channels: + raise ValueError("A run needs at least one channel.") + out = io.StringIO() + writer = csv.writer(out, lineterminator="\n") + headers = _generic_headers(run) + if headers is not None: + channels = list(run.channels.values()) + writer.writerow(headers) + for i, x in enumerate(channels[0].x): + writer.writerow([repr(float(x)), *(repr(float(ch.values[i])) for ch in channels)]) + return out.getvalue().encode("utf-8") + + if run.x_basis != "volume": + raise ValueError( + "Only volume-axis runs can be written with markers or per-channel x axes." + ) + reserved = {"Run Log", "Fraction"} & set(run.channels) + if reserved: + raise ValueError(f"Channel name(s) {sorted(reserved)} are reserved for markers.") + phases = [(x, t) for x, t in run.markers if t.startswith("Phase ")] + others = [(x, t) for x, t in run.markers if not t.startswith("Phase ")] + columns: List[Tuple[str, str, List[Tuple[str, str]]]] = [ + (name, ch.unit or "", [(repr(float(x)), repr(float(v))) for x, v in zip(ch.x, ch.values)]) + for name, ch in run.channels.items() + ] + for name, markers in (("Run Log", phases), ("Fraction", others)): + if markers: + columns.append((name, "", [(repr(float(x)), t) for x, t in markers])) + + writer.writerow([run.label, ""] * len(columns)) + writer.writerow([cell for name, _, _ in columns for cell in (name, "")]) + writer.writerow([cell for _, unit, _ in columns for cell in (run.x_unit, unit)]) + n_rows = max(len(values) for _, _, values in columns) + for i in range(n_rows): + row: List[str] = [] + for _, _, values in columns: + row.extend(values[i] if i < len(values) else ("", "")) + writer.writerow(row) + return out.getvalue().encode("utf-8") + + +def to_time( + run: ExperimentalRun, channel: str, flow_rate: Optional[float] = None +) -> Tuple[np.ndarray, np.ndarray]: + """Return `(time_s, values)`; volume axes need a constant `flow_rate` (m^3/s).""" + ch = run.channels[channel] + if run.x_basis == "time": + return ch.x, ch.values + if flow_rate is None: + raise ValueError("flow_rate is required to convert a volume-basis run to time.") + time_s = ch.x * 1e-6 / flow_rate + return time_s, ch.values + + +def to_reference( + run: ExperimentalRun, channel: str, flow_rate: Optional[float] = None +) -> ReferenceIO: + """Build a `CADETProcess.reference.ReferenceIO` for one channel.""" + from CADETProcess.reference import ReferenceIO + + time_s, values = to_time(run, channel, flow_rate) + return ReferenceIO(f"{run.label}: {channel}", time_s, values, flow_rate=flow_rate) diff --git a/cadetgui/run_store.py b/cadetgui/io/run_store.py similarity index 98% rename from cadetgui/run_store.py rename to cadetgui/io/run_store.py index 9d42a53..a059c8e 100644 --- a/cadetgui/run_store.py +++ b/cadetgui/io/run_store.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any, List, Optional -from . import __version__ as _cadetgui_version +from .. import __version__ as _cadetgui_version from ._record_store import ( delete_record, list_records, diff --git a/cadetgui/optimizer_runner.py b/cadetgui/optimizer_runner.py index a7ad2ff..2542dfe 100644 --- a/cadetgui/optimizer_runner.py +++ b/cadetgui/optimizer_runner.py @@ -11,6 +11,7 @@ "OptimizerSpec", "OPTIMIZERS", "OptimizationCancelled", + "pareto_front", "install_cancel_hook", "OptimizerRunResult", "run_optimization", @@ -88,6 +89,22 @@ class OptimizerRunResult: message: str cancelled: bool = False optimizer_name: str = "" + # Final front: independent-variable rows (`problem.independent_variable_names`) and + # their objective rows; after a cancel, the front as of the last full generation. + front_x: tuple[tuple[float, ...], ...] = () + front_f: tuple[tuple[float, ...], ...] = () + + +def pareto_front(optimizer: Any) -> tuple[tuple, tuple]: + """Return `(front_x, front_f)` from `optimizer.results`, empty if no front exists yet.""" + results = getattr(optimizer, "results", None) + if results is None or not results.pareto_fronts: + return (), () + front = results.meta_front + return ( + tuple(tuple(float(v) for v in row) for row in front.x_independent), + tuple(tuple(float(v) for v in row) for row in front.f), + ) def run_optimization( @@ -116,20 +133,22 @@ def run_optimization( try: results = optimizer.optimize(problem, x0=list(x0), save_results=False) except OptimizationCancelled: + front_x, front_f = pareto_front(optimizer) return OptimizerRunResult( {}, None, False, "Optimization cancelled by user.", - cancelled=True, optimizer_name=optimizer_name, + cancelled=True, optimizer_name=optimizer_name, front_x=front_x, front_f=front_f, ) except Exception as exc: # noqa: BLE001 return OptimizerRunResult({}, None, False, str(exc), optimizer_name=optimizer_name) - # `results.x[0]` holds one value per `problem.variable_names`; problems with - # variable dependencies are not supported. - problem.set_variables(results.x[0]) + # `results.x[0]` holds one value per `problem.variable_names` (dependent ones too); + # `set_variables` takes the independent ones only. + front_x, front_f = pareto_front(optimizer) + problem.set_variables(front_x[0]) x_best = dict(zip(problem.variable_names, (float(v) for v in results.x[0]))) return OptimizerRunResult( x_best, float(results.f_best[0]), True, "Optimization finished.", - optimizer_name=optimizer_name, + optimizer_name=optimizer_name, front_x=front_x, front_f=front_f, ) diff --git a/cadetgui/parameter_estimation.py b/cadetgui/parameter_estimation.py index ee262fe..cdbe2c9 100644 --- a/cadetgui/parameter_estimation.py +++ b/cadetgui/parameter_estimation.py @@ -13,11 +13,10 @@ from CADETProcess.optimization import OptimizationProblem from CADETProcess.processModel import ComponentSystem from CADETProcess.reference import ReferenceIO -from CADETProcess.simulator import Cadet from .cadetprocessadapter import FieldSpec from .optimizer_runner import run_optimization -from .simulation import run_process +from .simulation import Simulator, run_process __all__ = [ "FittableParameter", @@ -220,7 +219,7 @@ def build_estimation_problem( problem.add_evaluation_object(working_process) _register_variables(problem, working_process, working_column, params, selected_indices) - simulator = Cadet() + simulator = Simulator() problem.add_evaluator(simulator) problem.add_objective(comparator, n_objectives=comparator.n_metrics, requires=[simulator]) diff --git a/cadetgui/parameters/starting_values.json b/cadetgui/parameters/starting_values.json new file mode 100644 index 0000000..cf85328 --- /dev/null +++ b/cadetgui/parameters/starting_values.json @@ -0,0 +1,47 @@ +{ + "description": "Standard starting values that make a freshly configured model simulate: synthetic, resin-independent guesses (lysozyme-like SMA values from the CADET tutorials), not measured values or bounds. Replace them with your own. Binding values only fill parameters that are missing or zero. 'methods' holds, per process template, the method concentrations that suit the binding model ('salt' for the first component, 'other' for every further one) and plain values; they replace the template's defaults, and the column then starts equilibrated with the method's starting salt. Times assume the lab-scale defaults (1 mL column, 1 mL/min).", + "binding": { + "Steric Mass Action (SMA)": { + "salt_first": true, + "scalar": { + "is_kinetic": false, + "capacity": 1200.0 + }, + "per_component": { + "adsorption_rate": 35.5, + "desorption_rate": 1000.0, + "characteristic_charge": 4.7, + "steric_factor": 11.83 + }, + "methods": { + "Load–Wash–Elute (LWE)": { + "note": "Load and wash at 50 mM salt, linear gradient to 1000 mM.", + "c_buffer_a": {"salt": 50.0, "other": 0.0}, + "c_buffer_b": {"salt": 1000.0, "other": 0.0}, + "c_sample": {"salt": 50.0, "other": 1.0} + }, + "Step Elution": { + "note": "Load and wash at 50 mM salt, step to 1000 mM.", + "c_buffer_a": {"salt": 50.0, "other": 0.0}, + "c_buffer_b": {"salt": 1000.0, "other": 0.0}, + "c_sample": {"salt": 50.0, "other": 1.0} + }, + "Pulse Injection": { + "note": "Isocratic at 400 mM salt, where the protein elutes after a few column volumes (at 50 mM it would stay bound).", + "c_buffer_a": {"salt": 400.0, "other": 0.0}, + "c_sample": {"salt": 400.0, "other": 1.0} + }, + "Step": { + "note": "Salt step from 50 to 1000 mM, no protein.", + "c_buffer_a": {"salt": 50.0, "other": 0.0}, + "c_buffer_b": {"salt": 1000.0, "other": 0.0} + }, + "Breakthrough": { + "note": "1 mol/m³ protein at 50 mM salt breaks through at about 15 column volumes; the run lasts about 30.", + "c_sample": {"salt": 50.0, "other": 1.0}, + "cycle_time": 1800.0 + } + } + } + } +} diff --git a/cadetgui/process_builder.py b/cadetgui/process_builder.py new file mode 100644 index 0000000..e965c7c --- /dev/null +++ b/cadetgui/process_builder.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import json +from collections import OrderedDict +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, List, Mapping, Optional, Tuple + +from CADETProcess.instruments import LCFlowSheet +from CADETProcess.processModel import ComponentSystem, Process + +from .cadetprocessadapter import ( + BINDING_MODELS, + COLUMN_MODELS, + CONFIGURABLE_UNITS, + INSTRUMENT_TEMPLATES, + STANDALONE_TEMPLATES, + UNIT_SEED_DEFAULTS, + FieldSpec, + ModelSpec, + build_parameter_config_spec, + measurable_signal_options, +) +from .characterization.parameter_store import has_parameter +from .io.configuration_store import ConfigurationState + +__all__ = [ + "build_process", + "RecipeCheck", + "check_recipe", + "clear_recipe_checks", + "recipe_key", +] + + +def _field_default(f: FieldSpec) -> Any: + """Coerce a field's spec default to the type its rendered element would hold. + + Mirrors `FormRenderer`'s `_coerced_default` for a field absent from the saved values. + """ + if f.kind == "float": + return float(f.default) if f.default is not None else 0.0 + if f.kind == "float_list": + return list(f.default) if f.default else [0.0] + if f.kind == "bool": + return bool(f.default) + if f.kind == "choice": + return f.default + return str(f.default) if f.default is not None else "" + + +def _coerce_value(kind: str, raw: Any) -> Any: + """Coerce a raw stored value to the type its element's value trait would hold.""" + if kind == "float": + return float(raw) + if kind == "float_list": + return [float(v) for v in raw] + if kind == "bool": + return bool(raw) + if kind == "text": + return str(raw) if raw is not None else "" + return raw + + +def _apply_spec(spec: ModelSpec, values: Mapping[str, Any]) -> Any: + """Build `spec` from `values`, falling back to each field's default. + + Mirrors `FormRenderer.set_values()` followed by its auto-commit: every field is + set, from `values` if present else its default, then `spec.build` is called once. + """ + if spec.build is None: + raise RuntimeError(f"{spec.title!r} has no 'build' function.") + final: Dict[str, Any] = {} + for f in spec.fields: + raw = values[f.name] if f.name in values else _field_default(f) + coerced = _coerce_value(f.kind, raw) + final[f.name] = f.transform(coerced) if f.transform else coerced + return spec.build(final) + + +def _seed_unit_values( + flow_sheet: LCFlowSheet, bypass: List[str], unit_values: Mapping[str, Mapping[str, float]] +) -> None: + """Seed mixer/tubing dead-volume parameters, mirroring `InstrumentWidget`'s seeding. + + A bypassed unit is skipped: `LCFlowSheet` never removes the mixer but forces its own + near-zero bypass volume, which a seeded value would silently undo. + """ + for name in CONFIGURABLE_UNITS: + if name in bypass or name not in flow_sheet: + continue + unit = flow_sheet[name] + for pname, value in (unit_values.get(name) or UNIT_SEED_DEFAULTS[name]).items(): + setattr(unit, pname, value) + + +def _apply_column_and_binding( + column: Any, state: ConfigurationState, binding_model: Optional[Any] +) -> None: + column_spec = build_parameter_config_spec( + column, multiplex=state.multiplex_state, include_optional=state.show_optional_column + ) + _apply_spec(column_spec, state.column_values) + + if binding_model is None: + return + binding_spec = build_parameter_config_spec( + binding_model, include_optional=state.show_optional_binding + ) + column.binding_model = _apply_spec(binding_spec, state.binding_values) + + +def _build_instrument_process( + state: ConfigurationState, overrides: Optional[Mapping[str, Any]] +) -> Process: + instrument = state.instrument + assert instrument is not None + + column_cls = COLUMN_MODELS.get(state.column_key) + if column_cls is None: + raise ValueError(f"Unknown column model {state.column_key!r}.") + if state.binding_key not in BINDING_MODELS: + raise ValueError(f"Unknown binding model {state.binding_key!r}.") + binding_cls = BINDING_MODELS[state.binding_key] + template_factory = INSTRUMENT_TEMPLATES.get(state.template_key) + if template_factory is None: + raise ValueError(f"Unknown process template {state.template_key!r}.") + + component_system = ComponentSystem(list(state.components)) + bypass = list(instrument.bypass_units) + include_loop = bool(instrument.include_sample_loop) + flow_sheet = LCFlowSheet( + component_system, + sample_loop_volume=float(instrument.sample_loop_volume) if include_loop else None, + sample_loop_diameter=( + float(instrument.sample_loop_diameter) + if include_loop and not instrument.sample_loop_diameter_auto + else None + ), + ColumnModel=None if "column" in bypass else column_cls, + BindingModel=binding_cls, + bypass_units=bypass or None, + ) + + _seed_unit_values(flow_sheet, bypass, instrument.unit_values) + + if "column" not in bypass: + column = flow_sheet.column + _apply_column_and_binding(column, state, getattr(column, "binding_model", None)) + + model_spec = template_factory(flow_sheet) + model_values = {**state.model_values, **(overrides or {})} + return _apply_spec(model_spec, model_values) + + +def _build_standalone_process( + state: ConfigurationState, overrides: Optional[Mapping[str, Any]] +) -> Process: + column_cls = COLUMN_MODELS.get(state.column_key) + if column_cls is None: + raise ValueError(f"Unknown column model {state.column_key!r}.") + if state.binding_key not in BINDING_MODELS: + raise ValueError(f"Unknown binding model {state.binding_key!r}.") + binding_cls = BINDING_MODELS[state.binding_key] + template_factory = STANDALONE_TEMPLATES.get(state.template_key) + if template_factory is None: + raise ValueError(f"Unknown process template {state.template_key!r}.") + + component_system = ComponentSystem(list(state.components)) + column = column_cls(component_system, name="column") + + binding_model = ( + binding_cls(column.component_system, name="binding_model") + if binding_cls is not None + else None + ) + _apply_column_and_binding(column, state, binding_model) + + model_spec = template_factory(column) + model_values = {**state.model_values, **(overrides or {})} + return _apply_spec(model_spec, model_values) + + +def build_process( + state: ConfigurationState, *, overrides: Optional[Mapping[str, Any]] = None +) -> Process: + """Rebuild the `Process` a saved `ConfigurationState` describes, without any widget. + + Headless equivalent of constructing `InstrumentWidget` + `ConfigurationWidget` (or a + bare `ConfigurationWidget` when `state.instrument` is `None`) and calling their + `_apply_state`: the same registries (`INSTRUMENT_TEMPLATES`/`STANDALONE_TEMPLATES`, + `COLUMN_MODELS`, `BINDING_MODELS`), the same `LCFlowSheet` construction (sample loop, + bypass units, mixer/tubing dead-volume seeding) and the same column/binding/model + parameter application order. + + `overrides` replaces entries in `state.model_values` (e.g. flow rate, phase + durations) for the process template only; column, binding and instrument values are + always taken from `state`, so a comparison can vary the run's timing/flow off a + saved recipe without saving a new configuration. + """ + if state.instrument is not None: + return _build_instrument_process(state, overrides) + return _build_standalone_process(state, overrides) + + +@dataclass(frozen=True) +class RecipeCheck: + """Read-only facts about the process a recipe builds, or why it cannot be built. + + `error` is set (and everything else empty) when `build_process` raised. + `process` is the cached built process; read it, never mutate it. + """ + + error: Optional[str] + units: Tuple[str, ...] = () + species: Tuple[str, ...] = () + signal_options: Tuple[Tuple[str, Tuple[str, str]], ...] = () + process: Optional[Process] = field(default=None, repr=False, compare=False) + + def has_parameter(self, path: str) -> bool: + """Return whether the built process carries the parameter at `path`.""" + return self.process is not None and has_parameter(self.process, path) + + +_CHECK_CACHE_SIZE = 64 +_check_cache: "OrderedDict[str, RecipeCheck]" = OrderedDict() + + +def recipe_key(state: ConfigurationState, overrides: Optional[Mapping[str, Any]] = None) -> str: + """Return a string that is equal for equal `(state, overrides)` contents.""" + return json.dumps([asdict(state), dict(overrides or {})], sort_keys=True, default=str) + + +def check_recipe( + state: ConfigurationState, overrides: Optional[Mapping[str, Any]] = None +) -> RecipeCheck: + """Return `RecipeCheck` for `build_process(state, overrides=overrides)`, memoized by content. + + The check is cached and shared, so callers must not mutate `check.process`. + """ + key = recipe_key(state, overrides) + check = _check_cache.get(key) + if check is not None: + _check_cache.move_to_end(key) + return check + try: + process = build_process(state, overrides=overrides) + except Exception as exc: # noqa: BLE001 + check = RecipeCheck(error=str(exc)) + else: + check = RecipeCheck( + error=None, + units=tuple(process.flow_sheet.units_dict), + species=tuple(process.component_system.species), + signal_options=tuple( + (label, tuple(target)) for label, target in measurable_signal_options(process) + ), + process=process, + ) + _check_cache[key] = check + while len(_check_cache) > _CHECK_CACHE_SIZE: + _check_cache.popitem(last=False) + return check + + +def clear_recipe_checks() -> None: + """Drop every memoized `RecipeCheck`.""" + _check_cache.clear() diff --git a/cadetgui/sensitivity.py b/cadetgui/sensitivity.py new file mode 100644 index 0000000..c960198 --- /dev/null +++ b/cadetgui/sensitivity.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import copy +from dataclasses import dataclass +from typing import Any, Mapping, Sequence + +import numpy as np + +from .simulation import run_process + +__all__ = ["SensitivityReport", "perturb_parameters", "response_matrix", "analyze_sensitivity"] + + +def _get_by_path(obj: Any, path: str) -> float: + for attr in path.split("."): + obj = getattr(obj, attr) + # CADET-Process may list-wrap a scalar (e.g. a column's `axial_dispersion`). + if isinstance(obj, (list, tuple)): + if len(obj) != 1: + raise ValueError(f"{path!r} is not a single scalar value: {obj!r}") + obj = obj[0] + return float(obj) + + +def _set_by_path(obj: Any, path: str, value: float) -> None: + *parents, leaf = path.split(".") + target = obj + for attr in parents: + target = getattr(target, attr) + current = getattr(target, leaf) + setattr(target, leaf, type(current)([value]) if isinstance(current, (list, tuple)) else value) + + +def perturb_parameters( + process: Any, + parameter_paths: Sequence[str], + *, + relative_step: float = 1e-3, +) -> dict[str, tuple[Any, Any, float, float]]: + """Simulate two deep copies of `process` per path, nudged by `+/- relative_step`. + + The step is relative to the current value, or absolute when that is zero. + Returns `{path: (result_minus, result_plus, value_minus, value_plus)}`; `process` + itself is never modified. + """ + results: dict[str, tuple[Any, Any, float, float]] = {} + for path in parameter_paths: + base_value = _get_by_path(process, path) + step = relative_step * base_value if base_value != 0 else relative_step + value_minus, value_plus = base_value - step, base_value + step + + process_minus = copy.deepcopy(process) + _set_by_path(process_minus, path, value_minus) + process_plus = copy.deepcopy(process) + _set_by_path(process_plus, path, value_plus) + + results[path] = ( + run_process(process_minus), run_process(process_plus), value_minus, value_plus + ) + return results + + +def response_matrix( + perturbed: Mapping[str, tuple[Any, Any, float, float]], + signal_path: tuple[str, str], +) -> np.ndarray: + """Stack each parameter's central-difference response into an `n_params x n_timepoints` matrix. + + Each row is `(solution_plus - solution_minus) / (value_plus - value_minus)` at + `signal_path` (a `(unit_name, port)` pair); all runs must share one time grid. + """ + unit, port = signal_path + rows = [] + for result_minus, result_plus, value_minus, value_plus in perturbed.values(): + solution_minus = result_minus.solution[unit][port].solution + solution_plus = result_plus.solution[unit][port].solution + row = (np.asarray(solution_plus) - np.asarray(solution_minus)) / (value_plus - value_minus) + rows.append(row.ravel()) + return np.stack(rows) + + +@dataclass(frozen=True) +class SensitivityReport: + """Local identifiability diagnostic over a parameter subset. + + `cosine_similarity[i, j]` near 1.0 means parameters i and j cannot be told apart + from this signal. A large `condition_number` means the set is poorly constrained + overall. `flagged_pairs` lists pairs at or above `similarity_threshold`, most + similar first. + """ + + parameter_names: list[str] + cosine_similarity: np.ndarray + condition_number: float + flagged_pairs: list[tuple[str, str, float]] + + +def _report_from_matrix( + parameter_names: Sequence[str], matrix: np.ndarray, *, similarity_threshold: float +) -> SensitivityReport: + """Build a `SensitivityReport` from a response matrix (testable without simulating).""" + norms = np.linalg.norm(matrix, axis=1, keepdims=True) + normalized = matrix / norms + similarity = normalized @ normalized.T + condition_number = float(np.linalg.cond(matrix)) + + flagged: list[tuple[str, str, float]] = [] + for i in range(len(parameter_names)): + for j in range(i + 1, len(parameter_names)): + score = float(similarity[i, j]) + if score >= similarity_threshold: + flagged.append((parameter_names[i], parameter_names[j], score)) + flagged.sort(key=lambda item: item[2], reverse=True) + + return SensitivityReport( + parameter_names=list(parameter_names), + cosine_similarity=similarity, + condition_number=condition_number, + flagged_pairs=flagged, + ) + + +def analyze_sensitivity( + process: Any, + parameter_paths: Sequence[str], + signal_path: tuple[str, str], + *, + relative_step: float = 1e-3, + similarity_threshold: float = 0.99, +) -> SensitivityReport: + """Flag parameter pairs whose local response at `process` is nearly indistinguishable. + + Meant to run before a joint fit over `parameter_paths` against `signal_path`. + """ + perturbed = perturb_parameters(process, parameter_paths, relative_step=relative_step) + matrix = response_matrix(perturbed, signal_path) + return _report_from_matrix(parameter_paths, matrix, similarity_threshold=similarity_threshold) diff --git a/cadetgui/simulation.py b/cadetgui/simulation.py index a12f2bd..8da4ae9 100644 --- a/cadetgui/simulation.py +++ b/cadetgui/simulation.py @@ -2,9 +2,35 @@ from typing import Any +import numpy as np from CADETProcess.simulator import Cadet +_MAX_STEP = 1.0 +_POINTS_PER_CYCLE = 5000 +_POINTS_PER_SECTION = 20 +_MAX_POINTS = 20000 + + +def output_time_step(process: Any) -> float: + """Return the output time step for `process`: finer for short cycles and sections.""" + sections = np.diff(process.section_times) + step = min( + _MAX_STEP, + process.cycle_time / _POINTS_PER_CYCLE, + sections[sections > 0].min() / _POINTS_PER_SECTION, + ) + return max(step, process.cycle_time / _MAX_POINTS) + + +class Simulator(Cadet): + """CADET simulator whose output grid follows `output_time_step` of each process.""" + + def get_solution_time(self, process: Any, cycle: int = 1) -> np.ndarray: + """Return the time vector for one cycle of `process` at its output time step.""" + self.time_resolution = output_time_step(process) + return super().get_solution_time(process, cycle) + def run_process(process: Any, **kwargs: Any) -> Any: """Simulate `process` with CADET-Core and return the SimulationResults.""" - return Cadet().simulate(process, **kwargs) + return Simulator().simulate(process, **kwargs) diff --git a/cadetgui/starting_values.py b/cadetgui/starting_values.py new file mode 100644 index 0000000..2eced29 --- /dev/null +++ b/cadetgui/starting_values.py @@ -0,0 +1,141 @@ +"""Shared standard starting values for model parameters and methods. Headless. + +`parameters/starting_values.json` lists, per binding model, `scalar` and `per_component` +values and, under `methods`, per process template the method values that suit it; +with `salt_first` the first component is the salt. Binding values only fill parameters +that are missing or zero; method values replace the template's defaults, and the column +then starts equilibrated with the method's starting salt. +""" + +from __future__ import annotations + +import json +from dataclasses import replace +from functools import lru_cache +from pathlib import Path +from typing import Any, Dict, Mapping, Optional, Sequence + +from .io.configuration_store import ConfigurationState + +__all__ = [ + "starting_values", + "binding_starting_values", + "method_starting_values", + "with_starting_values", +] + +_PATH = Path(__file__).parent / "parameters" / "starting_values.json" + + +@lru_cache(maxsize=1) +def starting_values() -> Dict[str, Any]: + """Return the parsed `starting_values.json`.""" + return json.loads(_PATH.read_text(encoding="utf-8")) + + +def _entry(binding_key: str) -> Optional[Dict[str, Any]]: + return starting_values()["binding"].get(binding_key) + + +def _is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _is_unset(value: Any) -> bool: + if isinstance(value, (list, tuple)): + return all(_is_number(v) and v == 0 for v in value) + return value is None or (_is_number(value) and value == 0) + + +def binding_starting_values( + binding_key: str, components: Sequence[str], values: Mapping[str, Any] +) -> Dict[str, Any]: + """Return `values` with the binding parameters that are missing or zero filled in. + + Switches such as `is_kinetic` are only set when no numeric binding value is set yet. + """ + start = _entry(binding_key) + out = dict(values) + if start is None: + return out + untouched = all(_is_unset(v) for v in values.values() if not isinstance(v, bool)) + for key, value in start.get("scalar", {}).items(): + if key not in out or (untouched if isinstance(value, bool) else _is_unset(out[key])): + out[key] = value + salt = 0 if start.get("salt_first") else None + for key, value in start.get("per_component", {}).items(): + current = out.get(key) + if not isinstance(current, (list, tuple)) or len(current) != len(components): + current = [0.0] * len(components) + out[key] = [ + 0.0 if i == salt else (c if not _is_unset(c) else value) + for i, c in enumerate(current) + ] + return out + + +def method_starting_values( + binding_key: str, template_key: str, components: Sequence[str], values: Mapping[str, Any] +) -> Optional[Dict[str, Any]]: + """Return `values` with the method values listed for `template_key`, or None if none are.""" + start = _entry(binding_key) + method = (start or {}).get("methods", {}).get(template_key) + if method is None: + return None + out = dict(values) + for key, value in method.items(): + if key == "note": + continue + if isinstance(value, dict): + out[key] = [value["salt"]] + [value["other"]] * (len(components) - 1) + else: + out[key] = value + return out + + +def _initial_salt(model_values: Mapping[str, Any]) -> float: + for key in ("c_buffer_a", "c_sample"): + values = model_values.get(key) + if isinstance(values, (list, tuple)) and values: + return float(values[0]) + return 0.0 + + +def with_starting_values( + recipe: ConfigurationState, *, method: bool = True, equilibrate: bool = True +) -> ConfigurationState: + """Return `recipe` with binding starting values and, if `method`, its method values. + + With a salt-first binding model and `equilibrate` the column starts equilibrated: + `c` and `cp` (ignored by models without pores) hold the starting salt (buffer A, else + the sample) and `q` the capacity, for the salt only. Method values reset that state; + without them it is only set while `c`, `cp` and `q` are unset. A salt-first model + with real values stalls the solver on any other start. + """ + start = _entry(recipe.binding_key) + if start is None: + return recipe + binding = binding_starting_values( + recipe.binding_key, recipe.components, recipe.binding_values + ) + model = ( + method_starting_values( + recipe.binding_key, recipe.template_key, recipe.components, recipe.model_values + ) if method else None + ) + column = dict(recipe.column_values) + show_optional = recipe.show_optional_column + unset = all(_is_unset(column.get(k)) for k in ("c", "cp", "q")) + if equilibrate and start.get("salt_first") and (model is not None or unset): + rest = [0.0] * (len(recipe.components) - 1) + salt = _initial_salt(model if model is not None else recipe.model_values) + column["c"] = column["cp"] = [salt, *rest] + column["q"] = [float(binding.get("capacity", 0.0)), *rest] + show_optional = True + return replace( + recipe, + binding_values=binding, + model_values=model if model is not None else recipe.model_values, + column_values=column, + show_optional_column=show_optional, + ) diff --git a/cadetgui/widgets/__init__.py b/cadetgui/widgets/__init__.py index b4c4672..a3cc824 100644 --- a/cadetgui/widgets/__init__.py +++ b/cadetgui/widgets/__init__.py @@ -3,28 +3,49 @@ This subpackage provides: - elements: atomic anywidget input controls - forms: FormRenderer, rendering a ModelSpec into elements -- composite: task widgets assembled from elements + forms (Configuration, - Solution, RunHistory, DataImport) +- composite: task widgets assembled from elements + forms -Public API re-exports the composite widgets, the most common entry point. -Import from `cadetgui.widgets.elements`/`.forms` directly for the lower layers. +At this level: the most common composite widgets, the kit for building a bench +(`SidebarShell`, `bench_root` and the step/pane helpers) and the HTML helpers +the built-in widgets use for status lines, glossary terms and info boxes. """ +from ._chrome import style_tag +from ._help import checklist_html, info_box_html, term_html +from ._status import StatusKind, status_html from .composite import ( ConfigurationWidget, - DataImportWidget, - ExperimentalDataset, ParameterEstimationWidget, RunHistoryWidget, RunRecord, SolutionWidget, ) +from .composite.bench import bench_root +from .shell import ( + SYSTEM_GROUP, + SidebarShell, + collect_panes, + resolve_step, + system_pane_label, + validate_steps, +) __all__ = [ "ConfigurationWidget", "SolutionWidget", "RunHistoryWidget", "RunRecord", - "DataImportWidget", - "ExperimentalDataset", "ParameterEstimationWidget", + "SidebarShell", + "bench_root", + "SYSTEM_GROUP", + "system_pane_label", + "resolve_step", + "validate_steps", + "collect_panes", + "StatusKind", + "status_html", + "term_html", + "info_box_html", + "checklist_html", + "style_tag", ] diff --git a/cadetgui/widgets/_help.py b/cadetgui/widgets/_help.py new file mode 100644 index 0000000..fbbbe1b --- /dev/null +++ b/cadetgui/widgets/_help.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import html +from typing import Mapping, Optional, Sequence + +from ..characterization.guide import ( + EXPERIMENT_TYPES, + GLOSSARY, + GLOSSARY_ALIASES, + Determined, +) + +__all__ = [ + "term_html", + "info_box_html", + "checklist_html", + "determines_table_html", + "experiments_table_html", +] + + +def term_html(term: str, label: Optional[str] = None) -> str: + """Return `label` (default `term`) with the glossary definition of `term` as a hover tip. + + Terms in `GLOSSARY_ALIASES` resolve to their current entry; unknown terms render as + plain escaped text. + """ + text = html.escape(label if label is not None else term, quote=False) + term = GLOSSARY_ALIASES.get(term, GLOSSARY_ALIASES.get(term.lower(), term)) + definition = GLOSSARY.get(term) or GLOSSARY.get(term.lower()) + if not definition: + return text + tip = html.escape(definition, quote=True) + return f"{text}" + + +def info_box_html(title: str, body_html: str, *, open: bool = False) -> str: + """Return a collapsible help box; `body_html` is inserted as is, `title` is escaped.""" + state = " open" if open else "" + return ( + f"
" + f"{html.escape(title, quote=False)}" + f"
{body_html}
" + ) + + +def checklist_html(items: Sequence[str]) -> str: + """Return `items` as an escaped bullet list.""" + rows = "".join(f"
  • {html.escape(item, quote=False)}
  • " for item in items) + return f"
      {rows}
    " + + +def _table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str: + head = "".join(f"{h}" for h in headers) + body = "".join("" + "".join(f"{c}" for c in row) + "" for row in rows) + return f"{head}{body}
    " + + +def determines_table_html(determines: Sequence[Determined]) -> str: + """Return a Parameter / Meaning / Unit table for a step's determined parameters.""" + rows = [ + ( + f"{html.escape(d.name, quote=False)}", + html.escape(d.meaning, quote=False), + f"{html.escape(d.unit, quote=False)}", + ) + for d in determines + ] + return _table(["Parameter", "Meaning", "Unit"], rows) + + +def experiments_table_html( + type_ids: Sequence[str], attached: Optional[Mapping[str, Sequence[str]]] = None +) -> str: + """Return an experiments table; with `attached`, add each type's measurements.""" + headers = ["Experiment", "Detector", "Probe", "What you run"] + if attached is not None: + headers.append("Measurements") + rows = [] + for type_id in type_ids: + experiment = EXPERIMENT_TYPES[type_id] + row = [ + f"{html.escape(experiment.label, quote=False)}", + html.escape(experiment.detector, quote=False), + html.escape(experiment.probe_role, quote=False), + f"{html.escape(experiment.what_you_run, quote=False)}", + ] + if attached is not None: + names = attached.get(type_id) or () + row.append( + html.escape(", ".join(names), quote=False) if names + else "none yet" + ) + rows.append(row) + return _table(headers, rows) diff --git a/cadetgui/widgets/chrome.css b/cadetgui/widgets/chrome.css index 239c31b..bf0bb8b 100644 --- a/cadetgui/widgets/chrome.css +++ b/cadetgui/widgets/chrome.css @@ -347,11 +347,12 @@ .cadetgui-panel input[type="number"], .cadetgui-panel input[type="text"], -.cadetgui-panel select { +.cadetgui-panel select, +.cadetgui-select select { min-height: 30px; border: 1px solid var(--cg-border-strong); border-radius: var(--cg-radius-sm); - background: var(--cg-bg); + background-color: var(--cg-bg); color: var(--cg-fg); font: inherit; font-variant-numeric: tabular-nums; @@ -359,9 +360,39 @@ transition: border-color 0.12s ease, box-shadow 0.12s ease; } +/* Single-choice dropdowns get a chevron and a hover outline like the element + dropdowns (.cadetgui-field-select). ipywidgets draws its arrow as a background + image, so the chevron is drawn here as two gradients in the theme's muted colour. */ +.cadetgui-panel select:not([multiple]):not([size]), +.cadetgui-select select:not([multiple]):not([size]) { + appearance: none; + -webkit-appearance: none; + padding-right: 28px; + background-image: + linear-gradient(45deg, transparent 50%, var(--cg-muted) 50%), + linear-gradient(135deg, var(--cg-muted) 50%, transparent 50%); + background-position: calc(100% - 15px) 50%, calc(100% - 10px) 50%; + background-size: 5px 5px; + background-repeat: no-repeat; + cursor: pointer; +} + +.cadetgui-panel select:hover:not(:disabled), +.cadetgui-select select:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--cg-fg) 30%, var(--cg-border-strong)); + background-color: color-mix(in srgb, var(--cg-fg) 4%, var(--cg-bg)); +} + +.cadetgui-panel select:disabled, +.cadetgui-select select:disabled { + cursor: not-allowed; + opacity: 0.7; +} + .cadetgui-panel input[type="number"]:focus, .cadetgui-panel input[type="text"]:focus, -.cadetgui-panel select:focus { +.cadetgui-panel select:focus, +.cadetgui-select select:focus { outline: none; border-color: var(--cg-focus); box-shadow: 0 0 0 3px color-mix(in srgb, var(--cg-focus) 30%, transparent); @@ -496,3 +527,114 @@ padding: var(--cg-space-4); background: var(--cg-page); } + +.cadetgui-term { + position: relative; + cursor: help; + text-decoration: underline dotted; + text-underline-offset: 2px; +} + +.cadetgui-term:hover::after, +.cadetgui-term:focus::after { + content: attr(data-tip); + position: absolute; + left: 0; + top: calc(100% + 4px); + z-index: 20; + width: max-content; + max-width: 320px; + padding: 6px 8px; + border-radius: var(--cg-radius-sm); + background: var(--cg-fg); + color: var(--cg-bg); + font-size: var(--cg-text-sm); + font-style: normal; + font-weight: 400; + line-height: var(--cg-leading); + white-space: normal; + box-shadow: var(--cg-shadow-pop); +} + +.cadetgui-info { + margin: 4px 0 8px; + padding: 6px 10px; + border: 1px solid var(--cg-border); + border-left: 3px solid var(--cg-primary); + border-radius: var(--cg-radius-sm); + background: var(--cg-surface); +} + +.cadetgui-info > summary { + cursor: pointer; + font-weight: 600; +} + +.cadetgui-info-body { + margin-top: 6px; + color: var(--cg-fg); +} + +.cadetgui-checklist { + margin: 4px 0; + padding-left: 1.2em; +} + +.cadetgui-recipe-summary { + border-collapse: collapse; + margin: 4px 0 8px; + font-size: var(--cg-text-sm); +} + +.cadetgui-recipe-summary th, +.cadetgui-recipe-summary td { + padding: 3px 12px 3px 0; + border-bottom: 1px solid var(--cg-border); + vertical-align: top; + text-align: left; +} + +.cadetgui-recipe-summary th { + color: var(--cg-muted); + font-weight: 600; + white-space: nowrap; +} + +.cadetgui-info-table { + width: 100%; + border-collapse: collapse; + margin: 4px 0 10px; + font-size: var(--cg-text-sm); +} + +.cadetgui-info-table th { + padding: 4px 10px 4px 0; + border-bottom: 1px solid var(--cg-border-strong); + color: var(--cg-muted); + font-weight: 600; + text-align: left; + white-space: nowrap; +} + +.cadetgui-info-table td { + padding: 5px 10px 5px 0; + border-bottom: 1px solid var(--cg-border); + vertical-align: top; +} + +.cadetgui-info-table tr:last-child td { + border-bottom: none; +} + +.cadetgui-info-table small { + color: var(--cg-muted); +} + +.cadetgui-info-unit { + font-family: var(--cg-font-mono); + white-space: nowrap; +} + +.cadetgui-info-missing { + color: var(--cg-warn); +} diff --git a/cadetgui/widgets/composite/__init__.py b/cadetgui/widgets/composite/__init__.py index c3c0387..263d571 100644 --- a/cadetgui/widgets/composite/__init__.py +++ b/cadetgui/widgets/composite/__init__.py @@ -1,11 +1,19 @@ """Task widgets assembled from elements + forms.""" from .backend_versions import BackendVersionsWidget +from .characterization_guide_pane import CharacterizationGuideWidget +from .characterization_setup import CharacterizationSetupWidget +from .characterization_step import CharacterizationStepWidget +from .characterization_workbench import CharacterizationWorkbenchWidget +from .comparisons import ComparisonsWidget from .configuration import ConfigurationWidget from .data_import import DataImportWidget, ExperimentalDataset +from .flow_rate import FlowRateSection from .instrument import InstrumentWidget from .parameter_estimation import ParameterEstimationWidget +from .parameter_store_view import ParameterStoreWidget from .run_history import RunHistoryWidget, RunRecord from .solution import SolutionWidget +from .study_components import StudyComponentsWidget from .workbench import WorkbenchWidget from .workspace_header import WorkspaceHeader @@ -18,7 +26,15 @@ "RunRecord", "DataImportWidget", "ExperimentalDataset", + "FlowRateSection", "ParameterEstimationWidget", + "CharacterizationGuideWidget", + "CharacterizationSetupWidget", + "CharacterizationStepWidget", + "CharacterizationWorkbenchWidget", + "ComparisonsWidget", + "ParameterStoreWidget", + "StudyComponentsWidget", "WorkbenchWidget", "WorkspaceHeader", ] diff --git a/cadetgui/widgets/composite/_measurement_common.py b/cadetgui/widgets/composite/_measurement_common.py new file mode 100644 index 0000000..c7cab37 --- /dev/null +++ b/cadetgui/widgets/composite/_measurement_common.py @@ -0,0 +1,373 @@ +"""Labels, summaries and small helpers shared by the measurement panes.""" + +from __future__ import annotations + +import copy +import dataclasses +import html +import math +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Tuple + +import ipywidgets as W + +from ...cadetprocessadapter import ( + BYPASSABLE_UNITS, + INSTRUMENT_TEMPLATES, + STANDALONE_TEMPLATES, + UNIT_LABELS, + FieldSpec, + parse_float_list, + signal_label, + template_required_units, +) +from ...characterization.comparison import Comparison +from ...characterization.guide import ( + CHAIN_BY_ID, + EXPERIMENT_TYPES, + SALT_ROLE, +) +from ...characterization.study import Study +from ...io.configuration_store import ConfigurationState +from .._help import term_html +from .._status import status_html + +if TYPE_CHECKING: + pass + + +ML_PER_MIN = 1e-6 / 60.0 + +# Column-mapping dropdown options: (label, ColumnRole value). +COLUMN_ROLE_OPTIONS = [ + ("Time [s]", "time_s"), + ("Time [min]", "time_min"), + ("Volume [mL]", "volume_ml"), + ("Signal channel", "signal"), + ("Skip", "skip"), +] + +# Substring of a `Comparison.problems()` message -> what the user should do about it. +_ADVICE: Tuple[Tuple[str, str], ...] = ( + ("No flat baseline found", "Set a baseline window under Advanced → Baseline and " + "scaling over a stretch without signal from the probe, e.g. after the peak."), + ("No data loaded", "Add the measurement again from its data file, or pick a loaded run " + "under Advanced → Data and alignment → Data run."), + ("Unknown channel", "Pick one of the run's channels under Advanced → Data and alignment " + "→ Channel."), + ("No run-log marker", "Pick the injection marker under Advanced → Data and alignment, " + "or choose 'None / manual' and enter where the injection happened."), + ("flow rate", "Enter the run's flow rate (mL/min) under Advanced → Data and alignment, " + "or set one in the process."), + ("target_area", "Enter the target area (the injected amount) under Advanced → " + "Baseline and scaling, or switch normalization off."), + ("Unknown normalization", "Pick a normalization under Advanced → Baseline and scaling."), + ("Unknown metric", "Pick a metric under Advanced → Comparison."), + ("not in the recipe", "Tick only components of the recipe under Advanced → Simulated " + "signal → Components."), + ("Recipe cannot be built", "Open the process in Process Configuration, fix it there and " + "apply it to this measurement."), + ("Solution path", "Pick an observation point that is in the flow path under Advanced → " + "Simulated signal."), + ("is declared as", "Pick a component with a fitting role under Advanced → Simulated " + "signal → Component, or change the component's role under System → Components."), + ("is not a declared component", "Declare it under System → Components, or pick a " + "declared component under Advanced → Simulated signal → Component."), + ("No component picked", "Pick the component under Advanced → Simulated signal → " + "Component."), +) + + +def _format(value: Optional[float]) -> str: + return "" if value is None else f"{value:g}" + + +def _format_value(value: Any) -> str: + if isinstance(value, (list, tuple)): + return ", ".join(_format_value(v) for v in value) + return f"{value:g}" if isinstance(value, float) else str(value) + + +def _optional_float(text: str) -> Optional[float]: + text = text.strip() + return float(text) if text else None + + +def _check_float(text: str) -> Optional[str]: + try: + _optional_float(text) + except ValueError: + return "Enter a number or leave blank." + return None + + +def _check_float_list(text: str) -> Optional[str]: + try: + parse_float_list(text) + except ValueError: + return "Enter numbers separated by commas, or leave blank." + return None + + +def _template_fields(recipe: ConfigurationState) -> List[FieldSpec]: + """Return the recipe's process-template fields (the values `overrides` may replace).""" + registry = INSTRUMENT_TEMPLATES if recipe.instrument is not None else STANDALONE_TEMPLATES + factory = registry.get(recipe.template_key) + if factory is None: + return [] + # The template factories only read `component_system.names` before `build` is called. + stub = SimpleNamespace(component_system=SimpleNamespace(names=list(recipe.components))) + return [f for f in factory(stub).fields if f.kind in ("float", "float_list", "choice")] + + +def _template_registry(recipe: ConfigurationState) -> Dict[str, Callable[..., Any]]: + return INSTRUMENT_TEMPLATES if recipe.instrument is not None else STANDALONE_TEMPLATES + + +def recipe_with_template(recipe: ConfigurationState, template_key: str) -> ConfigurationState: + """Return `recipe` running process template `template_key` with its default method values. + + The flow rate is kept; the sample loop is put in the flow path if the template needs it. + """ + factory = _template_registry(recipe)[template_key] + changed = dataclasses.replace(recipe, template_key=template_key) + values = {f.name: copy.deepcopy(f.default) for f in _template_fields(changed)} + flow_rate = _recipe_flow_rate(recipe) + if flow_rate is not None: + for key in ("flow_rate", "flow_rate_wash"): + if key in values: + values[key] = flow_rate + instrument = recipe.instrument + if instrument is not None and "sample_loop" in template_required_units(factory): + instrument = dataclasses.replace(instrument, include_sample_loop=True) + return dataclasses.replace(changed, model_values=values, instrument=instrument) + + +def _template_options(recipe: ConfigurationState) -> List[Tuple[str, str]]: + options = [(key, key) for key in _template_registry(recipe)] + if recipe.template_key not in dict(options).values(): + options.append((f"{recipe.template_key} (not available)", recipe.template_key)) + return options + + +def _step_of(comparison: Comparison) -> Optional[str]: + """Return the id of the chain step `comparison`'s experiment type feeds, if any.""" + experiment_type = EXPERIMENT_TYPES.get(comparison.experiment_type or "") + if experiment_type is None or experiment_type.step_id not in CHAIN_BY_ID: + return None + return experiment_type.step_id + + +def _recipe_flow_rate(recipe: ConfigurationState) -> Optional[float]: + value = recipe.model_values.get("flow_rate", recipe.model_values.get("flow_rate_wash")) + return float(value) if value is not None else None + + +def _observed_unit(solution_path: str) -> str: + return solution_path.partition(".")[0] + + +def observation_label(solution_path: str) -> str: + """Return a readable name of the observation point `solution_path`.""" + unit, _, port = solution_path.partition(".") + return signal_label(unit, port.split(".")[0]) if port else solution_path + + +def detector_label(channel: str) -> str: + """Return a detector name for a measured channel: "UV", "Conductivity", or the channel.""" + lowered = (channel or "").lower() + if "uv" in lowered: + return "UV" + if "cond" in lowered: + return "Conductivity" + return channel or "no channel" + + +def channel_label(channel: str) -> str: + """Return the detector name followed by the raw channel name, e.g. "UV (UV 1_280)".""" + detector = detector_label(channel) + return detector if detector == channel else f"{detector} ({channel})" + + +def measurement_caption(comparison: Comparison) -> str: + """Return a plain-language one-liner: experiment type, channel, where it is read, probe.""" + experiment = EXPERIMENT_TYPES.get(comparison.experiment_type or "") + parts = [experiment.label if experiment is not None else "No experiment type"] + location = observation_label(comparison.solution_path) + parts.append(f"{channel_label(comparison.channel)}, read at {location}") + if comparison.probe: + parts.append(f"probe: {comparison.probe}") + return " · ".join(parts) + + +def _flow_path_units(recipe: ConfigurationState) -> Tuple[List[str], List[str]]: + """Return the in-line and the bypassed units of `recipe`, in flow order.""" + instrument = recipe.instrument + if instrument is None: + return ["column"], [] + bypass = set(instrument.bypass_units) + in_line = [] + for unit in BYPASSABLE_UNITS: + if unit == "tubing_pre_column" and instrument.include_sample_loop: + in_line.append("sample_loop") + if unit not in bypass: + in_line.append(unit) + return in_line, [u for u in BYPASSABLE_UNITS if u in bypass] + + +def recipe_summary_html(comparison: Comparison) -> str: + """Return a plain-language table of `comparison`'s recipe and observation point.""" + recipe = comparison.recipe + experiment_type = EXPERIMENT_TYPES.get(comparison.experiment_type or "") + flow_rate = comparison.resolved_flow_rate or _recipe_flow_rate(recipe) + if flow_rate is None: + flow_text = "not set" + else: + source = "this run" if comparison.flow_rate is not None else "from the recipe" + flow_text = f"{flow_rate / ML_PER_MIN:.4g} mL/min ({source})" + in_line, bypassed = _flow_path_units(recipe) + labels = [UNIT_LABELS.get(u, u) for u in in_line] + rows: List[Tuple[str, str]] = [ + ("Experiment type", html.escape(experiment_type.label if experiment_type else "not set")), + ("Data", html.escape( + f"{comparison.data_file or 'no file'} · {channel_label(comparison.channel)}" + )), + ("Process template", html.escape(recipe.template_key)), + ("Flow rate", html.escape(flow_text)), + ("Components", html.escape(", ".join(recipe.components))), + (term_html("probe", "Probe"), html.escape(comparison.probe or "not set")), + ("Column model", html.escape(f"{recipe.column_key}; binding: {recipe.binding_key}")), + ("Column", "in line" if "column" in in_line else "bypassed (replaced by a union)"), + (term_html("flow path", "Flow path"), html.escape(" → ".join(labels))), + ("Bypassed", html.escape(", ".join(UNIT_LABELS.get(u, u) for u in bypassed) or "none")), + ] + if recipe.instrument is not None: + loop = recipe.instrument + rows.append(( + "Sample loop", + f"{loop.sample_loop_volume * 1e9:.4g} µL" if loop.include_sample_loop else "not used", + )) + observed = observation_label(comparison.solution_path) + if experiment_type is not None: + observed += f" ({experiment_type.detector})" + rows.append((term_html("observation point", "Observation point"), html.escape(observed))) + body = "".join( + f"{label}" + f"{value}" + for label, value in rows + ) + return f"{body}
    " + + +def _column_settings(recipe: ConfigurationState) -> str: + values = recipe.column_values + scaled = ( + ("length", "length", 100.0, "cm"), + ("diameter", "diameter", 1000.0, "mm"), + ("bed_porosity", "bed porosity", 1.0, ""), + ("particle_porosity", "particle porosity", 1.0, ""), + ) + parts = [] + for key, label, factor, unit in scaled: + value = values.get(key) + if isinstance(value, (int, float)): + parts.append(f"{label} {value * factor:.4g}{' ' + unit if unit else ''}") + return " · ".join(parts) + + +def system_summary_html(recipe: ConfigurationState) -> str: + """Return a plain-language table of the system every new measurement starts from. + + Hardware (flow path, bypassed units, sample loop), flow rate, and the column settings + that seed each measurement; the process template is left out, since each measurement + gets its own from its experiment type. + """ + flow_rate = _recipe_flow_rate(recipe) + in_line, bypassed = _flow_path_units(recipe) + rows: List[Tuple[str, str]] = [ + (term_html("flow path", "Flow path"), + html.escape(" → ".join(UNIT_LABELS.get(u, u) for u in in_line))), + ("Bypassed", html.escape(", ".join(UNIT_LABELS.get(u, u) for u in bypassed) or "none")), + ] + if recipe.instrument is not None: + loop = recipe.instrument + rows.append(( + "Sample loop", + f"{loop.sample_loop_volume * 1e9:.4g} µL" if loop.include_sample_loop else "not used", + )) + rows += [ + ("Flow rate", "not set" if flow_rate is None + else html.escape(f"{flow_rate / ML_PER_MIN:.4g} mL/min")), + ("Column model", html.escape(recipe.column_key)), + ("Binding model", html.escape(recipe.binding_key)), + ] + settings = _column_settings(recipe) + if settings: + rows.append(( + "Column settings", + html.escape(settings) + " (starting values of every new measurement)", + )) + body = "".join(f"{label}{value}" for label, value in rows) + return f"{body}
    " + + +def problems_html(problems: Sequence[str]) -> str: + """Return `problems` as error lines, each followed by what to do about it.""" + if not problems: + return status_html("ok", "No problems found.") + lines = [] + for problem in problems: + advice = next((a for key, a in _ADVICE if key in problem), None) + line = status_html("error", problem) + if advice: + line += f"
    What to do: {html.escape(advice)}
    " + lines.append(line) + return "".join(f"
    {line}
    " for line in lines) + + +def _detector_hint(channel: str) -> Optional[str]: + lower = channel.lower() + if "cond" in lower: + return "conductivity" + if "uv" in lower: + return "UV" + return None + + +def component_options( + study: Study, roles: Optional[Sequence[str]] +) -> List[Tuple[str, str]]: + """Return the declared components as choices, those whose role is in `roles` first. + + A non-fitting one is marked, e.g. "Acetone (system tracer — not a small tracer)". With + `roles` None every component but the salt fits. + """ + fits = [ + c for c in study.components + if (c.role in roles if roles is not None else c.role != SALT_ROLE) + ] + others = [c for c in study.components if c not in fits and c.role != SALT_ROLE] + wanted = " or ".join(roles or ()) + return [(f"{c.name} ({c.role})", c.name) for c in fits] + [ + (f"{c.name} ({c.role} — not a {wanted})", c.name) for c in others + ] + + +def _title(text_html: str) -> W.HTML: + return W.HTML(f"
    {text_html}
    ") + + +def _row(*children: W.Widget) -> W.HBox: + row = W.HBox(list(children), layout=W.Layout(flex_flow="row wrap")) + row.add_class("cadetgui-row") + return row + + +def _note(text_html: str = "") -> W.HTML: + note = W.HTML(text_html) + note.add_class("cadetgui-note") + return note + + +def _same(a: Optional[float], b: Optional[float]) -> bool: + return a is None or (b is not None and math.isclose(a, float(b), rel_tol=1e-9)) diff --git a/cadetgui/widgets/composite/_measurement_upload.py b/cadetgui/widgets/composite/_measurement_upload.py new file mode 100644 index 0000000..cf23049 --- /dev/null +++ b/cadetgui/widgets/composite/_measurement_upload.py @@ -0,0 +1,141 @@ +"""The upload page of the add-measurement stepper: read a run file or pick a loaded run.""" + +from __future__ import annotations + +import html +from typing import Any, Dict, List, Optional, Tuple + +import ipywidgets as W + +from ...io.experimental_data import ( + ExperimentalRun, + detect_column_roles, + is_akta_format, + read_akta_csv, + read_generic_csv, + read_headers, +) +from .._status import status_html +from ..elements import ChoiceField +from ._measurement_common import COLUMN_ROLE_OPTIONS, _note, _row + + +def _upload_items(value: Any) -> List[dict]: + """Return the uploaded files as dicts with `name` and `content` (ipywidgets 7 and 8).""" + if isinstance(value, dict): + return [{"name": name, **item} for name, item in value.items()] + return list(value) + + +class _RunUpload: + """Upload page of `AddMeasurementFlow`; sets `run` and `data_file`. + + The host provides `_runs()` and `_loading`. + """ + + def _init_upload(self) -> W.VBox: + """Create the page's controls and return the page.""" + self.uploaded: Dict[str, ExperimentalRun] = {} + self.run: Optional[ExperimentalRun] = None + self.data_file = "" + self._pending: Optional[Tuple[str, bytes]] = None + self._role_dropdowns: List[W.Dropdown] = [] + + self._upload = W.FileUpload( + accept=".csv,.txt,.tsv", multiple=False, description="Upload run" + ) + self._time_unit = ChoiceField( + label="A 2-column file's time is in:", + options=[("minutes", "min"), ("seconds", "s")], + ) + self._loaded = ChoiceField(label="Or use a loaded run:") + self._roles_box = W.VBox([]) + self._btn_roles = W.Button(description="Use these columns", icon="check") + self._run_note = W.HTML() + + self._upload.observe(self._on_upload, names="value") + self._loaded.observe(self._on_pick_loaded, names="selected_index") + self._btn_roles.on_click(lambda _b: self._apply_roles()) + return W.VBox([ + _note("Upload the run's export (CSV, TXT or TSV). ÄKTA/Unicorn exports are " + "recognized automatically. Other tables work too: comma, semicolon or " + "tab separated, decimal commas, lines above the header row are skipped; " + "you say which column is time or volume."), + _row(self._upload, self._time_unit), + self._roles_box, + _row(self._loaded), + self._run_note, + ]) + + def _on_upload(self, _change: dict) -> None: + if not self._upload.value: + return + for item in _upload_items(self._upload.value): + self.load_bytes(item["name"], bytes(item["content"])) + self._upload.value = () + + def load_bytes(self, name: str, content: bytes) -> None: + """Read an uploaded file; ÄKTA exports and 2-column CSVs are used directly.""" + label = name.rsplit(".", 1)[0] + self._roles_box.children = () + self._pending = None + try: + if is_akta_format(content): + run = read_akta_csv(content, label) + self._use_run(name, run, "ÄKTA/Unicorn export") + return + headers = read_headers(content) + if len(headers) == 2: + role = "time_s" if self._time_unit.value == "s" else "time_min" + self._use_run(name, read_generic_csv(content, label, roles=[role, "signal"]), + "2-column CSV") + return + except ValueError as exc: + self._run_note.value = status_html("error", f"Could not read {name}: {exc}") + return + if len(headers) < 2: + self._run_note.value = status_html("error", f"Could not read {name}.") + return + self._pending = (name, content) + self._role_dropdowns = [ + W.Dropdown(options=COLUMN_ROLE_OPTIONS, value=role, description=header) + for header, role in zip(headers, detect_column_roles(headers)) + ] + self._roles_box.children = ( + _note(f"Say what each column of {html.escape(name)} holds:"), + *self._role_dropdowns, + self._btn_roles, + ) + self._run_note.value = "" + + def _apply_roles(self) -> None: + if self._pending is None: + return + name, content = self._pending + try: + run = read_generic_csv( + content, name.rsplit(".", 1)[0], roles=[d.value for d in self._role_dropdowns] + ) + except ValueError as exc: + self._run_note.value = status_html("error", str(exc)) + return + self._roles_box.children = () + self._pending = None + self._use_run(name, run, "CSV") + + def _use_run(self, data_file: str, run: ExperimentalRun, kind: str) -> None: + self.uploaded[data_file] = run + self.run, self.data_file = run, data_file + self._run_note.value = status_html( + "ok", f"{data_file}: {kind}, {len(run.channels)} channel(s) " + f"({', '.join(run.channels)}), {run.x_basis} axis in {run.x_unit}, " + f"{len(run.markers)} marker(s)." + ) + + def _on_pick_loaded(self, _change: dict) -> None: + key = self._loaded.value + if self._loading or key is None: + return + run = self._runs().get(key) + if run is not None: + self._use_run(key, run, "loaded run") diff --git a/cadetgui/widgets/composite/_optimizer_runner_panel.py b/cadetgui/widgets/composite/_optimizer_runner_panel.py index d1217e7..2b7f0d3 100644 --- a/cadetgui/widgets/composite/_optimizer_runner_panel.py +++ b/cadetgui/widgets/composite/_optimizer_runner_panel.py @@ -27,8 +27,8 @@ class OptimizerRunnerPanel: """The optimizer run section: picker + knobs, Run/Cancel/Accept, live plot, analytics. - Shared by `ParameterEstimationWidget` and `CharacterizationWidget`. Runs the fit on a - background thread with an elapsed-time ticker. + Used by `ParameterEstimationWidget`. Runs the fit on a background thread with an + elapsed-time ticker. `build_run_spec` is called on every "Run" click and must return a `cadetgui.optimizer_runner.RunSpec`, or a validation-error string (shown in `.status`, diff --git a/cadetgui/widgets/composite/_result_export.py b/cadetgui/widgets/composite/_result_export.py index f0da68d..5edd315 100644 --- a/cadetgui/widgets/composite/_result_export.py +++ b/cadetgui/widgets/composite/_result_export.py @@ -5,7 +5,7 @@ import numpy as np -from ... import configuration_store +from ...io import configuration_store def save_signal_outputs( diff --git a/cadetgui/widgets/composite/_script_export.py b/cadetgui/widgets/composite/_script_export.py index 8e75536..9b779d5 100644 --- a/cadetgui/widgets/composite/_script_export.py +++ b/cadetgui/widgets/composite/_script_export.py @@ -6,7 +6,7 @@ from CADETProcess.processModel import ComponentSystem from ...cadetprocessadapter import BYPASSABLE_UNITS -from ...configuration_store import InstrumentState +from ...io.configuration_store import InstrumentState def _import_line(obj: Any) -> str: diff --git a/cadetgui/widgets/composite/_step_results.py b/cadetgui/widgets/composite/_step_results.py new file mode 100644 index 0000000..83f8394 --- /dev/null +++ b/cadetgui/widgets/composite/_step_results.py @@ -0,0 +1,177 @@ +"""Pareto candidates of a finished characterization step run: table, previews, export.""" + +from __future__ import annotations + +import copy +import csv +import html +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +import ipywidgets as W +import numpy as np + +from ...characterization import runner +from ...characterization.parameter_store import ParameterStore +from ...characterization.runner import Candidate, StepResult, StepSetup +from .._help import term_html +from .._series import decimate_minmax, reference_series +from .._status import status_html +from ..elements import ChromatogramChart, SelectableTable + + +def _preview_series(preview: Any) -> list: + times, values = decimate_minmax( + np.asarray(preview.simulated_time, dtype=float) / 60.0, + np.asarray(preview.simulated_values, dtype=float), + ) + return [ + {"name": "Simulated", "times": times, "values": values}, + reference_series("Measured", preview.reference_time, preview.reference_values), + ] + + +def _preview_title(preview: Any) -> str: + return f"{html.escape(preview.name)}: {term_html(preview.metric)} = {preview.value:.4g}" + + +class _CandidateResults: + """Front candidates of one run, per-comparison previews, Accept and CSV export.""" + + def __init__(self, on_accept: Callable[[Candidate], None]) -> None: + self._on_accept = on_accept + self.result: Optional[StepResult] = None + self._setup: Optional[StepSetup] = None + self._prior: Optional[ParameterStore] = None + self.candidates: List[Candidate] = [] + self._previews: Dict[int, list] = {} + + self.table = SelectableTable(label="Candidates", empty_text="No candidates.") + self.table.observe(self._on_pick, names="selected_index") + self._charts = W.HBox(layout=W.Layout(flex_flow="row wrap")) + self.btn_accept = W.Button( + description="Accept candidate", icon="check", button_style="success" + ) + self.btn_accept.on_click(lambda _b: self._accept_clicked()) + self.export_path = W.Text(description="CSV file:", value="candidates.csv") + self._btn_export = W.Button(description="Export candidates (CSV)", icon="download") + self._btn_export.on_click(lambda _b: self._export_clicked()) + self.status = W.HTML() + self.status.add_class("cadetgui-status") + + self.root = W.VBox([ + W.HTML( + "
    " + f"{term_html('Pareto candidate', 'Pareto candidates')}
    " + f"Pick one: {term_html('best average')} has the lowest mean error " + f"over all measurements; \"best <measurement>\" matches that run best. " + f"Errors are shown as {term_html('NRMSE')} unless the measurement sets " + "another metric." + ), + self.table, + W.HBox([self.btn_accept, self.export_path, self._btn_export], + layout=W.Layout(flex_flow="row wrap")), + self.status, + self._charts, + ], layout=W.Layout(display="none")) + + def show(self, result: StepResult, setup: StepSetup, prior: ParameterStore) -> None: + """List `result`'s candidates and preview the best-average one.""" + self.result, self._setup, self._prior = result, setup, prior + self.candidates = runner.pareto_candidates(result) + self._previews = {} + self.table.columns = [ + "Tags", *result.variable_names, + *(f"{c.name} ({c.metric})" for c in setup.comparisons), + ] + self.table.set_options( + [(", ".join(c.tags), i) for i, c in enumerate(self.candidates)], + rows=[ + [(", ".join(c.tags), "info"), *(f"{v:.4g}" for v in (*c.x, *c.f))] + for c in self.candidates + ], + keep_value=False, select_none=True, + ) + self.btn_accept.layout.display = "" if self.candidates else "none" + self.btn_accept.disabled = False + self.root.layout.display = "" + self.status.value = "" if self.candidates else status_html( + "warn", "The run produced no front to choose from." + ) + if self.candidates: + self.table.value = 0 + + def clear(self) -> None: + self.result, self.candidates, self._previews = None, [], {} + self.table.set_options([], rows=[]) + self._charts.children = [] + self.status.value = "" + self.root.layout.display = "none" + + @property + def selected(self) -> Optional[Candidate]: + index = self.table.value + return self.candidates[index] if index is not None else None + + def preview_store(self, candidate: Candidate) -> ParameterStore: + """Return the store `candidate` would be accepted as, computed on copied processes.""" + built = copy.deepcopy(self.result.built) + return runner.posterior(self._setup, built, self._prior, candidate) + + def _on_pick(self, _change: Any) -> None: + candidate = self.selected + if candidate is None: + self._charts.children = [] + return + index = self.candidates.index(candidate) + self.status.value = status_html("running", "Simulating the candidate…") + try: + if index not in self._previews: + store = self.preview_store(candidate) + self._previews[index] = [c.evaluate(store) for c in self._setup.comparisons] + except Exception as exc: # noqa: BLE001 -- shown to the user + self.status.value = status_html("error", f"Preview failed: {exc}") + return + self.status.value = "" + self._charts.children = [self._chart(p) for p in self._previews[index]] + + @staticmethod + def _chart(preview: Any) -> W.VBox: + chart = ChromatogramChart( + series=_preview_series(preview), view_width=420, view_height=240, y_label="Signal", + ) + return W.VBox([W.HTML(_preview_title(preview)), chart]) + + def previews_of(self, candidate: Candidate) -> Optional[list]: + """Return the simulated previews of `candidate` if it has been previewed.""" + if candidate not in self.candidates: + return None + return self._previews.get(self.candidates.index(candidate)) + + def _accept_clicked(self) -> None: + candidate = self.selected + if candidate is None: + self.status.value = status_html("warn", "Pick a candidate first.") + return + self._on_accept(candidate) + + def _export_clicked(self) -> None: + try: + path = self.export_csv(self.export_path.value) + except (OSError, ValueError) as exc: + self.status.value = status_html("error", str(exc)) + return + self.status.value = status_html("ok", f"Wrote {path}.") + + def export_csv(self, path: "Path | str") -> Path: + """Write the candidates (tags, variables, objectives) to `path`.""" + if self.result is None: + raise ValueError("No candidates to export.") + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(["tags", *self.result.variable_names, *self.result.objective_names]) + for c in self.candidates: + writer.writerow(["; ".join(c.tags), *c.x, *c.f]) + return path diff --git a/cadetgui/widgets/composite/_step_run.py b/cadetgui/widgets/composite/_step_run.py new file mode 100644 index 0000000..f3502c7 --- /dev/null +++ b/cadetgui/widgets/composite/_step_run.py @@ -0,0 +1,215 @@ +"""Background fit, progress chart and live fit plot of a characterization step.""" + +from __future__ import annotations + +import html +import threading +import time +from typing import Any, Dict, List, Optional + +import ipywidgets as W +import numpy as np + +from ...characterization import runner +from ...characterization.parameter_store import ParameterStore +from ...characterization.runner import Candidate, StepSetup, current_best_average +from .._status import status_html +from ..elements import ChromatogramChart, LineChart +from ._step_results import _preview_series, _preview_title + + +class _StepRun: + """Run mechanics of `CharacterizationStepWidget`: fit thread, progress, live fit plot. + + The host provides `running` and `_finish(start)`. + """ + + _TICK_SECONDS = 0.5 + + def _init_run(self) -> None: + self._run_done = threading.Event() + self._cancel_event = threading.Event() + self._progress: Dict[str, Any] = {} + self._ticker: Optional[threading.Thread] = None + self._run_setup: Optional[StepSetup] = None + self._run_prior: Optional[ParameterStore] = None + self._live_busy = threading.Lock() + self._live_write = threading.Lock() + self._live_token = 0 + self._live_generation = 0 + self._live_built: Optional[Any] = None + self._live_thread: Optional[threading.Thread] = None + self.live_update_seconds: List[float] = [] + + self._btn_cancel = W.Button( + description="Cancel", icon="stop", button_style="danger", + layout=W.Layout(display="none"), + ) + self._btn_cancel.on_click(lambda _b: self.cancel()) + self._progress_label = W.HTML() + self._progress_chart = LineChart( + view_width=520, view_height=240, x_label="Generation", x_name="generation", + x_unit="", y_label="Best objective so far", empty_text="No generations yet", + ) + self._progress_title = W.HTML() + self.live_plot = W.Checkbox( + value=True, description="Live fit plot", indent=False, + tooltip="Show the current best fit while the run is going.", + layout=W.Layout(width="auto"), + ) + self._live_note = W.HTML() + self._live_title = W.HTML() + self._live_charts = W.HBox(layout=W.Layout(flex_flow="row wrap")) + self._live_chart_widgets: List[tuple[W.HTML, ChromatogramChart]] = [] + self._progress_box = W.VBox([ + self._progress_title, + self._progress_chart, + self._live_title, + self._live_charts, + ], layout=W.Layout(display="none")) + + def _launch(self, setup: StepSetup, prior: ParameterStore, name: str, kwargs: dict) -> None: + """Fit `setup` on a background thread and tick the progress until it finishes.""" + self._btn_cancel.layout.display = "" + self._btn_cancel.disabled = False + self._btn_cancel.description = "Cancel" + self._progress_chart.series = [] + self._progress_title.value = "Best so far per objective" + self._progress_box.layout.display = "" + self._run_done.clear() + self._cancel_event.clear() + self._progress = {"optimizer": None, "result": None, "error": None} + start = time.monotonic() + threading.Thread( + target=self._work, args=(setup, prior, name, kwargs), daemon=True + ).start() + self._ticker = threading.Thread(target=self._tick, args=(start,), daemon=True) + self._ticker.start() + + def _reset_live(self, setup: StepSetup) -> None: + """Drop pending live updates and make one live chart per measurement.""" + with self._live_write: + self._live_token += 1 + self._live_generation = 0 + self._live_built = None + self.live_update_seconds = [] + self._live_chart_widgets = [ + ( + W.HTML(f"{html.escape(c.name)}"), + ChromatogramChart( + view_width=420, view_height=240, y_label="Signal", + empty_text="Waiting for the first generation", + ), + ) + for c in setup.comparisons + ] + self._live_charts.children = [W.VBox(list(pair)) for pair in self._live_chart_widgets] + self._live_title.value = "" + self._live_note.value = "" + + def _work(self, setup: StepSetup, prior: ParameterStore, name: str, kwargs: dict) -> None: + try: + self._progress["result"] = runner.run( + setup, prior, optimizer_name=name, optimizer_kwargs=kwargs, + cancel_event=self._cancel_event, + on_optimizer_ready=lambda opt: self._progress.update(optimizer=opt), + ) + except Exception as exc: # noqa: BLE001 -- shown to the user + self._progress["error"] = str(exc) + self._run_done.set() + + def _tick(self, start: float) -> None: + shown = 0 + while not self._run_done.wait(timeout=self._TICK_SECONDS): + shown = self._update_progress(start, shown) + self._update_progress(start, -1) + self._finish(start) + + def _update_progress(self, start: float, shown: int) -> int: + optimizer = self._progress.get("optimizer") + results = getattr(optimizer, "results", None) + populations = list(results.populations) if results is not None else [] + n_gen = len(populations) + text = f"{time.monotonic() - start:.0f}s elapsed · generation {n_gen}" + if n_gen and n_gen != shown: + best = np.minimum.accumulate(np.array([p.f_min for p in populations]), axis=0) + names = self._run_setup.objective_names + generations = list(range(1, n_gen + 1)) + self._progress_chart.series = [ + {"name": name, "times": generations, "values": [float(v) for v in best[:, i]]} + for i, name in enumerate(names) + ] + self._progress["best"] = ", ".join( + f"{name} {best[-1, i]:.3g}" for i, name in enumerate(names) + ) + if shown >= 0 and self.live_plot.value: + candidate = current_best_average(optimizer) + if candidate is not None: + self._start_live_update(candidate, n_gen) + if self._progress.get("best"): + text += f" · best {self._progress['best']}" + self._progress_label.value = f"{html.escape(text)}" + return n_gen + + def _start_live_update(self, candidate: Candidate, generation: int) -> bool: + """Simulate `candidate` for the live plot unless an update is still running.""" + if generation <= self._live_generation or not self._live_busy.acquire(blocking=False): + return False + self._live_generation = generation + self._live_thread = threading.Thread( + target=self._live_update, args=(candidate, generation, self._live_token), + name="live-fit-plot", daemon=True, + ) + self._live_thread.start() + return True + + def _live_update(self, candidate: Candidate, generation: int, token: int) -> None: + """Simulate every measurement at `candidate` on processes built for this plot only.""" + try: + began = time.monotonic() + setup, prior = self._run_setup, self._run_prior + if self._live_built is None: + self._live_built = runner.build(setup, prior) + store = runner.posterior(setup, self._live_built, prior, candidate) + previews = [c.evaluate(store) for c in setup.comparisons] + seconds = time.monotonic() - began + self.live_update_seconds.append(seconds) + with self._live_write: + if token == self._live_token: + self._show_live( + previews, + f"Signal being fitted: generation {generation}, best-average " + f"candidate (simulated in {seconds:.1f} s)", + ) + except Exception as exc: # noqa: BLE001 -- the fit itself is unaffected + with self._live_write: + if token == self._live_token: + self._live_note.value = status_html("warn", f"Live fit plot: {exc}") + finally: + self._live_busy.release() + + def _show_live(self, previews: list, title: str) -> None: + for (label, chart), preview in zip(self._live_chart_widgets, previews): + label.value = _preview_title(preview) + chart.series = _preview_series(preview) + self._live_title.value = f"{html.escape(title)}" + self._live_note.value = "" + + def cancel(self) -> None: + """Stop the run after the current generation; the last full front is kept.""" + if not self.running: + return + self._cancel_event.set() + self._btn_cancel.disabled = True + self._btn_cancel.description = "Cancelling… (finishing generation)" + + def wait(self, timeout: Optional[float] = None) -> bool: + """Block until a started run and its last live-plot update have finished.""" + deadline = None if timeout is None else time.monotonic() + timeout + for attribute in ("_ticker", "_live_thread"): + thread = getattr(self, attribute) + if thread is not None: + thread.join(None if deadline is None else max(deadline - time.monotonic(), 0)) + if thread.is_alive(): + return False + return True diff --git a/cadetgui/widgets/composite/_step_setup_form.py b/cadetgui/widgets/composite/_step_setup_form.py new file mode 100644 index 0000000..60d92b8 --- /dev/null +++ b/cadetgui/widgets/composite/_step_setup_form.py @@ -0,0 +1,719 @@ +"""The setup form of a characterization step: step type, measurements, variables, optimizer.""" + +from __future__ import annotations + +import html +import inspect +import re +from types import SimpleNamespace +from typing import Any, Callable, Dict, List, Optional + +import ipywidgets as W + +from ... import process_builder +from ...cadetprocessadapter import UNIT_LABELS +from ...characterization.comparison import Comparison +from ...characterization.guide import ( + CHAIN_BY_ID, + EXPERIMENT_TYPES, + ChainStepGuide, + guide_for_step, + option_help, + option_label, + parameter_label, + starting_values, + step_summary, + step_type_help, +) +from ...characterization.runner import ( + DEFAULT_OPTIMIZER, + StepSetup, + default_optimizer, + default_optimizer_knobs, + estimated_simulations, + fitted_variables, +) +from ...characterization.stages import STAGES, VariableDef, describe_stage +from ...characterization.study import Study +from ...optimizer_runner import OPTIMIZERS +from .._help import term_html +from .._status import status_html +from ..elements import BoolField, ChoiceField +from ._measurement_common import measurement_caption + +MULTI_OBJECTIVE = DEFAULT_OPTIMIZER + + +def _knob_tooltip(is_population_based: bool) -> str: + text = "Pre-filled from the number of fitted variables until you change it." + if is_population_based: + text += " 0 lets CADET-Process size it (large: can take hours)." + return text + + +_VARIABLES_HELP = ( + "
    " + "Fit ticked: the optimizer searches this parameter between its lower and upper " + "bound, starting from its current value, and the accepted candidate's value is written " + "to the parameter store. Unticked, the parameter is fixed: it keeps its current " + "value (from an earlier step, the starting values or the process setup) and this step " + "does not change it. Keep a parameter fixed when your measurements cannot tell it " + "apart from another one, e.g. characteristic charge and equilibrium constant from only " + "a few similar gradients. " + "Dependent variables are computed from the fitted ones and have no bounds." + "
    " +) + +_FIT_TOOLTIP = ( + "Ticked: the optimizer fits this parameter between its bounds. Unticked: it is kept " + "fixed at its current value." +) + + +def _unit_html(latex: str) -> str: + """Render a CADET-Process LaTeX unit (fractions, powers, subscripts) as HTML.""" + if not latex: + return "" + text = re.sub(r"\\mathrm\{([^{}]*)\}", r"\1", latex) + text = re.sub(r"\^\{([^{}]*)\}", r"\1", text) + text = re.sub(r"_\{([^{}]*)\}", r"\1", text) + text = text.replace("\\cdot", "·").replace(" ", "") + + def fraction(match: "re.Match[str]") -> str: + numerator, denominator = match.group(1), match.group(2) + if "·" in denominator: + denominator = f"({denominator})" + return f"{numerator}/{denominator}" + + text = re.sub(r"\\frac\{([^{}]*)\}\{([^{}]*)\}", fraction, text) + return text.replace("{", "").replace("}", "").replace("\\", "") + + +def _is_bool(param: inspect.Parameter) -> bool: + return param.annotation in (bool, "bool") or isinstance(param.default, bool) + + +def _does_not_fit_html(comparison: Comparison) -> str: + experiment = EXPERIMENT_TYPES.get(comparison.experiment_type or "") + if experiment is None: + reason = "it has no experiment type" + else: + feeds = CHAIN_BY_ID.get(experiment.step_id) + reason = f"{experiment.label} feeds {feeds.title if feeds else experiment.step_id}" + return status_html( + "warn", f"Does not fit this step ({reason}). Untick to remove it from the step." + ) + + +def _tubing_units(comparison: Comparison) -> list[str]: + check = process_builder.check_recipe(comparison.recipe, comparison.overrides) + return [u for u in check.units if u.startswith("tubing_")] + + +class _StepSetupForm: + """Controls for one `StepSetup` plus the optimizer choice; `on_change()` fires on user edits.""" + + def __init__( + self, + study: Study, + on_change: Callable[[], None], + on_add_measurement: Optional[Callable[[str], None]] = None, + ) -> None: + self._study = study + self._on_change = on_change + self._on_add_measurement = on_add_measurement + self._syncing = False + self._variables: List[VariableDef] = [] + + self.name = W.Text(description="Step name:", continuous_update=False) + self.stage = ChoiceField( + label="Step type:", options=[(s.label, s.id) for s in STAGES.values()] + ) + self._stage_help = W.HTML() + self._options_box = W.VBox() + self._option_fields: Dict[str, Any] = {} + self.summary = W.HTML() + self._stage_controls = W.VBox([self.stage, self._stage_help, self._options_box]) + self._plain_slot = W.VBox() + self._advanced_body = W.VBox() + self.advanced = W.Accordion([self._advanced_body]) + self.advanced.set_title(0, "Advanced: step type and options") + self.advanced.selected_index = None + self.advanced.layout.display = "none" + self.guided = False + + self._comparison_rows = W.VBox() + self._comparison_boxes: Dict[str, W.Checkbox] = {} + self._comparison_note = W.HTML() + self._no_fitting = W.HTML() + self._btn_add_measurement = W.Button( + description="Add measurement for this step", icon="upload", + button_style="primary", layout=W.Layout(width="auto", display="none"), + ) + self._btn_add_measurement.on_click(lambda _b: self._add_measurement_clicked()) + self._fitting: set[str] = set() + self._excluded: Optional[set[str]] = None + self._rows_guide: Optional[str] = None + + self._variables_box = W.VBox() + self._bound_fields: Dict[str, tuple[W.FloatText, W.FloatText]] = {} + self._fit_boxes: Dict[str, W.Checkbox] = {} + self._current_cells: Dict[str, W.HTML] = {} + + self.requires = W.SelectMultiple( + description="Requires:", layout=W.Layout(width="100%", height="110px") + ) + self._require_paths: List[str] = [] + self._provides: List[str] = [] + self.provides = W.HTML() + self._chain_line = W.HTML() + self._chain_box = W.VBox([ + W.HTML("Values this step needs from earlier steps, and what it determines." + ""), + self.requires, + self.provides, + ]) + + self.optimizer = ChoiceField( + label="Optimizer:", options=[(name, name) for name in OPTIMIZERS] + ) + self.optimizer.value = MULTI_OBJECTIVE + self._estimate = W.HTML() + self._knob_fields: Dict[str, List[W.IntText]] = { + name: [ + W.IntText( + value=k.default, description=k.label, + tooltip=_knob_tooltip(spec.is_population_based), + ) + for k in spec.knobs + ] + for name, spec in OPTIMIZERS.items() + } + self._edited_knobs: set[tuple[str, str]] = set() + self._knob_boxes = { + name: W.HBox(fields, layout=W.Layout(flex_flow="row wrap")) + for name, fields in self._knob_fields.items() + } + + self.name.observe(self._edited, names="value") + self.stage.observe(self._on_stage_change, names="selected_index") + self.requires.observe(self._edited, names="value") + self.optimizer.observe(self._on_optimizer_change, names="selected_index") + for name, spec in OPTIMIZERS.items(): + for knob, knob_field in zip(spec.knobs, self._knob_fields[name]): + knob_field.observe( + lambda _c, key=(name, knob.attr): self._on_knob_edit(key), names="value" + ) + + def section(title: str, *children: W.Widget) -> W.VBox: + box = W.VBox([W.HTML(f"
    {title}
    "), *children]) + box.add_class("cadetgui-subsection") + return box + + self.root = W.VBox([ + self.name, + self.summary, + self._chain_line, + self._plain_slot, + self.advanced, + section( + f"{term_html('measurement', 'Measurements')} (one objective each)", + self._comparison_note, + self._comparison_rows, + W.HBox( + [self._no_fitting, self._btn_add_measurement], + layout=W.Layout(align_items="center", flex_flow="row wrap"), + ), + ), + section("Variables", self._variables_box), + section( + "Optimizer", + W.HBox( + [self.optimizer, *self._knob_boxes.values()], + layout=W.Layout(flex_flow="row wrap"), + ), + self._estimate, + ), + ]) + + def _edited(self, _change: Any = None) -> None: + if not self._syncing: + self._on_change() + + def _on_knob_edit(self, key: tuple[str, str]) -> None: + if self._syncing: + return + self._edited_knobs.add(key) + self._update_estimate() + self._on_change() + + def _on_fit_toggle(self, _change: Any) -> None: + if self._syncing: + return + self._prefill_knobs() + self._on_change() + + def _n_fitted(self) -> int: + return len(fitted_variables(self._variables, self._frozen())) + + def _prefill_knobs(self) -> None: + """Set every knob the user has not edited to its size for the fitted variables.""" + syncing, self._syncing = self._syncing, True + try: + n = self._n_fitted() + for name, spec in OPTIMIZERS.items(): + sized = default_optimizer_knobs(name, n) + for knob, knob_field in zip(spec.knobs, self._knob_fields[name]): + if (name, knob.attr) not in self._edited_knobs: + knob_field.value = sized[knob.attr] + finally: + self._syncing = syncing + self._update_estimate() + + def _update_estimate(self) -> None: + n_comparisons = len(self.selected_names()) + settings = self.optimizer_settings() + count = estimated_simulations(settings, n_comparisons) + measurements = f"{n_comparisons} measurement{'s' if n_comparisons != 1 else ''}" + if settings["name"] == MULTI_OBJECTIVE and count is None: + text = ( + "Population size or generations 0: CADET-Process sizes the run itself, " + "which can take hours." + ) + elif settings["name"] == MULTI_OBJECTIVE: + knobs = settings["knobs"] + text = ( + f"≈ {count:,} simulations ({knobs['pop_size']} × {knobs['n_max_gen']} " + f"generations × {measurements})." + ) + elif count is not None: + text = ( + f"≈ {count:,} simulations (max iterations × {measurements}; approximate, " + "scipy's evaluations per iteration vary)." + ) + else: + text = "" + self._estimate.value = f"{html.escape(text)}" if text else "" + + def _on_stage_change(self, _change: Any) -> None: + if self._syncing: + return + self._syncing = True + try: + self._rebuild_options({}) + self._rebuild_variables({}, ()) + finally: + self._syncing = False + self._prefill_knobs() + self._on_change() + + def _on_option_change(self, _change: Any) -> None: + if self._syncing: + return + bounds, frozen = self._bounds(), self._frozen() + self._syncing = True + try: + self._rebuild_variables(bounds, frozen) + finally: + self._syncing = False + self._prefill_knobs() + self._on_change() + + def _on_comparison_toggle(self, change: Any) -> None: + if self._syncing: + return + name = next(n for n, box in self._comparison_boxes.items() if box is change["owner"]) + if name in self._fitting: + (self._excluded.discard if change["new"] else self._excluded.add)(name) + bounds, frozen = self._bounds(), self._frozen() + self._syncing = True + try: + self._rebuild_options(self._options()) + self._rebuild_variables(bounds, frozen) + self._sync_optimizers() + finally: + self._syncing = False + self._prefill_knobs() + self._on_change() + + def _on_optimizer_change(self, _change: Any) -> None: + selected = self.optimizer.value + for name, box in self._knob_boxes.items(): + box.layout.display = "" if name == selected else "none" + self._update_estimate() + self._edited() + + def set_setup(self, setup: StepSetup) -> None: + """Show `setup` in the controls without firing `on_change`.""" + self._syncing = True + try: + self.name.value = setup.name + self.stage.value = setup.stage + self._excluded = None if setup.excluded is None else set(setup.excluded) + self.refresh_comparisons([c.name for c in setup.comparisons]) + self._rebuild_options(setup.options) + self._rebuild_variables(setup.bounds, setup.frozen) + self.refresh_requires(setup) + self.requires.value = tuple(p for p in setup.requires if p in self._require_paths) + self._set_optimizer(setup.optimizer) + self._prefill_knobs() + finally: + self._syncing = False + self.refresh_summary() + + def _layout_setup(self) -> SimpleNamespace: + return SimpleNamespace( + name=self.name.value.strip(), stage=self.stage.value, options=self._options(), + comparisons=self._selected(), + ) + + def refresh_summary(self) -> None: + """Show what the step fits; a chain step keeps its type and options under Advanced.""" + setup = self._layout_setup() + help_text = step_type_help(self.stage.value) + stage_help = f"{html.escape(help_text)}" if help_text else "" + if self._stage_help.value != stage_help: + self._stage_help.value = stage_help + guided = guide_for_step(setup) is not None + summary = f"

    What this step fits: {html.escape(step_summary(setup))}

    " + if self.summary.value != summary: + self.summary.value = summary + if guided != self.guided or not ( + self._plain_slot.children or self._advanced_body.children + ): + self.guided = guided + self._plain_slot.children = () if guided else (self._stage_controls,) + self._advanced_body.children = (self._stage_controls if guided else self._chain_box,) + self.advanced.set_title( + 0, "Advanced: step type and options" if guided else "Advanced: chain links" + ) + self.advanced.layout.display = "" + self.show_chain(self._provides) + guide = self.guide() + if (guide.id if guide else None) != self._rows_guide: + self.refresh_comparisons() + self.refresh_current_values() + + def guide(self) -> Optional[ChainStepGuide]: + """Return the chain guide the step follows (by name, step type and options), if any.""" + return guide_for_step(SimpleNamespace( + name=self.name.value.strip(), stage=self.stage.value, options=self._options(), + )) + + def _add_measurement_clicked(self) -> None: + guide = self.guide() + if guide is not None and self._on_add_measurement is not None: + self._on_add_measurement(guide.id) + + def refresh_current_values(self) -> None: + """Fill the "Current value" column from the step's prior or the process setup.""" + if not self._current_cells: + return + try: + values = starting_values(self._study, self._layout_setup(), self._variables) + except Exception: # noqa: BLE001 -- unbuildable recipes are reported elsewhere + values = {} + for name, cell in self._current_cells.items(): + start = values.get(name) + if start is None: + text = "—" + else: + unit = _unit_html(start.unit) + text = ( + f"{html.escape(start.text)}{' ' + unit if unit else ''}" + f"
    {html.escape(start.source)}" + ) + if cell.value != text: + cell.value = text + + def _set_optimizer(self, optimizer: dict) -> None: + """Show `optimizer`; its knobs that differ from the sized defaults count as edited.""" + name = optimizer.get("name", MULTI_OBJECTIVE) + sized = default_optimizer_knobs(name, self._n_fitted()) + self._edited_knobs = set() + for knob, knob_field in zip(OPTIMIZERS[name].knobs, self._knob_fields[name]): + knob_field.value = int(optimizer.get("knobs", {}).get(knob.attr, knob.default)) + if knob_field.value != sized[knob.attr]: + self._edited_knobs.add((name, knob.attr)) + self.optimizer.value = name + self._sync_optimizers() + + def refresh_comparisons( + self, selected: Optional[List[str]] = None, *, auto_include: bool = False + ) -> List[str]: + """Rebuild the measurement rows from the study, keeping (or setting) the selection. + + A chain step lists only the measurements whose experiment type feeds it, plus + selected ones that do not fit (with a warning); a custom step lists all. Fitting + measurements unselected when a chain step is first shown without recorded + exclusions, or when the user switches it to another chain step, count as left out. + With `auto_include`, fitting measurements not left out are selected; returns them. + """ + if selected is None: + selected = self.selected_names() + guide = self.guide() + guide_id = guide.id if guide is not None else None + added: List[str] = [] + if guide is None: + shown = list(self._study.comparisons) + fitting_names: set[str] = {c.name for c in shown} + else: + fitting = [ + c for c in self._study.comparisons if c.experiment_type in guide.experiment_types + ] + fitting_names = {c.name for c in fitting} + if self._excluded is None or (guide_id != self._rows_guide and not self._syncing): + self._excluded = (self._excluded or set()) | (fitting_names - set(selected)) + if auto_include: + added = [ + c.name for c in fitting + if c.name not in self._excluded and c.name not in selected + ] + selected = [*selected, *added] + shown = [ + c for c in self._study.comparisons + if c.name in fitting_names or c.name in selected + ] + self._rows_guide = guide_id + self._fitting = fitting_names if guide is not None else set() + + syncing, self._syncing = self._syncing, True + rows, self._comparison_boxes = [], {} + for comparison in shown: + box = W.Checkbox( + value=comparison.name in selected, indent=False, + layout=W.Layout(width="auto"), disabled=self.stage.disabled, + ) + box.observe(self._on_comparison_toggle, names="value") + self._comparison_boxes[comparison.name] = box + problems = self._study.measurement_problems(comparison) + chip = ( + status_html("warn", f"{len(problems)} problem(s): " + " ".join(problems)) + if problems else status_html("ok", "ready") + ) + detail = html.escape(measurement_caption(comparison)) + children = [ + box, + W.HTML(f"{html.escape(comparison.name)} {detail}"), + W.HTML(chip), + ] + if comparison.name not in fitting_names: + children.append(W.HTML(_does_not_fit_html(comparison))) + rows.append(W.HBox(children, layout=W.Layout(align_items="center"))) + if not rows and guide is None: + rows = [W.HTML("No measurements in this study yet.")] + self._comparison_rows.children = rows + self._syncing = syncing + + if guide is None: + self._comparison_note.value = "" + self._no_fitting.value = "" + else: + self._comparison_note.value = ( + "Measurements whose experiment type feeds this step. All are " + "included; untick one to leave it out (e.g. a bad replicate). New ones " + "are included when you add them." + ) + labels = ", ".join(EXPERIMENT_TYPES[t].label for t in guide.experiment_types) + self._no_fitting.value = "" if fitting_names else status_html( + "warn", f"No measurement fits this step yet. It needs: {labels}." + ) + show_add = guide is not None and not fitting_names and self._on_add_measurement + self._btn_add_measurement.layout.display = "" if show_add else "none" + return added + + def refresh_requires(self, setup: StepSetup) -> None: + """Offer every declared path and every other step's outputs as requirements.""" + paths = set(self._study.initial_store.specs) | set(self._study.initial_store.entries) + for step in self._study.steps: + if step.name == setup.name: + continue + try: + paths.update(step.provides) + except ValueError: + pass + paths.update(setup.requires) + options = sorted(paths) + if self._require_paths != options: + syncing, self._syncing = self._syncing, True + kept = tuple(p for p in self.requires.value if p in options) + self._require_paths = options + self.requires.options = [(parameter_label(p), p) for p in options] + self.requires.value = kept + self._syncing = syncing + + def show_chain(self, provides: List[str]) -> None: + """Show what the step uses from earlier steps and what it determines.""" + self._provides = list(provides) + + def names(paths: Any) -> str: + return ", ".join(html.escape(parameter_label(p)) for p in paths) or "nothing" + + self.provides.value = f"Determines: {names(provides)}" + self._chain_line.value = ( + f"Uses from earlier steps: {names(self.requires.value)} · " + f"Determines: {names(provides)}" + ) if self.guided else "" + + def selected_names(self) -> List[str]: + return [name for name, box in self._comparison_boxes.items() if box.value] + + def _selected(self) -> List[Comparison]: + by_name = {c.name: c for c in self._study.comparisons} + return [by_name[n] for n in self.selected_names() if n in by_name] + + def problems_of_selected(self) -> Dict[str, List[str]]: + found = {c.name: self._study.measurement_problems(c) for c in self._selected()} + return {name: problems for name, problems in found.items() if problems} + + def _rebuild_options(self, options: dict) -> None: + stage = STAGES[self.stage.value] + selected = self._selected() + fields: Dict[str, Any] = {} + for name, param in stage.options.items(): + current = options.get(name, param.default) + if name == "tubing": + units = [u for u in UNIT_LABELS if u.startswith("tubing_")] + for comparison in selected: + present = _tubing_units(comparison) + units = [u for u in units if u in present] + field = ChoiceField( + label=f"{option_label(name)}:", options=[(UNIT_LABELS[u], u) for u in units], + value=current if current in units else None, + ) + elif name == "component_index": + components = list(selected[0].recipe.components) if selected else [] + count = max(len(components), int(current or 0) + 1) + labels = components + [str(i) for i in range(len(components), count)] + field = ChoiceField( + label=f"{option_label(name)}:", + options=[(lab, i) for i, lab in enumerate(labels)], + value=int(current or 0), + ) + elif _is_bool(param): + field = BoolField(label=option_label(name), value=bool(current)) + else: + field = W.Text(description=option_label(name), value=str(current or "")) + field.observe( + self._on_option_change, + names="selected_index" if isinstance(field, ChoiceField) else "value", + ) + fields[name] = field + self._option_fields = fields + self._options_box.children = [ + W.VBox([ + field, + W.HTML(f"{html.escape(option_help(stage.id, name))}"), + ]) + for name, field in fields.items() + ] + + def _options(self) -> dict: + options = {} + for name, field in self._option_fields.items(): + if field.value is not None and field.value != "": + options[name] = field.value + return options + + def _rebuild_variables(self, bounds: dict, frozen: Any) -> None: + self._bound_fields, self._fit_boxes, self._current_cells = {}, {}, {} + try: + self._variables = describe_stage(self.stage.value, **self._options()) + except ValueError as exc: + self._variables = [] + self._variables_box.children = [W.HTML(status_html("warn", str(exc)))] + return + + header = [ + term_html("fixed variable", "Fit"), + "Variable", "Current value", "Parameter path", "Lower bound", "Upper bound", + ] + cells: List[W.Widget] = [W.HTML(f"{h}") for h in header] + for var in self._variables: + path = html.escape(var.parameter_path or "(helper)") + current = W.HTML("—") + if var.parameter_path is not None: + self._current_cells[var.name] = current + if var.is_dependent: + cells += [ + W.HTML("dependent"), W.Label(var.name), current, + W.HTML(f"{path}"), W.Label("—"), W.Label("—"), + ] + continue + lb, ub = bounds.get(var.name, (var.lb, var.ub)) + lb_field = W.FloatText(value=float(lb), layout=W.Layout(width="120px")) + ub_field = W.FloatText(value=float(ub), layout=W.Layout(width="120px")) + fit = W.Checkbox( + value=var.name not in frozen, description="Fit", indent=False, + tooltip=_FIT_TOOLTIP, layout=W.Layout(width="auto"), + ) + for control in (lb_field, ub_field): + control.observe(self._edited, names="value") + fit.observe(self._on_fit_toggle, names="value") + self._bound_fields[var.name] = (lb_field, ub_field) + self._fit_boxes[var.name] = fit + cells += [ + fit, W.Label(var.name), current, W.HTML(f"{path}"), + lb_field, ub_field, + ] + self._variables_box.children = [ + W.HTML(_VARIABLES_HELP), + W.GridBox(cells, layout=W.Layout( + grid_template_columns="70px 200px 200px 1fr 130px 130px", align_items="center", + )), + ] + self.refresh_current_values() + + def _bounds(self) -> dict: + defaults = {v.name: (v.lb, v.ub) for v in self._variables} + bounds = {} + for name, (lb, ub) in self._bound_fields.items(): + pair = (float(lb.value), float(ub.value)) + if pair != defaults.get(name): + bounds[name] = pair + return bounds + + def _frozen(self) -> tuple: + return tuple(n for n, box in self._fit_boxes.items() if not box.value) + + def _sync_optimizers(self) -> None: + n = len(self.selected_names()) + allowed = list(OPTIMIZERS) if n == 1 else [MULTI_OBJECTIVE] + current = self.optimizer.value + self.optimizer.set_options([(name, name) for name in allowed]) + self.optimizer.value = current if current in allowed else MULTI_OBJECTIVE + self._on_optimizer_change(None) + + def get_setup(self) -> StepSetup: + """Return the setup the controls describe; comparisons resolve against the study.""" + return StepSetup( + name=self.name.value.strip(), + stage=self.stage.value, + options=self._options(), + comparisons=self._selected(), + bounds=self._bounds(), + frozen=self._frozen(), + requires=tuple(self.requires.value), + optimizer=self.optimizer_settings(), + excluded=None if self._excluded is None else tuple( + c.name for c in self._study.comparisons if c.name in self._excluded + ), + ) + + def optimizer_settings(self) -> dict: + """Return the chosen optimizer as `StepSetup.optimizer`.""" + name = self.optimizer.value + knobs = OPTIMIZERS[name].knobs + return default_optimizer( + name, **{k.attr: int(f.value) for k, f in zip(knobs, self._knob_fields[name])} + ) + + def set_locked(self, locked: bool) -> None: + """Make every control read-only (or editable again).""" + controls: List[Any] = [self.name, self.stage, self.requires, self.optimizer] + controls += list(self._option_fields.values()) + list(self._comparison_boxes.values()) + controls += [f for pair in self._bound_fields.values() for f in pair] + controls += list(self._fit_boxes.values()) + controls += [f for fields in self._knob_fields.values() for f in fields] + for control in controls: + control.disabled = locked diff --git a/cadetgui/widgets/composite/add_measurement.py b/cadetgui/widgets/composite/add_measurement.py new file mode 100644 index 0000000..21392c0 --- /dev/null +++ b/cadetgui/widgets/composite/add_measurement.py @@ -0,0 +1,819 @@ +"""The add-measurement stepper of the Measurements pane.""" + +from __future__ import annotations + +import copy +import dataclasses +import html +import math +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple + +import ipywidgets as W + +from ...characterization.comparison import Comparison +from ...characterization.guide import ( + CHAIN_BY_ID, + COMPONENT_ROLES, + DEFAULT_CHAIN, + EXPERIMENT_TYPES, + SALT, + SALT_ROLE, + ExperimentType, + find_injection_marker, + measurement_from_run, + role_fix, + with_implied_values, +) +from ...characterization.study import Study +from ...io.configuration_store import ConfigurationState +from ...io.experimental_data import ExperimentalRun +from ...process_builder import check_recipe +from .._help import term_html +from .._status import status_html +from ..elements import ( + ChoiceField, + ChromatogramChart, + SelectableTable, + TextField, +) +from ._measurement_common import ( + ML_PER_MIN, + _check_float, + _detector_hint, + _format, + _note, + _observed_unit, + _optional_float, + _recipe_flow_rate, + _row, + _same, + _template_options, + _title, + component_options, + observation_label, + problems_html, + recipe_summary_html, + recipe_with_template, +) +from ._measurement_upload import _RunUpload +from .system_diagram import recipe_diagram_svg + +if TYPE_CHECKING: + pass + + +_OTHER = "__other__" +_NEW = "__new__" +_MAX_PREVIEW_POINTS = 2000 + +_PAGES = ("Upload", "Channel", "Experiment type", "Details", "Confirm") + + +class AddMeasurementFlow(_RunUpload): + """In-pane stepper: data file → channel → experiment type → details → a new comparison. + + Started for a step (or one of its experiment types), the experiment-type page is + skipped: the Details page shows the step's type, or a choice among its types + preselected by the channel's detector when that is unambiguous. + + `runs()` lists already loaded runs by data file; `base_recipe()` returns the recipe + to start from and a sentence saying where it comes from; `on_added(comparison, + notes)` is called after the comparison is in `study`. + """ + + def __init__( + self, + study: Study, + *, + runs: Callable[[], Dict[str, Optional[ExperimentalRun]]], + base_recipe: Callable[[], Tuple[Optional[ConfigurationState], str]], + on_added: Callable[[Comparison, List[str]], None], + on_change: Optional[Callable[[], None]] = None, + ) -> None: + self.study = study + self._runs = runs + self._base_recipe = base_recipe + self._on_added = on_added + self._on_change = on_change + self._start_step: Optional[str] = None + self._fixed_step: Optional[str] = None + self._visible_pages: Tuple[int, ...] = tuple(range(len(_PAGES))) + self._default_type: Optional[str] = None + self._typed_for_channel: Optional[str] = None + self._type_chosen = False + self._title = W.HTML() + self.page = 0 + self._prefilled_for: Optional[Tuple[Any, ...]] = None + self._loading = False + + upload_page = self._init_upload() + + self._channel = ChoiceField(label="Channel:") + self._channel_chart = ChromatogramChart(view_width=480, view_height=200, y_label="Signal") + self._markers = _note() + + self._type_table = SelectableTable( + columns=["Feeds step", "Experiment type", "Detector"], + empty_text="No experiment types.", + ) + self._type_help = W.HTML() + self._other_template = ChoiceField(label="Process template:") + self._other_template_note = _note( + "Runs added as “Other” get no experiment type, so pick the process (method) " + "the run followed. It starts with the template's default timing and " + "concentrations; change them under Advanced after adding." + ) + self._other_template_box = W.VBox( + [_row(self._other_template), self._other_template_note], + layout=W.Layout(display="none"), + ) + + self._step_type = W.RadioButtons(layout=W.Layout(width="auto")) + self._step_type.add_class("cadetgui-step-type") + self._step_type_info = W.HTML() + self._step_type_box = W.VBox([self._step_type_info, self._step_type], + layout=W.Layout(display="none")) + + self._name = TextField(label="Name:", validate=self._check_name) + self._component = ChoiceField(label="Component:") + self._new_component = TextField(label="New component:", + validate=self._check_new_component) + self._new_role = ChoiceField(label="Role:") + self._component_note = _note() + self._role_fix = W.HTML() + self._role_fix_target: Optional[Tuple[str, str]] = None + self._btn_role_fix = W.Button( + icon="exchange", button_style="warning", + layout=W.Layout(width="auto", display="none"), + ) + self._btn_role_fix.on_click(lambda _b: self.make_component_fit()) + self._flow_rate = TextField(label="Flow rate:", units="mL/min", validate=_check_float) + self._marker = ChoiceField(label="Injection marker:") + self._details_note = _note() + + self._base_note = W.HTML() + self._summary = W.HTML() + self._diagram = W.HTML() + self._confirm_problems = W.HTML() + self._implied = W.Checkbox( + value=False, indent=False, + description="Also add the values this experiment type assumes to the parameter store", + layout=W.Layout(width="auto"), + ) + self._implied_note = _note() + + self._progress = W.HTML() + self._btn_back = W.Button(description="Back", icon="arrow-left") + self._btn_next = W.Button(description="Next", icon="arrow-right", button_style="primary") + self._btn_add = W.Button(description="Add measurement", icon="check", + button_style="success") + self._btn_cancel = W.Button(description="Cancel", icon="times") + self.status = W.HTML() + self.status.add_class("cadetgui-status") + + self._channel.observe(lambda _c: self._show_channel(), names="selected_index") + self._type_table.observe(lambda _c: self._show_type_help(), names="selected_index") + self._type_table.observe(lambda _c: self._target_changed(), names="selected_index") + self._component.observe(lambda _c: self._sync_new_component(), names="selected_index") + self._new_component.observe(lambda _c: self._refresh_role_fix(), names="value") + self._step_type.observe(self._on_step_type, names="value") + self._btn_back.on_click(lambda _b: self.back()) + self._btn_next.on_click(lambda _b: self.next()) + self._btn_add.on_click(lambda _b: self.confirm()) + self._btn_cancel.on_click(lambda _b: self.close()) + + self._pages = [ + upload_page, + W.VBox([ + _note("Pick the detector channel this measurement compares against."), + _row(self._channel), + self._channel_chart, + self._markers, + ]), + W.VBox([ + _note("What did you run? The experiment type sets the flow path, process " + "template and observation point, and which characterization step the " + "measurement feeds. Types matching the channel's detector are marked."), + self._type_table, + self._type_help, + self._other_template_box, + ]), + W.VBox([ + self._step_type_box, + _row(self._name, self._component), + _row(self._new_component, self._new_role), + self._component_note, + _row(self._role_fix, self._btn_role_fix), + _row(self._flow_rate, self._marker), + self._details_note, + ]), + W.VBox([ + self._base_note, + _title(term_html("recipe", "Recipe") + " of the new measurement"), + self._summary, + self._diagram, + self._confirm_problems, + self._implied, + self._implied_note, + ]), + ] + nav = W.HBox([self._btn_back, self._btn_next, self._btn_add, self._btn_cancel]) + nav.add_class("cadetgui-toolbar") + self.root = W.VBox([ + self._title, + self._progress, + *self._pages, + self.status, + nav, + ]) + self.root.add_class("cadetgui-section") + self.root.layout.display = "none" + + # Navigation + + @property + def is_open(self) -> bool: + """Whether the stepper is shown.""" + return self.root.layout.display != "none" + + def start( + self, *, step_id: Optional[str] = None, experiment_type: Optional[str] = None + ) -> None: + """Open the stepper at the upload page. + + With a `step_id` or an `experiment_type` of a chain step, the experiment-type page + is left out and the type is picked among that step's types on the Details page. + An explicit `experiment_type` is preselected; with only a `step_id`, the channel's + detector decides when it matches exactly one of the step's types, otherwise the + step's first type is used. + """ + self._explicit_type = experiment_type + if experiment_type is None and step_id in CHAIN_BY_ID: + experiment_type = CHAIN_BY_ID[step_id].experiment_types[0] + self._start_step = step_id if step_id in CHAIN_BY_ID else ( + EXPERIMENT_TYPES[experiment_type].step_id if experiment_type in EXPERIMENT_TYPES + else None + ) + guide = CHAIN_BY_ID.get(self._start_step or "") + fixed = guide is not None and experiment_type in guide.experiment_types + self._fixed_step = self._start_step if fixed else None + self._default_type = experiment_type if fixed else None + self._typed_for_channel = None + self._type_chosen = False + self._visible_pages = (0, 1, 3, 4) if fixed else tuple(range(len(_PAGES))) + self._step_type_box.layout.display = "" if fixed else "none" + self.run, self.data_file, self._pending = None, "", None + self._roles_box.children = () + self._run_note.value = "" + self._prefilled_for = None + self._implied.value = False + self.status.value = "" + self._other_template.set_options([], keep_value=False) + options: List[Tuple[str, Optional[str]]] = [("Choose…", None)] + options += [(key, key) for key, run in self._runs().items() if run is not None] + self._loading = True + try: + self._loaded.set_options(options, keep_value=False) + self._fill_types(experiment_type) + finally: + self._loading = False + self.root.layout.display = "" + self._go(0) + self._target_changed() + + def close(self) -> None: + """Hide the stepper without adding anything.""" + self.root.layout.display = "none" + self._target_changed() + + @property + def target_step(self) -> Optional[str]: + """The chain step the measurement being added feeds, if known.""" + experiment_type = self.experiment_type + if experiment_type is not None: + return experiment_type.step_id + if self._type_table.value == _OTHER: + return None + return self._start_step + + def _target_changed(self) -> None: + guide = CHAIN_BY_ID.get(self.target_step or "") + target = ( + f" for the step {html.escape(guide.title)}" if guide is not None + else " (not assigned to a step)" if self._type_table.value == _OTHER + else "" + ) + self._title.value = ( + "
    Add a " + term_html("measurement") + + target + "
    " + ) + if self._on_change is not None: + self._on_change() + + @property + def pages(self) -> List[str]: + """The names of the pages this stepper walks through.""" + return [_PAGES[i] for i in self._visible_pages] + + def _step_by(self, delta: int) -> None: + pages = self._visible_pages + index = pages.index(self.page) if self.page in pages else 0 + self._go(pages[min(max(index + delta, 0), len(pages) - 1)]) + + def back(self) -> None: + """Go to the previous page.""" + self._step_by(-1) + + def next(self) -> None: + """Go to the next page if the current one is complete.""" + error = self._page_error() + if error: + self.status.value = status_html("error", error) + return + self._step_by(1) + + def _go(self, page: int) -> None: + self.page = page + self.status.value = "" + if page == 1: + self._fill_channels() + elif page == 2: + self._fill_types(self._type_table.value) + elif page == 3: + self._fill_step_types() + self._prefill_details() + elif page == 4: + self._show_confirm() + for i, box in enumerate(self._pages): + box.layout.display = "" if i == page else "none" + self._btn_back.disabled = page == 0 + self._btn_next.layout.display = "none" if page == len(_PAGES) - 1 else "" + self._btn_add.layout.display = "" if page == len(_PAGES) - 1 else "none" + self._progress.value = " › ".join( + f"{n} {_PAGES[i]}" if i == page else f"{n} {_PAGES[i]}" + for n, i in enumerate(self._visible_pages, start=1) + ) + + def _page_error(self) -> Optional[str]: + if self.page == 0 and self.run is None: + return "Upload a data file or pick a loaded run first." + if self.page == 1 and self._channel.value is None: + return "Pick a channel." + if self.page == 2 and self._type_table.value is None: + return "Pick what you ran (the experiment type)." + if self.page == 3: + fields = [self._name, self._flow_rate] + if self._component.value == _NEW: + fields.append(self._new_component) + invalid = [f.label.rstrip(":") for f in fields if not f.is_valid] + if invalid: + return f"Fix {', '.join(invalid)} first." + if self.experiment_type is not None and self._component.value is None: + return "Pick the component you injected." + text, target = self._component_role_fix() + if text and self._component.value != _NEW: + return text + ( + " Change its role with the button, or pick another component." + if target is not None else "" + ) + return None + + # 2 Channel + + def _fill_channels(self) -> None: + channels = list(self.run.channels) if self.run is not None else [] + self._loading = True + try: + self._channel.set_options([(c, c) for c in channels]) + finally: + self._loading = False + self._show_channel() + + def _show_channel(self) -> None: + run, channel = self.run, self._channel.value + if run is None or channel not in run.channels: + self._channel_chart.series = [] + return + trace = run.channels[channel] + stride = max(1, math.ceil(trace.x.size / _MAX_PREVIEW_POINTS)) + volume = run.x_basis == "volume" + self._channel_chart.x_label = f"{'Volume' if volume else 'Time'} / {run.x_unit}" + self._channel_chart.x_name = "V" if volume else "t" + self._channel_chart.x_unit = run.x_unit + self._channel_chart.y_label = trace.unit or channel + self._channel_chart.series = [{ + "name": channel, + "times": trace.x[::stride].tolist(), + "values": trace.values[::stride].tolist(), + "reference": True, + }] + self._markers.value = ( + "Markers found in the run log: " + ", ".join( + html.escape(f"{text} @ {x:g} {run.x_unit}") for x, text in run.markers + ) if run.markers else "No run-log markers in this file; the injection position " + "can be entered by hand under Advanced after adding." + ) + + # 3 Experiment type + + def _fill_types(self, select: Optional[str]) -> None: + hint = _detector_hint(self._channel.value or "") + options: List[Tuple[str, str]] = [] + rows: List[List[Any]] = [] + chained = [t for g in DEFAULT_CHAIN for t in g.experiment_types] + unchained = [t for t in EXPERIMENT_TYPES if t not in chained] + for type_id in (*chained, *unchained): + experiment_type = EXPERIMENT_TYPES[type_id] + guide = CHAIN_BY_ID.get(experiment_type.step_id) + detector: Any = experiment_type.detector + if hint is not None and hint == experiment_type.detector: + detector = (f"{experiment_type.detector} · matches channel", "ok") + options.append((experiment_type.label, type_id)) + rows.append([guide.title if guide else "Not assigned to a step", + experiment_type.label, detector]) + options.append(("Other: set up everything by hand", _OTHER)) + rows.append(["Not assigned to a step", "Other: set up everything by hand", "–"]) + was_loading = self._loading + self._loading = True + try: + self._type_table.set_options(options, rows=rows, select_none=True) + if select is not None: + self._type_table.value = select + finally: + self._loading = was_loading + self._show_type_help() + + def _show_type_help(self) -> None: + type_id = self._type_table.value + self._other_template_box.layout.display = "" if type_id == _OTHER else "none" + if type_id is None: + self._type_help.value = "" + return + if type_id == _OTHER: + self._fill_other_templates() + self._type_help.value = ( + "

    The run uses your System setup with the process " + "template picked below. Set the observation point, probe and everything " + "else under Advanced after adding. The measurement is not assigned to a " + "step until you set an experiment type.

    " + ) + return + experiment_type = EXPERIMENT_TYPES[type_id] + guide = CHAIN_BY_ID.get(experiment_type.step_id) + parts = [ + f"

    What you run: {html.escape(experiment_type.what_you_run)}

    ", + f"

    Feeds step: {html.escape(guide.title if guide else 'none')} · " + f"{term_html('probe', 'Probe')}: {html.escape(experiment_type.probe_role)}" + f" · {term_html('observation point', 'Observation point')}: " + f"{html.escape(observation_label(experiment_type.solution_path))} " + f"({html.escape(experiment_type.detector)})

    ", + ] + if experiment_type.help: + parts.append(f"

    {html.escape(experiment_type.help)}

    ") + self._type_help.value = "".join(parts) + + def _fill_other_templates(self) -> None: + base, _ = self._base_recipe() + if base is None: + self._other_template.set_options([], keep_value=False) + return + keep = self._other_template.value + options = _template_options(base) + self._other_template.set_options(options, keep_value=False) + values = [v for _, v in options] + self._other_template.value = keep if keep in values else base.template_key + + @property + def experiment_type(self) -> Optional[ExperimentType]: + """The chosen experiment type (`None` for 'Other' or no choice).""" + return EXPERIMENT_TYPES.get(self._type_table.value or "") + + # 4 Details + + def _fill_step_types(self) -> None: + """Offer the fixed step's experiment types; preselect by the channel's detector.""" + if self._fixed_step is None: + return + types = list(CHAIN_BY_ID[self._fixed_step].experiment_types) + channel = self._channel.value + if not self._type_chosen and channel != self._typed_for_channel: + self._typed_for_channel = channel + hint = _detector_hint(channel or "") + matches = [t for t in types if EXPERIMENT_TYPES[t].detector == hint] + if self._explicit_type in types: + choice = self._explicit_type + else: + choice = matches[0] if len(matches) == 1 else (self._default_type or types[0]) + self._type_table.value = choice + was_loading = self._loading + self._loading = True + try: + self._step_type.options = [(EXPERIMENT_TYPES[t].label, t) for t in types] + self._step_type.value = self._type_table.value + finally: + self._loading = was_loading + self._step_type.layout.display = "" if len(types) > 1 else "none" + self._show_step_type() + + def _show_step_type(self) -> None: + types = CHAIN_BY_ID[self._fixed_step].experiment_types if self._fixed_step else () + if len(types) == 1: + experiment_type = EXPERIMENT_TYPES[types[0]] + self._step_type_info.value = ( + f"

    Experiment: {html.escape(experiment_type.label)} — what you run: " + f"{html.escape(experiment_type.what_you_run)}

    " + ) + return + lines = "".join( + f"
  • {html.escape(EXPERIMENT_TYPES[t].label)}: " + f"{html.escape(EXPERIMENT_TYPES[t].what_you_run)}
  • " + for t in types + ) + self._step_type_info.value = ( + "

    Which experiment did you run? Preselected from the channel's " + f"detector where it tells them apart.

      {lines}
    " + ) + + def _on_step_type(self, change: dict) -> None: + if self._loading or change["new"] is None: + return + self._type_chosen = True + self._type_table.value = change["new"] + if self.page == 3: + self._prefill_details() + + def _check_name(self, name: str) -> Optional[str]: + if not name.strip(): + return "A name is required." + if any(c.name == name.strip() for c in self.study.comparisons): + return f"{name!r} is already used." + return None + + def _check_new_component(self, name: str) -> Optional[str]: + if self._component.value != _NEW: + return None + name = name.strip() + if not name: + return "Name the new component (the injected substance)." + if name == SALT: + return f"{SALT!r} is reserved for the salt component." + existing = self.study.component(name) + if existing is None: + return None + experiment_type = self.experiment_type + if experiment_type is not None and existing.role not in experiment_type.probe_roles: + return ( + f"{name!r} is already declared as {existing.role}; see below how to use it, " + "or give the new component another name." + ) + return f"{name!r} is already declared as {existing.role}; pick it from the list." + + def _fill_components(self, prefer: Optional[str]) -> None: + """Offer the declared components whose role fits the chosen type, plus a new one.""" + experiment_type = self.experiment_type + roles = experiment_type.probe_roles if experiment_type is not None else tuple( + r for r in COMPONENT_ROLES if r != SALT_ROLE + ) + fitting = [c for c in self.study.components if c.role in roles] + options: List[Tuple[str, Optional[str]]] = [] + if experiment_type is None: + options.append(("None", None)) + options += component_options(self.study, roles) + options.append(("New component…", _NEW)) + names = [c.name for c in fitting] + value = prefer if prefer in names else (names[0] if names else _NEW) + if experiment_type is None and prefer is None: + value = None + self._component.set_options(options, keep_value=False) + self._component.value = value + self._new_role.set_options([(r, r) for r in roles], keep_value=False) + self._new_component.value = "" + if experiment_type is not None and not fitting: + wanted = html.escape(" or ".join(roles)) + label = html.escape(experiment_type.label) + self._component_note.value = ( + f"No declared component is a {wanted}; {label} needs one. Name it below; " + "it is added to System → Components." + ) + if len(options) > 1: + self._component_note.value += ( + " Or pick a declared component marked \"not a …\" and change its role." + ) + else: + self._component_note.value = ( + "The " + term_html("component") + " links this measurement's fitted values " + "to the other steps; pick the same one wherever the same molecule is injected." + ) + self._sync_new_component() + + def _sync_new_component(self) -> None: + new = self._component.value == _NEW + self._new_component.layout.display = "" if new else "none" + self._new_role.layout.display = "" if new else "none" + self._new_component._run_validate() + self._refresh_role_fix() + + def _component_role_fix(self) -> Tuple[str, Optional[str]]: + """Return `role_fix` for the picked (or typed, already declared) component.""" + experiment_type, name = self.experiment_type, self.component + if experiment_type is None or not name: + return "", None + return role_fix(self.study, name, experiment_type) + + def _refresh_role_fix(self) -> None: + text, target = self._component_role_fix() + name = self.component + self._role_fix_target = (name, target) if target is not None and name else None + self._role_fix.value = status_html("warn", text) if text else "" + if self._role_fix_target is not None: + self._btn_role_fix.description = f"Make {name} a {target}" + self._btn_role_fix.layout.display = "" if self._role_fix_target else "none" + + def make_component_fit(self) -> None: + """Give the picked component the role this experiment needs, then select it.""" + if self._role_fix_target is None: + return + name, role = self._role_fix_target + self.study.set_component_role(name, role) + self._fill_components(name) + self.status.value = status_html("ok", f"{name} is now a {role}.") + + @property + def component(self) -> Optional[str]: + """The chosen component's name (the new one's when "New component…" is picked).""" + value = self._component.value + return self._new_component.value.strip() or None if value == _NEW else value + + def new_component(self, name: str, role: Optional[str] = None) -> None: + """Pick "New component…" with `name` (and `role`, else the type's first role).""" + self._component.value = _NEW + self._new_component.value = name + if role is not None: + self._new_role.value = role + + def _unique_name(self, base: str) -> str: + names = {c.name for c in self.study.comparisons} + if base not in names: + return base + i = 2 + while f"{base} {i}" in names: + i += 1 + return f"{base} {i}" + + def _prefill_details(self) -> None: + experiment_type = self.experiment_type + base, _ = self._base_recipe() + key = (self._type_table.value, self.data_file, self._channel.value, id(base)) + if key != self._prefilled_for: + self._prefilled_for = key + label = self.run.label if self.run is not None else "Measurement" + self._name.value = self._unique_name(label) + probes = [ + c.probe for c in self.study.comparisons + if c.probe and experiment_type is not None + and c.experiment_type == experiment_type.id + ] + self._fill_components(probes[0] if probes else None) + flow_rate = None + if base is not None: + recipe = ( + experiment_type.apply(base, component="Probe") + if experiment_type is not None else base + ) + flow_rate = _recipe_flow_rate(recipe) + self._flow_rate.value = _format( + flow_rate / ML_PER_MIN if flow_rate is not None else None + ) + self._fill_markers(experiment_type) + self._new_component._run_validate() + self._details_note.value = ( + "The flow rate is prefilled from the recipe; change it if this run used " + "another one. The " + term_html("injection marker") + " was picked from the run " + "log by the experiment type's keywords; change it if it is wrong." + ) + + def _fill_markers(self, experiment_type: Optional[ExperimentType]) -> None: + run = self.run + options: List[Tuple[str, Optional[str]]] = [("None / manual", None)] + options += [ + (f"{text} (at {x:g} {run.x_unit} in the run log)", text) for x, text in run.markers + ] + keywords = experiment_type.marker_keywords if experiment_type is not None else ( + "inject", "sample", "elution" + ) + self._marker.set_options(options, keep_value=False) + self._marker.value = find_injection_marker(run, keywords) + + # 5 Confirm + + def draft(self) -> Comparison: + """Return the comparison the current choices describe; raises without a base recipe.""" + base, _ = self._base_recipe() + if base is None or self.run is None: + raise ValueError("A base recipe and a run are needed.") + name = self._name.value.strip() + channel = self._channel.value or "" + marker = self._marker.value + flow_rate = _optional_float(self._flow_rate.value) + flow_rate = flow_rate * ML_PER_MIN if flow_rate is not None else None + experiment_type = self.experiment_type + if experiment_type is None: + recipe = copy.deepcopy(base) + template = self._other_template.value + if template is not None and template != base.template_key: + recipe = recipe_with_template(recipe, template) + recipe_flow = _recipe_flow_rate(recipe) + options = list(check_recipe(recipe).signal_options) + unit, port = options[0][1] if options else ("outlet", "inlet") + comparison = Comparison( + name=name, + data_file=self.data_file, + channel=channel, + recipe=recipe, + solution_path=f"{unit}.{port}", + flow_rate=None if _same(flow_rate, recipe_flow) else flow_rate, + injection_marker=marker, + probe=self.component, + run=self.run, + ) + return comparison + component = self.component or "" + recipe_flow = experiment_type.apply(base, component=component).model_values.get( + "flow_rate" + ) + comparison = measurement_from_run( + name, self.run, channel, experiment_type, base, + component=component, + flow_rate=None if _same(flow_rate, recipe_flow) else flow_rate, + injection_marker=marker, + ) + return dataclasses.replace(comparison, data_file=self.data_file, injection_marker=marker) + + def _show_confirm(self) -> None: + base, source = self._base_recipe() + self._base_note.value = ( + "

    Base " + term_html("recipe") + ": " + + html.escape(source, quote=False) + ".

    " + ) + experiment_type = self.experiment_type + implied = experiment_type.implied if experiment_type is not None else () + self._implied.layout.display = "" if implied else "none" + self._implied_note.value = ( + "This experiment type assumes: " + "; ".join( + html.escape(f"{item.path} = {item.value:g} ({item.note})") for item in implied + ) + ". Nothing is written unless you tick the box." + if implied else "" + ) + if base is None: + self._summary.value = self._diagram.value = "" + self._confirm_problems.value = status_html( + "error", "No base recipe: build a process in Process Configuration first." + ) + self._btn_add.disabled = True + return + comparison = self.draft() + self._summary.value = recipe_summary_html(comparison) + self._diagram.value = recipe_diagram_svg( + comparison.recipe, observe=_observed_unit(comparison.solution_path), compact=True + ) + self._confirm_problems.value = problems_html(comparison.problems()) + self._btn_add.disabled = False + + def confirm(self) -> Optional[Comparison]: + """Add the drafted comparison to the study (and implied values if ticked).""" + for page in self._visible_pages[:-1]: + self.page = page + error = self._page_error() + if error: + self._go(page) + self.status.value = status_html("error", error) + return None + self.page = len(_PAGES) - 1 + base, _ = self._base_recipe() + if base is None: + self.status.value = status_html( + "error", "No base recipe: build a process in Process Configuration first." + ) + return None + comparison = self.draft() + notes: List[str] = [] + if self._component.value == _NEW and comparison.probe: + self.study.add_component(comparison.probe, self._new_role.value) + notes.append( + f"Declared {comparison.probe} ({self._new_role.value}) under System → " + "Components." + ) + self.study.upsert_comparison(comparison) + experiment_type = self.experiment_type + if experiment_type is not None and experiment_type.implied and self._implied.value: + self.study.initial_store = with_implied_values( + self.study.initial_store, experiment_type, comparison.probe + ) + self.study.notify() + notes.append( + "Added to the parameter store (assumed by the experiment type): " + + ", ".join(f"{i.path} = {i.value:g}" for i in experiment_type.implied) + "." + ) + self.close() + self._on_added(comparison, notes) + return comparison diff --git a/cadetgui/widgets/composite/bench.py b/cadetgui/widgets/composite/bench.py new file mode 100644 index 0000000..861e9a5 --- /dev/null +++ b/cadetgui/widgets/composite/bench.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import Any, Optional + +import ipywidgets as W + +from .._chrome import logo_data_uri, style_tag +from ..shell import SidebarShell +from .workspace_header import hoisted_header_rows + +__all__ = ["bench_root"] + + +def bench_root(title: str, shell: SidebarShell, configuration: Optional[Any] = None) -> W.VBox: + """Frame `shell` as a workbench: top bar with `title`, the configuration's header, panes.""" + top_bar = W.HTML( + "
    " + f"CADET" + f"{title}" + "
    " + ) + root = W.VBox([ + W.HTML(style_tag()), top_bar, *hoisted_header_rows(configuration), shell.body + ]) + root.add_class("cadetgui-panel") + root.add_class("cadetgui-workbench") + return root diff --git a/cadetgui/widgets/composite/characterization_guide_pane.py b/cadetgui/widgets/composite/characterization_guide_pane.py new file mode 100644 index 0000000..e3d93d0 --- /dev/null +++ b/cadetgui/widgets/composite/characterization_guide_pane.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import html +import re +from typing import List + +import ipywidgets as W + +from ...characterization.guide import ( + CHAIN_BY_ID, + DEFAULT_CHAIN, + EXPERIMENT_TYPES, + GLOSSARY, + WORKFLOW_GUIDE, +) +from .._chrome import style_tag +from .._help import checklist_html + +__all__ = ["CharacterizationGuideWidget", "markdown_html"] + +_ORDERED = re.compile(r"^\d+\.\s+") +_BULLET = re.compile(r"^[-*]\s+") +_CELL = "padding:4px 8px;border-bottom:1px solid var(--cg-border);vertical-align:top" + + +def _inline(text: str) -> str: + text = html.escape(text, quote=False) + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + return re.sub(r"`(.+?)`", r"\1", text) + + +def markdown_html(text: str) -> str: + """Convert the small markdown subset the guide uses: headings, paragraphs, lists, bold.""" + out: List[str] = [] + paragraph: List[str] = [] + items: List[List[str]] = [] + list_tag = "" + + def flush() -> None: + nonlocal list_tag + if paragraph: + out.append(f"

    {_inline(' '.join(paragraph))}

    ") + paragraph.clear() + if items: + body = "".join(f"
  • {_inline(' '.join(item))}
  • " for item in items) + out.append(f"<{list_tag}>{body}") + items.clear() + list_tag = "" + + for raw in text.splitlines(): + line = raw.strip() + heading = re.match(r"^(#{1,6})\s+(.*)$", line) + marker = _ORDERED.match(line) or _BULLET.match(line) + if not line: + flush() + elif heading: + flush() + level = min(len(heading.group(1)) + 2, 6) + out.append(f"{_inline(heading.group(2))}") + elif marker: + tag = "ol" if _ORDERED.match(line) else "ul" + if paragraph or (items and tag != list_tag): + flush() + list_tag = tag + items.append([line[marker.end():]]) + elif items and raw[:1].isspace(): + items[-1].append(line) + else: + if items: + flush() + paragraph.append(line) + flush() + return "".join(out) + + +def _glossary_html() -> str: + rows = "".join( + f"
    {html.escape(term)}
    {html.escape(definition)}
    " + for term, definition in sorted(GLOSSARY.items(), key=lambda kv: kv[0].lower()) + ) + return f"
    {rows}
    " + + +def _table(header: List[str], rows: List[List[str]]) -> str: + head = "".join(f"{h}" for h in header) + body = "".join( + "" + "".join(f"{cell}" for cell in row) + "" + for row in rows + ) + return f"{head}{body}
    " + + +def _experiment_types_html() -> str: + rows = [ + [ + f"{html.escape(t.label)}", + html.escape(CHAIN_BY_ID[t.step_id].title if t.step_id in CHAIN_BY_ID else t.step_id), + html.escape(t.what_you_run), + html.escape(t.detector), + ] + for t in EXPERIMENT_TYPES.values() + ] + return _table(["Experiment type", "Feeds step", "What you run", "Detector"], rows) + + +def _chain_html() -> str: + rows = [ + [ + f"{i}. {html.escape(g.title)}", + html.escape(g.purpose), + "
    ".join(html.escape(f"{d.name} ({d.unit})") for d in g.determines), + ] + for i, g in enumerate(DEFAULT_CHAIN, start=1) + ] + return _table(["Step", "Purpose", "Determines"], rows) + + +def _lab_checks_html() -> str: + return "".join( + f"{i}. {html.escape(g.title)}{checklist_html(g.advice)}" + for i, g in enumerate(DEFAULT_CHAIN, start=1) if g.advice + ) + + +class CharacterizationGuideWidget: + """Read-only help pane: workflow, chain, lab checks, optimizers, experiment types, glossary.""" + + def __init__(self) -> None: + def section(title: str, body: str) -> W.HTML: + box = W.HTML(f"
    {html.escape(title)}
    {body}") + box.add_class("cadetgui-section") + return box + + self.workflow = W.HTML(markdown_html(WORKFLOW_GUIDE)) + self.workflow.add_class("cadetgui-section") + self.chain = section("Steps of the chain", _chain_html()) + self.lab_checks = section( + "Lab checks the software cannot do", + "

    Check these yourself before fitting a step.

    " + _lab_checks_html(), + ) + self.optimizers = section( + "Optimizers", + "

    Nelder-Mead minimizes a single objective. Each selected measurement is its own " + "objective, so a step with other than exactly one measurement needs the " + "multi-objective U-NSGA-III.

    ", + ) + self.experiment_types = section("Experiment types", _experiment_types_html()) + self.glossary = section("Glossary", _glossary_html()) + self.root = W.VBox([ + W.HTML(style_tag()), + W.HTML("
    Guide
    "), + self.workflow, + self.chain, + self.lab_checks, + self.optimizers, + self.experiment_types, + self.glossary, + ]) + self.root.add_class("cadetgui-panel") + + def display(self) -> None: + """Render this widget in a Jupyter cell.""" + from IPython.display import display as _display + + _display(self.root) diff --git a/cadetgui/widgets/composite/characterization_setup.py b/cadetgui/widgets/composite/characterization_setup.py new file mode 100644 index 0000000..12ccdf7 --- /dev/null +++ b/cadetgui/widgets/composite/characterization_setup.py @@ -0,0 +1,560 @@ +from __future__ import annotations + +import html +import json +from typing import Any, Callable, Collection, Dict, List, Optional, Sequence, Union + +import ipywidgets as W + +from ...cadetprocessadapter import BYPASSABLE_UNITS +from ...characterization.comparison import Comparison +from ...characterization.guide import ( + DEFAULT_CHAIN, + EXPERIMENT_TYPES, + ChainStepGuide, + guide_for_step, + guide_measurements, + new_step_setup, + step_type_help, +) +from ...characterization.runner import StepSetup +from ...characterization.stages import STAGES +from ...characterization.step_checks import StepStatus, study_steps_status +from ...characterization.study import Study +from ...io.configuration_store import ConfigurationState, InstrumentState +from .._chrome import style_tag +from .._help import determines_table_html, experiments_table_html, term_html +from .._status import status_html +from ._measurement_common import observation_label, system_summary_html +from .system_diagram import recipe_diagram_svg + +__all__ = [ + "CharacterizationSetupWidget", + "step_diagrams_html", + "components_table_html", + "runs_use_text", + "set_up_label", + "add_custom_step", +] + +Navigate = Callable[[str, Dict[str, Any]], None] +FittedSteps = Union[Collection[str], Callable[[], Collection[str]]] + +ADD_MEASUREMENT = "Add measurement for this step" +ADD_ANOTHER = "Add another measurement" +SET_UP = "Set up this step" +OPEN_STEP = "Open step" +TRANSFER = "Transfer species…" + + +def set_up_label(n_measurements: int) -> str: + """Return the "Set up this step" button label naming how many measurements it fits.""" + plural = "s" if n_measurements != 1 else "" + return f"{SET_UP} ({n_measurements} measurement{plural})" + + +def runs_use_text(type_id: str) -> str: + """Return how runs of experiment type `type_id` use the system, in one line.""" + experiment = EXPERIMENT_TYPES[type_id] + in_line = "column" in experiment.in_line or "column" not in experiment.bypass + return ( + f"Runs use: {experiment.template_key} · " + f"{'column in line' if in_line else 'column bypassed'} · read at " + f"{observation_label(experiment.solution_path)} ({experiment.detector})" + ) + + +def _runs_use_html(type_ids: Sequence[str]) -> str: + lines = [] + for type_id in type_ids: + text = runs_use_text(type_id) + if len(type_ids) > 1: + text += f" — {EXPERIMENT_TYPES[type_id].label}" + lines.append(f"
    {html.escape(text)}
    ") + return "".join(lines) + + +def _observed_unit(solution_path: Optional[str]) -> Optional[str]: + return solution_path.split(".")[0] if solution_path else None + + +def _type_recipe(type_id: str) -> ConfigurationState: + """Return a recipe carrying only the flow path of experiment type `type_id`, for drawing.""" + experiment = EXPERIMENT_TYPES[type_id] + return ConfigurationState( + components=experiment.components("probe"), + column_key="", + binding_key=experiment.binding_key, + template_key=experiment.template_key, + instrument=InstrumentState( + include_sample_loop=experiment.sample_loop, + bypass_units=[u for u in BYPASSABLE_UNITS if u in experiment.bypass], + ), + ) + + +def step_diagrams_html(guide: ChainStepGuide, comparisons: Sequence[Comparison] = ()) -> str: + """Compact flow-path diagrams with `guide.system_units` highlighted. + + One diagram per distinct flow path and observation point of `comparisons`; without + comparisons, one per experiment type feeding the step. + """ + drawn: Dict[tuple, tuple] = {} + for comparison in comparisons: + unit = _observed_unit(comparison.solution_path) + instrument = comparison.recipe.instrument + key = (unit, tuple(sorted(instrument.bypass_units)) if instrument else None) + if key in drawn: + continue + experiment = EXPERIMENT_TYPES.get(comparison.experiment_type or "") + label = experiment.label if experiment else comparison.name + drawn[key] = (label, recipe_diagram_svg( + comparison.recipe, highlight=guide.system_units, observe=unit, compact=True + )) + if not drawn: + for type_id in guide.experiment_types: + experiment = EXPERIMENT_TYPES[type_id] + drawn[(type_id,)] = (experiment.label, recipe_diagram_svg( + _type_recipe(type_id), highlight=guide.system_units, + observe=_observed_unit(experiment.solution_path), compact=True, + )) + cells = "".join( + f"
    {html.escape(label)}{svg}
    " + for label, svg in drawn.values() + ) + return f"
    {cells}
    " + + +def components_table_html(study: Study) -> str: + """Return the study's components: name, role and how many measurements use each.""" + if not study.components: + return ( + "

    No " + term_html("component", "components") + " declared yet; add them " + "under System → Components.

    " + ) + rows = "".join( + f"{html.escape(c.name)}{term_html(c.role)}" + f"{len(study.measurements_using(c.name))}" + for c in study.components + ) + return ( + "" + term_html("component", "Components") + "" + "" + f"{rows}
    NameRoleUsed by measurements
    " + ) + + +def add_custom_step(study: Study, name: str = "", stage: Optional[str] = None) -> str: + """Append a step outside the chain to `study` (auto-named when `name` is blank). + + Returns the new step's name; raises ValueError if a step of that name exists. + """ + names = {s.name for s in study.steps} + name = name.strip() + if not name: + n = len(names) + 1 + while f"step_{n}" in names: + n += 1 + name = f"step_{n}" + if name in names: + raise ValueError(f"A step called {name!r} already exists.") + study.upsert_step(StepSetup(name=name, stage=stage or next(iter(STAGES)))) + return name + + +def _chip(status: Optional[StepStatus]) -> str: + if status is None: + return status_html("info", "not started") + if status.accepted: + return status_html("ok", "accepted") + if status.fitted: + return status_html("info", "fitted, choose a candidate") + if not status.measurements: + return status_html("warn", "missing data") + if status.ready: + return status_html("ok", "ready to fit") + return status_html("warn", "needs attention") + + +class CharacterizationSetupWidget: + """One card per step of the recommended chain (`DEFAULT_CHAIN`), in lab order. + + Each card says which part of the system the step characterizes (highlighted on the + flow path), which experiments to run, which measurements are attached, where the step + stands and what to do next, with one button for that next action. A chain step not + in the study offers "Add measurement for this step" until a measurement of its + experiment types exists, then "Set up this step (N measurements)" plus the secondary + "Add another measurement" (`secondary_buttons`). Study steps that follow no chain + guide get a card after the chain. + + `on_navigate(target, context)` is called for actions that lead elsewhere: + `("measurements", {"step_id": guide_id})`, `("step", {"name": step_name})` and + `("guide", {})`. `fitted_steps` names the steps with a fit result waiting for a + choice (or is a callable returning them). With `auto_refresh=False` study changes + only mark the cards stale and the owner calls `refresh()`. + """ + + def __init__( + self, + study: Study, + *, + on_navigate: Optional[Navigate] = None, + fitted_steps: FittedSteps = (), + auto_refresh: bool = True, + system: Optional[Callable[[], Optional[ConfigurationState]]] = None, + ) -> None: + self.study = study + self._system = system + self._system_card = W.VBox() + self.on_navigate = on_navigate + self._fitted_steps = fitted_steps + self._auto_refresh = auto_refresh + self._key: Optional[str] = None + self.stale = True + self.statuses: Dict[str, StepStatus] = {} + self.buttons: Dict[str, W.Button] = {} + self.secondary_buttons: Dict[str, W.Button] = {} + self.details: Dict[str, W.VBox] = {} + self._toggled: Dict[str, bool] = {} + self._current: Optional[str] = None + + self._cards = W.VBox() + self.status = W.HTML() + self.status.add_class("cadetgui-status") + self._btn_guide = W.Button(description="How it works", icon="question-circle") + self._btn_guide.on_click(lambda _b: self._navigate("guide", {})) + intro = W.HTML( + "Work from top to bottom: each step fits one part of your system to " + "your " + term_html("measurement", "measurements") + "." + ) + self._new_step_name = W.Text( + description="Name:", placeholder="optional", continuous_update=False + ) + self._new_step_stage = W.Dropdown( + description="Step type:", options=[(s.label, s.id) for s in STAGES.values()], + style={"description_width": "initial"}, layout=W.Layout(width="auto"), + ) + self._new_step_help = W.HTML() + self._new_step_stage.observe(lambda _c: self._refresh_new_step_help(), names="value") + self._refresh_new_step_help() + self._btn_add_step = W.Button(description="Add custom step", icon="plus") + self._btn_add_step.on_click(lambda _b: self._on_add_step()) + self.custom_step_status = W.HTML() + self.custom_step_status.add_class("cadetgui-status") + add_row = W.HBox( + [self._new_step_name, self._new_step_stage, self._btn_add_step], + layout=W.Layout(flex_flow="row wrap"), + ) + add_row.add_class("cadetgui-toolbar") + self._custom_step = W.Accordion([W.VBox([ + W.HTML("A step outside the recommended chain, with no measurements " + "yet. You pick its step type and measurements on its page."), + add_row, + self._new_step_help, + self.custom_step_status, + ])]) + self._custom_step.set_title(0, "Advanced: add a custom step") + self._custom_step.selected_index = None + + self.root = W.VBox([ + W.HTML(style_tag()), + W.HBox([ + W.HTML("
    Setup
    "), + self._btn_guide, + ], layout=W.Layout(justify_content="space-between", align_items="center")), + intro, + self.status, + self._system_card, + self._cards, + self._custom_step, + ]) + self.root.add_class("cadetgui-panel") + + self.refresh() + study.add_listener(self._on_study_change) + + @property + def fitted_steps(self) -> Collection[str]: + """Steps with a fit result waiting for a choice.""" + fitted = self._fitted_steps + return fitted() if callable(fitted) else fitted + + def _on_study_change(self) -> None: + self.stale = True + if self._auto_refresh: + self.refresh() + + def _state_key(self, fitted: Collection[str]) -> str: + study = self.study + return json.dumps([ + [s.to_dict() for s in study.steps], + [[c.to_dict(), id(c.run)] for c in study.comparisons], + id(study.initial_store), + {name: id(store) for name, store in study.posteriors.items()}, + [[c.name, c.role] for c in study.components], + sorted(fitted), + ], sort_keys=True, default=str) + + def refresh(self, *, force: bool = False) -> None: + """Rebuild the cards from the study; skipped when nothing relevant changed.""" + fitted = set(self.fitted_steps) + key = self._state_key(fitted) + self.stale = False + if key == self._key and not force: + return + self._key = key + self.refresh_system() + statuses = study_steps_status(self.study, fitted) + by_step = {s.name: s for s in self.study.steps} + self.statuses = {s.step_name: s for s in statuses} + + placed: Dict[str, str] = {} + for status in statuses: + if status.guide is not None and status.guide.id not in placed: + placed[status.guide.id] = status.step_name + + self.buttons = {k: v for k, v in self.buttons.items() if k == "system"} + self.secondary_buttons = {} + self.details = {} + accepted = {s.guide.id for s in statuses if s.guide is not None and s.accepted} + self._current = next((g.id for g in DEFAULT_CHAIN if g.id not in accepted), None) + cards = [] + for number, guide in enumerate(DEFAULT_CHAIN, start=1): + step_name = placed.get(guide.id) + status = self.statuses.get(step_name) if step_name else None + step = by_step.get(step_name) if step_name else None + cards.append(self._card(number, guide, status, step)) + for status in statuses: + if status.step_name not in placed.values(): + cards.append(self._custom_card(status)) + self._cards.children = cards + + def refresh_system(self) -> None: + """Redraw the "Your system" card from the `system` callable; hidden without one.""" + recipe = self._system() if self._system is not None else None + if recipe is None: + self._system_card.children = () + return + others = [c.name for c in self.study.comparisons + if c.recipe.column_key != recipe.column_key] + note = ( + status_html( + "warn", + f"{len(others)} measurement(s) were added with a different column model: " + f"{', '.join(others)}. They keep the setup they were added with.", + ) if others else "" + ) + button = W.Button(description="Edit system", icon="sliders", button_style="primary", + layout=W.Layout(width="auto")) + button.on_click(lambda _b: self._navigate("system", {})) + self.buttons["system"] = button + body = W.HTML( + "

    Every new measurement starts from this system.

    " + + recipe_diagram_svg(recipe, compact=False) + + system_summary_html(recipe) + + components_table_html(self.study) + + note + ) + card = W.VBox([ + W.HBox([ + W.HTML("
    0. Your system
    "), button, + ], layout=W.Layout(justify_content="space-between", align_items="center")), + body, + ]) + card.add_class("cadetgui-panel") + card.add_class("cadetgui-section") + self._system_card.children = (card,) + + def _tagged(self, guide: ChainStepGuide) -> List[Comparison]: + return guide_measurements(self.study, guide) + + def _card( + self, + number: int, + guide: ChainStepGuide, + status: Optional[StepStatus], + step: Any, + ) -> W.VBox: + tagged = self._tagged(guide) + attached = step.comparisons if step is not None else tagged + attached_by_type = { + type_id: [c.name for c in attached if c.experiment_type == type_id] + for type_id in guide.experiment_types + } + body = [ + f"

    {html.escape(guide.purpose)}

    ", + "Determines" + determines_table_html(guide.determines), + "Experiments to run" + + experiments_table_html(guide.experiment_types, attached_by_type) + + _runs_use_html(guide.experiment_types), + ] + buttons = [] + if status is None and tagged: + next_action = "Set up this step (add further runs first if you have them)." + buttons = [ + self._button( + guide.id, set_up_label(len(tagged)), "plus", lambda: self.set_up(guide.id) + ), + self._button( + guide.id, ADD_ANOTHER, "upload", + lambda: self._add_measurement(guide.id), primary=False, + ), + ] + elif status is None: + labels = [EXPERIMENT_TYPES[t].label for t in guide.experiment_types] + next_action = f"Add a measurement for this step first ({' or '.join(labels)})." + buttons = [self._button( + guide.id, ADD_MEASUREMENT, "plus", lambda: self._add_measurement(guide.id) + )] + else: + next_action = status.next_action + body.append(self._problems_html(status)) + buttons = [self._action_button(guide, status, step)] + + details = W.VBox([ + W.HTML("".join(body)), W.HTML(step_diagrams_html(guide, attached)), + ]) + return self._collapsible( + guide.id, f"{number}. {guide.title}", status, buttons, next_action, details, + open_=guide.id == self._current, + ) + + def _collapsible( + self, key: str, title: str, status: Optional[StepStatus], buttons: List[W.Button], + next_action: str, details: W.VBox, *, open_: bool, + ) -> W.VBox: + """Return a card showing title, status, next action and buttons, details on demand.""" + shown = self._toggled.get(key, open_) + details.layout.display = "" if shown else "none" + toggle = W.Button( + icon="chevron-down" if shown else "chevron-right", tooltip="Show or hide details", + layout=W.Layout(width="32px"), + ) + + def on_toggle(_b: Any) -> None: + now = details.layout.display == "none" + details.layout.display = "" if now else "none" + toggle.icon = "chevron-down" if now else "chevron-right" + self._toggled[key] = now + + toggle.on_click(on_toggle) + self.details[key] = details + header = W.HBox([ + W.HBox([toggle, W.HTML( + f"
    {html.escape(title)} {_chip(status)}
    " + )], layout=W.Layout(align_items="center")), + W.HBox(buttons), + ], layout=W.Layout( + justify_content="space-between", align_items="center", flex_flow="row wrap", + )) + card = W.VBox([header, W.HTML(f"Next: {html.escape(next_action)}"), details]) + card.add_class("cadetgui-section") + return card + + def _custom_card(self, status: StepStatus) -> W.VBox: + name = status.step_name + button = self._button(name, OPEN_STEP, "arrow-right", lambda: self._open(name)) + details = W.VBox([W.HTML( + "A step outside the recommended chain." + self._problems_html(status) + )]) + return self._collapsible( + name, name, status, [button], status.next_action, details, open_=False + ) + + @staticmethod + def _problems_html(status: StepStatus) -> str: + if not status.problems or status.accepted: + return "" + return "".join(status_html("warn", p) + "
    " for p in status.problems) + + def _action_button(self, guide: ChainStepGuide, status: StepStatus, step: Any) -> W.Button: + name = status.step_name + if status.accepted: + return self._button(guide.id, OPEN_STEP, "arrow-right", lambda: self._open(name)) + if not step.comparisons and not self._tagged(guide): + return self._button( + guide.id, ADD_MEASUREMENT, "plus", lambda: self._add_measurement(guide.id) + ) + open_ids = [c.id for c in status.open_checks] + if open_ids and all(i == "species_gaps" for i in open_ids): + return self._button(guide.id, TRANSFER, "share", lambda: self._open(name)) + return self._button(guide.id, OPEN_STEP, "arrow-right", lambda: self._open(name)) + + def _button( + self, key: str, label: str, icon: str, action: Callable[[], None], *, + primary: bool = True, + ) -> W.Button: + button = W.Button( + description=label, icon=icon, button_style="primary" if primary else "", + layout=W.Layout(width="auto"), + ) + button.on_click(lambda _b: action()) + (self.buttons if primary else self.secondary_buttons)[key] = button + return button + + def _navigate(self, target: str, context: Dict[str, Any]) -> None: + if self.on_navigate is not None: + self.on_navigate(target, context) + + def _open(self, step_name: str) -> None: + self._navigate("step", {"name": step_name}) + + def _add_measurement(self, step_id: str) -> None: + self._navigate("measurements", {"step_id": step_id}) + + def set_up(self, guide_id: str) -> str: + """Add the chain step `guide_id` to the study with its tagged measurements. + + The step is placed after the study steps that come before it in the chain. + Returns the new step's name. + """ + guide = next(g for g in DEFAULT_CHAIN if g.id == guide_id) + try: + setup = new_step_setup(guide, self._tagged(guide)) + except ValueError as exc: + self.status.value = status_html("error", str(exc)) + return "" + if setup.name in {s.name for s in self.study.steps}: + self.status.value = status_html("error", f"A step called {setup.name!r} exists.") + return "" + order = [g.id for g in DEFAULT_CHAIN] + position = order.index(guide.id) + index = len(self.study.steps) + for i, step in enumerate(self.study.steps): + other = guide_for_step(step) + if other is not None and order.index(other.id) > position: + index = i + break + if index == len(self.study.steps): + self.study.upsert_step(setup) + else: + self.study.steps.insert(index, setup) + self.study.notify() + self.status.value = status_html("ok", f"Set up {guide.title}.") + self._open(setup.name) + return setup.name + + def add_step(self, name: str = "", stage: Optional[str] = None) -> str: + """Append a step outside the chain (see `add_custom_step`) and open it.""" + name = add_custom_step(self.study, name, stage) + self._open(name) + return name + + def _refresh_new_step_help(self) -> None: + text = step_type_help(self._new_step_stage.value) + self._new_step_help.value = f"{html.escape(text)}" if text else "" + + def _on_add_step(self) -> None: + try: + name = self.add_step(self._new_step_name.value, self._new_step_stage.value) + except ValueError as exc: + self.custom_step_status.value = status_html("error", str(exc)) + return + self._new_step_name.value = "" + self.custom_step_status.value = status_html("ok", f"Added {name}.") + + def display(self) -> None: + """Render this widget in a Jupyter cell.""" + from IPython.display import display as _display + + _display(self.root) diff --git a/cadetgui/widgets/composite/characterization_step.py b/cadetgui/widgets/composite/characterization_step.py new file mode 100644 index 0000000..c025073 --- /dev/null +++ b/cadetgui/widgets/composite/characterization_step.py @@ -0,0 +1,727 @@ +from __future__ import annotations + +import html +import time +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +import ipywidgets as W +import numpy as np + +from ...characterization import runner +from ...characterization.guide import ChainStepGuide, guide_for_step, starting_values +from ...characterization.parameter_store import ( + ParameterStore, + Step, + missing_requirements, + transfer_species, +) +from ...characterization.runner import ( + AVERAGE_TAG, + Candidate, + StepResult, + StepSetup, + fitted_variables, +) +from ...characterization.stages import STAGES, describe_stage +from ...characterization.step_checks import ( + CONFIRM_KNOWN, + CONFIRMED_DEFAULT, + KNOWN_HARDWARE, + RECORD_ASSUMPTIONS, + TRANSFER, + StepCheck, + confirm_known_value, + known_value_label, + missing_assumptions, + record_assumptions, + step_checks, +) +from ...characterization.study import Study +from .._chrome import style_tag +from .._help import ( + determines_table_html, + experiments_table_html, + info_box_html, + term_html, +) +from .._status import status_html +from ._step_results import _CandidateResults +from ._step_run import _StepRun +from ._step_setup_form import _StepSetupForm, _unit_html +from .characterization_setup import step_diagrams_html + +__all__ = ["CharacterizationStepWidget"] + + +def _missing(setup: StepSetup, store: ParameterStore) -> List[str]: + """`setup.requires` absent from `store`; unlike `setup.step`, works with invalid options.""" + return missing_requirements(Step(setup.name, requires=tuple(setup.requires)), store) + + +def _components_html(study: Study, setup: StepSetup) -> str: + """Return a line naming the components this step's measurements use, with roles.""" + names = list(dict.fromkeys(c.probe for c in setup.comparisons if c.probe)) + if not names: + return "" + parts = [] + for name in names: + component = study.component(name) + role = f" ({component.role})" if component is not None else " (not declared)" + parts.append(html.escape(name + role)) + return ( + "

    " + term_html("component", "Components") + ": " + ", ".join(parts) + + ". Values this step fits are stored under these names; a later step " + "reuses them only for the same component.

    " + ) + + +def _header_html( + guide: ChainStepGuide, setup: StepSetup, *, open: bool, components: str = "" +) -> str: + """Return the step's lab-language description: purpose, experiments, advice.""" + body = ( + f"

    {html.escape(guide.purpose)}

    " + + components + + f"Determines{determines_table_html(guide.determines)}" + f"Experiments that feed it{experiments_table_html(guide.experiment_types)}" + + "

    Lab checks the software cannot do for this step are in the Guide." + "

    " + + info_box_html("Reading the results", f"

    {html.escape(guide.reading_results)}

    ") + + step_diagrams_html(guide, setup.comparisons) + ) + return info_box_html(f"About this step: {guide.title}", body, open=open) + + +Navigate = Callable[[str, Dict[str, Any]], None] + + +def _best_values_html( + names: List[str], units: Dict[str, str], candidate: Candidate, setup: StepSetup +) -> str: + """Return a candidate's variable values and objectives as two tables.""" + if len(names) != len(candidate.x): + names = [f"x{i + 1}" for i in range(len(candidate.x))] + variables = "".join( + f"{html.escape(name)}{value:.4g}" + f"{_unit_html(units.get(name, ''))}" + for name, value in zip(names, candidate.x) + ) + objectives = "".join( + f"{html.escape(c.name)}{value:.4g}" + f"{term_html(c.metric)}" + for c, value in zip(setup.comparisons, candidate.f) + ) + return ( + f"
    {term_html('best average', 'Best average')} of the final front
    " + "
    " + "" + f"{variables}
    ParameterValueUnit
    " + "" + f"{objectives}
    MeasurementErrorMetric
    " + ) + + +class CharacterizationStepWidget(_StepRun): + """Set up, run and accept one characterization step of a `Study`. + + Every setup edit is written to the study with `Study.upsert_step`. A chain step + lists the measurements that feed it, all included unless unticked; new fitting + measurements are included when they are added. "Before you run" shows live checks + (`step_checks.step_checks`), each open one with its fix; a hardware value left at its + default offers "Use this value" (with an editable value) and "Edit in System". A run + fits the step type's `Characterize*` class on a background thread; the live fit plot + simulates the current best-average candidate on separately built processes, at most + once per generation. Pareto candidates are + previewed against every comparison, and only "Accept candidate" writes a posterior + (`Study.accept`). The setup is read-only while running or showing a result until + "Edit setup" is clicked. + + `on_navigate(target, context)` is called for fixes that lead elsewhere, with the + targets of `CharacterizationWorkbenchWidget.navigate`: `("measurements", {"step_id"})`, + `("measurements", {"select": name})`, `("step", {"name"})`, `("system", {"unit"})`, + `("parameters", {})`. Without it those buttons are hidden. + """ + + def __init__( + self, + study: Study, + step_name: Optional[str] = None, + *, + on_navigate: Optional[Navigate] = None, + ) -> None: + self.study = study + self.on_navigate = on_navigate + self._listeners: List[Callable[[], None]] = [] + self._committing = False + self._run_names: List[str] = [] + self._run_units: Dict[str, str] = {} + self.accepted = False + self._running = False + self._checks_key: Optional[tuple] = None + self.checks: List[StepCheck] = [] + self._known_fields: Dict[str, W.FloatText] = {} + self._init_run() + + existing = next((s for s in study.steps if s.name == step_name), None) + if existing is None: + name = step_name or self._free_name() + existing = StepSetup(name=name, stage=next(iter(STAGES))) + self._name = existing.name + self._committed: Optional[StepSetup] = ( + existing if any(s is existing for s in study.steps) else None + ) + + self.form = _StepSetupForm( + study, self._on_form_change, + on_add_measurement=( + (lambda step_id: self._navigate("measurements", {"step_id": step_id})) + if on_navigate is not None else None + ), + ) + self.results = _CandidateResults(self._accept) + + self._warnings = W.HTML() + self._checks_box = W.VBox() + self._gaps = W.HTML() + self._btn_transfer = W.Button( + description="Transfer to this step's species", icon="share", + layout=W.Layout(display="none", width="auto"), + ) + self._btn_transfer.on_click(lambda _b: self._transfer_clicked()) + self._btn_edit = W.Button( + description="Edit setup", icon="pencil", layout=W.Layout(display="none") + ) + self._btn_edit.on_click(lambda _b: self.edit_setup()) + self._lock_note = W.HTML( + "Locked while a fit result is shown. Edit setup unlocks it and discards " + "a result that is not accepted yet; accepted values stay in the parameter " + "store.", + layout=W.Layout(display="none"), + ) + self._btn_run = W.Button(description="Run step", icon="play", button_style="success") + self._btn_run.on_click(lambda _b: self.start_run()) + self._best_values = W.HTML() + self.status = W.HTML("Ready.") + self.status.add_class("cadetgui-status") + + self.header = W.HTML() + header_box = W.VBox([self.header]) + header_box.add_class("cadetgui-panel") + header_box.add_class("cadetgui-section") + self._header_box = header_box + setup_box = W.VBox([ + W.HBox([ + W.HTML("
    Setup
    "), self._btn_edit, + ], layout=W.Layout(justify_content="space-between", align_items="center")), + self._lock_note, + W.HTML( + f"The fit starts from this step's {term_html('prior')}: the values " + "accepted before it." + ), + W.HTML("
    Before you run
    "), + self._checks_box, + self.form.root, + self._warnings, + ]) + setup_box.add_class("cadetgui-panel") + setup_box.add_class("cadetgui-section") + run_box = W.VBox([ + W.HTML("
    Run
    "), + W.HBox( + [self._btn_run, self._btn_cancel, self.live_plot, self._progress_label], + layout=W.Layout(flex_flow="row wrap", align_items="center"), + ), + self._progress_box, + self._live_note, + self.status, + ]) + run_box.add_class("cadetgui-panel") + run_box.add_class("cadetgui-section") + results_box = W.VBox([ + W.HTML("
    Results
    "), + W.HTML( + "Accepting a candidate writes its values as this step's " + f"{term_html('posterior')} into the parameter store." + ), + self._best_values, + self.results.root, + ]) + results_box.add_class("cadetgui-panel") + results_box.add_class("cadetgui-section") + self.root = W.VBox([W.HTML(style_tag()), header_box, setup_box, run_box, results_box]) + + self.form.set_setup(existing) + self._refresh_warnings() + study.add_listener(self._on_study_change) + + def _free_name(self) -> str: + names = {s.name for s in self.study.steps} + n = len(names) + 1 + while f"step_{n}" in names: + n += 1 + return f"step_{n}" + + @property + def step_name(self) -> str: + """Name of the step this widget edits.""" + return self._name + + @property + def running(self) -> bool: + """Whether a run is in progress.""" + return self._running + + @property + def result(self) -> Optional[StepResult]: + """The last finished run's front, until the setup is edited again.""" + return self.results.result + + @property + def candidates(self) -> List[Candidate]: + """Candidates offered from the last run.""" + return self.results.candidates + + def add_listener(self, fn: Callable[[], None]) -> None: + """Call `fn()` after a setup edit, a finished run or an accept.""" + self._listeners.append(fn) + + def close(self) -> None: + """Stop following the study; call when the widget is discarded.""" + self.study.remove_listener(self._on_study_change) + + def _notify(self) -> None: + for fn in list(self._listeners): + fn() + + def _navigate(self, target: str, context: Dict[str, Any]) -> None: + if self.on_navigate is not None: + self.on_navigate(target, context) + + def get_value(self) -> StepSetup: + """Return the setup the controls currently describe.""" + return self.form.get_setup() + + def _on_form_change(self) -> None: + setup = self.form.get_setup() + error = self._commit(setup) + if error: + self.status.value = status_html("error", error) + self._refresh_warnings() + self._notify() + + def _commit(self, setup: StepSetup) -> Optional[str]: + if not setup.name: + return "The step needs a name." + names = [s.name for s in self.study.steps] + if setup.name != self._name and setup.name in names: + return f"Another step is already called {setup.name!r}." + self._committing = True + try: + if setup.name != self._name and self._name in names: + self.study.steps[names.index(self._name)] = setup + if self._name in self.study.posteriors: + self.study.posteriors[setup.name] = self.study.posteriors.pop(self._name) + self._name, self._committed = setup.name, setup + self.study.notify() + else: + self._name, self._committed = setup.name, setup + self.study.upsert_step(setup) + finally: + self._committing = False + return None + + @property + def _editable(self) -> bool: + return not self.running and self.result is None + + def _include_new_measurements(self) -> None: + """Rebuild the measurement rows; commit fitting measurements added to the study.""" + added = self.form.refresh_comparisons(auto_include=self._editable) + if added: + self._on_form_change() + + def _on_study_change(self) -> None: + if self._committing or self.running: + self._refresh_warnings() + return + stored = next((s for s in self.study.steps if s.name == self._name), None) + if stored is not None and stored is not self._committed: + changed = self._committed is not None and ( + [c.name for c in stored.comparisons] + != [c.name for c in self._committed.comparisons] + ) + self._committed = stored + self.form.set_setup(stored) + if changed and self.results.result is not None: + self.edit_setup() + self.status.value = status_html( + "warn", "The measurements changed, so the fit result was discarded." + ) + else: + self._include_new_measurements() + self.form.refresh_requires(self.form.get_setup()) + if self.results.result is not None: + self.form.set_locked(True) + self._refresh_warnings() + + def _prior(self) -> ParameterStore: + names = [s.name for s in self.study.steps] + return self.study.prior_for(self._name) if self._name in names else self.study.current_store + + def _setup_problems(self, setup: StepSetup) -> List[str]: + problems = [] + if not setup.comparisons: + problems.append("Select at least one measurement.") + for name, found in self.form.problems_of_selected().items(): + problems.append(f"{name}: " + " ".join(found)) + try: + describe_stage(setup.stage, **setup.options) + except ValueError as exc: + problems.append(str(exc)) + return problems + + def _refresh_warnings(self) -> None: + setup = self.form.get_setup() + try: + provides = setup.provides + except ValueError: + provides = [] + self.form.show_chain(provides) + lines = [] + if self._name in self.study.posteriors and not self.accepted: + lines.append(status_html( + "info", "This step already has an accepted posterior; accepting a new " + "candidate replaces it.", + )) + self._warnings.value = "
    ".join(lines) + self._refresh_checks(setup) + self.form.refresh_summary() + self._refresh_header(setup) + + def _refresh_checks(self, setup: StepSetup) -> None: + """Recompute the live checks and rebuild their rows when they changed.""" + try: + checks = step_checks(self.study, setup) + except Exception as exc: # noqa: BLE001 -- shown instead of the checks + checks = [StepCheck("error", "warn", f"The checks could not run: {exc}")] + editable = self._editable + key = (tuple(checks), editable, self.on_navigate is not None) + self.checks = checks + if key == self._checks_key: + return + self._checks_key = key + gaps = next((c for c in checks if c.id == "species_gaps"), None) + self._gaps.value = "" if gaps is None else ( + status_html("warn", gaps.text) + " " + + term_html("species transfer", "(species transfer)") + ) + self._btn_transfer.layout.display = "" if gaps is not None and editable else "none" + self._known_fields = {} + rows = [] + for check in checks: + if any(action.target == TRANSFER for action in check.actions): + rows.append(W.HBox( + [self._gaps, self._btn_transfer], + layout=W.Layout(align_items="center", flex_flow="row wrap"), + )) + continue + controls: List[W.Widget] = [W.HTML(status_html(check.status, check.text))] + for action in check.actions: + if action.target == CONFIRM_KNOWN and editable: + controls += self._known_value_field(action.context) + button = self._action_button(action, editable) + if button is not None: + controls.append(button) + rows.append(W.HBox( + controls, layout=W.Layout(align_items="center", flex_flow="row wrap"), + )) + self._checks_box.children = rows + + def _known_value_field(self, context: Any) -> List[W.Widget]: + """Return an editable hardware value prefilled with the default, and its unit.""" + field = W.FloatText( + value=float(f"{context['value'] / context['scale']:.6g}"), + tooltip="Your hardware value; the default is prefilled.", + layout=W.Layout(width="110px"), + ) + self._known_fields[context["path"]] = field + return [field, W.HTML(html.escape(context["unit"]))] + + def _action_button(self, action: Any, editable: bool) -> Optional[W.Button]: + local = { + RECORD_ASSUMPTIONS: self._record_assumptions_clicked, + CONFIRM_KNOWN: lambda: self._confirm_known_clicked(action.context), + } + if action.target in local: + if not editable: + return None + callback = local[action.target] + elif self.on_navigate is None: + return None + else: + def callback(action: Any = action) -> None: + self._navigate(action.target, dict(action.context)) + button = W.Button(description=action.label, layout=W.Layout(width="auto")) + button.on_click(lambda _b: callback()) + return button + + def check_buttons(self) -> Dict[str, W.Button]: + """Buttons of the open checks by label, for scripting and tests.""" + buttons = {} + for row in self._checks_box.children: + for child in row.children: + if isinstance(child, W.Button) and child.layout.display != "none": + buttons[child.description] = child + return buttons + + @property + def guide(self) -> Optional[ChainStepGuide]: + """The chain guide this step follows, if any.""" + return guide_for_step(self.form.get_setup()) + + def _refresh_header(self, setup: StepSetup) -> None: + guide = guide_for_step(setup) + self._header_box.layout.display = "none" if guide is None else "" + if guide is None: + self.header.value = "" + return + not_run = ( + self.result is None and not self.running and setup.name not in self.study.posteriors + ) + value = _header_html( + guide, setup, open=not_run, components=_components_html(self.study, setup) + ) + if self.header.value != value: + self.header.value = value + + def _species_gaps(self, setup: StepSetup) -> Dict[str, List[str]]: + """Gaps left once the experiment types' assumed values are recorded.""" + prior = self._prior() + try: + gaps = runner.species_gaps(setup, record_assumptions(setup, prior)) + except Exception: # noqa: BLE001 -- an unbuildable recipe is reported by problems() + return {} + return {path: names for path, names in gaps.items() if path in prior} + + def _transfer_clicked(self) -> None: + try: + self.transfer_species_gaps() + except KeyError as exc: + self.status.value = status_html("error", str(exc.args[0])) + + def _replace_prior(self, store: ParameterStore) -> str: + """Make `store` the store this step starts from; return where it was written.""" + source = self._prior_step_name() + if source is None: + self.study.initial_store = store + self.study.notify() + else: + self.study.accept(source, store) + return "initial store" if source is None else source + + def transfer_species_gaps(self, note: Optional[str] = None) -> ParameterStore: + """Copy single-probe species values onto this step's missing species in its prior. + + Values the experiment types assume are left to `record_assumptions`. The + transferred store replaces the posterior this step starts from (or the initial + store when no earlier step is accepted), so `Study.prior_for` returns it. + """ + setup = self.form.get_setup() + prior = self._prior() + gaps = self._species_gaps(setup) + species = list(dict.fromkeys(name for names in gaps.values() for name in names)) + transferred = transfer_species(prior, list(gaps), species, note=note) + where = self._replace_prior(transferred) + self.status.value = status_html( + "ok", f"Transferred {', '.join(gaps)} to {', '.join(species)} ({where}).", + ) + return transferred + + def _record_assumptions_clicked(self) -> None: + self.record_assumptions() + + def _confirm_known_clicked(self, context: Any) -> None: + field = self._known_fields.get(context["path"]) + value = context["value"] if field is None else field.value * context["scale"] + if not np.isfinite(value) or value <= 0: + self.status.value = status_html("error", "Enter a positive hardware value.") + return + self.confirm_known_value(context["path"], value, default=context["value"]) + + def confirm_known_value( + self, path: str, value: float, *, default: Optional[float] = None + ) -> ParameterStore: + """Record `value` of hardware `path` as known (see `step_checks.confirm_known_value`). + + The note is "confirmed default" when `value` equals `default` (or no default is + given), else "known hardware". + """ + same = default is None or bool(np.isclose(value, default, rtol=1e-9, atol=0.0)) + store = confirm_known_value( + self.study, path, value, note=CONFIRMED_DEFAULT if same else KNOWN_HARDWARE + ) + self.status.value = status_html( + "ok", f"Recorded the {known_value_label(path).lower()} as known hardware " + "(starting values).", + ) + return store + + def record_assumptions(self) -> ParameterStore: + """Write the values this step's experiment types assume into the store it starts from.""" + store = record_assumptions(self.form.get_setup(), self._prior()) + where = self._replace_prior(store) + self.status.value = status_html("ok", f"Recorded the assumed values ({where}).") + return store + + def _prior_step_name(self) -> Optional[str]: + """Return the accepted step whose posterior `_prior()` is, or None (initial store).""" + names = [s.name for s in self.study.steps] + before = names[: names.index(self._name)] if self._name in names else names + accepted = [n for n in before if n in self.study.posteriors] + return accepted[-1] if accepted else None + + def _show_edit(self, shown: bool) -> None: + self._btn_edit.layout.display = self._lock_note.layout.display = "" if shown else "none" + + def edit_setup(self) -> None: + """Unlock the setup; an unaccepted result is discarded.""" + if self.running: + return + self.results.clear() + self._progress_box.layout.display = "none" + self._best_values.value = "" + self._live_note.value = "" + self.form.set_locked(False) + self._show_edit(False) + self._btn_run.disabled = False + self.status.value = "Ready." + self._include_new_measurements() + self._refresh_warnings() + + def start_run(self) -> None: + """Validate the setup and fit it on a background thread.""" + if self.running: + return + setup = self.form.get_setup() + problems = self._setup_problems(setup) + error = self._commit(setup) + if error: + problems.insert(0, error) + prior = self._prior() + missing = _missing(setup, prior) + if missing: + problems.append(f"Missing requirements: {', '.join(missing)}.") + if missing_assumptions(setup, prior): + problems.append("Record the values the experiment types assume first.") + if self._species_gaps(setup): + problems.append("Stored values lack this step's species; transfer them first.") + if problems: + self.status.value = status_html("error", " ".join(problems)) + return + + optimizer_name = setup.optimizer["name"] + optimizer_kwargs = dict(setup.optimizer["knobs"]) + self._run_setup, self._run_prior = setup, prior + self._prepare_run_info(setup) + self.accepted = False + self.results.clear() + self.form.set_locked(True) + self._show_edit(False) + self._running = True + self._btn_run.disabled = True + self.status.value = status_html("running", f"Fitting {setup.name}…") + self._refresh_checks(setup) + self._launch(setup, prior, optimizer_name, optimizer_kwargs) + + def _prepare_run_info(self, setup: StepSetup) -> None: + """Reset the results' best-values table and the live charts (one per measurement).""" + try: + variables = describe_stage(setup.stage, **setup.options) + except ValueError: + variables = [] + self._run_names = fitted_variables(variables, setup.frozen) + try: + starts = starting_values(self.study, setup, variables) + except Exception: # noqa: BLE001 -- units are optional in the table + starts = {} + self._run_units = {name: start.unit for name, start in starts.items()} + self._best_values.value = "" + self._reset_live(setup) + + def _finish(self, start: float) -> None: + elapsed = time.monotonic() - start + with self._live_write: + self._live_token += 1 + self._running = False + self._btn_run.disabled = False + self._btn_cancel.layout.display = "none" + self._progress_title.value = "Convergence: best so far per objective" + result: Optional[StepResult] = self._progress.get("result") + error = self._progress.get("error") + if error or result is None: + self.status.value = status_html("error", error or "The run failed.") + self.form.set_locked(False) + else: + if result.cancelled: + message = f"Cancelled after {elapsed:.0f}s; showing the last full front." + kind = "warn" + elif result.success: + message, kind = f"{result.message} ({elapsed:.0f}s)", "ok" + else: + message, kind = result.message, "error" + self.status.value = status_html(kind, message) + if len(result.x): + self.results.show(result, self._run_setup, self._run_prior) + self._show_edit(True) + self._btn_run.disabled = True + self._show_final_fit() + else: + self.form.set_locked(False) + self._on_study_change() + self._notify() + + def _show_final_fit(self) -> None: + """Show the final front's best-average candidate in the results and the live plot.""" + best = next((c for c in self.results.candidates if AVERAGE_TAG in c.tags), None) + if best is None: + return + self._best_values.value = _best_values_html( + self._run_names, self._run_units, best, self._run_setup + ) + previews = self.results.previews_of(best) + if previews: + with self._live_write: + self._show_live(previews, "Final front: best-average candidate") + + def accept(self, candidate: Optional[Candidate] = None) -> ParameterStore: + """Write `candidate` (default: the selected one) as this step's posterior.""" + candidate = candidate or self.results.selected + if candidate is None or self.result is None: + raise ValueError("No candidate to accept.") + return self._accept(candidate) + + def _accept(self, candidate: Candidate) -> ParameterStore: + store = runner.posterior( + self._run_setup, self.result.built, self._run_prior, candidate + ) + self.accepted = True + self.study.accept(self._run_setup.name, store) + self.results.btn_accept.disabled = True + self.results.status.value = status_html( + "ok", f"Accepted candidate ({', '.join(candidate.tags)}) as the posterior of " + f"{self._run_setup.name}.", + ) + self._refresh_warnings() + self._notify() + return store + + def export_candidates_csv(self, path: "Path | str") -> Path: + """Write the current run's candidates to a CSV file.""" + return self.results.export_csv(path) + + def display(self) -> None: + """Render this widget in a Jupyter cell.""" + from IPython.display import display as _display + + _display(self.root) diff --git a/cadetgui/widgets/composite/characterization_workbench.py b/cadetgui/widgets/composite/characterization_workbench.py new file mode 100644 index 0000000..801453e --- /dev/null +++ b/cadetgui/widgets/composite/characterization_workbench.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import dataclasses +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple + +import ipywidgets as W + +from ...characterization.guide import guide_for_step +from ...characterization.step_checks import study_steps_status +from ...characterization.study import Study +from ...io.configuration_store import ConfigurationState +from ..shell import ( + SYSTEM_GROUP, + SidebarShell, + collect_panes, + resolve_step, + system_pane_label, + validate_steps, +) +from .bench import bench_root +from .characterization_guide_pane import CharacterizationGuideWidget +from .characterization_setup import CharacterizationSetupWidget, add_custom_step +from .characterization_step import CharacterizationStepWidget +from .comparisons import ComparisonsWidget +from .configuration import ConfigurationWidget +from .flow_rate import FlowRateSection +from .instrument import InstrumentWidget +from .parameter_store_view import ParameterStoreWidget, chain_warnings + +__all__ = ["CharacterizationWorkbenchWidget"] + +SETUP = "Setup" +SYSTEM = SYSTEM_GROUP +PROCESS = "Advanced configuration" +MEASUREMENTS = "Measurements" +STEPS = "Characterization steps" +PARAMETERS = "Parameters" +GUIDE = "Guide" +SYSTEM_PANE = system_pane_label("Instrument") +PROCESS_PANE = system_pane_label(PROCESS) +CONFIGURATION_INTRO = ( + "In characterization these settings are the starting point of every new measurement: " + "the column geometry, starting guesses for the values the characterization steps " + "determine (porosities, dispersion, film diffusion) and the binding model parameters. " + "Each measurement keeps its own copy; accepted fit results replace the guesses. The " + "process (method) of a run is not set here: it comes with the measurement's experiment " + "type." +) +_FLOW_RATE_NOTE = ( + "The flow rate your runs use. Every new measurement starts with it; a run that used " + "another flow rate keeps its own, entered when you add it." +) +_PANES = (SETUP, SYSTEM, PROCESS, MEASUREMENTS, STEPS, PARAMETERS, GUIDE) + + +def step_pane(name: str) -> str: + """Sidebar label of the pane for the step called `name`.""" + return f"{STEPS}: {name}" + + +class CharacterizationWorkbenchWidget: + """Sidebar workbench over one characterization `Study`. + + Panes: "Setup" (one card per chain step with its next action; sets up steps), + "System" (instrument with the study's components and a `FlowRateSection`; the + "Advanced configuration" sub-pane holds column and binding settings, the base recipe + of new measurements), "Measurements", "Characterization steps" (one + `CharacterizationStepWidget` per study step, following the study live), "Parameters" + (the store; saves and loads the study) and "Guide". Sidebar warnings come from + `study_steps_status`, the same checks the step pages and the Setup pane show. + + `include` narrows the panes to a subset of `_PANES`; an excluded pane's widget is + neither constructed nor wired and its attribute (`.setup`, `.comparisons`, + `.parameters`, `.guide`; `.steps` stays empty) is None. The instrument and + configuration are always built, since they hold the base recipe of every new + measurement. Without `instrument`/`configuration`, defaults are seeded (all units and + the sample loop in the flow path; process template hidden, LRMP column with SMA + binding); passed-in widgets are only bound to the study. + + `navigate(target, context)` switches panes: `"measurements"` (`step_id` and/or + `experiment_type` open the add stepper, `select` shows a measurement), `"step"` + (`name`), `"setup"`, `"system"` (`unit` expands the hardware and highlights it), + `"parameters"`, `"guide"`. Opening a step highlights the units it characterizes. + """ + + def __init__( + self, + *, + study: Optional[Study] = None, + include: Optional[Sequence[str]] = None, + instrument: Optional[InstrumentWidget] = None, + configuration: Optional[ConfigurationWidget] = None, + ) -> None: + if include is None: + include = _PANES + validate_steps(include, _PANES) + self._include = tuple(dict.fromkeys(include)) + self.study = study if study is not None else Study() + + self.instrument = instrument + if self.instrument is None: + self.instrument = InstrumentWidget(hardware_expanded=True, hardware_only=True) + self.instrument.apply_state(dataclasses.replace( + self.instrument.snapshot(), include_sample_loop=True, bypass_units=[] + )) + + self.configuration = configuration + if self.configuration is None: + self.configuration = ConfigurationWidget( + instrument=self.instrument, workspace_header=False, + show_process_template=False, intro=CONFIGURATION_INTRO, + ) + self.configuration.select_models( + template="Pulse Injection", + column="Lumped Rate Model With Pores (LRMP)", + binding="Steric Mass Action (SMA)", + ) + + self.instrument.bind_study(self.study) + self.components = self.instrument.study_components + self.flow_rate = FlowRateSection(self.configuration, note=_FLOW_RATE_NOTE) + self.instrument.add_section(self.flow_rate.root, after_components=True) + + def pane(label: str, factory: Callable[[], Any]) -> Any: + return resolve_step(label, None, steps=self._include, factory=factory) + + self.comparisons = pane(MEASUREMENTS, lambda: ComparisonsWidget( + self.study, configuration=self.configuration + )) + self.parameters = pane(PARAMETERS, lambda: ParameterStoreWidget(self.study)) + self.guide = pane(GUIDE, CharacterizationGuideWidget) + self._step_widgets: List[CharacterizationStepWidget] = [] + self.setup = pane(SETUP, lambda: CharacterizationSetupWidget( + self.study, on_navigate=self.navigate, + fitted_steps=lambda: self.fitted_steps, auto_refresh=False, + system=self._system_state, + )) + + self._shell = SidebarShell(self._panes(), groups=self._groups()) + self._nav = self._shell.nav + self._shell.add_show_listener(lambda _label: self._on_pane_shown()) + self._sync_steps() + + self.study.add_listener(self._on_study_change) + self.configuration.add_listener(self._refresh_status) + if self.setup is not None: + self.configuration.add_listener(lambda *_: self.setup.refresh_system()) + self._refresh_status() + + self.root = bench_root("Characterization", self._shell, self.configuration) + + @classmethod + def from_file(cls, path: "Path | str", **kwargs: Any) -> "CharacterizationWorkbenchWidget": + """Open the study file at `path` (see `Study.load`); `kwargs` go to the constructor.""" + return cls(study=Study.load(path), **kwargs) + + @property + def steps(self) -> Dict[str, CharacterizationStepWidget]: + """Step widgets by step name, in chain order.""" + return {w.step_name: w for w in self._step_widgets} + + @property + def fitted_steps(self) -> Set[str]: + """Steps whose widget holds a fit result that was not accepted.""" + return { + w.step_name for w in self._step_widgets if w.result is not None and not w.accepted + } + + def navigate(self, target: str, context: Optional[Dict[str, Any]] = None) -> None: + """Show the pane for `target` (see the class docstring); excluded panes are ignored.""" + context = context or {} + labels: Dict[str, Callable[[], Optional[str]]] = { + "setup": lambda: SETUP, + "system": lambda: SYSTEM_PANE, + "measurements": lambda: MEASUREMENTS, + "step": lambda: step_pane(context["name"]), + "parameters": lambda: PARAMETERS, + "guide": lambda: GUIDE, + } + if target not in labels: + raise ValueError(f"Unknown navigation target {target!r}.") + label = labels[target]() + if label not in self._shell.panes: + return + if target == "step": + self._highlight_step(context["name"]) + self._shell.show(label) + if target == "system" and context.get("unit"): + self.instrument.set_hardware_expanded(True) + self.instrument.set_highlight((context["unit"],)) + step_id, type_id = context.get("step_id"), context.get("experiment_type") + if target == "measurements" and context.get("select"): + self.comparisons.select(context["select"]) + if target == "measurements" and (step_id or type_id): + self.comparisons.start_add(step_id=step_id, experiment_type=type_id) + + def _system_state(self) -> Optional[ConfigurationState]: + """Return the current System setup as a recipe, or None while nothing valid is built.""" + try: + return self.configuration.snapshot() + except RuntimeError: + return None + + def _on_pane_shown(self) -> None: + current = self._shell.current + if current == SETUP: + self.setup.refresh() + for widget in self._step_widgets: + if current == step_pane(widget.step_name): + self._highlight_step(widget.step_name) + + def _highlight_step(self, step_name: str) -> None: + setup = next((s for s in self.study.steps if s.name == step_name), None) + if setup is None: + return + guide = guide_for_step(setup) + observe = next( + (c.solution_path.split(".")[0] for c in setup.comparisons if c.solution_path), None + ) + self.instrument.set_highlight(guide.system_units if guide else (), observe) + + def add_step( + self, name: str = "", stage: Optional[str] = None + ) -> Optional[CharacterizationStepWidget]: + """Append a custom step to the study (see `add_custom_step`) and open its pane. + + Returns the new step's widget, or None when the "Steps" panes are not included. + """ + name = add_custom_step(self.study, name, stage) + self.navigate("step", {"name": name}) + return self.steps.get(name) + + def _panes(self) -> List[Tuple[str, W.Widget]]: + slots: Dict[str, Any] = { + SETUP: self.setup, + SYSTEM_PANE: self.instrument if SYSTEM in self._include else None, + PROCESS_PANE: self.configuration if PROCESS in self._include else None, + MEASUREMENTS: self.comparisons, + **{step_pane(w.step_name): w for w in self._step_widgets}, + PARAMETERS: self.parameters, + GUIDE: self.guide, + } + return [(label, widget.root) for label, widget in collect_panes(list(slots), slots)] + + def _groups(self) -> Dict[str, List[str]]: + return { + SYSTEM: [SYSTEM_PANE, PROCESS_PANE], + STEPS: [step_pane(w.step_name) for w in self._step_widgets], + } + + def _sync_steps(self) -> None: + """Match the step widgets to `study.steps`: add, drop and reorder, then re-lay panes.""" + if STEPS not in self._include: + return + by_name = {w.step_name: w for w in self._step_widgets} + widgets = [] + for setup in self.study.steps: + widget = by_name.pop(setup.name, None) + if widget is None: + widget = CharacterizationStepWidget( + self.study, setup.name, on_navigate=self.navigate + ) + widget.add_listener(self._refresh_status) + widgets.append(widget) + for stale in by_name.values(): + stale.close() + self._step_widgets = widgets + + panes = self._panes() + current = [(label, id(widget)) for label, widget in panes] + shown = [(label, id(self._shell.panes[label])) for label in self._shell.order] + if current != shown: + selected = self._shell.panes.get(self._shell.current) + show = next((label for label, widget in panes if widget is selected), None) + self._shell.set_panes(panes, groups=self._groups(), show=show) + + def _on_study_change(self) -> None: + self._sync_steps() + self._refresh_status() + + def _set_warning(self, label: str, message: Optional[str]) -> None: + if label in self._shell.panes: + self._shell.set_warning(label, message) + + def _refresh_status(self, *_args: object) -> None: + """Flag panes that can't do anything useful yet, without blocking navigation.""" + config_problem = self.configuration.name_error("running a simulation") + if not self.configuration.show_process_template: + if config_problem is None and self._system_state() is None: + config_problem = "The column or binding settings are incomplete." + elif config_problem is None and self.configuration.process is None: + config_problem = "No valid process is built yet." + self._set_warning(PROCESS_PANE, config_problem) + + problems = { + c.name: found for c in self.study.comparisons + if (found := self.study.measurement_problems(c)) + } + self._set_warning(MEASUREMENTS, "\n".join( + f"{name}: {' '.join(found)}" for name, found in problems.items() + ) or None) + for status in study_steps_status(self.study, self.fitted_steps): + message = None if status.accepted else "\n".join(status.problems) + self._set_warning(step_pane(status.step_name), message or None) + self._set_warning(PARAMETERS, "\n".join(chain_warnings(self.study)) or None) + if self._shell.current == SETUP: + self.setup.refresh() + + def display(self) -> None: + """Render this widget in a Jupyter cell.""" + from IPython.display import display as _display + + _display(self.root) diff --git a/cadetgui/widgets/composite/comparisons.py b/cadetgui/widgets/composite/comparisons.py new file mode 100644 index 0000000..dc15ffa --- /dev/null +++ b/cadetgui/widgets/composite/comparisons.py @@ -0,0 +1,1028 @@ +from __future__ import annotations + +import copy +import dataclasses +import html +import math +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Tuple + +import ipywidgets as W +import numpy as np + +from ...cadetprocessadapter import ( + FieldSpec, + parse_float_list, +) +from ...characterization.comparison import Comparison +from ...characterization.guide import ( + CHAIN_BY_ID, + DEFAULT_CHAIN, + EXPERIMENT_TYPES, + map_species, + role_fix, +) +from ...characterization.study import Study +from ...io import configuration_store +from ...io.configuration_store import ConfigurationState +from ...io.experimental_data import ( + ExperimentalRun, + to_time, +) +from ...process_builder import check_recipe +from .._chrome import style_tag +from .._help import info_box_html, term_html +from .._status import status_html +from ..elements import ( + BoolField, + ChoiceField, + ChromatogramChart, + SelectableTable, + TextField, +) +from ._measurement_common import ( + ML_PER_MIN, + _check_float, + _check_float_list, + _format, + _format_value, + _note, + _observed_unit, + _optional_float, + _row, + _step_of, + _template_fields, + _template_options, + _title, + channel_label, + component_options, + problems_html, + recipe_summary_html, + recipe_with_template, +) +from .system_diagram import recipe_diagram_svg + +if TYPE_CHECKING: + from .configuration import ConfigurationWidget + +from .add_measurement import AddMeasurementFlow + +__all__ = ["ComparisonsWidget", "METRICS"] + +# CADET-Process difference metrics that `setup_comparators` builds from the name alone. +METRICS: Tuple[str, ...] = ( + "NRMSE", "RMSE", "SSE", "L1", "L2", "AbsoluteArea", "RelativeArea", + "Shape", "ShapeFront", "PeakHeight", "PeakPosition", +) + +_CONFIG = "__configuration__" +_MAX_POINTS = 2000 + +_INTRO = ( + "

    A " + term_html("measurement") + " is one recorded run (a data file with one or " + "more detector channels) paired with the " + term_html("recipe") + " that should " + "reproduce it: which units are in the " + term_html("flow path") + ", where the detector " + "sits (the " + term_html("observation point") + "), and how the trace is aligned " + "(" + term_html("injection marker") + ") and scaled (" + term_html("normalization") + + ").

    Each measurement feeds one characterization step, set by its experiment " + "type. Add a " + "measurement for every run you want a step to fit; the step compares the simulated " + "signal to the measured one with a metric such as " + term_html("NRMSE") + ".

    " +) + + +ADVANCED_TITLE = ( + "Advanced: fine-tune how this measurement is compared — the defaults from its " + "experiment type are usually right." +) +_MEASURED_HINT = ( + "The measured trace as the fit compares it: shifted onto the simulated injection, " + "baseline-corrected and scaled. Check alignment and baseline here; the step pages " + "compare it with simulations." +) + + +def _subsection(title: str, when: str, children: Sequence[W.Widget]) -> W.VBox: + box = W.VBox([ + W.HTML(f"
    {html.escape(title)}
    "), + _note(f"{html.escape(when)}"), + *children, + ]) + box.add_class("cadetgui-subsection") + return box + + +class ComparisonsWidget: + """The Measurements pane: add, list, inspect and edit a study's comparisons. + + New measurements come from an in-pane stepper (upload → channel → experiment type → + details → confirm). The list is grouped by the chain step each measurement feeds; + the selected one shows a plain recipe card, its flow-path diagram, problems and the + measured trace as the fit sees it (no simulation). Every field of the comparison is + editable under Advanced, grouped in titled sections (`_advanced_sections`), and + written through `study.replace_comparison`, so steps holding it see the new object. + A component whose role does not fit the experiment type is listed as such, with an + explicit "Make a " action when no other measurement needs its role. + `configuration` supplies the base recipe of new measurements and can load or + supply a measurement's recipe. + """ + + def __init__( + self, + study: Study, + *, + configuration: Optional["ConfigurationWidget"] = None, + ) -> None: + self.study = study + self.configuration = configuration + self._loading = False + self._committing = False + self._selected_name: Optional[str] = None + self._editing: Optional[Comparison] = None + self._recipe: Optional[ConfigurationState] = None + self._runs: Dict[str, Optional[ExperimentalRun]] = {} + self._override_fields: Dict[str, Tuple[FieldSpec, Any]] = {} + self._component_fields: Dict[str, BoolField] = {} + self._tables: Dict[Optional[str], SelectableTable] = {} + self._group_buttons: Dict[str, W.Button] = {} + + self._flow = AddMeasurementFlow( + study, runs=self._known_runs, base_recipe=self._base_recipe, + on_added=self._on_added, on_change=self._on_flow_change, + ) + self._btn_duplicate = W.Button(description="Duplicate", icon="copy") + self._btn_remove = W.Button(description="Remove", icon="trash") + self.status = W.HTML("") + self.status.add_class("cadetgui-status") + self._list_box = W.VBox([]) + + self._card_title = W.HTML() + self._recipe_summary = W.HTML() + self._diagram = W.HTML() + self._problems_html = W.HTML() + self._problems_html.add_class("cadetgui-status") + self._name = TextField(label="Name:", validate=self._check_name) + self._probe = ChoiceField(label="Component:") + self._probe_role_fix = W.HTML() + self._probe_role_fix_target: Optional[Tuple[str, str]] = None + self._btn_probe_role_fix = W.Button( + icon="exchange", button_style="warning", + layout=W.Layout(width="auto", display="none"), + ) + self._btn_probe_role_fix.on_click(lambda _b: self.make_probe_fit()) + self._experiment_type = ChoiceField( + label="Experiment type:", + options=[("Not set", None), *((t.label, t.id) for t in EXPERIMENT_TYPES.values())], + ) + self._run = ChoiceField(label="Data run:") + self._channel = ChoiceField(label="Channel:") + self._data_file = W.HTML() + self._flow_rate = TextField(label="Run flow rate:", units="mL/min", validate=_check_float) + self._flow_rate_note = _note() + + self._template = ChoiceField(label="Process template:") + self._template_note = _note( + "Changing the process template changes this measurement's method: its timing " + "and concentrations go back to the new template's defaults (the flow rate is " + "kept). Experiment types set their template for you; change it only if the run " + "followed another method." + ) + self._recipe_source = ChoiceField(label="Recipe from:") + self._btn_use_recipe = W.Button(description="Use recipe", icon="download") + self._overrides_box = W.HBox(layout=W.Layout(flex_flow="row wrap")) + + self._marker = ChoiceField(label="Injection marker:") + self._injection_x = TextField(label="Injection at:", validate=_check_float) + self._baseline_start = TextField(label="Baseline from:", validate=_check_float) + self._baseline_end = TextField(label="to:", validate=_check_float) + self._normalization = ChoiceField( + label="Normalization:", options=[("None", "none"), ("Area", "area")] + ) + self._target_area = TextField(label="Target area:", validate=_check_float) + + self._solution_path = ChoiceField(label="Observation point:") + self._components_box = W.HBox(layout=W.Layout(flex_flow="row wrap")) + self._metric = ChoiceField(label="Metric:", options=[(m, m) for m in METRICS]) + self._window_start = TextField(label="Compare from:", units="s", validate=_check_float) + self._window_end = TextField(label="to:", units="s", validate=_check_float) + + self._measured_chart = ChromatogramChart( + view_width=480, view_height=240, y_label="Signal", x_label="Time / min" + ) + self._measured_note = _note() + + self._remove_note = W.HTML() + self._btn_remove_confirm = W.Button( + description="Remove from those steps too", icon="trash", button_style="danger", + layout=W.Layout(width="auto"), + ) + self._btn_remove_cancel = W.Button(description="Keep it", icon="times") + self._remove_confirm = W.HBox( + [self._remove_note, self._btn_remove_confirm, self._btn_remove_cancel], + layout=W.Layout(display="none", flex_flow="row wrap", align_items="center"), + ) + self._btn_remove_confirm.on_click(lambda _b: self.remove_selected(from_steps=True)) + self._btn_remove_cancel.on_click(lambda _b: self._hide_remove_confirm()) + self._btn_duplicate.on_click(self._on_duplicate) + self._btn_remove.on_click(self._on_remove) + self._btn_use_recipe.on_click(self._on_use_recipe) + self._run.observe(self._on_run_change, names="selected_index") + self._template.observe(self._on_template_change, names="selected_index") + for choice in (self._channel, self._marker, self._normalization, + self._solution_path, self._metric, self._experiment_type, self._probe): + choice.observe(self._on_edit, names="selected_index") + for text in (self._name, self._flow_rate, self._injection_x, + self._baseline_start, self._baseline_end, self._target_area, + self._window_start, self._window_end): + text.observe(self._on_edit, names="value") + toolbar = W.HBox([self._btn_duplicate, self._btn_remove]) + toolbar.add_class("cadetgui-toolbar") + self._advanced_sections: Dict[str, W.VBox] = { + title: _subsection(title, when, children) + for title, when, children in ( + ( + "Data and alignment", + "Change when the wrong run or detector channel is used, or the measured " + "trace is shifted in time against the simulation.", + [ + _row(self._run, self._channel, self._data_file), + _row(self._flow_rate, self._flow_rate_note), + _title(term_html("injection marker", "Injection marker")), + _row(self._marker, self._injection_x), + ], + ), + ( + "Baseline and scaling", + "Change when the measured baseline drifts or the trace needs another " + "scale, e.g. to match the injected amount.", + [ + _title(term_html("baseline window", "Baseline window")), + _row(self._baseline_start, self._baseline_end), + _title(term_html("normalization", "Normalization")), + _row(self._normalization, self._target_area), + ], + ), + ( + "Simulated signal", + "Change when the detector sits elsewhere in the flow path or another " + "component should be compared.", + [ + _title(term_html("observation point", "Observation point") + + " (solution path) and component"), + _row(self._solution_path, self._probe), + _row(self._probe_role_fix, self._btn_probe_role_fix), + self._components_box, + ], + ), + ( + "Comparison", + "Change to use another error measure, or to compare only part of the " + "run (e.g. leave out a noisy tail).", + [_row(self._metric), _row(self._window_start, self._window_end)], + ), + ( + "Process", + "Change only if the run followed another method than its experiment " + "type assumes.", + [ + _row(self._template), + self._template_note, + _title(term_html("recipe", "Recipe") + " source and method overrides"), + _row(self._recipe_source, self._btn_use_recipe), + self._overrides_box, + ], + ), + ( + "Name and type", + "Change to rename the measurement or give it another experiment type.", + [ + _row(self._name, self._experiment_type), + _note("Changing the experiment type moves the measurement to another " + "step's group; it does not rebuild the recipe (add the run " + "again for that)."), + ], + ), + ) + } + self._editor = W.VBox(list(self._advanced_sections.values())) + self._advanced = W.Accordion(children=[self._editor], selected_index=None) + self._advanced.set_title(0, ADVANCED_TITLE) + + self._card = W.VBox( + [ + self._card_title, + self._recipe_summary, + self._diagram, + _title("Problems"), + self._problems_html, + _title("Measured trace"), + _note(_MEASURED_HINT), + self._measured_chart, + self._measured_note, + self._advanced, + ] + ) + self._card.add_class("cadetgui-section") + + self.root = W.VBox( + [ + W.HTML(style_tag()), + W.HTML("
    " + + term_html("measurement", "Measurements") + "
    "), + W.HTML(info_box_html("What is a measurement?", _INTRO)), + toolbar, + self._remove_confirm, + self.status, + self._list_box, + self._card, + ] + ) + self.root.add_class("cadetgui-panel") + + study.add_listener(self._on_study_change) + self._refresh_table() + self._refresh_recipe_sources() + self._load_editor() + + @property + def selected(self) -> Optional[Comparison]: + """The comparison shown in the card and editor.""" + return next((c for c in self.study.comparisons if c.name == self._selected_name), None) + + def select(self, name: str) -> None: + """Show the comparison called `name`.""" + self._selected_name = name + self._sync_table_selection() + self._load_editor() + + def start_add( + self, *, step_id: Optional[str] = None, experiment_type: Optional[str] = None + ) -> None: + """Open the add-measurement stepper, preselecting a step's or a given experiment type.""" + self._flow.start(step_id=step_id, experiment_type=experiment_type) + + # List + + def _group_order(self, groups: Dict[Optional[str], List[Comparison]]) -> List[Optional[str]]: + return [g.id for g in DEFAULT_CHAIN] + ([None] if None in groups else []) + + def _table_for(self, key: Optional[str]) -> SelectableTable: + if key not in self._tables: + table = SelectableTable( + columns=["Name", "Experiment type", "Detector", "Used in", "Status"], + empty_text="No measurements yet.", + ) + table.observe(lambda _c, key=key: self._on_table_select(key), names="selected_index") + self._tables[key] = table + return self._tables[key] + + def _group_button(self, step_id: str) -> W.Button: + if step_id not in self._group_buttons: + button = W.Button(description="Add measurement for this step", icon="plus", + layout=W.Layout(width="auto")) + button.on_click(lambda _b: self.start_add(step_id=step_id)) + self._group_buttons[step_id] = button + return self._group_buttons[step_id] + + def _refresh_table(self) -> None: + names = [c.name for c in self.study.comparisons] + if self._selected_name not in names and not self._committing: + self._selected_name = names[0] if names else None + groups: Dict[Optional[str], List[Comparison]] = {} + for c in self.study.comparisons: + groups.setdefault(_step_of(c), []).append(c) + children: List[W.Widget] = [] + order = self._group_order(groups) + target = self._flow.target_step if self._flow.is_open else None + placed = target in order + + def flow_here(key: Optional[str]) -> bool: + return self._flow.is_open and placed and key == target + + if self._flow.is_open and not placed: + children.append(self._flow.root) + was_loading = self._loading + self._loading = True + try: + for key in order: + members = groups.get(key, []) + guide = CHAIN_BY_ID.get(key) if key is not None else None + if guide is not None: + labels = ", ".join(EXPERIMENT_TYPES[t].label for t in guide.experiment_types) + heading = _title(f"{html.escape(guide.title)} " + f"· {len(members)} " + f"measurement(s) · expects: {html.escape(labels)}") + else: + heading = _title( + "Not assigned to a step · set an " + "experiment type under Advanced to assign one" + ) + children.append(heading) + if flow_here(key): + children.append(self._flow.root) + if members: + table = self._table_for(key) + table.set_options( + [(c.name, c.name) for c in members], + rows=[self._row_cells(c) for c in members], + select_none=True, + ) + children.append(table) + if guide is not None and not flow_here(key): + children.append(self._group_button(key)) + self._tables = {k: t for k, t in self._tables.items() if groups.get(k)} + finally: + self._loading = was_loading + self._list_box.children = children + self._sync_table_selection() + self._btn_duplicate.disabled = self._btn_remove.disabled = self.selected is None + + def _row_cells(self, c: Comparison) -> List[Any]: + n = len(self.study.measurement_problems(c)) + chip = ("ok", "ok") if n == 0 else (f"{n} problem{'s' * (n > 1)}", "error") + experiment_type = EXPERIMENT_TYPES.get(c.experiment_type or "") + return [ + c.name, + experiment_type.label if experiment_type else "not set", + channel_label(c.channel), + ", ".join(self.study.steps_using(c.name)) or "–", + chip, + ] + + def _sync_table_selection(self) -> None: + was_loading = self._loading + self._loading = True + try: + for table in self._tables.values(): + table.value = self._selected_name + finally: + self._loading = was_loading + + def _on_table_select(self, key: Optional[str]) -> None: + table = self._tables.get(key) + if self._loading or table is None or table.value is None: + return + self._selected_name = table.value + self._sync_table_selection() + self._load_editor() + self._btn_duplicate.disabled = self._btn_remove.disabled = self.selected is None + + def _on_study_change(self) -> None: + self._refresh_table() + editing = self._editing + if self._committing: + return + if self.selected is not editing or (editing is not None and editing.recipe + is not self._recipe): + self._load_editor() + elif editing is not None: + self._loading = True + try: + self._fill_probe(editing.experiment_type, editing.probe) + finally: + self._loading = False + self._show_card(editing) + + def _unique_name(self, base: str) -> str: + names = {c.name for c in self.study.comparisons} + if base not in names: + return base + i = 2 + while f"{base} {i}" in names: + i += 1 + return f"{base} {i}" + + def _base_recipe(self) -> Tuple[Optional[ConfigurationState], str]: + """Return the recipe new measurements start from and where it comes from.""" + if self.configuration is not None: + source = ( + "the current process in Process Configuration" + if getattr(self.configuration, "show_process_template", True) + else "your System setup (hardware, flow rate, column and binding settings)" + ) + try: + return self.configuration.snapshot(), source + except RuntimeError: + pass + if self.study.comparisons: + first = self.study.comparisons[0] + return copy.deepcopy(first.recipe), ( + f"the recipe of measurement {first.name!r} (no process built in " + "Process Configuration)" + ) + return None, "none; build a process in Process Configuration first" + + def _known_runs(self) -> Dict[str, Optional[ExperimentalRun]]: + runs: Dict[str, Optional[ExperimentalRun]] = {} + for c in self.study.comparisons: + if runs.get(c.data_file) is None: + runs[c.data_file] = c.run + known = {id(r) for r in runs.values() if r is not None} + for key, run in self._flow.uploaded.items(): + if id(run) not in known: + known.add(id(run)) + runs.setdefault(key, run) + return runs + + def _on_flow_change(self) -> None: + if hasattr(self, "_list_box"): + self._refresh_table() + + def _on_added(self, comparison: Comparison, notes: List[str]) -> None: + self._refresh_runs() + self.select(comparison.name) + guide = CHAIN_BY_ID.get(_step_of(comparison) or "") + text = f"Added {comparison.name}." + ( + f" It feeds the step {guide.title!r}; attach it there." if guide else "" + ) + self.status.value = "
    ".join( + [status_html("ok", text), *(status_html("info", n) for n in notes)] + ) + + def _on_duplicate(self, _btn: Any) -> None: + source = self.selected + if source is None: + return + comparison = dataclasses.replace( + source, + name=self._unique_name(f"{source.name} copy"), + recipe=copy.deepcopy(source.recipe), + overrides=copy.deepcopy(source.overrides), + normalization=dict(source.normalization), + ) + self.study.upsert_comparison(comparison) + self.select(comparison.name) + self.status.value = status_html("ok", f"Duplicated {source.name}.") + + def _on_remove(self, _btn: Any) -> None: + comparison = self.selected + if comparison is None: + return + users = self.study.steps_using(comparison.name) + if not users: + self.remove_selected() + return + self._remove_note.value = ( + f"{html.escape(comparison.name)} is fitted by step(s) " + f"{html.escape(', '.join(users))}. Removing it takes it out of those steps and " + "discards their fit results that are not accepted yet; accepted values stay in " + "the parameter store." + ) + self._remove_confirm.layout.display = "" + + def _hide_remove_confirm(self) -> None: + self._remove_confirm.layout.display = "none" + + def remove_selected(self, *, from_steps: bool = False) -> None: + """Remove the selected measurement; `from_steps` also takes it out of the steps.""" + self._hide_remove_confirm() + comparison = self.selected + if comparison is None: + return + index = self.study.comparisons.index(comparison) + try: + self.study.remove_comparison(comparison.name, from_steps=from_steps) + except ValueError as exc: + self.status.value = status_html("error", str(exc)) + return + if self.study.comparisons: + self.select(self.study.comparisons[min(index, len(self.study.comparisons) - 1)].name) + self.status.value = status_html("ok", f"Removed {comparison.name}.") + + def _configuration_recipe(self) -> ConfigurationState: + """Return the configuration's recipe for the edited measurement. + + With the configuration's process template hidden, only the system is taken from + it (column, binding model and their values, hardware values); the measurement + keeps its components, process template, method values and how its run uses the + hardware (bypassed units, sample loop). + """ + recipe = self.configuration.snapshot() + editing = self._editing + if getattr(self.configuration, "show_process_template", True) or editing is None: + return recipe + own = editing.recipe + instrument = own.instrument + if recipe.instrument is not None and own.instrument is not None: + instrument = dataclasses.replace( + recipe.instrument, + bypass_units=list(own.instrument.bypass_units), + include_sample_loop=own.instrument.include_sample_loop, + unit_values={**own.instrument.unit_values, **recipe.instrument.unit_values}, + ) + same_binding = recipe.binding_key == own.binding_key + return dataclasses.replace( + own, + column_key=recipe.column_key, + column_values=map_species( + recipe.column_values, recipe.components, own.components + ), + binding_values=( + map_species(recipe.binding_values, recipe.components, own.components) + if same_binding else copy.deepcopy(own.binding_values) + ), + multiplex_state=dict(recipe.multiplex_state), + instrument=instrument, + ) + + # Editor + + def _check_name(self, name: str) -> Optional[str]: + if not name.strip(): + return "A name is required." + current = self._editing.name if self._editing is not None else None + if name != current and any(c.name == name for c in self.study.comparisons): + return f"{name!r} is already used." + return None + + def _refresh_runs(self) -> None: + runs = self._known_runs() + self._runs = runs + editing = self._editing + if editing is not None and editing.data_file not in runs: + runs[editing.data_file] = editing.run + labels = [ + (f"{key} ({len(run.channels)} channels)" if run is not None else f"{key} (not loaded)", + key) + for key, run in runs.items() + ] + was_loading = self._loading + self._loading = True + try: + self._run.set_options(labels) + finally: + self._loading = was_loading + + def _refresh_recipe_sources(self) -> None: + options: List[Tuple[str, Any]] = [("Keep this measurement's recipe", None)] + if self.configuration is not None: + options.append(("Current configuration", _CONFIG)) + store_dir = self.configuration.persistence.store_dir if self.configuration else None + options.extend( + (f"Saved: {name} ({hash_[:8]})", hash_) + for name, hash_ in configuration_store.list_store(store_dir=store_dir) + ) + self._recipe_source.set_options(options, keep_value=False) + + def _signal_options(self, recipe: ConfigurationState, overrides: dict) -> List[Tuple[str, str]]: + return [ + (f"{label} ({unit}.{port})", f"{unit}.{port}") + for label, (unit, port) in check_recipe(recipe, overrides).signal_options + ] + + def _load_editor(self) -> None: + comparison = self.selected + self._editing = comparison + self._card.layout.display = "none" if comparison is None else "" + if comparison is None: + return + self._loading = True + try: + self._name.value = comparison.name + self._name._run_validate() + self._experiment_type.value = comparison.experiment_type + self._fill_probe(comparison.experiment_type, comparison.probe) + self._refresh_runs() + self._run.value = comparison.data_file + self._flow_rate.value = ( + _format(comparison.flow_rate / ML_PER_MIN) + if comparison.flow_rate is not None else "" + ) + baseline = comparison.baseline_window or (None, None) + self._baseline_start.value = _format(baseline[0]) + self._baseline_end.value = _format(baseline[1]) + self._injection_x.value = _format(comparison.measured_injection) + self._normalization.value = comparison.normalization.get("kind", "none") + self._target_area.value = _format(comparison.normalization.get("target_area")) + self._metric.set_options( + [(m, m) for m in dict.fromkeys((*METRICS, comparison.metric))], keep_value=False + ) + self._metric.value = comparison.metric + self._window_start.value = _format(comparison.window[0]) + self._window_end.value = _format(comparison.window[1]) + self._load_run_dependents(comparison.run, comparison) + self._load_recipe(comparison.recipe, comparison) + finally: + self._loading = False + self._sync_visibility() + self._show_card(comparison) + + def _fill_probe(self, type_id: Optional[str], probe: Optional[str]) -> None: + """Offer the declared components whose role fits `type_id`; keep `probe` listed.""" + experiment_type = EXPERIMENT_TYPES.get(type_id or "") + roles = experiment_type.probe_roles if experiment_type is not None else None + options: List[Tuple[str, Optional[str]]] = [("None", None)] + options += component_options(self.study, roles) + if probe is not None and probe not in [v for _, v in options]: + component = self.study.component(probe) + note = f"{component.role}, does not fit" if component else "not declared" + options.append((f"{probe} ({note})", probe)) + self._probe.set_options(options, keep_value=False) + self._probe.value = probe + text, target = role_fix(self.study, probe, experiment_type) if ( + probe and experiment_type is not None + ) else ("", None) + self._probe_role_fix_target = (probe, target) if target is not None else None + self._probe_role_fix.value = status_html("warn", text) if text else "" + if target is not None: + self._btn_probe_role_fix.description = f"Make {probe} a {target}" + self._btn_probe_role_fix.layout.display = "" if target is not None else "none" + + def make_probe_fit(self) -> None: + """Give this measurement's component the role its experiment type needs.""" + if self._probe_role_fix_target is None: + return + name, role = self._probe_role_fix_target + self.study.set_component_role(name, role) + self.status.value = status_html("ok", f"{name} is now a {role}.") + + def _load_run_dependents(self, run: Optional[ExperimentalRun], comparison: Comparison) -> None: + channels = list(run.channels) if run is not None else [] + if comparison.channel and comparison.channel not in channels: + channels.append(comparison.channel) + self._channel.set_options([(c, c) for c in channels], keep_value=False) + self._channel.value = comparison.channel + + marker = comparison.injection_marker + options: List[Tuple[str, Optional[str]]] = [("None / manual", None)] + matched = False + for x, text in run.markers if run is not None else (): + value = text + if marker is not None and not matched and text.startswith(marker): + value, matched = marker, True + options.append((f"{text} @ {x:g} {run.x_unit}", value)) + if marker is not None and not matched: + options.append((f"{marker} (not in run)", marker)) + self._marker.set_options(options, keep_value=False) + self._marker.value = marker + + x_unit = run.x_unit if run is not None else "" + for field in (self._injection_x, self._baseline_start, self._baseline_end): + field.units = x_unit + file_note = f"file: {html.escape(comparison.data_file)}" if comparison.data_file else "" + self._data_file.value = f"{file_note}" + + def _load_recipe(self, recipe: ConfigurationState, comparison: Comparison) -> None: + self._recipe = recipe + self._template.set_options(_template_options(recipe), keep_value=False) + self._template.value = recipe.template_key + recipe_flow = recipe.model_values.get("flow_rate") + self._flow_rate_note.value = ( + f"volume → time; blank = recipe ({recipe_flow / ML_PER_MIN:g} mL/min)" + if recipe_flow is not None else "volume → time; blank = recipe (none set)" + ) + + self._override_fields = {} + children = [_note("Overrides (blank = recipe value)")] + for spec in _template_fields(recipe): + current = comparison.overrides.get(spec.name) + recipe_value = recipe.model_values.get(spec.name, spec.default) + label = f"{spec.label or spec.name} [recipe {_format_value(recipe_value)}]:" + if spec.kind == "choice": + element: Any = ChoiceField( + label=label, + options=[(f"recipe ({recipe_value})", None), *(spec.options or ())], + value=current, + ) + element.observe(self._on_edit, names="selected_index") + else: + shown = ( + ", ".join(f"{v:g}" for v in current) if spec.kind == "float_list" and current + else _format(current) + ) + element = TextField( + label=label, + value=shown, + units=spec.units, + validate=_check_float_list if spec.kind == "float_list" else _check_float, + ) + element.observe(self._on_edit, names="value") + self._override_fields[spec.name] = (spec, element) + children.append(element) + self._overrides_box.children = children + + self._component_fields = {} + for name in recipe.components: + box = BoolField(label=name, value=name in (comparison.components or ())) + box.observe(self._on_edit, names="value") + self._component_fields[name] = box + self._components_box.children = [ + _note("Components (none = total):"), + *self._component_fields.values(), + ] + + options = list(self._signal_options(recipe, comparison.overrides)) + if comparison.solution_path not in [v for _, v in options]: + options.append((f"{comparison.solution_path} (not in process)", + comparison.solution_path)) + self._solution_path.set_options(options, keep_value=False) + self._solution_path.value = comparison.solution_path + + def _show_card(self, comparison: Comparison) -> None: + self._card_title.value = ( + f"
    {html.escape(comparison.name)}
    " + ) + self._recipe_summary.value = recipe_summary_html(comparison) + self._diagram.value = recipe_diagram_svg( + comparison.recipe, observe=_observed_unit(comparison.solution_path), compact=True + ) + problems = self.study.measurement_problems(comparison) + unprepared = self._show_measured(comparison) + if unprepared and not problems: + problems = [unprepared] + self._problems_html.value = problems_html(problems) + + def _show_measured(self, comparison: Comparison) -> Optional[str]: + """Plot the measured trace as the fit compares it, else the raw trace; return why.""" + run = comparison.run + if run is None or comparison.channel not in run.channels: + self._measured_chart.series = [] + self._measured_note.value = "No measured data loaded." + return None + try: + check = check_recipe(comparison.recipe, comparison.overrides) + if check.error is not None: + raise ValueError(check.error) + reference = comparison.build_reference(check.process) + time = np.asarray(reference.time) + values = np.asarray(reference.solution)[:, 0] + problem = None + except Exception as exc: # noqa: BLE001 -- shown with the raw trace instead + time, values = to_time(run, comparison.channel, comparison.resolved_flow_rate) + problem = str(exc) + stride = max(1, math.ceil(time.size / _MAX_POINTS)) + self._measured_chart.series = [{ + "name": comparison.name, + "times": (time[::stride] / 60.0).tolist(), + "values": values[::stride].tolist(), + "reference": True, + }] + self._measured_note.value = ( + status_html("warn", "Showing the raw trace; it cannot be prepared for the fit.") + if problem else "" + ) + return problem + + def _sync_visibility(self) -> None: + self._injection_x.layout.display = "" if self._marker.value is None else "none" + self._target_area.layout.display = "" if self._normalization.value == "area" else "none" + + def _on_run_change(self, _change: dict) -> None: + if self._loading or self._editing is None: + return + run = self._runs.get(self._run.value) + draft = dataclasses.replace(self._editing, data_file=self._run.value or "", run=run) + if run is not None and draft.channel not in run.channels: + draft.channel = next(iter(run.channels), "") + if run is not None and draft.injection_marker is not None and not any( + text.startswith(draft.injection_marker) for _, text in run.markers + ): + draft.injection_marker = None + self._loading = True + try: + self._load_run_dependents(run, draft) + finally: + self._loading = False + self._on_edit() + + def _on_template_change(self, _change: dict) -> None: + if self._loading or self._editing is None or self._recipe is None: + return + template = self._template.value + if template is None or template == self._recipe.template_key: + return + self._set_recipe(recipe_with_template(self._recipe, template)) + + def _on_use_recipe(self, _btn: Any) -> None: + if self._editing is None: + return + source = self._recipe_source.value + try: + if source is None: + recipe = self._editing.recipe + elif source == _CONFIG: + recipe = self._configuration_recipe() + else: + store_dir = self.configuration.persistence.store_dir if self.configuration else None + _, recipe = configuration_store.load_from_store(source, store_dir=store_dir) + except (RuntimeError, FileNotFoundError) as exc: + self.status.value = status_html("error", str(exc)) + return + self._set_recipe(recipe) + + def _set_recipe(self, recipe: ConfigurationState) -> None: + draft = dataclasses.replace( + self._editing, + recipe=recipe, + components=[c for c in self._editing.components or () if c in recipe.components] + or None, + ) + self._loading = True + try: + self._load_recipe(recipe, draft) + finally: + self._loading = False + self._on_edit() + + def _read_overrides(self) -> dict: + overrides = {} + for name, (spec, element) in self._override_fields.items(): + if spec.kind == "choice": + if element.value is not None: + overrides[name] = element.value + elif element.value.strip(): + overrides[name] = ( + parse_float_list(element.value) if spec.kind == "float_list" + else float(element.value) + ) + return overrides + + def _draft(self) -> Comparison: + """Return the comparison the editor fields describe.""" + assert self._editing is not None and self._recipe is not None + flow_rate = _optional_float(self._flow_rate.value) + start, end = _optional_float(self._baseline_start.value), _optional_float( + self._baseline_end.value + ) + normalization: dict = {"kind": self._normalization.value} + if self._normalization.value == "area": + normalization["target_area"] = _optional_float(self._target_area.value) + components = [n for n, box in self._component_fields.items() if box.value] + recipe = self._recipe + probe, old = self._probe.value, self._editing.probe + if probe and old and probe != old and old in recipe.components: + recipe = dataclasses.replace( + recipe, components=[probe if n == old else n for n in recipe.components] + ) + components = [probe if n == old else n for n in components] + return dataclasses.replace( + self._editing, + name=self._name.value.strip(), + probe=probe, + experiment_type=self._experiment_type.value, + data_file=self._run.value or "", + run=self._runs.get(self._run.value), + channel=self._channel.value or "", + flow_rate=flow_rate * ML_PER_MIN if flow_rate is not None else None, + recipe=recipe, + overrides=self._read_overrides(), + injection_marker=self._marker.value, + measured_injection=( + _optional_float(self._injection_x.value) if self._marker.value is None else None + ), + baseline_window=None if start is None and end is None else (start, end), + normalization=normalization, + solution_path=self._solution_path.value or "", + components=components or None, + metric=self._metric.value, + window=( + _optional_float(self._window_start.value), + _optional_float(self._window_end.value), + ), + ) + + def _invalid_fields(self) -> List[str]: + fields = [ + self._name, self._flow_rate, self._injection_x, self._baseline_start, + self._baseline_end, self._target_area, self._window_start, self._window_end, + *(element for _, element in self._override_fields.values()), + ] + return [f.label.rstrip(":") for f in fields if not f.is_valid] + + def _on_edit(self, _change: Optional[dict] = None) -> None: + if self._loading or self._editing is None: + return + self._sync_visibility() + invalid = self._invalid_fields() + if invalid: + self._problems_html.value = status_html( + "error", f"Fix {', '.join(invalid)} under Advanced to save this measurement." + ) + return + draft = self._draft() + old_name = self._editing.name + self._committing = True + try: + self._editing = draft + self._selected_name = draft.name + self.study.replace_comparison(old_name, draft) + finally: + self._committing = False + self._loading = True + try: + self._fill_probe(draft.experiment_type, draft.probe) + if draft.recipe is not self._recipe: + self._load_recipe(draft.recipe, draft) + finally: + self._loading = False + self._sync_table_selection() + self._show_card(draft) + + def add_listener(self, fn: Callable[[], None]) -> None: + """Call `fn()` whenever the study changes.""" + self.study.add_listener(fn) + + def display(self) -> None: + """Render this widget in a Jupyter cell.""" + from IPython.display import display as _display + + _display(self.root) diff --git a/cadetgui/widgets/composite/configuration.py b/cadetgui/widgets/composite/configuration.py index 795231c..74030f9 100644 --- a/cadetgui/widgets/composite/configuration.py +++ b/cadetgui/widgets/composite/configuration.py @@ -1,5 +1,6 @@ from __future__ import annotations +import dataclasses from pathlib import Path from typing import Any, Callable, List, Mapping, Optional @@ -23,7 +24,8 @@ step_elution_spec, template_required_units, ) -from ...configuration_store import ConfigurationState +from ...io.configuration_store import ConfigurationState +from ...starting_values import with_starting_values from .._chrome import style_tag from .._settings_popover import SettingsPopover from .._status import status_html @@ -43,13 +45,24 @@ __all__ = ["ConfigurationWidget"] _CYCLE_TIME_SLIDER_MAX_SECONDS = 300.0 * 60.0 -_DEFAULT_CONFIG_NAME = "New Experiment" +_DEFAULT_CONFIG_NAME = "New Project" _BYPASS_NOTE = "Column is bypassed in the System configuration -- these settings are not used." # Gradient templates are meaningless with a single component. _TEMPLATES_REQUIRING_MULTIPLE_COMPONENTS = frozenset({lwe_spec, step_elution_spec}) +def _push_to_slider(slider: W.FloatSlider, value: Any) -> None: + """Show a typed value on `slider`, widening its range instead of clamping the value.""" + try: + value = _round_10sf(float(value)) + except (TypeError, ValueError): + return + if value > slider.max: + slider.max = value * 2 + slider.value = max(value, slider.min) + + def _round_10sf(v: float) -> float: """Round to 10 significant figures, clearing browser-slider float noise.""" return float(f"{v:.10g}") @@ -100,6 +113,15 @@ class ConfigurationWidget: `.workspace_header` (name, Save, saved-version/run counts, backend versions) is embedded at the top of this panel by default; pass `workspace_header=False` when a shell places `workspace_header.root` elsewhere. + + Bound to an instrument, the component names are edited in the instrument's Components + section; this widget's own "Component System" section is hidden and follows it. + + `show_process_template=False` hides the process-template picker and the template's + method fields; the template stays selected internally so `snapshot()` still + returns a complete recipe, and a bound instrument's sample loop is not locked by it. + `intro` is HTML shown under the panel title. `set_flow_rate()` sets the flow rate every + snapshot carries, whatever the method form holds. """ def __init__( @@ -107,7 +129,11 @@ def __init__( *, instrument: Optional[InstrumentWidget] = None, workspace_header: bool = True, + show_process_template: bool = True, + intro: str = "", ) -> None: + self.show_process_template = show_process_template + self._flow_rate: Optional[float] = None self._column_cache: dict[Any, Any] = {} self._binding_cache: dict[Any, Any] = {} self._instrument: Optional[InstrumentWidget] = None @@ -116,6 +142,8 @@ def __init__( self._column_form: Optional[FormRenderer] = None self._binding_form: Optional[FormRenderer] = None self._forms_key: Optional[tuple] = None + self._model_key: Optional[tuple] = None + self._restoring = False self._model_form: Optional[FormRenderer] = None self._event_sliders: dict[str, W.FloatSlider] = {} self._cycle_time_minutes_element: Optional[FloatField] = None @@ -187,7 +215,7 @@ def __init__( self.persistence = ConfigurationPersistence( default_name=_DEFAULT_CONFIG_NAME, - snapshot=self._snapshot_state, + snapshot=self.snapshot, apply_state=self._apply_state, get_process=lambda: self.process, on_name_change=lambda _name: self._notify(), @@ -202,6 +230,8 @@ def __init__( self._component_note, ] ) + self._components_section = components_section + self._pulling_components = False column_section = _section( [ _titled_header("Column Model", self._column_settings), @@ -239,11 +269,18 @@ def __init__( process_columns, ] ) + self._process_section = process_section + if not show_process_template: + process_section.layout.display = "none" + intro_html = W.HTML(intro) + intro_html.add_class("cadetgui-note") + intro_html.layout.display = "" if intro else "none" self.root = W.VBox( [ W.HTML(style_tag()), W.HTML("
    Configuration
    "), + intro_html, *([self.workspace_header.root] if workspace_header else []), components_section, column_binding_row, @@ -269,6 +306,7 @@ def __init__( def bind_to_instrument(self, instrument: InstrumentWidget) -> None: """Attach the LC-system topology layer and switch to the `INSTRUMENT_TEMPLATES` registry.""" self._instrument = instrument + self._components_section.layout.display = "none" instrument.add_listener(self._on_instrument_changed) self._column_cache.clear() self._binding_cache.clear() @@ -313,7 +351,7 @@ def _push_column_to_instrument(self) -> None: ) def _sync_template_dropdown(self) -> None: - if self._instrument is None: + if self._instrument is None or not self.show_process_template: return registry = self._active_registry() self._instrument.set_template_options( @@ -326,6 +364,38 @@ def _select_template(self, label: str) -> None: if template is not None: self._model_picker.value = template + def select_models( + self, + *, + template: Optional[str] = None, + column: Optional[str] = None, + binding: Optional[str] = None, + ) -> None: + """Pick the process template, column and binding model by their registry keys.""" + if template is not None: + self._model_picker.value = self._active_registry()[template] + if column is not None: + self._column_picker.value = COLUMN_MODELS[column] + if binding is not None: + self._binding_picker.value = BINDING_MODELS[binding] + + @property + def flow_rate(self) -> Optional[float]: + """The flow rate (m³/s) snapshots carry: the one set, else the method form's.""" + if self._flow_rate is not None: + return self._flow_rate + if self._model_form is None: + return None + values = self._model_form.collect_values() + value = values.get("flow_rate", values.get("flow_rate_wash")) + return float(value) if value is not None else None + + def set_flow_rate(self, value: Optional[float]) -> None: + """Make every snapshot carry flow rate `value` (m³/s); `None` uses the method form.""" + self._flow_rate = None if value is None else float(value) + self.persistence.refresh_hash_display() + self._notify() + @property def flow_sheet(self) -> Optional[LCFlowSheet]: """The bound instrument's current flow sheet, or None if unbound/unbuilt.""" @@ -421,6 +491,15 @@ def _sync_column_bypass(self) -> None: def _on_instrument_changed(self, flow_sheet: Any) -> None: if self._suspend_rebuild: return + names = self._instrument.components + if names != list(self._components.value): + self._column_cache.clear() + self._binding_cache.clear() + self._pulling_components = True + try: + self._components.value = names + finally: + self._pulling_components = False if flow_sheet is None: self.process = None self._sync_column_bypass() @@ -434,6 +513,8 @@ def _on_instrument_changed(self, flow_sheet: Any) -> None: self._apply_template_constraints() def _on_components_change(self, _change: dict) -> None: + if self._pulling_components: + return self._column_cache.clear() self._binding_cache.clear() if self._instrument is not None: @@ -451,7 +532,7 @@ def _on_selection_change(self, _change: dict) -> None: def _sync_instrument_requirements(self) -> None: """Tell a bound instrument which units the selected template can't be built without.""" - if self._instrument is None: + if self._instrument is None or not self.show_process_template: return model_fn = self._model_picker.value was_suspended = self._suspend_rebuild @@ -479,13 +560,14 @@ def _apply_template_constraints(self) -> None: """Enforce the template's component floor, then rebuild the forms.""" required = self._required_min_components() self._components.min_components = required - if required > 1: - self._component_note.value = ( - f"The process template you selected does not allow less than {required} components." - ) - self._component_note.layout.display = "" - else: - self._component_note.layout.display = "none" + note = ( + f"The process template you selected does not allow less than {required} components." + if required > 1 else "" + ) + self._component_note.value = note + self._component_note.layout.display = "" if note else "none" + if self._instrument is not None: + self._instrument.set_min_components(required, note) names = self._components.value if len(names) < required: @@ -519,7 +601,7 @@ def _on_process_built(self, built: Any) -> None: self._notify() self.status.value = "Process built." - def _snapshot_state(self) -> ConfigurationState: + def snapshot(self) -> ConfigurationState: """Capture the current selection and field values as the hashed save/import payload.""" if self._model_form is None: raise RuntimeError("Nothing built yet -- pick a column and a process template first.") @@ -528,6 +610,11 @@ def _snapshot_state(self) -> ConfigurationState: template_key = _key_for_value(self._active_registry(), self._model_picker.value) if column_key is None or binding_key is None or template_key is None: raise RuntimeError("Current selection isn't in a known registry -- can't snapshot it.") + model_values = self._model_form.collect_values() + if self._flow_rate is not None: + keys = [k for k in ("flow_rate", "flow_rate_wash") if k in model_values] + for key in keys or ["flow_rate"]: + model_values[key] = self._flow_rate return ConfigurationState( components=list(self._components.value), column_key=column_key, @@ -539,7 +626,7 @@ def _snapshot_state(self) -> ConfigurationState: show_optional_binding=bool(self._show_optional_binding_checkbox.value), column_values=self._column_form.collect_values() if self._column_form else {}, binding_values=self._binding_form.collect_values() if self._binding_form else {}, - model_values=self._model_form.collect_values(), + model_values=model_values, ) @property @@ -570,9 +657,16 @@ def import_from_store(self, hash_: str) -> None: def _apply_state(self, name: str, state: ConfigurationState) -> None: """Reconstruct pickers/forms (and a bound instrument, if any) from a saved state.""" + self._restoring = True + try: + self._restore_state(name, state) + finally: + self._restoring = False + + def _restore_state(self, name: str, state: ConfigurationState) -> None: column_cls = COLUMN_MODELS.get(state.column_key) binding_cls = BINDING_MODELS.get(state.binding_key) - if column_cls is None or binding_cls is None: + if column_cls is None or state.binding_key not in BINDING_MODELS: raise ValueError( "Saved configuration references a column or binding model" " that isn't registered here anymore." @@ -652,17 +746,28 @@ def _rebuild_forms(self) -> None: self._binding_form.collect_values() if carry and self._binding_form else None ) self._forms_key = form_key + # The template is rebuilt from scratch every time, so its method values are + # carried over until the template, binding model or components change. + model_key = (model_fn, self._binding_picker.value, tuple(self._components.value)) + previous_model = ( + self._model_form.collect_values() + if self._model_form is not None and model_key == self._model_key else None + ) + self._model_key = model_key self._rebuild_column_form(column, previous_column) self._rebuild_binding_form(column, binding_model, previous_binding) self._sync_column_bypass() self._model_form = FormRenderer(model_fn(model_arg), on_built=self._on_process_built) + if previous_model is not None: + self._model_form.set_values(previous_model) self._model_form_box.children = (self._model_form.root,) self._rebuild_event_sliders() # The model form's `on_built` fires inside its constructor, before `_model_form` is # assigned, so the hash must be refreshed again here. self.persistence.refresh_hash_display() + self._apply_starting_values(method=previous_model is None) self.status.value = ( "Fields apply automatically as you edit them" @@ -712,6 +817,23 @@ def _attach_binding(built: Any) -> None: if previous is not None: self._binding_form.set_values(previous) + def _apply_starting_values(self, *, method: bool) -> None: + """Fill the shared starting values into freshly built forms (see `starting_values`). + + Method values are only used while the process template is shown and with `method`. + """ + if self._restoring: + return + state = self.snapshot() + shown = self.show_process_template + seeded = with_starting_values(state, method=shown and method, equilibrate=shown) + known = getattr(COLUMN_MODELS.get(state.column_key), "_parameters", ()) + seeded = dataclasses.replace(seeded, column_values={ + k: v for k, v in seeded.column_values.items() if k in known + }) + if seeded != state: + self._apply_state(self.config_name, seeded) + def _clear_event_section(self) -> None: self._event_sliders = {} self._cycle_time_minutes_element = None @@ -750,7 +872,7 @@ def _rebuild_event_sliders(self) -> None: ) # dlink + rounding rather than link: slider drags report float noise. W.dlink((slider, "value"), (el, "value"), transform=_round_10sf) - W.dlink((el, "value"), (slider, "value"), transform=_round_10sf) + el.observe(lambda change, s=slider: _push_to_slider(s, change["new"]), names="value") slider.observe(lambda _change: self._redraw_event_plot(), names="value") self._event_sliders[f.name] = slider diff --git a/cadetgui/widgets/composite/configuration_persistence.py b/cadetgui/widgets/composite/configuration_persistence.py index af86277..c1f91c5 100644 --- a/cadetgui/widgets/composite/configuration_persistence.py +++ b/cadetgui/widgets/composite/configuration_persistence.py @@ -6,7 +6,7 @@ import ipywidgets as W -from ...configuration_store import ( +from ...io.configuration_store import ( ConfigurationState, compute_hash, load_from_store, diff --git a/cadetgui/widgets/composite/data_import.py b/cadetgui/widgets/composite/data_import.py index a2d2f38..ce57507 100644 --- a/cadetgui/widgets/composite/data_import.py +++ b/cadetgui/widgets/composite/data_import.py @@ -1,62 +1,51 @@ from __future__ import annotations -import csv -import io -from dataclasses import dataclass -from typing import Callable, List, Optional, Tuple +from typing import Callable, List, Optional import ipywidgets as W -import numpy as np +from ...io.experimental_data import ( + ColumnRole, + ExperimentalDataset, + ExperimentalRun, + detect_column_roles, + is_akta_format, + read_akta_csv, + read_generic_csv, + read_headers, + to_time, +) from .._chrome import style_tag from .._status import status_html from ..elements import ChoiceField +from ._measurement_common import COLUMN_ROLE_OPTIONS __all__ = ["DataImportWidget", "ExperimentalDataset"] -@dataclass -class ExperimentalDataset: - """One imported experimental signal, time already normalized to minutes.""" - - label: str - time_min: np.ndarray - signal: np.ndarray - - -def _parse_csv(content: bytes) -> Tuple[np.ndarray, np.ndarray]: - """Parse (time, signal) from a CSV's first two columns, skipping the header row.""" - text = content.decode("utf-8", errors="replace") - times: List[float] = [] - values: List[float] = [] - for row in list(csv.reader(io.StringIO(text)))[1:]: - if len(row) < 2: - continue - try: - times.append(float(row[0])) - values.append(float(row[1])) - except ValueError: - continue - return np.array(times), np.array(values) - - class DataImportWidget: - """Import experimental chromatography data (CSV: time, signal) for comparison. + """Import experimental chromatography data for comparison. - Assumes a header row and uses the file's first two columns as time/signal. + Reads generic header CSVs (one x column + N signal columns) and Äkta/Unicorn-style + exports (auto-detected). A simple 2-column CSV is auto-mapped using the min/s + toggle, same as before; anything else gets a column-mapping step before import. """ def __init__(self) -> None: self.datasets: List[ExperimentalDataset] = [] self._listeners: List[Callable[[], None]] = [] - self._upload = W.FileUpload(accept=".csv", multiple=True, description="Import CSV") + self._upload = W.FileUpload( + accept=".csv,.txt,.tsv", multiple=True, description="Import CSV" + ) self._time_unit = ChoiceField( label="Time column is in:", options=[("minutes", "min"), ("seconds", "s")], ) self._dataset_picker = ChoiceField(label="Loaded:", options=[]) self._btn_remove = W.Button(description="Remove", icon="trash") + self._mapping_box = W.VBox([]) + self._basis_label = W.HTML() self._preview_out = W.Output() self.status = W.HTML("No experimental data loaded.") self.status.add_class("cadetgui-status") @@ -75,6 +64,8 @@ def __init__(self) -> None: [ W.HTML(style_tag()), toolbar, + self._mapping_box, + self._basis_label, self._preview_out, self.status, ] @@ -93,21 +84,136 @@ def _on_upload(self, _change: dict) -> None: if not self._upload.value: return for item in self._upload.value: - name = item["name"] - time, signal = _parse_csv(bytes(item["content"])) - if time.size == 0: + self._handle_upload_item(item) + self._upload.value = () # allow re-uploading the same filename later + self._finish_import() + + def _handle_upload_item(self, item: dict) -> None: + name = item["name"] + label = name.rsplit(".", 1)[0] + content = bytes(item["content"]) + + if is_akta_format(content): + self._queue_mapping(label, name, content, akta=True) + return + + headers = read_headers(content) + if len(headers) == 2: + self._import_simple(label, name, content) + elif len(headers) > 2: + self._queue_mapping(label, name, content, akta=False, headers=headers) + else: + self.status.value = status_html("error", f"Could not parse {name}.") + + def _import_simple(self, label: str, name: str, content: bytes) -> None: + """Auto-map a plain 2-column CSV using the min/s toggle, no mapping step.""" + role: ColumnRole = "time_s" if self._time_unit.value == "s" else "time_min" + try: + run = read_generic_csv(content, label, roles=[role, "signal"]) + except ValueError: + self.status.value = status_html("error", f"Could not parse {name}.") + return + channel_name = next(iter(run.channels)) + if run.channels[channel_name].x.size == 0: + self.status.value = status_html("error", f"Could not parse {name}.") + return + self._add_dataset_from_channel(run, channel_name) + + def _queue_mapping( + self, + label: str, + name: str, + content: bytes, + *, + akta: bool, + headers: Optional[List[str]] = None, + ) -> None: + role_dropdowns: List[W.Dropdown] = [] + parsed_run: Optional[ExperimentalRun] = None + + if akta: + try: + parsed_run = read_akta_csv(content, label) + except ValueError: self.status.value = status_html("error", f"Could not parse {name}.") - continue - if self._time_unit.value == "s": - time = time / 60.0 - label = name.rsplit(".", 1)[0] - self.datasets.append(ExperimentalDataset(label=label, time_min=time, signal=signal)) - self.status.value = f"Loaded {label} ({time.size} points)." + return + else: + default_roles = detect_column_roles(headers or []) + role_dropdowns = [ + W.Dropdown(options=COLUMN_ROLE_OPTIONS, value=role, description=header) + for header, role in zip(headers or [], default_roles) + ] + + flow_rate_field = W.FloatText(value=1.0, description="Flow rate (mL/min):") + flow_rate_box = W.HBox([flow_rate_field]) + + def _update_flow_visibility(_change: object = None) -> None: + is_volume = akta or any(d.value == "volume_ml" for d in role_dropdowns) + flow_rate_box.layout.display = "" if is_volume else "none" + + for dropdown in role_dropdowns: + dropdown.observe(_update_flow_visibility, names="value") + _update_flow_visibility() + + import_btn = W.Button(description="Import", button_style="primary") + cancel_btn = W.Button(description="Cancel") + def _remove_panel() -> None: + self._mapping_box.children = tuple( + c for c in self._mapping_box.children if c is not panel + ) + + def _on_import(_btn: object) -> None: + if akta: + built = parsed_run + else: + roles = [d.value for d in role_dropdowns] + try: + built = read_generic_csv(content, label, roles=roles) + except ValueError as exc: + self.status.value = status_html("error", str(exc)) + return + flow_rate = ( + flow_rate_field.value * 1e-6 / 60.0 if built.x_basis == "volume" else None + ) + for channel_name in built.channels: + self._add_dataset_from_channel(built, channel_name, flow_rate) + _remove_panel() + self._finish_import() + + def _on_cancel(_btn: object) -> None: + _remove_panel() + + import_btn.on_click(_on_import) + cancel_btn.on_click(_on_cancel) + + rows: List[W.Widget] = [W.HTML(f"Map columns — {name}")] + if akta: + channel_list = ", ".join(parsed_run.channels) + rows.append(W.HTML(f"Detected Äkta/Unicorn export. Channels: {channel_list}.")) + else: + rows.extend(role_dropdowns) + rows.append(flow_rate_box) + rows.append(W.HBox([import_btn, cancel_btn])) + panel = W.VBox(rows) + panel.add_class("cadetgui-panel") + self._mapping_box.children = tuple(self._mapping_box.children) + (panel,) + + def _add_dataset_from_channel( + self, run: ExperimentalRun, channel_name: str, flow_rate: Optional[float] = None + ) -> None: + time_s, values = to_time(run, channel_name, flow_rate) + label = run.label if len(run.channels) == 1 else f"{run.label} — {channel_name}" + ds = ExperimentalDataset( + label=label, time_min=time_s / 60.0, signal=values, run=run, channel=channel_name + ) + self.datasets.append(ds) + self.status.value = f"Loaded {label} ({time_s.size} points)." + + def _finish_import(self) -> None: self._dataset_picker.set_options([(d.label, d) for d in self.datasets], keep_value=False) if self.datasets: self._dataset_picker.selected_index = len(self.datasets) - 1 - self._upload.value = () # allow re-uploading the same filename later self._preview_selected() self._notify() @@ -118,6 +224,7 @@ def _on_remove(self, _btn: object) -> None: self.datasets.remove(ds) self._dataset_picker.set_options([(d.label, d) for d in self.datasets]) self._preview_out.clear_output() + self._basis_label.value = "" self.status.value = f"Removed {ds.label}." self._notify() @@ -128,7 +235,9 @@ def _preview_selected(self) -> None: ds: Optional[ExperimentalDataset] = self._dataset_picker.value self._preview_out.clear_output(wait=True) if ds is None: + self._basis_label.value = "" return + self._basis_label.value = f"x-basis: {ds.run.x_basis if ds.run else 'time'}" with self._preview_out: import matplotlib.pyplot as plt from IPython.display import display diff --git a/cadetgui/widgets/composite/flow_rate.py b/cadetgui/widgets/composite/flow_rate.py new file mode 100644 index 0000000..20ce66c --- /dev/null +++ b/cadetgui/widgets/composite/flow_rate.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import html +import math +from typing import Optional + +import ipywidgets as W + +from ..elements import TextField +from ._measurement_common import ML_PER_MIN +from .configuration import ConfigurationWidget + +__all__ = ["FlowRateSection"] + + +def _check_flow_rate(text: str) -> Optional[str]: + try: + value = float(text) + except ValueError: + return "Enter the flow rate in mL/min." + return None if value > 0 and math.isfinite(value) else "Enter a positive flow rate." + + +class FlowRateSection: + """A "Flow rate" section (mL/min) that sets `configuration.set_flow_rate`. + + The field follows the configuration's flow rate unless it holds an unsaved edit. + """ + + def __init__(self, configuration: ConfigurationWidget, *, note: str = "") -> None: + self.configuration = configuration + self.field = TextField(label="Flow rate:", units="mL/min", validate=_check_flow_rate) + self._sync() + self.field.observe(lambda _c: self._on_edit(), names="value") + configuration.add_listener(lambda *_: self._sync()) + self.root = W.VBox([ + W.HTML("
    Flow rate
    "), + self.field, + W.HTML(f"{html.escape(note)}" if note else ""), + ]) + self.root.add_class("cadetgui-section") + + def _sync(self) -> None: + value = self.configuration.flow_rate + current = self.field.value.strip() + try: + same = value is not None and bool(current) and math.isclose( + float(current) * ML_PER_MIN, value, rel_tol=1e-9 + ) + except ValueError: + return + if not same: + self.field.value = f"{value / ML_PER_MIN:.6g}" if value is not None else "" + + def _on_edit(self) -> None: + if _check_flow_rate(self.field.value) is not None: + return + value = float(self.field.value) * ML_PER_MIN + current = self.configuration.flow_rate + if current is None or not math.isclose(current, value, rel_tol=1e-9): + self.configuration.set_flow_rate(value) + + def display(self) -> None: + """Render this widget in a Jupyter cell.""" + from IPython.display import display as _display + + _display(self.root) diff --git a/cadetgui/widgets/composite/instrument.py b/cadetgui/widgets/composite/instrument.py index 9b8464e..3ac1a6c 100644 --- a/cadetgui/widgets/composite/instrument.py +++ b/cadetgui/widgets/composite/instrument.py @@ -1,7 +1,18 @@ from __future__ import annotations from dataclasses import replace -from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Collection, + Dict, + Iterable, + List, + Mapping, + Optional, + Sequence, +) import ipywidgets as W from CADETProcess.instruments import LCFlowSheet @@ -11,38 +22,31 @@ BINDING_MODELS, BYPASSABLE_UNITS, COLUMN_MODELS, + CONFIGURABLE_UNITS, UNIT_LABELS, + UNIT_SEED_DEFAULTS, ModelSpec, build_parameter_config_spec, require_finite_above, ) -from ...configuration_store import InstrumentState +from ...io.configuration_store import InstrumentState from .._chrome import style_tag from .._settings_popover import toggle_box from .._status import status_html -from ..elements import ChoiceField, FloatField +from ..elements import ChoiceField, ComponentListField, FloatField from ..forms import FormRenderer from .system_diagram import SystemDiagram +if TYPE_CHECKING: + from ...characterization.study import Study + from .study_components import StudyComponentsWidget + __all__ = ["InstrumentWidget"] # The column's own form belongs to ConfigurationWidget; this widget only toggles it. -_CONFIGURABLE_UNITS = ( - "mixer", "tubing_pre_injection", "tubing_pre_column", "tubing_post_column", "tubing_detectors", -) - -# Non-degenerate starting values; CADET-Core has no canonical default for these. -_TUBING_SEED_DEFAULTS: Dict[str, float] = { - "diameter": 0.5e-3, "length": 0.1, "axial_dispersion": 1e-7, -} -_UNIT_SEED_DEFAULTS: Dict[str, Dict[str, float]] = { - "mixer": {"init_liquid_volume": 1e-6}, - "tubing_pre_injection": _TUBING_SEED_DEFAULTS, - "tubing_pre_column": _TUBING_SEED_DEFAULTS, - "tubing_post_column": _TUBING_SEED_DEFAULTS, - "tubing_detectors": _TUBING_SEED_DEFAULTS, -} +_CONFIGURABLE_UNITS = CONFIGURABLE_UNITS +_UNIT_SEED_DEFAULTS = UNIT_SEED_DEFAULTS _ZERO_ALLOWED_PARAMS = frozenset({"axial_dispersion"}) @@ -79,9 +83,11 @@ class InstrumentWidget: Optional layer on top of `ConfigurationWidget`: adds a sample loop and the choice of which units (mixer, tubing, the column itself) are in the flow path, each with its own - parameter form. Component names and the column/binding types are driven from outside - via `.components` and `set_column_and_binding()`; both default to something sane so the - widget also builds a flow sheet on its own. + parameter form. The "Components" section above the hardware edits the component names + (also settable via `.components`); the column/binding types are driven from outside via + `set_column_and_binding()`. Both default to something sane so the widget also builds a + flow sheet on its own. `bind_study(study)` turns the Components section into an editor + of `study.components` (name and role) that the component names follow. Builds `.flow_sheet`, auto-committing on every valid change; `add_listener` fires with it on every build. While any input is invalid the offending field shows the error, @@ -91,11 +97,15 @@ class InstrumentWidget: it (`set_required_units`) while the selected template can't be built without one. The unit toggles, sample-loop controls and per-unit forms live in a collapsible - "Hardware in the flow path" section (`hardware_expanded` sets its initial state). + "Hardware" section (`hardware_expanded` sets its initial state). Collapsing only hides the controls; a one-line summary stays visible. + + With `hardware_only`, the diagram shows the installed hardware without the inlet use of + a process and no process-template picker is shown (see `SystemDiagram`). """ - def __init__(self, *, hardware_expanded: bool = False) -> None: + def __init__(self, *, hardware_expanded: bool = False, hardware_only: bool = False) -> None: + self.hardware_only = hardware_only self._listeners: List[Callable[[Any], None]] = [] self._built_sheet: Optional[LCFlowSheet] = None self._problems: Dict[str, str] = {} @@ -103,6 +113,21 @@ def __init__(self, *, hardware_expanded: bool = False) -> None: self._suspend_rebuild = False self._component_names: List[str] = ["Component 1"] + self._syncing_components = False + self._study: Optional["Study"] = None + self._study_names: List[str] = [] + self.study_components: Optional["StudyComponentsWidget"] = None + self._components_field = ComponentListField( + label="Components:", value=self._component_names + ) + self._components_note = W.HTML("", layout=W.Layout(display="none")) + self._components_section = W.VBox([ + W.HTML("
    Components
    "), + self._components_field, + self._components_note, + ]) + self._components_section.add_class("cadetgui-section") + self._components_field.observe(self._on_components_edit, names="value") self._column_cls: Optional[type] = COLUMN_MODELS.get( "Lumped Rate Model Without Pores (LRM)" ) @@ -144,7 +169,7 @@ def __init__(self, *, hardware_expanded: bool = False) -> None: self._carries: Optional[Mapping[str, Sequence[str]]] = None self._equilibration: Sequence[str] = () self._column_label: Optional[str] = None - self._diagram = SystemDiagram() + self._diagram = SystemDiagram(hardware_only=hardware_only) self._on_template_select: Optional[Callable[[str], None]] = None self._syncing_template = False self._template_picker = ChoiceField(label="Process template:") @@ -185,7 +210,7 @@ def __init__(self, *, hardware_expanded: bool = False) -> None: ) self._hardware_body = W.VBox([self._flow_path_note, *unit_subsections]) self._hardware_toggle = W.Button( - description="Hardware in the flow path", icon="chevron-right", + description="Hardware", icon="chevron-right", tooltip="Show or hide the units in the flow path", ) self._hardware_summary = W.HTML("", layout=W.Layout(margin="0 0 0 10px")) @@ -207,6 +232,7 @@ def __init__(self, *, hardware_expanded: bool = False) -> None: W.HTML(style_tag()), W.HTML("
    System
    "), self._template_picker, + self._components_section, self._diagram.root, self._flow_path_section, self.status, @@ -228,7 +254,7 @@ def __init__(self, *, hardware_expanded: bool = False) -> None: @property def hardware_expanded(self) -> bool: - """Whether the "Hardware in the flow path" controls are currently shown.""" + """Whether the "Hardware" controls are currently shown.""" return self._hardware_body.layout.display != "none" def set_hardware_expanded(self, expanded: bool) -> None: @@ -236,6 +262,10 @@ def set_hardware_expanded(self, expanded: bool) -> None: self._hardware_body.layout.display = "" if expanded else "none" self._hardware_toggle.icon = "chevron-down" if expanded else "chevron-right" + def set_highlight(self, units: Collection[str], observe: Optional[str] = None) -> None: + """Highlight `units` and the observed outlet `observe` on the diagram.""" + self._diagram.set_highlight(units, observe) + def _on_hardware_toggle(self, _btn: Any) -> None: self.set_hardware_expanded(toggle_box(self._hardware_body)) @@ -286,9 +316,47 @@ def components(self) -> List[str]: @components.setter def components(self, names: List[str]) -> None: self._component_names = list(names) + if list(self._components_field.value) != self._component_names: + self._syncing_components = True + try: + self._components_field.value = list(self._component_names) + finally: + self._syncing_components = False if not self._suspend_rebuild: self._rebuild() + def _on_components_edit(self, change: dict) -> None: + if not self._syncing_components: + self.components = list(change["new"]) + + def set_min_components(self, count: int, note: str = "") -> None: + """Keep at least `count` component names; `note` explains why (hidden when empty).""" + self._components_field.min_components = count + self._components_note.value = f"{note}" if note else "" + self._components_note.layout.display = "" if note else "none" + + def bind_study(self, study: "Study") -> None: + """Edit `study.components` (name and role) in the Components section. + + The component names follow the study's list (salt first) whenever it changes. + """ + from .study_components import StudyComponentsWidget + + self._study = study + self.study_components = StudyComponentsWidget(study) + self.study_components.root.remove_class("cadetgui-section") + self._components_section.children = ( + self.study_components.root, self._components_note, + ) + study.add_listener(self._sync_from_study) + self._sync_from_study() + + def _sync_from_study(self) -> None: + names = [c.name for c in self._study.components] + if names and names != self._study_names: + self._study_names = names + self.components = names + def set_column_and_binding( self, column_cls: Optional[type], @@ -302,6 +370,16 @@ def set_column_and_binding( if not self._suspend_rebuild: self._rebuild() + def add_section(self, section: W.Widget, *, after_components: bool = False) -> None: + """Show `section` below the hardware (or right after Components), above the status.""" + children = list(self.root.children) + index = ( + children.index(self._components_section) + 1 if after_components + else children.index(self.status) + ) + children.insert(index, section) + self.root.children = tuple(children) + def set_active_inlets( self, names: Optional[Iterable[str]], @@ -332,7 +410,9 @@ def set_template_options( self._template_picker.value = selected finally: self._syncing_template = False - self._template_picker.layout.display = "" if labels else "none" + self._template_picker.layout.display = ( + "" if labels and not self.hardware_only else "none" + ) def select_template(self, label: Optional[str]) -> None: """Show `label` as the selected template without firing the selection callback.""" diff --git a/cadetgui/widgets/composite/parameter_estimation.py b/cadetgui/widgets/composite/parameter_estimation.py index 2d3a959..ff271e5 100644 --- a/cadetgui/widgets/composite/parameter_estimation.py +++ b/cadetgui/widgets/composite/parameter_estimation.py @@ -5,7 +5,7 @@ import ipywidgets as W -from ... import configuration_store +from ...io import configuration_store from ...optimizer_runner import OptimizerRunResult, RunSpec from ...parameter_estimation import ( EstimationResult, @@ -64,6 +64,9 @@ def __init__(self, *, data: Optional[DataImportWidget] = None) -> None: # The result shown in the overlay: the raw preview, or the fitted run after a fit. self._display_result: Optional[Any] = None self._preview_process: Optional[Any] = None + # Off: configuration changes only mark the preview stale until `refresh_preview()`. + self.auto_preview = True + self._preview_stale = False self._saved_options: list[tuple[str, Any]] = [] self._base_process_picker = ChoiceField( @@ -289,7 +292,19 @@ def _on_config_changed(self, _process: Any) -> None: self._refresh_component_options() self._refresh_signal_options() self._refresh_active_label() - self._refresh_preview() + if self.auto_preview: + self._refresh_preview() + else: + self._preview_stale = True + self._preview_status.value = status_html( + "info", "Configuration changed; the preview updates when this pane is opened." + ) + + def refresh_preview(self) -> None: + """Simulate the preview now if the configuration changed since the last one.""" + if self._preview_stale: + self._preview_stale = False + self._refresh_preview() def _refresh_signal_options(self) -> None: cw = self._config_widget diff --git a/cadetgui/widgets/composite/parameter_store_view.py b/cadetgui/widgets/composite/parameter_store_view.py new file mode 100644 index 0000000..0bf0f0f --- /dev/null +++ b/cadetgui/widgets/composite/parameter_store_view.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import dataclasses +from dataclasses import replace +from pathlib import Path +from typing import Any, Callable, List, Optional + +import ipywidgets as W + +from ...cadetprocessadapter import UNIT_LABELS +from ...characterization.guide import EXPERIMENT_TYPES +from ...characterization.parameter_store import ( + ChainError, + ParameterSpec, + ParameterStore, + check_chain, + missing_requirements, +) +from ...characterization.study import Study +from .._chrome import style_tag +from .._help import term_html +from .._status import status_html +from ..elements import ChoiceField, SelectableTable + +__all__ = ["ParameterStoreWidget", "parameter_label", "set_by_label"] + +CURRENT = "__current__" +INITIAL = "__initial__" +ASSUMED = "assumed: " + + +def parameter_label(path: str) -> str: + """Plain name for a parameter path, e.g. "Tubing (pre column) · axial dispersion".""" + parts = path.split(".") + if len(parts) >= 3 and parts[0] == "flow_sheet" and parts[1] in UNIT_LABELS: + return f"{UNIT_LABELS[parts[1]]} · {' '.join(parts[2:]).replace('_', ' ')}" + return path + + +def set_by_label(step: str) -> str: + """Plain "Set by" text for a provenance step; implied values name their experiment type.""" + if not step.startswith(ASSUMED): + return step + type_id = step[len(ASSUMED):] + experiment = EXPERIMENT_TYPES.get(type_id) + return f"Assumed by experiment type: {experiment.label if experiment else type_id}" + + +def _fmt(value: Any) -> str: + if isinstance(value, float): + return f"{value:.4g}" + if isinstance(value, list): + return "[" + ", ".join(_fmt(v) for v in value) + "]" + return str(value) + + +def _metric(metric: Optional[dict]) -> str: + return ", ".join(f"{k} {_fmt(v)}" for k, v in (metric or {}).items()) + + +def chain_warnings(study: Study) -> List[str]: + """Return chain-order problems of `study.steps`, and requirements unmet by accepted stores.""" + warnings: List[str] = [] + steps = [] + for setup in study.steps: + try: + steps.append(setup.step) + except ValueError as exc: + warnings.append(f"Step {setup.name!r} is incomplete: {exc}") + paths = {p for step in steps for p in (*step.requires, *step.provides)} + # Paths a step adds lazily are declared on accept, so declare them for the order check. + declared = replace( + study.initial_store, + specs={**{p: ParameterSpec(p) for p in paths}, **study.initial_store.specs}, + ) + try: + check_chain(steps, declared) + except ChainError as exc: + warnings.append(str(exc)) + for step in steps: + missing = missing_requirements(step, study.prior_for(step.name)) + if missing: + warnings.append( + f"Step {step.name!r} requires {missing}, not in the store it starts from." + ) + return warnings + + +class ParameterStoreWidget: + """The study's parameter store after any step, with provenance, chain checks and file I/O. + + Loading a study replaces the shared `Study`'s contents in place and notifies its + listeners, so every widget holding the same object follows. + """ + + def __init__(self, study: Study) -> None: + self.study = study + self._listeners: List[Callable[[], None]] = [] + + self._step_picker = ChoiceField(label="Show store:") + self._step_picker.observe(lambda _c: self._refresh_table(), names="selected_index") + self._table = SelectableTable( + label="Parameters", + columns=["Parameter", "Path", "Species", "Value", "Set by", "Probe", "Source", + "Metric", "Note"], + empty_text="The store is empty.", + ) + self._warnings = W.HTML() + self._path = W.Text( + description="File:", value=self._default_path(), + layout=W.Layout(width="100%"), + ) + self._store_path = W.Text( + description="Store file:", value="parameter_store.json", + layout=W.Layout(width="100%"), + ) + self._btn_save = W.Button(description="Save study", icon="save") + self._btn_load = W.Button(description="Load study", icon="folder-open") + self._btn_export = W.Button(description="Export current store (JSON)", icon="download") + self._btn_save.on_click(lambda _b: self._file_action(self.save_study, self._path)) + self._btn_load.on_click(lambda _b: self._file_action(self.load_study, self._path)) + self._btn_export.on_click( + lambda _b: self._file_action(self.export_store, self._store_path) + ) + self.status = W.HTML() + self.status.add_class("cadetgui-status") + + toolbar = W.HBox([self._btn_save, self._btn_load], layout=W.Layout(flex_flow="row wrap")) + toolbar.add_class("cadetgui-toolbar") + self.root = W.VBox([ + W.HTML(style_tag()), + W.HTML( + "
    " + f"{term_html('parameter store', 'Parameter store')}
    " + f"Every value carries its {term_html('provenance')}: the step that " + f"set it, the {term_html('probe')} it was measured with, the runs and the " + f"metric. \"After <step>\" shows that step's {term_html('posterior')}, " + f"which is the {term_html('prior')} of the next step." + ), + self._step_picker, + self._warnings, + self._table, + self._path, + toolbar, + self._store_path, + self._btn_export, + self.status, + ]) + self.root.add_class("cadetgui-panel") + + self.refresh() + study.add_listener(self.refresh) + + def _default_path(self) -> str: + base = self.study.base_dir + return str(base / "study.json") if base is not None else "study.json" + + def add_listener(self, fn: Callable[[], None]) -> None: + """Call `fn()` after the shown store changes.""" + self._listeners.append(fn) + + def refresh(self) -> None: + """Re-read the study: step choices, chain warnings and the table.""" + options = [("Current (after the last accepted step)", CURRENT), ("Initial", INITIAL)] + options += [ + (f"After {s.name}" + ("" if s.name in self.study.posteriors else " (not accepted)"), + s.name) + for s in self.study.steps + ] + self._step_picker.set_options(options) + warnings = chain_warnings(self.study) + self._warnings.value = "
    ".join(status_html("warn", w) for w in warnings) + self._refresh_table() + + def show_after(self, step_name: Optional[str]) -> None: + """Show the store after `step_name`; `None` is the current store, "" the initial one.""" + self._step_picker.value = CURRENT if step_name is None else (step_name or INITIAL) + + def get_value(self) -> ParameterStore: + """Return the store being shown.""" + choice = self._step_picker.value + if choice in (None, CURRENT): + return self.study.current_store + if choice == INITIAL: + return self.study.initial_store + store = self.study.initial_store + for step in self.study.steps: + store = self.study.posteriors.get(step.name, store) + if step.name == choice: + break + return store + + def _refresh_table(self) -> None: + store = self.get_value() + rows, options = [], [] + for path, entry in sorted(store.entries.items()): + spec = store.specs.get(path) + values = ( + sorted(entry.value.items()) + if spec is not None and spec.species_indexed else [("", entry.value)] + ) + prov = entry.provenance + for species, value in values: + rows.append([ + parameter_label(path), path, species, _fmt(value), + (set_by_label(prov.step), "info"), prov.probe or "", prov.source or "", + _metric(prov.metric), prov.note or "", + ]) + options.append((f"{path} {species}".strip(), (path, species))) + self._table.set_options(options, rows=rows, select_none=True) + for fn in list(self._listeners): + fn() + + def _file_action(self, action: Callable[[str], Path], field: W.Text) -> None: + try: + path = action(field.value) + except (OSError, ValueError, KeyError) as exc: + self.status.value = status_html("error", f"{type(exc).__name__}: {exc}") + return + self.status.value = status_html("ok", f"{action.__name__.replace('_', ' ')}: {path}") + + def save_study(self, path: "Path | str") -> Path: + """Write the study as JSON; data files stay referenced relative to `path`.""" + return self.study.save(path) + + def load_study(self, path: "Path | str") -> Path: + """Replace the study's contents with the file's, in place, and notify its listeners.""" + loaded = Study.load(path) + for f in dataclasses.fields(Study): + if f.name != "_listeners": + setattr(self.study, f.name, getattr(loaded, f.name)) + self.study.notify() + return Path(path) + + def export_store(self, path: "Path | str") -> Path: + """Write the current store (after the last accepted step) as JSON.""" + return self.study.current_store.save(path) + + def display(self) -> None: + """Render this widget in a Jupyter cell.""" + from IPython.display import display as _display + + _display(self.root) diff --git a/cadetgui/widgets/composite/run_history.py b/cadetgui/widgets/composite/run_history.py index edfbd51..1b10d33 100644 --- a/cadetgui/widgets/composite/run_history.py +++ b/cadetgui/widgets/composite/run_history.py @@ -7,7 +7,7 @@ import ipywidgets as W -from ... import run_store +from ...io import run_store from .._chrome import style_tag from ..elements import SelectableTable, TextField diff --git a/cadetgui/widgets/composite/solution.py b/cadetgui/widgets/composite/solution.py index 3e53e8a..3a2d283 100644 --- a/cadetgui/widgets/composite/solution.py +++ b/cadetgui/widgets/composite/solution.py @@ -4,8 +4,8 @@ import ipywidgets as W -from ... import configuration_store, run_store from ...cadetprocessadapter import classify_signal_ports, measurable_signal_options +from ...io import configuration_store, run_store from ...simulation import run_process as _default_runner from .._chrome import style_tag from .._series import solution_series diff --git a/cadetgui/widgets/composite/study_components.py b/cadetgui/widgets/composite/study_components.py new file mode 100644 index 0000000..1a12dc0 --- /dev/null +++ b/cadetgui/widgets/composite/study_components.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Tuple + +import ipywidgets as W + +from ...characterization.guide import COMPONENT_ROLES, SALT, SALT_ROLE +from ...characterization.study import Study +from .._chrome import style_tag +from .._help import info_box_html, term_html +from .._status import status_html + +__all__ = ["StudyComponentsWidget", "COMPONENTS_HELP"] + +COMPONENTS_HELP = ( + "

    Components are the molecules you inject or elute with: tracers, the protein and, " + "for gradient and breakthrough runs, the salt. Each measurement picks its component " + "from this list, and the experiment type decides which " + + term_html("component role", "roles") + " fit.

    " + "

    Fitted values are stored under the component's name, so the name links values " + "across steps: the protein's film diffusion from Particle transport is reused in " + "Binding only if both measurements use the same component. Values measured with a " + "tracer reach another molecule only through an explicit " + + term_html("species transfer") + ".

    " +) + +_ROLE_OPTIONS = [(role, role) for role in COMPONENT_ROLES] +SALT_NAME_NOTE = "fixed name, required by the binding model" + + +def _role_dropdown(**kwargs: Any) -> W.Dropdown: + dropdown = W.Dropdown(**kwargs) + dropdown.add_class("cadetgui-select") + return dropdown + + +class StudyComponentsWidget: + """Editor over `study.components`: one row per component (name, role), add and remove. + + Renames, role changes and removals go through the `Study` methods, which refuse what + would break a measurement or detach stored values; the refusal is shown in `.status`. + The salt component's name is fixed to `SALT`: its row is read-only, and picking the + salt role in the add row fills in and locks that name. + """ + + def __init__(self, study: Study) -> None: + self.study = study + self.rows: Dict[str, Tuple[W.Text, W.Dropdown, W.Button]] = {} + self._key: Any = None + self._syncing = False + + self._rows_box = W.VBox() + self._new_name = W.Text(placeholder="e.g. IgG", description="New:", + continuous_update=False) + self._new_role = _role_dropdown(options=_ROLE_OPTIONS, value="protein", + description="Role:") + self._btn_add = W.Button(description="Add component", icon="plus") + self._btn_add.on_click(lambda _b: self.add(self._new_name.value, self._new_role.value)) + self._role_help = W.HTML() + self._new_role.observe(lambda _c: self._sync_new_role(), names="value") + self.status = W.HTML() + self.status.add_class("cadetgui-status") + + add_row = W.HBox([self._new_name, self._new_role, self._btn_add], + layout=W.Layout(flex_flow="row wrap")) + add_row.add_class("cadetgui-toolbar") + self.root = W.VBox([ + W.HTML(style_tag()), + W.HTML("
    " + + term_html("component", "Components") + "
    "), + W.HTML(info_box_html("What goes here?", COMPONENTS_HELP)), + self._rows_box, + add_row, + self._role_help, + self.status, + ]) + self.root.add_class("cadetgui-section") + + self._show_role_help() + self.refresh() + study.add_listener(self.refresh) + + def _sync_new_role(self) -> None: + salt = self._new_role.value == SALT_ROLE + if salt: + self._new_name.value = SALT + elif self._new_name.disabled: + self._new_name.value = "" + self._new_name.disabled = salt + self._show_role_help() + + def _show_role_help(self) -> None: + role = self._new_role.value + text = f"{role}: {COMPONENT_ROLES[role]}" + if role == SALT_ROLE: + text += f" The name is set to {SALT} ({SALT_NAME_NOTE})." + elif self.study.component(SALT) is not None: + text += f" The salt role is not offered: {SALT} is already declared." + self._role_help.value = f"{text}" + + def _refresh_new_role_options(self) -> None: + has_salt = self.study.component(SALT) is not None + options = [o for o in _ROLE_OPTIONS if not (has_salt and o[1] == SALT_ROLE)] + if list(self._new_role.options) != options: + if has_salt and self._new_role.value == SALT_ROLE: + self._new_role.value = "protein" + self._new_role.options = options + self._show_role_help() + + def refresh(self) -> None: + """Rebuild the rows when the declared components or their use changed.""" + usage = [(c.name, c.role, len(self.study.measurements_using(c.name))) + for c in self.study.components] + self._refresh_new_role_options() + if usage == self._key: + return + self._key = usage + self.rows = {} + children: List[W.Widget] = [] + for name, role, used in usage: + text = W.Text(value=name, continuous_update=False, layout=W.Layout(width="200px")) + dropdown = _role_dropdown(options=_ROLE_OPTIONS, value=role, + layout=W.Layout(width="160px")) + remove = W.Button(icon="trash", tooltip=f"Remove {name}", + layout=W.Layout(width="40px"), disabled=bool(used)) + if role == SALT_ROLE: + text.disabled = dropdown.disabled = True + text.observe(self._on_rename(name), names="value") + dropdown.observe(self._on_role(name), names="value") + remove.on_click(lambda _b, name=name: self.remove(name)) + fixed = f"{SALT_NAME_NOTE} · " if role == SALT_ROLE else "" + note = W.HTML( + f"{fixed}{term_html(role, 'about this role')} · used by {used} " + f"measurement{'s' * (used != 1)}", + ) + self.rows[name] = (text, dropdown, remove) + row = W.HBox([text, dropdown, remove, note], + layout=W.Layout(align_items="center", flex_flow="row wrap")) + children.append(row) + if not children: + children.append(W.HTML( + "No components yet. Add the tracers and the protein you inject." + )) + self._rows_box.children = children + + def _on_rename(self, old: str) -> Callable[[dict], None]: + def _handler(change: dict) -> None: + if not self._syncing: + self.rename(old, change["new"]) + return _handler + + def _on_role(self, name: str) -> Callable[[dict], None]: + def _handler(change: dict) -> None: + if not self._syncing: + self.set_role(name, change["new"]) + return _handler + + def _run(self, action: Callable[[], Any], ok: str) -> bool: + try: + action() + except (KeyError, ValueError) as exc: + self.status.value = status_html("error", str(exc.args[0])) + self._key = None + self._syncing = True + try: + self.refresh() + finally: + self._syncing = False + return False + self.status.value = status_html("ok", ok) + return True + + def add(self, name: str, role: str) -> bool: + """Declare a component; returns whether it was added.""" + name = name.strip() + if self._run(lambda: self.study.add_component(name, role), f"Added {name} ({role})."): + self._new_name.value = "" + return True + return False + + def rename(self, old: str, new: str) -> bool: + """Rename a component everywhere it is used; returns whether it was renamed.""" + new = new.strip() + return self._run(lambda: self.study.rename_component(old, new), + f"Renamed {old} to {new} in the measurements using it.") + + def set_role(self, name: str, role: str) -> bool: + """Change a component's role; returns whether it changed.""" + def action() -> None: + if role == SALT_ROLE and name != SALT: + where = ( + f"{SALT} is already declared" if self.study.component(SALT) is not None + else f"add {SALT} with the salt role in the row below instead" + ) + raise ValueError( + f"{name} cannot take the salt role: only the component named {SALT} " + f"can ({SALT_NAME_NOTE}); {where}." + ) + self.study.set_component_role(name, role) + return self._run(action, f"{name} is now {role}.") + + def remove(self, name: str) -> bool: + """Remove an unused component; returns whether it was removed.""" + return self._run(lambda: self.study.remove_component(name), f"Removed {name}.") + + def display(self) -> None: + """Render this widget in a Jupyter cell.""" + from IPython.display import display as _display + + _display(self.root) diff --git a/cadetgui/widgets/composite/system_diagram.py b/cadetgui/widgets/composite/system_diagram.py index f66aa68..58481d5 100644 --- a/cadetgui/widgets/composite/system_diagram.py +++ b/cadetgui/widgets/composite/system_diagram.py @@ -6,10 +6,18 @@ import ipywidgets as W -from ...cadetprocessadapter import UNIT_LABELS +from ...cadetprocessadapter import BYPASSABLE_UNITS, UNIT_LABELS +from ...io.configuration_store import ConfigurationState, InstrumentState from ._pid_symbols import FG, MUTED, MUTED_STROKE, SURFACE, load_symbol -__all__ = ["SystemDiagram", "render_system_svg"] +__all__ = ["SystemDiagram", "render_system_svg", "recipe_diagram_svg", "HARDWARE_ONLY_NOTE"] + +HARDWARE_ONLY_NOTE = ( + "How each run uses this hardware (which inlets pump, when the loop injects, which " + "units are bypassed) is set by its experiment type — see the measurement." +) + +ACCENT = "var(--cg-primary, #005b82)" _MAIN_PATH = ( "mixer", @@ -61,6 +69,10 @@ _ROW_DROP = 40 _CAPTION_FONT = 14 _CAPTION_LEAD = 16 +_HALO_PAD = 5 +_OBSERVE_STEM = 14 +_OBSERVE_GAP = 6 +_COMPACT_MAX_WIDTH = 560 def _num(value: float) -> str: @@ -223,8 +235,47 @@ def _draw_item(it: _Item, my: float, note: str, sub: str = "") -> str: return "".join(parts) -def _unit_group(unit: str, state: str, content: str) -> str: - return f'{content}' +def _unit_group(unit: str, state: str, content: str, *, highlighted: bool = False) -> str: + flag = ' data-highlight="true"' if highlighted else "" + return f'{content}' + + +def _halo_box(it: _Item, my: float) -> Tuple[float, float, float, float]: + """Bounding box (x, y, width, height) an accent halo wraps around `it`.""" + pad = _HALO_PAD + if it.symbol is not None: + sym = load_symbol(it.symbol) + sy = my - _PORTS[it.symbol][2] + return it.x - pad, sy - pad, sym.width + 2 * pad, sym.height + 2 * pad + if it.unit == "tubing_detectors": + top = my - it.up + return it.x - pad, top - pad, it.width + 2 * pad, it.up + 2 * pad + return it.x - pad, my - 6 - pad, it.width + 2 * pad, 12 + 2 * pad + + +def _halo(it: _Item, my: float) -> str: + x, y, w, h = _halo_box(it, my) + return ( + f'' + ) + + +def _observe_extent() -> float: + """Height of the lane above the drawing that holds the "measured here" label.""" + return _OBSERVE_GAP + _CAPTION_FONT + 8 + + +def _observe_marker(x: float, y: float, tip: float, label_x: float) -> str: + """Pin from the outlet at (`x`, `y`) up to `tip`, labelled in the lane above it.""" + dot = ( + f'' + ) + return ( + f'' + f"{dot}" + _caption(["measured here"], label_x, tip - _OBSERVE_GAP - 2, italic=True) + ) def _dim(content: str, on: bool) -> str: @@ -330,6 +381,11 @@ def render_system_svg( carries: Optional[Mapping[str, Sequence[str]]] = None, equilibration: Collection[str] = (), column_model: Optional[str] = None, + *, + highlight: Collection[str] = (), + observe: Optional[str] = None, + compact: bool = False, + hardware_only: bool = False, ) -> str: """Render the LC flow path from the P&ID symbols as an HTML fragment holding one SVG. @@ -341,16 +397,31 @@ def render_system_svg( `carries` maps an inlet (and `"sample_loop"`) to the component names it delivers and adds them to the labels and to a plain-language caption under the drawing. `column_model` is captioned under the column symbol. + + `highlight` marks units an accent halo and `data-highlight="true"`, and lists their + labels in a "Characterized here" caption. `observe` draws a "measured here" marker at + that unit's outlet (`data-observe`); if the unit isn't drawn, a caption says so instead. + `compact` drops the inlet stack and general captions (highlight/observe captions stay) + and scales the drawing down for use in a small card. + + `hardware_only` draws every installed inlet plainly (`inlets`, `carries` and + `equilibration` are ignored) and replaces the usage captions by `HARDWARE_ONLY_NOTE`. """ units = set(units) + if hardware_only and not compact: + inlets = [u for u in (*_BUFFERS, "feed_inlet") if u in units] + carries, equilibration = None, () bypassed = set(bypassed) + highlight = set(highlight) path = [u for u in _MAIN_PATH if u in units] has_loop = "sample_loop" in path - known = inlets is not None + known = inlets is not None and not compact active = set(inlets or ()) if known: buffers = [b for b in _BUFFERS if b in units or b in active] active_buffers = [b for b in buffers if b in active] + elif compact: + buffers = active_buffers = [] else: buffers = active_buffers = ["inlet"] feed_shown = known and ("feed_inlet" in units or "feed_inlet" in active) @@ -392,7 +463,8 @@ def render_system_svg( sub = (column_model or "") if it.unit == "column" else "" up, down = _extent(it, "bypassed" if it.state == "bypassed" else "", sub) up_max, item_down = max(up_max, up), max(item_down, down) - my = _MARGIN + up_max + observe_lane = _observe_extent() if any(it.unit == observe for it in items) else 0.0 + my = _MARGIN + observe_lane + up_max junction = next((it for it in reversed(items) if it.unit in _JUNCTIONS), None) target = next((it for it in items if it.unit not in _JUNCTIONS), None) @@ -456,12 +528,24 @@ def render_system_svg( d = f"M{_num(x1)} {_num(my)} H{_num(b.x + b.in_x)}" parts.append(_wire(d, arrow=b.symbol is not None)) + observe_drawn = False for it in items: note = "bypassed" if it.state == "bypassed" else "" sub = (column_model or "") if it.unit == "column" else "" - parts.append(_unit_group(it.unit, it.state, _draw_item(it, my, note, sub))) - - if "waste" in units and junction is not None: + is_highlighted = it.unit in highlight + content = _draw_item(it, my, note, sub) + if is_highlighted: + content += _halo(it, my) + parts.append(_unit_group(it.unit, it.state, content, highlighted=is_highlighted)) + if observe is not None and it.unit == observe: + ox = it.x + it.out_x + half = _text_width(["measured here"]) / 2 + label_x = min(max(ox, _MARGIN + half), right + _MARGIN - half) + marker = _observe_marker(ox, my, my - up_max - 4, label_x) + parts.append(f'{marker}') + observe_drawn = True + + if "waste" in units and junction is not None and not compact: cx = junction.x + junction.width / 2 start = my + junction.down wx = cx - inlet_sym.width / 2 @@ -509,7 +593,9 @@ def render_system_svg( width = right + _MARGIN bottom = max(my + item_down, my + stack_h / 2 + 24 + (_CAPTION_LEAD if known else 0)) - if ("waste" in units and junction is not None) or (feed_shown and not has_loop): + if ("waste" in units and junction is not None and not compact) or ( + feed_shown and not has_loop + ): below = row2_y + inlet_sym.height + 24 + (feed_extra if feed_shown and not has_loop else 0) bottom = max(bottom, below) height = bottom + _MARGIN @@ -517,30 +603,110 @@ def render_system_svg( shown = set(buffers) | ({"feed_inlet"} if feed_shown else set()) notes = ( _usage_notes(active, carries, shown, path, "mixer" not in bypassed, equilibration) - if known + if known and not hardware_only else [] ) off = [UNIT_LABELS[u] for u in _MAIN_PATH if u in bypassed and u in UNIT_LABELS] if off: notes.append(f"Not in the flow path: {', '.join(off)}") + + lead_notes: List[str] = [] + if highlight: + ordered = [u for u in _MAIN_PATH if u in highlight] + ordered += [u for u in dict.fromkeys(highlight) if u not in _MAIN_PATH] + labels = [UNIT_LABELS.get(u, u) for u in ordered] + lead_notes.append(f"Characterized here: {', '.join(labels)}") + if observe is not None and not observe_drawn: + label = UNIT_LABELS.get(observe, observe) + lead_notes.append(f"Measured here: {label} (not shown in this diagram).") + notes = lead_notes + notes + if hardware_only and not compact: + notes.append(HARDWARE_ONLY_NOTE) + caption = "".join( f'
    {escape(n)}
    ' for n in notes ) + + inner = "".join(parts) + if compact and width > 0: + scale = min(1.0, _COMPACT_MAX_WIDTH / width) + if scale < 1.0: + inner = f'{inner}' + width, height = width * scale, height * scale + return ( f'
    ' f'' - f"{''.join(parts)}{caption}
    " + f"{inner}{caption}" + ) + + +def _recipe_units(instrument: InstrumentState) -> set: + """Return the unit names an `InstrumentState` builds, mirroring `LCFlowSheet.__init__`. + + Buffers A-D, the feed inlet, mixer, outlet and waste are always present; the + bypassable tubing/column units are present unless in `bypass_units`, and the + sample loop is present iff `include_sample_loop`. + """ + bypass = set(instrument.bypass_units) + units = { + "buffer_a", "buffer_b", "buffer_c", "buffer_d", + "feed_inlet", "mixer", "outlet", "waste", + } # fmt: skip + units.update(u for u in BYPASSABLE_UNITS if u != "mixer" and u not in bypass) + if instrument.include_sample_loop: + units.add("sample_loop") + return units + + +def recipe_diagram_svg( + recipe: ConfigurationState, + *, + observe: Optional[str] = None, + highlight: Collection[str] = (), + compact: bool = True, +) -> str: + """Render a saved recipe's flow path without building a process or any widget. + + Derives the unit set from `recipe.instrument` the same way `InstrumentWidget` feeds + `SystemDiagram` (bypass list + sample-loop flag); `instrument is None` (a standalone, + column-only configuration) renders just the column. + """ + instrument = recipe.instrument + if instrument is None: + return render_system_svg( + {"column"}, highlight=highlight, observe=observe, compact=compact + ) + return render_system_svg( + _recipe_units(instrument), + set(instrument.bypass_units), + highlight=highlight, + observe=observe, + compact=compact, ) class SystemDiagram: - """Read-only schematic of the current LC flow path.""" + """Read-only schematic of the current LC flow path. + + With `hardware_only`, the installed hardware is drawn without the inlet usage of the + current process (see `render_system_svg`). + """ - def __init__(self) -> None: + def __init__(self, *, hardware_only: bool = False) -> None: + self.hardware_only = hardware_only + self._flow_sheet: object = None + self._bypassed: Collection[str] = () + self._inlets: Optional[Sequence[str]] = None + self._carries: Optional[Mapping[str, Sequence[str]]] = None + self._equilibration: Collection[str] = () + self._column_model: Optional[str] = None + self._highlight: Collection[str] = () + self._observe: Optional[str] = None self.root = W.HTML("") self.update(None) @@ -554,10 +720,33 @@ def update( column_model: Optional[str] = None, ) -> None: """Redraw for `flow_sheet` (a built LCFlowSheet, or `None` while inputs are invalid).""" - if flow_sheet is None: + self._flow_sheet = flow_sheet + self._bypassed = bypassed + self._inlets = inlets + self._carries = carries + self._equilibration = equilibration + self._column_model = column_model + self._render() + + def set_highlight(self, units: Collection[str], observe: Optional[str] = None) -> None: + """Mark the units and outlet the currently selected characterization step covers.""" + self._highlight = units + self._observe = observe + self._render() + + def _render(self) -> None: + if self._flow_sheet is None: note = "No system to show while inputs are invalid." self.root.value = f'{note}' return self.root.value = render_system_svg( - flow_sheet.units_dict, bypassed, inlets, carries, equilibration, column_model + self._flow_sheet.units_dict, + self._bypassed, + self._inlets, + self._carries, + self._equilibration, + self._column_model, + highlight=self._highlight, + observe=self._observe, + hardware_only=self.hardware_only, ) diff --git a/cadetgui/widgets/composite/workbench.py b/cadetgui/widgets/composite/workbench.py index ba2b36e..058c22e 100644 --- a/cadetgui/widgets/composite/workbench.py +++ b/cadetgui/widgets/composite/workbench.py @@ -2,19 +2,29 @@ from typing import Optional, Sequence -import ipywidgets as W - -from .._chrome import logo_data_uri, style_tag -from .._sidebar_shell import SidebarShell, collect_panes, resolve_step, validate_steps +from ..shell import ( + SYSTEM_GROUP, + SidebarShell, + collect_panes, + resolve_step, + system_pane_label, + validate_steps, +) +from .bench import bench_root from .configuration import ConfigurationWidget from .instrument import InstrumentWidget from .parameter_estimation import ParameterEstimationWidget from .solution import SolutionWidget -from .workspace_header import hoisted_header_rows __all__ = ["WorkbenchWidget"] -_STEPS = ("System Configuration", "Process Configuration", "Simulation", "Parameter Estimation") +SYSTEM = SYSTEM_GROUP +PROCESS = "Process configuration" +SIMULATION = "Simulation" +ESTIMATION = "Parameter Estimation" +SYSTEM_PANE = system_pane_label("Instrument") +PROCESS_PANE = system_pane_label(PROCESS) +_STEPS = (SYSTEM, PROCESS, SIMULATION, ESTIMATION) class WorkbenchWidget: @@ -22,11 +32,14 @@ class WorkbenchWidget: Builds and wires an `InstrumentWidget`, `ConfigurationWidget`, `SolutionWidget` and `ParameterEstimationWidget`, showing one at a time via `SidebarShell`. Each stays a plain - attribute (`.instrument`/`.configuration`/`.solution`/`.parameter_estimation`). - - `include` narrows which steps are built and shown (default: all four); a skipped step is - neither constructed nor wired. Passing an explicit widget for a step not in `include` - raises `ValueError`. + attribute (`.instrument`/`.configuration`/`.solution`/`.parameter_estimation`). The + instrument and the process configuration are the "System" group's sub-panes + ("System: Instrument", "System: Process configuration"), as in the characterization + workbench. + + `include` narrows which steps are built and shown (default: all of `_STEPS`); a + skipped step is neither constructed nor wired. Passing an explicit widget for a step + not in `include` raises `ValueError`. """ def __init__( @@ -42,27 +55,28 @@ def __init__( validate_steps(steps, _STEPS) self.instrument = resolve_step( - "System Configuration", + SYSTEM, instrument, steps=steps, factory=lambda: InstrumentWidget(hardware_expanded=False), ) self.configuration = resolve_step( - "Process Configuration", + PROCESS, configuration, steps=steps, factory=lambda: ConfigurationWidget(workspace_header=False), ) - self.solution = resolve_step( - "Simulation", solution, steps=steps, factory=SolutionWidget - ) + self.solution = resolve_step(SIMULATION, solution, steps=steps, factory=SolutionWidget) self.parameter_estimation = resolve_step( - "Parameter Estimation", + ESTIMATION, parameter_estimation, steps=steps, factory=ParameterEstimationWidget, ) + if self.parameter_estimation is not None: + self.parameter_estimation.auto_preview = False + if self.instrument is not None and self.configuration is not None: self.configuration.bind_to_instrument(self.instrument) @@ -72,29 +86,24 @@ def __init__( dependent.bind_to_config(self.configuration) panes = collect_panes( - _STEPS, + (SYSTEM_PANE, PROCESS_PANE, SIMULATION, ESTIMATION), { - "System Configuration": self.instrument.root if self.instrument else None, - "Process Configuration": self.configuration.root if self.configuration else None, - "Simulation": self.solution.root if self.solution else None, - "Parameter Estimation": ( + SYSTEM_PANE: self.instrument.root if self.instrument else None, + PROCESS_PANE: self.configuration.root if self.configuration else None, + SIMULATION: self.solution.root if self.solution else None, + ESTIMATION: ( self.parameter_estimation.root if self.parameter_estimation else None ), }, ) - self._shell = SidebarShell(panes) - - top_bar = W.HTML( - "
    " - f"CADET" - "Workbench" - "
    " - ) - - header_rows = hoisted_header_rows(self.configuration) - self.root = W.VBox([W.HTML(style_tag()), top_bar, *header_rows, self._shell.body]) - self.root.add_class("cadetgui-panel") - self.root.add_class("cadetgui-workbench") + self._shell = SidebarShell(panes, groups={SYSTEM: [SYSTEM_PANE, PROCESS_PANE]}) + if self.parameter_estimation is not None: + estimation = self.parameter_estimation + self._shell.add_show_listener( + lambda label: estimation.refresh_preview() if label == ESTIMATION else None + ) + + self.root = bench_root("Workbench", self._shell, self.configuration) def display(self) -> None: """Render this widget in a Jupyter cell.""" diff --git a/cadetgui/widgets/composite/workspace_header.py b/cadetgui/widgets/composite/workspace_header.py index 099d81f..661eba2 100644 --- a/cadetgui/widgets/composite/workspace_header.py +++ b/cadetgui/widgets/composite/workspace_header.py @@ -5,7 +5,7 @@ import ipywidgets as W -from ... import configuration_store +from ...io import configuration_store from .backend_versions import BackendVersionsWidget from .configuration_persistence import ConfigurationPersistence diff --git a/cadetgui/widgets/elements/_shared.css b/cadetgui/widgets/elements/_shared.css index eedc97e..f79b452 100644 --- a/cadetgui/widgets/elements/_shared.css +++ b/cadetgui/widgets/elements/_shared.css @@ -92,6 +92,25 @@ /* Dropdowns hold full names ("General Rate Model (GRM)"), so they are wider than inputs. */ .cadetgui-field-select { min-width: 260px; + appearance: none; + -webkit-appearance: none; + padding-right: 28px; + background-image: + linear-gradient(45deg, transparent 50%, var(--cg-muted) 50%), + linear-gradient(135deg, var(--cg-muted) 50%, transparent 50%); + background-position: calc(100% - 15px) 50%, calc(100% - 10px) 50%; + background-size: 5px 5px; + background-repeat: no-repeat; + cursor: pointer; +} + +.cadetgui-field-select:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--cg-fg) 30%, var(--cg-border-strong)); + background-color: color-mix(in srgb, var(--cg-fg) 4%, var(--cg-bg)); +} + +.cadetgui-field-select:disabled { + cursor: not-allowed; } /* Puts the error on its own line instead of widening the row. Not for list diff --git a/cadetgui/widgets/elements/base.py b/cadetgui/widgets/elements/base.py index 8deabe3..defc513 100644 --- a/cadetgui/widgets/elements/base.py +++ b/cadetgui/widgets/elements/base.py @@ -14,7 +14,7 @@ class Element(anywidget.AnyWidget): """Base for atomic input widgets: label, units, validation and error state. Subclasses define `value`, either as a synced trait or, when the value is not - JSON-safe, as a property over another synced trait named by `_value_trait_name`. + JSON-safe, as a property over another synced trait named by `value_trait_name`. """ label = T.Unicode("").tag(sync=True) @@ -22,12 +22,12 @@ class Element(anywidget.AnyWidget): error = T.Unicode("").tag(sync=True) disabled = T.Bool(False).tag(sync=True) - _value_trait_name = "value" + value_trait_name = "value" def __init__(self, *, validate: Optional[Validator] = None, **kwargs: Any) -> None: self._validate_fn = validate super().__init__(**kwargs) - self.observe(self._run_validate, names=self._value_trait_name) + self.observe(self._run_validate, names=self.value_trait_name) self._run_validate() def _run_validate(self, change: Optional[dict] = None) -> None: diff --git a/cadetgui/widgets/elements/choice_field.py b/cadetgui/widgets/elements/choice_field.py index dc0da74..8f46488 100644 --- a/cadetgui/widgets/elements/choice_field.py +++ b/cadetgui/widgets/elements/choice_field.py @@ -19,7 +19,7 @@ class ChoiceField(Element): option_labels = T.List(T.Unicode()).tag(sync=True) selected_index = T.Int(allow_none=True, default_value=None).tag(sync=True) - _value_trait_name = "selected_index" + value_trait_name = "selected_index" _esm = field_esm("choice_field", units=False) _css = FIELD_CSS diff --git a/cadetgui/widgets/forms/renderer.py b/cadetgui/widgets/forms/renderer.py index 910e604..a7c2aab 100644 --- a/cadetgui/widgets/forms/renderer.py +++ b/cadetgui/widgets/forms/renderer.py @@ -69,7 +69,7 @@ def __init__( self._elements: Dict[str, Element] = {f.name: element_for_field(f) for f in spec.fields} for element in self._elements.values(): - element.observe(self._on_field_changed, names=element._value_trait_name) + element.observe(self._on_field_changed, names=element.value_trait_name) self._btn_reset = W.Button(description="Reset") self._btn_reset.on_click(self._on_reset) diff --git a/cadetgui/widgets/_sidebar_shell.py b/cadetgui/widgets/shell.py similarity index 76% rename from cadetgui/widgets/_sidebar_shell.py rename to cadetgui/widgets/shell.py index 87cd067..e1917bf 100644 --- a/cadetgui/widgets/_sidebar_shell.py +++ b/cadetgui/widgets/shell.py @@ -15,10 +15,24 @@ import ipywidgets as W -__all__ = ["SidebarShell", "validate_steps", "resolve_step", "collect_panes"] +__all__ = [ + "SidebarShell", + "validate_steps", + "resolve_step", + "collect_panes", + "SYSTEM_GROUP", + "system_pane_label", +] _T = TypeVar("_T") +SYSTEM_GROUP = "System" + + +def system_pane_label(name: str) -> str: + """Label of a pane nested under the workbenches' "System" sidebar group.""" + return f"{SYSTEM_GROUP}: {name}" + class SidebarShell: """A left-sidebar nav over `(label, widget)` panes, showing one at a time. @@ -27,7 +41,8 @@ class SidebarShell: nested under it: a group is one collapsible nav entry placed where its first child sits, and expanded children are indented without the `": "` prefix. With groups `nav.options` holds `(display, key)` pairs and `nav.value` is `None` - while the selected pane sits in a collapsed group. + while the selected pane sits in a collapsed group. `set_panes` replaces the panes + after construction. """ _instances = itertools.count() @@ -36,6 +51,34 @@ def __init__( self, panes: Sequence[Tuple[str, W.Widget]], groups: Optional[Mapping[str, Sequence[str]]] = None, + ) -> None: + self._warnings: Dict[str, str] = {} + self._expanded: Set[str] = set() + self._entries: List[Tuple[str, str]] = [] + self._syncing = False + self._show_listeners: List[Callable[[str], None]] = [] + self._configure(panes, groups) + self._current: str = self._order[0] + + self._nav_class = f"cadetgui-sidebar-nav-{next(self._instances)}" + self.nav = W.ToggleButtons(options=self._order) + self.nav.add_class("cadetgui-sidebar-nav") + self.nav.add_class(self._nav_class) + self.nav.observe(self._on_nav_change, names="index") + self._nav_style = W.HTML() + + self._content = W.VBox([self._nav_style, *self.panes.values()]) + self._content.add_class("cadetgui-workbench-content") + + self.body = W.HBox([self.nav, self._content]) + self.body.add_class("cadetgui-workbench-body") + + self.show(self._order[0]) + + def _configure( + self, + panes: Sequence[Tuple[str, W.Widget]], + groups: Optional[Mapping[str, Sequence[str]]], ) -> None: if not panes: raise ValueError("SidebarShell needs at least one pane") @@ -43,43 +86,54 @@ def __init__( if len(set(labels)) != len(labels): raise ValueError(f"Pane labels must be unique, got {labels!r}") - self._order: List[str] = labels - self.panes: Dict[str, W.Widget] = dict(panes) - self._groups: Dict[str, List[str]] = {} - self._group_of: Dict[str, str] = {} + group_map: Dict[str, List[str]] = {} + group_of: Dict[str, str] = {} for group, children in (groups or {}).items(): - if group in self.panes: + if group in labels: raise ValueError(f"Group label {group!r} clashes with a pane label") present = [label for label in labels if label in children] for child in present: - if child in self._group_of: + if child in group_of: raise ValueError(f"Pane {child!r} is in more than one group") - self._group_of[child] = group + group_of[child] = group if present: - self._groups[group] = present + group_map[group] = present + + self._order: List[str] = labels + self.panes: Dict[str, W.Widget] = dict(panes) + self._groups: Dict[str, List[str]] = group_map + self._group_of: Dict[str, str] = group_of for widget in self.panes.values(): widget.layout.display = "none" + self._warnings = {k: v for k, v in self._warnings.items() if k in self.panes} + self._expanded &= set(self._groups) - self._warnings: Dict[str, str] = {} - self._expanded: Set[str] = set() - self._current: str = self._order[0] - self._entries: List[Tuple[str, str]] = [] - self._syncing = False - - self._nav_class = f"cadetgui-sidebar-nav-{next(self._instances)}" - self.nav = W.ToggleButtons(options=self._order) - self.nav.add_class("cadetgui-sidebar-nav") - self.nav.add_class(self._nav_class) - self.nav.observe(self._on_nav_change, names="index") - self._nav_style = W.HTML() + @property + def current(self) -> str: + """Label of the shown pane.""" + return self._current - content = W.VBox([self._nav_style, *self.panes.values()]) - content.add_class("cadetgui-workbench-content") + @property + def order(self) -> List[str]: + """Pane labels in sidebar order.""" + return list(self._order) - self.body = W.HBox([self.nav, content]) - self.body.add_class("cadetgui-workbench-body") + def set_panes( + self, + panes: Sequence[Tuple[str, W.Widget]], + groups: Optional[Mapping[str, Sequence[str]]] = None, + *, + show: Optional[str] = None, + ) -> None: + """Replace the panes and groups, keeping warnings and the selection where labels remain. - self.show(self._order[0]) + Shows `show` if given, else the current pane if it is still there, else the first. + """ + self._configure(panes, groups) + self._content.children = (self._nav_style, *self.panes.values()) + if show is None: + show = self._current if self._current in self.panes else self._order[0] + self.show(show) def show(self, label: str) -> None: """Switch to the pane registered under `label`, expanding its group.""" @@ -91,6 +145,12 @@ def show(self, label: str) -> None: for name, widget in self.panes.items(): widget.layout.display = "" if name == label else "none" self._sync_nav() + for fn in list(self._show_listeners): + fn(label) + + def add_show_listener(self, fn: Callable[[str], None]) -> None: + """Call `fn(label)` whenever a pane is shown.""" + self._show_listeners.append(fn) def set_warning(self, label: str, message: Optional[str]) -> None: """Mark a step with a warning icon and hover text, or clear it with `None`. diff --git a/source/Column.drawio.svg b/docs/pid-drawings/Column.drawio.svg similarity index 100% rename from source/Column.drawio.svg rename to docs/pid-drawings/Column.drawio.svg diff --git a/source/CondSensor.drawio.svg b/docs/pid-drawings/CondSensor.drawio.svg similarity index 100% rename from source/CondSensor.drawio.svg rename to docs/pid-drawings/CondSensor.drawio.svg diff --git a/source/Inlet.drawio.svg b/docs/pid-drawings/Inlet.drawio.svg similarity index 100% rename from source/Inlet.drawio.svg rename to docs/pid-drawings/Inlet.drawio.svg diff --git a/source/InletA.drawio.svg b/docs/pid-drawings/InletA.drawio.svg similarity index 100% rename from source/InletA.drawio.svg rename to docs/pid-drawings/InletA.drawio.svg diff --git a/source/InletB.drawio.svg b/docs/pid-drawings/InletB.drawio.svg similarity index 100% rename from source/InletB.drawio.svg rename to docs/pid-drawings/InletB.drawio.svg diff --git a/source/InletC.drawio.svg b/docs/pid-drawings/InletC.drawio.svg similarity index 100% rename from source/InletC.drawio.svg rename to docs/pid-drawings/InletC.drawio.svg diff --git a/source/InletD.drawio.svg b/docs/pid-drawings/InletD.drawio.svg similarity index 100% rename from source/InletD.drawio.svg rename to docs/pid-drawings/InletD.drawio.svg diff --git a/source/LC-LCSystem.drawio.svg b/docs/pid-drawings/LC-LCSystem.drawio.svg similarity index 100% rename from source/LC-LCSystem.drawio.svg rename to docs/pid-drawings/LC-LCSystem.drawio.svg diff --git a/source/LC.drawio.xml b/docs/pid-drawings/LC.drawio.xml similarity index 100% rename from source/LC.drawio.xml rename to docs/pid-drawings/LC.drawio.xml diff --git a/source/Mixer.drawio.svg b/docs/pid-drawings/Mixer.drawio.svg similarity index 100% rename from source/Mixer.drawio.svg rename to docs/pid-drawings/Mixer.drawio.svg diff --git a/source/Outlet.drawio.svg b/docs/pid-drawings/Outlet.drawio.svg similarity index 100% rename from source/Outlet.drawio.svg rename to docs/pid-drawings/Outlet.drawio.svg diff --git a/source/Pump.drawio.svg b/docs/pid-drawings/Pump.drawio.svg similarity index 100% rename from source/Pump.drawio.svg rename to docs/pid-drawings/Pump.drawio.svg diff --git a/source/SampleLoop.drawio.svg b/docs/pid-drawings/SampleLoop.drawio.svg similarity index 100% rename from source/SampleLoop.drawio.svg rename to docs/pid-drawings/SampleLoop.drawio.svg diff --git a/source/UVSensor.drawio.svg b/docs/pid-drawings/UVSensor.drawio.svg similarity index 100% rename from source/UVSensor.drawio.svg rename to docs/pid-drawings/UVSensor.drawio.svg diff --git a/examples/EXAMPLE_DATA.md b/examples/EXAMPLE_DATA.md index e83fa87..0cd9642 100644 --- a/examples/EXAMPLE_DATA.md +++ b/examples/EXAMPLE_DATA.md @@ -1,73 +1,68 @@ # Example datasets -Every file in `examples/data/` is *synthetic* — generated by running the exact -`ConfigurationWidget()` default process (or a known, named deviation from it) -through the real CADET-Process simulator, sampling the result, and adding a -small amount of noise. None of it is measured data. The point of writing down -exactly how each file was made is so you know what the "correct" answer is -before you fit it — useful for checking the Parameter Estimation tab actually -works, not just that it runs without error. +The `example_*.csv` files in `examples/data/` are *synthetic*: each one is the +outlet signal of a real CADET-Process simulation of the base configuration +below (or a named deviation from it), sampled and given a small amount of +fixed-seed noise. None of it is measured data. Knowing how each file was made +tells you the "correct" answer before you fit it, so you can check that +parameter estimation actually recovers it. -All three share the same base: `ConfigurationWidget()`'s plain defaults — -`LumpedRateModelWithoutPores` column, `Linear` binding, 2 components, flow -rate `1e-6`, `total_porosity = 0.72`, `axial_dispersion = 1e-8`, -`adsorption_rate = [0, 0]`, `desorption_rate = [0, 0]` (i.e. no binding at -all by default). Each row below only lists what was changed from that. +They are written by `examples/generate_example_data.py` (run it from the +repository root; re-running produces identical files). -| File | Changed from defaults | Signal / Calibration | Window | Noise (seed) | +The base is the default configuration of +`ConfigurationWidget(instrument=InstrumentWidget())` (and of the simulation +workbench), so the files fit out of the box without entering any values: +lab-scale `LumpedRateModelWithoutPores` column, `length = 0.05` m, +`diameter = 0.005` m, `total_porosity = 0.75`, `axial_dispersion = 1e-8`, +non-binding `Linear` isotherm (`adsorption_rate = desorption_rate = [0]`), +1 component, **Breakthrough** template with feed concentration `10` mol/m³, +flow rate 1 mL/min (`1.667e-8` m³/s) and cycle time `600` s, all instrument +units bypassed. Each row below only lists what was changed from that base. + +| File | Changed from the base | Signal / Calibration | Window | Noise (seed) | |---|---|---|---|---| -| `example_uv_signal.csv` | *(nothing — plain defaults)* | Outlet, Component sum, Beer-Lambert (`extinction_coefficient=5000 L/(mol·cm)`, `path_length=1 cm`) | 0–10 min, 0.1 min steps | Gaussian, seed `20260911` | -| `example_raw_signal.csv` | `total_porosity = 0.60` | Outlet, Component 1, no calibration | 0–10 min, 0.1 min steps | Gaussian (scale 0.15), seed `20260916` | -| `example_binding_signal.csv` | `total_porosity = 0.65`, `axial_dispersion = 2e-8`, `adsorption_rate = [0, 0.3]`, `desorption_rate = [0, 0.05]` | Outlet, Total (sum of both components), no calibration | 0–20 min, 0.1 min steps | Gaussian (scale 0.08), seed `20260917` | +| `example_uv_signal.csv` | *(nothing, the base itself)* | Outlet, Beer-Lambert (`extinction_coefficient = 100` L/(mol·cm), `path_length = 0.2` cm) | 0–5 min, 0.01 min steps | Gaussian, 1 % of max, seed `20260911` | +| `example_raw_signal.csv` | `total_porosity = 0.60` | Outlet, no calibration | 0–5 min, 0.01 min steps | Gaussian, 1.5 % of max, seed `20260916` | +| `example_binding_signal.csv` | `total_porosity = 0.65`, `axial_dispersion = 2e-8`, `adsorption_rate = [0.3]`, `desorption_rate = [0.05]` | Outlet, no calibration | 0–10 min, 0.01 min steps | Gaussian, 1 % of max, seed `20260917` | All noise is clipped at `0` afterward (no negative signal values). Every file -has a `time_min,signal` header and reads directly with `DataImportWidget` -(first two columns, time already in minutes). +has a two-column header with the time in minutes (`time_min,...`) and reads +directly with `DataImportWidget`. ## `example_uv_signal.csv` -The Beer-Lambert calibration exercise: nothing about the underlying process -differs from the plain default configuration, only the *signal* is -transformed (`signal = concentration * extinction_coefficient * path_length`) -to simulate a raw UV280 detector trace instead of a concentration directly. -Pick **Calibration: Beer-Lambert**, enter `extinction_coefficient = 5000`, -`path_length = 1`, to convert it back and see it line up with the simulated -curve. Not meant for parameter fitting (nothing was perturbed to fit -*towards*) — it's the calibration-mechanism demo. +The Beer-Lambert calibration exercise: the process is the base configuration +unchanged, only the *signal* is transformed +(`signal = concentration * extinction_coefficient * path_length`) to mimic a +raw UV280 detector trace. Pick **Calibration: Beer-Lambert**, enter +`extinction_coefficient = 100`, `path_length = 0.2`, and the trace lines up +with the simulated breakthrough curve. Not meant for fitting (nothing was +perturbed to fit *towards*). ## `example_raw_signal.csv` -The simplest possible fitting exercise. `total_porosity` is the only thing -that differs from the default configuration (`0.60` vs. the default `0.72`); -`axial_dispersion` and the binding rates are untouched. Pick -**Calibration: None**, check only **Total porosity**, run — it recovers a -value close to `0.60`. +The simplest fitting exercise: only `total_porosity` differs from the base +(`0.60` vs. `0.75`), so the breakthrough front arrives earlier. Pick +**Calibration: None**, add only **Total porosity**, run; Nelder-Mead from the +base value recovers `0.6001`. ## `example_binding_signal.csv` -A genuine bind-and-elute scenario, four parameters away from the default -configuration: `total_porosity`, `axial_dispersion`, and Component 2's -`adsorption_rate`/`desorption_rate` (Component 1 is left non-binding, i.e. an -unretained tracer — like a salt front eluting near the void volume, ahead of -a retained protein peak). Signal is **Total** (both components summed, as a -non-selective detector would see them), no calibration. +A retained solute: `total_porosity`, `axial_dispersion` and the linear binding +rates differ from the base, so the breakthrough front is delayed to about +2.5 min and broadened by the binding kinetics. No calibration. -Verified end-to-end (fitting from the plain default configuration, starting -`total_porosity=0.5`, `adsorption_rate=0.1`, `desorption_rate=0.1`): +Verified with Nelder-Mead, starting from `total_porosity = 0.5`, +`adsorption_rate = 0.1`, `desorption_rate = 0.1` and leaving +`axial_dispersion` at the base value: | Parameter | True value | Recovered | |---|---|---| -| `total_porosity` | 0.65 | 0.6509 | -| `adsorption_rate` (Component 2) | 0.3 | 0.2980 | -| `desorption_rate` (Component 2) | 0.05 | 0.0495 | +| `total_porosity` | 0.65 | 0.642 | +| `adsorption_rate` | 0.3 | 0.295 | +| `desorption_rate` | 0.05 | 0.0501 | -These three recover cleanly and are the recommended checklist for a first -try. `axial_dispersion` (true `2e-8`) is deliberately *not* in that -recommended set — checking it too converges to a plausible but wrong value -(~`6e-8`) alongside the other three, because a single noisy Total-signal -trace doesn't carry enough independent information to separate axial -dispersion's effect from porosity's — a real identifiability limitation, -not a bug. Fitting it usefully needs either a -cleaner (lower-noise / higher-resolution) reference or fixing the other -parameters first from an independent measurement, same as in a real -experiment. +`axial_dispersion` (true `2e-8`) is not in that verified set: on a single +noisy trace its broadening of the front overlaps with that of the binding +kinetics, so add it only once the other three are fitted. diff --git a/examples/characterization.ipynb b/examples/characterization.ipynb new file mode 100644 index 0000000..50e6453 --- /dev/null +++ b/examples/characterization.ipynb @@ -0,0 +1,78 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# CADET-GUI: characterization workbench\n", + "\n", + "`CharacterizationWorkbenchWidget` walks a study from the system and its\n", + "measurements through the characterization steps that fit model parameters\n", + "step by step, each step building on the parameters the earlier ones accepted." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Start an empty study" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "from cadetgui import CharacterizationWorkbenchWidget\n", + "\n", + "workbench = CharacterizationWorkbenchWidget()\n", + "workbench.display()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Open the synthetic example study\n", + "\n", + "`examples/data/characterization_akta/` holds a synthetic chain of measurements\n", + "(see `examples/EXAMPLE_DATA.md`). The path below resolves whether the notebook\n", + "runs from the repository root or from `examples/`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "MANIFEST = next(\n", + " path\n", + " for path in (\n", + " Path(\"data/characterization_akta/manifest.json\"),\n", + " Path(\"examples/data/characterization_akta/manifest.json\"),\n", + " )\n", + " if path.exists()\n", + ")\n", + "\n", + "example = CharacterizationWorkbenchWidget.from_file(MANIFEST)\n", + "example.display()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/configuration_and_solution.ipynb b/examples/configuration_and_solution.ipynb index b37a3d4..7846ba2 100644 --- a/examples/configuration_and_solution.ipynb +++ b/examples/configuration_and_solution.ipynb @@ -80,7 +80,7 @@ "source": [ "## 3. Import experimental data and compare\n", "\n", - "`ParameterEstimationWidget.bind_to_config(config)` tracks `config`'s built process for fitting. It nests a `DataImportWidget` (`.data`, CSV upload, first two columns read as time/signal) as its experimental-data source. This tab owns the comparison view — the Solution tab above only ever shows the raw simulation, never an overlay. It has four sections:\n", + "`ParameterEstimationWidget.bind_to_config(config)` tracks `config`'s built process for fitting. It nests a `DataImportWidget` (`.data`, CSV upload; a two-column time/signal CSV imports directly, other CSVs get a column-mapping step) as its experimental-data source. This tab owns the comparison view — the Solution tab above only ever shows the raw simulation, never an overlay. It has four sections:\n", "\n", "- **Experimental data** — upload a CSV, pick which dataset, say what the signal represents (a specific simulated component, or the total of all of them), and calibrate it if needed (e.g. Beer-Lambert for a raw UV trace).\n", "- **Configure parameter space** — pick parameters to fit from the \"Add parameter\" dropdown; each added row gets its own Start/lower/upper-bound fields (aligned into columns, with a \"✕\" to remove a row).\n", @@ -96,7 +96,7 @@ "\n", "- `example_uv_signal.csv` — a synthetic UV280 trace requiring a **Calibration** step (Beer-Lambert) before it overlays the simulated curve.\n", "- `example_raw_signal.csv` — the simplest fitting exercise: one parameter (`total_porosity`), no calibration needed.\n", - "- `example_binding_signal.csv` — a bind-and-elute scenario: 3-4 parameters (porosity, binding rates, optionally axial dispersion), no calibration needed." + "- `example_binding_signal.csv` — a retained solute: 3 parameters (porosity and both binding rates), no calibration needed." ] }, { diff --git a/examples/custom_sidebar.ipynb b/examples/custom_sidebar.ipynb index f308e84..bb4a1b7 100644 --- a/examples/custom_sidebar.ipynb +++ b/examples/custom_sidebar.ipynb @@ -13,7 +13,7 @@ "widget outside that set.\n", "\n", "The actual general-purpose building block is `SidebarShell`\n", - "(`cadetgui/widgets/_sidebar_shell.py`): it takes any `(label, widget)`\n", + "(`from cadetgui.widgets import SidebarShell`): it takes any `(label, widget)`\n", "pairs and shows exactly one at a time. This notebook combines\n", "`InstrumentWidget` + `ConfigurationWidget` + `SolutionWidget` + a standalone\n", "`DataImportWidget` -- a combination `WorkbenchWidget` cannot express, since\n", @@ -27,7 +27,7 @@ "metadata": {}, "outputs": [], "source": [ - "from cadetgui.widgets._sidebar_shell import SidebarShell\n", + "from cadetgui.widgets import SYSTEM_GROUP, SidebarShell, bench_root, system_pane_label\n", "from cadetgui.widgets.composite import (\n", " ConfigurationWidget,\n", " DataImportWidget,\n", @@ -115,6 +115,37 @@ ], "id": "f929dd69" }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Frame it as a bench\n", + "\n", + "`bench_root` adds the top bar and the configuration's header rows around a\n", + "shell, the way the built-in benches do. `system_pane_label` names a pane nested\n", + "under the sidebar's `SYSTEM_GROUP` entry." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "system = [system_pane_label(\"Instrument\"), system_pane_label(\"Configuration\")]\n", + "bench_shell = SidebarShell(\n", + " [\n", + " (system[0], instrument.root),\n", + " (system[1], config.root),\n", + " (\"Simulation\", solution.root),\n", + " (\"Experimental Data\", data.root),\n", + " ],\n", + " groups={SYSTEM_GROUP: system},\n", + ")\n", + "bench_root(\"My bench\", bench_shell, configuration=config)" + ], + "id": "f929dd69" + }, { "cell_type": "markdown", "metadata": {}, diff --git a/examples/data/characterization_akta/README.md b/examples/data/characterization_akta/README.md new file mode 100644 index 0000000..c34c847 --- /dev/null +++ b/examples/data/characterization_akta/README.md @@ -0,0 +1,98 @@ +# Synthetic Äkta-style characterization chain + +Same convention as `examples/EXAMPLE_DATA.md`: +every file here is *synthetic*, generated by `examples/generate_characterization_chain_data.py` +from a real CADET-Process simulation, then reshaped to look like an Äkta/Unicorn export +(volume axis in mL, per-channel sampling, detector units, run-log phase markers) and noised. +None of it is measured data; every number is invented for this synthetic instrument. + +The six CSVs and `manifest.json` exercise a two-step characterization chain end to end: +`cadetgui.characterization.comparison.Comparison` reads each CSV, aligns it to its process's simulated clock via +the `"Phase Elution"` run-log marker, and `cadetgui.characterization.stages`/ +`cadetgui.characterization.runner` fit each step's stage. `manifest.json`'s `initial_store` and +each step's `truth` are a `cadetgui.characterization.parameter_store.ParameterStore` in `to_dict()` form, keyed by +CADET-Process parameter path. + +## Step 1 — "Extra-column volume" (`stage="tubing"`, `tubing="tubing_pre_column"`) + +Two replicate pulse-tracer runs, `System pulse 1 (UV)` and `System pulse 2 (UV)`, with the column bypassed and every periphery +segment but `tubing_pre_column` bypassed too. Read on the `"UV 1_280"` channel at +`tubing_pre_column.outlet`; the injected species is `SystemTracer`, one component per run's +recipe. + +| Truth | Value | +|---|---| +| `flow_sheet.tubing_pre_column.length` | 0.42 m | +| `flow_sheet.tubing_pre_column.axial_dispersion` | 4.5e-7 m²/s | + +Known (not fitted, in `initial_store`): `tubing_pre_column.diameter` = 0.8 mm. + +## Step 2 — "Column packing" (`stage="bed"`, `include_particle_porosity=True`) + +Column in line (Lumped Rate Model With Pores, no binding), carrying step 1's fitted +`tubing_pre_column` forward. Two small-tracer runs (`Small tracer pulse 1 (conductivity)`, `Small tracer pulse 2 (conductivity)`, species +`SmallTracer`) read on `"Cond"` at `tubing_detectors.outlet` (`tubing_detectors` in line, a known +small volume); two large-tracer runs (`Large tracer pulse 1 (UV)`, `Large tracer pulse 2 (UV)`, species `LargeTracer`) read on +`"UV 1_280"` at `column.outlet` (`tubing_detectors` bypassed). The large tracer is pore-excluded +via `initial_store`: `flow_sheet.column.film_diffusion["LargeTracer"] = 0.0`. + +| Truth | Value | +|---|---| +| `flow_sheet.column.bed_porosity` | 0.36 | +| `flow_sheet.column.particle_porosity` | 0.68 | +| `flow_sheet.column.axial_dispersion` | 5.5e-8 m²/s | + +Known (not fitted, in `initial_store`): `column.particle_radius` = 30 µm, +`tubing_detectors.diameter` = 0.5 mm, `tubing_detectors.length` = 50 mm (a small, known +detector-to-detector volume), `column.film_diffusion["SmallTracer"]` = 8e-5 m/s. + +`tubing_pre_column`'s axial dispersion (species-indexed) is carried from step 1's `SystemTracer` +probe to `SmallTracer`/`LargeTracer` unchanged, and reused for `tubing_detectors` at the same +flow rate — the same chain handoff `CADETProcess.characterization.CharacterizeBed` needs. + +## Shared setup + +All six runs share one instrument: flow rate 8.3e-9 m³/s, a 20 µL sample loop (`Pulse Injection` +template, sample loop included), 50 mol/m³ tracer in the loop — an injected amount of 1e-6 mol, +used as every comparison's `normalization.target_area`. The Pulse Injection template has no +pre-injection equilibration phase available through `cadetgui.process_builder.build_process` (its +`c_buffer_a`/`c_sample`/`cycle_time`/`flow_rate` fields never reach `delta_t_equilibration`), so +every process injects at simulated `t=0`; the exported Äkta CSVs still start `PRE_OFFSET_S = 60 s` +earlier, as a real export would, and alignment (via the `"Phase Elution"` marker) crops that +lead-in the same way it would crop a real one. + +Each detector channel is shaped independently: `"UV 1_280"` in mAU (small baseline, an arbitrary +response factor per mol/m³, sampled every 0.5 s), `"Cond"` in mS/cm (a baseline of several tens +of mS/cm with a small rise, sampled every 0.2 s), both with fixed-seed Gaussian noise. Only the +channel a comparison actually reads carries a real signal; the other is baseline and noise only, +matching the real instrument (every detector runs on every injection, but salt is not +UV-active and a 200 nm bead does not move a conductivity reading). `Small tracer pulse 1 (conductivity)` additionally +carries a sharp, narrow pre-injection dip on `Cond` — large enough to bias a whole-trace baseline +fit's lowest-2.5%-of-points threshold (`CADETProcess.calibration.fit_baseline`'s default), +narrow enough not to meaningfully distort the area-normalization integral — so its comparison +needs (and sets) an explicit post-peak `baseline_window`; every other comparison leaves it `null` +and uses the default whole-trace fit. + +## Verified end-to-end + +Every comparison's `cadetgui.characterization.comparison.Comparison.problems()` is empty (auto-detects as an +Äkta export, its channel and `"Phase Elution"` marker exist, its recipe builds, its solution +path resolves), and evaluating each one at the store its own step's truth was generated from +(`initial_store` plus step 1's truth, plus step 2's truth once carried forward) gives an NRMSE +within a few percent — consistent with the injected noise, not a misalignment or a bad +normalization. `Small tracer pulse 1 (conductivity)` needs its explicit `baseline_window`: without it, +`Comparison.build_reference` raises (too few points survive the default threshold within the +pre-injection dip), which is the point — this dataset is deliberately not clean everywhere. + +Also verified: rebuilding each process from its recipe, `initial_store` and the relevant step's +`truth` (`cadetgui.process_builder.build_process` + `cadetgui.characterization.parameter_store.apply_store`) +reproduces this file's own generation exactly, since CADET-Core is deterministic for identical +input; see `tests/test_characterization_chain_data.py`. + +## Regenerating + +``` +python3 examples/generate_characterization_chain_data.py +``` + +Deterministic (fixed seeds): re-running produces byte-identical files. diff --git a/examples/data/characterization_akta/large_tracer_pulse_1_uv.csv b/examples/data/characterization_akta/large_tracer_pulse_1_uv.csv new file mode 100644 index 0000000..51291cc --- /dev/null +++ b/examples/data/characterization_akta/large_tracer_pulse_1_uv.csv @@ -0,0 +1,1804 @@ +Chromatogram,,,,, +UV 1_280,,Cond,,Run Log, +ml,mAU,ml,mS/cm,ml, +0.000000,4.043450,0.000000,42.000578,0.000000,Phase Equilibration +0.004150,4.769502,0.001660,42.004122,0.498000,Phase Elution +0.008300,4.299119,0.003320,41.998720,2.539800,Phase Wash +0.012450,4.726118,0.004980,41.993546,, +0.016600,3.947639,0.006640,42.007103,, +0.020750,4.041030,0.008300,42.001880,, +0.024900,4.199807,0.009960,41.999232,, +0.029050,4.312957,0.011620,41.995806,, +0.033200,4.223906,0.013280,42.002975,, +0.037350,4.924457,0.014940,41.994202,, +0.041500,4.222175,0.016600,42.004162,, +0.045650,3.864732,0.018260,42.000500,, +0.049800,4.691131,0.019920,41.997471,, +0.053950,4.225325,0.021580,42.002635,, +0.058100,4.173045,0.023240,42.002187,, +0.062250,3.794351,0.024900,41.994832,, +0.066400,4.271217,0.026560,41.997365,, +0.070550,3.359885,0.028220,41.997335,, +0.074700,4.438359,0.029880,41.995811,, +0.078850,4.558733,0.031540,42.003902,, +0.083000,4.200352,0.033200,42.005162,, +0.087150,4.306664,0.034860,42.007251,, +0.091300,4.195409,0.036520,41.993225,, +0.095450,2.853985,0.038180,42.002667,, +0.099600,3.688151,0.039840,42.000092,, +0.103750,4.280563,0.041500,41.992322,, +0.107900,4.901176,0.043160,42.000435,, +0.112050,4.582205,0.044820,41.994695,, +0.116200,3.922565,0.046480,42.002005,, +0.120350,4.043169,0.048140,41.996649,, +0.124500,3.802006,0.049800,42.002583,, +0.128650,4.276211,0.051460,41.999899,, +0.132800,3.994549,0.053120,41.996878,, +0.136950,3.878614,0.054780,42.003033,, +0.141100,3.757566,0.056440,41.998649,, +0.145250,4.163170,0.058100,41.990405,, +0.149400,5.051141,0.059760,42.004084,, +0.153550,3.571993,0.061420,41.991221,, +0.157700,3.642669,0.063080,41.998559,, +0.161850,3.703718,0.064740,42.004346,, +0.166000,5.142833,0.066400,41.998468,, +0.170150,2.924775,0.068060,42.001568,, +0.174300,4.099588,0.069720,42.003995,, +0.178450,3.666096,0.071380,42.002812,, +0.182600,3.604159,0.073040,41.999626,, +0.186750,3.907051,0.074700,42.003627,, +0.190900,4.298115,0.076360,41.995918,, +0.195050,3.579220,0.078020,42.001548,, +0.199200,3.083383,0.079680,42.000585,, +0.203350,4.358487,0.081340,41.996230,, +0.207500,4.326240,0.083000,42.006059,, +0.211650,4.120808,0.084660,41.991853,, +0.215800,4.185270,0.086320,42.002286,, +0.219950,4.093605,0.087980,41.999475,, +0.224100,3.868517,0.089640,42.005775,, +0.228250,3.507750,0.091300,42.000201,, +0.232400,3.958675,0.092960,41.993693,, +0.236550,3.289235,0.094620,41.999483,, +0.240700,4.587806,0.096280,42.005722,, +0.244850,4.095671,0.097940,41.998998,, +0.249000,3.759381,0.099600,41.997703,, +0.253150,3.396733,0.101260,41.997089,, +0.257300,3.407308,0.102920,41.998650,, +0.261450,3.980051,0.104580,41.999583,, +0.265600,4.507194,0.106240,42.002510,, +0.269750,4.063080,0.107900,42.003938,, +0.273900,3.943652,0.109560,41.998665,, +0.278050,3.899746,0.111220,41.999734,, +0.282200,3.821747,0.112880,42.001695,, +0.286350,3.345184,0.114540,42.001773,, +0.290500,4.271684,0.116200,41.998828,, +0.294650,3.098903,0.117860,41.994605,, +0.298800,4.217928,0.119520,42.006127,, +0.302950,3.634012,0.121180,41.995217,, +0.307100,4.677311,0.122840,42.000607,, +0.311250,3.319418,0.124500,42.002203,, +0.315400,3.790093,0.126160,42.007564,, +0.319550,4.386885,0.127820,42.008837,, +0.323700,4.186314,0.129480,41.997657,, +0.327850,4.400892,0.131140,42.000182,, +0.332000,3.774220,0.132800,41.999242,, +0.336150,4.175427,0.134460,41.997275,, +0.340300,4.416886,0.136120,42.004748,, +0.344450,3.424086,0.137780,41.996491,, +0.348600,3.885296,0.139440,42.005039,, +0.352750,4.714161,0.141100,41.998136,, +0.356900,3.025591,0.142760,41.996545,, +0.361050,4.318015,0.144420,41.998281,, +0.365200,4.632147,0.146080,41.995970,, +0.369350,4.667840,0.147740,42.001887,, +0.373500,4.002381,0.149400,41.999717,, +0.377650,3.475979,0.151060,42.003498,, +0.381800,4.414370,0.152720,41.999134,, +0.385950,4.019361,0.154380,41.993941,, +0.390100,3.635160,0.156040,41.993877,, +0.394250,4.094368,0.157700,42.000432,, +0.398400,3.764439,0.159360,41.997758,, +0.402550,4.094500,0.161020,41.999766,, +0.406700,4.306599,0.162680,42.000155,, +0.410850,4.255706,0.164340,42.003077,, +0.415000,4.137495,0.166000,42.003713,, +0.419150,4.641567,0.167660,42.000386,, +0.423300,4.022322,0.169320,41.997993,, +0.427450,3.961599,0.170980,42.000262,, +0.431600,3.686315,0.172640,42.002894,, +0.435750,3.616335,0.174300,42.002274,, +0.439900,3.351593,0.175960,42.004320,, +0.444050,3.901887,0.177620,41.996625,, +0.448200,4.499284,0.179280,41.999104,, +0.452350,4.711194,0.180940,41.998965,, +0.456500,4.468750,0.182600,41.999661,, +0.460650,4.451078,0.184260,41.993632,, +0.464800,4.668800,0.185920,42.003543,, +0.468950,2.236063,0.187580,42.002168,, +0.473100,4.238348,0.189240,41.990670,, +0.477250,4.535912,0.190900,41.998862,, +0.481400,4.332409,0.192560,41.997474,, +0.485550,3.526588,0.194220,42.001461,, +0.489700,3.484841,0.195880,42.001977,, +0.493850,3.705177,0.197540,42.007029,, +0.498000,3.594412,0.199200,41.999288,, +0.502150,4.272140,0.200860,42.001471,, +0.506300,3.480034,0.202520,42.001057,, +0.510450,3.660429,0.204180,41.998111,, +0.514600,4.782202,0.205840,41.999936,, +0.518750,4.307839,0.207500,42.006993,, +0.522900,5.265213,0.209160,41.996209,, +0.527050,4.438543,0.210820,41.999679,, +0.531200,4.584154,0.212480,41.992647,, +0.535350,4.094647,0.214140,41.997031,, +0.539500,4.646179,0.215800,41.997826,, +0.543650,4.164680,0.217460,41.991237,, +0.547800,4.318084,0.219120,42.003096,, +0.551950,3.827287,0.220780,42.005651,, +0.556100,3.768702,0.222440,41.999057,, +0.560250,4.943411,0.224100,41.994189,, +0.564400,4.869934,0.225760,41.999766,, +0.568550,3.939071,0.227420,41.988911,, +0.572700,3.968451,0.229080,41.997645,, +0.576850,3.362734,0.230740,41.999721,, +0.581000,4.247023,0.232400,41.996755,, +0.585150,3.945359,0.234060,42.002914,, +0.589300,3.812856,0.235720,42.000614,, +0.593450,3.735149,0.237380,42.002914,, +0.597600,3.561027,0.239040,41.998633,, +0.601750,4.002745,0.240700,42.004044,, +0.605900,3.565421,0.242360,42.000608,, +0.610050,3.490229,0.244020,41.997732,, +0.614200,4.446509,0.245680,41.992559,, +0.618350,3.284934,0.247340,41.999571,, +0.622500,3.380108,0.249000,41.999160,, +0.626650,4.465060,0.250660,41.997178,, +0.630800,4.729361,0.252320,41.998783,, +0.634950,3.413518,0.253980,41.998039,, +0.639100,3.787398,0.255640,42.002761,, +0.643250,3.679211,0.257300,41.999793,, +0.647400,4.324766,0.258960,42.003931,, +0.651550,4.037021,0.260620,41.997356,, +0.655700,3.494320,0.262280,41.990844,, +0.659850,4.972668,0.263940,41.993287,, +0.664000,3.871212,0.265600,41.996901,, +0.668150,4.525858,0.267260,41.997536,, +0.672300,4.014142,0.268920,41.997166,, +0.676450,3.703875,0.270580,42.002859,, +0.680600,4.753386,0.272240,41.999257,, +0.684750,3.968008,0.273900,42.001510,, +0.688900,3.743725,0.275560,42.004952,, +0.693050,4.757715,0.277220,41.995310,, +0.697200,3.487524,0.278880,42.003767,, +0.701350,3.067400,0.280540,41.994383,, +0.705500,3.596810,0.282200,41.999464,, +0.709650,4.739258,0.283860,42.003374,, +0.713800,3.604612,0.285520,42.004664,, +0.717950,3.633325,0.287180,41.997385,, +0.722100,2.921869,0.288840,41.999830,, +0.726250,3.856731,0.290500,42.008223,, +0.730400,3.008945,0.292160,42.000302,, +0.734550,3.967078,0.293820,42.004712,, +0.738700,4.195046,0.295480,42.001261,, +0.742850,4.258671,0.297140,41.994872,, +0.747000,4.224418,0.298800,42.004578,, +0.751150,3.365047,0.300460,42.003424,, +0.755300,3.837845,0.302120,41.996607,, +0.759450,3.359498,0.303780,41.998482,, +0.763600,4.084764,0.305440,41.999195,, +0.767750,3.461794,0.307100,41.994669,, +0.771900,4.182583,0.308760,42.001862,, +0.776050,4.402709,0.310420,41.994136,, +0.780200,4.161918,0.312080,41.999175,, +0.784350,4.421112,0.313740,41.999312,, +0.788500,2.939480,0.315400,41.996328,, +0.792650,4.644566,0.317060,41.999148,, +0.796800,4.087691,0.318720,42.000065,, +0.800950,2.631795,0.320380,42.002691,, +0.805100,3.357840,0.322040,42.001074,, +0.809250,4.314461,0.323700,41.997610,, +0.813400,3.434729,0.325360,41.997204,, +0.817550,4.019418,0.327020,41.999758,, +0.821700,4.216599,0.328680,41.999006,, +0.825850,8.497223,0.330340,42.001667,, +0.830000,12.209608,0.332000,41.994791,, +0.834150,26.450742,0.333660,41.994391,, +0.838300,43.621174,0.335320,42.004919,, +0.842450,78.063613,0.336980,41.998318,, +0.846600,111.597708,0.338640,41.992576,, +0.850750,146.028479,0.340300,41.996843,, +0.854900,181.362137,0.341960,41.994090,, +0.859050,184.645136,0.343620,42.003230,, +0.863200,188.638825,0.345280,41.991163,, +0.867350,158.915389,0.346940,41.996008,, +0.871500,129.287510,0.348600,42.005628,, +0.875650,96.373676,0.350260,42.001006,, +0.879800,60.471586,0.351920,42.006899,, +0.883950,41.479610,0.353580,42.004509,, +0.888100,21.407762,0.355240,42.002040,, +0.892250,15.208865,0.356900,42.006125,, +0.896400,8.619310,0.358560,41.997287,, +0.900550,6.586038,0.360220,41.997812,, +0.904700,5.133707,0.361880,42.004926,, +0.908850,5.075018,0.363540,41.995766,, +0.913000,3.076548,0.365200,42.003942,, +0.917150,4.705638,0.366860,41.997571,, +0.921300,4.175457,0.368520,41.994497,, +0.925450,3.214956,0.370180,41.999924,, +0.929600,3.482929,0.371840,41.999020,, +0.933750,3.974216,0.373500,41.996854,, +0.937900,4.881898,0.375160,41.991704,, +0.942050,4.540306,0.376820,41.999706,, +0.946200,4.147884,0.378480,42.002649,, +0.950350,4.898183,0.380140,42.003141,, +0.954500,4.186355,0.381800,42.005418,, +0.958650,3.920819,0.383460,41.999570,, +0.962800,5.140234,0.385120,41.999592,, +0.966950,3.403012,0.386780,41.993708,, +0.971100,3.937684,0.388440,42.001311,, +0.975250,3.779040,0.390100,42.003214,, +0.979400,3.888666,0.391760,42.006428,, +0.983550,4.529784,0.393420,41.998243,, +0.987700,4.217906,0.395080,41.990592,, +0.991850,4.351876,0.396740,41.998529,, +0.996000,3.927230,0.398400,42.004189,, +1.000150,3.658513,0.400060,42.002852,, +1.004300,4.560780,0.401720,42.001042,, +1.008450,3.727522,0.403380,41.999476,, +1.012600,3.522548,0.405040,42.002521,, +1.016750,4.529277,0.406700,42.000518,, +1.020900,4.461442,0.408360,42.003201,, +1.025050,3.428202,0.410020,41.996792,, +1.029200,4.673895,0.411680,42.002641,, +1.033350,3.480839,0.413340,42.002346,, +1.037500,3.312400,0.415000,42.009235,, +1.041650,3.619334,0.416660,41.994637,, +1.045800,3.944181,0.418320,41.996040,, +1.049950,4.290904,0.419980,42.000719,, +1.054100,3.885696,0.421640,42.003195,, +1.058250,4.516968,0.423300,41.999167,, +1.062400,3.720114,0.424960,41.999304,, +1.066550,4.243528,0.426620,41.999085,, +1.070700,4.813464,0.428280,42.001311,, +1.074850,4.697287,0.429940,42.003431,, +1.079000,4.496874,0.431600,41.995648,, +1.083150,4.898432,0.433260,41.998735,, +1.087300,4.315158,0.434920,41.998053,, +1.091450,3.898156,0.436580,41.998376,, +1.095600,4.639487,0.438240,41.992558,, +1.099750,4.094979,0.439900,41.999365,, +1.103900,3.309560,0.441560,42.001035,, +1.108050,4.620655,0.443220,42.002469,, +1.112200,4.216728,0.444880,42.000578,, +1.116350,3.070417,0.446540,42.003379,, +1.120500,4.119969,0.448200,41.999153,, +1.124650,2.986832,0.449860,41.994151,, +1.128800,3.942050,0.451520,41.999724,, +1.132950,4.103768,0.453180,41.999740,, +1.137100,4.676443,0.454840,41.998167,, +1.141250,3.674736,0.456500,41.999143,, +1.145400,4.070003,0.458160,41.996872,, +1.149550,4.156607,0.459820,42.000583,, +1.153700,3.681637,0.461480,42.000505,, +1.157850,4.037128,0.463140,42.003562,, +1.162000,4.668964,0.464800,41.993369,, +1.166150,4.019446,0.466460,42.000518,, +1.170300,3.125679,0.468120,42.001918,, +1.174450,4.794240,0.469780,41.995561,, +1.178600,4.478509,0.471440,42.002336,, +1.182750,3.965507,0.473100,41.999004,, +1.186900,3.399689,0.474760,42.003082,, +1.191050,4.668862,0.476420,42.001132,, +1.195200,4.118056,0.478080,42.002328,, +1.199350,3.257587,0.479740,42.000946,, +1.203500,4.137051,0.481400,41.998534,, +1.207650,3.118972,0.483060,42.005673,, +1.211800,4.154985,0.484720,42.000365,, +1.215950,3.604548,0.486380,42.001389,, +1.220100,4.463832,0.488040,41.999871,, +1.224250,3.219400,0.489700,41.998535,, +1.228400,4.379088,0.491360,42.001323,, +1.232550,4.072208,0.493020,42.000016,, +1.236700,4.577299,0.494680,42.002907,, +1.240850,3.700143,0.496340,42.001276,, +1.245000,4.352006,0.498000,41.998530,, +1.249150,4.056682,0.499660,42.002940,, +1.253300,3.918982,0.501320,41.998055,, +1.257450,3.534035,0.502980,41.997021,, +1.261600,4.439459,0.504640,42.003863,, +1.265750,3.833340,0.506300,42.003149,, +1.269900,3.512215,0.507960,42.001074,, +1.274050,4.346933,0.509620,42.009276,, +1.278200,3.067773,0.511280,42.010200,, +1.282350,3.695656,0.512940,41.997968,, +1.286500,4.280978,0.514600,42.000180,, +1.290650,4.141991,0.516260,42.002422,, +1.294800,3.510297,0.517920,41.995543,, +1.298950,4.222463,0.519580,41.994774,, +1.303100,4.462071,0.521240,41.998306,, +1.307250,3.839126,0.522900,42.004239,, +1.311400,3.654550,0.524560,41.997368,, +1.315550,3.525672,0.526220,41.996006,, +1.319700,4.085721,0.527880,42.004550,, +1.323850,4.226849,0.529540,41.999192,, +1.328000,3.645236,0.531200,41.999024,, +1.332150,4.829652,0.532860,42.000457,, +1.336300,4.110697,0.534520,41.996717,, +1.340450,2.998792,0.536180,42.002489,, +1.344600,4.699368,0.537840,42.004780,, +1.348750,4.280230,0.539500,42.001400,, +1.352900,3.119930,0.541160,41.996860,, +1.357050,4.733105,0.542820,42.003000,, +1.361200,4.671681,0.544480,41.998483,, +1.365350,4.378579,0.546140,41.998648,, +1.369500,4.538412,0.547800,42.006205,, +1.373650,4.112892,0.549460,41.997619,, +1.377800,3.608753,0.551120,41.999313,, +1.381950,4.085905,0.552780,42.001988,, +1.386100,4.496836,0.554440,41.997726,, +1.390250,3.937295,0.556100,41.996795,, +1.394400,3.381533,0.557760,41.993139,, +1.398550,2.929951,0.559420,41.991334,, +1.402700,3.279542,0.561080,41.994011,, +1.406850,3.385198,0.562740,42.000769,, +1.411000,3.678850,0.564400,41.995128,, +1.415150,4.037384,0.566060,41.999942,, +1.419300,3.017323,0.567720,41.992800,, +1.423450,3.726977,0.569380,42.000812,, +1.427600,3.745008,0.571040,42.000104,, +1.431750,4.311419,0.572700,41.996801,, +1.435900,4.021457,0.574360,42.002112,, +1.440050,4.391987,0.576020,42.001326,, +1.444200,4.395372,0.577680,41.998470,, +1.448350,3.880450,0.579340,42.000516,, +1.452500,2.686040,0.581000,41.997289,, +1.456650,3.695931,0.582660,42.003279,, +1.460800,4.247086,0.584320,41.998376,, +1.464950,4.269399,0.585980,41.998018,, +1.469100,3.572193,0.587640,41.994172,, +1.473250,3.743716,0.589300,42.007706,, +1.477400,4.322173,0.590960,41.994915,, +1.481550,4.532309,0.592620,42.007163,, +1.485700,4.508833,0.594280,42.006786,, +1.489850,4.294650,0.595940,41.995815,, +1.494000,3.501941,0.597600,41.998363,, +1.498150,3.898860,0.599260,42.003439,, +1.502300,4.001511,0.600920,41.997361,, +1.506450,4.539860,0.602580,42.008645,, +1.510600,3.720030,0.604240,41.992885,, +1.514750,3.313815,0.605900,41.993666,, +1.518900,4.104432,0.607560,42.002513,, +1.523050,4.743180,0.609220,42.001617,, +1.527200,3.764303,0.610880,42.003381,, +1.531350,4.803490,0.612540,41.998188,, +1.535500,3.713905,0.614200,42.003003,, +1.539650,3.913004,0.615860,41.995617,, +1.543800,4.112774,0.617520,41.999270,, +1.547950,4.266429,0.619180,42.001071,, +1.552100,2.762375,0.620840,41.998018,, +1.556250,4.595963,0.622500,42.003467,, +1.560400,3.652948,0.624160,42.004005,, +1.564550,4.022264,0.625820,41.997530,, +1.568700,3.827303,0.627480,41.993857,, +1.572850,3.340087,0.629140,42.004858,, +1.577000,4.282369,0.630800,41.999505,, +1.581150,4.795230,0.632460,42.006802,, +1.585300,2.917484,0.634120,42.004691,, +1.589450,4.090372,0.635780,41.998173,, +1.593600,3.328586,0.637440,41.999022,, +1.597750,4.160171,0.639100,42.000314,, +1.601900,4.195259,0.640760,41.998059,, +1.606050,4.179258,0.642420,42.001474,, +1.610200,4.021049,0.644080,42.002583,, +1.614350,4.682761,0.645740,41.995604,, +1.618500,4.078289,0.647400,41.998844,, +1.622650,2.989132,0.649060,41.998879,, +1.626800,4.509013,0.650720,42.002870,, +1.630950,3.918195,0.652380,41.993275,, +1.635100,3.976903,0.654040,42.007187,, +1.639250,3.441085,0.655700,41.994472,, +1.643400,2.875877,0.657360,42.000974,, +1.647550,4.372373,0.659020,41.999660,, +1.651700,4.458853,0.660680,41.995277,, +1.655850,3.937797,0.662340,41.999674,, +1.660000,4.063069,0.664000,42.000329,, +1.664150,4.095842,0.665660,41.993135,, +1.668300,4.081411,0.667320,42.008195,, +1.672450,4.114631,0.668980,42.001723,, +1.676600,4.910629,0.670640,41.992277,, +1.680750,3.174637,0.672300,42.002166,, +1.684900,4.572864,0.673960,41.999369,, +1.689050,3.768977,0.675620,41.997855,, +1.693200,3.647252,0.677280,41.996627,, +1.697350,4.221075,0.678940,41.991731,, +1.701500,3.895303,0.680600,42.001899,, +1.705650,3.550255,0.682260,41.996760,, +1.709800,3.929658,0.683920,41.992931,, +1.713950,4.062763,0.685580,41.994647,, +1.718100,4.422707,0.687240,42.000030,, +1.722250,4.472330,0.688900,42.000952,, +1.726400,3.846890,0.690560,41.997953,, +1.730550,4.703046,0.692220,42.000467,, +1.734700,3.516977,0.693880,42.002188,, +1.738850,3.261302,0.695540,42.006771,, +1.743000,3.791250,0.697200,41.997558,, +1.747150,3.524920,0.698860,42.001403,, +1.751300,4.149780,0.700520,42.001244,, +1.755450,4.374248,0.702180,42.003417,, +1.759600,3.181913,0.703840,41.999679,, +1.763750,4.595914,0.705500,42.002556,, +1.767900,4.004245,0.707160,42.009703,, +1.772050,3.207944,0.708820,42.000889,, +1.776200,3.213829,0.710480,42.000115,, +1.780350,4.648104,0.712140,42.003203,, +1.784500,4.014912,0.713800,42.000953,, +1.788650,3.297555,0.715460,41.998677,, +1.792800,3.920888,0.717120,42.008628,, +1.796950,4.813008,0.718780,41.999320,, +1.801100,3.760199,0.720440,42.004113,, +1.805250,4.198335,0.722100,42.002016,, +1.809400,3.250563,0.723760,42.001670,, +1.813550,4.584450,0.725420,41.993870,, +1.817700,4.399409,0.727080,41.996395,, +1.821850,3.021055,0.728740,41.997149,, +1.826000,4.212987,0.730400,42.006262,, +1.830150,4.585634,0.732060,41.996717,, +1.834300,3.471950,0.733720,42.003334,, +1.838450,3.523298,0.735380,42.001463,, +1.842600,3.760657,0.737040,42.004356,, +1.846750,3.568967,0.738700,42.003417,, +1.850900,3.670525,0.740360,42.004795,, +1.855050,3.598235,0.742020,41.999176,, +1.859200,4.637982,0.743680,41.999171,, +1.863350,4.070977,0.745340,42.002431,, +1.867500,4.676954,0.747000,41.995877,, +1.871650,4.462696,0.748660,42.004450,, +1.875800,3.113736,0.750320,41.998695,, +1.879950,4.643494,0.751980,41.995856,, +1.884100,4.588977,0.753640,42.004196,, +1.888250,4.199482,0.755300,41.997009,, +1.892400,3.736825,0.756960,42.005543,, +1.896550,5.068577,0.758620,41.996204,, +1.900700,3.716915,0.760280,41.999191,, +1.904850,4.178863,0.761940,41.996256,, +1.909000,4.447307,0.763600,41.997554,, +1.913150,4.438195,0.765260,41.999323,, +1.917300,3.575997,0.766920,41.997958,, +1.921450,4.054226,0.768580,41.999671,, +1.925600,4.259330,0.770240,41.997885,, +1.929750,4.198250,0.771900,41.995495,, +1.933900,4.152419,0.773560,41.997487,, +1.938050,4.258486,0.775220,41.999316,, +1.942200,4.489367,0.776880,42.000907,, +1.946350,3.859219,0.778540,41.994347,, +1.950500,3.764793,0.780200,41.994325,, +1.954650,4.111943,0.781860,41.999737,, +1.958800,4.549146,0.783520,42.002164,, +1.962950,3.721599,0.785180,41.997708,, +1.967100,3.529905,0.786840,42.002062,, +1.971250,4.368678,0.788500,41.994064,, +1.975400,5.129174,0.790160,41.999484,, +1.979550,3.860166,0.791820,42.002220,, +1.983700,3.903179,0.793480,42.006020,, +1.987850,3.583854,0.795140,42.004506,, +1.992000,4.915085,0.796800,42.005977,, +1.996150,3.075143,0.798460,41.996313,, +2.000300,4.878972,0.800120,42.000468,, +2.004450,3.621646,0.801780,42.004891,, +2.008600,3.589106,0.803440,42.000606,, +2.012750,4.055784,0.805100,41.999580,, +2.016900,4.591759,0.806760,41.994643,, +2.021050,3.059251,0.808420,41.997368,, +2.025200,3.746041,0.810080,42.006447,, +2.029350,5.114731,0.811740,42.005532,, +2.033500,4.175135,0.813400,41.999011,, +2.037650,3.701450,0.815060,41.992286,, +2.041800,3.962300,0.816720,42.001708,, +2.045950,3.431734,0.818380,42.000329,, +2.050100,3.933740,0.820040,42.002595,, +2.054250,4.117516,0.821700,42.000425,, +2.058400,3.890410,0.823360,41.996377,, +2.062550,3.869146,0.825020,42.009412,, +2.066700,4.247240,0.826680,41.995202,, +2.070850,3.943790,0.828340,41.995948,, +2.075000,4.791687,0.830000,42.003997,, +2.079150,4.031997,0.831660,42.000582,, +2.083300,4.822618,0.833320,41.993352,, +2.087450,4.466679,0.834980,42.002438,, +2.091600,3.605595,0.836640,41.997023,, +2.095750,4.737246,0.838300,41.999809,, +2.099900,5.169262,0.839960,42.005351,, +2.104050,3.823972,0.841620,41.996654,, +2.108200,3.992294,0.843280,42.000256,, +2.112350,4.382903,0.844940,41.998713,, +2.116500,3.866255,0.846600,41.997249,, +2.120650,4.271518,0.848260,41.999109,, +2.124800,3.828084,0.849920,42.003282,, +2.128950,3.914931,0.851580,41.999841,, +2.133100,4.081924,0.853240,41.999305,, +2.137250,4.010809,0.854900,41.998054,, +2.141400,3.587998,0.856560,41.988556,, +2.145550,3.289395,0.858220,42.007652,, +2.149700,4.240774,0.859880,42.002425,, +2.153850,2.343211,0.861540,41.998130,, +2.158000,3.003746,0.863200,41.998508,, +2.162150,4.507031,0.864860,41.999591,, +2.166300,4.229701,0.866520,41.993107,, +2.170450,3.793639,0.868180,41.994055,, +2.174600,4.747006,0.869840,42.000428,, +2.178750,3.855360,0.871500,42.005177,, +2.182900,4.894082,0.873160,42.002051,, +2.187050,3.998431,0.874820,42.000511,, +2.191200,4.074498,0.876480,41.995936,, +2.195350,2.875990,0.878140,41.998622,, +2.199500,3.603539,0.879800,41.997952,, +2.203650,3.822070,0.881460,41.998287,, +2.207800,3.679253,0.883120,41.990609,, +2.211950,3.630193,0.884780,41.998688,, +2.216100,4.038870,0.886440,42.005479,, +2.220250,4.424938,0.888100,42.000638,, +2.224400,3.457110,0.889760,41.999217,, +2.228550,3.762292,0.891420,41.997407,, +2.232700,4.643303,0.893080,41.996451,, +2.236850,3.789371,0.894740,41.999767,, +2.241000,4.078859,0.896400,42.003322,, +2.245150,4.724681,0.898060,42.000389,, +2.249300,2.605295,0.899720,42.000980,, +2.253450,3.449607,0.901380,41.997082,, +2.257600,3.424619,0.903040,41.997039,, +2.261750,3.948700,0.904700,41.997013,, +2.265900,3.983794,0.906360,42.003985,, +2.270050,3.122505,0.908020,41.998797,, +2.274200,3.195653,0.909680,41.997625,, +2.278350,4.469556,0.911340,41.999801,, +2.282500,3.998098,0.913000,42.001313,, +2.286650,4.116982,0.914660,42.001916,, +2.290800,4.279144,0.916320,42.003870,, +2.294950,3.940370,0.917980,41.997632,, +2.299100,4.995637,0.919640,41.997645,, +2.303250,3.881916,0.921300,41.999601,, +2.307400,4.156442,0.922960,41.997260,, +2.311550,3.469072,0.924620,42.001212,, +2.315700,4.899989,0.926280,41.997206,, +2.319850,4.543794,0.927940,42.000517,, +2.324000,3.498581,0.929600,41.996212,, +2.328150,3.554562,0.931260,42.000692,, +2.332300,4.786384,0.932920,42.009412,, +2.336450,3.725647,0.934580,41.991457,, +2.340600,3.686648,0.936240,42.000910,, +2.344750,4.083825,0.937900,42.002696,, +2.348900,3.213540,0.939560,42.003754,, +2.353050,4.143229,0.941220,41.998076,, +2.357200,2.987490,0.942880,41.996389,, +2.361350,4.162807,0.944540,41.996226,, +2.365500,4.536017,0.946200,42.005755,, +2.369650,4.809112,0.947860,41.998058,, +2.373800,3.960582,0.949520,41.995903,, +2.377950,4.421777,0.951180,41.999347,, +2.382100,4.167554,0.952840,42.001990,, +2.386250,3.964769,0.954500,42.005039,, +2.390400,3.610147,0.956160,41.997522,, +2.394550,3.637584,0.957820,42.000236,, +2.398700,3.880809,0.959480,42.003807,, +2.402850,4.109484,0.961140,41.999047,, +2.407000,4.742611,0.962800,42.003832,, +2.411150,4.472716,0.964460,41.999955,, +2.415300,5.087712,0.966120,42.003516,, +2.419450,4.382308,0.967780,42.002140,, +2.423600,4.454056,0.969440,41.998932,, +2.427750,3.063596,0.971100,42.005402,, +2.431900,3.864590,0.972760,41.996688,, +2.436050,4.523972,0.974420,41.998966,, +2.440200,4.363196,0.976080,41.999833,, +2.444350,4.159214,0.977740,41.998987,, +2.448500,4.019090,0.979400,42.003686,, +2.452650,3.644316,0.981060,41.998822,, +2.456800,3.029303,0.982720,41.999920,, +2.460950,4.080641,0.984380,42.003626,, +2.465100,3.083797,0.986040,41.992419,, +2.469250,4.221828,0.987700,42.000261,, +2.473400,3.930598,0.989360,41.999348,, +2.477550,4.334781,0.991020,41.998418,, +2.481700,3.855317,0.992680,41.997653,, +2.485850,3.430935,0.994340,41.997867,, +2.490000,3.222952,0.996000,41.998712,, +2.494150,3.859311,0.997660,41.994303,, +2.498300,4.630755,0.999320,41.998600,, +2.502450,3.818174,1.000980,42.004008,, +2.506600,3.206193,1.002640,42.004181,, +2.510750,3.893285,1.004300,41.995458,, +2.514900,4.079684,1.005960,41.997381,, +2.519050,3.938850,1.007620,42.004651,, +2.523200,4.531831,1.009280,41.998502,, +2.527350,4.345205,1.010940,42.001905,, +2.531500,3.751665,1.012600,41.999514,, +2.535650,3.818201,1.014260,41.998927,, +2.539800,3.756488,1.015920,41.993996,, +2.543950,4.361596,1.017580,41.995090,, +2.548100,3.992167,1.019240,41.999551,, +2.552250,4.376968,1.020900,42.002036,, +2.556400,4.387623,1.022560,42.001157,, +2.560550,4.698943,1.024220,42.001593,, +2.564700,4.387947,1.025880,41.995044,, +2.568850,4.157887,1.027540,41.999339,, +2.573000,4.178477,1.029200,41.997672,, +2.577150,3.997192,1.030860,41.999191,, +2.581300,4.044470,1.032520,42.000176,, +2.585450,4.051467,1.034180,41.998846,, +2.589600,4.234569,1.035840,42.001344,, +2.593750,3.521094,1.037500,42.003584,, +2.597900,3.672474,1.039160,42.003298,, +2.602050,3.724429,1.040820,42.006696,, +2.606200,4.326091,1.042480,41.998060,, +2.610350,3.365259,1.044140,42.001639,, +2.614500,3.387262,1.045800,42.000439,, +2.618650,3.927161,1.047460,41.998456,, +2.622800,3.632213,1.049120,41.993929,, +2.626950,3.627911,1.050780,42.001372,, +2.631100,3.892937,1.052440,42.001489,, +2.635250,4.268348,1.054100,42.002082,, +2.639400,4.529529,1.055760,42.008313,, +2.643550,3.717957,1.057420,42.001331,, +2.647700,3.968083,1.059080,41.999574,, +2.651850,3.621559,1.060740,41.999819,, +2.656000,4.064723,1.062400,42.001510,, +2.660150,3.330565,1.064060,42.005164,, +2.664300,4.604895,1.065720,41.998358,, +2.668450,3.692467,1.067380,41.996819,, +2.672600,4.028499,1.069040,42.002447,, +2.676750,4.280387,1.070700,42.000798,, +2.680900,3.655645,1.072360,42.010191,, +2.685050,2.718996,1.074020,41.997482,, +2.689200,3.868558,1.075680,41.996878,, +2.693350,3.703282,1.077340,41.998465,, +2.697500,3.798422,1.079000,42.003186,, +2.701650,3.812203,1.080660,41.998512,, +2.705800,4.746599,1.082320,42.000547,, +2.709950,3.808745,1.083980,42.000640,, +2.714100,4.601319,1.085640,42.001464,, +2.718250,3.891322,1.087300,42.008941,, +2.722400,3.726536,1.088960,41.998688,, +2.726550,4.102598,1.090620,42.006726,, +2.730700,3.474553,1.092280,41.999344,, +2.734850,4.619269,1.093940,42.001681,, +2.739000,4.266056,1.095600,41.994298,, +2.743150,2.899876,1.097260,41.998782,, +2.747300,2.935371,1.098920,41.998182,, +2.751450,3.593683,1.100580,41.998366,, +2.755600,3.495900,1.102240,42.003314,, +2.759750,4.184808,1.103900,42.006076,, +2.763900,3.945348,1.105560,42.001983,, +2.768050,4.079231,1.107220,41.998639,, +2.772200,4.566075,1.108880,41.994688,, +2.776350,3.171117,1.110540,41.997805,, +2.780500,4.766514,1.112200,42.003886,, +2.784650,3.221549,1.113860,41.995079,, +2.788800,3.916646,1.115520,41.994002,, +2.792950,3.785529,1.117180,41.998340,, +2.797100,3.925680,1.118840,41.998041,, +2.801250,3.359058,1.120500,42.004513,, +2.805400,4.268560,1.122160,41.996623,, +2.809550,3.761539,1.123820,42.001825,, +2.813700,3.591358,1.125480,41.998086,, +2.817850,3.868728,1.127140,41.997071,, +2.822000,4.831106,1.128800,41.998229,, +2.826150,4.486268,1.130460,41.993399,, +2.830300,3.099907,1.132120,42.003721,, +2.834450,3.560182,1.133780,42.000196,, +2.838600,3.524375,1.135440,42.001559,, +2.842750,3.901091,1.137100,41.999107,, +2.846900,3.208234,1.138760,41.999797,, +2.851050,3.843211,1.140420,42.005564,, +2.855200,3.789611,1.142080,42.005477,, +2.859350,4.293927,1.143740,41.997195,, +2.863500,3.831567,1.145400,41.994592,, +2.867650,3.654069,1.147060,41.991970,, +2.871800,4.000789,1.148720,41.995786,, +2.875950,3.968388,1.150380,41.995544,, +2.880100,4.556714,1.152040,42.000739,, +2.884250,4.133504,1.153700,42.001139,, +2.888400,3.569705,1.155360,42.006240,, +2.892550,4.342756,1.157020,42.001110,, +2.896700,4.606237,1.158680,41.996890,, +2.900850,4.046682,1.160340,42.002151,, +2.905000,3.880858,1.162000,42.000208,, +2.909150,4.654711,1.163660,42.000924,, +2.913300,4.195600,1.165320,41.999632,, +2.917450,4.535821,1.166980,42.003083,, +2.921600,3.917008,1.168640,42.003125,, +2.925750,4.698114,1.170300,42.004596,, +2.929900,3.704844,1.171960,42.002555,, +2.934050,2.914007,1.173620,42.001115,, +2.938200,3.501547,1.175280,42.006135,, +2.942350,4.255385,1.176940,42.001480,, +2.946500,3.830202,1.178600,42.002991,, +2.950650,4.996376,1.180260,42.001289,, +2.954800,3.255727,1.181920,41.998008,, +2.958950,4.382913,1.183580,42.001993,, +2.963100,4.085493,1.185240,42.000125,, +2.967250,3.801874,1.186900,41.995975,, +2.971400,4.569062,1.188560,42.006990,, +2.975550,4.071232,1.190220,41.996595,, +2.979700,4.019640,1.191880,41.994894,, +2.983850,3.808283,1.193540,42.002476,, +2.988000,4.789263,1.195200,41.995835,, +,,1.196860,41.989921,, +,,1.198520,42.003621,, +,,1.200180,41.997461,, +,,1.201840,41.996929,, +,,1.203500,42.012807,, +,,1.205160,42.004591,, +,,1.206820,41.997052,, +,,1.208480,41.997490,, +,,1.210140,41.994465,, +,,1.211800,41.997400,, +,,1.213460,41.994505,, +,,1.215120,42.013342,, +,,1.216780,42.004601,, +,,1.218440,42.003856,, +,,1.220100,42.003227,, +,,1.221760,41.999298,, +,,1.223420,42.003813,, +,,1.225080,42.000113,, +,,1.226740,41.993361,, +,,1.228400,42.001229,, +,,1.230060,42.007216,, +,,1.231720,42.003733,, +,,1.233380,42.005000,, +,,1.235040,42.005990,, +,,1.236700,41.998797,, +,,1.238360,42.001770,, +,,1.240020,42.006103,, +,,1.241680,42.005627,, +,,1.243340,41.993983,, +,,1.245000,41.999796,, +,,1.246660,41.996941,, +,,1.248320,42.004653,, +,,1.249980,42.003406,, +,,1.251640,42.005144,, +,,1.253300,41.996955,, +,,1.254960,41.997125,, +,,1.256620,41.993710,, +,,1.258280,41.997954,, +,,1.259940,41.996480,, +,,1.261600,41.998569,, +,,1.263260,42.003704,, +,,1.264920,41.994253,, +,,1.266580,41.995880,, +,,1.268240,41.999981,, +,,1.269900,42.002374,, +,,1.271560,41.999487,, +,,1.273220,41.999138,, +,,1.274880,42.001984,, +,,1.276540,41.994157,, +,,1.278200,42.003606,, +,,1.279860,42.011325,, +,,1.281520,42.000572,, +,,1.283180,41.996136,, +,,1.284840,42.000689,, +,,1.286500,41.998384,, +,,1.288160,41.995816,, +,,1.289820,41.993879,, +,,1.291480,42.008983,, +,,1.293140,42.004691,, +,,1.294800,41.999074,, +,,1.296460,42.001055,, +,,1.298120,42.005035,, +,,1.299780,42.001701,, +,,1.301440,42.000898,, +,,1.303100,41.996544,, +,,1.304760,41.999499,, +,,1.306420,41.997103,, +,,1.308080,42.001562,, +,,1.309740,41.993794,, +,,1.311400,41.997806,, +,,1.313060,42.003039,, +,,1.314720,41.999535,, +,,1.316380,41.996250,, +,,1.318040,41.996233,, +,,1.319700,42.007640,, +,,1.321360,41.997232,, +,,1.323020,42.003918,, +,,1.324680,41.995734,, +,,1.326340,41.997091,, +,,1.328000,42.007209,, +,,1.329660,41.996369,, +,,1.331320,41.996952,, +,,1.332980,41.997683,, +,,1.334640,42.002118,, +,,1.336300,42.005436,, +,,1.337960,42.002328,, +,,1.339620,42.002353,, +,,1.341280,42.004609,, +,,1.342940,41.998178,, +,,1.344600,42.002472,, +,,1.346260,41.998159,, +,,1.347920,42.005247,, +,,1.349580,42.000027,, +,,1.351240,41.997368,, +,,1.352900,41.998557,, +,,1.354560,41.999745,, +,,1.356220,41.996420,, +,,1.357880,41.999904,, +,,1.359540,41.999838,, +,,1.361200,41.994322,, +,,1.362860,42.002658,, +,,1.364520,42.002665,, +,,1.366180,41.997658,, +,,1.367840,42.004048,, +,,1.369500,41.999541,, +,,1.371160,41.997663,, +,,1.372820,42.002789,, +,,1.374480,42.003259,, +,,1.376140,41.999649,, +,,1.377800,41.996310,, +,,1.379460,42.003134,, +,,1.381120,42.002531,, +,,1.382780,42.000377,, +,,1.384440,42.002999,, +,,1.386100,42.003581,, +,,1.387760,41.998260,, +,,1.389420,42.002144,, +,,1.391080,41.997591,, +,,1.392740,42.000369,, +,,1.394400,41.996623,, +,,1.396060,41.999890,, +,,1.397720,42.001308,, +,,1.399380,41.999066,, +,,1.401040,41.999822,, +,,1.402700,42.002546,, +,,1.404360,41.998288,, +,,1.406020,42.002560,, +,,1.407680,41.992840,, +,,1.409340,42.004281,, +,,1.411000,41.997601,, +,,1.412660,42.013640,, +,,1.414320,41.997012,, +,,1.415980,42.005625,, +,,1.417640,42.002805,, +,,1.419300,41.995121,, +,,1.420960,42.003022,, +,,1.422620,41.996892,, +,,1.424280,41.998372,, +,,1.425940,42.005899,, +,,1.427600,41.995058,, +,,1.429260,42.000616,, +,,1.430920,41.998852,, +,,1.432580,42.001778,, +,,1.434240,42.002514,, +,,1.435900,41.999729,, +,,1.437560,42.005575,, +,,1.439220,41.997166,, +,,1.440880,42.003927,, +,,1.442540,41.999548,, +,,1.444200,41.998624,, +,,1.445860,41.998144,, +,,1.447520,42.006319,, +,,1.449180,41.999603,, +,,1.450840,41.997156,, +,,1.452500,42.000399,, +,,1.454160,42.004576,, +,,1.455820,41.993604,, +,,1.457480,42.001725,, +,,1.459140,41.999781,, +,,1.460800,42.006788,, +,,1.462460,42.002122,, +,,1.464120,42.001398,, +,,1.465780,42.006619,, +,,1.467440,41.999943,, +,,1.469100,42.006207,, +,,1.470760,42.003638,, +,,1.472420,41.999932,, +,,1.474080,41.999445,, +,,1.475740,42.003718,, +,,1.477400,41.999700,, +,,1.479060,42.001710,, +,,1.480720,42.000653,, +,,1.482380,41.997923,, +,,1.484040,41.995850,, +,,1.485700,41.998176,, +,,1.487360,41.997759,, +,,1.489020,42.002159,, +,,1.490680,42.010570,, +,,1.492340,42.001495,, +,,1.494000,41.994453,, +,,1.495660,41.997287,, +,,1.497320,42.001271,, +,,1.498980,42.003234,, +,,1.500640,41.998843,, +,,1.502300,42.005201,, +,,1.503960,42.005385,, +,,1.505620,42.005574,, +,,1.507280,42.000419,, +,,1.508940,42.001551,, +,,1.510600,42.001569,, +,,1.512260,41.999658,, +,,1.513920,42.001935,, +,,1.515580,42.006270,, +,,1.517240,41.998399,, +,,1.518900,41.998646,, +,,1.520560,42.002542,, +,,1.522220,42.009698,, +,,1.523880,42.004099,, +,,1.525540,41.997624,, +,,1.527200,42.003024,, +,,1.528860,42.003286,, +,,1.530520,42.000829,, +,,1.532180,42.006306,, +,,1.533840,41.994268,, +,,1.535500,41.997232,, +,,1.537160,41.996025,, +,,1.538820,41.999757,, +,,1.540480,42.004231,, +,,1.542140,42.005932,, +,,1.543800,41.999541,, +,,1.545460,42.003050,, +,,1.547120,41.997809,, +,,1.548780,42.005411,, +,,1.550440,42.000137,, +,,1.552100,42.003503,, +,,1.553760,41.994249,, +,,1.555420,42.000237,, +,,1.557080,41.988362,, +,,1.558740,41.997050,, +,,1.560400,41.999978,, +,,1.562060,42.005961,, +,,1.563720,41.996805,, +,,1.565380,42.000844,, +,,1.567040,42.002218,, +,,1.568700,41.991766,, +,,1.570360,42.003796,, +,,1.572020,42.003649,, +,,1.573680,42.000759,, +,,1.575340,42.007193,, +,,1.577000,41.997643,, +,,1.578660,42.003947,, +,,1.580320,42.001141,, +,,1.581980,41.997913,, +,,1.583640,41.997530,, +,,1.585300,41.996872,, +,,1.586960,41.995512,, +,,1.588620,41.995122,, +,,1.590280,42.001000,, +,,1.591940,41.997583,, +,,1.593600,42.000057,, +,,1.595260,41.997649,, +,,1.596920,41.992652,, +,,1.598580,42.000007,, +,,1.600240,42.005293,, +,,1.601900,41.997491,, +,,1.603560,41.994522,, +,,1.605220,42.000268,, +,,1.606880,41.996166,, +,,1.608540,42.002386,, +,,1.610200,42.004858,, +,,1.611860,42.001839,, +,,1.613520,41.998995,, +,,1.615180,42.001003,, +,,1.616840,42.005275,, +,,1.618500,41.997298,, +,,1.620160,41.997676,, +,,1.621820,42.005734,, +,,1.623480,42.001507,, +,,1.625140,42.002573,, +,,1.626800,42.000171,, +,,1.628460,41.997740,, +,,1.630120,41.999075,, +,,1.631780,42.001271,, +,,1.633440,41.994360,, +,,1.635100,41.998016,, +,,1.636760,42.006763,, +,,1.638420,42.000625,, +,,1.640080,42.001599,, +,,1.641740,41.998405,, +,,1.643400,41.992517,, +,,1.645060,42.005746,, +,,1.646720,42.000045,, +,,1.648380,42.004402,, +,,1.650040,41.997121,, +,,1.651700,42.002233,, +,,1.653360,42.007457,, +,,1.655020,42.005315,, +,,1.656680,42.001036,, +,,1.658340,41.999045,, +,,1.660000,42.000528,, +,,1.661660,41.998628,, +,,1.663320,42.003167,, +,,1.664980,42.001324,, +,,1.666640,42.001737,, +,,1.668300,41.990111,, +,,1.669960,41.992660,, +,,1.671620,42.000241,, +,,1.673280,41.999476,, +,,1.674940,41.998146,, +,,1.676600,41.999737,, +,,1.678260,41.995507,, +,,1.679920,41.997891,, +,,1.681580,42.006231,, +,,1.683240,41.999799,, +,,1.684900,41.993955,, +,,1.686560,41.998463,, +,,1.688220,41.999542,, +,,1.689880,42.003512,, +,,1.691540,41.998062,, +,,1.693200,41.999932,, +,,1.694860,41.996609,, +,,1.696520,42.004932,, +,,1.698180,41.999055,, +,,1.699840,42.000661,, +,,1.701500,42.000324,, +,,1.703160,42.005410,, +,,1.704820,41.996895,, +,,1.706480,42.009183,, +,,1.708140,42.002773,, +,,1.709800,42.005681,, +,,1.711460,41.994591,, +,,1.713120,42.002460,, +,,1.714780,41.999227,, +,,1.716440,41.994828,, +,,1.718100,42.005034,, +,,1.719760,42.005785,, +,,1.721420,42.002675,, +,,1.723080,42.005137,, +,,1.724740,41.989456,, +,,1.726400,41.998906,, +,,1.728060,42.005536,, +,,1.729720,42.000804,, +,,1.731380,41.999346,, +,,1.733040,41.999811,, +,,1.734700,42.004432,, +,,1.736360,42.002606,, +,,1.738020,42.000962,, +,,1.739680,42.008401,, +,,1.741340,41.998005,, +,,1.743000,42.002320,, +,,1.744660,42.001771,, +,,1.746320,41.998972,, +,,1.747980,41.987663,, +,,1.749640,42.001663,, +,,1.751300,42.006090,, +,,1.752960,41.995086,, +,,1.754620,41.998949,, +,,1.756280,42.000816,, +,,1.757940,42.001781,, +,,1.759600,41.991998,, +,,1.761260,42.001680,, +,,1.762920,41.997407,, +,,1.764580,42.002656,, +,,1.766240,42.002820,, +,,1.767900,41.996163,, +,,1.769560,42.005712,, +,,1.771220,41.999380,, +,,1.772880,42.001912,, +,,1.774540,42.004658,, +,,1.776200,42.004161,, +,,1.777860,42.006179,, +,,1.779520,41.993913,, +,,1.781180,41.998014,, +,,1.782840,42.004713,, +,,1.784500,41.996966,, +,,1.786160,41.999717,, +,,1.787820,41.995889,, +,,1.789480,42.004574,, +,,1.791140,41.994274,, +,,1.792800,41.998723,, +,,1.794460,42.002927,, +,,1.796120,41.996202,, +,,1.797780,42.001377,, +,,1.799440,42.007568,, +,,1.801100,41.994122,, +,,1.802760,42.006479,, +,,1.804420,42.001067,, +,,1.806080,42.001107,, +,,1.807740,42.002975,, +,,1.809400,41.999322,, +,,1.811060,41.998905,, +,,1.812720,41.995417,, +,,1.814380,42.003802,, +,,1.816040,41.999089,, +,,1.817700,42.003606,, +,,1.819360,42.001487,, +,,1.821020,42.001356,, +,,1.822680,41.993376,, +,,1.824340,41.995286,, +,,1.826000,42.000948,, +,,1.827660,41.999075,, +,,1.829320,41.998183,, +,,1.830980,41.998244,, +,,1.832640,42.004890,, +,,1.834300,41.995178,, +,,1.835960,41.998613,, +,,1.837620,41.996863,, +,,1.839280,41.992950,, +,,1.840940,42.001509,, +,,1.842600,41.997782,, +,,1.844260,42.006699,, +,,1.845920,41.999130,, +,,1.847580,42.002085,, +,,1.849240,41.997327,, +,,1.850900,41.998356,, +,,1.852560,41.999372,, +,,1.854220,41.996796,, +,,1.855880,41.997623,, +,,1.857540,41.994237,, +,,1.859200,41.997207,, +,,1.860860,41.992716,, +,,1.862520,42.003033,, +,,1.864180,42.003063,, +,,1.865840,42.000129,, +,,1.867500,41.996634,, +,,1.869160,42.001931,, +,,1.870820,41.995748,, +,,1.872480,41.998460,, +,,1.874140,42.002510,, +,,1.875800,42.006830,, +,,1.877460,42.000922,, +,,1.879120,41.998118,, +,,1.880780,41.999827,, +,,1.882440,42.005490,, +,,1.884100,41.993110,, +,,1.885760,42.007441,, +,,1.887420,41.994196,, +,,1.889080,42.009218,, +,,1.890740,42.002212,, +,,1.892400,41.999488,, +,,1.894060,42.001703,, +,,1.895720,41.999690,, +,,1.897380,41.993963,, +,,1.899040,42.004248,, +,,1.900700,42.005482,, +,,1.902360,41.995758,, +,,1.904020,42.007171,, +,,1.905680,42.001546,, +,,1.907340,42.002964,, +,,1.909000,41.998337,, +,,1.910660,41.993627,, +,,1.912320,42.001345,, +,,1.913980,42.000760,, +,,1.915640,41.997451,, +,,1.917300,41.996663,, +,,1.918960,42.000786,, +,,1.920620,41.997563,, +,,1.922280,42.001737,, +,,1.923940,41.998228,, +,,1.925600,41.995856,, +,,1.927260,42.000333,, +,,1.928920,41.998436,, +,,1.930580,42.000819,, +,,1.932240,42.000179,, +,,1.933900,41.997964,, +,,1.935560,42.004242,, +,,1.937220,42.007231,, +,,1.938880,41.992192,, +,,1.940540,41.997444,, +,,1.942200,41.995193,, +,,1.943860,42.008641,, +,,1.945520,42.000627,, +,,1.947180,42.007119,, +,,1.948840,42.000429,, +,,1.950500,41.994734,, +,,1.952160,41.998626,, +,,1.953820,41.996734,, +,,1.955480,42.000245,, +,,1.957140,41.995345,, +,,1.958800,42.002063,, +,,1.960460,42.002276,, +,,1.962120,42.001944,, +,,1.963780,42.001269,, +,,1.965440,41.999701,, +,,1.967100,41.997871,, +,,1.968760,42.001775,, +,,1.970420,42.001887,, +,,1.972080,41.997330,, +,,1.973740,41.999860,, +,,1.975400,41.994284,, +,,1.977060,42.001727,, +,,1.978720,42.007025,, +,,1.980380,42.006581,, +,,1.982040,42.002333,, +,,1.983700,41.998220,, +,,1.985360,41.996059,, +,,1.987020,41.996717,, +,,1.988680,42.000235,, +,,1.990340,41.998147,, +,,1.992000,42.002420,, +,,1.993660,41.999081,, +,,1.995320,41.992059,, +,,1.996980,41.997176,, +,,1.998640,41.997605,, +,,2.000300,41.992890,, +,,2.001960,41.996394,, +,,2.003620,41.999105,, +,,2.005280,42.000759,, +,,2.006940,42.000733,, +,,2.008600,42.006379,, +,,2.010260,42.006620,, +,,2.011920,41.998880,, +,,2.013580,42.000240,, +,,2.015240,41.999177,, +,,2.016900,41.998383,, +,,2.018560,42.001423,, +,,2.020220,41.998323,, +,,2.021880,41.996072,, +,,2.023540,42.007642,, +,,2.025200,42.003297,, +,,2.026860,42.005886,, +,,2.028520,41.994067,, +,,2.030180,41.991749,, +,,2.031840,42.007353,, +,,2.033500,41.996371,, +,,2.035160,41.997356,, +,,2.036820,42.003643,, +,,2.038480,41.998465,, +,,2.040140,41.998687,, +,,2.041800,42.005422,, +,,2.043460,42.000589,, +,,2.045120,42.006250,, +,,2.046780,41.998126,, +,,2.048440,41.999880,, +,,2.050100,41.997872,, +,,2.051760,41.997020,, +,,2.053420,42.004909,, +,,2.055080,41.995586,, +,,2.056740,42.002160,, +,,2.058400,42.006277,, +,,2.060060,41.997406,, +,,2.061720,41.999177,, +,,2.063380,42.002849,, +,,2.065040,42.000555,, +,,2.066700,42.003823,, +,,2.068360,41.998071,, +,,2.070020,42.003873,, +,,2.071680,42.000557,, +,,2.073340,41.998811,, +,,2.075000,41.995741,, +,,2.076660,42.003499,, +,,2.078320,42.006185,, +,,2.079980,42.000021,, +,,2.081640,41.997348,, +,,2.083300,41.999868,, +,,2.084960,42.000649,, +,,2.086620,42.005735,, +,,2.088280,42.007822,, +,,2.089940,42.000696,, +,,2.091600,41.998198,, +,,2.093260,42.000024,, +,,2.094920,41.992686,, +,,2.096580,42.000731,, +,,2.098240,42.002745,, +,,2.099900,42.003001,, +,,2.101560,42.006214,, +,,2.103220,41.995172,, +,,2.104880,42.000940,, +,,2.106540,42.001633,, +,,2.108200,42.001003,, +,,2.109860,41.999398,, +,,2.111520,41.995221,, +,,2.113180,42.001147,, +,,2.114840,42.002345,, +,,2.116500,41.992543,, +,,2.118160,42.004910,, +,,2.119820,41.993192,, +,,2.121480,42.004374,, +,,2.123140,41.996489,, +,,2.124800,41.998821,, +,,2.126460,42.002229,, +,,2.128120,41.995963,, +,,2.129780,41.998004,, +,,2.131440,42.001297,, +,,2.133100,42.003525,, +,,2.134760,42.000876,, +,,2.136420,41.997724,, +,,2.138080,41.994392,, +,,2.139740,41.999671,, +,,2.141400,41.997483,, +,,2.143060,41.990079,, +,,2.144720,42.001869,, +,,2.146380,42.004928,, +,,2.148040,42.002536,, +,,2.149700,41.995106,, +,,2.151360,42.001044,, +,,2.153020,42.000782,, +,,2.154680,41.993716,, +,,2.156340,41.995012,, +,,2.158000,42.004890,, +,,2.159660,42.000385,, +,,2.161320,42.003739,, +,,2.162980,41.990955,, +,,2.164640,42.002675,, +,,2.166300,41.992350,, +,,2.167960,41.999340,, +,,2.169620,42.001309,, +,,2.171280,41.998732,, +,,2.172940,42.000204,, +,,2.174600,41.999615,, +,,2.176260,41.995415,, +,,2.177920,42.001754,, +,,2.179580,42.001262,, +,,2.181240,41.992813,, +,,2.182900,41.999061,, +,,2.184560,42.000747,, +,,2.186220,42.002150,, +,,2.187880,42.000863,, +,,2.189540,41.989830,, +,,2.191200,41.993111,, +,,2.192860,42.005838,, +,,2.194520,41.998767,, +,,2.196180,42.000812,, +,,2.197840,41.998530,, +,,2.199500,42.003322,, +,,2.201160,41.989113,, +,,2.202820,42.003259,, +,,2.204480,41.996702,, +,,2.206140,42.000303,, +,,2.207800,41.988649,, +,,2.209460,41.997665,, +,,2.211120,42.004284,, +,,2.212780,42.006769,, +,,2.214440,41.996619,, +,,2.216100,42.008797,, +,,2.217760,42.005465,, +,,2.219420,41.995147,, +,,2.221080,41.997223,, +,,2.222740,41.999522,, +,,2.224400,42.003120,, +,,2.226060,42.003094,, +,,2.227720,42.003377,, +,,2.229380,41.992200,, +,,2.231040,41.998730,, +,,2.232700,42.005848,, +,,2.234360,42.001446,, +,,2.236020,41.998579,, +,,2.237680,42.000839,, +,,2.239340,42.002317,, +,,2.241000,41.995652,, +,,2.242660,41.999193,, +,,2.244320,41.997861,, +,,2.245980,42.000438,, +,,2.247640,41.998119,, +,,2.249300,42.002642,, +,,2.250960,41.999617,, +,,2.252620,41.996693,, +,,2.254280,41.996345,, +,,2.255940,42.000070,, +,,2.257600,42.004093,, +,,2.259260,42.000639,, +,,2.260920,42.002591,, +,,2.262580,42.000292,, +,,2.264240,42.007564,, +,,2.265900,42.000244,, +,,2.267560,42.005940,, +,,2.269220,41.999924,, +,,2.270880,42.003335,, +,,2.272540,41.995651,, +,,2.274200,42.000985,, +,,2.275860,42.002789,, +,,2.277520,42.001011,, +,,2.279180,42.001142,, +,,2.280840,41.998968,, +,,2.282500,41.997715,, +,,2.284160,41.998636,, +,,2.285820,42.000792,, +,,2.287480,41.999456,, +,,2.289140,42.000746,, +,,2.290800,42.001495,, +,,2.292460,41.990129,, +,,2.294120,42.003289,, +,,2.295780,41.997777,, +,,2.297440,41.995090,, +,,2.299100,42.000772,, +,,2.300760,41.996837,, +,,2.302420,42.003226,, +,,2.304080,41.998749,, +,,2.305740,42.000234,, +,,2.307400,41.998622,, +,,2.309060,41.999963,, +,,2.310720,42.005181,, +,,2.312380,42.004844,, +,,2.314040,41.998124,, +,,2.315700,42.005054,, +,,2.317360,41.998508,, +,,2.319020,41.997768,, +,,2.320680,42.001574,, +,,2.322340,42.003761,, +,,2.324000,42.008641,, +,,2.325660,42.007244,, +,,2.327320,41.998135,, +,,2.328980,41.994960,, +,,2.330640,42.003884,, +,,2.332300,42.002156,, +,,2.333960,42.002340,, +,,2.335620,41.998845,, +,,2.337280,42.010248,, +,,2.338940,42.006003,, +,,2.340600,41.994363,, +,,2.342260,41.999566,, +,,2.343920,42.000813,, +,,2.345580,41.998977,, +,,2.347240,41.998813,, +,,2.348900,42.000823,, +,,2.350560,41.999688,, +,,2.352220,41.995591,, +,,2.353880,41.995455,, +,,2.355540,41.998201,, +,,2.357200,41.999213,, +,,2.358860,41.994317,, +,,2.360520,41.996593,, +,,2.362180,42.002077,, +,,2.363840,41.996394,, +,,2.365500,41.999795,, +,,2.367160,42.004360,, +,,2.368820,41.999198,, +,,2.370480,41.999968,, +,,2.372140,41.997083,, +,,2.373800,41.996668,, +,,2.375460,41.999618,, +,,2.377120,41.999684,, +,,2.378780,41.998088,, +,,2.380440,42.002048,, +,,2.382100,41.997085,, +,,2.383760,42.000540,, +,,2.385420,41.996296,, +,,2.387080,42.001980,, +,,2.388740,42.002396,, +,,2.390400,42.006414,, +,,2.392060,41.996400,, +,,2.393720,42.003600,, +,,2.395380,42.001188,, +,,2.397040,42.002394,, +,,2.398700,42.001253,, +,,2.400360,42.004969,, +,,2.402020,42.002807,, +,,2.403680,42.001730,, +,,2.405340,41.997815,, +,,2.407000,41.997613,, +,,2.408660,41.995632,, +,,2.410320,42.001626,, +,,2.411980,42.001502,, +,,2.413640,42.005423,, +,,2.415300,41.998501,, +,,2.416960,41.998079,, +,,2.418620,42.002081,, +,,2.420280,41.995926,, +,,2.421940,41.995933,, +,,2.423600,41.993076,, +,,2.425260,41.998351,, +,,2.426920,41.996171,, +,,2.428580,41.998837,, +,,2.430240,42.004128,, +,,2.431900,42.001302,, +,,2.433560,41.994783,, +,,2.435220,42.002445,, +,,2.436880,41.998797,, +,,2.438540,41.995348,, +,,2.440200,42.001806,, +,,2.441860,41.997370,, +,,2.443520,41.999823,, +,,2.445180,41.995420,, +,,2.446840,41.993569,, +,,2.448500,42.002907,, +,,2.450160,41.998207,, +,,2.451820,41.998559,, +,,2.453480,41.992304,, +,,2.455140,41.999955,, +,,2.456800,42.002549,, +,,2.458460,42.001881,, +,,2.460120,41.999779,, +,,2.461780,41.998115,, +,,2.463440,42.002595,, +,,2.465100,42.001280,, +,,2.466760,42.009615,, +,,2.468420,42.004556,, +,,2.470080,41.999964,, +,,2.471740,41.996257,, +,,2.473400,42.004054,, +,,2.475060,42.000452,, +,,2.476720,42.003806,, +,,2.478380,41.996905,, +,,2.480040,42.000849,, +,,2.481700,42.001287,, +,,2.483360,41.996051,, +,,2.485020,41.999726,, +,,2.486680,41.998286,, +,,2.488340,42.001606,, +,,2.490000,42.005537,, +,,2.491660,41.998200,, +,,2.493320,42.001538,, +,,2.494980,42.004282,, +,,2.496640,41.999801,, +,,2.498300,42.004633,, +,,2.499960,41.997776,, +,,2.501620,41.999813,, +,,2.503280,42.007229,, +,,2.504940,41.998223,, +,,2.506600,42.003369,, +,,2.508260,42.002028,, +,,2.509920,42.008107,, +,,2.511580,42.000300,, +,,2.513240,41.996514,, +,,2.514900,41.996847,, +,,2.516560,41.996854,, +,,2.518220,42.002426,, +,,2.519880,41.998160,, +,,2.521540,41.994961,, +,,2.523200,42.000446,, +,,2.524860,41.997537,, +,,2.526520,41.997734,, +,,2.528180,42.003352,, +,,2.529840,41.999522,, +,,2.531500,41.999340,, +,,2.533160,41.997381,, +,,2.534820,41.992923,, +,,2.536480,41.996428,, +,,2.538140,41.999045,, +,,2.539800,41.998988,, +,,2.541460,41.994608,, +,,2.543120,41.997627,, +,,2.544780,42.005611,, +,,2.546440,42.003908,, +,,2.548100,41.999377,, +,,2.549760,42.000907,, +,,2.551420,42.006459,, +,,2.553080,42.002938,, +,,2.554740,41.998192,, +,,2.556400,41.996296,, +,,2.558060,42.001939,, +,,2.559720,41.999889,, +,,2.561380,41.994933,, +,,2.563040,42.008495,, +,,2.564700,41.999795,, +,,2.566360,42.003424,, +,,2.568020,42.002742,, +,,2.569680,41.999887,, +,,2.571340,41.999921,, +,,2.573000,41.996134,, +,,2.574660,41.995582,, +,,2.576320,41.999235,, +,,2.577980,42.002355,, +,,2.579640,42.002084,, +,,2.581300,41.992821,, +,,2.582960,41.996649,, +,,2.584620,42.001574,, +,,2.586280,41.999773,, +,,2.587940,41.993337,, +,,2.589600,41.994703,, +,,2.591260,41.992897,, +,,2.592920,42.003592,, +,,2.594580,42.002419,, +,,2.596240,41.995258,, +,,2.597900,42.001783,, +,,2.599560,41.995601,, +,,2.601220,42.002167,, +,,2.602880,42.001030,, +,,2.604540,42.002716,, +,,2.606200,41.988533,, +,,2.607860,41.994189,, +,,2.609520,42.000209,, +,,2.611180,42.004863,, +,,2.612840,42.002974,, +,,2.614500,41.993270,, +,,2.616160,42.005647,, +,,2.617820,42.000009,, +,,2.619480,42.000317,, +,,2.621140,42.004019,, +,,2.622800,41.997388,, +,,2.624460,42.000594,, +,,2.626120,41.999020,, +,,2.627780,41.993220,, +,,2.629440,41.994997,, +,,2.631100,41.994704,, +,,2.632760,42.004670,, +,,2.634420,42.002922,, +,,2.636080,41.998977,, +,,2.637740,42.004363,, +,,2.639400,41.996975,, +,,2.641060,42.003928,, +,,2.642720,42.006815,, +,,2.644380,42.000249,, +,,2.646040,42.005098,, +,,2.647700,41.989072,, +,,2.649360,42.001664,, +,,2.651020,41.993722,, +,,2.652680,42.004748,, +,,2.654340,42.007957,, +,,2.656000,41.994879,, +,,2.657660,42.002740,, +,,2.659320,41.997180,, +,,2.660980,42.002266,, +,,2.662640,41.998404,, +,,2.664300,42.003381,, +,,2.665960,41.994233,, +,,2.667620,41.997686,, +,,2.669280,41.999584,, +,,2.670940,42.000480,, +,,2.672600,41.991828,, +,,2.674260,41.997399,, +,,2.675920,42.002403,, +,,2.677580,41.997273,, +,,2.679240,42.003297,, +,,2.680900,42.001949,, +,,2.682560,41.997841,, +,,2.684220,41.997114,, +,,2.685880,42.000608,, +,,2.687540,41.999561,, +,,2.689200,41.996992,, +,,2.690860,41.995347,, +,,2.692520,42.003925,, +,,2.694180,42.005081,, +,,2.695840,41.994473,, +,,2.697500,42.003696,, +,,2.699160,41.997917,, +,,2.700820,42.004420,, +,,2.702480,42.000552,, +,,2.704140,41.997063,, +,,2.705800,41.990654,, +,,2.707460,42.006280,, +,,2.709120,42.002268,, +,,2.710780,42.005539,, +,,2.712440,41.999537,, +,,2.714100,42.004186,, +,,2.715760,41.998457,, +,,2.717420,42.001997,, +,,2.719080,42.003884,, +,,2.720740,42.008696,, +,,2.722400,41.995680,, +,,2.724060,42.003162,, +,,2.725720,42.000325,, +,,2.727380,41.994638,, +,,2.729040,41.998667,, +,,2.730700,42.005493,, +,,2.732360,41.998392,, +,,2.734020,42.003854,, +,,2.735680,42.004721,, +,,2.737340,42.004004,, +,,2.739000,41.998249,, +,,2.740660,41.997702,, +,,2.742320,42.000932,, +,,2.743980,42.000086,, +,,2.745640,41.995347,, +,,2.747300,41.999645,, +,,2.748960,41.996140,, +,,2.750620,41.994719,, +,,2.752280,41.995507,, +,,2.753940,42.010816,, +,,2.755600,42.006512,, +,,2.757260,42.001250,, +,,2.758920,42.001009,, +,,2.760580,42.000334,, +,,2.762240,42.007300,, +,,2.763900,42.000647,, +,,2.765560,41.997655,, +,,2.767220,41.995664,, +,,2.768880,42.003014,, +,,2.770540,42.003232,, +,,2.772200,42.005810,, +,,2.773860,42.002041,, +,,2.775520,42.007918,, +,,2.777180,42.001951,, +,,2.778840,42.005557,, +,,2.780500,42.005957,, +,,2.782160,41.999344,, +,,2.783820,42.006576,, +,,2.785480,41.991170,, +,,2.787140,41.998473,, +,,2.788800,42.002754,, +,,2.790460,41.999568,, +,,2.792120,42.009291,, +,,2.793780,41.995410,, +,,2.795440,41.994383,, +,,2.797100,41.998447,, +,,2.798760,42.002109,, +,,2.800420,41.997417,, +,,2.802080,42.000637,, +,,2.803740,41.993954,, +,,2.805400,42.001091,, +,,2.807060,42.002106,, +,,2.808720,42.003233,, +,,2.810380,42.002677,, +,,2.812040,42.000970,, +,,2.813700,41.998932,, +,,2.815360,41.996035,, +,,2.817020,42.001345,, +,,2.818680,42.001003,, +,,2.820340,42.001582,, +,,2.822000,42.001112,, +,,2.823660,41.998423,, +,,2.825320,41.998798,, +,,2.826980,41.996712,, +,,2.828640,42.000953,, +,,2.830300,42.004160,, +,,2.831960,41.999011,, +,,2.833620,42.004016,, +,,2.835280,42.000900,, +,,2.836940,41.999516,, +,,2.838600,42.002757,, +,,2.840260,42.007661,, +,,2.841920,42.006028,, +,,2.843580,41.993527,, +,,2.845240,42.000701,, +,,2.846900,41.998585,, +,,2.848560,41.999474,, +,,2.850220,42.000423,, +,,2.851880,42.004046,, +,,2.853540,41.994180,, +,,2.855200,41.994096,, +,,2.856860,42.006656,, +,,2.858520,41.995949,, +,,2.860180,41.997967,, +,,2.861840,42.002329,, +,,2.863500,41.992793,, +,,2.865160,41.999139,, +,,2.866820,41.999106,, +,,2.868480,42.003122,, +,,2.870140,42.007026,, +,,2.871800,41.998471,, +,,2.873460,41.991644,, +,,2.875120,41.999305,, +,,2.876780,42.001547,, +,,2.878440,41.996732,, +,,2.880100,41.999277,, +,,2.881760,42.001301,, +,,2.883420,42.003380,, +,,2.885080,42.003043,, +,,2.886740,41.998116,, +,,2.888400,41.996661,, +,,2.890060,41.999154,, +,,2.891720,41.994568,, +,,2.893380,41.996463,, +,,2.895040,42.004043,, +,,2.896700,42.003022,, +,,2.898360,42.004309,, +,,2.900020,42.007307,, +,,2.901680,42.000772,, +,,2.903340,42.001603,, +,,2.905000,42.009075,, +,,2.906660,41.999850,, +,,2.908320,41.994234,, +,,2.909980,41.998625,, +,,2.911640,42.001389,, +,,2.913300,42.001225,, +,,2.914960,41.992851,, +,,2.916620,42.001224,, +,,2.918280,41.997992,, +,,2.919940,42.003860,, +,,2.921600,42.001467,, +,,2.923260,41.996082,, +,,2.924920,41.999466,, +,,2.926580,42.002486,, +,,2.928240,41.992886,, +,,2.929900,41.993985,, +,,2.931560,41.999378,, +,,2.933220,41.995951,, +,,2.934880,41.998443,, +,,2.936540,42.000059,, +,,2.938200,41.993431,, +,,2.939860,42.006553,, +,,2.941520,41.998524,, +,,2.943180,42.001839,, +,,2.944840,41.992581,, +,,2.946500,41.997149,, +,,2.948160,42.006280,, +,,2.949820,41.998334,, +,,2.951480,42.001337,, +,,2.953140,41.998639,, +,,2.954800,42.003556,, +,,2.956460,42.004958,, +,,2.958120,41.994823,, +,,2.959780,42.001087,, +,,2.961440,42.003677,, +,,2.963100,41.997287,, +,,2.964760,42.004568,, +,,2.966420,42.004804,, +,,2.968080,42.000917,, +,,2.969740,42.001474,, +,,2.971400,41.997414,, +,,2.973060,42.000252,, +,,2.974720,41.994437,, +,,2.976380,41.993777,, +,,2.978040,42.001102,, +,,2.979700,41.996632,, +,,2.981360,42.001983,, +,,2.983020,41.998466,, +,,2.984680,41.996658,, +,,2.986340,41.998734,, +,,2.988000,41.997085,, diff --git a/examples/data/characterization_akta/large_tracer_pulse_2_uv.csv b/examples/data/characterization_akta/large_tracer_pulse_2_uv.csv new file mode 100644 index 0000000..b48b643 --- /dev/null +++ b/examples/data/characterization_akta/large_tracer_pulse_2_uv.csv @@ -0,0 +1,1804 @@ +Chromatogram,,,,, +UV 1_280,,Cond,,Run Log, +ml,mAU,ml,mS/cm,ml, +0.000000,4.072282,0.000000,41.996924,0.000000,Phase Equilibration +0.004150,4.515309,0.001660,41.989491,0.498000,Phase Elution +0.008300,3.840014,0.003320,41.997595,2.539800,Phase Wash +0.012450,3.193285,0.004980,41.995289,, +0.016600,4.887893,0.006640,42.010618,, +0.020750,4.234959,0.008300,41.999533,, +0.024900,3.903965,0.009960,42.000564,, +0.029050,3.475703,0.011620,41.998433,, +0.033200,4.371913,0.013280,41.999193,, +0.037350,3.275306,0.014940,42.003004,, +0.041500,4.520291,0.016600,42.006524,, +0.045650,4.062473,0.018260,41.994051,, +0.049800,3.683850,0.019920,42.003635,, +0.053950,4.329404,0.021580,42.002039,, +0.058100,4.273422,0.023240,41.996879,, +0.062250,3.353983,0.024900,41.992203,, +0.066400,3.670586,0.026560,42.008402,, +0.070550,3.666831,0.028220,41.999182,, +0.074700,3.476388,0.029880,42.003315,, +0.078850,4.487701,0.031540,41.998788,, +0.083000,4.645280,0.033200,41.996478,, +0.087150,4.906401,0.034860,41.994034,, +0.091300,3.153071,0.036520,42.004418,, +0.095450,4.333394,0.038180,42.000841,, +0.099600,4.011552,0.039840,42.003525,, +0.103750,3.040261,0.041500,41.997154,, +0.107900,4.054427,0.043160,42.000010,, +0.112050,3.336885,0.044820,42.001349,, +0.116200,4.250667,0.046480,42.005713,, +0.120350,3.581096,0.048140,41.994166,, +0.124500,4.322859,0.049800,42.000605,, +0.128650,3.987356,0.051460,41.995050,, +0.132800,3.609758,0.053120,42.005672,, +0.136950,4.379184,0.054780,42.001639,, +0.141100,3.831101,0.056440,42.002852,, +0.145250,2.800666,0.058100,42.002827,, +0.149400,4.510497,0.059760,42.001929,, +0.153550,2.902591,0.061420,42.003364,, +0.157700,3.819869,0.063080,42.004220,, +0.161850,4.543299,0.064740,42.000916,, +0.166000,3.808546,0.066400,41.997305,, +0.170150,4.196027,0.068060,42.000887,, +0.174300,4.499320,0.069720,42.003011,, +0.178450,4.351559,0.071380,41.998198,, +0.182600,3.953202,0.073040,42.004428,, +0.186750,4.453424,0.074700,41.998088,, +0.190900,3.489697,0.076360,42.009090,, +0.195050,4.193479,0.078020,41.995940,, +0.199200,4.073147,0.079680,41.997828,, +0.203350,3.528704,0.081340,41.996751,, +0.207500,4.757348,0.083000,42.001490,, +0.211650,2.981617,0.084660,42.000793,, +0.215800,4.285693,0.086320,42.009372,, +0.219950,3.934376,0.087980,42.001250,, +0.224100,4.721869,0.089640,42.001927,, +0.228250,4.025151,0.091300,42.000788,, +0.232400,3.211574,0.092960,42.001171,, +0.236550,3.935351,0.094620,42.003590,, +0.240700,4.715263,0.096280,42.002832,, +0.244850,3.874757,0.097940,42.000298,, +0.249000,3.712816,0.099600,41.997694,, +0.253150,3.636070,0.101260,42.002421,, +0.257300,3.831188,0.102920,41.997234,, +0.261450,3.947918,0.104580,42.004109,, +0.265600,4.313738,0.106240,41.998112,, +0.269750,4.492231,0.107900,42.005619,, +0.273900,3.833081,0.109560,41.996268,, +0.278050,3.966729,0.111220,41.994643,, +0.282200,4.211933,0.112880,42.000848,, +0.286350,4.221584,0.114540,41.999775,, +0.290500,3.853438,0.116200,41.994869,, +0.294650,3.325579,0.117860,42.003008,, +0.298800,4.765851,0.119520,41.992776,, +0.302950,3.402121,0.121180,41.996311,, +0.307100,4.075903,0.122840,41.995771,, +0.311250,4.275338,0.124500,42.000829,, +0.315400,4.945460,0.126160,41.995670,, +0.319550,5.104569,0.127820,41.998930,, +0.323700,3.707179,0.129480,42.006153,, +0.327850,4.022706,0.131140,41.997141,, +0.332000,3.905306,0.132800,42.000095,, +0.336150,3.659411,0.134460,41.989637,, +0.340300,4.593476,0.136120,41.997273,, +0.344450,3.561358,0.137780,41.990845,, +0.348600,4.629866,0.139440,42.003535,, +0.352750,3.766987,0.141100,42.002679,, +0.356900,3.568162,0.142760,42.003413,, +0.361050,3.785173,0.144420,41.995574,, +0.365200,3.496302,0.146080,41.999686,, +0.369350,4.235865,0.147740,42.002278,, +0.373500,3.964606,0.149400,41.997541,, +0.377650,4.437279,0.151060,41.994667,, +0.381800,3.891782,0.152720,42.002660,, +0.385950,3.242579,0.154380,42.006961,, +0.390100,3.234604,0.156040,41.997283,, +0.394250,4.054033,0.157700,42.005604,, +0.398400,3.719698,0.159360,42.007626,, +0.402550,3.970772,0.161020,41.995631,, +0.406700,4.019382,0.162680,41.997500,, +0.410850,4.384621,0.164340,42.009266,, +0.415000,4.464082,0.166000,42.001056,, +0.419150,4.048264,0.167660,42.002092,, +0.423300,3.749108,0.169320,42.000951,, +0.427450,4.032707,0.170980,41.997200,, +0.431600,4.361771,0.172640,42.005314,, +0.435750,4.284258,0.174300,41.998970,, +0.439900,4.540061,0.175960,42.003687,, +0.444050,3.578108,0.177620,41.995446,, +0.448200,3.887969,0.179280,41.997974,, +0.452350,3.870576,0.180940,41.997335,, +0.456500,3.957611,0.182600,42.008248,, +0.460650,3.204004,0.184260,41.999627,, +0.464800,4.442815,0.185920,42.000558,, +0.468950,4.271061,0.187580,41.999122,, +0.473100,2.833806,0.189240,42.002505,, +0.477250,3.857781,0.190900,42.003729,, +0.481400,3.684219,0.192560,42.007053,, +0.485550,4.182630,0.194220,42.001882,, +0.489700,4.247091,0.195880,42.007406,, +0.493850,4.878668,0.197540,42.006929,, +0.498000,3.911004,0.199200,42.005121,, +0.502150,4.183884,0.200860,41.998558,, +0.506300,4.132070,0.202520,42.002964,, +0.510450,3.763835,0.204180,41.996193,, +0.514600,3.991985,0.205840,42.001451,, +0.518750,4.874157,0.207500,41.999306,, +0.522900,3.526135,0.209160,41.997554,, +0.527050,3.959873,0.210820,42.005977,, +0.531200,3.080894,0.212480,41.994228,, +0.535350,3.628872,0.214140,42.003167,, +0.539500,3.728310,0.215800,41.994725,, +0.543650,2.904621,0.217460,41.999010,, +0.547800,4.387060,0.219120,41.991068,, +0.551950,4.706401,0.220780,42.005026,, +0.556100,3.882165,0.222440,42.000876,, +0.560250,3.273627,0.224100,42.001337,, +0.564400,3.970766,0.225760,41.997804,, +0.568550,2.613831,0.227420,42.001216,, +0.572700,3.705670,0.229080,41.991384,, +0.576850,3.965177,0.230740,41.990200,, +0.581000,3.594437,0.232400,41.998817,, +0.585150,4.364272,0.234060,41.995703,, +0.589300,4.076791,0.235720,41.995600,, +0.593450,4.364263,0.237380,41.995702,, +0.597600,3.829080,0.239040,42.001861,, +0.601750,4.505528,0.240700,41.997007,, +0.605900,4.076009,0.242360,41.997781,, +0.610050,3.716548,0.244020,41.999900,, +0.614200,3.069881,0.245680,41.999135,, +0.618350,3.946423,0.247340,41.997210,, +0.622500,3.895005,0.249000,42.000818,, +0.626650,3.647270,0.250660,41.993811,, +0.630800,3.847864,0.252320,41.998318,, +0.634950,3.754894,0.253980,42.001690,, +0.639100,4.345086,0.255640,42.003517,, +0.643250,3.974174,0.257300,41.992143,, +0.647400,4.491400,0.258960,41.997149,, +0.651550,3.669451,0.260620,42.002201,, +0.655700,2.855558,0.262280,42.000381,, +0.659850,3.160818,0.263940,42.001221,, +0.664000,3.612685,0.265600,41.998589,, +0.668150,3.691952,0.267260,42.002426,, +0.672300,3.645713,0.268920,42.000780,, +0.676450,4.357379,0.270580,42.002102,, +0.680600,3.907085,0.272240,42.001153,, +0.684750,4.188733,0.273900,41.999291,, +0.688900,4.618990,0.275560,42.005636,, +0.693050,3.413803,0.277220,42.000516,, +0.697200,4.470824,0.278880,41.999050,, +0.701350,3.297921,0.280540,41.993884,, +0.705500,3.932984,0.282200,41.999542,, +0.709650,4.421767,0.283860,42.000089,, +0.713800,4.582955,0.285520,42.000224,, +0.717950,3.673123,0.287180,41.997589,, +0.722100,3.978758,0.288840,42.002827,, +0.726250,5.027906,0.290500,41.999233,, +0.730400,4.037758,0.292160,41.994663,, +0.734550,4.589030,0.293820,41.999508,, +0.738700,4.157634,0.295480,42.004302,, +0.742850,3.359011,0.297140,41.994145,, +0.747000,4.572278,0.298800,41.995971,, +0.751150,4.427941,0.300460,42.005635,, +0.755300,3.575827,0.302120,42.000658,, +0.759450,3.810238,0.303780,42.000780,, +0.763600,3.899377,0.305440,42.001136,, +0.767750,3.333645,0.307100,42.002365,, +0.771900,4.232725,0.308760,41.992944,, +0.776050,3.266977,0.310420,42.001525,, +0.780200,3.896870,0.312080,41.998772,, +0.784350,3.913969,0.313740,42.000866,, +0.788500,3.541023,0.315400,41.994773,, +0.792650,3.893501,0.317060,41.999116,, +0.796800,4.008142,0.318720,41.997863,, +0.800950,4.337621,0.320380,42.001326,, +0.805100,4.136656,0.322040,42.002704,, +0.809250,3.732698,0.323700,41.997570,, +0.813400,3.710900,0.325360,41.996098,, +0.817550,4.465937,0.327020,42.000526,, +0.821700,4.807619,0.328680,42.001137,, +0.825850,8.763203,0.330340,41.996838,, +0.830000,11.526699,0.332000,41.994724,, +0.834150,27.088215,0.333660,41.988431,, +0.838300,44.015632,0.335320,41.995049,, +0.842450,77.304710,0.336980,41.999031,, +0.846600,110.701034,0.338640,42.000216,, +0.850750,145.944912,0.340300,42.000111,, +0.854900,180.311185,0.341960,42.005932,, +0.859050,185.153872,0.343620,42.001440,, +0.863200,187.345649,0.345280,41.994288,, +0.867350,158.438877,0.346940,41.993066,, +0.871500,130.129078,0.348600,41.996633,, +0.875650,95.412934,0.350260,41.992094,, +0.879800,62.011081,0.351920,41.993690,, +0.883950,42.117567,0.353580,42.002319,, +0.888100,22.214274,0.355240,41.993208,, +0.892250,15.764560,0.356900,41.995907,, +0.896400,7.699569,0.358560,41.999045,, +0.900550,6.084373,0.360220,41.993473,, +0.904700,5.292798,0.361880,42.002248,, +0.908850,3.853231,0.363540,41.997223,, +0.913000,4.580791,0.365200,42.005253,, +0.917150,3.745025,0.366860,42.000327,, +0.921300,3.321351,0.368520,42.000809,, +0.925450,3.995513,0.370180,41.999148,, +0.929600,3.878262,0.371840,42.001509,, +0.933750,3.607230,0.373500,41.997960,, +0.937900,2.963101,0.375160,41.998889,, +0.942050,3.963279,0.376820,42.002281,, +0.946200,4.331130,0.378480,41.994983,, +0.950350,4.392630,0.380140,42.007991,, +0.954500,4.677236,0.381800,41.999263,, +0.958650,3.946274,0.383460,41.997084,, +0.962800,3.948959,0.385120,42.005128,, +0.966950,3.213486,0.386780,42.003383,, +0.971100,4.163841,0.388440,41.994953,, +0.975250,4.401743,0.390100,42.005395,, +0.979400,4.803528,0.391760,42.000882,, +0.983550,3.780414,0.393420,41.994749,, +0.987700,2.824003,0.395080,42.005219,, +0.991850,3.816171,0.396740,42.003456,, +0.996000,4.523565,0.398400,41.996535,, +1.000150,4.356523,0.400060,42.001381,, +1.004300,4.130192,0.401720,42.002872,, +1.008450,3.934508,0.403380,41.999471,, +1.012600,4.315091,0.405040,41.995633,, +1.016750,4.064733,0.406700,41.995644,, +1.020900,4.400114,0.408360,41.998676,, +1.025050,3.599041,0.410020,42.007511,, +1.029200,4.330146,0.411680,41.998492,, +1.033350,4.293194,0.413340,42.003687,, +1.037500,5.154372,0.415000,41.998340,, +1.041650,3.329590,0.416660,41.994572,, +1.045800,3.505016,0.418320,41.993655,, +1.049950,4.089931,0.419980,42.001285,, +1.054100,4.399325,0.421640,42.002755,, +1.058250,3.895842,0.423300,41.995610,, +1.062400,3.913003,0.424960,42.007667,, +1.066550,3.885613,0.426620,42.006822,, +1.070700,4.163846,0.428280,41.998656,, +1.074850,4.428868,0.429940,42.001998,, +1.079000,3.455973,0.431600,41.997609,, +1.083150,3.841880,0.433260,42.000152,, +1.087300,3.756615,0.434920,42.004424,, +1.091450,3.796968,0.436580,42.002348,, +1.095600,3.069766,0.438240,41.995191,, +1.099750,3.920687,0.439900,41.995446,, +1.103900,4.129423,0.441560,41.999092,, +1.108050,4.308605,0.443220,42.006744,, +1.112200,4.072299,0.444880,41.999358,, +1.116350,4.422347,0.446540,42.002045,, +1.120500,3.894066,0.448200,42.005912,, +1.124650,3.268911,0.449860,41.993913,, +1.128800,3.965515,0.451520,41.997254,, +1.132950,3.967518,0.453180,42.002491,, +1.137100,3.770846,0.454840,42.000584,, +1.141250,3.892924,0.456500,41.993126,, +1.145400,3.609016,0.458160,41.994107,, +1.149550,4.072926,0.459820,42.003342,, +1.153700,4.063064,0.461480,41.994740,, +1.157850,4.445216,0.463140,41.999290,, +1.162000,3.171175,0.464800,42.000268,, +1.166150,4.064785,0.466460,42.000649,, +1.170300,4.239770,0.468120,42.006236,, +1.174450,3.445077,0.469780,42.002390,, +1.178600,4.292038,0.471440,42.006389,, +1.182750,3.875438,0.473100,41.997177,, +1.186900,4.385288,0.474760,41.992839,, +1.191050,4.141456,0.476420,41.995047,, +1.195200,4.291018,0.478080,42.000647,, +1.199350,4.118305,0.479740,41.994705,, +1.203500,3.816790,0.481400,41.997184,, +1.207650,4.709066,0.483060,41.997801,, +1.211800,4.045658,0.484720,41.998692,, +1.215950,4.173645,0.486380,41.993980,, +1.220100,3.983887,0.488040,42.000509,, +1.224250,3.816882,0.489700,42.001662,, +1.228400,4.165426,0.491360,41.999711,, +1.232550,4.002032,0.493020,42.004732,, +1.236700,4.363339,0.494680,41.996070,, +1.240850,4.159473,0.496340,41.996207,, +1.245000,3.816291,0.498000,41.995803,, +1.249150,4.367444,0.499660,41.996648,, +1.253300,3.756918,0.501320,42.005841,, +1.257450,3.627657,0.502980,42.004132,, +1.261600,4.482875,0.504640,42.002174,, +1.265750,4.393653,0.506300,42.005372,, +1.269900,4.134297,0.507960,42.001170,, +1.274050,5.159472,0.509620,42.003114,, +1.278200,5.274950,0.511280,41.999240,, +1.282350,3.746043,0.512940,42.002539,, +1.286500,4.022478,0.514600,41.995667,, +1.290650,4.302776,0.516260,41.997617,, +1.294800,3.442825,0.517920,41.996688,, +1.298950,3.346753,0.519580,41.993975,, +1.303100,3.788249,0.521240,41.998620,, +1.307250,4.529922,0.522900,41.998578,, +1.311400,3.670973,0.524560,42.001162,, +1.315550,3.500692,0.526220,41.996925,, +1.319700,4.568700,0.527880,41.997592,, +1.323850,3.899039,0.529540,41.995461,, +1.328000,3.878031,0.531200,41.996686,, +1.332150,4.057172,0.532860,41.997268,, +1.336300,3.589627,0.534520,41.994565,, +1.340450,4.311165,0.536180,42.002161,, +1.344600,4.597550,0.537840,42.000737,, +1.348750,4.175057,0.539500,42.003284,, +1.352900,3.607470,0.541160,42.001073,, +1.357050,4.374953,0.542820,42.003879,, +1.361200,3.810376,0.544480,42.000335,, +1.365350,3.831055,0.546140,41.991282,, +1.369500,4.775629,0.547800,41.995773,, +1.373650,3.702321,0.549460,41.996219,, +1.377800,3.914150,0.551120,41.993314,, +1.381950,4.248547,0.552780,42.001584,, +1.386100,3.715797,0.554440,41.993694,, +1.390250,3.599363,0.556100,42.000062,, +1.394400,3.142402,0.557760,42.001436,, +1.398550,2.916725,0.559420,42.000634,, +1.402700,3.251339,0.561080,42.000045,, +1.406850,4.096150,0.562740,41.998306,, +1.411000,3.391015,0.564400,42.005992,, +1.415150,3.992693,0.566060,41.997600,, +1.419300,3.100009,0.567720,41.993077,, +1.423450,4.101544,0.569380,41.987876,, +1.427600,4.013039,0.571040,42.001784,, +1.431750,3.600104,0.572700,41.994839,, +1.435900,4.263941,0.574360,41.999148,, +1.440050,4.165713,0.576020,41.999509,, +1.444200,3.808725,0.577680,41.999966,, +1.448350,4.064538,0.579340,42.002374,, +1.452500,3.661160,0.581000,41.998115,, +1.456650,4.409879,0.582660,41.997387,, +1.460800,3.797043,0.584320,42.000276,, +1.464950,3.752192,0.585980,41.999169,, +1.469100,3.271474,0.587640,42.004327,, +1.473250,4.963246,0.589300,41.997627,, +1.477400,3.364378,0.590960,42.001774,, +1.481550,4.895377,0.592620,42.000818,, +1.485700,4.848272,0.594280,42.002849,, +1.489850,3.476897,0.595940,41.997594,, +1.494000,3.795371,0.597600,42.004609,, +1.498150,4.429897,0.599260,41.997189,, +1.502300,3.670136,0.600920,42.003124,, +1.506450,5.080637,0.602580,42.002719,, +1.510600,3.110686,0.604240,41.998201,, +1.514750,3.208277,0.605900,42.001780,, +1.518900,4.314107,0.607560,41.997079,, +1.523050,4.202101,0.609220,41.999768,, +1.527200,4.422567,0.610880,41.999464,, +1.531350,3.773558,0.612540,41.998949,, +1.535500,4.375385,0.614200,42.001283,, +1.539650,3.452078,0.615860,42.004514,, +1.543800,3.908767,0.617520,42.002502,, +1.547950,4.133892,0.619180,42.003260,, +1.552100,3.752241,0.620840,41.994629,, +1.556250,4.433354,0.622500,42.003436,, +1.560400,4.500674,0.624160,41.998397,, +1.564550,3.691201,0.625820,41.997038,, +1.568700,3.232112,0.627480,41.998495,, +1.572850,4.607250,0.629140,42.003790,, +1.577000,3.938144,0.630800,42.002205,, +1.581150,4.850302,0.632460,42.006948,, +1.585300,4.586402,0.634120,41.995782,, +1.589450,3.771618,0.635780,42.002596,, +1.593600,3.877692,0.637440,41.993044,, +1.597750,4.039258,0.639100,41.999616,, +1.601900,3.757430,0.640760,41.997164,, +1.606050,4.184283,0.642420,41.994200,, +1.610200,4.322890,0.644080,41.998968,, +1.614350,3.450494,0.645740,41.998203,, +1.618500,3.855509,0.647400,41.997594,, +1.622650,3.859880,0.649060,42.005668,, +1.626800,4.358746,0.650720,41.990833,, +1.630950,3.159401,0.652380,41.996255,, +1.635100,4.898331,0.654040,41.998866,, +1.639250,3.308968,0.655700,42.002186,, +1.643400,4.121809,0.657360,42.002746,, +1.647550,3.957489,0.659020,42.001436,, +1.651700,3.409578,0.660680,42.003034,, +1.655850,3.959253,0.662340,41.997057,, +1.660000,4.041074,0.664000,42.000923,, +1.664150,3.141858,0.665660,41.997833,, +1.668300,5.024391,0.667320,41.992382,, +1.672450,4.215378,0.668980,42.004668,, +1.676600,3.034640,0.670640,41.996545,, +1.680750,4.270698,0.672300,41.995971,, +1.684900,3.921182,0.673960,41.996751,, +1.689050,3.731919,0.675620,41.993765,, +1.693200,3.578419,0.677280,42.002427,, +1.697350,2.966375,0.678940,41.998029,, +1.701500,4.237365,0.680600,41.990475,, +1.705650,3.594983,0.682260,42.001681,, +1.709800,3.116375,0.683920,41.999462,, +1.713950,3.330862,0.685580,41.997193,, +1.718100,4.003696,0.687240,41.997940,, +1.722250,4.118972,0.688900,42.001850,, +1.726400,3.744148,0.690560,42.003486,, +1.730550,4.058352,0.692220,41.999409,, +1.734700,4.273491,0.693880,42.003538,, +1.738850,4.846428,0.695540,41.998777,, +1.743000,3.694723,0.697200,42.000805,, +1.747150,4.175313,0.698860,41.999715,, +1.751300,4.155472,0.700520,41.996954,, +1.755450,4.427101,0.702180,41.997996,, +1.759600,3.959932,0.703840,42.002304,, +1.763750,4.319469,0.705500,42.000130,, +1.767900,5.212870,0.707160,42.002997,, +1.772050,4.111071,0.708820,41.993799,, +1.776200,4.014427,0.710480,41.997234,, +1.780350,4.400419,0.712140,41.997247,, +1.784500,4.119172,0.713800,42.005392,, +1.788650,3.834613,0.715460,41.998934,, +1.792800,5.078477,0.717120,41.993958,, +1.796950,3.915030,0.718780,42.002744,, +1.801100,4.514066,0.720440,42.006378,, +1.805250,4.251999,0.722100,41.998306,, +1.809400,4.208731,0.723760,42.004049,, +1.813550,3.233731,0.725420,41.998036,, +1.817700,3.549370,0.727080,42.000724,, +1.821850,3.643564,0.728740,41.994112,, +1.826000,4.782792,0.730400,41.998963,, +1.830150,3.589631,0.732060,41.996054,, +1.834300,4.416728,0.733720,41.999275,, +1.838450,4.182933,0.735380,41.998564,, +1.842600,4.544456,0.737040,41.995979,, +1.846750,4.427141,0.738700,41.995276,, +1.850900,4.599410,0.740360,41.998082,, +1.855050,3.897008,0.742020,41.996491,, +1.859200,3.896378,0.743680,41.993874,, +1.863350,4.303895,0.745340,42.001824,, +1.867500,3.484582,0.747000,41.999912,, +1.871650,4.556294,0.748660,42.002487,, +1.875800,3.836825,0.750320,41.999647,, +1.879950,3.481998,0.751980,41.992906,, +1.884100,4.524545,0.753640,42.007963,, +1.888250,3.626151,0.755300,41.994152,, +1.892400,4.692822,0.756960,42.004194,, +1.896550,3.525444,0.758620,42.003011,, +1.900700,3.898874,0.760280,42.006856,, +1.904850,3.531987,0.761940,41.999909,, +1.909000,3.694215,0.763600,42.002137,, +1.913150,3.915412,0.765260,41.998341,, +1.917300,3.744753,0.766920,41.997335,, +1.921450,3.958875,0.768580,41.994978,, +1.925600,3.735644,0.770240,41.995320,, +1.929750,3.436915,0.771900,41.998835,, +1.933900,3.685876,0.773560,42.000041,, +1.938050,3.914555,0.775220,42.001780,, +1.942200,4.113354,0.776880,42.001357,, +1.946350,3.293365,0.778540,41.999709,, +1.950500,3.290643,0.780200,42.000396,, +1.954650,3.967172,0.781860,41.997395,, +1.958800,4.270501,0.783520,41.998621,, +1.962950,3.713481,0.785180,41.998137,, +1.967100,4.257741,0.786840,42.001814,, +1.971250,3.258048,0.788500,42.004105,, +1.975400,3.935557,0.790160,42.000639,, +1.979550,4.277528,0.791820,42.000274,, +1.983700,4.752500,0.793480,42.004849,, +1.987850,4.563244,0.795140,41.997497,, +1.992000,4.747172,0.796800,41.999900,, +1.996150,3.539104,0.798460,42.000247,, +2.000300,4.058472,0.800120,42.001013,, +2.004450,4.611425,0.801780,42.003941,, +2.008600,4.075723,0.803440,41.997361,, +2.012750,3.947533,0.805100,42.001355,, +2.016900,3.330341,0.806760,41.996393,, +2.021050,3.671055,0.808420,41.993995,, +2.025200,4.805891,0.810080,42.002255,, +2.029350,4.691519,0.811740,41.998200,, +2.033500,3.876434,0.813400,42.000629,, +2.037650,3.035748,0.815060,41.991994,, +2.041800,4.213557,0.816720,42.000263,, +2.045950,4.041097,0.818380,41.996903,, +2.050100,4.324360,0.820040,41.998912,, +2.054250,4.053131,0.821700,42.003343,, +2.058400,3.547151,0.823360,42.005642,, +2.062550,5.176458,0.825020,42.004344,, +2.066700,3.400237,0.826680,42.002904,, +2.070850,3.493486,0.828340,41.998340,, +2.075000,4.499686,0.830000,42.007151,, +2.079150,4.072692,0.831660,42.002289,, +2.083300,3.169054,0.833320,41.997419,, +2.087450,4.304713,0.834980,42.000476,, +2.091600,3.627929,0.836640,42.000616,, +2.095750,3.976184,0.838300,42.006533,, +2.099900,4.668930,0.839960,41.999811,, +2.104050,3.581803,0.841620,41.997788,, +2.108200,4.031995,0.843280,42.003710,, +2.112350,3.839085,0.844940,42.004501,, +2.116500,3.656110,0.846600,41.990833,, +2.120650,3.888616,0.848260,42.004152,, +2.124800,4.410202,0.849920,41.998051,, +2.128950,3.980108,0.851580,42.011230,, +2.133100,3.913163,0.853240,41.999784,, +2.137250,3.756711,0.854900,41.994919,, +2.141400,2.569561,0.856560,42.000163,, +2.145550,4.956526,0.858220,42.003060,, +2.149700,4.303118,0.859880,41.998292,, +2.153850,3.766307,0.861540,41.999391,, +2.158000,3.813551,0.863200,41.994581,, +2.162150,3.948854,0.864860,41.994971,, +2.166300,3.138340,0.866520,42.007488,, +2.170450,3.256892,0.868180,42.003487,, +2.174600,4.053516,0.869840,42.002968,, +2.178750,4.647074,0.871500,41.997519,, +2.182900,4.256434,0.873160,42.003451,, +2.187050,4.063861,0.874820,42.005125,, +2.191200,3.491975,0.876480,41.997070,, +2.195350,3.827801,0.878140,41.994932,, +2.199500,3.744012,0.879800,41.998702,, +2.203650,3.785818,0.881460,41.998446,, +2.207800,2.826175,0.883120,42.002450,, +2.211950,3.835972,0.884780,42.008769,, +2.216100,4.684876,0.886440,42.003790,, +2.220250,4.079720,0.888100,42.000427,, +2.224400,3.902146,0.889760,42.003076,, +2.228550,3.675858,0.891420,42.001585,, +2.232700,3.556404,0.893080,41.996637,, +2.236850,3.970895,0.894740,42.001097,, +2.241000,4.415266,0.896400,42.002805,, +2.245150,4.048679,0.898060,41.996308,, +2.249300,4.122488,0.899720,41.997040,, +2.253450,3.635245,0.901380,42.000005,, +2.257600,3.629813,0.903040,42.001825,, +2.261750,3.626602,0.904700,41.999440,, +2.265900,4.498183,0.906360,41.998437,, +2.270050,3.849627,0.908020,41.999602,, +2.274200,3.703090,0.909680,42.003206,, +2.278350,3.975096,0.911340,41.998723,, +2.282500,4.164121,0.913000,42.000526,, +2.286650,4.239517,0.914660,42.004669,, +2.290800,4.483759,0.916320,41.998850,, +2.294950,3.704046,0.917980,42.005287,, +2.299100,3.705685,0.919640,42.000933,, +2.303250,3.950087,0.921300,42.002957,, +2.307400,3.657463,0.922960,42.002121,, +2.311550,4.151523,0.924620,41.995892,, +2.315700,3.650774,0.926280,41.995771,, +2.319850,4.064637,0.927940,41.995637,, +2.324000,3.526560,0.929600,42.001704,, +2.328150,4.086521,0.931260,41.996857,, +2.332300,5.176507,0.932920,42.001741,, +2.336450,2.932064,0.934580,42.001748,, +2.340600,4.113745,0.936240,41.997832,, +2.344750,4.336987,0.937900,42.000990,, +2.348900,4.469211,0.939560,42.002129,, +2.353050,3.759457,0.941220,41.995493,, +2.357200,3.548625,0.942880,41.992631,, +2.361350,3.528260,0.944540,41.995944,, +2.365500,4.719382,0.946200,42.000859,, +2.369650,3.757195,0.947860,41.999756,, +2.373800,3.487917,0.949520,41.997876,, +2.377950,3.918415,0.951180,41.998870,, +2.382100,4.248711,0.952840,42.001505,, +2.386250,4.629856,0.954500,41.997554,, +2.390400,3.690299,0.956160,41.997053,, +2.394550,4.029526,0.957820,42.003509,, +2.398700,4.475814,0.959480,42.009159,, +2.402850,3.880816,0.961140,41.995956,, +2.407000,4.479011,0.962800,41.993432,, +2.411150,3.994365,0.964460,42.003596,, +2.415300,4.439535,0.966120,42.003109,, +2.419450,4.267521,0.967780,42.001401,, +2.423600,3.866530,0.969440,42.002269,, +2.427750,4.675218,0.971100,42.001370,, +2.431900,3.585976,0.972760,42.000553,, +2.436050,3.870752,0.974420,42.005710,, +2.440200,3.979179,0.976080,42.009202,, +2.444350,3.873323,0.977740,42.003429,, +2.448500,4.460738,0.979400,42.004982,, +2.452650,3.852790,0.981060,42.004063,, +2.456800,3.990008,0.982720,42.004122,, +2.460950,4.453285,0.984380,42.007703,, +2.465100,3.052372,0.986040,41.998745,, +2.469250,4.032668,0.987700,41.998587,, +2.473400,3.918490,0.989360,41.998813,, +2.477550,3.802294,0.991020,41.995786,, +2.481700,3.706648,0.992680,41.994866,, +2.485850,3.733397,0.994340,41.998356,, +2.490000,3.838950,0.996000,41.998030,, +2.494150,3.287830,0.997660,41.998242,, +2.498300,3.825042,0.999320,41.992963,, +2.502450,4.500999,1.000980,41.998300,, +2.506600,4.522626,1.002640,41.999482,, +2.510750,3.432245,1.004300,42.000028,, +2.514900,3.672568,1.005960,41.996072,, +2.519050,4.581344,1.007620,42.001848,, +2.523200,3.812690,1.009280,41.998154,, +2.527350,4.238117,1.010940,41.999246,, +2.531500,3.939211,1.012600,41.997586,, +2.535650,3.865932,1.014260,41.995721,, +2.539800,3.249531,1.015920,41.999344,, +2.543950,3.386280,1.017580,42.000883,, +2.548100,3.943866,1.019240,41.993943,, +2.552250,4.254550,1.020900,42.002973,, +2.556400,4.144613,1.022560,41.999259,, +2.560550,4.199116,1.024220,42.007433,, +2.564700,3.380507,1.025880,42.003166,, +2.568850,3.917347,1.027540,42.001833,, +2.573000,3.708943,1.029200,41.998566,, +2.577150,3.898888,1.030860,41.994278,, +2.581300,4.022010,1.032520,42.008190,, +2.585450,3.855700,1.034180,41.992699,, +2.589600,4.168029,1.035840,42.003707,, +2.593750,4.447945,1.037500,42.006400,, +2.597900,4.412274,1.039160,42.005002,, +2.602050,4.836999,1.040820,41.995857,, +2.606200,3.757490,1.042480,42.005675,, +2.610350,4.204835,1.044140,42.001661,, +2.614500,4.054918,1.045800,41.999147,, +2.618650,3.807058,1.047460,41.993779,, +2.622800,3.241072,1.049120,41.997948,, +2.626950,4.171544,1.050780,41.995776,, +2.631100,4.186075,1.052440,42.001707,, +2.635250,4.260219,1.054100,42.001322,, +2.639400,5.039169,1.055760,42.010984,, +2.643550,4.166398,1.057420,41.999676,, +2.647700,3.946803,1.059080,42.004549,, +2.651850,3.977430,1.060740,41.991550,, +2.656000,4.188722,1.062400,41.999687,, +2.660150,4.645557,1.064060,41.998551,, +2.664300,3.794726,1.065720,41.997331,, +2.668450,3.602343,1.067380,41.998687,, +2.672600,4.305830,1.069040,41.999417,, +2.676750,4.099741,1.070700,41.994420,, +2.680900,5.273924,1.072360,41.999069,, +2.685050,3.685188,1.074020,42.002574,, +2.689200,3.609697,1.075680,42.003135,, +2.693350,3.808083,1.077340,42.001294,, +2.697500,4.398285,1.079000,41.996484,, +2.701650,3.813954,1.080660,42.003251,, +2.705800,4.068384,1.082320,42.003210,, +2.709950,4.080042,1.083980,41.998168,, +2.714100,4.182969,1.085640,42.002768,, +2.718250,5.117600,1.087300,41.996832,, +2.722400,3.835962,1.088960,41.991479,, +2.726550,4.840723,1.090620,41.999673,, +2.730700,3.917971,1.092280,42.000484,, +2.734850,4.210065,1.093940,42.000207,, +2.739000,3.287293,1.095600,42.007120,, +2.743150,3.847712,1.097260,41.996075,, +2.747300,3.772719,1.098920,41.994487,, +2.751450,3.795803,1.100580,41.994069,, +2.755600,4.414247,1.102240,42.000755,, +2.759750,4.759442,1.103900,42.000390,, +2.763900,4.247904,1.105560,42.001526,, +2.768050,3.829837,1.107220,41.998324,, +2.772200,3.335998,1.108880,42.001621,, +2.776350,3.725670,1.110540,41.997132,, +2.780500,4.485733,1.112200,42.002660,, +2.784650,3.384860,1.113860,42.007768,, +2.788800,3.250236,1.115520,41.992624,, +2.792950,3.792470,1.117180,42.004995,, +2.797100,3.755167,1.118840,41.993925,, +2.801250,4.564102,1.120500,41.992108,, +2.805400,3.577914,1.122160,41.999278,, +2.809550,4.228187,1.123820,41.997692,, +2.813700,3.760751,1.125480,42.002505,, +2.817850,3.633930,1.127140,42.004135,, +2.822000,3.778587,1.128800,41.992594,, +2.826150,3.174828,1.130460,42.005059,, +2.830300,4.465105,1.132120,42.002466,, +2.834450,4.024495,1.133780,41.999963,, +2.838600,4.194911,1.135440,41.999456,, +2.842750,3.888367,1.137100,42.001427,, +2.846900,3.974619,1.138760,42.013726,, +2.851050,4.695562,1.140420,41.999830,, +2.855200,4.684602,1.142080,42.001361,, +2.859350,3.649358,1.143740,41.999812,, +2.863500,3.324016,1.145400,41.996901,, +2.867650,2.996285,1.147060,42.000836,, +2.871800,3.473302,1.148720,41.998928,, +2.875950,3.442960,1.150380,42.000462,, +2.880100,4.092369,1.152040,42.000893,, +2.884250,4.142430,1.153700,42.001973,, +2.888400,4.779989,1.155360,42.001276,, +2.892550,4.138752,1.157020,41.996882,, +2.896700,3.611211,1.158680,41.992492,, +2.900850,4.268835,1.160340,42.006686,, +2.905000,4.026062,1.162000,42.003056,, +2.909150,4.115521,1.163660,41.995715,, +2.913300,3.954023,1.165320,41.998898,, +2.917450,4.385395,1.166980,42.000372,, +2.921600,4.390630,1.168640,41.999713,, +2.925750,4.574510,1.170300,41.994118,, +2.929900,4.319436,1.171960,42.006154,, +2.934050,4.139347,1.173620,41.995988,, +2.938200,4.766888,1.175280,42.006257,, +2.942350,4.185019,1.176940,41.997888,, +2.946500,4.373896,1.178600,42.000257,, +2.950650,4.161186,1.180260,42.000812,, +2.954800,3.751027,1.181920,41.994224,, +2.958950,4.249149,1.183580,41.997077,, +2.963100,4.015594,1.185240,41.997584,, +2.967250,3.496841,1.186900,42.003672,, +2.971400,4.873712,1.188560,42.002759,, +2.975550,3.574433,1.190220,41.999569,, +2.979700,3.361733,1.191880,41.998511,, +2.983850,4.309458,1.193540,41.995931,, +2.988000,3.479343,1.195200,42.000340,, +,,1.196860,42.005167,, +,,1.198520,42.005315,, +,,1.200180,42.000086,, +,,1.201840,42.000510,, +,,1.203500,42.001473,, +,,1.205160,42.000627,, +,,1.206820,42.004057,, +,,1.208480,42.000379,, +,,1.210140,42.004769,, +,,1.211800,41.997472,, +,,1.213460,42.000562,, +,,1.215120,42.000458,, +,,1.216780,42.006109,, +,,1.218440,42.012797,, +,,1.220100,42.005890,, +,,1.221760,41.997421,, +,,1.223420,42.005570,, +,,1.225080,41.995542,, +,,1.226740,41.992048,, +,,1.228400,42.007690,, +,,1.230060,42.000527,, +,,1.231720,41.999038,, +,,1.233380,41.998508,, +,,1.235040,41.999927,, +,,1.236700,42.001323,, +,,1.238360,41.997586,, +,,1.240020,42.002838,, +,,1.241680,42.001080,, +,,1.243340,41.994749,, +,,1.245000,42.000885,, +,,1.246660,41.993086,, +,,1.248320,41.997176,, +,,1.249980,41.992846,, +,,1.251640,41.996540,, +,,1.253300,42.000758,, +,,1.254960,41.990103,, +,,1.256620,42.001088,, +,,1.258280,41.995363,, +,,1.259940,41.995360,, +,,1.261600,41.992902,, +,,1.263260,41.999942,, +,,1.264920,42.000855,, +,,1.266580,42.000690,, +,,1.268240,41.999013,, +,,1.269900,41.995574,, +,,1.271560,42.000764,, +,,1.273220,42.004345,, +,,1.274880,42.000885,, +,,1.276540,42.006592,, +,,1.278200,42.000769,, +,,1.279860,41.998172,, +,,1.281520,42.004876,, +,,1.283180,42.003205,, +,,1.284840,42.003406,, +,,1.286500,41.999090,, +,,1.288160,42.008051,, +,,1.289820,41.994463,, +,,1.291480,41.991621,, +,,1.293140,42.002787,, +,,1.294800,42.002243,, +,,1.296460,42.003722,, +,,1.298120,42.003584,, +,,1.299780,41.999094,, +,,1.301440,42.007914,, +,,1.303100,42.002698,, +,,1.304760,41.998299,, +,,1.306420,41.998117,, +,,1.308080,42.005936,, +,,1.309740,42.000272,, +,,1.311400,42.007869,, +,,1.313060,41.997146,, +,,1.314720,41.998336,, +,,1.316380,41.993221,, +,,1.318040,42.004576,, +,,1.319700,42.000629,, +,,1.321360,42.002851,, +,,1.323020,42.004647,, +,,1.324680,41.997662,, +,,1.326340,41.993070,, +,,1.328000,41.999435,, +,,1.329660,42.005777,, +,,1.331320,42.005737,, +,,1.332980,42.001322,, +,,1.334640,41.998963,, +,,1.336300,41.999042,, +,,1.337960,41.997005,, +,,1.339620,42.003208,, +,,1.341280,42.007008,, +,,1.342940,42.000532,, +,,1.344600,41.999706,, +,,1.346260,42.000436,, +,,1.347920,42.002261,, +,,1.349580,41.998903,, +,,1.351240,41.999383,, +,,1.352900,41.999638,, +,,1.354560,41.996860,, +,,1.356220,41.998860,, +,,1.357880,41.998953,, +,,1.359540,42.002196,, +,,1.361200,41.991519,, +,,1.362860,42.003347,, +,,1.364520,41.999787,, +,,1.366180,42.002032,, +,,1.367840,41.995199,, +,,1.369500,42.001415,, +,,1.371160,42.007363,, +,,1.372820,42.001081,, +,,1.374480,42.000490,, +,,1.376140,42.000860,, +,,1.377800,42.004262,, +,,1.379460,41.998991,, +,,1.381120,41.994111,, +,,1.382780,42.000709,, +,,1.384440,41.997630,, +,,1.386100,41.996707,, +,,1.387760,41.998654,, +,,1.389420,41.995665,, +,,1.391080,41.992323,, +,,1.392740,41.995953,, +,,1.394400,42.004119,, +,,1.396060,41.998618,, +,,1.397720,41.996658,, +,,1.399380,42.001216,, +,,1.401040,42.003950,, +,,1.402700,42.002497,, +,,1.404360,42.000433,, +,,1.406020,42.003429,, +,,1.407680,42.005031,, +,,1.409340,41.995969,, +,,1.411000,42.000409,, +,,1.412660,41.997275,, +,,1.414320,42.006576,, +,,1.415980,41.992687,, +,,1.417640,41.997500,, +,,1.419300,41.999796,, +,,1.420960,41.993542,, +,,1.422620,42.000192,, +,,1.424280,41.998592,, +,,1.425940,42.004474,, +,,1.427600,41.998846,, +,,1.429260,41.994477,, +,,1.430920,41.999236,, +,,1.432580,41.998590,, +,,1.434240,42.008029,, +,,1.435900,42.003294,, +,,1.437560,41.998294,, +,,1.439220,42.000535,, +,,1.440880,41.998322,, +,,1.442540,42.003884,, +,,1.444200,42.003500,, +,,1.445860,42.006694,, +,,1.447520,41.995317,, +,,1.449180,42.005404,, +,,1.450840,42.002778,, +,,1.452500,41.997726,, +,,1.454160,41.997017,, +,,1.455820,42.000142,, +,,1.457480,42.005820,, +,,1.459140,42.002768,, +,,1.460800,41.997250,, +,,1.462460,42.003146,, +,,1.464120,42.002930,, +,,1.465780,41.999798,, +,,1.467440,42.002993,, +,,1.469100,42.003190,, +,,1.470760,41.999057,, +,,1.472420,41.994741,, +,,1.474080,42.003258,, +,,1.475740,42.007232,, +,,1.477400,41.999120,, +,,1.479060,42.002397,, +,,1.480720,42.000351,, +,,1.482380,42.006569,, +,,1.484040,41.996434,, +,,1.485700,42.006511,, +,,1.487360,41.998054,, +,,1.489020,41.994438,, +,,1.490680,42.007313,, +,,1.492340,42.002654,, +,,1.494000,42.005782,, +,,1.495660,42.002130,, +,,1.497320,42.000619,, +,,1.498980,42.008712,, +,,1.500640,41.995327,, +,,1.502300,42.007308,, +,,1.503960,41.997969,, +,,1.505620,41.994094,, +,,1.507280,41.997279,, +,,1.508940,41.998088,, +,,1.510600,42.001119,, +,,1.512260,41.998306,, +,,1.513920,41.996787,, +,,1.515580,42.003969,, +,,1.517240,41.999240,, +,,1.518900,41.993486,, +,,1.520560,41.988086,, +,,1.522220,42.000962,, +,,1.523880,41.993715,, +,,1.525540,42.006866,, +,,1.527200,42.006938,, +,,1.528860,41.998038,, +,,1.530520,42.006681,, +,,1.532180,42.001753,, +,,1.533840,41.994882,, +,,1.535500,42.004092,, +,,1.537160,41.993525,, +,,1.538820,41.998384,, +,,1.540480,42.001686,, +,,1.542140,42.009563,, +,,1.543800,42.003073,, +,,1.545460,41.997766,, +,,1.547120,42.003733,, +,,1.548780,41.996895,, +,,1.550440,41.996752,, +,,1.552100,42.003139,, +,,1.553760,41.998569,, +,,1.555420,42.002040,, +,,1.557080,41.998009,, +,,1.558740,42.000275,, +,,1.560400,41.998962,, +,,1.562060,41.998196,, +,,1.563720,42.001631,, +,,1.565380,41.998809,, +,,1.567040,42.001462,, +,,1.568700,42.002727,, +,,1.570360,41.998359,, +,,1.572020,41.998700,, +,,1.573680,41.993957,, +,,1.575340,41.999938,, +,,1.577000,42.001995,, +,,1.578660,42.004484,, +,,1.580320,41.996866,, +,,1.581980,42.003041,, +,,1.583640,42.000054,, +,,1.585300,42.003528,, +,,1.586960,42.007544,, +,,1.588620,41.997956,, +,,1.590280,42.002460,, +,,1.591940,41.999300,, +,,1.593600,42.007203,, +,,1.595260,41.996994,, +,,1.596920,42.003200,, +,,1.598580,42.004165,, +,,1.600240,42.001444,, +,,1.601900,42.007001,, +,,1.603560,42.009511,, +,,1.605220,41.997457,, +,,1.606880,42.000084,, +,,1.608540,41.999189,, +,,1.610200,42.002927,, +,,1.611860,41.994395,, +,,1.613520,42.003316,, +,,1.615180,41.995540,, +,,1.616840,41.996686,, +,,1.618500,41.999014,, +,,1.620160,41.999920,, +,,1.621820,41.992437,, +,,1.623480,41.994399,, +,,1.625140,42.000203,, +,,1.626800,41.998746,, +,,1.628460,41.998432,, +,,1.630120,42.006576,, +,,1.631780,42.003246,, +,,1.633440,42.003827,, +,,1.635100,42.002526,, +,,1.636760,42.002648,, +,,1.638420,42.002769,, +,,1.640080,42.002886,, +,,1.641740,41.997058,, +,,1.643400,41.999222,, +,,1.645060,41.996301,, +,,1.646720,42.002496,, +,,1.648380,42.007387,, +,,1.650040,41.996361,, +,,1.651700,42.006893,, +,,1.653360,42.008656,, +,,1.655020,41.996849,, +,,1.656680,42.001726,, +,,1.658340,42.004918,, +,,1.660000,42.004337,, +,,1.661660,41.999430,, +,,1.663320,42.001535,, +,,1.664980,42.003378,, +,,1.666640,42.006341,, +,,1.668300,42.007030,, +,,1.669960,42.002246,, +,,1.671620,42.007089,, +,,1.673280,41.998569,, +,,1.674940,41.991641,, +,,1.676600,42.004524,, +,,1.678260,42.004503,, +,,1.679920,41.999623,, +,,1.681580,41.992665,, +,,1.683240,42.005569,, +,,1.684900,41.993485,, +,,1.686560,42.004032,, +,,1.688220,42.002031,, +,,1.689880,42.007280,, +,,1.691540,41.999066,, +,,1.693200,42.002030,, +,,1.694860,41.996493,, +,,1.696520,41.988978,, +,,1.698180,41.998633,, +,,1.699840,41.997710,, +,,1.701500,41.997368,, +,,1.703160,42.005437,, +,,1.704820,42.001525,, +,,1.706480,41.999836,, +,,1.708140,41.998292,, +,,1.709800,42.005897,, +,,1.711460,41.996299,, +,,1.713120,42.001194,, +,,1.714780,41.996283,, +,,1.716440,41.996464,, +,,1.718100,41.997065,, +,,1.719760,42.009622,, +,,1.721420,41.998058,, +,,1.723080,41.988542,, +,,1.724740,41.997392,, +,,1.726400,42.006832,, +,,1.728060,41.999025,, +,,1.729720,42.002428,, +,,1.731380,42.003528,, +,,1.733040,41.999007,, +,,1.734700,41.996241,, +,,1.736360,41.996068,, +,,1.738020,42.001512,, +,,1.739680,41.992952,, +,,1.741340,41.997927,, +,,1.743000,42.002400,, +,,1.744660,42.001698,, +,,1.746320,42.000546,, +,,1.747980,41.999563,, +,,1.749640,42.001370,, +,,1.751300,41.999556,, +,,1.752960,41.998906,, +,,1.754620,41.998119,, +,,1.756280,42.001193,, +,,1.757940,41.997880,, +,,1.759600,42.004589,, +,,1.761260,42.001853,, +,,1.762920,42.000270,, +,,1.764580,42.004615,, +,,1.766240,42.001619,, +,,1.767900,42.002168,, +,,1.769560,42.003970,, +,,1.771220,41.997223,, +,,1.772880,41.998885,, +,,1.774540,42.000628,, +,,1.776200,41.997476,, +,,1.777860,41.994798,, +,,1.779520,42.002721,, +,,1.781180,41.999073,, +,,1.782840,42.002507,, +,,1.784500,42.004002,, +,,1.786160,41.998561,, +,,1.787820,42.003914,, +,,1.789480,41.998439,, +,,1.791140,42.003650,, +,,1.792800,42.003919,, +,,1.794460,41.996729,, +,,1.796120,41.998503,, +,,1.797780,41.998038,, +,,1.799440,42.004340,, +,,1.801100,42.005765,, +,,1.802760,42.003924,, +,,1.804420,42.000217,, +,,1.806080,41.996276,, +,,1.807740,41.999078,, +,,1.809400,42.001824,, +,,1.811060,41.997947,, +,,1.812720,41.997816,, +,,1.814380,42.001019,, +,,1.816040,42.002794,, +,,1.817700,42.001115,, +,,1.819360,41.995282,, +,,1.821020,42.003981,, +,,1.822680,42.003430,, +,,1.824340,42.003990,, +,,1.826000,42.006779,, +,,1.827660,42.002530,, +,,1.829320,42.004765,, +,,1.830980,42.003508,, +,,1.832640,41.996659,, +,,1.834300,41.996774,, +,,1.835960,41.999031,, +,,1.837620,41.995536,, +,,1.839280,41.997252,, +,,1.840940,41.991787,, +,,1.842600,42.001346,, +,,1.844260,41.997369,, +,,1.845920,42.002559,, +,,1.847580,41.999139,, +,,1.849240,42.003613,, +,,1.850900,41.999778,, +,,1.852560,42.001202,, +,,1.854220,42.003953,, +,,1.855880,42.002686,, +,,1.857540,41.996293,, +,,1.859200,42.004528,, +,,1.860860,42.001219,, +,,1.862520,42.004634,, +,,1.864180,42.000746,, +,,1.865840,41.999168,, +,,1.867500,41.999744,, +,,1.869160,42.004145,, +,,1.870820,41.997142,, +,,1.872480,41.998034,, +,,1.874140,42.001254,, +,,1.875800,42.004045,, +,,1.877460,42.003067,, +,,1.879120,42.005938,, +,,1.880780,41.998042,, +,,1.882440,42.003256,, +,,1.884100,42.001151,, +,,1.885760,42.000452,, +,,1.887420,41.996758,, +,,1.889080,42.004741,, +,,1.890740,42.007764,, +,,1.892400,42.005202,, +,,1.894060,42.005816,, +,,1.895720,42.005634,, +,,1.897380,42.007034,, +,,1.899040,41.999456,, +,,1.900700,42.007747,, +,,1.902360,42.008132,, +,,1.904020,42.001327,, +,,1.905680,42.000559,, +,,1.907340,41.998856,, +,,1.909000,42.001789,, +,,1.910660,41.997848,, +,,1.912320,41.995628,, +,,1.913980,42.000060,, +,,1.915640,42.006204,, +,,1.917300,42.005062,, +,,1.918960,42.002287,, +,,1.920620,42.003216,, +,,1.922280,41.989839,, +,,1.923940,42.003385,, +,,1.925600,41.998149,, +,,1.927260,42.005481,, +,,1.928920,42.000197,, +,,1.930580,41.990891,, +,,1.932240,42.003529,, +,,1.933900,41.998388,, +,,1.935560,41.996718,, +,,1.937220,42.008395,, +,,1.938880,41.997883,, +,,1.940540,41.997712,, +,,1.942200,42.001073,, +,,1.943860,41.995184,, +,,1.945520,41.998020,, +,,1.947180,41.995858,, +,,1.948840,42.006806,, +,,1.950500,41.998276,, +,,1.952160,42.006276,, +,,1.953820,42.002241,, +,,1.955480,41.996172,, +,,1.957140,41.996779,, +,,1.958800,42.003632,, +,,1.960460,42.001436,, +,,1.962120,42.003337,, +,,1.963780,41.999630,, +,,1.965440,41.993122,, +,,1.967100,42.003299,, +,,1.968760,42.007492,, +,,1.970420,42.001550,, +,,1.972080,41.999103,, +,,1.973740,41.992831,, +,,1.975400,42.001685,, +,,1.977060,42.000437,, +,,1.978720,42.000008,, +,,1.980380,42.002569,, +,,1.982040,42.000860,, +,,1.983700,41.998623,, +,,1.985360,42.003801,, +,,1.987020,42.005543,, +,,1.988680,41.993476,, +,,1.990340,42.004833,, +,,1.992000,41.999257,, +,,1.993660,41.999372,, +,,1.995320,41.996435,, +,,1.996980,42.000106,, +,,1.998640,42.003391,, +,,2.000300,42.000379,, +,,2.001960,41.997357,, +,,2.003620,41.998537,, +,,2.005280,42.000486,, +,,2.006940,41.993539,, +,,2.008600,42.005560,, +,,2.010260,41.997045,, +,,2.011920,41.999555,, +,,2.013580,41.995483,, +,,2.015240,42.002281,, +,,2.016900,42.003556,, +,,2.018560,41.996103,, +,,2.020220,41.992871,, +,,2.021880,41.998609,, +,,2.023540,42.000957,, +,,2.025200,41.999673,, +,,2.026860,41.997530,, +,,2.028520,42.006958,, +,,2.030180,42.005645,, +,,2.031840,41.993984,, +,,2.033500,41.995740,, +,,2.035160,42.002815,, +,,2.036820,42.005936,, +,,2.038480,42.000953,, +,,2.040140,42.001491,, +,,2.041800,41.997908,, +,,2.043460,42.001191,, +,,2.045120,42.006294,, +,,2.046780,42.001627,, +,,2.048440,41.996038,, +,,2.050100,41.997662,, +,,2.051760,41.998034,, +,,2.053420,42.004280,, +,,2.055080,41.995653,, +,,2.056740,42.007161,, +,,2.058400,41.998635,, +,,2.060060,42.004244,, +,,2.061720,41.992160,, +,,2.063380,42.007247,, +,,2.065040,41.998807,, +,,2.066700,41.992913,, +,,2.068360,42.006261,, +,,2.070020,41.995748,, +,,2.071680,41.995527,, +,,2.073340,41.997136,, +,,2.075000,42.000042,, +,,2.076660,41.996546,, +,,2.078320,41.997542,, +,,2.079980,41.995467,, +,,2.081640,41.997642,, +,,2.083300,42.003261,, +,,2.084960,42.007360,, +,,2.086620,42.008064,, +,,2.088280,42.001423,, +,,2.089940,41.998589,, +,,2.091600,41.995841,, +,,2.093260,41.996636,, +,,2.094920,42.001870,, +,,2.096580,42.005279,, +,,2.098240,42.000026,, +,,2.099900,42.006411,, +,,2.101560,42.005642,, +,,2.103220,41.999435,, +,,2.104880,42.004555,, +,,2.106540,42.001532,, +,,2.108200,41.997920,, +,,2.109860,41.995663,, +,,2.111520,42.002431,, +,,2.113180,42.000862,, +,,2.114840,41.995341,, +,,2.116500,41.999777,, +,,2.118160,41.995007,, +,,2.119820,41.999583,, +,,2.121480,42.003523,, +,,2.123140,42.004465,, +,,2.124800,42.004933,, +,,2.126460,41.998078,, +,,2.128120,42.002522,, +,,2.129780,41.994126,, +,,2.131440,41.996982,, +,,2.133100,42.000190,, +,,2.134760,42.001014,, +,,2.136420,41.995278,, +,,2.138080,42.003791,, +,,2.139740,41.995258,, +,,2.141400,41.997223,, +,,2.143060,41.995300,, +,,2.144720,41.995374,, +,,2.146380,42.003142,, +,,2.148040,42.004220,, +,,2.149700,41.992860,, +,,2.151360,42.001233,, +,,2.153020,41.995219,, +,,2.154680,41.993190,, +,,2.156340,42.000002,, +,,2.158000,41.999891,, +,,2.159660,41.997459,, +,,2.161320,42.000777,, +,,2.162980,41.999334,, +,,2.164640,42.002103,, +,,2.166300,41.998024,, +,,2.167960,42.001228,, +,,2.169620,41.990667,, +,,2.171280,42.001534,, +,,2.172940,41.997638,, +,,2.174600,42.003169,, +,,2.176260,42.001672,, +,,2.177920,41.999197,, +,,2.179580,42.002473,, +,,2.181240,42.001544,, +,,2.182900,41.999769,, +,,2.184560,42.004053,, +,,2.186220,41.999348,, +,,2.187880,41.997357,, +,,2.189540,42.002068,, +,,2.191200,41.999970,, +,,2.192860,42.002908,, +,,2.194520,41.996391,, +,,2.196180,42.005681,, +,,2.197840,41.991086,, +,,2.199500,41.994038,, +,,2.201160,41.995525,, +,,2.202820,42.003261,, +,,2.204480,42.007186,, +,,2.206140,42.002404,, +,,2.207800,41.999716,, +,,2.209460,42.001319,, +,,2.211120,42.001677,, +,,2.212780,41.990165,, +,,2.214440,42.000770,, +,,2.216100,41.994251,, +,,2.217760,42.007033,, +,,2.219420,41.998296,, +,,2.221080,41.999445,, +,,2.222740,41.994554,, +,,2.224400,41.999052,, +,,2.226060,41.999118,, +,,2.227720,41.992050,, +,,2.229380,42.002927,, +,,2.231040,42.005143,, +,,2.232700,41.994009,, +,,2.234360,42.006110,, +,,2.236020,42.004325,, +,,2.237680,42.004404,, +,,2.239340,42.002804,, +,,2.241000,42.003383,, +,,2.242660,41.996834,, +,,2.244320,41.998773,, +,,2.245980,42.002410,, +,,2.247640,42.004678,, +,,2.249300,41.998137,, +,,2.250960,42.001937,, +,,2.252620,42.004979,, +,,2.254280,41.999411,, +,,2.255940,41.997110,, +,,2.257600,41.998777,, +,,2.259260,42.001400,, +,,2.260920,42.003443,, +,,2.262580,41.998818,, +,,2.264240,41.995144,, +,,2.265900,42.002586,, +,,2.267560,41.999909,, +,,2.269220,42.000849,, +,,2.270880,42.002252,, +,,2.272540,41.998088,, +,,2.274200,41.993880,, +,,2.275860,41.996978,, +,,2.277520,41.997005,, +,,2.279180,41.996138,, +,,2.280840,41.997973,, +,,2.282500,42.000296,, +,,2.284160,41.992301,, +,,2.285820,42.005598,, +,,2.287480,42.002204,, +,,2.289140,41.997466,, +,,2.290800,41.997146,, +,,2.292460,42.005904,, +,,2.294120,41.997364,, +,,2.295780,42.001365,, +,,2.297440,42.005488,, +,,2.299100,41.995072,, +,,2.300760,41.993516,, +,,2.302420,42.001799,, +,,2.304080,41.996725,, +,,2.305740,42.002203,, +,,2.307400,41.993882,, +,,2.309060,42.008169,, +,,2.310720,41.994868,, +,,2.312380,42.000676,, +,,2.314040,41.996707,, +,,2.315700,42.002021,, +,,2.317360,41.995971,, +,,2.319020,42.005060,, +,,2.320680,42.000559,, +,,2.322340,41.999219,, +,,2.324000,42.002593,, +,,2.325660,41.999489,, +,,2.327320,42.001643,, +,,2.328980,42.004252,, +,,2.330640,41.997513,, +,,2.332300,41.999210,, +,,2.333960,42.001582,, +,,2.335620,42.001822,, +,,2.337280,42.000429,, +,,2.338940,41.996964,, +,,2.340600,42.000847,, +,,2.342260,42.005522,, +,,2.343920,41.995165,, +,,2.345580,42.003607,, +,,2.347240,41.998472,, +,,2.348900,41.998494,, +,,2.350560,42.002250,, +,,2.352220,42.004084,, +,,2.353880,41.998895,, +,,2.355540,42.004238,, +,,2.357200,42.000195,, +,,2.358860,41.995829,, +,,2.360520,42.001900,, +,,2.362180,42.002636,, +,,2.363840,42.006512,, +,,2.365500,42.000422,, +,,2.367160,41.993859,, +,,2.368820,41.995931,, +,,2.370480,41.999316,, +,,2.372140,41.999687,, +,,2.373800,41.997922,, +,,2.375460,42.002378,, +,,2.377120,42.003390,, +,,2.378780,41.998542,, +,,2.380440,41.998697,, +,,2.382100,42.006115,, +,,2.383760,41.999035,, +,,2.385420,41.995798,, +,,2.387080,41.993711,, +,,2.388740,42.001100,, +,,2.390400,42.005056,, +,,2.392060,41.993230,, +,,2.393720,42.005516,, +,,2.395380,42.006405,, +,,2.397040,42.003246,, +,,2.398700,41.998860,, +,,2.400360,42.004932,, +,,2.402020,42.003834,, +,,2.403680,41.999012,, +,,2.405340,41.998015,, +,,2.407000,41.998809,, +,,2.408660,42.003597,, +,,2.410320,41.992147,, +,,2.411980,42.000540,, +,,2.413640,41.999685,, +,,2.415300,41.995496,, +,,2.416960,41.995420,, +,,2.418620,42.006409,, +,,2.420280,42.000683,, +,,2.421940,41.998513,, +,,2.423600,42.002653,, +,,2.425260,41.995885,, +,,2.426920,42.001791,, +,,2.428580,42.000700,, +,,2.430240,42.008969,, +,,2.431900,41.996574,, +,,2.433560,42.003796,, +,,2.435220,41.998091,, +,,2.436880,41.993325,, +,,2.438540,42.002209,, +,,2.440200,42.001574,, +,,2.441860,42.001026,, +,,2.443520,41.998527,, +,,2.445180,42.009755,, +,,2.446840,41.995973,, +,,2.448500,42.002073,, +,,2.450160,42.001590,, +,,2.451820,42.006385,, +,,2.453480,42.000162,, +,,2.455140,41.999100,, +,,2.456800,41.999283,, +,,2.458460,41.999637,, +,,2.460120,42.005621,, +,,2.461780,41.995477,, +,,2.463440,42.001819,, +,,2.465100,42.004829,, +,,2.466760,42.010469,, +,,2.468420,41.996744,, +,,2.470080,41.993362,, +,,2.471740,41.998515,, +,,2.473400,42.004687,, +,,2.475060,41.998201,, +,,2.476720,42.001839,, +,,2.478380,42.009534,, +,,2.480040,41.999304,, +,,2.481700,42.003395,, +,,2.483360,42.006661,, +,,2.485020,42.002327,, +,,2.486680,42.000279,, +,,2.488340,42.002785,, +,,2.490000,41.997275,, +,,2.491660,42.003695,, +,,2.493320,42.001227,, +,,2.494980,42.002992,, +,,2.496640,42.000128,, +,,2.498300,41.994575,, +,,2.499960,41.996292,, +,,2.501620,42.000048,, +,,2.503280,41.995031,, +,,2.504940,42.000038,, +,,2.506600,41.994458,, +,,2.508260,42.002688,, +,,2.509920,42.001212,, +,,2.511580,42.001249,, +,,2.513240,42.002923,, +,,2.514900,41.997390,, +,,2.516560,41.990746,, +,,2.518220,41.995322,, +,,2.519880,41.998436,, +,,2.521540,42.007814,, +,,2.523200,42.001933,, +,,2.524860,41.998564,, +,,2.526520,42.001592,, +,,2.528180,42.001614,, +,,2.529840,41.996429,, +,,2.531500,42.002325,, +,,2.533160,42.001760,, +,,2.534820,42.000049,, +,,2.536480,41.997306,, +,,2.538140,41.997988,, +,,2.539800,41.999873,, +,,2.541460,41.997043,, +,,2.543120,41.995929,, +,,2.544780,41.999903,, +,,2.546440,42.002742,, +,,2.548100,42.001251,, +,,2.549760,42.003245,, +,,2.551420,41.996240,, +,,2.553080,41.997123,, +,,2.554740,42.000332,, +,,2.556400,41.999011,, +,,2.558060,42.003272,, +,,2.559720,41.994943,, +,,2.561380,42.000426,, +,,2.563040,42.004462,, +,,2.564700,42.002855,, +,,2.566360,41.999394,, +,,2.568020,42.003885,, +,,2.569680,41.995090,, +,,2.571340,41.997766,, +,,2.573000,41.999667,, +,,2.574660,42.000443,, +,,2.576320,42.002489,, +,,2.577980,41.991953,, +,,2.579640,42.003147,, +,,2.581300,42.001284,, +,,2.582960,42.001655,, +,,2.584620,42.004673,, +,,2.586280,42.003924,, +,,2.587940,42.003574,, +,,2.589600,42.003882,, +,,2.591260,42.003335,, +,,2.592920,42.002872,, +,,2.594580,42.004547,, +,,2.596240,41.996676,, +,,2.597900,42.001580,, +,,2.599560,42.001789,, +,,2.601220,41.997192,, +,,2.602880,41.997441,, +,,2.604540,42.001879,, +,,2.606200,42.001202,, +,,2.607860,41.999304,, +,,2.609520,42.000072,, +,,2.611180,41.999632,, +,,2.612840,41.993887,, +,,2.614500,42.003257,, +,,2.616160,42.000931,, +,,2.617820,42.002460,, +,,2.619480,41.995640,, +,,2.621140,41.996752,, +,,2.622800,42.001882,, +,,2.624460,41.999792,, +,,2.626120,41.998157,, +,,2.627780,42.006015,, +,,2.629440,41.996412,, +,,2.631100,42.001170,, +,,2.632760,42.004128,, +,,2.634420,42.002986,, +,,2.636080,42.003325,, +,,2.637740,42.008574,, +,,2.639400,41.993081,, +,,2.641060,42.000553,, +,,2.642720,42.002577,, +,,2.644380,42.006160,, +,,2.646040,42.000412,, +,,2.647700,41.995736,, +,,2.649360,42.000614,, +,,2.651020,42.000955,, +,,2.652680,42.000314,, +,,2.654340,41.992902,, +,,2.656000,42.003550,, +,,2.657660,42.003901,, +,,2.659320,42.000372,, +,,2.660980,42.000854,, +,,2.662640,42.003272,, +,,2.664300,42.003613,, +,,2.665960,41.995897,, +,,2.667620,41.997311,, +,,2.669280,41.992539,, +,,2.670940,41.999903,, +,,2.672600,42.000515,, +,,2.674260,41.991926,, +,,2.675920,41.997746,, +,,2.677580,41.996903,, +,,2.679240,41.999716,, +,,2.680900,42.002671,, +,,2.682560,42.001909,, +,,2.684220,41.992717,, +,,2.685880,41.992892,, +,,2.687540,42.003131,, +,,2.689200,41.999843,, +,,2.690860,41.996919,, +,,2.692520,41.994167,, +,,2.694180,42.004316,, +,,2.695840,41.995590,, +,,2.697500,42.003446,, +,,2.699160,42.007637,, +,,2.700820,41.997328,, +,,2.702480,41.997489,, +,,2.704140,41.993866,, +,,2.705800,42.002175,, +,,2.707460,42.000339,, +,,2.709120,41.998045,, +,,2.710780,41.996363,, +,,2.712440,42.007129,, +,,2.714100,41.996492,, +,,2.715760,41.992373,, +,,2.717420,42.000585,, +,,2.719080,42.005444,, +,,2.720740,42.000077,, +,,2.722400,41.998512,, +,,2.724060,42.002166,, +,,2.725720,41.995813,, +,,2.727380,41.999018,, +,,2.729040,41.998098,, +,,2.730700,42.007625,, +,,2.732360,42.005848,, +,,2.734020,41.997348,, +,,2.735680,41.998158,, +,,2.737340,41.999036,, +,,2.739000,42.000956,, +,,2.740660,41.999417,, +,,2.742320,42.006718,, +,,2.743980,42.000416,, +,,2.745640,42.000910,, +,,2.747300,41.998427,, +,,2.748960,41.994417,, +,,2.750620,42.004913,, +,,2.752280,42.001074,, +,,2.753940,42.000685,, +,,2.755600,42.004406,, +,,2.757260,42.001570,, +,,2.758920,41.993754,, +,,2.760580,41.997322,, +,,2.762240,42.001851,, +,,2.763900,42.004124,, +,,2.765560,41.999555,, +,,2.767220,42.004729,, +,,2.768880,42.002792,, +,,2.770540,42.000614,, +,,2.772200,41.997422,, +,,2.773860,41.999620,, +,,2.775520,42.002427,, +,,2.777180,42.002279,, +,,2.778840,42.000441,, +,,2.780500,41.997610,, +,,2.782160,42.003097,, +,,2.783820,42.001131,, +,,2.785480,42.000753,, +,,2.787140,41.995811,, +,,2.788800,41.999641,, +,,2.790460,41.999995,, +,,2.792120,41.999902,, +,,2.793780,42.002925,, +,,2.795440,41.998016,, +,,2.797100,41.999021,, +,,2.798760,42.004123,, +,,2.800420,42.000022,, +,,2.802080,42.000647,, +,,2.803740,41.991948,, +,,2.805400,42.002573,, +,,2.807060,42.004298,, +,,2.808720,41.999685,, +,,2.810380,42.003136,, +,,2.812040,42.003735,, +,,2.813700,42.008872,, +,,2.815360,42.004045,, +,,2.817020,42.006529,, +,,2.818680,41.996262,, +,,2.820340,41.994676,, +,,2.822000,42.005262,, +,,2.823660,41.999834,, +,,2.825320,41.994396,, +,,2.826980,41.995826,, +,,2.828640,41.998301,, +,,2.830300,41.998085,, +,,2.831960,42.000690,, +,,2.833620,41.998997,, +,,2.835280,42.001831,, +,,2.836940,42.001763,, +,,2.838600,41.999978,, +,,2.840260,41.998239,, +,,2.841920,41.997362,, +,,2.843580,42.003381,, +,,2.845240,41.995044,, +,,2.846900,42.000997,, +,,2.848560,41.997778,, +,,2.850220,41.994902,, +,,2.851880,42.001599,, +,,2.853540,42.001606,, +,,2.855200,41.995717,, +,,2.856860,41.995972,, +,,2.858520,41.994033,, +,,2.860180,41.994070,, +,,2.861840,42.002128,, +,,2.863500,41.998085,, +,,2.865160,42.000358,, +,,2.866820,41.996590,, +,,2.868480,41.997751,, +,,2.870140,42.005767,, +,,2.871800,41.998842,, +,,2.873460,42.001332,, +,,2.875120,41.994703,, +,,2.876780,42.001902,, +,,2.878440,42.002267,, +,,2.880100,41.998459,, +,,2.881760,41.995160,, +,,2.883420,42.004495,, +,,2.885080,42.001352,, +,,2.886740,41.997968,, +,,2.888400,41.996429,, +,,2.890060,42.002238,, +,,2.891720,41.994812,, +,,2.893380,42.001754,, +,,2.895040,41.997897,, +,,2.896700,42.004459,, +,,2.898360,42.006058,, +,,2.900020,42.002238,, +,,2.901680,41.989438,, +,,2.903340,42.003576,, +,,2.905000,41.998928,, +,,2.906660,42.000469,, +,,2.908320,41.993259,, +,,2.909980,42.001298,, +,,2.911640,42.003936,, +,,2.913300,42.000340,, +,,2.914960,42.003974,, +,,2.916620,41.997196,, +,,2.918280,41.997628,, +,,2.919940,42.001651,, +,,2.921600,42.001033,, +,,2.923260,42.005000,, +,,2.924920,42.001166,, +,,2.926580,42.003054,, +,,2.928240,42.000142,, +,,2.929900,41.999919,, +,,2.931560,41.996541,, +,,2.933220,42.005682,, +,,2.934880,41.997862,, +,,2.936540,41.997548,, +,,2.938200,41.989341,, +,,2.939860,41.996434,, +,,2.941520,41.996077,, +,,2.943180,41.999015,, +,,2.944840,41.999268,, +,,2.946500,42.004760,, +,,2.948160,42.003917,, +,,2.949820,41.999436,, +,,2.951480,42.006739,, +,,2.953140,42.003450,, +,,2.954800,42.000459,, +,,2.956460,42.001773,, +,,2.958120,41.999560,, +,,2.959780,41.997264,, +,,2.961440,41.995779,, +,,2.963100,42.000237,, +,,2.964760,41.999765,, +,,2.966420,42.001468,, +,,2.968080,42.001180,, +,,2.969740,41.996308,, +,,2.971400,41.999128,, +,,2.973060,42.001075,, +,,2.974720,42.007873,, +,,2.976380,41.993588,, +,,2.978040,42.001911,, +,,2.979700,41.998689,, +,,2.981360,42.001554,, +,,2.983020,42.003385,, +,,2.984680,41.998724,, +,,2.986340,41.998699,, +,,2.988000,42.000740,, diff --git a/examples/data/characterization_akta/manifest.json b/examples/data/characterization_akta/manifest.json new file mode 100644 index 0000000..8067a1a --- /dev/null +++ b/examples/data/characterization_akta/manifest.json @@ -0,0 +1,554 @@ +{ + "description": "Synthetic two-step characterization chain (extra-column volume, then column packing), shaped like \u00c4kta/Unicorn exports. Every number is invented for this synthetic instrument.", + "initial_store": { + "step": "initial", + "prior": null, + "specs": { + "flow_sheet.column.axial_dispersion": { + "species_indexed": true + }, + "flow_sheet.column.bed_porosity": { + "species_indexed": false + }, + "flow_sheet.column.film_diffusion": { + "species_indexed": true + }, + "flow_sheet.column.particle_porosity": { + "species_indexed": false + }, + "flow_sheet.column.particle_radius": { + "species_indexed": false + }, + "flow_sheet.tubing_detectors.axial_dispersion": { + "species_indexed": true + }, + "flow_sheet.tubing_detectors.diameter": { + "species_indexed": false + }, + "flow_sheet.tubing_detectors.length": { + "species_indexed": false + }, + "flow_sheet.tubing_pre_column.axial_dispersion": { + "species_indexed": true + }, + "flow_sheet.tubing_pre_column.diameter": { + "species_indexed": false + }, + "flow_sheet.tubing_pre_column.length": { + "species_indexed": false + } + }, + "entries": { + "flow_sheet.column.film_diffusion": { + "value": { + "SmallTracer": 8e-05, + "LargeTracer": 0.0 + }, + "provenance": { + "step": "initial", + "note": "known hardware, not determined by either step" + } + }, + "flow_sheet.column.particle_radius": { + "value": 3e-05, + "provenance": { + "step": "initial", + "note": "known hardware, not determined by either step" + } + }, + "flow_sheet.tubing_detectors.diameter": { + "value": 0.0005, + "provenance": { + "step": "initial", + "note": "known hardware, not determined by either step" + } + }, + "flow_sheet.tubing_detectors.length": { + "value": 0.05, + "provenance": { + "step": "initial", + "note": "known hardware, not determined by either step" + } + }, + "flow_sheet.tubing_pre_column.diameter": { + "value": 0.0008, + "provenance": { + "step": "initial", + "note": "known hardware, not determined by either step" + } + } + } + }, + "steps": [ + { + "name": "Extra-column volume", + "stage": "tubing", + "options": { + "tubing": "tubing_pre_column" + }, + "comparisons": [ + "System pulse 1 (UV)", + "System pulse 2 (UV)" + ], + "bounds": { + "tubing_pre_column_length": [ + 0.1, + 1.0 + ], + "tubing_pre_column_axial_dispersion": [ + 1e-08, + 1e-05 + ] + }, + "frozen": [], + "truth": { + "flow_sheet.tubing_pre_column.length": 0.42, + "flow_sheet.tubing_pre_column.axial_dispersion": 4.5e-07 + } + }, + { + "name": "Column packing", + "stage": "bed", + "options": { + "include_particle_porosity": true + }, + "comparisons": [ + "Small tracer pulse 1 (conductivity)", + "Small tracer pulse 2 (conductivity)", + "Large tracer pulse 1 (UV)", + "Large tracer pulse 2 (UV)" + ], + "bounds": { + "bed_porosity": [ + 0.25, + 0.5 + ], + "particle_porosity": [ + 0.55, + 0.85 + ], + "axial_dispersion": [ + 1e-09, + 1e-06 + ] + }, + "frozen": [], + "truth": { + "flow_sheet.column.bed_porosity": 0.36, + "flow_sheet.column.particle_porosity": 0.68, + "flow_sheet.column.axial_dispersion": 5.5e-08 + } + } + ], + "comparisons": [ + { + "name": "System pulse 1 (UV)", + "data_file": "system_pulse_1_uv.csv", + "channel": "UV 1_280", + "flow_rate": null, + "recipe": { + "components": [ + "SystemTracer" + ], + "column_key": "Lumped Rate Model With Pores (LRMP)", + "binding_key": "None", + "template_key": "Pulse Injection", + "instrument": { + "include_sample_loop": true, + "sample_loop_volume": 2e-08, + "sample_loop_diameter_auto": false, + "sample_loop_diameter": 0.00075, + "bypass_units": [ + "tubing_pre_injection", + "column", + "tubing_post_column", + "tubing_detectors" + ], + "unit_values": {} + }, + "multiplex_state": {}, + "show_optional_column": false, + "show_optional_binding": false, + "column_values": {}, + "binding_values": {}, + "model_values": { + "c_buffer_a": [ + 0.0 + ], + "c_sample": [ + 50.0 + ], + "cycle_time": 150.0, + "flow_rate": 8.3e-09 + } + }, + "overrides": {}, + "injection_marker": "Phase Elution", + "measured_injection": null, + "baseline_window": null, + "normalization": { + "kind": "area", + "target_area": 1e-06 + }, + "solution_path": "tubing_pre_column.outlet", + "components": [ + "SystemTracer" + ], + "metric": "NRMSE", + "window": [ + null, + null + ], + "probe": "SystemTracer", + "experiment_type": "system_pulse" + }, + { + "name": "System pulse 2 (UV)", + "data_file": "system_pulse_2_uv.csv", + "channel": "UV 1_280", + "flow_rate": null, + "recipe": { + "components": [ + "SystemTracer" + ], + "column_key": "Lumped Rate Model With Pores (LRMP)", + "binding_key": "None", + "template_key": "Pulse Injection", + "instrument": { + "include_sample_loop": true, + "sample_loop_volume": 2e-08, + "sample_loop_diameter_auto": false, + "sample_loop_diameter": 0.00075, + "bypass_units": [ + "tubing_pre_injection", + "column", + "tubing_post_column", + "tubing_detectors" + ], + "unit_values": {} + }, + "multiplex_state": {}, + "show_optional_column": false, + "show_optional_binding": false, + "column_values": {}, + "binding_values": {}, + "model_values": { + "c_buffer_a": [ + 0.0 + ], + "c_sample": [ + 50.0 + ], + "cycle_time": 150.0, + "flow_rate": 8.3e-09 + } + }, + "overrides": {}, + "injection_marker": "Phase Elution", + "measured_injection": null, + "baseline_window": null, + "normalization": { + "kind": "area", + "target_area": 1e-06 + }, + "solution_path": "tubing_pre_column.outlet", + "components": [ + "SystemTracer" + ], + "metric": "NRMSE", + "window": [ + null, + null + ], + "probe": "SystemTracer", + "experiment_type": "system_pulse" + }, + { + "name": "Small tracer pulse 1 (conductivity)", + "data_file": "small_tracer_pulse_1_conductivity.csv", + "channel": "Cond", + "flow_rate": null, + "recipe": { + "components": [ + "SmallTracer" + ], + "column_key": "Lumped Rate Model With Pores (LRMP)", + "binding_key": "None", + "template_key": "Pulse Injection", + "instrument": { + "include_sample_loop": true, + "sample_loop_volume": 2e-08, + "sample_loop_diameter_auto": false, + "sample_loop_diameter": 0.00075, + "bypass_units": [ + "tubing_pre_injection", + "tubing_post_column" + ], + "unit_values": {} + }, + "multiplex_state": {}, + "show_optional_column": false, + "show_optional_binding": false, + "column_values": { + "diameter": 0.005, + "length": 0.02, + "particle_radius": 1e-05, + "bed_porosity": 0.5, + "particle_porosity": 0.5, + "axial_dispersion": 1e-08, + "film_diffusion": 0.0001 + }, + "binding_values": {}, + "model_values": { + "c_buffer_a": [ + 0.0 + ], + "c_sample": [ + 50.0 + ], + "cycle_time": 300.0, + "flow_rate": 8.3e-09 + } + }, + "overrides": {}, + "injection_marker": "Phase Elution", + "measured_injection": null, + "baseline_window": [ + 1.3695, + 2.8634999999999997 + ], + "normalization": { + "kind": "area", + "target_area": 1e-06 + }, + "solution_path": "tubing_detectors.outlet", + "components": [ + "SmallTracer" + ], + "metric": "NRMSE", + "window": [ + null, + null + ], + "probe": "SmallTracer", + "experiment_type": "column_pulse_small_tracer" + }, + { + "name": "Small tracer pulse 2 (conductivity)", + "data_file": "small_tracer_pulse_2_conductivity.csv", + "channel": "Cond", + "flow_rate": null, + "recipe": { + "components": [ + "SmallTracer" + ], + "column_key": "Lumped Rate Model With Pores (LRMP)", + "binding_key": "None", + "template_key": "Pulse Injection", + "instrument": { + "include_sample_loop": true, + "sample_loop_volume": 2e-08, + "sample_loop_diameter_auto": false, + "sample_loop_diameter": 0.00075, + "bypass_units": [ + "tubing_pre_injection", + "tubing_post_column" + ], + "unit_values": {} + }, + "multiplex_state": {}, + "show_optional_column": false, + "show_optional_binding": false, + "column_values": { + "diameter": 0.005, + "length": 0.02, + "particle_radius": 1e-05, + "bed_porosity": 0.5, + "particle_porosity": 0.5, + "axial_dispersion": 1e-08, + "film_diffusion": 0.0001 + }, + "binding_values": {}, + "model_values": { + "c_buffer_a": [ + 0.0 + ], + "c_sample": [ + 50.0 + ], + "cycle_time": 300.0, + "flow_rate": 8.3e-09 + } + }, + "overrides": {}, + "injection_marker": "Phase Elution", + "measured_injection": null, + "baseline_window": null, + "normalization": { + "kind": "area", + "target_area": 1e-06 + }, + "solution_path": "tubing_detectors.outlet", + "components": [ + "SmallTracer" + ], + "metric": "NRMSE", + "window": [ + null, + null + ], + "probe": "SmallTracer", + "experiment_type": "column_pulse_small_tracer" + }, + { + "name": "Large tracer pulse 1 (UV)", + "data_file": "large_tracer_pulse_1_uv.csv", + "channel": "UV 1_280", + "flow_rate": null, + "recipe": { + "components": [ + "LargeTracer" + ], + "column_key": "Lumped Rate Model With Pores (LRMP)", + "binding_key": "None", + "template_key": "Pulse Injection", + "instrument": { + "include_sample_loop": true, + "sample_loop_volume": 2e-08, + "sample_loop_diameter_auto": false, + "sample_loop_diameter": 0.00075, + "bypass_units": [ + "tubing_pre_injection", + "tubing_post_column", + "tubing_detectors" + ], + "unit_values": {} + }, + "multiplex_state": {}, + "show_optional_column": false, + "show_optional_binding": false, + "column_values": { + "diameter": 0.005, + "length": 0.02, + "particle_radius": 1e-05, + "bed_porosity": 0.5, + "particle_porosity": 0.5, + "axial_dispersion": 1e-08, + "film_diffusion": 0.0001 + }, + "binding_values": {}, + "model_values": { + "c_buffer_a": [ + 0.0 + ], + "c_sample": [ + 50.0 + ], + "cycle_time": 300.0, + "flow_rate": 8.3e-09 + } + }, + "overrides": {}, + "injection_marker": "Phase Elution", + "measured_injection": null, + "baseline_window": null, + "normalization": { + "kind": "area", + "target_area": 1e-06 + }, + "solution_path": "column.outlet", + "components": [ + "LargeTracer" + ], + "metric": "NRMSE", + "window": [ + null, + null + ], + "probe": "LargeTracer", + "experiment_type": "column_pulse_large_tracer" + }, + { + "name": "Large tracer pulse 2 (UV)", + "data_file": "large_tracer_pulse_2_uv.csv", + "channel": "UV 1_280", + "flow_rate": null, + "recipe": { + "components": [ + "LargeTracer" + ], + "column_key": "Lumped Rate Model With Pores (LRMP)", + "binding_key": "None", + "template_key": "Pulse Injection", + "instrument": { + "include_sample_loop": true, + "sample_loop_volume": 2e-08, + "sample_loop_diameter_auto": false, + "sample_loop_diameter": 0.00075, + "bypass_units": [ + "tubing_pre_injection", + "tubing_post_column", + "tubing_detectors" + ], + "unit_values": {} + }, + "multiplex_state": {}, + "show_optional_column": false, + "show_optional_binding": false, + "column_values": { + "diameter": 0.005, + "length": 0.02, + "particle_radius": 1e-05, + "bed_porosity": 0.5, + "particle_porosity": 0.5, + "axial_dispersion": 1e-08, + "film_diffusion": 0.0001 + }, + "binding_values": {}, + "model_values": { + "c_buffer_a": [ + 0.0 + ], + "c_sample": [ + 50.0 + ], + "cycle_time": 300.0, + "flow_rate": 8.3e-09 + } + }, + "overrides": {}, + "injection_marker": "Phase Elution", + "measured_injection": null, + "baseline_window": null, + "normalization": { + "kind": "area", + "target_area": 1e-06 + }, + "solution_path": "column.outlet", + "components": [ + "LargeTracer" + ], + "metric": "NRMSE", + "window": [ + null, + null + ], + "probe": "LargeTracer", + "experiment_type": "column_pulse_large_tracer" + } + ], + "components": [ + { + "name": "SystemTracer", + "role": "system tracer" + }, + { + "name": "SmallTracer", + "role": "small tracer" + }, + { + "name": "LargeTracer", + "role": "large tracer" + } + ] +} diff --git a/examples/data/characterization_akta/small_tracer_pulse_1_conductivity.csv b/examples/data/characterization_akta/small_tracer_pulse_1_conductivity.csv new file mode 100644 index 0000000..6245552 --- /dev/null +++ b/examples/data/characterization_akta/small_tracer_pulse_1_conductivity.csv @@ -0,0 +1,1804 @@ +Chromatogram,,,,, +UV 1_280,,Cond,,Run Log, +ml,mAU,ml,mS/cm,ml, +0.000000,2.774292,0.000000,41.997379,0.000000,Phase Equilibration +0.004150,4.070215,0.001660,42.001343,0.498000,Phase Elution +0.008300,3.744496,0.003320,41.995974,2.539800,Phase Wash +0.012450,4.079431,0.004980,41.993561,, +0.016600,4.179006,0.006640,41.999897,, +0.020750,3.530475,0.008300,42.001955,, +0.024900,3.561104,0.009960,42.000552,, +0.029050,3.879515,0.011620,41.995790,, +0.033200,4.067752,0.013280,41.998037,, +0.037350,4.081118,0.014940,42.000865,, +0.041500,4.484119,0.016600,42.002221,, +0.045650,4.575414,0.018260,42.000465,, +0.049800,4.166825,0.019920,42.005141,, +0.053950,3.547960,0.021580,42.003614,, +0.058100,4.407148,0.023240,42.003620,, +0.062250,3.826405,0.024900,41.994273,, +0.066400,3.621192,0.026560,41.998753,, +0.070550,3.540051,0.028220,41.998938,, +0.074700,4.314650,0.029880,41.989593,, +0.078850,4.322433,0.031540,41.977929,, +0.083000,3.743756,0.033200,41.913345,, +0.087150,3.026965,0.034860,41.728522,, +0.091300,3.445784,0.036520,41.350303,, +0.095450,4.545078,0.038180,40.790236,, +0.099600,3.967159,0.039840,40.235879,, +0.103750,3.650247,0.041500,39.993577,, +0.107900,3.970735,0.043160,40.239067,, +0.112050,3.927163,0.044820,40.783653,, +0.116200,4.071268,0.046480,41.348054,, +0.120350,3.441521,0.048140,41.738129,, +0.124500,3.366373,0.049800,41.919751,, +0.128650,4.466858,0.051460,41.976043,, +0.132800,3.943846,0.053120,41.996844,, +0.136950,3.896777,0.054780,42.001643,, +0.141100,4.148368,0.056440,42.003123,, +0.145250,4.245023,0.058100,41.995187,, +0.149400,4.280870,0.059760,42.008428,, +0.153550,4.097211,0.061420,42.000042,, +0.157700,3.445050,0.063080,41.999473,, +0.161850,3.848760,0.064740,41.994462,, +0.166000,4.288804,0.066400,41.998308,, +0.170150,3.808110,0.068060,41.998387,, +0.174300,3.188482,0.069720,41.999260,, +0.178450,3.433678,0.071380,41.996658,, +0.182600,3.424057,0.073040,42.000508,, +0.186750,2.975791,0.074700,41.998419,, +0.190900,3.283221,0.076360,41.992802,, +0.195050,3.955816,0.078020,42.004736,, +0.199200,4.245157,0.079680,42.002259,, +0.203350,4.249144,0.081340,42.001677,, +0.207500,4.565980,0.083000,42.003569,, +0.211650,3.756281,0.084660,41.999528,, +0.215800,2.925518,0.086320,42.003425,, +0.219950,4.226655,0.087980,42.005305,, +0.224100,4.018276,0.089640,42.000689,, +0.228250,4.341973,0.091300,42.000717,, +0.232400,3.627646,0.092960,42.005198,, +0.236550,3.370854,0.094620,41.999750,, +0.240700,3.667666,0.096280,42.000668,, +0.244850,5.007206,0.097940,42.003392,, +0.249000,4.446883,0.099600,42.006815,, +0.253150,4.278784,0.101260,42.004406,, +0.257300,4.166558,0.102920,41.990857,, +0.261450,4.829309,0.104580,41.998323,, +0.265600,4.105804,0.106240,41.991450,, +0.269750,4.267234,0.107900,41.999071,, +0.273900,3.895613,0.109560,41.996062,, +0.278050,3.711953,0.111220,41.999781,, +0.282200,3.691431,0.112880,42.003456,, +0.286350,3.653642,0.114540,41.999476,, +0.290500,4.023325,0.116200,42.000935,, +0.294650,3.892109,0.117860,41.998957,, +0.298800,4.224387,0.119520,41.997361,, +0.302950,5.201993,0.121180,42.000283,, +0.307100,4.431747,0.122840,41.996865,, +0.311250,4.573079,0.124500,41.994470,, +0.315400,4.046592,0.126160,41.998292,, +0.319550,4.081950,0.127820,41.997816,, +0.323700,3.280722,0.129480,41.998075,, +0.327850,4.239740,0.131140,41.997961,, +0.332000,4.066436,0.132800,41.994079,, +0.336150,5.125946,0.134460,42.002019,, +0.340300,3.730060,0.136120,41.993231,, +0.344450,4.628792,0.137780,42.005476,, +0.348600,4.637977,0.139440,42.004253,, +0.352750,3.532945,0.141100,42.000345,, +0.356900,3.809205,0.142760,41.998406,, +0.361050,4.277204,0.144420,42.008203,, +0.365200,3.577502,0.146080,41.998604,, +0.369350,3.717349,0.147740,42.002020,, +0.373500,4.369965,0.149400,42.006614,, +0.377650,4.946374,0.151060,41.999812,, +0.381800,3.506733,0.152720,42.001602,, +0.385950,3.514445,0.154380,42.003922,, +0.390100,4.047767,0.156040,41.999755,, +0.394250,3.807275,0.157700,42.010723,, +0.398400,4.450388,0.159360,41.996423,, +0.402550,3.853618,0.161020,42.004902,, +0.406700,4.208551,0.162680,42.005678,, +0.410850,4.500550,0.164340,42.003434,, +0.415000,3.825842,0.166000,42.003338,, +0.419150,4.001357,0.167660,41.998321,, +0.423300,4.151480,0.169320,41.997447,, +0.427450,4.286519,0.170980,42.006663,, +0.431600,3.754310,0.172640,42.004542,, +0.435750,4.222898,0.174300,41.997377,, +0.439900,3.529961,0.175960,42.001274,, +0.444050,4.430559,0.177620,42.003307,, +0.448200,3.733031,0.179280,41.997411,, +0.452350,4.058036,0.180940,42.000294,, +0.456500,3.871021,0.182600,41.998761,, +0.460650,4.181445,0.184260,41.999827,, +0.464800,4.891720,0.185920,42.003621,, +0.468950,3.799115,0.187580,41.994288,, +0.473100,4.226387,0.189240,41.997805,, +0.477250,4.479850,0.190900,41.993510,, +0.481400,3.476370,0.192560,41.998766,, +0.485550,3.124175,0.194220,42.000779,, +0.489700,2.797769,0.195880,41.994532,, +0.493850,5.247606,0.197540,42.001123,, +0.498000,4.258770,0.199200,42.003136,, +0.502150,4.862069,0.200860,41.999769,, +0.506300,3.130327,0.202520,42.005196,, +0.510450,3.293211,0.204180,41.998767,, +0.514600,3.753873,0.205840,42.000994,, +0.518750,2.852984,0.207500,41.994448,, +0.522900,4.670219,0.209160,42.001786,, +0.527050,4.198682,0.210820,41.999612,, +0.531200,4.314392,0.212480,41.996300,, +0.535350,3.929163,0.214140,42.000994,, +0.539500,4.298387,0.215800,41.995009,, +0.543650,4.137866,0.217460,42.004057,, +0.547800,3.588582,0.219120,41.996095,, +0.551950,3.935665,0.220780,42.004682,, +0.556100,4.256744,0.222440,41.994321,, +0.560250,3.703026,0.224100,42.004786,, +0.564400,3.405123,0.225760,41.998454,, +0.568550,4.195316,0.227420,42.000907,, +0.572700,4.418033,0.229080,42.003472,, +0.576850,3.132183,0.230740,41.999950,, +0.581000,3.290352,0.232400,41.994801,, +0.585150,4.031376,0.234060,42.002608,, +0.589300,4.946346,0.235720,41.997098,, +0.593450,3.883375,0.237380,41.998404,, +0.597600,4.225570,0.239040,42.007562,, +0.601750,3.805127,0.240700,41.995603,, +0.605900,4.320761,0.242360,41.997236,, +0.610050,4.783790,0.244020,42.004968,, +0.614200,4.067225,0.245680,41.998953,, +0.618350,4.538802,0.247340,41.994320,, +0.622500,3.770658,0.249000,41.997955,, +0.626650,3.723845,0.250660,42.001565,, +0.630800,3.603628,0.252320,41.992357,, +0.634950,2.491232,0.253980,42.002367,, +0.639100,4.412576,0.255640,42.007632,, +0.643250,3.890905,0.257300,42.002019,, +0.647400,3.316739,0.258960,41.998285,, +0.651550,4.086471,0.260620,42.002207,, +0.655700,4.608689,0.262280,42.000503,, +0.659850,3.829763,0.263940,41.995431,, +0.664000,4.283855,0.265600,42.001115,, +0.668150,3.550148,0.267260,42.004072,, +0.672300,3.577959,0.268920,42.004400,, +0.676450,4.070172,0.270580,42.001019,, +0.680600,3.002242,0.272240,41.998541,, +0.684750,4.719330,0.273900,42.007350,, +0.688900,3.190980,0.275560,42.001737,, +0.693050,4.091352,0.277220,42.001012,, +0.697200,3.757608,0.278880,41.999733,, +0.701350,4.500829,0.280540,42.003830,, +0.705500,3.868243,0.282200,42.002568,, +0.709650,3.629285,0.283860,42.005364,, +0.713800,3.381148,0.285520,42.000081,, +0.717950,3.478783,0.287180,42.002115,, +0.722100,3.844750,0.288840,41.998368,, +0.726250,3.824444,0.290500,41.994997,, +0.730400,4.042208,0.292160,41.999648,, +0.734550,4.957039,0.293820,41.998131,, +0.738700,4.036560,0.295480,41.998096,, +0.742850,4.004874,0.297140,41.999503,, +0.747000,4.306874,0.298800,42.005406,, +0.751150,3.848822,0.300460,42.000521,, +0.755300,3.552435,0.302120,41.999203,, +0.759450,4.578407,0.303780,42.006418,, +0.763600,4.394603,0.305440,42.001671,, +0.767750,3.863584,0.307100,41.996954,, +0.771900,3.174843,0.308760,41.995903,, +0.776050,3.661770,0.310420,41.999567,, +0.780200,3.718668,0.312080,41.990695,, +0.784350,3.620777,0.313740,41.997183,, +0.788500,3.303390,0.315400,42.007027,, +0.792650,3.730831,0.317060,41.998729,, +0.796800,3.837224,0.318720,41.995155,, +0.800950,4.393056,0.320380,42.004127,, +0.805100,4.409249,0.322040,41.999534,, +0.809250,3.924403,0.323700,42.001261,, +0.813400,3.895176,0.325360,42.003655,, +0.817550,3.782143,0.327020,41.994989,, +0.821700,3.617390,0.328680,41.994671,, +0.825850,4.867656,0.330340,42.004168,, +0.830000,4.010424,0.332000,41.995134,, +0.834150,3.730786,0.333660,42.000698,, +0.838300,3.748149,0.335320,42.003978,, +0.842450,3.916098,0.336980,41.996702,, +0.846600,3.519012,0.338640,42.000442,, +0.850750,3.840701,0.340300,41.997167,, +0.854900,4.031172,0.341960,42.000917,, +0.859050,4.468644,0.343620,42.000885,, +0.863200,3.996980,0.345280,41.997164,, +0.867350,4.318098,0.346940,41.994301,, +0.871500,3.480142,0.348600,42.006857,, +0.875650,4.119536,0.350260,42.004465,, +0.879800,4.420533,0.351920,41.999014,, +0.883950,3.391948,0.353580,42.006018,, +0.888100,4.848298,0.355240,41.999591,, +0.892250,4.082850,0.356900,42.004223,, +0.896400,4.217424,0.358560,41.999113,, +0.900550,3.763842,0.360220,41.995174,, +0.904700,4.650352,0.361880,41.998970,, +0.908850,3.939098,0.363540,41.991600,, +0.913000,3.957135,0.365200,42.000056,, +0.917150,4.309144,0.366860,41.998295,, +0.921300,4.110984,0.368520,41.997381,, +0.925450,3.209958,0.370180,41.998583,, +0.929600,3.404904,0.371840,42.001204,, +0.933750,4.085015,0.373500,41.997837,, +0.937900,4.138687,0.375160,41.998658,, +0.942050,4.671939,0.376820,41.999458,, +0.946200,4.489817,0.378480,42.002497,, +0.950350,3.558239,0.380140,42.003168,, +0.954500,3.424169,0.381800,42.002568,, +0.958650,4.991566,0.383460,42.004862,, +0.962800,3.845500,0.385120,41.998854,, +0.966950,4.172432,0.386780,42.009617,, +0.971100,3.557885,0.388440,41.998442,, +0.975250,3.813948,0.390100,42.005522,, +0.979400,3.829677,0.391760,41.997884,, +0.983550,3.803749,0.393420,42.001437,, +0.987700,4.111378,0.395080,42.001761,, +0.991850,3.665447,0.396740,42.002593,, +0.996000,3.040546,0.398400,42.002529,, +1.000150,3.839548,0.400060,42.005687,, +1.004300,4.118798,0.401720,42.000363,, +1.008450,4.256812,0.403380,42.000139,, +1.012600,3.948012,0.405040,41.995560,, +1.016750,4.002139,0.406700,42.001068,, +1.020900,4.282662,0.408360,41.997402,, +1.025050,4.096699,0.410020,41.996079,, +1.029200,4.066271,0.411680,42.005432,, +1.033350,3.870937,0.413340,41.999822,, +1.037500,3.568573,0.415000,41.998575,, +1.041650,3.762909,0.416660,41.993862,, +1.045800,4.718835,0.418320,41.993615,, +1.049950,4.091950,0.419980,41.997630,, +1.054100,4.077268,0.421640,41.997990,, +1.058250,4.122022,0.423300,42.002053,, +1.062400,4.229016,0.424960,41.999973,, +1.066550,4.181386,0.426620,41.996654,, +1.070700,3.936834,0.428280,41.996834,, +1.074850,4.222353,0.429940,41.997470,, +1.079000,4.319910,0.431600,42.004829,, +1.083150,4.004802,0.433260,42.006693,, +1.087300,3.828566,0.434920,41.999263,, +1.091450,3.620495,0.436580,42.011531,, +1.095600,4.268027,0.438240,41.994994,, +1.099750,3.428593,0.439900,41.993380,, +1.103900,3.349261,0.441560,41.992551,, +1.108050,4.383599,0.443220,41.999687,, +1.112200,5.035589,0.444880,42.002069,, +1.116350,2.769723,0.446540,41.998313,, +1.120500,2.925375,0.448200,42.007666,, +1.124650,3.079955,0.449860,41.998414,, +1.128800,3.436724,0.451520,42.002704,, +1.132950,4.602058,0.453180,42.001447,, +1.137100,4.616059,0.454840,41.997224,, +1.141250,4.215654,0.456500,41.998161,, +1.145400,4.339847,0.458160,41.995348,, +1.149550,3.034456,0.459820,42.004985,, +1.153700,3.706623,0.461480,42.001999,, +1.157850,3.280175,0.463140,41.999583,, +1.162000,4.215200,0.464800,41.988182,, +1.166150,4.314689,0.466460,41.998845,, +1.170300,4.032270,0.468120,41.999200,, +1.174450,3.984218,0.469780,41.998819,, +1.178600,4.093826,0.471440,41.995782,, +1.182750,3.835150,0.473100,42.002854,, +1.186900,3.484276,0.474760,41.999194,, +1.191050,3.382828,0.476420,42.006692,, +1.195200,4.137487,0.478080,41.998960,, +1.199350,4.172134,0.479740,41.998088,, +1.203500,4.314740,0.481400,41.994406,, +1.207650,3.773768,0.483060,41.999747,, +1.211800,3.586767,0.484720,41.996968,, +1.215950,4.039251,0.486380,42.002923,, +1.220100,4.351467,0.488040,42.001231,, +1.224250,4.506961,0.489700,42.002790,, +1.228400,4.410504,0.491360,41.998948,, +1.232550,3.207533,0.493020,42.002985,, +1.236700,3.652902,0.494680,41.998096,, +1.240850,4.592280,0.496340,41.995063,, +1.245000,4.435060,0.498000,41.994621,, +1.249150,3.883541,0.499660,42.002918,, +1.253300,4.314608,0.501320,41.997578,, +1.257450,4.599701,0.502980,41.995522,, +1.261600,4.476827,0.504640,41.998350,, +1.265750,4.773419,0.506300,41.999485,, +1.269900,3.608238,0.507960,42.002460,, +1.274050,4.617610,0.509620,41.999387,, +1.278200,5.229804,0.511280,41.994765,, +1.282350,3.588660,0.512940,42.002023,, +1.286500,3.181083,0.514600,42.000115,, +1.290650,3.689295,0.516260,41.996396,, +1.294800,4.216725,0.517920,41.993983,, +1.298950,3.886614,0.519580,41.999803,, +1.303100,4.072355,0.521240,42.003288,, +1.307250,4.848075,0.522900,42.004876,, +1.311400,2.896093,0.524560,42.002824,, +1.315550,3.259835,0.526220,42.001826,, +1.319700,3.514737,0.527880,42.005359,, +1.323850,5.192594,0.529540,41.999432,, +1.328000,4.272879,0.531200,41.997170,, +1.332150,3.329145,0.532860,41.996309,, +1.336300,3.603574,0.534520,41.999595,, +1.340450,4.433073,0.536180,41.998200,, +1.344600,3.582819,0.537840,41.992909,, +1.348750,3.643135,0.539500,42.000708,, +1.352900,3.959105,0.541160,42.002302,, +1.357050,3.551322,0.542820,41.999804,, +1.361200,3.019229,0.544480,42.003991,, +1.365350,3.141678,0.546140,41.992932,, +1.369500,3.873409,0.547800,42.001426,, +1.373650,4.279669,0.549460,42.004087,, +1.377800,3.832579,0.551120,41.996649,, +1.381950,4.203305,0.552780,42.000531,, +1.386100,3.541900,0.554440,42.004077,, +1.390250,3.567060,0.556100,41.996663,, +1.394400,3.730989,0.557760,41.993747,, +1.398550,4.173994,0.559420,42.000410,, +1.402700,5.291224,0.561080,42.003774,, +1.406850,2.676272,0.562740,41.995043,, +1.411000,3.079596,0.564400,41.996748,, +1.415150,5.139823,0.566060,41.999666,, +1.419300,3.918881,0.567720,41.999916,, +1.423450,3.871677,0.569380,41.996873,, +1.427600,3.769411,0.571040,42.004812,, +1.431750,4.162000,0.572700,42.003983,, +1.435900,4.364694,0.574360,42.000431,, +1.440050,4.277240,0.576020,42.001677,, +1.444200,4.232269,0.577680,42.004666,, +1.448350,3.503756,0.579340,42.001040,, +1.452500,3.812462,0.581000,41.997945,, +1.456650,5.020701,0.582660,42.002961,, +1.460800,3.697004,0.584320,41.999468,, +1.464950,4.298981,0.585980,41.996667,, +1.469100,3.113837,0.587640,42.001355,, +1.473250,4.845122,0.589300,41.993382,, +1.477400,4.564700,0.590960,42.001952,, +1.481550,4.307152,0.592620,41.996564,, +1.485700,3.786910,0.594280,42.001425,, +1.489850,3.585581,0.595940,42.001135,, +1.494000,3.882507,0.597600,42.000365,, +1.498150,4.511232,0.599260,42.004829,, +1.502300,4.019340,0.600920,42.002557,, +1.506450,3.969426,0.602580,41.998000,, +1.510600,4.820682,0.604240,42.005105,, +1.514750,4.571594,0.605900,42.010191,, +1.518900,3.863185,0.607560,41.992876,, +1.523050,3.718772,0.609220,42.003163,, +1.527200,3.337724,0.610880,42.000708,, +1.531350,3.183145,0.612540,41.997332,, +1.535500,4.530180,0.614200,42.005393,, +1.539650,3.993791,0.615860,42.002578,, +1.543800,4.264618,0.617520,41.990478,, +1.547950,5.304110,0.619180,42.000597,, +1.552100,3.626081,0.620840,41.998167,, +1.556250,3.413245,0.622500,41.999354,, +1.560400,4.493798,0.624160,41.996991,, +1.564550,4.535131,0.625820,42.004023,, +1.568700,3.280493,0.627480,42.004663,, +1.572850,4.701633,0.629140,41.998437,, +1.577000,3.802010,0.630800,41.995091,, +1.581150,4.391927,0.632460,42.000574,, +1.585300,4.356824,0.634120,42.004629,, +1.589450,3.438347,0.635780,42.000463,, +1.593600,3.703933,0.637440,42.003961,, +1.597750,3.987362,0.639100,42.000354,, +1.601900,3.712099,0.640760,42.002877,, +1.606050,4.409902,0.642420,42.001210,, +1.610200,5.088459,0.644080,41.997438,, +1.614350,3.903170,0.645740,41.996574,, +1.618500,4.939171,0.647400,41.997505,, +1.622650,3.735035,0.649060,41.997591,, +1.626800,4.101878,0.650720,41.997816,, +1.630950,3.976103,0.652380,41.997789,, +1.635100,3.294318,0.654040,41.998865,, +1.639250,3.892174,0.655700,41.992040,, +1.643400,4.012770,0.657360,41.996466,, +1.647550,4.095542,0.659020,41.994894,, +1.651700,4.573633,0.660680,42.002754,, +1.655850,3.788843,0.662340,41.997324,, +1.660000,3.917583,0.664000,42.004753,, +1.664150,4.004656,0.665660,42.000875,, +1.668300,3.771501,0.667320,42.002841,, +1.672450,5.045630,0.668980,42.003939,, +1.676600,4.357735,0.670640,41.995757,, +1.680750,4.111884,0.672300,42.002822,, +1.684900,3.769126,0.673960,41.995815,, +1.689050,3.653752,0.675620,42.001431,, +1.693200,4.244518,0.677280,42.007013,, +1.697350,4.616524,0.678940,42.008363,, +1.701500,3.795971,0.680600,42.000013,, +1.705650,3.886773,0.682260,41.996311,, +1.709800,4.114274,0.683920,41.994324,, +1.713950,4.535490,0.685580,42.002108,, +1.718100,3.735436,0.687240,42.003303,, +1.722250,3.737418,0.688900,42.003783,, +1.726400,3.660564,0.690560,41.995714,, +1.730550,3.632941,0.692220,42.001289,, +1.734700,5.027403,0.693880,41.996001,, +1.738850,4.204250,0.695540,42.001515,, +1.743000,3.544125,0.697200,41.995392,, +1.747150,4.099369,0.698860,42.000660,, +1.751300,3.196181,0.700520,42.002124,, +1.755450,4.407793,0.702180,42.001521,, +1.759600,4.572437,0.703840,42.000804,, +1.763750,4.186980,0.705500,41.993185,, +1.767900,3.876869,0.707160,41.994764,, +1.772050,3.202227,0.708820,41.999061,, +1.776200,3.599820,0.710480,41.992953,, +1.780350,3.526100,0.712140,41.997751,, +1.784500,4.924847,0.713800,42.004108,, +1.788650,3.797491,0.715460,41.998865,, +1.792800,4.649309,0.717120,41.994458,, +1.796950,4.024287,0.718780,42.004191,, +1.801100,4.084840,0.720440,42.004953,, +1.805250,4.151983,0.722100,41.992997,, +1.809400,4.029663,0.723760,41.993577,, +1.813550,3.701813,0.725420,41.995572,, +1.817700,3.396365,0.727080,42.003966,, +1.821850,3.650607,0.728740,41.989319,, +1.826000,4.559754,0.730400,41.998242,, +1.830150,4.651729,0.732060,42.002284,, +1.834300,3.230283,0.733720,42.001670,, +1.838450,4.266181,0.735380,42.003121,, +1.842600,3.784365,0.737040,42.000868,, +1.846750,3.610357,0.738700,41.997444,, +1.850900,3.801853,0.740360,41.991203,, +1.855050,3.352479,0.742020,42.000377,, +1.859200,4.188813,0.743680,42.003164,, +1.863350,3.943009,0.745340,42.001649,, +1.867500,4.736745,0.747000,42.000228,, +1.871650,4.291304,0.748660,42.000456,, +1.875800,3.522281,0.750320,41.996633,, +1.879950,3.166882,0.751980,41.990974,, +1.884100,4.236101,0.753640,42.005152,, +1.888250,4.604531,0.755300,41.997442,, +1.892400,4.430716,0.756960,41.992588,, +1.896550,4.641576,0.758620,42.003310,, +1.900700,3.860497,0.760280,42.002981,, +1.904850,4.192627,0.761940,41.999778,, +1.909000,3.877532,0.763600,41.999982,, +1.913150,3.922384,0.765260,42.000161,, +1.917300,3.995992,0.766920,42.002198,, +1.921450,3.628707,0.768580,41.995981,, +1.925600,4.139357,0.770240,42.002525,, +1.929750,4.200267,0.771900,42.003732,, +1.933900,3.714904,0.773560,42.000351,, +1.938050,4.496771,0.775220,42.004960,, +1.942200,4.366079,0.776880,42.003901,, +1.946350,4.630904,0.778540,41.994443,, +1.950500,4.307733,0.780200,42.003356,, +1.954650,4.257845,0.781860,41.997507,, +1.958800,3.954798,0.783520,41.992473,, +1.962950,5.248373,0.785180,42.007230,, +1.967100,3.732745,0.786840,42.000839,, +1.971250,4.162542,0.788500,41.996510,, +1.975400,4.236786,0.790160,42.007860,, +1.979550,3.486943,0.791820,42.003997,, +1.983700,4.233018,0.793480,41.994350,, +1.987850,3.063490,0.795140,41.993491,, +1.992000,3.610003,0.796800,41.993967,, +1.996150,4.315618,0.798460,41.994921,, +2.000300,3.224474,0.800120,41.997446,, +2.004450,4.297278,0.801780,41.997818,, +2.008600,3.900051,0.803440,41.996242,, +2.012750,4.771962,0.805100,42.004291,, +2.016900,4.162237,0.806760,41.996271,, +2.021050,4.345070,0.808420,41.995177,, +2.025200,3.994065,0.810080,42.001004,, +2.029350,3.854500,0.811740,42.005513,, +2.033500,3.523187,0.813400,42.005442,, +2.037650,4.007108,0.815060,41.997401,, +2.041800,4.569433,0.816720,42.001366,, +2.045950,3.322947,0.818380,41.999121,, +2.050100,4.696708,0.820040,42.000880,, +2.054250,3.555054,0.821700,41.994842,, +2.058400,4.093582,0.823360,42.004250,, +2.062550,4.212325,0.825020,42.008315,, +2.066700,4.189935,0.826680,42.002161,, +2.070850,4.203151,0.828340,41.996085,, +2.075000,3.287949,0.830000,42.001155,, +2.079150,3.317691,0.831660,41.998102,, +2.083300,4.906748,0.833320,42.002825,, +2.087450,3.675894,0.834980,42.001748,, +2.091600,3.637876,0.836640,41.990394,, +2.095750,3.762808,0.838300,41.998210,, +2.099900,3.741617,0.839960,42.002288,, +2.104050,5.079309,0.841620,41.999619,, +2.108200,5.723988,0.843280,42.002743,, +2.112350,4.098968,0.844940,41.998280,, +2.116500,3.925716,0.846600,42.001183,, +2.120650,4.106303,0.848260,41.994922,, +2.124800,3.949498,0.849920,42.007223,, +2.128950,4.300205,0.851580,41.998765,, +2.133100,3.698281,0.853240,42.006245,, +2.137250,5.432802,0.854900,41.995670,, +2.141400,4.029074,0.856560,41.998632,, +2.145550,4.475715,0.858220,42.003408,, +2.149700,4.638899,0.859880,42.000719,, +2.153850,4.257294,0.861540,41.999750,, +2.158000,3.644317,0.863200,41.998469,, +2.162150,3.319975,0.864860,41.999063,, +2.166300,4.194766,0.866520,42.009735,, +2.170450,3.574747,0.868180,41.993847,, +2.174600,4.630001,0.869840,42.001056,, +2.178750,3.572740,0.871500,41.992876,, +2.182900,3.665026,0.873160,42.005449,, +2.187050,3.733382,0.874820,42.003892,, +2.191200,4.326005,0.876480,41.996197,, +2.195350,4.648900,0.878140,42.003669,, +2.199500,3.067903,0.879800,41.997506,, +2.203650,4.701720,0.881460,41.995842,, +2.207800,3.909522,0.883120,41.998942,, +2.211950,3.604144,0.884780,41.993386,, +2.216100,4.266664,0.886440,42.004709,, +2.220250,3.708050,0.888100,42.003695,, +2.224400,3.761904,0.889760,41.999925,, +2.228550,4.276395,0.891420,41.995960,, +2.232700,4.304460,0.893080,41.999042,, +2.236850,3.117678,0.894740,42.005124,, +2.241000,4.144727,0.896400,41.999848,, +2.245150,3.726125,0.898060,41.997288,, +2.249300,4.102233,0.899720,41.991231,, +2.253450,3.739999,0.901380,42.002917,, +2.257600,4.845386,0.903040,41.998520,, +2.261750,3.613783,0.904700,42.000915,, +2.265900,3.533931,0.906360,41.994764,, +2.270050,4.319909,0.908020,41.999195,, +2.274200,3.486739,0.909680,41.996700,, +2.278350,3.635619,0.911340,41.998401,, +2.282500,3.625431,0.913000,42.006539,, +2.286650,2.626274,0.914660,41.999323,, +2.290800,3.969159,0.916320,41.994423,, +2.294950,4.546985,0.917980,42.001473,, +2.299100,3.885637,0.919640,42.002565,, +2.303250,3.017552,0.921300,41.999112,, +2.307400,4.605708,0.922960,41.997300,, +2.311550,3.955438,0.924620,41.994726,, +2.315700,3.982291,0.926280,42.002727,, +2.319850,4.232437,0.927940,41.997801,, +2.324000,4.312593,0.929600,42.000637,, +2.328150,3.861903,0.931260,42.004733,, +2.332300,3.387109,0.932920,42.003583,, +2.336450,3.237965,0.934580,42.001353,, +2.340600,3.646023,0.936240,42.006251,, +2.344750,4.072278,0.937900,42.002326,, +2.348900,4.280008,0.939560,41.997180,, +2.353050,3.453159,0.941220,41.999198,, +2.357200,3.967902,0.942880,42.003477,, +2.361350,3.785242,0.944540,42.002254,, +2.365500,3.934203,0.946200,42.001054,, +2.369650,4.030772,0.947860,42.008025,, +2.373800,3.691169,0.949520,41.999568,, +2.377950,4.520681,0.951180,42.002530,, +2.382100,3.696013,0.952840,42.006935,, +2.386250,3.197821,0.954500,42.013781,, +2.390400,3.785161,0.956160,42.013809,, +2.394550,3.614757,0.957820,42.009639,, +2.398700,2.886710,0.959480,42.017161,, +2.402850,4.497597,0.961140,42.025034,, +2.407000,3.885349,0.962800,42.014483,, +2.411150,3.905004,0.964460,42.034787,, +2.415300,5.590732,0.966120,42.040677,, +2.419450,4.269918,0.967780,42.046130,, +2.423600,5.108740,0.969440,42.058149,, +2.427750,4.003785,0.971100,42.070949,, +2.431900,3.675281,0.972760,42.085062,, +2.436050,3.787989,0.974420,42.099663,, +2.440200,3.536067,0.976080,42.120585,, +2.444350,3.335406,0.977740,42.129304,, +2.448500,4.421475,0.979400,42.153195,, +2.452650,3.730722,0.981060,42.190884,, +2.456800,4.180394,0.982720,42.214762,, +2.460950,3.337224,0.984380,42.252956,, +2.465100,3.358975,0.986040,42.288156,, +2.469250,3.937620,0.987700,42.320923,, +2.473400,4.823910,0.989360,42.375180,, +2.477550,3.568589,0.991020,42.418828,, +2.481700,3.870131,0.992680,42.471804,, +2.485850,3.260655,0.994340,42.525505,, +2.490000,4.242519,0.996000,42.585227,, +2.494150,3.340803,0.997660,42.643918,, +2.498300,4.385856,0.999320,42.731607,, +2.502450,3.949488,1.000980,42.789794,, +2.506600,4.291201,1.002640,42.861473,, +2.510750,3.484095,1.004300,42.934658,, +2.514900,3.017341,1.005960,43.016893,, +2.519050,3.643519,1.007620,43.101977,, +2.523200,3.630587,1.009280,43.184206,, +2.527350,3.920135,1.010940,43.261699,, +2.531500,3.575618,1.012600,43.344788,, +2.535650,4.008679,1.014260,43.413965,, +2.539800,4.461761,1.015920,43.497237,, +2.543950,3.444224,1.017580,43.578996,, +2.548100,2.854361,1.019240,43.657419,, +2.552250,3.465865,1.020900,43.730482,, +2.556400,3.773265,1.022560,43.798091,, +2.560550,3.078920,1.024220,43.854614,, +2.564700,3.768872,1.025880,43.923209,, +2.568850,4.699873,1.027540,43.973460,, +2.573000,4.222187,1.029200,44.030140,, +2.577150,4.451981,1.030860,44.055157,, +2.581300,3.622963,1.032520,44.077895,, +2.585450,4.316114,1.034180,44.108547,, +2.589600,3.546927,1.035840,44.135470,, +2.593750,3.953983,1.037500,44.154084,, +2.597900,4.794374,1.039160,44.142510,, +2.602050,4.097913,1.040820,44.136431,, +2.606200,3.480876,1.042480,44.115049,, +2.610350,4.443398,1.044140,44.103292,, +2.614500,2.780924,1.045800,44.089227,, +2.618650,4.183506,1.047460,44.050526,, +2.622800,3.405113,1.049120,43.993432,, +2.626950,3.909424,1.050780,43.956205,, +2.631100,2.869361,1.052440,43.901988,, +2.635250,3.655756,1.054100,43.867957,, +2.639400,4.089951,1.055760,43.799009,, +2.643550,3.327861,1.057420,43.726907,, +2.647700,4.151475,1.059080,43.654970,, +2.651850,4.074497,1.060740,43.594497,, +2.656000,4.150145,1.062400,43.524191,, +2.660150,3.286279,1.064060,43.457217,, +2.664300,3.673875,1.065720,43.381524,, +2.668450,4.917786,1.067380,43.308079,, +2.672600,3.770895,1.069040,43.229869,, +2.676750,3.082913,1.070700,43.157218,, +2.680900,4.449613,1.072360,43.087527,, +2.685050,4.342149,1.074020,43.032488,, +2.689200,3.425561,1.075680,42.947053,, +2.693350,3.831318,1.077340,42.884820,, +2.697500,4.107484,1.079000,42.809246,, +2.701650,3.930251,1.080660,42.760251,, +2.705800,4.654298,1.082320,42.705849,, +2.709950,4.299351,1.083980,42.650275,, +2.714100,4.218330,1.085640,42.594691,, +2.718250,4.194053,1.087300,42.528287,, +2.722400,3.878330,1.088960,42.494606,, +2.726550,5.015476,1.090620,42.451993,, +2.730700,4.246694,1.092280,42.414881,, +2.734850,4.557082,1.093940,42.366656,, +2.739000,4.354787,1.095600,42.334093,, +2.743150,3.624108,1.097260,42.300855,, +2.747300,3.865326,1.098920,42.279648,, +2.751450,3.610459,1.100580,42.247901,, +2.755600,2.776323,1.102240,42.224962,, +2.759750,4.578723,1.103900,42.182801,, +2.763900,3.011344,1.105560,42.177115,, +2.768050,4.893859,1.107220,42.157373,, +2.772200,3.424239,1.108880,42.141139,, +2.776350,3.798034,1.110540,42.120788,, +2.780500,4.122369,1.112200,42.089434,, +2.784650,3.461572,1.113860,42.102065,, +2.788800,3.986067,1.115520,42.082001,, +2.792950,4.353894,1.117180,42.070560,, +2.797100,3.461316,1.118840,42.053590,, +2.801250,3.734580,1.120500,42.053672,, +2.805400,4.550718,1.122160,42.050050,, +2.809550,3.926033,1.123820,42.045969,, +2.813700,4.596633,1.125480,42.036377,, +2.817850,3.735951,1.127140,42.029600,, +2.822000,4.045544,1.128800,42.022572,, +2.826150,4.019845,1.130460,42.031583,, +2.830300,2.982366,1.132120,42.016894,, +2.834450,2.798264,1.133780,42.014656,, +2.838600,4.198729,1.135440,42.011265,, +2.842750,3.978091,1.137100,42.013478,, +2.846900,4.337024,1.138760,42.013257,, +2.851050,4.577855,1.140420,42.011658,, +2.855200,3.975097,1.142080,42.010745,, +2.859350,4.749143,1.143740,42.008413,, +2.863500,3.240980,1.145400,41.998184,, +2.867650,5.026817,1.147060,42.001685,, +2.871800,4.684627,1.148720,42.006418,, +2.875950,5.141034,1.150380,42.002679,, +2.880100,3.827341,1.152040,42.001834,, +2.884250,4.120403,1.153700,42.001448,, +2.888400,4.245187,1.155360,42.008421,, +2.892550,3.699195,1.157020,42.003768,, +2.896700,3.989532,1.158680,42.001716,, +2.900850,3.711317,1.160340,42.010336,, +2.905000,3.370147,1.162000,41.999744,, +2.909150,3.940522,1.163660,41.998383,, +2.913300,5.099117,1.165320,42.000016,, +2.917450,3.771112,1.166980,42.001240,, +2.921600,3.928202,1.168640,41.997587,, +2.925750,4.438552,1.170300,41.998766,, +2.929900,4.134030,1.171960,41.998676,, +2.934050,4.101141,1.173620,41.995093,, +2.938200,4.092815,1.175280,42.004961,, +2.942350,4.181022,1.176940,41.998448,, +2.946500,4.540868,1.178600,42.000693,, +2.950650,4.216146,1.180260,41.998860,, +2.954800,4.331856,1.181920,41.995435,, +2.958950,4.665726,1.183580,41.997024,, +2.963100,4.220582,1.185240,42.001738,, +2.967250,3.637505,1.186900,42.006827,, +2.971400,4.174967,1.188560,42.004352,, +2.975550,4.149122,1.190220,41.995613,, +2.979700,3.829504,1.191880,42.008523,, +2.983850,4.599106,1.193540,42.007059,, +2.988000,3.570568,1.195200,41.995849,, +,,1.196860,42.004787,, +,,1.198520,41.997195,, +,,1.200180,42.006597,, +,,1.201840,41.999313,, +,,1.203500,42.001929,, +,,1.205160,41.996783,, +,,1.206820,42.007096,, +,,1.208480,41.996988,, +,,1.210140,41.997188,, +,,1.211800,41.991423,, +,,1.213460,42.000756,, +,,1.215120,42.002564,, +,,1.216780,41.993290,, +,,1.218440,41.997651,, +,,1.220100,42.003943,, +,,1.221760,41.999477,, +,,1.223420,41.996734,, +,,1.225080,42.003775,, +,,1.226740,41.999588,, +,,1.228400,42.006045,, +,,1.230060,41.996962,, +,,1.231720,42.001269,, +,,1.233380,41.999163,, +,,1.235040,41.994244,, +,,1.236700,41.998345,, +,,1.238360,41.995671,, +,,1.240020,42.002443,, +,,1.241680,42.000496,, +,,1.243340,42.001886,, +,,1.245000,42.001594,, +,,1.246660,42.008534,, +,,1.248320,41.997601,, +,,1.249980,42.006978,, +,,1.251640,41.997645,, +,,1.253300,41.994940,, +,,1.254960,41.997797,, +,,1.256620,41.997731,, +,,1.258280,41.999266,, +,,1.259940,41.995574,, +,,1.261600,41.998831,, +,,1.263260,41.999062,, +,,1.264920,42.002875,, +,,1.266580,41.995365,, +,,1.268240,41.996325,, +,,1.269900,41.996741,, +,,1.271560,42.002288,, +,,1.273220,42.002661,, +,,1.274880,42.001034,, +,,1.276540,42.003315,, +,,1.278200,41.997697,, +,,1.279860,41.993759,, +,,1.281520,42.003832,, +,,1.283180,42.004158,, +,,1.284840,41.998357,, +,,1.286500,42.003286,, +,,1.288160,42.003685,, +,,1.289820,42.004195,, +,,1.291480,42.000608,, +,,1.293140,41.999703,, +,,1.294800,42.005788,, +,,1.296460,41.994490,, +,,1.298120,42.001536,, +,,1.299780,42.006439,, +,,1.301440,41.997196,, +,,1.303100,42.004236,, +,,1.304760,41.994776,, +,,1.306420,42.000377,, +,,1.308080,41.999143,, +,,1.309740,42.005681,, +,,1.311400,41.999571,, +,,1.313060,42.001554,, +,,1.314720,41.998101,, +,,1.316380,41.998418,, +,,1.318040,42.000149,, +,,1.319700,41.995679,, +,,1.321360,42.002456,, +,,1.323020,41.995434,, +,,1.324680,41.999936,, +,,1.326340,42.000126,, +,,1.328000,41.998655,, +,,1.329660,42.002386,, +,,1.331320,41.995236,, +,,1.332980,42.001260,, +,,1.334640,41.996477,, +,,1.336300,41.998465,, +,,1.337960,41.999984,, +,,1.339620,41.995654,, +,,1.341280,41.997704,, +,,1.342940,42.002810,, +,,1.344600,42.000132,, +,,1.346260,42.004823,, +,,1.347920,42.005133,, +,,1.349580,41.998536,, +,,1.351240,41.999553,, +,,1.352900,41.992375,, +,,1.354560,42.007119,, +,,1.356220,41.999603,, +,,1.357880,42.001683,, +,,1.359540,42.005894,, +,,1.361200,41.994529,, +,,1.362860,41.993352,, +,,1.364520,42.001336,, +,,1.366180,41.995568,, +,,1.367840,41.994244,, +,,1.369500,42.001226,, +,,1.371160,41.995221,, +,,1.372820,42.000305,, +,,1.374480,41.994677,, +,,1.376140,41.996563,, +,,1.377800,41.998665,, +,,1.379460,42.002013,, +,,1.381120,42.002112,, +,,1.382780,42.000128,, +,,1.384440,42.002160,, +,,1.386100,41.997053,, +,,1.387760,41.995371,, +,,1.389420,42.002162,, +,,1.391080,42.001511,, +,,1.392740,42.002055,, +,,1.394400,41.994323,, +,,1.396060,41.993969,, +,,1.397720,42.000053,, +,,1.399380,42.001727,, +,,1.401040,42.001709,, +,,1.402700,41.998491,, +,,1.404360,41.998949,, +,,1.406020,41.993593,, +,,1.407680,41.998603,, +,,1.409340,41.993494,, +,,1.411000,42.004641,, +,,1.412660,42.009855,, +,,1.414320,41.999206,, +,,1.415980,42.001197,, +,,1.417640,42.000596,, +,,1.419300,42.004506,, +,,1.420960,42.005873,, +,,1.422620,41.996585,, +,,1.424280,42.002494,, +,,1.425940,41.996498,, +,,1.427600,42.006856,, +,,1.429260,42.006801,, +,,1.430920,41.998369,, +,,1.432580,42.007594,, +,,1.434240,41.996464,, +,,1.435900,41.994694,, +,,1.437560,41.999875,, +,,1.439220,41.998390,, +,,1.440880,41.998792,, +,,1.442540,41.997087,, +,,1.444200,42.004565,, +,,1.445860,42.000163,, +,,1.447520,41.999464,, +,,1.449180,41.999162,, +,,1.450840,42.000141,, +,,1.452500,41.996696,, +,,1.454160,42.001816,, +,,1.455820,41.994746,, +,,1.457480,41.996311,, +,,1.459140,42.005511,, +,,1.460800,42.004075,, +,,1.462460,42.004465,, +,,1.464120,41.997834,, +,,1.465780,41.998685,, +,,1.467440,41.997623,, +,,1.469100,41.991061,, +,,1.470760,41.997595,, +,,1.472420,42.001701,, +,,1.474080,41.991361,, +,,1.475740,42.000844,, +,,1.477400,42.000795,, +,,1.479060,42.002151,, +,,1.480720,42.000235,, +,,1.482380,42.000269,, +,,1.484040,41.999657,, +,,1.485700,42.002297,, +,,1.487360,42.004430,, +,,1.489020,42.002946,, +,,1.490680,41.996566,, +,,1.492340,42.003612,, +,,1.494000,42.002147,, +,,1.495660,42.006888,, +,,1.497320,41.996543,, +,,1.498980,41.997377,, +,,1.500640,41.995423,, +,,1.502300,42.003388,, +,,1.503960,41.998281,, +,,1.505620,41.991376,, +,,1.507280,41.998588,, +,,1.508940,42.002381,, +,,1.510600,41.997742,, +,,1.512260,41.993628,, +,,1.513920,41.994709,, +,,1.515580,41.998557,, +,,1.517240,42.001710,, +,,1.518900,41.996306,, +,,1.520560,41.997683,, +,,1.522220,41.994372,, +,,1.523880,41.994018,, +,,1.525540,42.002381,, +,,1.527200,41.997281,, +,,1.528860,42.002649,, +,,1.530520,41.999779,, +,,1.532180,42.005577,, +,,1.533840,41.999420,, +,,1.535500,41.999449,, +,,1.537160,42.004373,, +,,1.538820,42.000124,, +,,1.540480,41.994451,, +,,1.542140,41.999852,, +,,1.543800,42.002889,, +,,1.545460,42.002936,, +,,1.547120,42.001497,, +,,1.548780,42.008004,, +,,1.550440,41.996327,, +,,1.552100,41.999092,, +,,1.553760,42.009000,, +,,1.555420,41.995799,, +,,1.557080,41.999650,, +,,1.558740,41.998928,, +,,1.560400,42.000885,, +,,1.562060,42.006076,, +,,1.563720,41.999489,, +,,1.565380,42.002419,, +,,1.567040,41.995870,, +,,1.568700,42.000179,, +,,1.570360,42.000799,, +,,1.572020,41.995367,, +,,1.573680,42.001566,, +,,1.575340,41.998931,, +,,1.577000,42.001217,, +,,1.578660,42.001751,, +,,1.580320,42.002622,, +,,1.581980,42.006616,, +,,1.583640,41.997799,, +,,1.585300,41.993217,, +,,1.586960,42.006508,, +,,1.588620,41.994717,, +,,1.590280,42.001575,, +,,1.591940,41.999150,, +,,1.593600,41.999201,, +,,1.595260,42.002317,, +,,1.596920,42.004187,, +,,1.598580,42.002678,, +,,1.600240,41.995455,, +,,1.601900,42.003608,, +,,1.603560,42.000124,, +,,1.605220,41.995820,, +,,1.606880,42.002311,, +,,1.608540,42.002828,, +,,1.610200,42.000577,, +,,1.611860,42.002522,, +,,1.613520,41.999269,, +,,1.615180,41.998070,, +,,1.616840,41.999706,, +,,1.618500,41.997838,, +,,1.620160,41.998549,, +,,1.621820,42.001156,, +,,1.623480,41.998440,, +,,1.625140,42.002678,, +,,1.626800,41.999961,, +,,1.628460,42.001833,, +,,1.630120,42.000445,, +,,1.631780,41.992530,, +,,1.633440,42.007167,, +,,1.635100,41.996050,, +,,1.636760,41.996149,, +,,1.638420,42.001417,, +,,1.640080,42.000200,, +,,1.641740,42.000110,, +,,1.643400,41.999772,, +,,1.645060,41.997468,, +,,1.646720,41.999179,, +,,1.648380,42.002505,, +,,1.650040,42.008711,, +,,1.651700,41.992893,, +,,1.653360,41.994858,, +,,1.655020,41.997038,, +,,1.656680,42.002847,, +,,1.658340,42.000606,, +,,1.660000,41.997930,, +,,1.661660,41.997124,, +,,1.663320,41.998717,, +,,1.664980,42.002334,, +,,1.666640,41.998656,, +,,1.668300,42.000590,, +,,1.669960,42.005659,, +,,1.671620,42.005500,, +,,1.673280,41.997270,, +,,1.674940,42.004105,, +,,1.676600,41.999427,, +,,1.678260,41.992127,, +,,1.679920,42.003466,, +,,1.681580,41.998372,, +,,1.683240,42.000855,, +,,1.684900,41.998769,, +,,1.686560,42.002705,, +,,1.688220,42.003815,, +,,1.689880,41.998749,, +,,1.691540,41.998741,, +,,1.693200,42.007994,, +,,1.694860,42.004707,, +,,1.696520,41.996989,, +,,1.698180,41.995310,, +,,1.699840,41.998928,, +,,1.701500,42.004982,, +,,1.703160,41.997133,, +,,1.704820,41.996725,, +,,1.706480,42.000659,, +,,1.708140,42.006048,, +,,1.709800,42.002384,, +,,1.711460,42.002291,, +,,1.713120,42.004794,, +,,1.714780,41.996872,, +,,1.716440,42.004371,, +,,1.718100,41.998364,, +,,1.719760,42.005199,, +,,1.721420,42.002031,, +,,1.723080,41.994909,, +,,1.724740,41.999463,, +,,1.726400,42.003078,, +,,1.728060,41.997217,, +,,1.729720,41.994787,, +,,1.731380,41.997215,, +,,1.733040,42.001068,, +,,1.734700,42.003550,, +,,1.736360,41.997917,, +,,1.738020,41.993609,, +,,1.739680,41.996380,, +,,1.741340,41.999793,, +,,1.743000,41.998393,, +,,1.744660,42.003669,, +,,1.746320,41.997441,, +,,1.747980,42.002334,, +,,1.749640,42.000697,, +,,1.751300,41.998633,, +,,1.752960,41.999053,, +,,1.754620,41.991087,, +,,1.756280,42.001390,, +,,1.757940,42.000383,, +,,1.759600,41.997917,, +,,1.761260,42.005183,, +,,1.762920,42.001854,, +,,1.764580,41.995693,, +,,1.766240,41.998186,, +,,1.767900,42.004512,, +,,1.769560,42.000740,, +,,1.771220,41.999550,, +,,1.772880,41.993204,, +,,1.774540,42.007927,, +,,1.776200,42.001948,, +,,1.777860,42.002874,, +,,1.779520,41.996409,, +,,1.781180,42.001436,, +,,1.782840,41.999313,, +,,1.784500,41.995329,, +,,1.786160,41.999625,, +,,1.787820,42.003254,, +,,1.789480,41.999042,, +,,1.791140,41.991309,, +,,1.792800,42.001766,, +,,1.794460,41.999673,, +,,1.796120,41.997956,, +,,1.797780,41.998867,, +,,1.799440,42.001578,, +,,1.801100,41.996663,, +,,1.802760,41.996364,, +,,1.804420,42.006726,, +,,1.806080,42.005631,, +,,1.807740,41.995515,, +,,1.809400,41.999936,, +,,1.811060,42.001789,, +,,1.812720,42.003651,, +,,1.814380,41.992934,, +,,1.816040,41.999790,, +,,1.817700,42.001304,, +,,1.819360,42.004473,, +,,1.821020,41.997779,, +,,1.822680,42.003377,, +,,1.824340,41.992640,, +,,1.826000,41.996885,, +,,1.827660,41.995788,, +,,1.829320,42.006288,, +,,1.830980,41.997720,, +,,1.832640,42.000709,, +,,1.834300,42.000935,, +,,1.835960,41.994396,, +,,1.837620,42.007752,, +,,1.839280,41.998103,, +,,1.840940,42.000465,, +,,1.842600,42.001835,, +,,1.844260,41.991934,, +,,1.845920,42.006714,, +,,1.847580,42.005728,, +,,1.849240,42.003084,, +,,1.850900,41.994710,, +,,1.852560,42.002033,, +,,1.854220,41.999947,, +,,1.855880,41.995032,, +,,1.857540,41.999841,, +,,1.859200,42.000076,, +,,1.860860,42.005694,, +,,1.862520,42.006424,, +,,1.864180,41.998195,, +,,1.865840,41.999058,, +,,1.867500,42.001229,, +,,1.869160,41.990821,, +,,1.870820,41.992780,, +,,1.872480,42.000161,, +,,1.874140,41.994375,, +,,1.875800,42.003243,, +,,1.877460,41.996693,, +,,1.879120,42.004224,, +,,1.880780,42.000410,, +,,1.882440,41.999625,, +,,1.884100,41.995000,, +,,1.885760,42.002739,, +,,1.887420,41.998074,, +,,1.889080,41.996677,, +,,1.890740,42.003152,, +,,1.892400,41.999033,, +,,1.894060,42.004666,, +,,1.895720,42.003725,, +,,1.897380,41.997298,, +,,1.899040,41.996708,, +,,1.900700,42.004718,, +,,1.902360,42.000554,, +,,1.904020,42.005445,, +,,1.905680,42.000220,, +,,1.907340,42.002045,, +,,1.909000,42.002219,, +,,1.910660,42.010308,, +,,1.912320,41.999270,, +,,1.913980,42.000135,, +,,1.915640,42.002421,, +,,1.917300,41.994005,, +,,1.918960,41.998085,, +,,1.920620,41.996542,, +,,1.922280,41.998168,, +,,1.923940,41.999323,, +,,1.925600,41.999175,, +,,1.927260,41.996056,, +,,1.928920,41.993588,, +,,1.930580,42.006700,, +,,1.932240,42.004139,, +,,1.933900,41.998623,, +,,1.935560,41.991821,, +,,1.937220,42.002260,, +,,1.938880,41.999806,, +,,1.940540,41.995520,, +,,1.942200,41.999493,, +,,1.943860,42.003552,, +,,1.945520,42.000318,, +,,1.947180,41.993523,, +,,1.948840,41.993739,, +,,1.950500,42.003175,, +,,1.952160,42.003136,, +,,1.953820,42.004600,, +,,1.955480,41.995632,, +,,1.957140,41.998370,, +,,1.958800,42.010346,, +,,1.960460,42.000963,, +,,1.962120,41.997771,, +,,1.963780,41.999189,, +,,1.965440,41.997891,, +,,1.967100,41.994826,, +,,1.968760,42.004370,, +,,1.970420,42.002653,, +,,1.972080,42.001091,, +,,1.973740,41.998269,, +,,1.975400,42.003810,, +,,1.977060,41.995474,, +,,1.978720,41.988851,, +,,1.980380,41.991382,, +,,1.982040,41.997806,, +,,1.983700,42.000935,, +,,1.985360,42.004793,, +,,1.987020,41.998151,, +,,1.988680,41.998119,, +,,1.990340,42.006820,, +,,1.992000,41.998702,, +,,1.993660,41.997332,, +,,1.995320,42.005754,, +,,1.996980,41.995680,, +,,1.998640,41.995956,, +,,2.000300,42.000850,, +,,2.001960,41.997875,, +,,2.003620,41.999839,, +,,2.005280,42.000260,, +,,2.006940,42.000706,, +,,2.008600,41.992517,, +,,2.010260,42.000671,, +,,2.011920,42.001918,, +,,2.013580,41.998490,, +,,2.015240,42.003356,, +,,2.016900,41.997019,, +,,2.018560,41.991287,, +,,2.020220,42.001601,, +,,2.021880,42.004226,, +,,2.023540,42.000255,, +,,2.025200,41.998386,, +,,2.026860,41.995333,, +,,2.028520,41.999642,, +,,2.030180,41.998450,, +,,2.031840,42.000513,, +,,2.033500,41.999620,, +,,2.035160,42.006057,, +,,2.036820,42.000447,, +,,2.038480,42.001207,, +,,2.040140,42.001491,, +,,2.041800,41.998166,, +,,2.043460,41.994132,, +,,2.045120,41.993669,, +,,2.046780,42.002659,, +,,2.048440,42.003962,, +,,2.050100,42.007443,, +,,2.051760,41.996353,, +,,2.053420,42.001695,, +,,2.055080,41.996908,, +,,2.056740,41.999465,, +,,2.058400,42.006757,, +,,2.060060,42.001658,, +,,2.061720,42.002536,, +,,2.063380,42.001079,, +,,2.065040,41.996013,, +,,2.066700,42.000667,, +,,2.068360,41.999430,, +,,2.070020,41.996664,, +,,2.071680,42.000185,, +,,2.073340,41.996494,, +,,2.075000,42.005565,, +,,2.076660,42.000007,, +,,2.078320,41.992897,, +,,2.079980,42.003073,, +,,2.081640,41.999955,, +,,2.083300,41.996127,, +,,2.084960,42.003518,, +,,2.086620,41.995888,, +,,2.088280,41.996317,, +,,2.089940,41.994571,, +,,2.091600,42.003392,, +,,2.093260,41.996306,, +,,2.094920,41.991740,, +,,2.096580,41.992859,, +,,2.098240,42.004524,, +,,2.099900,42.005008,, +,,2.101560,41.997061,, +,,2.103220,41.994759,, +,,2.104880,41.999032,, +,,2.106540,42.005239,, +,,2.108200,41.994571,, +,,2.109860,41.999807,, +,,2.111520,42.005879,, +,,2.113180,41.993650,, +,,2.114840,41.995766,, +,,2.116500,41.996495,, +,,2.118160,41.998075,, +,,2.119820,42.000863,, +,,2.121480,42.000186,, +,,2.123140,42.001925,, +,,2.124800,42.001590,, +,,2.126460,41.997478,, +,,2.128120,42.009583,, +,,2.129780,42.006894,, +,,2.131440,42.002619,, +,,2.133100,41.998069,, +,,2.134760,42.003066,, +,,2.136420,41.994084,, +,,2.138080,41.994604,, +,,2.139740,41.995779,, +,,2.141400,42.003301,, +,,2.143060,41.997285,, +,,2.144720,42.009796,, +,,2.146380,41.996801,, +,,2.148040,42.004409,, +,,2.149700,42.000287,, +,,2.151360,41.994468,, +,,2.153020,42.000402,, +,,2.154680,41.997132,, +,,2.156340,41.999168,, +,,2.158000,42.000618,, +,,2.159660,42.001566,, +,,2.161320,41.992147,, +,,2.162980,42.003225,, +,,2.164640,41.993819,, +,,2.166300,41.997694,, +,,2.167960,42.003284,, +,,2.169620,41.997780,, +,,2.171280,41.998206,, +,,2.172940,42.001187,, +,,2.174600,41.998101,, +,,2.176260,41.995804,, +,,2.177920,42.002175,, +,,2.179580,42.003340,, +,,2.181240,41.997394,, +,,2.182900,42.001066,, +,,2.184560,42.000403,, +,,2.186220,41.999648,, +,,2.187880,42.003682,, +,,2.189540,42.007105,, +,,2.191200,41.999379,, +,,2.192860,41.994587,, +,,2.194520,42.003117,, +,,2.196180,41.997594,, +,,2.197840,42.005148,, +,,2.199500,42.004653,, +,,2.201160,41.991856,, +,,2.202820,42.000685,, +,,2.204480,42.003507,, +,,2.206140,41.999268,, +,,2.207800,42.003957,, +,,2.209460,42.003975,, +,,2.211120,41.998825,, +,,2.212780,42.002592,, +,,2.214440,42.002892,, +,,2.216100,41.995858,, +,,2.217760,42.009583,, +,,2.219420,41.996745,, +,,2.221080,42.003373,, +,,2.222740,42.008072,, +,,2.224400,42.004029,, +,,2.226060,41.998438,, +,,2.227720,42.004793,, +,,2.229380,41.996082,, +,,2.231040,41.998710,, +,,2.232700,42.000359,, +,,2.234360,41.999733,, +,,2.236020,42.002283,, +,,2.237680,41.998267,, +,,2.239340,41.999407,, +,,2.241000,42.000855,, +,,2.242660,42.003288,, +,,2.244320,41.996037,, +,,2.245980,41.989081,, +,,2.247640,42.000477,, +,,2.249300,42.001205,, +,,2.250960,42.010564,, +,,2.252620,41.997949,, +,,2.254280,42.004808,, +,,2.255940,42.001585,, +,,2.257600,41.999099,, +,,2.259260,41.990802,, +,,2.260920,42.004167,, +,,2.262580,42.002389,, +,,2.264240,42.001220,, +,,2.265900,42.000637,, +,,2.267560,42.001821,, +,,2.269220,42.006129,, +,,2.270880,42.000183,, +,,2.272540,41.995946,, +,,2.274200,42.007025,, +,,2.275860,41.999696,, +,,2.277520,42.005533,, +,,2.279180,42.007394,, +,,2.280840,41.994273,, +,,2.282500,41.997862,, +,,2.284160,41.998284,, +,,2.285820,42.002746,, +,,2.287480,42.001833,, +,,2.289140,41.998383,, +,,2.290800,42.001348,, +,,2.292460,41.999609,, +,,2.294120,42.001477,, +,,2.295780,41.996176,, +,,2.297440,41.996684,, +,,2.299100,41.997648,, +,,2.300760,41.999861,, +,,2.302420,41.996647,, +,,2.304080,41.997549,, +,,2.305740,41.999138,, +,,2.307400,41.999083,, +,,2.309060,42.008016,, +,,2.310720,41.999919,, +,,2.312380,42.004256,, +,,2.314040,41.994692,, +,,2.315700,41.998390,, +,,2.317360,42.000858,, +,,2.319020,42.003096,, +,,2.320680,41.997077,, +,,2.322340,41.995882,, +,,2.324000,41.995753,, +,,2.325660,41.999093,, +,,2.327320,42.002032,, +,,2.328980,42.006888,, +,,2.330640,41.998464,, +,,2.332300,42.002848,, +,,2.333960,42.001061,, +,,2.335620,41.997248,, +,,2.337280,41.997384,, +,,2.338940,42.000873,, +,,2.340600,41.996141,, +,,2.342260,42.003719,, +,,2.343920,41.998678,, +,,2.345580,42.006400,, +,,2.347240,41.993026,, +,,2.348900,42.000493,, +,,2.350560,41.990625,, +,,2.352220,42.002694,, +,,2.353880,42.001633,, +,,2.355540,41.999758,, +,,2.357200,42.001420,, +,,2.358860,41.998450,, +,,2.360520,42.002756,, +,,2.362180,42.003460,, +,,2.363840,42.000723,, +,,2.365500,41.999762,, +,,2.367160,41.996056,, +,,2.368820,41.999066,, +,,2.370480,41.995463,, +,,2.372140,41.998456,, +,,2.373800,41.999914,, +,,2.375460,41.995405,, +,,2.377120,42.000112,, +,,2.378780,41.994505,, +,,2.380440,42.000019,, +,,2.382100,42.004963,, +,,2.383760,41.995556,, +,,2.385420,42.001052,, +,,2.387080,42.005211,, +,,2.388740,42.000377,, +,,2.390400,41.999337,, +,,2.392060,41.992265,, +,,2.393720,42.001458,, +,,2.395380,42.003899,, +,,2.397040,42.007357,, +,,2.398700,41.998555,, +,,2.400360,42.002515,, +,,2.402020,41.996781,, +,,2.403680,42.000166,, +,,2.405340,42.001446,, +,,2.407000,41.997699,, +,,2.408660,41.998308,, +,,2.410320,42.001364,, +,,2.411980,42.001863,, +,,2.413640,41.997686,, +,,2.415300,42.003064,, +,,2.416960,41.999609,, +,,2.418620,42.003269,, +,,2.420280,41.998186,, +,,2.421940,41.996471,, +,,2.423600,41.998389,, +,,2.425260,42.005274,, +,,2.426920,42.001371,, +,,2.428580,42.001706,, +,,2.430240,41.999173,, +,,2.431900,42.008629,, +,,2.433560,42.003660,, +,,2.435220,42.004211,, +,,2.436880,42.002063,, +,,2.438540,41.999367,, +,,2.440200,42.002683,, +,,2.441860,41.993250,, +,,2.443520,42.000170,, +,,2.445180,42.000811,, +,,2.446840,41.996292,, +,,2.448500,42.005072,, +,,2.450160,42.000559,, +,,2.451820,42.002129,, +,,2.453480,41.997747,, +,,2.455140,41.999255,, +,,2.456800,41.997491,, +,,2.458460,41.990195,, +,,2.460120,42.003248,, +,,2.461780,41.997114,, +,,2.463440,41.996261,, +,,2.465100,42.001125,, +,,2.466760,42.003857,, +,,2.468420,41.995489,, +,,2.470080,41.993070,, +,,2.471740,41.997291,, +,,2.473400,41.999907,, +,,2.475060,42.002287,, +,,2.476720,41.996624,, +,,2.478380,41.992409,, +,,2.480040,41.990310,, +,,2.481700,42.003446,, +,,2.483360,42.001583,, +,,2.485020,42.003426,, +,,2.486680,42.001998,, +,,2.488340,41.996621,, +,,2.490000,42.005014,, +,,2.491660,42.004120,, +,,2.493320,41.996763,, +,,2.494980,42.001388,, +,,2.496640,41.997314,, +,,2.498300,41.998813,, +,,2.499960,41.995943,, +,,2.501620,42.002383,, +,,2.503280,41.996472,, +,,2.504940,41.998472,, +,,2.506600,42.005076,, +,,2.508260,41.994643,, +,,2.509920,41.997112,, +,,2.511580,41.999090,, +,,2.513240,42.007430,, +,,2.514900,42.009024,, +,,2.516560,41.994863,, +,,2.518220,41.999226,, +,,2.519880,41.998980,, +,,2.521540,42.003395,, +,,2.523200,42.001501,, +,,2.524860,42.000556,, +,,2.526520,41.995641,, +,,2.528180,42.005566,, +,,2.529840,41.997432,, +,,2.531500,42.001547,, +,,2.533160,41.995882,, +,,2.534820,42.001571,, +,,2.536480,42.000716,, +,,2.538140,41.996024,, +,,2.539800,42.001487,, +,,2.541460,42.000395,, +,,2.543120,41.999628,, +,,2.544780,41.997256,, +,,2.546440,41.999805,, +,,2.548100,41.998231,, +,,2.549760,41.997111,, +,,2.551420,42.009177,, +,,2.553080,41.992931,, +,,2.554740,42.002505,, +,,2.556400,41.998611,, +,,2.558060,42.001833,, +,,2.559720,42.000339,, +,,2.561380,41.999266,, +,,2.563040,42.007883,, +,,2.564700,42.000480,, +,,2.566360,42.000194,, +,,2.568020,42.001302,, +,,2.569680,42.000782,, +,,2.571340,42.004359,, +,,2.573000,42.000282,, +,,2.574660,41.992176,, +,,2.576320,41.998596,, +,,2.577980,41.997522,, +,,2.579640,42.006355,, +,,2.581300,42.000670,, +,,2.582960,42.002793,, +,,2.584620,42.000176,, +,,2.586280,42.000027,, +,,2.587940,42.007889,, +,,2.589600,41.996111,, +,,2.591260,42.003555,, +,,2.592920,42.006474,, +,,2.594580,41.995714,, +,,2.596240,42.005066,, +,,2.597900,41.998702,, +,,2.599560,41.996791,, +,,2.601220,41.997146,, +,,2.602880,41.998831,, +,,2.604540,41.998893,, +,,2.606200,41.995179,, +,,2.607860,41.994837,, +,,2.609520,41.993879,, +,,2.611180,41.996829,, +,,2.612840,41.995217,, +,,2.614500,42.003859,, +,,2.616160,41.998206,, +,,2.617820,41.997476,, +,,2.619480,41.999261,, +,,2.621140,42.001344,, +,,2.622800,42.000762,, +,,2.624460,42.002344,, +,,2.626120,41.995972,, +,,2.627780,42.005956,, +,,2.629440,42.000375,, +,,2.631100,42.007290,, +,,2.632760,41.994912,, +,,2.634420,41.999762,, +,,2.636080,41.998142,, +,,2.637740,41.995551,, +,,2.639400,42.005947,, +,,2.641060,42.001609,, +,,2.642720,42.000286,, +,,2.644380,42.001170,, +,,2.646040,42.003703,, +,,2.647700,41.998606,, +,,2.649360,41.999587,, +,,2.651020,42.002248,, +,,2.652680,41.999198,, +,,2.654340,42.001542,, +,,2.656000,42.003983,, +,,2.657660,42.001758,, +,,2.659320,42.002517,, +,,2.660980,42.003961,, +,,2.662640,41.996990,, +,,2.664300,42.005784,, +,,2.665960,41.999939,, +,,2.667620,41.997127,, +,,2.669280,41.999068,, +,,2.670940,42.002995,, +,,2.672600,41.998543,, +,,2.674260,42.002328,, +,,2.675920,41.998258,, +,,2.677580,42.004495,, +,,2.679240,42.007190,, +,,2.680900,41.993429,, +,,2.682560,42.004999,, +,,2.684220,41.995674,, +,,2.685880,42.001379,, +,,2.687540,41.999651,, +,,2.689200,42.000479,, +,,2.690860,41.997930,, +,,2.692520,42.002380,, +,,2.694180,42.000415,, +,,2.695840,42.001220,, +,,2.697500,42.001540,, +,,2.699160,42.000833,, +,,2.700820,42.005613,, +,,2.702480,41.999819,, +,,2.704140,42.002070,, +,,2.705800,42.001583,, +,,2.707460,41.989844,, +,,2.709120,42.001024,, +,,2.710780,42.001357,, +,,2.712440,42.007374,, +,,2.714100,42.004929,, +,,2.715760,42.001489,, +,,2.717420,41.999791,, +,,2.719080,41.993001,, +,,2.720740,42.001744,, +,,2.722400,42.000417,, +,,2.724060,42.001610,, +,,2.725720,42.004402,, +,,2.727380,41.995331,, +,,2.729040,42.004601,, +,,2.730700,41.996017,, +,,2.732360,41.997746,, +,,2.734020,41.993721,, +,,2.735680,41.993871,, +,,2.737340,41.999616,, +,,2.739000,41.995272,, +,,2.740660,42.004555,, +,,2.742320,41.995823,, +,,2.743980,42.001083,, +,,2.745640,42.004653,, +,,2.747300,41.997084,, +,,2.748960,42.002445,, +,,2.750620,41.998036,, +,,2.752280,42.000473,, +,,2.753940,42.001344,, +,,2.755600,42.001978,, +,,2.757260,42.003151,, +,,2.758920,41.994221,, +,,2.760580,41.996690,, +,,2.762240,41.999881,, +,,2.763900,42.000767,, +,,2.765560,41.992165,, +,,2.767220,42.005444,, +,,2.768880,41.998188,, +,,2.770540,42.004109,, +,,2.772200,41.994888,, +,,2.773860,42.001903,, +,,2.775520,42.003190,, +,,2.777180,42.003218,, +,,2.778840,42.003024,, +,,2.780500,42.003254,, +,,2.782160,42.002586,, +,,2.783820,41.998173,, +,,2.785480,42.003763,, +,,2.787140,41.994517,, +,,2.788800,41.997048,, +,,2.790460,41.996548,, +,,2.792120,41.995987,, +,,2.793780,41.993462,, +,,2.795440,41.999287,, +,,2.797100,42.004402,, +,,2.798760,41.996399,, +,,2.800420,41.994592,, +,,2.802080,42.002032,, +,,2.803740,42.002088,, +,,2.805400,41.995787,, +,,2.807060,42.003045,, +,,2.808720,42.007614,, +,,2.810380,42.002004,, +,,2.812040,42.001458,, +,,2.813700,41.993930,, +,,2.815360,42.001351,, +,,2.817020,41.995323,, +,,2.818680,42.000724,, +,,2.820340,41.998628,, +,,2.822000,42.003699,, +,,2.823660,42.001071,, +,,2.825320,42.003910,, +,,2.826980,41.997780,, +,,2.828640,41.994834,, +,,2.830300,42.002156,, +,,2.831960,42.007419,, +,,2.833620,41.994670,, +,,2.835280,42.003273,, +,,2.836940,42.001935,, +,,2.838600,42.001800,, +,,2.840260,41.999624,, +,,2.841920,41.997911,, +,,2.843580,42.000616,, +,,2.845240,42.001344,, +,,2.846900,42.000232,, +,,2.848560,42.001466,, +,,2.850220,42.005725,, +,,2.851880,42.001446,, +,,2.853540,42.000702,, +,,2.855200,41.996815,, +,,2.856860,42.002608,, +,,2.858520,42.001871,, +,,2.860180,42.001028,, +,,2.861840,42.001177,, +,,2.863500,42.004323,, +,,2.865160,42.005235,, +,,2.866820,42.003468,, +,,2.868480,42.001255,, +,,2.870140,42.002413,, +,,2.871800,41.998362,, +,,2.873460,41.994198,, +,,2.875120,41.997352,, +,,2.876780,41.999289,, +,,2.878440,41.993975,, +,,2.880100,42.001944,, +,,2.881760,41.998248,, +,,2.883420,42.001386,, +,,2.885080,41.994830,, +,,2.886740,41.995517,, +,,2.888400,41.996705,, +,,2.890060,41.998621,, +,,2.891720,41.995977,, +,,2.893380,41.996395,, +,,2.895040,41.995374,, +,,2.896700,42.001748,, +,,2.898360,41.998002,, +,,2.900020,42.002758,, +,,2.901680,41.998891,, +,,2.903340,42.003755,, +,,2.905000,42.003443,, +,,2.906660,41.995728,, +,,2.908320,41.998341,, +,,2.909980,42.004851,, +,,2.911640,41.999701,, +,,2.913300,41.999464,, +,,2.914960,41.996991,, +,,2.916620,41.992903,, +,,2.918280,41.995824,, +,,2.919940,42.001849,, +,,2.921600,42.002709,, +,,2.923260,41.998949,, +,,2.924920,41.997044,, +,,2.926580,41.999969,, +,,2.928240,41.999311,, +,,2.929900,41.997136,, +,,2.931560,41.999686,, +,,2.933220,41.999578,, +,,2.934880,42.002599,, +,,2.936540,41.998835,, +,,2.938200,42.003632,, +,,2.939860,42.001967,, +,,2.941520,41.996773,, +,,2.943180,41.998766,, +,,2.944840,42.004441,, +,,2.946500,42.004317,, +,,2.948160,41.997786,, +,,2.949820,42.002144,, +,,2.951480,42.008726,, +,,2.953140,41.996872,, +,,2.954800,41.993915,, +,,2.956460,41.999225,, +,,2.958120,41.997973,, +,,2.959780,42.000346,, +,,2.961440,42.000676,, +,,2.963100,42.000346,, +,,2.964760,41.998531,, +,,2.966420,41.999096,, +,,2.968080,42.000907,, +,,2.969740,41.996864,, +,,2.971400,41.998203,, +,,2.973060,41.996567,, +,,2.974720,41.999188,, +,,2.976380,41.999061,, +,,2.978040,42.000263,, +,,2.979700,41.995833,, +,,2.981360,42.006645,, +,,2.983020,42.000099,, +,,2.984680,42.005412,, +,,2.986340,41.996680,, +,,2.988000,41.999610,, diff --git a/examples/data/characterization_akta/small_tracer_pulse_2_conductivity.csv b/examples/data/characterization_akta/small_tracer_pulse_2_conductivity.csv new file mode 100644 index 0000000..1e119c7 --- /dev/null +++ b/examples/data/characterization_akta/small_tracer_pulse_2_conductivity.csv @@ -0,0 +1,1804 @@ +Chromatogram,,,,, +UV 1_280,,Cond,,Run Log, +ml,mAU,ml,mS/cm,ml, +0.000000,3.672317,0.000000,42.000348,0.000000,Phase Equilibration +0.004150,4.167857,0.001660,42.006156,0.498000,Phase Elution +0.008300,3.496806,0.003320,42.002393,2.539800,Phase Wash +0.012450,3.195135,0.004980,42.005809,, +0.016600,3.987157,0.006640,41.999581,, +0.020750,4.244336,0.008300,42.000328,, +0.024900,4.069039,0.009960,42.001598,, +0.029050,3.473807,0.011620,42.002504,, +0.033200,3.754586,0.013280,42.001791,, +0.037350,4.108145,0.014940,42.007396,, +0.041500,4.277568,0.016600,42.001777,, +0.045650,4.058158,0.018260,41.998918,, +0.049800,4.642677,0.019920,42.005529,, +0.053950,4.451769,0.021580,42.001803,, +0.058100,4.452598,0.023240,42.001384,, +0.062250,3.285049,0.024900,41.998355,, +0.066400,3.854142,0.026560,42.002170,, +0.070550,3.951098,0.028220,41.994879,, +0.074700,3.246020,0.029880,42.003507,, +0.078850,4.018385,0.031540,42.004470,, +0.083000,4.152355,0.033200,42.001603,, +0.087150,3.899118,0.034860,42.002453,, +0.091300,3.951045,0.036520,42.001563,, +0.095450,4.412133,0.038180,41.990832,, +0.099600,4.109086,0.039840,41.997505,, +0.103750,3.197128,0.041500,42.002245,, +0.107900,4.507540,0.043160,42.007209,, +0.112050,3.589242,0.044820,42.004658,, +0.116200,3.669825,0.046480,41.999381,, +0.120350,5.099886,0.048140,42.000345,, +0.124500,4.953111,0.049800,41.998416,, +0.128650,3.782644,0.051460,42.002210,, +0.132800,4.152387,0.053120,41.999956,, +0.136950,4.289268,0.054780,41.999029,, +0.141100,4.400393,0.056440,41.998061,, +0.145250,3.399306,0.058100,42.001305,, +0.149400,5.053585,0.059760,42.008409,, +0.153550,4.005283,0.061420,41.996576,, +0.157700,3.934068,0.063080,41.997141,, +0.161850,3.307713,0.064740,41.997630,, +0.166000,3.788510,0.066400,42.009143,, +0.170150,3.798363,0.068060,41.991398,, +0.174300,3.907519,0.069720,42.000797,, +0.178450,3.582295,0.071380,41.997329,, +0.182600,4.063558,0.073040,41.996833,, +0.186750,3.802358,0.074700,41.999256,, +0.190900,3.100230,0.076360,42.002385,, +0.195050,4.591986,0.078020,41.996634,, +0.199200,4.282388,0.079680,41.992667,, +0.203350,4.209618,0.081340,42.002868,, +0.207500,4.446149,0.083000,42.002610,, +0.211650,3.940980,0.084660,42.000966,, +0.215800,4.428173,0.086320,42.001482,, +0.219950,4.663105,0.087980,42.000749,, +0.224100,4.086160,0.089640,41.998948,, +0.228250,4.089622,0.091300,41.996062,, +0.232400,4.649808,0.092960,41.999669,, +0.236550,3.968787,0.094620,41.994314,, +0.240700,4.083540,0.096280,42.004702,, +0.244850,4.423977,0.097940,42.000765,, +0.249000,4.851913,0.099600,41.998075,, +0.253150,4.550740,0.101260,41.995174,, +0.257300,2.857096,0.102920,41.995258,, +0.261450,3.790326,0.104580,41.999840,, +0.265600,2.931205,0.106240,42.004058,, +0.269750,3.883928,0.107900,42.000505,, +0.273900,3.507691,0.109560,41.999549,, +0.278050,3.972666,0.111220,41.999198,, +0.282200,4.432037,0.112880,41.998574,, +0.286350,3.934543,0.114540,41.994761,, +0.290500,4.116879,0.116200,42.002173,, +0.294650,3.869642,0.117860,41.992791,, +0.298800,3.670111,0.119520,42.001743,, +0.302950,4.035401,0.121180,41.997072,, +0.307100,3.608183,0.122840,42.005418,, +0.311250,3.308729,0.124500,41.994555,, +0.315400,3.786518,0.126160,41.998321,, +0.319550,3.727016,0.127820,42.003095,, +0.323700,3.759341,0.129480,42.001491,, +0.327850,3.745083,0.131140,42.003207,, +0.332000,3.259896,0.132800,41.998194,, +0.336150,4.252330,0.134460,42.001403,, +0.340300,3.153882,0.136120,42.003335,, +0.344450,4.684474,0.137780,41.995393,, +0.348600,4.531686,0.139440,41.999082,, +0.352750,4.043165,0.141100,42.005713,, +0.356900,3.800762,0.142760,41.992205,, +0.361050,5.025431,0.144420,42.002544,, +0.365200,3.825496,0.146080,42.005057,, +0.369350,4.252542,0.147740,42.005343,, +0.373500,4.826775,0.149400,42.000019,, +0.377650,3.976460,0.151060,41.995808,, +0.381800,4.200288,0.152720,42.003315,, +0.385950,4.490191,0.154380,42.000155,, +0.390100,3.969418,0.156040,41.997081,, +0.394250,5.340383,0.157700,42.000755,, +0.398400,3.552916,0.159360,41.998116,, +0.402550,4.612789,0.161020,42.000756,, +0.406700,4.709764,0.162680,42.002453,, +0.410850,4.429279,0.164340,42.002046,, +0.415000,4.417262,0.166000,42.001100,, +0.419150,3.790072,0.167660,42.005133,, +0.423300,3.680837,0.169320,42.000179,, +0.427450,4.832892,0.170980,41.999693,, +0.431600,4.567775,0.172640,41.997491,, +0.435750,3.672094,0.174300,41.996931,, +0.439900,4.159214,0.175960,41.994813,, +0.444050,4.413382,0.177620,41.999215,, +0.448200,3.676363,0.179280,42.003994,, +0.452350,4.036726,0.180940,42.005690,, +0.456500,3.845125,0.182600,42.003750,, +0.460650,3.978371,0.184260,42.003609,, +0.464800,4.452613,0.185920,42.005350,, +0.468950,3.285942,0.187580,41.985889,, +0.473100,3.725668,0.189240,42.001907,, +0.477250,3.188710,0.190900,42.004287,, +0.481400,3.845758,0.192560,42.002659,, +0.485550,4.097399,0.194220,41.996213,, +0.489700,3.316560,0.195880,41.995879,, +0.493850,4.140329,0.197540,41.997641,, +0.498000,4.391988,0.199200,41.996755,, +0.502150,3.971110,0.200860,42.002177,, +0.506300,4.649515,0.202520,41.995840,, +0.510450,3.845920,0.204180,41.997283,, +0.514600,4.124261,0.205840,42.006258,, +0.518750,3.305968,0.207500,42.002463,, +0.522900,4.223244,0.209160,42.010122,, +0.527050,3.951468,0.210820,42.003508,, +0.531200,3.537519,0.212480,42.004673,, +0.535350,4.124263,0.214140,42.000757,, +0.539500,3.376184,0.215800,42.005169,, +0.543650,4.507093,0.217460,42.001317,, +0.547800,3.511896,0.219120,42.002545,, +0.551950,4.585308,0.220780,41.998618,, +0.556100,3.290138,0.222440,41.998150,, +0.560250,4.598295,0.224100,42.007547,, +0.564400,3.806702,0.225760,42.006959,, +0.568550,4.113364,0.227420,41.999513,, +0.572700,4.434057,0.229080,41.999748,, +0.576850,3.993791,0.230740,41.994902,, +0.581000,3.350123,0.232400,42.001976,, +0.585150,4.326051,0.234060,41.999563,, +0.589300,3.637265,0.235720,41.998503,, +0.593450,3.800453,0.237380,41.997881,, +0.597600,4.945199,0.239040,41.996488,, +0.601750,3.450394,0.240700,42.000022,, +0.605900,3.654482,0.242360,41.996523,, +0.610050,4.620961,0.244020,41.995922,, +0.614200,3.869139,0.245680,42.003572,, +0.618350,3.290008,0.247340,41.994279,, +0.622500,3.744323,0.249000,41.995041,, +0.626650,4.195608,0.250660,42.003720,, +0.630800,3.044640,0.252320,42.005835,, +0.634950,4.295825,0.253980,41.995308,, +0.639100,4.954017,0.255640,41.998299,, +0.643250,4.252435,0.257300,41.997434,, +0.647400,3.785609,0.258960,42.002598,, +0.651550,4.275923,0.260620,42.000296,, +0.655700,4.062867,0.262280,41.995955,, +0.659850,3.428931,0.263940,42.007781,, +0.664000,4.139424,0.265600,41.998970,, +0.668150,4.508956,0.267260,42.004207,, +0.672300,4.550025,0.268920,42.000113,, +0.676450,4.127339,0.270580,41.997631,, +0.680600,3.817670,0.272240,42.006027,, +0.684750,4.918737,0.273900,41.999744,, +0.688900,4.217168,0.275560,41.997950,, +0.693050,4.126484,0.277220,42.006062,, +0.697200,3.966642,0.278880,41.995900,, +0.701350,4.478806,0.280540,41.992539,, +0.705500,4.321034,0.282200,41.996774,, +0.709650,4.670549,0.283860,42.005914,, +0.713800,4.010079,0.285520,41.996837,, +0.717950,4.264354,0.287180,41.997067,, +0.722100,3.796033,0.288840,41.991375,, +0.726250,3.374673,0.290500,41.998854,, +0.730400,3.956036,0.292160,41.992072,, +0.734550,3.766437,0.293820,41.999737,, +0.738700,3.762062,0.295480,42.001560,, +0.742850,3.937852,0.297140,42.002069,, +0.747000,4.675775,0.298800,42.001795,, +0.751150,4.065121,0.300460,41.994920,, +0.755300,3.900406,0.302120,41.998703,, +0.759450,4.802234,0.303780,41.994876,, +0.763600,4.208863,0.305440,42.000678,, +0.767750,3.619293,0.307100,41.995694,, +0.771900,3.487842,0.308760,42.001461,, +0.776050,3.945905,0.310420,42.003222,, +0.780200,2.836883,0.312080,42.001295,, +0.784350,3.647866,0.313740,42.003369,, +0.788500,4.878355,0.315400,41.991516,, +0.792650,3.841136,0.317060,42.005156,, +0.796800,3.394417,0.318720,42.000701,, +0.800950,4.515867,0.320380,41.989044,, +0.805100,3.941734,0.322040,41.994843,, +0.809250,4.157675,0.323700,42.002264,, +0.813400,4.456890,0.325360,41.994995,, +0.817550,3.373663,0.327020,41.996186,, +0.821700,3.333936,0.328680,41.994277,, +0.825850,4.521006,0.330340,41.999539,, +0.830000,3.391727,0.332000,42.000254,, +0.834150,4.087274,0.333660,41.989291,, +0.838300,4.497209,0.335320,42.001763,, +0.842450,3.587702,0.336980,42.004390,, +0.846600,4.055210,0.338640,41.999749,, +0.850750,3.645903,0.340300,41.997512,, +0.854900,4.114590,0.341960,42.002498,, +0.859050,4.110640,0.343620,41.999160,, +0.863200,3.645551,0.345280,42.001509,, +0.867350,3.287568,0.346940,41.999820,, +0.871500,4.857128,0.348600,41.998896,, +0.875650,4.558100,0.350260,42.008692,, +0.879800,3.876761,0.351920,41.994583,, +0.883950,4.752293,0.353580,41.999405,, +0.888100,3.948877,0.355240,41.995588,, +0.892250,4.527874,0.356900,42.001679,, +0.896400,3.889115,0.358560,42.004645,, +0.900550,3.396736,0.360220,42.001825,, +0.904700,3.871278,0.361880,42.003654,, +0.908850,2.950037,0.363540,42.005540,, +0.913000,4.006996,0.365200,41.991908,, +0.917150,3.786831,0.366860,42.005256,, +0.921300,3.672627,0.368520,42.001330,, +0.925450,3.822821,0.370180,41.993680,, +0.929600,4.150500,0.371840,41.995857,, +0.933750,3.729577,0.373500,41.999790,, +0.937900,3.832219,0.375160,42.007055,, +0.942050,3.932256,0.376820,42.004322,, +0.946200,4.312104,0.378480,42.001183,, +0.950350,4.396034,0.380140,42.007185,, +0.954500,4.320995,0.381800,42.001491,, +0.958650,4.607734,0.383460,41.999367,, +0.962800,3.856721,0.385120,42.009122,, +0.966950,5.202085,0.386780,41.995224,, +0.971100,3.805189,0.388440,41.999501,, +0.975250,4.690197,0.390100,41.998232,, +0.979400,3.735448,0.391760,41.999109,, +0.983550,4.179644,0.393420,42.004238,, +0.987700,4.220125,0.395080,42.001743,, +0.991850,4.324144,0.396740,42.002815,, +0.996000,4.316086,0.398400,41.999418,, +1.000150,4.710841,0.400060,41.997268,, +1.004300,4.045433,0.401720,42.004486,, +1.008450,4.017331,0.403380,41.997820,, +1.012600,3.445017,0.405040,41.996180,, +1.016750,4.133547,0.406700,42.004234,, +1.020900,3.675235,0.408360,42.003692,, +1.025050,3.509839,0.410020,41.995426,, +1.029200,4.679001,0.411680,42.005391,, +1.033350,3.977754,0.413340,41.995847,, +1.037500,3.821830,0.415000,41.994499,, +1.041650,3.232698,0.416660,41.996955,, +1.045800,3.201911,0.418320,41.999553,, +1.049950,3.703808,0.419980,42.002327,, +1.054100,3.748762,0.421640,41.999086,, +1.058250,4.256635,0.423300,42.004136,, +1.062400,3.996684,0.424960,41.997761,, +1.066550,3.581776,0.426620,42.001948,, +1.070700,3.604292,0.428280,42.006508,, +1.074850,3.683757,0.429940,42.005578,, +1.079000,4.603643,0.431600,42.003975,, +1.083150,4.836628,0.433260,42.007187,, +1.087300,3.907925,0.434920,42.002521,, +1.091450,5.441430,0.436580,41.999185,, +1.095600,3.374242,0.438240,42.005116,, +1.099750,3.172472,0.439900,42.000760,, +1.103900,3.068842,0.441560,41.994476,, +1.108050,3.960893,0.443220,42.004965,, +1.112200,4.258584,0.444880,42.001734,, +1.116350,3.789137,0.446540,41.992563,, +1.120500,4.958270,0.448200,42.000960,, +1.124650,3.801733,0.449860,41.991895,, +1.128800,4.338027,0.451520,41.999536,, +1.132950,4.180838,0.453180,42.000830,, +1.137100,3.652938,0.454840,42.005412,, +1.141250,3.770178,0.456500,41.997398,, +1.145400,3.418481,0.458160,42.000560,, +1.149550,4.623152,0.459820,42.001253,, +1.153700,4.249828,0.461480,41.997453,, +1.157850,3.947869,0.463140,42.000297,, +1.162000,2.522748,0.464800,42.005352,, +1.166150,3.855567,0.466460,42.000156,, +1.170300,3.899946,0.468120,41.993005,, +1.174450,3.852374,0.469780,42.006354,, +1.178600,3.472752,0.471440,42.003828,, +1.182750,4.356796,0.473100,41.999724,, +1.186900,3.899222,0.474760,41.995198,, +1.191050,4.836518,0.476420,42.005351,, +1.195200,3.869990,0.478080,42.000944,, +1.199350,3.760965,0.479740,41.994061,, +1.203500,3.300748,0.481400,42.001096,, +1.207650,3.968389,0.483060,41.992952,, +1.211800,3.620943,0.484720,42.001240,, +1.215950,4.365364,0.486380,41.996836,, +1.220100,4.153903,0.488040,42.003711,, +1.224250,4.348779,0.489700,41.993755,, +1.228400,3.868529,0.491360,42.003033,, +1.232550,4.373177,0.493020,42.000578,, +1.236700,3.761992,0.494680,42.004618,, +1.240850,3.382903,0.496340,41.997601,, +1.245000,3.327687,0.498000,42.002816,, +1.249150,4.364764,0.499660,42.000453,, +1.253300,3.697216,0.501320,41.999352,, +1.257450,3.440234,0.502980,41.996272,, +1.261600,3.793694,0.504640,42.003516,, +1.265750,3.935599,0.506300,41.998667,, +1.269900,4.307551,0.507960,41.996098,, +1.274050,3.923336,0.509620,42.002775,, +1.278200,3.345616,0.511280,41.992542,, +1.282350,4.252828,0.512940,41.997565,, +1.286500,4.014317,0.514600,42.002248,, +1.290650,3.549467,0.516260,42.001136,, +1.294800,3.247915,0.517920,41.996082,, +1.298950,3.975339,0.519580,42.001780,, +1.303100,4.410944,0.521240,42.003697,, +1.307250,4.609528,0.522900,41.998713,, +1.311400,4.353052,0.524560,41.997236,, +1.315550,4.228266,0.526220,41.996205,, +1.319700,4.669857,0.527880,42.000686,, +1.323850,3.929020,0.529540,42.001815,, +1.328000,3.646277,0.531200,41.997162,, +1.332150,3.538652,0.532860,42.006637,, +1.336300,3.949335,0.534520,42.000886,, +1.340450,3.775008,0.536180,41.991990,, +1.344600,3.113655,0.537840,42.005595,, +1.348750,4.088448,0.539500,42.002242,, +1.352900,4.287746,0.541160,41.992959,, +1.357050,3.975479,0.542820,42.005865,, +1.361200,4.498913,0.544480,42.005373,, +1.365350,3.116444,0.546140,42.003029,, +1.369500,4.178257,0.547800,42.004307,, +1.373650,4.510930,0.549460,42.000903,, +1.377800,3.581105,0.551120,41.996870,, +1.381950,4.066411,0.552780,42.000687,, +1.386100,4.509632,0.554440,42.003975,, +1.390250,3.582826,0.556100,41.999498,, +1.394400,3.218354,0.557760,41.995052,, +1.398550,4.051250,0.559420,41.991440,, +1.402700,4.471793,0.561080,41.994236,, +1.406850,3.380434,0.562740,41.995082,, +1.411000,3.593450,0.564400,41.997431,, +1.415150,3.958289,0.566060,42.000299,, +1.419300,3.989552,0.567720,41.992139,, +1.423450,3.609154,0.569380,41.997816,, +1.427600,4.601500,0.571040,41.997960,, +1.431750,4.497856,0.572700,42.002491,, +1.435900,4.053910,0.574360,42.000172,, +1.440050,4.209642,0.576020,42.003136,, +1.444200,4.583192,0.577680,42.003163,, +1.448350,4.130025,0.579340,41.999044,, +1.452500,3.743074,0.581000,41.989488,, +1.456650,4.370186,0.582660,41.997567,, +1.460800,3.933456,0.584320,42.001977,, +1.464950,3.583337,0.585980,42.002155,, +1.469100,4.169369,0.587640,41.996578,, +1.473250,3.172767,0.589300,41.997950,, +1.477400,4.244012,0.590960,42.002577,, +1.481550,3.570439,0.592620,42.004258,, +1.485700,4.178147,0.594280,42.004071,, +1.489850,4.141819,0.595940,42.002357,, +1.494000,4.045614,0.597600,41.996016,, +1.498150,4.603604,0.599260,41.999191,, +1.502300,4.319620,0.600920,42.000012,, +1.506450,3.749972,0.602580,42.004319,, +1.510600,4.638071,0.604240,41.997760,, +1.514750,5.273829,0.605900,41.994511,, +1.518900,3.109549,0.607560,42.000835,, +1.523050,4.395376,0.609220,42.005945,, +1.527200,4.088538,0.610880,41.998114,, +1.531350,3.666537,0.612540,42.006428,, +1.535500,4.674162,0.614200,41.997711,, +1.539650,4.322189,0.615860,41.999304,, +1.543800,2.809738,0.617520,42.000902,, +1.547950,4.074681,0.619180,42.002131,, +1.552100,3.770896,0.620840,41.990099,, +1.556250,3.919202,0.622500,42.004768,, +1.560400,3.623927,0.624160,41.997224,, +1.564550,4.502832,0.625820,42.000178,, +1.568700,4.582894,0.627480,41.998618,, +1.572850,3.804579,0.629140,41.994721,, +1.577000,3.386422,0.630800,42.002259,, +1.581150,4.071719,0.632460,42.006362,, +1.585300,4.578606,0.634120,41.991340,, +1.589450,4.057873,0.635780,42.000723,, +1.593600,4.495118,0.637440,41.994629,, +1.597750,4.044201,0.639100,42.001281,, +1.601900,4.359589,0.640760,42.001562,, +1.606050,4.151236,0.642420,42.001434,, +1.610200,3.679749,0.644080,42.000168,, +1.614350,3.571729,0.645740,42.005462,, +1.618500,3.688076,0.647400,42.000626,, +1.622650,3.698932,0.649060,41.991913,, +1.626800,3.726962,0.650720,42.004072,, +1.630950,3.723648,0.652380,41.999346,, +1.635100,3.858126,0.654040,41.999815,, +1.639250,3.004947,0.655700,41.995529,, +1.643400,3.558202,0.657360,41.991007,, +1.647550,3.361707,0.659020,42.002979,, +1.651700,4.344286,0.660680,42.003671,, +1.655850,3.665469,0.662340,41.999502,, +1.660000,4.594136,0.664000,42.000505,, +1.664150,4.109322,0.665660,42.000767,, +1.668300,4.355182,0.667320,42.000651,, +1.672450,4.492389,0.668980,42.000917,, +1.676600,3.469636,0.670640,42.007285,, +1.680750,4.352708,0.672300,41.993397,, +1.684900,3.476937,0.673960,42.004583,, +1.689050,4.178847,0.675620,41.998152,, +1.693200,4.876684,0.677280,41.997178,, +1.697350,5.045316,0.678940,42.001769,, +1.701500,4.001618,0.680600,41.999162,, +1.705650,3.538912,0.682260,41.996402,, +1.709800,3.290550,0.683920,41.999437,, +1.713950,4.263451,0.685580,42.000502,, +1.718100,4.412918,0.687240,42.003382,, +1.722250,4.472885,0.688900,42.003779,, +1.726400,3.464244,0.690560,41.998775,, +1.730550,4.161176,0.692220,42.005624,, +1.734700,3.500173,0.693880,41.996136,, +1.738850,4.189317,0.695540,41.994090,, +1.743000,3.424005,0.697200,41.998330,, +1.747150,4.082512,0.698860,41.996199,, +1.751300,4.265475,0.700520,42.001198,, +1.755450,4.190147,0.702180,42.002994,, +1.759600,4.100503,0.703840,41.993455,, +1.763750,3.148073,0.705500,42.004767,, +1.767900,3.345510,0.707160,42.000034,, +1.772050,3.882620,0.708820,41.993664,, +1.776200,3.119082,0.710480,41.993711,, +1.780350,3.718902,0.712140,42.005185,, +1.784500,4.513497,0.713800,42.000119,, +1.788650,3.858098,0.715460,41.994380,, +1.792800,3.307239,0.717120,41.999367,, +1.796950,4.523918,0.718780,42.006504,, +1.801100,4.619080,0.720440,41.998082,, +1.805250,3.124666,0.722100,42.001587,, +1.809400,3.197161,0.723760,41.994005,, +1.813550,3.446465,0.725420,42.004676,, +1.817700,4.495714,0.727080,42.003195,, +1.821850,2.664930,0.728740,41.992168,, +1.826000,3.780240,0.730400,42.001704,, +1.830150,4.285455,0.732060,42.004685,, +1.834300,4.208738,0.733720,41.995776,, +1.838450,4.390071,0.735380,41.996186,, +1.842600,4.108483,0.737040,41.998085,, +1.846750,3.680485,0.738700,41.996552,, +1.850900,2.900341,0.740360,41.997364,, +1.855050,4.047089,0.742020,41.996786,, +1.859200,4.395510,0.743680,42.005104,, +1.863350,4.206111,0.745340,42.000568,, +1.867500,4.028525,0.747000,42.005416,, +1.871650,4.057007,0.748660,42.003702,, +1.875800,3.579096,0.750320,41.992910,, +1.879950,2.871812,0.751980,42.005148,, +1.884100,4.644014,0.753640,42.004712,, +1.888250,3.680240,0.755300,42.001596,, +1.892400,3.073510,0.756960,41.997895,, +1.896550,4.413716,0.758620,42.008549,, +1.900700,4.372659,0.760280,41.997735,, +1.904850,3.972226,0.761940,42.001431,, +1.909000,3.997746,0.763600,42.003578,, +1.913150,4.020175,0.765260,42.003506,, +1.917300,4.274726,0.766920,41.996608,, +1.921450,3.497631,0.768580,42.000434,, +1.925600,4.315639,0.770240,42.002075,, +1.929750,4.466465,0.771900,42.001586,, +1.933900,4.043930,0.773560,42.001219,, +1.938050,4.619959,0.775220,42.002068,, +1.942200,4.487638,0.776880,42.003915,, +1.946350,3.305386,0.778540,41.998874,, +1.950500,4.419488,0.780200,41.998118,, +1.954650,3.688413,0.781860,42.000896,, +1.958800,3.059142,0.783520,42.004393,, +1.962950,4.903786,0.785180,41.997773,, +1.967100,4.104884,0.786840,41.996239,, +1.971250,3.563696,0.788500,42.002949,, +1.975400,4.982523,0.790160,42.009033,, +1.979550,4.499609,0.791820,41.998881,, +1.983700,3.293764,0.793480,41.999225,, +1.987850,3.186377,0.795140,41.996671,, +1.992000,3.245825,0.796800,42.007321,, +1.996150,3.365147,0.798460,41.992601,, +2.000300,3.680692,0.800120,42.007032,, +2.004450,3.727215,0.801780,41.996973,, +2.008600,3.530188,0.803440,41.996713,, +2.012750,4.536421,0.805100,42.000446,, +2.016900,3.533897,0.806760,42.004734,, +2.021050,3.397080,0.808420,41.992474,, +2.025200,4.125525,0.810080,41.997968,, +2.029350,4.689083,0.811740,42.008918,, +2.033500,4.680307,0.813400,42.001401,, +2.037650,3.675076,0.815060,41.997612,, +2.041800,4.170691,0.816720,41.999698,, +2.045950,3.890107,0.818380,41.995454,, +2.050100,4.110062,0.820040,41.999470,, +2.054250,3.355302,0.821700,42.000940,, +2.058400,4.531240,0.823360,41.999123,, +2.062550,5.039341,0.825020,41.998953,, +2.066700,4.270088,0.826680,42.001978,, +2.070850,3.510586,0.828340,41.999550,, +2.075000,4.144316,0.830000,42.006333,, +2.079150,3.762740,0.831660,42.000256,, +2.083300,4.353080,0.833320,42.006581,, +2.087450,4.218510,0.834980,42.003733,, +2.091600,2.799261,0.836640,41.996845,, +2.095750,3.776239,0.838300,42.005898,, +2.099900,4.286031,0.839960,42.009354,, +2.104050,3.952348,0.841620,41.998592,, +2.108200,4.342861,0.843280,41.999938,, +2.112350,3.784990,0.844940,42.003063,, +2.116500,4.147827,0.846600,41.998930,, +2.120650,3.365245,0.848260,42.002172,, +2.124800,4.902920,0.849920,41.998625,, +2.128950,3.845566,0.851580,41.999319,, +2.133100,4.780609,0.853240,42.000655,, +2.137250,3.458774,0.854900,42.000086,, +2.141400,3.829045,0.856560,41.996704,, +2.145550,4.425978,0.858220,41.994315,, +2.149700,4.089821,0.859880,42.001926,, +2.153850,3.968749,0.861540,41.986746,, +2.158000,3.808586,0.863200,41.992030,, +2.162150,3.882863,0.864860,42.004056,, +2.166300,5.216893,0.866520,42.001838,, +2.170450,3.230873,0.868180,41.998349,, +2.174600,4.131979,0.869840,42.005976,, +2.178750,3.109556,0.871500,41.998843,, +2.182900,4.681136,0.873160,42.007153,, +2.187050,4.486442,0.874820,41.999987,, +2.191200,3.524679,0.876480,42.000596,, +2.195350,4.458672,0.878140,41.991008,, +2.199500,3.688261,0.879800,41.996828,, +2.203650,3.480268,0.881460,41.998577,, +2.207800,3.867743,0.883120,41.997434,, +2.211950,3.173290,0.884780,41.997042,, +2.216100,4.588620,0.886440,42.000311,, +2.220250,4.461823,0.888100,42.003400,, +2.224400,3.990678,0.889760,41.995657,, +2.228550,3.494967,0.891420,41.998098,, +2.232700,3.880225,0.893080,42.005146,, +2.236850,4.640518,0.894740,41.998315,, +2.241000,3.980940,0.896400,42.000631,, +2.245150,3.660963,0.898060,42.005798,, +2.249300,2.903846,0.899720,41.988842,, +2.253450,4.364585,0.901380,41.995597,, +2.257600,3.814919,0.903040,41.995397,, +2.261750,4.114324,0.904700,41.999590,, +2.265900,3.345366,0.906360,41.999871,, +2.270050,3.899220,0.908020,41.992981,, +2.274200,3.587295,0.909680,41.993567,, +2.278350,3.799853,0.911340,42.003758,, +2.282500,4.817039,0.913000,41.999987,, +2.286650,3.914701,0.914660,42.000941,, +2.290800,3.301853,0.916320,42.002241,, +2.294950,4.182718,0.917980,41.999534,, +2.299100,4.318896,0.919640,42.007979,, +2.303250,3.886884,0.921300,41.999072,, +2.307400,3.658263,0.922960,42.001285,, +2.311550,3.334446,0.924620,41.995803,, +2.315700,4.332568,0.926280,42.007267,, +2.319850,3.714605,0.927940,42.004434,, +2.324000,4.067047,0.929600,41.996089,, +2.328150,4.568926,0.931260,41.996618,, +2.332300,4.414982,0.932920,42.006554,, +2.336450,4.126171,0.934580,41.998149,, +2.340600,4.728210,0.936240,41.997918,, +2.344750,4.227498,0.937900,42.001177,, +2.348900,3.542917,0.939560,41.994545,, +2.353050,3.753784,0.941220,42.002313,, +2.357200,4.247359,0.942880,41.993398,, +2.361350,4.053200,0.944540,42.003131,, +2.365500,3.861967,0.946200,42.006447,, +2.369650,4.591204,0.947860,42.009768,, +2.373800,3.391922,0.949520,42.004117,, +2.377950,3.620058,0.951180,42.008943,, +2.382100,4.028680,0.952840,42.008046,, +2.386250,4.742277,0.954500,42.007561,, +2.390400,4.330188,0.956160,42.008049,, +2.394550,3.393304,0.957820,42.011593,, +2.398700,3.917920,0.959480,42.016864,, +2.402850,4.486424,0.961140,42.022018,, +2.407000,2.751949,0.962800,42.030408,, +2.411150,4.251408,0.964460,42.036557,, +2.415300,3.949079,0.966120,42.049786,, +2.419450,3.592211,0.967780,42.052451,, +2.423600,4.056061,0.969440,42.061333,, +2.427750,4.617558,0.971100,42.058518,, +2.431900,4.156030,0.972760,42.082731,, +2.436050,3.755486,0.974420,42.105811,, +2.440200,4.145111,0.976080,42.122330,, +2.444350,3.009319,0.977740,42.138504,, +2.448500,3.770040,0.979400,42.155188,, +2.452650,4.379621,0.981060,42.185001,, +2.456800,3.262860,0.982720,42.212893,, +2.460950,3.935697,0.984380,42.254116,, +2.465100,4.234166,0.986040,42.278953,, +2.469250,4.228584,0.987700,42.320869,, +2.473400,4.505773,0.989360,42.370578,, +2.477550,3.456890,0.991020,42.425851,, +2.481700,3.574025,0.992680,42.474054,, +2.485850,3.781785,0.994340,42.522698,, +2.490000,4.742254,0.996000,42.573073,, +2.494150,3.221940,0.997660,42.649017,, +2.498300,5.326413,0.999320,42.726042,, +2.502450,3.743058,1.000980,42.790395,, +2.506600,3.846298,1.002640,42.856352,, +2.510750,4.137823,1.004300,42.932702,, +2.514900,4.159200,1.005960,43.016257,, +2.519050,4.536609,1.007620,43.097195,, +2.523200,4.557236,1.009280,43.184003,, +2.527350,3.985825,1.010940,43.264574,, +2.531500,4.113921,1.012600,43.341890,, +2.535650,2.897654,1.014260,43.421329,, +2.539800,3.443222,1.015920,43.499743,, +2.543950,3.799688,1.017580,43.583491,, +2.548100,3.739173,1.019240,43.659443,, +2.552250,3.008585,1.020900,43.741429,, +2.556400,4.110807,1.022560,43.800305,, +2.560550,3.827383,1.024220,43.861587,, +2.564700,5.052868,1.025880,43.917890,, +2.568850,3.985222,1.027540,43.974841,, +2.573000,3.721338,1.029200,44.033797,, +2.577150,3.784031,1.030860,44.056862,, +2.581300,3.561857,1.032520,44.081756,, +2.585450,4.328842,1.034180,44.106328,, +2.589600,4.629713,1.035840,44.132309,, +2.593750,3.892023,1.037500,44.151117,, +2.597900,3.992087,1.039160,44.139953,, +2.602050,4.779114,1.040820,44.127994,, +2.606200,3.653209,1.042480,44.120432,, +2.610350,3.730429,1.044140,44.100371,, +2.614500,3.519221,1.045800,44.088172,, +2.618650,4.437496,1.047460,44.046443,, +2.622800,3.056782,1.049120,43.998036,, +2.626950,4.159374,1.050780,43.951954,, +2.631100,3.138120,1.052440,43.908026,, +2.635250,4.640238,1.054100,43.864982,, +2.639400,4.439898,1.055760,43.799726,, +2.643550,3.845303,1.057420,43.725888,, +2.647700,3.271475,1.059080,43.660543,, +2.651850,4.130525,1.060740,43.590426,, +2.656000,3.760404,1.062400,43.526626,, +2.660150,4.607565,1.064060,43.447001,, +2.664300,4.364853,1.065720,43.383444,, +2.668450,4.403144,1.067380,43.302393,, +2.672600,3.845863,1.069040,43.231330,, +2.676750,3.983441,1.070700,43.159594,, +2.680900,3.820076,1.072360,43.086212,, +2.685050,5.488225,1.074020,43.010334,, +2.689200,3.356813,1.075680,42.951147,, +2.693350,4.125793,1.077340,42.881440,, +2.697500,3.227058,1.079000,42.813817,, +2.701650,4.094625,1.080660,42.757992,, +2.705800,4.286339,1.082320,42.709531,, +2.709950,4.331469,1.083980,42.646093,, +2.714100,4.375505,1.085640,42.596498,, +2.718250,3.066890,1.087300,42.534882,, +2.722400,4.014992,1.088960,42.492299,, +2.726550,3.846525,1.090620,42.454042,, +2.730700,4.365667,1.092280,42.407752,, +2.734850,3.495731,1.093940,42.375645,, +2.739000,4.583473,1.095600,42.331554,, +2.743150,3.908574,1.097260,42.292786,, +2.747300,4.737453,1.098920,42.265231,, +2.751450,4.248844,1.100580,42.242659,, +2.755600,4.861315,1.102240,42.214039,, +2.759750,3.071012,1.103900,42.191712,, +2.763900,4.528186,1.105560,42.172452,, +2.768050,4.228286,1.107220,42.156180,, +2.772200,4.366987,1.108880,42.142731,, +2.776350,3.991041,1.110540,42.114228,, +2.780500,2.239703,1.112200,42.109648,, +2.784650,5.075267,1.113860,42.087235,, +2.788800,3.823942,1.115520,42.082742,, +2.792950,3.650541,1.117180,42.071640,, +2.797100,2.785982,1.118840,42.062708,, +2.801250,4.052846,1.120500,42.048121,, +2.805400,4.282024,1.122160,42.049942,, +2.809550,4.453743,1.123820,42.040431,, +2.813700,3.936700,1.125480,42.033614,, +2.817850,3.771506,1.127140,42.030378,, +2.822000,3.574850,1.128800,42.032622,, +2.826150,5.049390,1.130460,42.027078,, +2.830300,3.561339,1.132120,42.013202,, +2.834450,3.629801,1.133780,42.014099,, +2.838600,3.553971,1.135440,42.011028,, +2.842750,4.178784,1.137100,42.011257,, +2.846900,4.319112,1.138760,42.004370,, +2.851050,4.287240,1.140420,42.008106,, +2.855200,4.341030,1.142080,42.006334,, +2.859350,4.217480,1.143740,42.009024,, +2.863500,3.106834,1.145400,42.003982,, +2.867650,3.621373,1.147060,42.001947,, +2.871800,4.289884,1.148720,42.004105,, +2.875950,3.899391,1.150380,42.003231,, +2.880100,3.870616,1.152040,42.007323,, +2.884250,3.899259,1.153700,42.003322,, +2.888400,4.804359,1.155360,41.998543,, +2.892550,4.256352,1.157020,42.004460,, +2.896700,4.033282,1.158680,42.006299,, +2.900850,5.144281,1.160340,42.001555,, +2.905000,3.853787,1.162000,41.999960,, +2.909150,3.697638,1.163660,42.006040,, +2.913300,3.915693,1.165320,42.002255,, +2.917450,4.082659,1.166980,42.004865,, +2.921600,3.639909,1.168640,41.999803,, +2.925750,3.801268,1.170300,42.005941,, +2.929900,3.795634,1.171960,41.997950,, +2.934050,3.353295,1.173620,41.991579,, +2.938200,4.592375,1.175280,41.996235,, +2.942350,3.783760,1.176940,42.002221,, +2.946500,4.069955,1.178600,41.998775,, +2.950650,3.842947,1.180260,42.008087,, +2.954800,3.416925,1.181920,41.994145,, +2.958950,3.617728,1.183580,42.003146,, +2.963100,4.209146,1.185240,42.000749,, +2.967250,4.847387,1.186900,41.998463,, +2.971400,4.538804,1.188560,42.004594,, +2.975550,3.447128,1.190220,42.000606,, +2.979700,5.061757,1.191880,42.000187,, +2.983850,4.879505,1.193540,41.998489,, +2.988000,3.479026,1.195200,42.006331,, +,,1.196860,41.993096,, +,,1.198520,41.996845,, +,,1.200180,41.995427,, +,,1.201840,42.001413,, +,,1.203500,41.999725,, +,,1.205160,41.997505,, +,,1.206820,42.000176,, +,,1.208480,42.005811,, +,,1.210140,42.000896,, +,,1.211800,41.997495,, +,,1.213460,42.001175,, +,,1.215120,41.992425,, +,,1.216780,41.995134,, +,,1.218440,42.004150,, +,,1.220100,42.003937,, +,,1.221760,41.999215,, +,,1.223420,42.003818,, +,,1.225080,42.002021,, +,,1.226740,41.998359,, +,,1.228400,41.993551,, +,,1.230060,41.998447,, +,,1.231720,41.999331,, +,,1.233380,42.000345,, +,,1.235040,41.998690,, +,,1.236700,42.000368,, +,,1.238360,41.999939,, +,,1.240020,41.997134,, +,,1.241680,42.003825,, +,,1.243340,41.998540,, +,,1.245000,41.997420,, +,,1.246660,41.998559,, +,,1.248320,42.002500,, +,,1.249980,41.995838,, +,,1.251640,42.008110,, +,,1.253300,41.996666,, +,,1.254960,41.986211,, +,,1.256620,41.997763,, +,,1.258280,42.006667,, +,,1.259940,41.997586,, +,,1.261600,41.998433,, +,,1.263260,42.000282,, +,,1.264920,42.007940,, +,,1.266580,42.002896,, +,,1.268240,41.999649,, +,,1.269900,41.999293,, +,,1.271560,42.001192,, +,,1.273220,42.007997,, +,,1.274880,41.992852,, +,,1.276540,42.004666,, +,,1.278200,42.005061,, +,,1.279860,41.998588,, +,,1.281520,41.994560,, +,,1.283180,42.004241,, +,,1.284840,41.999876,, +,,1.286500,42.004523,, +,,1.288160,42.003740,, +,,1.289820,42.002687,, +,,1.291480,41.995345,, +,,1.293140,42.009482,, +,,1.294800,41.996798,, +,,1.296460,42.004619,, +,,1.298120,42.003406,, +,,1.299780,41.996025,, +,,1.301440,41.994229,, +,,1.303100,41.997990,, +,,1.304760,41.999412,, +,,1.306420,42.001689,, +,,1.308080,41.995698,, +,,1.309740,41.996808,, +,,1.311400,42.000771,, +,,1.313060,42.007176,, +,,1.314720,42.002496,, +,,1.316380,42.002007,, +,,1.318040,41.998079,, +,,1.319700,42.007987,, +,,1.321360,41.995347,, +,,1.323020,42.003813,, +,,1.324680,41.992456,, +,,1.326340,41.999300,, +,,1.328000,42.005843,, +,,1.329660,42.003001,, +,,1.331320,41.998635,, +,,1.332980,41.998766,, +,,1.334640,41.997833,, +,,1.336300,41.997112,, +,,1.337960,41.994053,, +,,1.339620,41.992108,, +,,1.341280,42.001869,, +,,1.342940,42.003400,, +,,1.344600,42.000906,, +,,1.346260,42.001994,, +,,1.347920,42.006418,, +,,1.349580,41.998671,, +,,1.351240,42.003385,, +,,1.352900,42.002748,, +,,1.354560,41.997425,, +,,1.356220,41.999436,, +,,1.357880,42.004100,, +,,1.359540,42.003288,, +,,1.361200,42.005330,, +,,1.362860,42.004104,, +,,1.364520,42.002493,, +,,1.366180,41.996965,, +,,1.367840,42.001946,, +,,1.369500,41.997606,, +,,1.371160,41.990042,, +,,1.372820,41.998045,, +,,1.374480,41.995970,, +,,1.376140,42.001975,, +,,1.377800,42.000991,, +,,1.379460,42.001940,, +,,1.381120,42.007775,, +,,1.382780,41.997792,, +,,1.384440,41.999998,, +,,1.386100,42.005708,, +,,1.387760,42.002212,, +,,1.389420,42.001906,, +,,1.391080,41.996835,, +,,1.392740,42.006903,, +,,1.394400,42.001358,, +,,1.396060,41.993796,, +,,1.397720,41.998374,, +,,1.399380,42.006386,, +,,1.401040,42.003965,, +,,1.402700,42.011530,, +,,1.404360,41.996664,, +,,1.406020,41.994560,, +,,1.407680,41.990254,, +,,1.409340,41.998170,, +,,1.411000,41.997806,, +,,1.412660,41.997372,, +,,1.414320,42.000502,, +,,1.415980,42.001384,, +,,1.417640,42.000676,, +,,1.419300,41.994630,, +,,1.420960,42.002723,, +,,1.422620,42.002312,, +,,1.424280,42.001293,, +,,1.425940,41.995063,, +,,1.427600,42.001535,, +,,1.429260,41.988977,, +,,1.430920,42.000663,, +,,1.432580,42.005822,, +,,1.434240,41.998704,, +,,1.435900,42.005994,, +,,1.437560,41.998748,, +,,1.439220,42.004020,, +,,1.440880,41.994989,, +,,1.442540,41.996864,, +,,1.444200,42.000102,, +,,1.445860,41.998521,, +,,1.447520,41.999987,, +,,1.449180,41.996179,, +,,1.450840,41.994567,, +,,1.452500,42.005594,, +,,1.454160,41.994486,, +,,1.455820,41.996360,, +,,1.457480,41.993810,, +,,1.459140,42.005953,, +,,1.460800,41.999733,, +,,1.462460,42.001185,, +,,1.464120,41.999604,, +,,1.465780,41.994273,, +,,1.467440,41.999592,, +,,1.469100,42.004045,, +,,1.470760,42.007411,, +,,1.472420,42.000551,, +,,1.474080,41.997912,, +,,1.475740,42.000759,, +,,1.477400,42.000019,, +,,1.479060,41.995755,, +,,1.480720,41.997811,, +,,1.482380,42.000716,, +,,1.484040,41.999480,, +,,1.485700,41.997921,, +,,1.487360,42.004484,, +,,1.489020,41.995800,, +,,1.490680,41.996067,, +,,1.492340,42.006601,, +,,1.494000,41.998401,, +,,1.495660,41.996710,, +,,1.497320,42.000994,, +,,1.498980,42.005599,, +,,1.500640,42.000269,, +,,1.502300,41.995053,, +,,1.503960,42.003359,, +,,1.505620,41.997514,, +,,1.507280,42.001826,, +,,1.508940,42.000757,, +,,1.510600,42.000680,, +,,1.512260,41.990269,, +,,1.513920,41.996144,, +,,1.515580,42.000129,, +,,1.517240,41.994654,, +,,1.518900,42.001447,, +,,1.520560,41.995734,, +,,1.522220,41.997855,, +,,1.523880,41.997363,, +,,1.525540,41.996970,, +,,1.527200,41.994375,, +,,1.528860,42.000793,, +,,1.530520,41.998293,, +,,1.532180,42.003319,, +,,1.533840,41.995537,, +,,1.535500,42.002456,, +,,1.537160,41.997863,, +,,1.538820,42.000531,, +,,1.540480,41.999815,, +,,1.542140,41.993341,, +,,1.543800,41.996962,, +,,1.545460,42.007177,, +,,1.547120,41.995895,, +,,1.548780,41.998726,, +,,1.550440,41.997645,, +,,1.552100,42.000515,, +,,1.553760,41.994528,, +,,1.555420,41.994256,, +,,1.557080,42.003071,, +,,1.558740,41.995348,, +,,1.560400,42.000238,, +,,1.562060,41.994737,, +,,1.563720,41.999144,, +,,1.565380,42.003175,, +,,1.567040,41.996505,, +,,1.568700,42.006881,, +,,1.570360,41.998248,, +,,1.572020,41.999773,, +,,1.573680,42.006595,, +,,1.575340,42.001988,, +,,1.577000,41.993702,, +,,1.578660,42.003311,, +,,1.580320,42.004753,, +,,1.581980,41.991234,, +,,1.583640,42.005684,, +,,1.585300,42.002941,, +,,1.586960,41.998486,, +,,1.588620,41.996557,, +,,1.590280,42.002368,, +,,1.591940,41.996541,, +,,1.593600,42.002756,, +,,1.595260,41.998617,, +,,1.596920,41.994541,, +,,1.598580,41.996068,, +,,1.600240,42.001921,, +,,1.601900,41.993330,, +,,1.603560,42.002902,, +,,1.605220,41.996004,, +,,1.606880,42.003093,, +,,1.608540,41.993589,, +,,1.610200,41.996961,, +,,1.611860,42.002180,, +,,1.613520,41.999778,, +,,1.615180,41.995539,, +,,1.616840,42.004971,, +,,1.618500,41.998523,, +,,1.620160,42.003070,, +,,1.621820,41.994257,, +,,1.623480,41.998529,, +,,1.625140,42.001816,, +,,1.626800,42.000713,, +,,1.628460,41.996182,, +,,1.630120,41.993274,, +,,1.631780,41.995990,, +,,1.633440,42.002159,, +,,1.635100,41.996784,, +,,1.636760,41.991677,, +,,1.638420,42.000867,, +,,1.640080,41.998476,, +,,1.641740,42.001059,, +,,1.643400,41.992146,, +,,1.645060,41.996422,, +,,1.646720,41.992940,, +,,1.648380,41.998926,, +,,1.650040,42.003586,, +,,1.651700,41.997153,, +,,1.653360,41.999773,, +,,1.655020,42.003546,, +,,1.656680,41.999609,, +,,1.658340,42.003949,, +,,1.660000,42.001228,, +,,1.661660,42.004899,, +,,1.663320,41.996774,, +,,1.664980,41.995185,, +,,1.666640,42.003574,, +,,1.668300,42.000196,, +,,1.669960,42.000726,, +,,1.671620,41.994925,, +,,1.673280,42.001075,, +,,1.674940,41.998905,, +,,1.676600,41.998245,, +,,1.678260,42.000622,, +,,1.679920,42.003641,, +,,1.681580,41.998000,, +,,1.683240,42.002741,, +,,1.684900,42.005555,, +,,1.686560,42.002625,, +,,1.688220,42.007849,, +,,1.689880,41.997773,, +,,1.691540,41.991297,, +,,1.693200,41.996680,, +,,1.694860,42.001593,, +,,1.696520,42.001923,, +,,1.698180,42.002815,, +,,1.699840,41.993596,, +,,1.701500,41.999936,, +,,1.703160,41.994053,, +,,1.704820,41.993991,, +,,1.706480,41.998401,, +,,1.708140,42.003509,, +,,1.709800,41.999601,, +,,1.711460,41.998236,, +,,1.713120,41.996708,, +,,1.714780,41.994713,, +,,1.716440,42.002848,, +,,1.718100,41.996511,, +,,1.719760,41.999554,, +,,1.721420,41.996862,, +,,1.723080,41.997492,, +,,1.724740,42.009956,, +,,1.726400,42.001284,, +,,1.728060,41.997534,, +,,1.729720,41.999473,, +,,1.731380,41.994953,, +,,1.733040,41.999000,, +,,1.734700,41.998819,, +,,1.736360,41.999311,, +,,1.738020,42.004538,, +,,1.739680,41.993525,, +,,1.741340,42.009520,, +,,1.743000,42.002985,, +,,1.744660,42.004977,, +,,1.746320,42.004911,, +,,1.747980,42.004684,, +,,1.749640,42.009063,, +,,1.751300,41.998140,, +,,1.752960,42.000572,, +,,1.754620,41.998233,, +,,1.756280,41.994519,, +,,1.757940,41.998385,, +,,1.759600,42.007522,, +,,1.761260,42.000072,, +,,1.762920,41.993486,, +,,1.764580,42.003548,, +,,1.766240,42.003919,, +,,1.767900,42.002422,, +,,1.769560,42.002423,, +,,1.771220,42.000848,, +,,1.772880,41.998263,, +,,1.774540,42.002176,, +,,1.776200,41.998634,, +,,1.777860,41.996748,, +,,1.779520,41.998764,, +,,1.781180,42.007970,, +,,1.782840,41.997566,, +,,1.784500,42.004910,, +,,1.786160,42.004892,, +,,1.787820,41.997750,, +,,1.789480,41.999467,, +,,1.791140,41.998464,, +,,1.792800,41.999458,, +,,1.794460,41.994112,, +,,1.796120,41.994617,, +,,1.797780,42.000909,, +,,1.799440,42.008134,, +,,1.801100,42.002039,, +,,1.802760,42.001468,, +,,1.804420,42.000928,, +,,1.806080,42.006029,, +,,1.807740,42.004215,, +,,1.809400,42.000065,, +,,1.811060,42.002172,, +,,1.812720,42.004019,, +,,1.814380,42.007725,, +,,1.816040,42.004177,, +,,1.817700,41.994977,, +,,1.819360,41.995981,, +,,1.821020,42.002514,, +,,1.822680,42.000977,, +,,1.824340,41.999126,, +,,1.826000,41.998156,, +,,1.827660,41.997640,, +,,1.829320,42.000184,, +,,1.830980,41.992484,, +,,1.832640,42.000960,, +,,1.834300,41.995528,, +,,1.835960,41.997829,, +,,1.837620,42.003518,, +,,1.839280,41.992023,, +,,1.840940,42.002282,, +,,1.842600,42.004893,, +,,1.844260,42.005952,, +,,1.845920,41.998742,, +,,1.847580,42.001727,, +,,1.849240,42.005720,, +,,1.850900,41.993629,, +,,1.852560,42.001062,, +,,1.854220,42.002658,, +,,1.855880,42.005807,, +,,1.857540,41.994569,, +,,1.859200,41.999830,, +,,1.860860,42.000730,, +,,1.862520,42.002850,, +,,1.864180,42.001484,, +,,1.865840,41.997348,, +,,1.867500,41.997024,, +,,1.869160,42.000414,, +,,1.870820,41.998595,, +,,1.872480,42.004560,, +,,1.874140,41.998705,, +,,1.875800,42.002264,, +,,1.877460,41.999815,, +,,1.879120,41.992917,, +,,1.880780,41.996114,, +,,1.882440,41.996235,, +,,1.884100,41.999777,, +,,1.885760,41.996521,, +,,1.887420,41.998708,, +,,1.889080,41.999683,, +,,1.890740,42.001285,, +,,1.892400,41.998169,, +,,1.894060,41.998538,, +,,1.895720,41.999535,, +,,1.897380,41.995674,, +,,1.899040,42.005305,, +,,1.900700,41.998363,, +,,1.902360,41.995907,, +,,1.904020,42.002364,, +,,1.905680,42.001840,, +,,1.907340,41.991085,, +,,1.909000,41.991286,, +,,1.910660,42.000809,, +,,1.912320,42.005145,, +,,1.913980,41.998361,, +,,1.915640,42.000561,, +,,1.917300,41.997917,, +,,1.918960,42.002310,, +,,1.920620,42.001854,, +,,1.922280,41.998951,, +,,1.923940,41.999189,, +,,1.925600,41.999061,, +,,1.927260,42.003824,, +,,1.928920,42.002359,, +,,1.930580,41.995696,, +,,1.932240,41.998770,, +,,1.933900,42.006464,, +,,1.935560,41.997129,, +,,1.937220,42.000008,, +,,1.938880,41.997686,, +,,1.940540,41.998343,, +,,1.942200,42.002094,, +,,1.943860,42.004496,, +,,1.945520,41.993328,, +,,1.947180,41.999563,, +,,1.948840,41.997382,, +,,1.950500,42.007068,, +,,1.952160,41.996158,, +,,1.953820,41.997102,, +,,1.955480,42.002286,, +,,1.957140,41.998867,, +,,1.958800,42.000323,, +,,1.960460,42.000896,, +,,1.962120,41.995520,, +,,1.963780,41.996752,, +,,1.965440,41.998085,, +,,1.967100,41.996846,, +,,1.968760,41.997134,, +,,1.970420,41.994721,, +,,1.972080,42.000209,, +,,1.973740,41.997333,, +,,1.975400,41.997898,, +,,1.977060,42.000543,, +,,1.978720,41.994526,, +,,1.980380,41.996275,, +,,1.982040,42.001680,, +,,1.983700,42.003286,, +,,1.985360,42.003092,, +,,1.987020,42.003668,, +,,1.988680,41.994064,, +,,1.990340,42.000005,, +,,1.992000,42.000686,, +,,1.993660,41.997717,, +,,1.995320,41.999172,, +,,1.996980,42.005195,, +,,1.998640,41.996054,, +,,2.000300,41.997781,, +,,2.001960,42.005631,, +,,2.003620,42.001162,, +,,2.005280,41.997258,, +,,2.006940,41.998065,, +,,2.008600,42.000034,, +,,2.010260,41.999442,, +,,2.011920,42.000761,, +,,2.013580,41.998613,, +,,2.015240,42.005383,, +,,2.016900,42.004158,, +,,2.018560,42.004837,, +,,2.020220,41.993029,, +,,2.021880,42.005409,, +,,2.023540,41.997921,, +,,2.025200,41.998557,, +,,2.026860,41.995548,, +,,2.028520,41.997341,, +,,2.030180,41.996634,, +,,2.031840,41.998602,, +,,2.033500,41.994981,, +,,2.035160,42.008405,, +,,2.036820,42.000748,, +,,2.038480,42.004659,, +,,2.040140,42.009002,, +,,2.041800,42.001692,, +,,2.043460,41.990847,, +,,2.045120,42.000288,, +,,2.046780,42.001144,, +,,2.048440,42.001491,, +,,2.050100,42.010238,, +,,2.051760,42.010715,, +,,2.053420,41.996619,, +,,2.055080,41.996451,, +,,2.056740,42.006970,, +,,2.058400,42.000277,, +,,2.060060,41.997938,, +,,2.061720,41.997569,, +,,2.063380,42.003791,, +,,2.065040,42.001540,, +,,2.066700,42.003598,, +,,2.068360,41.996987,, +,,2.070020,42.004144,, +,,2.071680,41.997078,, +,,2.073340,41.997895,, +,,2.075000,42.001176,, +,,2.076660,42.003587,, +,,2.078320,41.997166,, +,,2.079980,41.997727,, +,,2.081640,41.996461,, +,,2.083300,41.998892,, +,,2.084960,42.001075,, +,,2.086620,42.001951,, +,,2.088280,42.002231,, +,,2.089940,41.999648,, +,,2.091600,42.001030,, +,,2.093260,41.997297,, +,,2.094920,42.001394,, +,,2.096580,42.000665,, +,,2.098240,42.004226,, +,,2.099900,41.997319,, +,,2.101560,41.994179,, +,,2.103220,42.001796,, +,,2.104880,41.999056,, +,,2.106540,41.996364,, +,,2.108200,41.999343,, +,,2.109860,41.995280,, +,,2.111520,42.004062,, +,,2.113180,41.994075,, +,,2.114840,42.000798,, +,,2.116500,42.001836,, +,,2.118160,41.993101,, +,,2.119820,42.002678,, +,,2.121480,41.996452,, +,,2.123140,42.000495,, +,,2.124800,42.005419,, +,,2.126460,41.997436,, +,,2.128120,42.003605,, +,,2.129780,41.998585,, +,,2.131440,42.005888,, +,,2.133100,42.005141,, +,,2.134760,42.008174,, +,,2.136420,42.004252,, +,,2.138080,41.999425,, +,,2.139740,41.995825,, +,,2.141400,41.998129,, +,,2.143060,41.993288,, +,,2.144720,42.001287,, +,,2.146380,41.999550,, +,,2.148040,42.000660,, +,,2.149700,42.002558,, +,,2.151360,42.001867,, +,,2.153020,42.003677,, +,,2.154680,42.003279,, +,,2.156340,41.997259,, +,,2.158000,42.003545,, +,,2.159660,41.999348,, +,,2.161320,41.997529,, +,,2.162980,41.991498,, +,,2.164640,41.998949,, +,,2.166300,41.996859,, +,,2.167960,41.997179,, +,,2.169620,41.998192,, +,,2.171280,42.002285,, +,,2.172940,41.997899,, +,,2.174600,42.002574,, +,,2.176260,41.997711,, +,,2.177920,42.000212,, +,,2.179580,41.997435,, +,,2.181240,42.001006,, +,,2.182900,41.992167,, +,,2.184560,41.998985,, +,,2.186220,41.997268,, +,,2.187880,41.996979,, +,,2.189540,42.001890,, +,,2.191200,42.004028,, +,,2.192860,41.995122,, +,,2.194520,41.999876,, +,,2.196180,42.006548,, +,,2.197840,42.005498,, +,,2.199500,41.999108,, +,,2.201160,41.996571,, +,,2.202820,42.001454,, +,,2.204480,41.999717,, +,,2.206140,42.001823,, +,,2.207800,41.998657,, +,,2.209460,41.993621,, +,,2.211120,41.996577,, +,,2.212780,41.996719,, +,,2.214440,41.994001,, +,,2.216100,42.000594,, +,,2.217760,41.995260,, +,,2.219420,42.003485,, +,,2.221080,41.997636,, +,,2.222740,41.994724,, +,,2.224400,42.007836,, +,,2.226060,42.000706,, +,,2.227720,42.004857,, +,,2.229380,41.989221,, +,,2.231040,42.001186,, +,,2.232700,42.006372,, +,,2.234360,42.000555,, +,,2.236020,42.000882,, +,,2.237680,41.998572,, +,,2.239340,41.996790,, +,,2.241000,41.994897,, +,,2.242660,41.999760,, +,,2.244320,41.997877,, +,,2.245980,42.000872,, +,,2.247640,41.997119,, +,,2.249300,42.000745,, +,,2.250960,41.999382,, +,,2.252620,41.993548,, +,,2.254280,41.991815,, +,,2.255940,41.999783,, +,,2.257600,42.006835,, +,,2.259260,42.000031,, +,,2.260920,41.997261,, +,,2.262580,42.003729,, +,,2.264240,41.992412,, +,,2.265900,41.998466,, +,,2.267560,42.001349,, +,,2.269220,42.003066,, +,,2.270880,42.006544,, +,,2.272540,41.999050,, +,,2.274200,42.000316,, +,,2.275860,41.991086,, +,,2.277520,42.004101,, +,,2.279180,41.996344,, +,,2.280840,42.000695,, +,,2.282500,41.996406,, +,,2.284160,41.999511,, +,,2.285820,42.001253,, +,,2.287480,41.996628,, +,,2.289140,41.998030,, +,,2.290800,41.992015,, +,,2.292460,41.996789,, +,,2.294120,41.998813,, +,,2.295780,42.000436,, +,,2.297440,41.991430,, +,,2.299100,42.008201,, +,,2.300760,42.004160,, +,,2.302420,42.008827,, +,,2.304080,41.993623,, +,,2.305740,42.004968,, +,,2.307400,42.003986,, +,,2.309060,41.998227,, +,,2.310720,41.998558,, +,,2.312380,41.997853,, +,,2.314040,41.993661,, +,,2.315700,42.005690,, +,,2.317360,41.997308,, +,,2.319020,41.996112,, +,,2.320680,41.999091,, +,,2.322340,42.000513,, +,,2.324000,41.995905,, +,,2.325660,42.001085,, +,,2.327320,42.004159,, +,,2.328980,41.998603,, +,,2.330640,41.995862,, +,,2.332300,42.003047,, +,,2.333960,41.999582,, +,,2.335620,42.002309,, +,,2.337280,41.994393,, +,,2.338940,42.005961,, +,,2.340600,41.997320,, +,,2.342260,42.005504,, +,,2.343920,41.999260,, +,,2.345580,41.998527,, +,,2.347240,41.995338,, +,,2.348900,42.002033,, +,,2.350560,41.997221,, +,,2.352220,42.005550,, +,,2.353880,42.001963,, +,,2.355540,42.000483,, +,,2.357200,41.997433,, +,,2.358860,41.998898,, +,,2.360520,41.998731,, +,,2.362180,42.002491,, +,,2.363840,41.999069,, +,,2.365500,41.998276,, +,,2.367160,42.003039,, +,,2.368820,41.998579,, +,,2.370480,42.003985,, +,,2.372140,42.000096,, +,,2.373800,42.000016,, +,,2.375460,41.996776,, +,,2.377120,41.999350,, +,,2.378780,42.003443,, +,,2.380440,42.007709,, +,,2.382100,41.999333,, +,,2.383760,42.001785,, +,,2.385420,41.998620,, +,,2.387080,42.004229,, +,,2.388740,42.000717,, +,,2.390400,41.999933,, +,,2.392060,41.997387,, +,,2.393720,42.000312,, +,,2.395380,42.000202,, +,,2.397040,42.002216,, +,,2.398700,41.999838,, +,,2.400360,41.995475,, +,,2.402020,42.002700,, +,,2.403680,42.004180,, +,,2.405340,41.996792,, +,,2.407000,42.001611,, +,,2.408660,41.997160,, +,,2.410320,42.004182,, +,,2.411980,42.002833,, +,,2.413640,42.001973,, +,,2.415300,42.002173,, +,,2.416960,41.991509,, +,,2.418620,42.005373,, +,,2.420280,42.008621,, +,,2.421940,42.005023,, +,,2.423600,41.997926,, +,,2.425260,42.001017,, +,,2.426920,41.993467,, +,,2.428580,42.003979,, +,,2.430240,42.004592,, +,,2.431900,42.004429,, +,,2.433560,41.987874,, +,,2.435220,42.003280,, +,,2.436880,41.996648,, +,,2.438540,42.003494,, +,,2.440200,41.996460,, +,,2.441860,42.001574,, +,,2.443520,42.001213,, +,,2.445180,41.997578,, +,,2.446840,41.999382,, +,,2.448500,41.995859,, +,,2.450160,42.000633,, +,,2.451820,42.002696,, +,,2.453480,42.005567,, +,,2.455140,42.000300,, +,,2.456800,42.000231,, +,,2.458460,41.998640,, +,,2.460120,41.999228,, +,,2.461780,41.999675,, +,,2.463440,42.006268,, +,,2.465100,41.991372,, +,,2.466760,42.005112,, +,,2.468420,41.998582,, +,,2.470080,41.995636,, +,,2.471740,42.005906,, +,,2.473400,41.997522,, +,,2.475060,41.999075,, +,,2.476720,42.010503,, +,,2.478380,41.997045,, +,,2.480040,42.005514,, +,,2.481700,42.008780,, +,,2.483360,42.006826,, +,,2.485020,41.996923,, +,,2.486680,41.992768,, +,,2.488340,42.001843,, +,,2.490000,41.994745,, +,,2.491660,42.003621,, +,,2.493320,41.998922,, +,,2.494980,41.994780,, +,,2.496640,41.997156,, +,,2.498300,41.994769,, +,,2.499960,41.997550,, +,,2.501620,41.998569,, +,,2.503280,41.994757,, +,,2.504940,41.997542,, +,,2.506600,42.001392,, +,,2.508260,42.003338,, +,,2.509920,41.993046,, +,,2.511580,42.002278,, +,,2.513240,42.010380,, +,,2.514900,42.002392,, +,,2.516560,42.001536,, +,,2.518220,41.997183,, +,,2.519880,42.001716,, +,,2.521540,42.007307,, +,,2.523200,42.000274,, +,,2.524860,41.997056,, +,,2.526520,41.995738,, +,,2.528180,42.002051,, +,,2.529840,42.004143,, +,,2.531500,41.991848,, +,,2.533160,41.996970,, +,,2.534820,42.003415,, +,,2.536480,41.995446,, +,,2.538140,41.998568,, +,,2.539800,42.001747,, +,,2.541460,41.998813,, +,,2.543120,41.995490,, +,,2.544780,41.999621,, +,,2.546440,42.005708,, +,,2.548100,42.007973,, +,,2.549760,42.008834,, +,,2.551420,41.999581,, +,,2.553080,41.994008,, +,,2.554740,42.003398,, +,,2.556400,42.005443,, +,,2.558060,42.003395,, +,,2.559720,41.998250,, +,,2.561380,41.999260,, +,,2.563040,41.996070,, +,,2.564700,41.998143,, +,,2.566360,41.999805,, +,,2.568020,42.001908,, +,,2.569680,41.997467,, +,,2.571340,41.998047,, +,,2.573000,42.002349,, +,,2.574660,41.998897,, +,,2.576320,42.000583,, +,,2.577980,42.004103,, +,,2.579640,42.005984,, +,,2.581300,42.004263,, +,,2.582960,41.998251,, +,,2.584620,42.000011,, +,,2.586280,42.006859,, +,,2.587940,41.989893,, +,,2.589600,42.000580,, +,,2.591260,41.996152,, +,,2.592920,41.999071,, +,,2.594580,42.001066,, +,,2.596240,42.001884,, +,,2.597900,41.998332,, +,,2.599560,41.994220,, +,,2.601220,41.997359,, +,,2.602880,41.994693,, +,,2.604540,42.001798,, +,,2.606200,42.001259,, +,,2.607860,42.005730,, +,,2.609520,42.006944,, +,,2.611180,41.997057,, +,,2.612840,42.000669,, +,,2.614500,41.994911,, +,,2.616160,41.995278,, +,,2.617820,42.000258,, +,,2.619480,41.996742,, +,,2.621140,42.000536,, +,,2.622800,41.998149,, +,,2.624460,42.001639,, +,,2.626120,42.003728,, +,,2.627780,41.999758,, +,,2.629440,42.000992,, +,,2.631100,42.001529,, +,,2.632760,42.003635,, +,,2.634420,42.003268,, +,,2.636080,42.006550,, +,,2.637740,42.006087,, +,,2.639400,42.002482,, +,,2.641060,42.000801,, +,,2.642720,41.997408,, +,,2.644380,41.997780,, +,,2.646040,41.993614,, +,,2.647700,41.997520,, +,,2.649360,42.003832,, +,,2.651020,42.001225,, +,,2.652680,41.995468,, +,,2.654340,41.999982,, +,,2.656000,41.999545,, +,,2.657660,41.992301,, +,,2.659320,41.997704,, +,,2.660980,42.000680,, +,,2.662640,42.000797,, +,,2.664300,42.000487,, +,,2.665960,41.999007,, +,,2.667620,41.996001,, +,,2.669280,42.002071,, +,,2.670940,42.003896,, +,,2.672600,41.997276,, +,,2.674260,42.003570,, +,,2.675920,42.002195,, +,,2.677580,41.997050,, +,,2.679240,41.998251,, +,,2.680900,41.998781,, +,,2.682560,41.995617,, +,,2.684220,42.005264,, +,,2.685880,42.000359,, +,,2.687540,42.003974,, +,,2.689200,42.001026,, +,,2.690860,41.997768,, +,,2.692520,42.004858,, +,,2.694180,42.000765,, +,,2.695840,41.997080,, +,,2.697500,41.995020,, +,,2.699160,41.993781,, +,,2.700820,41.999140,, +,,2.702480,41.993775,, +,,2.704140,41.995955,, +,,2.705800,41.996503,, +,,2.707460,41.995479,, +,,2.709120,42.003530,, +,,2.710780,41.996962,, +,,2.712440,41.999182,, +,,2.714100,42.009823,, +,,2.715760,41.999470,, +,,2.717420,42.004329,, +,,2.719080,42.001643,, +,,2.720740,42.004438,, +,,2.722400,41.999801,, +,,2.724060,42.005997,, +,,2.725720,41.993266,, +,,2.727380,41.994379,, +,,2.729040,41.993043,, +,,2.730700,42.003637,, +,,2.732360,41.998723,, +,,2.734020,41.999387,, +,,2.735680,42.001639,, +,,2.737340,41.993295,, +,,2.739000,42.004327,, +,,2.740660,41.997630,, +,,2.742320,41.999012,, +,,2.743980,41.998792,, +,,2.745640,42.009446,, +,,2.747300,42.000999,, +,,2.748960,41.997070,, +,,2.750620,41.994779,, +,,2.752280,41.998507,, +,,2.753940,41.994057,, +,,2.755600,41.999153,, +,,2.757260,42.006799,, +,,2.758920,42.003960,, +,,2.760580,42.000639,, +,,2.762240,41.994732,, +,,2.763900,42.004902,, +,,2.765560,41.994315,, +,,2.767220,41.994808,, +,,2.768880,42.010645,, +,,2.770540,41.995092,, +,,2.772200,41.997939,, +,,2.773860,42.003102,, +,,2.775520,41.999317,, +,,2.777180,42.004489,, +,,2.778840,42.001280,, +,,2.780500,42.003393,, +,,2.782160,42.000849,, +,,2.783820,41.998808,, +,,2.785480,42.000857,, +,,2.787140,41.994164,, +,,2.788800,41.996747,, +,,2.790460,41.992919,, +,,2.792120,42.003224,, +,,2.793780,41.996656,, +,,2.795440,41.999005,, +,,2.797100,41.995720,, +,,2.798760,41.998386,, +,,2.800420,41.998572,, +,,2.802080,42.002687,, +,,2.803740,42.001454,, +,,2.805400,41.995766,, +,,2.807060,41.997976,, +,,2.808720,42.002453,, +,,2.810380,42.002989,, +,,2.812040,42.005543,, +,,2.813700,42.000539,, +,,2.815360,41.995893,, +,,2.817020,42.007712,, +,,2.818680,41.999585,, +,,2.820340,41.989956,, +,,2.822000,42.000904,, +,,2.823660,42.000483,, +,,2.825320,41.998025,, +,,2.826980,42.001031,, +,,2.828640,41.997304,, +,,2.830300,42.001133,, +,,2.831960,41.988179,, +,,2.833620,41.994910,, +,,2.835280,41.998369,, +,,2.836940,42.004078,, +,,2.838600,42.003293,, +,,2.840260,42.004353,, +,,2.841920,41.996750,, +,,2.843580,42.005244,, +,,2.845240,41.993976,, +,,2.846900,41.995989,, +,,2.848560,41.999467,, +,,2.850220,42.005040,, +,,2.851880,41.995106,, +,,2.853540,42.004142,, +,,2.855200,42.002930,, +,,2.856860,41.997688,, +,,2.858520,41.998327,, +,,2.860180,42.001404,, +,,2.861840,41.994199,, +,,2.863500,41.998755,, +,,2.865160,42.000631,, +,,2.866820,42.003131,, +,,2.868480,41.989455,, +,,2.870140,42.004482,, +,,2.871800,42.002208,, +,,2.873460,42.001268,, +,,2.875120,42.001685,, +,,2.876780,41.999321,, +,,2.878440,41.995236,, +,,2.880100,42.008229,, +,,2.881760,41.996098,, +,,2.883420,41.994227,, +,,2.885080,42.003399,, +,,2.886740,42.002314,, +,,2.888400,42.001731,, +,,2.890060,41.993226,, +,,2.891720,42.003567,, +,,2.893380,41.999906,, +,,2.895040,41.996957,, +,,2.896700,41.998776,, +,,2.898360,41.995565,, +,,2.900020,41.993522,, +,,2.901680,42.000392,, +,,2.903340,41.996701,, +,,2.905000,41.994816,, +,,2.906660,41.998465,, +,,2.908320,41.998416,, +,,2.909980,41.996973,, +,,2.911640,41.996423,, +,,2.913300,42.006321,, +,,2.914960,42.000156,, +,,2.916620,42.007282,, +,,2.918280,41.997479,, +,,2.919940,41.995891,, +,,2.921600,41.991508,, +,,2.923260,42.006819,, +,,2.924920,41.996512,, +,,2.926580,42.006170,, +,,2.928240,42.003702,, +,,2.929900,42.001054,, +,,2.931560,42.001113,, +,,2.933220,41.995643,, +,,2.934880,41.996983,, +,,2.936540,41.995025,, +,,2.938200,42.003108,, +,,2.939860,41.995027,, +,,2.941520,41.997141,, +,,2.943180,42.002819,, +,,2.944840,41.999574,, +,,2.946500,41.994636,, +,,2.948160,42.007041,, +,,2.949820,41.997910,, +,,2.951480,41.998457,, +,,2.953140,41.995626,, +,,2.954800,42.000113,, +,,2.956460,41.992910,, +,,2.958120,42.001212,, +,,2.959780,42.007352,, +,,2.961440,42.001882,, +,,2.963100,42.003446,, +,,2.964760,41.995200,, +,,2.966420,42.004953,, +,,2.968080,42.004673,, +,,2.969740,41.999276,, +,,2.971400,42.000129,, +,,2.973060,42.010343,, +,,2.974720,42.000499,, +,,2.976380,41.999558,, +,,2.978040,42.005153,, +,,2.979700,41.998416,, +,,2.981360,42.000142,, +,,2.983020,41.988649,, +,,2.984680,41.997265,, +,,2.986340,41.997786,, +,,2.988000,42.002994,, diff --git a/examples/data/characterization_akta/system_pulse_1_uv.csv b/examples/data/characterization_akta/system_pulse_1_uv.csv new file mode 100644 index 0000000..9674cc1 --- /dev/null +++ b/examples/data/characterization_akta/system_pulse_1_uv.csv @@ -0,0 +1,1054 @@ +Chromatogram,,,,, +UV 1_280,,Cond,,Run Log, +ml,mAU,ml,mS/cm,ml, +0.000000,3.678010,0.000000,41.997865,0.000000,Phase Equilibration +0.004150,4.308722,0.001660,41.992296,0.498000,Phase Elution +0.008300,4.172828,0.003320,42.000270,1.481550,Phase Wash +0.012450,4.286471,0.004980,41.995168,, +0.016600,4.418815,0.006640,42.004401,, +0.020750,4.066277,0.008300,42.000603,, +0.024900,3.198492,0.009960,42.003825,, +0.029050,4.644957,0.011620,41.991491,, +0.033200,3.611487,0.013280,42.002617,, +0.037350,4.845939,0.014940,41.999372,, +0.041500,4.325690,0.016600,42.005816,, +0.045650,3.865157,0.018260,42.002420,, +0.049800,4.330164,0.019920,41.994964,, +0.053950,3.607009,0.021580,42.000074,, +0.058100,4.468809,0.023240,41.999929,, +0.062250,2.833543,0.024900,41.996862,, +0.066400,4.559943,0.026560,42.002612,, +0.070550,4.268994,0.028220,41.997157,, +0.074700,4.248603,0.029880,42.004432,, +0.078850,4.337544,0.031540,41.997783,, +0.083000,2.608722,0.033200,42.004375,, +0.087150,4.620950,0.034860,41.999960,, +0.091300,4.251676,0.036520,41.996266,, +0.095450,4.478226,0.038180,42.001657,, +0.099600,3.656540,0.039840,42.004520,, +0.103750,3.626102,0.041500,42.000657,, +0.107900,3.493011,0.043160,41.995632,, +0.112050,4.300692,0.044820,42.002617,, +0.116200,2.620905,0.046480,41.992773,, +0.120350,4.625621,0.048140,42.003357,, +0.124500,4.392315,0.049800,42.002452,, +0.128650,3.616000,0.051460,42.002073,, +0.132800,4.407665,0.053120,41.995026,, +0.136950,3.901125,0.054780,41.998898,, +0.141100,4.092696,0.056440,41.999021,, +0.145250,4.221321,0.058100,41.993025,, +0.149400,3.767128,0.059760,42.003229,, +0.153550,3.732119,0.061420,41.995900,, +0.157700,2.852538,0.063080,41.999315,, +0.161850,4.290864,0.064740,41.997555,, +0.166000,3.630428,0.066400,42.003663,, +0.170150,3.311932,0.068060,41.997165,, +0.174300,4.441821,0.069720,41.999281,, +0.178450,4.908248,0.071380,42.006834,, +0.182600,4.062132,0.073040,41.996739,, +0.186750,3.516056,0.074700,41.994151,, +0.190900,3.501474,0.076360,42.006119,, +0.195050,4.646108,0.078020,41.995338,, +0.199200,3.365143,0.079680,42.001835,, +0.203350,3.955445,0.081340,41.997453,, +0.207500,4.081773,0.083000,42.001442,, +0.211650,3.674044,0.084660,41.997887,, +0.215800,3.788211,0.086320,41.999012,, +0.219950,3.543573,0.087980,41.995827,, +0.224100,4.553491,0.089640,42.002532,, +0.228250,3.415521,0.091300,42.005283,, +0.232400,3.457262,0.092960,42.005049,, +0.236550,4.238683,0.094620,41.997574,, +0.240700,4.507250,0.096280,42.000686,, +0.244850,4.237192,0.097940,42.001199,, +0.249000,4.275667,0.099600,42.011896,, +0.253150,3.518248,0.101260,41.999681,, +0.257300,5.028684,0.102920,42.003028,, +0.261450,3.475133,0.104580,41.996581,, +0.265600,4.493640,0.106240,42.002336,, +0.269750,3.704705,0.107900,42.001772,, +0.273900,2.755594,0.109560,42.002597,, +0.278050,4.327331,0.111220,41.995293,, +0.282200,4.137491,0.112880,42.002095,, +0.286350,3.839092,0.114540,41.999228,, +0.290500,3.226973,0.116200,42.010644,, +0.294650,3.251845,0.117860,42.003272,, +0.298800,4.188461,0.119520,41.997370,, +0.302950,3.437447,0.121180,41.992079,, +0.307100,3.949378,0.122840,41.997141,, +0.311250,4.813776,0.124500,41.993568,, +0.315400,4.038826,0.126160,42.003215,, +0.319550,3.002773,0.127820,41.999767,, +0.323700,3.749719,0.129480,41.999160,, +0.327850,3.391858,0.131140,41.999624,, +0.332000,3.788774,0.132800,41.998296,, +0.336150,3.472302,0.134460,41.994283,, +0.340300,4.306224,0.136120,41.997430,, +0.344450,4.678159,0.137780,42.000745,, +0.348600,4.300772,0.139440,42.003687,, +0.352750,3.784153,0.141100,42.007226,, +0.356900,4.883434,0.142760,41.999381,, +0.361050,4.834716,0.144420,42.006321,, +0.365200,3.679711,0.146080,42.001095,, +0.369350,4.375474,0.147740,42.003420,, +0.373500,3.554415,0.149400,42.002226,, +0.377650,4.129283,0.151060,42.008156,, +0.381800,5.047060,0.152720,42.005736,, +0.385950,3.917044,0.154380,41.995836,, +0.390100,4.081036,0.156040,42.000261,, +0.394250,4.175404,0.157700,42.000820,, +0.398400,4.063468,0.159360,41.998740,, +0.402550,4.794333,0.161020,41.997062,, +0.406700,3.204198,0.162680,41.998804,, +0.410850,3.970563,0.164340,41.999083,, +0.415000,4.503776,0.166000,41.994284,, +0.419150,4.169053,0.167660,42.001660,, +0.423300,4.280524,0.169320,41.997272,, +0.427450,4.111807,0.170980,42.001524,, +0.431600,4.134470,0.172640,41.997747,, +0.435750,4.990845,0.174300,41.999326,, +0.439900,4.150458,0.175960,41.998398,, +0.444050,3.712970,0.177620,41.995999,, +0.448200,4.015342,0.179280,42.005459,, +0.452350,4.058937,0.180940,41.998125,, +0.456500,3.232705,0.182600,41.994225,, +0.460650,4.636557,0.184260,42.006473,, +0.464800,4.530091,0.185920,41.996195,, +0.468950,3.537136,0.187580,41.998775,, +0.473100,4.411254,0.189240,42.000535,, +0.477250,3.769411,0.190900,42.003662,, +0.481400,4.035894,0.192560,41.997881,, +0.485550,4.603759,0.194220,42.005420,, +0.489700,3.896199,0.195880,42.003302,, +0.493850,3.985450,0.197540,42.006295,, +0.498000,4.730609,0.199200,41.997105,, +0.502150,3.692235,0.200860,41.998917,, +0.506300,4.552295,0.202520,41.996695,, +0.510450,3.294563,0.204180,41.994413,, +0.514600,4.070335,0.205840,41.997195,, +0.518750,4.413903,0.207500,41.999858,, +0.522900,3.511205,0.209160,41.993182,, +0.527050,3.080127,0.210820,42.000902,, +0.531200,3.527897,0.212480,42.006195,, +0.535350,3.997977,0.214140,42.002768,, +0.539500,4.057489,0.215800,41.998575,, +0.543650,4.705218,0.217460,42.001149,, +0.547800,4.833008,0.219120,42.001448,, +0.551950,3.978716,0.220780,42.004314,, +0.556100,3.418639,0.222440,42.002715,, +0.560250,3.846775,0.224100,41.994330,, +0.564400,3.688865,0.225760,41.997615,, +0.568550,3.786005,0.227420,42.007679,, +0.572700,4.185767,0.229080,41.998177,, +0.576850,3.552480,0.230740,41.996651,, +0.581000,3.137181,0.232400,42.002191,, +0.585150,4.921782,0.234060,41.998074,, +0.589300,4.716664,0.235720,41.997134,, +0.593450,4.952714,0.237380,41.999731,, +0.597600,3.666628,0.239040,41.999655,, +0.601750,4.372065,0.240700,42.007400,, +0.605900,4.224990,0.242360,42.004280,, +0.610050,4.347661,0.244020,41.994981,, +0.614200,3.618354,0.245680,42.005778,, +0.618350,4.362996,0.247340,42.001656,, +0.622500,4.250374,0.249000,41.994794,, +0.626650,3.106038,0.250660,41.992440,, +0.630800,3.850960,0.252320,41.994454,, +0.634950,3.112867,0.253980,42.004048,, +0.639100,4.649986,0.255640,42.000865,, +0.643250,3.597548,0.257300,42.003819,, +0.647400,3.569534,0.258960,42.004737,, +0.651550,3.425290,0.260620,41.995484,, +0.655700,3.667179,0.262280,42.003442,, +0.659850,4.968157,0.263940,41.994324,, +0.664000,4.304753,0.265600,42.000429,, +0.668150,4.088866,0.267260,42.001706,, +0.672300,4.626939,0.268920,41.997972,, +0.676450,4.364141,0.270580,41.995958,, +0.680600,3.586603,0.272240,42.002664,, +0.684750,3.421241,0.273900,42.001437,, +0.688900,4.021559,0.275560,42.003829,, +0.693050,5.873345,0.277220,42.006318,, +0.697200,6.823222,0.278880,41.997949,, +0.701350,39.224811,0.280540,42.010221,, +0.705500,70.645032,0.282200,42.008870,, +0.709650,163.177262,0.283860,42.003978,, +0.713800,254.402828,0.285520,42.004932,, +0.717950,268.407505,0.287180,41.997680,, +0.722100,281.329329,0.288840,41.995156,, +0.726250,198.478848,0.290500,42.005321,, +0.730400,114.899996,0.292160,42.001054,, +0.734550,65.844609,0.293820,41.993141,, +0.738700,15.224172,0.295480,41.997130,, +0.742850,11.480414,0.297140,42.001797,, +0.747000,4.639102,0.298800,42.005334,, +0.751150,3.578472,0.300460,41.993443,, +0.755300,4.162308,0.302120,41.998147,, +0.759450,4.420386,0.303780,42.000393,, +0.763600,4.263713,0.305440,41.999226,, +0.767750,3.807521,0.307100,41.996313,, +0.771900,3.549398,0.308760,42.000225,, +0.776050,3.501014,0.310420,42.007402,, +0.780200,4.083650,0.312080,41.998108,, +0.784350,3.739144,0.313740,42.000599,, +0.788500,4.312811,0.315400,41.995069,, +0.792650,4.415069,0.317060,42.003757,, +0.796800,4.226262,0.318720,42.002117,, +0.800950,4.126324,0.320380,42.006461,, +0.805100,3.454868,0.322040,42.000707,, +0.809250,4.172329,0.323700,41.998749,, +0.813400,4.179171,0.325360,41.994280,, +0.817550,4.918696,0.327020,42.002068,, +0.821700,4.109121,0.328680,42.003990,, +0.825850,4.919807,0.330340,41.998288,, +0.830000,3.793785,0.332000,41.997005,, +0.834150,3.680756,0.333660,41.993502,, +0.838300,3.365237,0.335320,41.995818,, +0.842450,3.154886,0.336980,42.004228,, +0.846600,3.776087,0.338640,42.007441,, +0.850750,4.612596,0.340300,42.000318,, +0.854900,4.472881,0.341960,42.000861,, +0.859050,3.566733,0.343620,41.996063,, +0.863200,3.805049,0.345280,42.003099,, +0.867350,4.054844,0.346940,41.998153,, +0.871500,4.253438,0.348600,42.002780,, +0.875650,3.598610,0.350260,41.997451,, +0.879800,3.877376,0.351920,41.996811,, +0.883950,4.090528,0.353580,41.994151,, +0.888100,3.874940,0.355240,42.005211,, +0.892250,4.347043,0.356900,42.001298,, +0.896400,3.737076,0.358560,41.996511,, +0.900550,4.525736,0.360220,42.002359,, +0.904700,3.358775,0.361880,41.991429,, +0.908850,3.240785,0.363540,41.993559,, +0.913000,3.666966,0.365200,41.999424,, +0.917150,4.666669,0.366860,42.005965,, +0.921300,3.471203,0.368520,41.997746,, +0.925450,4.549201,0.370180,41.997533,, +0.929600,4.308605,0.371840,41.996056,, +0.933750,3.795977,0.373500,41.999692,, +0.937900,4.359980,0.375160,42.001586,, +0.942050,4.596677,0.376820,42.002573,, +0.946200,4.077766,0.378480,41.998667,, +0.950350,3.404841,0.380140,42.004806,, +0.954500,4.394033,0.381800,42.004489,, +0.958650,4.247129,0.383460,41.997162,, +0.962800,5.046109,0.385120,41.997788,, +0.966950,3.824515,0.386780,42.005005,, +0.971100,3.128201,0.388440,41.994599,, +0.975250,3.898263,0.390100,41.994177,, +0.979400,4.040317,0.391760,41.998507,, +0.983550,4.128871,0.393420,41.991956,, +0.987700,3.580610,0.395080,42.008120,, +0.991850,4.052497,0.396740,42.002096,, +0.996000,3.300961,0.398400,41.994563,, +1.000150,3.641193,0.400060,41.996172,, +1.004300,3.974847,0.401720,41.994391,, +1.008450,3.556627,0.403380,42.000257,, +1.012600,3.499582,0.405040,41.994437,, +1.016750,3.695722,0.406700,42.000180,, +1.020900,4.093467,0.408360,41.991186,, +1.025050,3.711786,0.410020,42.000046,, +1.029200,4.281945,0.411680,41.993175,, +1.033350,3.608035,0.413340,41.990494,, +1.037500,4.166016,0.415000,41.998179,, +1.041650,4.176846,0.416660,41.998328,, +1.045800,3.801008,0.418320,41.995222,, +1.049950,5.323884,0.419980,42.000298,, +1.054100,4.091480,0.421640,42.003582,, +1.058250,4.484321,0.423300,41.996024,, +1.062400,4.280527,0.424960,42.004961,, +1.066550,4.572947,0.426620,41.992744,, +1.070700,3.973633,0.428280,42.000433,, +1.074850,3.204064,0.429940,42.003977,, +1.079000,4.212234,0.431600,41.998414,, +1.083150,4.367189,0.433260,41.998391,, +1.087300,3.966566,0.434920,42.002525,, +1.091450,4.055890,0.436580,42.003849,, +1.095600,4.609540,0.438240,42.005670,, +1.099750,3.759727,0.439900,41.996097,, +1.103900,3.507297,0.441560,41.998399,, +1.108050,4.160903,0.443220,41.997643,, +1.112200,3.531537,0.444880,41.991060,, +1.116350,4.352426,0.446540,42.002702,, +1.120500,4.148948,0.448200,42.000417,, +1.124650,4.253166,0.449860,41.997572,, +1.128800,4.210283,0.451520,42.001756,, +1.132950,4.377606,0.453180,41.995007,, +1.137100,4.601572,0.454840,41.995297,, +1.141250,2.979843,0.456500,42.000875,, +1.145400,3.343271,0.458160,42.000652,, +1.149550,3.998401,0.459820,41.999317,, +1.153700,4.804471,0.461480,42.004383,, +1.157850,3.691321,0.463140,41.996628,, +1.162000,4.400375,0.464800,41.998959,, +1.166150,3.446207,0.466460,41.998364,, +1.170300,4.415919,0.468120,41.996024,, +1.174450,3.799170,0.469780,42.008535,, +1.178600,4.038368,0.471440,42.001374,, +1.182750,4.019868,0.473100,42.002880,, +1.186900,3.757566,0.474760,41.991611,, +1.191050,4.198509,0.476420,42.007378,, +1.195200,3.671977,0.478080,42.006457,, +1.199350,3.712595,0.479740,41.998255,, +1.203500,4.249829,0.481400,41.999780,, +1.207650,4.442146,0.483060,41.994930,, +1.211800,3.294936,0.484720,42.003788,, +1.215950,3.962383,0.486380,41.999289,, +1.220100,2.914053,0.488040,41.998945,, +1.224250,3.578397,0.489700,42.004634,, +1.228400,4.588817,0.491360,41.993146,, +1.232550,4.893505,0.493020,42.001426,, +1.236700,4.256353,0.494680,42.000507,, +1.240850,3.228312,0.496340,42.002972,, +1.245000,3.461314,0.498000,41.995777,, +1.249150,4.227715,0.499660,41.998888,, +1.253300,3.558405,0.501320,42.001872,, +1.257450,3.931016,0.502980,41.994355,, +1.261600,4.273790,0.504640,41.995644,, +1.265750,3.818635,0.506300,41.996038,, +1.269900,4.080441,0.507960,42.000660,, +1.274050,4.063918,0.509620,41.994968,, +1.278200,4.137164,0.511280,41.999029,, +1.282350,4.414612,0.512940,41.992468,, +1.286500,3.871070,0.514600,42.003422,, +1.290650,4.308769,0.516260,42.002177,, +1.294800,4.118085,0.517920,41.993896,, +1.298950,3.962773,0.519580,41.998024,, +1.303100,3.554953,0.521240,41.997997,, +1.307250,4.682809,0.522900,42.004770,, +1.311400,4.213854,0.524560,41.997656,, +1.315550,3.995709,0.526220,42.005865,, +1.319700,3.925369,0.527880,41.999798,, +1.323850,4.698933,0.529540,41.995230,, +1.328000,3.623182,0.531200,42.004113,, +1.332150,3.884744,0.532860,41.997106,, +1.336300,4.173546,0.534520,41.999003,, +1.340450,2.829362,0.536180,41.998241,, +1.344600,4.083722,0.537840,42.001078,, +1.348750,3.835321,0.539500,42.005059,, +1.352900,3.560458,0.541160,41.999186,, +1.357050,4.011485,0.542820,41.995158,, +1.361200,3.921260,0.544480,41.999528,, +1.365350,3.527084,0.546140,41.998977,, +1.369500,3.211734,0.547800,42.006299,, +1.373650,4.038246,0.549460,41.994048,, +1.377800,4.134806,0.551120,41.999217,, +1.381950,4.683693,0.552780,41.998316,, +1.386100,2.457516,0.554440,42.000388,, +1.390250,3.891435,0.556100,42.002715,, +1.394400,3.993092,0.557760,41.997514,, +1.398550,3.775553,0.559420,41.997361,, +1.402700,4.299608,0.561080,42.001266,, +1.406850,4.219240,0.562740,41.995786,, +1.411000,3.404686,0.564400,42.005213,, +1.415150,4.656411,0.566060,42.003774,, +1.419300,3.635789,0.567720,42.008470,, +1.423450,3.370699,0.569380,42.000457,, +1.427600,3.535046,0.571040,42.002627,, +1.431750,3.916585,0.572700,41.997587,, +1.435900,4.000173,0.574360,42.001339,, +1.440050,3.915993,0.576020,41.999212,, +1.444200,4.091216,0.577680,41.998520,, +1.448350,3.849455,0.579340,42.001502,, +1.452500,3.033432,0.581000,41.997394,, +1.456650,4.778202,0.582660,41.999197,, +1.460800,3.795550,0.584320,41.997731,, +1.464950,3.522725,0.585980,41.995510,, +1.469100,3.299073,0.587640,42.004312,, +1.473250,3.993596,0.589300,41.989926,, +1.477400,3.612261,0.590960,41.998228,, +1.481550,3.680469,0.592620,41.999103,, +1.485700,3.751480,0.594280,41.997106,, +1.489850,3.748256,0.595940,41.997545,, +1.494000,5.028894,0.597600,41.997586,, +1.498150,3.925437,0.599260,41.995934,, +1.502300,4.811595,0.600920,41.997687,, +1.506450,4.376362,0.602580,42.003497,, +1.510600,3.688425,0.604240,41.998897,, +1.514750,3.871573,0.605900,41.995134,, +1.518900,4.031707,0.607560,42.008296,, +1.523050,4.321172,0.609220,42.000873,, +1.527200,4.477617,0.610880,41.996999,, +1.531350,4.210035,0.612540,41.993959,, +1.535500,4.583563,0.614200,42.000564,, +1.539650,3.962438,0.615860,41.997385,, +1.543800,4.004794,0.617520,42.003391,, +1.547950,4.204867,0.619180,42.001058,, +1.552100,3.064494,0.620840,41.998934,, +1.556250,3.485640,0.622500,42.002301,, +1.560400,3.748218,0.624160,41.992480,, +1.564550,4.094551,0.625820,41.999354,, +1.568700,4.097554,0.627480,41.995772,, +1.572850,4.410691,0.629140,41.990438,, +1.577000,3.275420,0.630800,42.004870,, +1.581150,4.146538,0.632460,41.999282,, +1.585300,3.391621,0.634120,42.000230,, +1.589450,4.010553,0.635780,42.004453,, +1.593600,4.113562,0.637440,41.999783,, +1.597750,4.074072,0.639100,41.997956,, +1.601900,4.146828,0.640760,41.998053,, +1.606050,3.620710,0.642420,41.996335,, +1.610200,4.311859,0.644080,42.001455,, +1.614350,3.627813,0.645740,42.006339,, +1.618500,3.381400,0.647400,42.002157,, +1.622650,4.132218,0.649060,42.006217,, +1.626800,4.219950,0.650720,41.995593,, +1.630950,4.607534,0.652380,41.998717,, +1.635100,5.311633,0.654040,42.000467,, +1.639250,3.278373,0.655700,42.003254,, +1.643400,4.864568,0.657360,41.999065,, +1.647550,4.694877,0.659020,41.997858,, +1.651700,4.126408,0.660680,41.997692,, +1.655850,4.546993,0.662340,42.001738,, +1.660000,4.356386,0.664000,42.005971,, +1.664150,3.506549,0.665660,42.007772,, +1.668300,4.826547,0.667320,41.992232,, +1.672450,4.863977,0.668980,41.996107,, +1.676600,4.588674,0.670640,41.997882,, +1.680750,4.383975,0.672300,42.000239,, +1.684900,4.224932,0.673960,41.994455,, +1.689050,3.628880,0.675620,41.999931,, +1.693200,4.497883,0.677280,42.000090,, +1.697350,4.762720,0.678940,41.995701,, +1.701500,3.050117,0.680600,41.999511,, +1.705650,3.994622,0.682260,42.003998,, +1.709800,3.133826,0.683920,41.999679,, +1.713950,4.382130,0.685580,41.999606,, +1.718100,3.876248,0.687240,42.000928,, +1.722250,4.226919,0.688900,41.999196,, +1.726400,3.573958,0.690560,42.002362,, +1.730550,3.852898,0.692220,41.996695,, +1.734700,3.880466,0.693880,41.995438,, +1.738850,5.240490,0.695540,41.997102,, +1.743000,4.323669,0.697200,41.990064,, +,,0.698860,41.994792,, +,,0.700520,42.001136,, +,,0.702180,41.990888,, +,,0.703840,42.001898,, +,,0.705500,42.000520,, +,,0.707160,41.996929,, +,,0.708820,42.003472,, +,,0.710480,42.000125,, +,,0.712140,41.995589,, +,,0.713800,41.989797,, +,,0.715460,41.996023,, +,,0.717120,42.007545,, +,,0.718780,41.996611,, +,,0.720440,42.005204,, +,,0.722100,41.999853,, +,,0.723760,41.998037,, +,,0.725420,41.998701,, +,,0.727080,42.003915,, +,,0.728740,41.999122,, +,,0.730400,42.004468,, +,,0.732060,42.002597,, +,,0.733720,41.998206,, +,,0.735380,42.003052,, +,,0.737040,41.999664,, +,,0.738700,42.008400,, +,,0.740360,42.001139,, +,,0.742020,41.992980,, +,,0.743680,42.000658,, +,,0.745340,42.000303,, +,,0.747000,41.998900,, +,,0.748660,42.004730,, +,,0.750320,42.001145,, +,,0.751980,42.005599,, +,,0.753640,41.998839,, +,,0.755300,41.998083,, +,,0.756960,42.000881,, +,,0.758620,41.997918,, +,,0.760280,41.997881,, +,,0.761940,41.998948,, +,,0.763600,41.997288,, +,,0.765260,42.001364,, +,,0.766920,42.000165,, +,,0.768580,41.999798,, +,,0.770240,42.001362,, +,,0.771900,41.995818,, +,,0.773560,42.002231,, +,,0.775220,41.999523,, +,,0.776880,42.006426,, +,,0.778540,41.997972,, +,,0.780200,42.002223,, +,,0.781860,41.990342,, +,,0.783520,42.001845,, +,,0.785180,41.998435,, +,,0.786840,41.998861,, +,,0.788500,42.001819,, +,,0.790160,42.003579,, +,,0.791820,42.005125,, +,,0.793480,41.996742,, +,,0.795140,42.006949,, +,,0.796800,41.998506,, +,,0.798460,42.004078,, +,,0.800120,41.998371,, +,,0.801780,42.005077,, +,,0.803440,42.002289,, +,,0.805100,42.003561,, +,,0.806760,41.998968,, +,,0.808420,42.003968,, +,,0.810080,41.998545,, +,,0.811740,41.998986,, +,,0.813400,41.998687,, +,,0.815060,41.996242,, +,,0.816720,41.994393,, +,,0.818380,42.000179,, +,,0.820040,42.002113,, +,,0.821700,42.001869,, +,,0.823360,42.003367,, +,,0.825020,41.998520,, +,,0.826680,41.998900,, +,,0.828340,42.001787,, +,,0.830000,41.999116,, +,,0.831660,42.005119,, +,,0.833320,41.984949,, +,,0.834980,41.998446,, +,,0.836640,42.005822,, +,,0.838300,41.995644,, +,,0.839960,41.992221,, +,,0.841620,42.001294,, +,,0.843280,41.998822,, +,,0.844940,41.999059,, +,,0.846600,42.004286,, +,,0.848260,42.002312,, +,,0.849920,42.000150,, +,,0.851580,42.003847,, +,,0.853240,41.999166,, +,,0.854900,42.003451,, +,,0.856560,41.992928,, +,,0.858220,42.003395,, +,,0.859880,41.992569,, +,,0.861540,41.996680,, +,,0.863200,42.003865,, +,,0.864860,41.998961,, +,,0.866520,42.000174,, +,,0.868180,42.003176,, +,,0.869840,42.000635,, +,,0.871500,42.003775,, +,,0.873160,41.998090,, +,,0.874820,42.002115,, +,,0.876480,41.996486,, +,,0.878140,41.995934,, +,,0.879800,41.996625,, +,,0.881460,42.005148,, +,,0.883120,42.002805,, +,,0.884780,41.999980,, +,,0.886440,41.999462,, +,,0.888100,41.999481,, +,,0.889760,42.005498,, +,,0.891420,41.999303,, +,,0.893080,42.007128,, +,,0.894740,42.003854,, +,,0.896400,41.998198,, +,,0.898060,41.996646,, +,,0.899720,41.997842,, +,,0.901380,42.000559,, +,,0.903040,41.998590,, +,,0.904700,41.996574,, +,,0.906360,42.001434,, +,,0.908020,41.996879,, +,,0.909680,41.998320,, +,,0.911340,41.998405,, +,,0.913000,41.998006,, +,,0.914660,42.000590,, +,,0.916320,41.999642,, +,,0.917980,42.002203,, +,,0.919640,41.997521,, +,,0.921300,41.996943,, +,,0.922960,41.991387,, +,,0.924620,41.991874,, +,,0.926280,41.996866,, +,,0.927940,42.002417,, +,,0.929600,42.003731,, +,,0.931260,42.001579,, +,,0.932920,42.003705,, +,,0.934580,41.993746,, +,,0.936240,42.004720,, +,,0.937900,42.004191,, +,,0.939560,41.994633,, +,,0.941220,41.994454,, +,,0.942880,42.005663,, +,,0.944540,42.004112,, +,,0.946200,42.003558,, +,,0.947860,41.995423,, +,,0.949520,42.001340,, +,,0.951180,41.995354,, +,,0.952840,41.996908,, +,,0.954500,42.000901,, +,,0.956160,41.996741,, +,,0.957820,42.000663,, +,,0.959480,41.997422,, +,,0.961140,41.997206,, +,,0.962800,42.004413,, +,,0.964460,42.006606,, +,,0.966120,42.005160,, +,,0.967780,42.002332,, +,,0.969440,42.006559,, +,,0.971100,41.996564,, +,,0.972760,41.999733,, +,,0.974420,41.993105,, +,,0.976080,41.993366,, +,,0.977740,42.008073,, +,,0.979400,41.995652,, +,,0.981060,41.997516,, +,,0.982720,42.001787,, +,,0.984380,41.997770,, +,,0.986040,42.000356,, +,,0.987700,42.001882,, +,,0.989360,41.999769,, +,,0.991020,42.004361,, +,,0.992680,41.991667,, +,,0.994340,42.005471,, +,,0.996000,41.999619,, +,,0.997660,42.004529,, +,,0.999320,41.989561,, +,,1.000980,42.003243,, +,,1.002640,42.006178,, +,,1.004300,42.001123,, +,,1.005960,41.999747,, +,,1.007620,41.995828,, +,,1.009280,41.998393,, +,,1.010940,41.993527,, +,,1.012600,42.001407,, +,,1.014260,42.006107,, +,,1.015920,41.997635,, +,,1.017580,42.000380,, +,,1.019240,42.000659,, +,,1.020900,42.000654,, +,,1.022560,42.002346,, +,,1.024220,41.999524,, +,,1.025880,42.004865,, +,,1.027540,42.003297,, +,,1.029200,41.995760,, +,,1.030860,42.002649,, +,,1.032520,42.008031,, +,,1.034180,42.006913,, +,,1.035840,42.004509,, +,,1.037500,41.999045,, +,,1.039160,42.000731,, +,,1.040820,41.996668,, +,,1.042480,42.006486,, +,,1.044140,42.000341,, +,,1.045800,41.995689,, +,,1.047460,42.003008,, +,,1.049120,42.003154,, +,,1.050780,42.000533,, +,,1.052440,42.001982,, +,,1.054100,41.997002,, +,,1.055760,42.003159,, +,,1.057420,41.993191,, +,,1.059080,42.002031,, +,,1.060740,41.999733,, +,,1.062400,42.004801,, +,,1.064060,42.002759,, +,,1.065720,42.003207,, +,,1.067380,42.004108,, +,,1.069040,41.998146,, +,,1.070700,41.994043,, +,,1.072360,41.992813,, +,,1.074020,42.008711,, +,,1.075680,41.998073,, +,,1.077340,42.001547,, +,,1.079000,41.992836,, +,,1.080660,41.996824,, +,,1.082320,41.995594,, +,,1.083980,42.001755,, +,,1.085640,42.006956,, +,,1.087300,41.995777,, +,,1.088960,41.996031,, +,,1.090620,41.997561,, +,,1.092280,41.996583,, +,,1.093940,41.999053,, +,,1.095600,41.999505,, +,,1.097260,42.001676,, +,,1.098920,42.002655,, +,,1.100580,41.990126,, +,,1.102240,41.997233,, +,,1.103900,41.999099,, +,,1.105560,41.996881,, +,,1.107220,42.002641,, +,,1.108880,42.002297,, +,,1.110540,41.992032,, +,,1.112200,42.001740,, +,,1.113860,42.000479,, +,,1.115520,41.999634,, +,,1.117180,42.003275,, +,,1.118840,41.993532,, +,,1.120500,42.001764,, +,,1.122160,41.996132,, +,,1.123820,41.998601,, +,,1.125480,42.001954,, +,,1.127140,41.994643,, +,,1.128800,41.999244,, +,,1.130460,42.005060,, +,,1.132120,41.994245,, +,,1.133780,41.998693,, +,,1.135440,41.991100,, +,,1.137100,41.997346,, +,,1.138760,41.997637,, +,,1.140420,42.004225,, +,,1.142080,42.000239,, +,,1.143740,42.000191,, +,,1.145400,42.004746,, +,,1.147060,42.004506,, +,,1.148720,41.999894,, +,,1.150380,42.002706,, +,,1.152040,42.005144,, +,,1.153700,41.999757,, +,,1.155360,41.999928,, +,,1.157020,41.999732,, +,,1.158680,42.001755,, +,,1.160340,42.002368,, +,,1.162000,42.007033,, +,,1.163660,42.001405,, +,,1.165320,41.999515,, +,,1.166980,41.999776,, +,,1.168640,42.006358,, +,,1.170300,41.998225,, +,,1.171960,42.001040,, +,,1.173620,41.994192,, +,,1.175280,41.999189,, +,,1.176940,41.994606,, +,,1.178600,41.999130,, +,,1.180260,42.001565,, +,,1.181920,42.005773,, +,,1.183580,41.995701,, +,,1.185240,41.997189,, +,,1.186900,42.001852,, +,,1.188560,41.997973,, +,,1.190220,42.002021,, +,,1.191880,42.002677,, +,,1.193540,42.004242,, +,,1.195200,41.994454,, +,,1.196860,42.003352,, +,,1.198520,42.001033,, +,,1.200180,41.988866,, +,,1.201840,41.998971,, +,,1.203500,41.989764,, +,,1.205160,41.998781,, +,,1.206820,41.999382,, +,,1.208480,42.003408,, +,,1.210140,42.004036,, +,,1.211800,42.001563,, +,,1.213460,41.990286,, +,,1.215120,42.000235,, +,,1.216780,41.999027,, +,,1.218440,41.998725,, +,,1.220100,41.992344,, +,,1.221760,41.995070,, +,,1.223420,41.999315,, +,,1.225080,42.000635,, +,,1.226740,42.001733,, +,,1.228400,41.999928,, +,,1.230060,41.998365,, +,,1.231720,41.999371,, +,,1.233380,42.006268,, +,,1.235040,41.995984,, +,,1.236700,42.000309,, +,,1.238360,41.991148,, +,,1.240020,41.997403,, +,,1.241680,42.002687,, +,,1.243340,41.995964,, +,,1.245000,41.998340,, +,,1.246660,42.008447,, +,,1.248320,41.997512,, +,,1.249980,42.001829,, +,,1.251640,42.004932,, +,,1.253300,41.998131,, +,,1.254960,41.999770,, +,,1.256620,41.994437,, +,,1.258280,42.002738,, +,,1.259940,42.001919,, +,,1.261600,41.998503,, +,,1.263260,42.003267,, +,,1.264920,42.002679,, +,,1.266580,42.001514,, +,,1.268240,42.006505,, +,,1.269900,42.002652,, +,,1.271560,42.005690,, +,,1.273220,42.000521,, +,,1.274880,41.998110,, +,,1.276540,42.005239,, +,,1.278200,42.001850,, +,,1.279860,41.994616,, +,,1.281520,41.998989,, +,,1.283180,41.997668,, +,,1.284840,41.999613,, +,,1.286500,42.000423,, +,,1.288160,41.999856,, +,,1.289820,42.003397,, +,,1.291480,42.002683,, +,,1.293140,41.995500,, +,,1.294800,42.003578,, +,,1.296460,42.001721,, +,,1.298120,42.005806,, +,,1.299780,41.999199,, +,,1.301440,41.992010,, +,,1.303100,41.997259,, +,,1.304760,41.992771,, +,,1.306420,41.999783,, +,,1.308080,41.997782,, +,,1.309740,42.004354,, +,,1.311400,42.002638,, +,,1.313060,42.004443,, +,,1.314720,42.002095,, +,,1.316380,42.000075,, +,,1.318040,42.001078,, +,,1.319700,42.000002,, +,,1.321360,42.007937,, +,,1.323020,41.997119,, +,,1.324680,42.001685,, +,,1.326340,42.001775,, +,,1.328000,41.996816,, +,,1.329660,41.998628,, +,,1.331320,42.000995,, +,,1.332980,42.003551,, +,,1.334640,41.999947,, +,,1.336300,42.003644,, +,,1.337960,41.995092,, +,,1.339620,41.997931,, +,,1.341280,42.003949,, +,,1.342940,42.000040,, +,,1.344600,42.000908,, +,,1.346260,42.006556,, +,,1.347920,41.995506,, +,,1.349580,41.998260,, +,,1.351240,42.003528,, +,,1.352900,41.993189,, +,,1.354560,42.004048,, +,,1.356220,42.002264,, +,,1.357880,41.997794,, +,,1.359540,41.996858,, +,,1.361200,42.000263,, +,,1.362860,42.001735,, +,,1.364520,42.000385,, +,,1.366180,41.999033,, +,,1.367840,41.999031,, +,,1.369500,42.003450,, +,,1.371160,41.998422,, +,,1.372820,41.992424,, +,,1.374480,42.001237,, +,,1.376140,42.001131,, +,,1.377800,42.004746,, +,,1.379460,42.000653,, +,,1.381120,42.000025,, +,,1.382780,42.001313,, +,,1.384440,42.007604,, +,,1.386100,41.998456,, +,,1.387760,42.004016,, +,,1.389420,42.006563,, +,,1.391080,42.001071,, +,,1.392740,41.994340,, +,,1.394400,41.997907,, +,,1.396060,41.997854,, +,,1.397720,41.990978,, +,,1.399380,42.001829,, +,,1.401040,42.003287,, +,,1.402700,42.005857,, +,,1.404360,41.999166,, +,,1.406020,41.998883,, +,,1.407680,42.002304,, +,,1.409340,42.009680,, +,,1.411000,41.996814,, +,,1.412660,42.003559,, +,,1.414320,41.999662,, +,,1.415980,42.004248,, +,,1.417640,42.005484,, +,,1.419300,41.998461,, +,,1.420960,41.996235,, +,,1.422620,41.994848,, +,,1.424280,41.998391,, +,,1.425940,41.989647,, +,,1.427600,42.003035,, +,,1.429260,41.995946,, +,,1.430920,41.994161,, +,,1.432580,42.004913,, +,,1.434240,42.000937,, +,,1.435900,42.001336,, +,,1.437560,42.007797,, +,,1.439220,41.997838,, +,,1.440880,41.995310,, +,,1.442540,42.004508,, +,,1.444200,41.994935,, +,,1.445860,41.996231,, +,,1.447520,42.004015,, +,,1.449180,42.000758,, +,,1.450840,42.003211,, +,,1.452500,41.991571,, +,,1.454160,42.001206,, +,,1.455820,42.000867,, +,,1.457480,42.004694,, +,,1.459140,41.993042,, +,,1.460800,41.996571,, +,,1.462460,41.996391,, +,,1.464120,41.998653,, +,,1.465780,41.998491,, +,,1.467440,41.995092,, +,,1.469100,41.993911,, +,,1.470760,41.987108,, +,,1.472420,42.001448,, +,,1.474080,41.993958,, +,,1.475740,41.994621,, +,,1.477400,41.996923,, +,,1.479060,41.998822,, +,,1.480720,41.996851,, +,,1.482380,42.001650,, +,,1.484040,42.002780,, +,,1.485700,41.998590,, +,,1.487360,42.003146,, +,,1.489020,42.006617,, +,,1.490680,42.007707,, +,,1.492340,41.998323,, +,,1.494000,41.996062,, +,,1.495660,42.003871,, +,,1.497320,42.001039,, +,,1.498980,41.998015,, +,,1.500640,42.001773,, +,,1.502300,42.003982,, +,,1.503960,42.000627,, +,,1.505620,41.995976,, +,,1.507280,41.989624,, +,,1.508940,42.000600,, +,,1.510600,41.993755,, +,,1.512260,41.997688,, +,,1.513920,41.999650,, +,,1.515580,42.001130,, +,,1.517240,41.995591,, +,,1.518900,42.001651,, +,,1.520560,42.003097,, +,,1.522220,41.999839,, +,,1.523880,41.994832,, +,,1.525540,42.005052,, +,,1.527200,41.996019,, +,,1.528860,42.005398,, +,,1.530520,41.997327,, +,,1.532180,41.997909,, +,,1.533840,42.003372,, +,,1.535500,41.995911,, +,,1.537160,42.007577,, +,,1.538820,42.004641,, +,,1.540480,42.004170,, +,,1.542140,41.993838,, +,,1.543800,42.006369,, +,,1.545460,42.002404,, +,,1.547120,41.996556,, +,,1.548780,41.999438,, +,,1.550440,42.000897,, +,,1.552100,42.002796,, +,,1.553760,41.998910,, +,,1.555420,41.999639,, +,,1.557080,42.005370,, +,,1.558740,42.004317,, +,,1.560400,42.005495,, +,,1.562060,42.005052,, +,,1.563720,41.999062,, +,,1.565380,42.008057,, +,,1.567040,42.011152,, +,,1.568700,41.998582,, +,,1.570360,42.001613,, +,,1.572020,41.996324,, +,,1.573680,41.997212,, +,,1.575340,41.991600,, +,,1.577000,41.995142,, +,,1.578660,41.999425,, +,,1.580320,41.995785,, +,,1.581980,41.996516,, +,,1.583640,42.005055,, +,,1.585300,41.997543,, +,,1.586960,41.998419,, +,,1.588620,41.993763,, +,,1.590280,41.997999,, +,,1.591940,41.994975,, +,,1.593600,41.997882,, +,,1.595260,42.005603,, +,,1.596920,41.999841,, +,,1.598580,42.006181,, +,,1.600240,41.996096,, +,,1.601900,42.002355,, +,,1.603560,41.999808,, +,,1.605220,41.998336,, +,,1.606880,41.997538,, +,,1.608540,42.004299,, +,,1.610200,41.997131,, +,,1.611860,41.992331,, +,,1.613520,41.998140,, +,,1.615180,42.001360,, +,,1.616840,42.001658,, +,,1.618500,41.999183,, +,,1.620160,42.002434,, +,,1.621820,41.998891,, +,,1.623480,42.004514,, +,,1.625140,41.997876,, +,,1.626800,41.998632,, +,,1.628460,41.991586,, +,,1.630120,42.009258,, +,,1.631780,42.004966,, +,,1.633440,42.001775,, +,,1.635100,42.001455,, +,,1.636760,42.000878,, +,,1.638420,42.002317,, +,,1.640080,41.995933,, +,,1.641740,42.006093,, +,,1.643400,42.000110,, +,,1.645060,42.002959,, +,,1.646720,42.002130,, +,,1.648380,42.002715,, +,,1.650040,42.000271,, +,,1.651700,42.001867,, +,,1.653360,42.004412,, +,,1.655020,42.006898,, +,,1.656680,42.005308,, +,,1.658340,42.004799,, +,,1.660000,42.001568,, +,,1.661660,41.997721,, +,,1.663320,42.001495,, +,,1.664980,41.999499,, +,,1.666640,41.999780,, +,,1.668300,42.003473,, +,,1.669960,41.998776,, +,,1.671620,42.005440,, +,,1.673280,41.991452,, +,,1.674940,41.993189,, +,,1.676600,42.003412,, +,,1.678260,41.999640,, +,,1.679920,41.998722,, +,,1.681580,41.996899,, +,,1.683240,41.999962,, +,,1.684900,41.995096,, +,,1.686560,41.998977,, +,,1.688220,42.006970,, +,,1.689880,42.001963,, +,,1.691540,41.998813,, +,,1.693200,42.003606,, +,,1.694860,42.000841,, +,,1.696520,41.993677,, +,,1.698180,41.999244,, +,,1.699840,42.000808,, +,,1.701500,42.010186,, +,,1.703160,41.998893,, +,,1.704820,41.996684,, +,,1.706480,42.005923,, +,,1.708140,41.998474,, +,,1.709800,41.996961,, +,,1.711460,41.996348,, +,,1.713120,41.994804,, +,,1.714780,42.007608,, +,,1.716440,41.997282,, +,,1.718100,41.999591,, +,,1.719760,42.001841,, +,,1.721420,41.993137,, +,,1.723080,41.997440,, +,,1.724740,42.000565,, +,,1.726400,42.001912,, +,,1.728060,41.999602,, +,,1.729720,41.998517,, +,,1.731380,42.001641,, +,,1.733040,41.999330,, +,,1.734700,41.993632,, +,,1.736360,42.004451,, +,,1.738020,42.003552,, +,,1.739680,41.997799,, +,,1.741340,42.005472,, +,,1.743000,41.996663,, diff --git a/examples/data/characterization_akta/system_pulse_2_uv.csv b/examples/data/characterization_akta/system_pulse_2_uv.csv new file mode 100644 index 0000000..36eef43 --- /dev/null +++ b/examples/data/characterization_akta/system_pulse_2_uv.csv @@ -0,0 +1,1054 @@ +Chromatogram,,,,, +UV 1_280,,Cond,,Run Log, +ml,mAU,ml,mS/cm,ml, +0.000000,3.733155,0.000000,41.990194,0.000000,Phase Equilibration +0.004150,3.037029,0.001660,42.000562,0.498000,Phase Elution +0.008300,4.033765,0.003320,41.997956,1.481550,Phase Wash +0.012450,3.396057,0.004980,42.000635,, +0.016600,4.550164,0.006640,42.001432,, +0.020750,4.075331,0.008300,41.996244,, +0.024900,4.478156,0.009960,41.996489,, +0.029050,2.936419,0.011620,41.999036,, +0.033200,4.327122,0.013280,42.000542,, +0.037350,3.921539,0.014940,42.000649,, +0.041500,4.726997,0.016600,42.003873,, +0.045650,4.302485,0.018260,42.004603,, +0.049800,3.370538,0.019920,42.001335,, +0.053950,4.009268,0.021580,41.996384,, +0.058100,3.991164,0.023240,42.003257,, +0.062250,3.607753,0.024900,41.998611,, +0.066400,4.326528,0.026560,41.996970,, +0.070550,3.644582,0.028220,41.996320,, +0.074700,4.553977,0.029880,42.002517,, +0.078850,3.722896,0.031540,42.002579,, +0.083000,4.546868,0.033200,41.997950,, +0.087150,3.995010,0.034860,41.992216,, +0.091300,3.533296,0.036520,41.995566,, +0.095450,4.207160,0.038180,42.004361,, +0.099600,4.565015,0.039840,41.999737,, +0.103750,4.082092,0.041500,41.997202,, +0.107900,3.454009,0.043160,41.999766,, +0.112050,4.327149,0.044820,41.999417,, +0.116200,3.096646,0.046480,42.000570,, +0.120350,4.419647,0.048140,41.995532,, +0.124500,4.306532,0.049800,41.994931,, +0.128650,4.259147,0.051460,42.003735,, +0.132800,3.378223,0.053120,41.999551,, +0.136950,3.862213,0.054780,41.999174,, +0.141100,3.877603,0.056440,42.001187,, +0.145250,3.128099,0.058100,42.001960,, +0.149400,4.403622,0.059760,42.002247,, +0.153550,3.487495,0.061420,42.000778,, +0.157700,3.914418,0.063080,41.995560,, +0.161850,3.694398,0.064740,41.998790,, +0.166000,4.457884,0.066400,42.002310,, +0.170150,3.645597,0.068060,41.998465,, +0.174300,3.910169,0.069720,41.993508,, +0.178450,4.854294,0.071380,41.995469,, +0.182600,3.592427,0.073040,41.995392,, +0.186750,3.268883,0.074700,41.991806,, +0.190900,4.764934,0.076360,41.994266,, +0.195050,3.417249,0.078020,41.999647,, +0.199200,4.229408,0.079680,42.001961,, +0.203350,3.681686,0.081340,42.001993,, +0.207500,4.180312,0.083000,42.004528,, +0.211650,3.735903,0.084660,41.998050,, +0.215800,3.876458,0.086320,41.991404,, +0.219950,3.478373,0.087980,42.001813,, +0.224100,4.316515,0.089640,42.000146,, +0.228250,4.660383,0.091300,42.002736,, +0.232400,4.631121,0.092960,41.997021,, +0.236550,3.696695,0.094620,41.994967,, +0.240700,4.085773,0.096280,41.997341,, +0.244850,4.149844,0.097940,42.008058,, +0.249000,5.486971,0.099600,42.003575,, +0.253150,3.960148,0.101260,42.002230,, +0.257300,4.378562,0.102920,42.001332,, +0.261450,3.572660,0.104580,42.006634,, +0.265600,4.291948,0.106240,42.000846,, +0.269750,4.221550,0.107900,42.002138,, +0.273900,4.324566,0.109560,41.999165,, +0.278050,3.411644,0.111220,41.997696,, +0.282200,4.261913,0.112880,41.997531,, +0.286350,3.903509,0.114540,41.997229,, +0.290500,5.330533,0.116200,42.000187,, +0.294650,4.409053,0.117860,41.999137,, +0.298800,3.671228,0.119520,42.001795,, +0.302950,3.009906,0.121180,42.009616,, +0.307100,3.642594,0.122840,42.003454,, +0.311250,3.195963,0.124500,42.004585,, +0.315400,4.401899,0.126160,42.000373,, +0.319550,3.970866,0.127820,42.000656,, +0.323700,3.894983,0.129480,41.994246,, +0.327850,3.953025,0.131140,42.001918,, +0.332000,3.786983,0.132800,42.000531,, +0.336150,3.285406,0.134460,42.009008,, +0.340300,3.678741,0.136120,41.997840,, +0.344450,4.093135,0.137780,42.005030,, +0.348600,4.460857,0.139440,42.005104,, +0.352750,4.903249,0.141100,41.996264,, +0.356900,3.922569,0.142760,41.998474,, +0.361050,4.790155,0.144420,42.002218,, +0.365200,4.136820,0.146080,41.996620,, +0.369350,4.427459,0.147740,41.997739,, +0.373500,4.278287,0.149400,42.002960,, +0.377650,5.019505,0.151060,42.007571,, +0.381800,4.716987,0.152720,41.996054,, +0.385950,3.479542,0.154380,41.996116,, +0.390100,4.032631,0.156040,42.000382,, +0.394250,4.102486,0.157700,41.998458,, +0.398400,3.842562,0.159360,42.003603,, +0.402550,3.632716,0.161020,41.998829,, +0.406700,3.850532,0.162680,42.001668,, +0.410850,3.885391,0.164340,42.004004,, +0.415000,3.285535,0.166000,41.998607,, +0.419150,4.207523,0.167660,42.000011,, +0.423300,3.658985,0.169320,42.001212,, +0.427450,4.190548,0.170980,42.002292,, +0.431600,3.718378,0.172640,41.998034,, +0.435750,3.915800,0.174300,42.001783,, +0.439900,3.799737,0.175960,41.996240,, +0.444050,3.499844,0.177620,42.003444,, +0.448200,4.682404,0.179280,41.997864,, +0.452350,3.765628,0.180940,42.000464,, +0.456500,3.278136,0.182600,41.998968,, +0.460650,4.809152,0.184260,42.001452,, +0.464800,3.524425,0.185920,42.007134,, +0.468950,3.846901,0.187580,41.998393,, +0.473100,4.066861,0.189240,42.001811,, +0.477250,4.457804,0.190900,42.003839,, +0.481400,3.735094,0.192560,41.995811,, +0.485550,4.677443,0.194220,41.992993,, +0.489700,4.412699,0.195880,41.990382,, +0.493850,4.786842,0.197540,42.009981,, +0.498000,3.638150,0.199200,42.002070,, +0.502150,3.864630,0.200860,42.006897,, +0.506300,3.586898,0.202520,41.993043,, +0.510450,3.301615,0.204180,41.994346,, +0.514600,3.649357,0.205840,41.998031,, +0.518750,3.982305,0.207500,41.990824,, +0.522900,3.147741,0.209160,42.005362,, +0.527050,4.112792,0.210820,42.001589,, +0.531200,4.774351,0.212480,42.002515,, +0.535350,4.346020,0.214140,41.999433,, +0.539500,3.821855,0.215800,42.002387,, +0.543650,4.143652,0.217460,42.001103,, +0.547800,4.181007,0.219120,41.996709,, +0.551950,4.539247,0.220780,41.999485,, +0.556100,4.339406,0.222440,42.002054,, +0.560250,3.291289,0.224100,41.997624,, +0.564400,3.701916,0.225760,41.995241,, +0.568550,4.959876,0.227420,42.001563,, +0.572700,3.772179,0.229080,42.003344,, +0.576850,3.581325,0.230740,41.993057,, +0.581000,4.273890,0.232400,41.994323,, +0.585150,3.759300,0.234060,42.000251,, +0.589300,3.641768,0.235720,42.007571,, +0.593450,3.966382,0.237380,41.999067,, +0.597600,3.956822,0.239040,42.001805,, +0.601750,4.925008,0.240700,41.998441,, +0.605900,4.535027,0.242360,42.002566,, +0.610050,3.372564,0.244020,42.006270,, +0.614200,4.722225,0.245680,42.000538,, +0.618350,4.207059,0.247340,42.004310,, +0.622500,3.349271,0.249000,41.998165,, +0.626650,3.055055,0.250660,41.997791,, +0.630800,3.306698,0.252320,41.996829,, +0.634950,4.506030,0.253980,41.987930,, +0.639100,4.108112,0.255640,42.003301,, +0.643250,4.477389,0.257300,41.999127,, +0.647400,4.592093,0.258960,41.994534,, +0.651550,3.435518,0.260620,42.000692,, +0.655700,4.430312,0.262280,42.004870,, +0.659850,3.290476,0.263940,41.998638,, +0.664000,4.053570,0.265600,42.002271,, +0.668150,4.213296,0.267260,41.996401,, +0.672300,3.746481,0.268920,41.996624,, +0.676450,3.495928,0.270580,42.000561,, +0.680600,4.335362,0.272240,41.992018,, +0.684750,4.235266,0.273900,42.005755,, +0.688900,4.587571,0.275560,41.993528,, +0.693050,6.588016,0.277220,42.000731,, +0.697200,7.231272,0.278880,41.998061,, +0.701350,40.616293,0.280540,42.004007,, +0.705500,72.298433,0.282200,41.998946,, +0.709650,163.205823,0.283860,41.997034,, +0.713800,254.843929,0.285520,41.995049,, +0.717950,267.399048,0.287180,41.995830,, +0.722100,280.545045,0.288840,41.998758,, +0.726250,199.165694,0.290500,41.998596,, +0.730400,115.982423,0.292160,42.000338,, +0.734550,64.977512,0.293820,42.007656,, +0.738700,15.460188,0.295480,42.000292,, +0.742850,10.485679,0.297140,42.000039,, +0.747000,5.369849,0.298800,42.002455,, +0.751150,3.547978,0.300460,41.998791,, +0.755300,3.800543,0.302120,41.996419,, +0.759450,4.065801,0.303780,42.004627,, +0.763600,3.904467,0.305440,42.003157,, +0.767750,3.539728,0.307100,41.998909,, +0.771900,4.028154,0.308760,41.993399,, +0.776050,4.925215,0.310420,41.997294,, +0.780200,3.763492,0.312080,41.997749,, +0.784350,4.074901,0.313740,41.996966,, +0.788500,3.383687,0.315400,41.994427,, +0.792650,4.469610,0.317060,41.997847,, +0.796800,4.264571,0.318720,41.998698,, +0.800950,4.807634,0.320380,42.003144,, +0.805100,4.088385,0.322040,42.003274,, +0.809250,3.843600,0.323700,41.999395,, +0.813400,3.284978,0.325360,41.999161,, +0.817550,4.258521,0.327020,41.998257,, +0.821700,4.498722,0.328680,41.996939,, +0.825850,3.786003,0.330340,42.006941,, +0.830000,3.625663,0.332000,42.000083,, +0.834150,3.187708,0.333660,41.997846,, +0.838300,3.477283,0.335320,41.997985,, +0.842450,4.528467,0.336980,41.999329,, +0.846600,4.930143,0.338640,41.996152,, +0.850750,4.039737,0.340300,41.998726,, +0.854900,4.107653,0.341960,42.000249,, +0.859050,3.507917,0.343620,42.003749,, +0.863200,4.387381,0.345280,41.999976,, +0.867350,3.769108,0.346940,42.002545,, +0.871500,4.347534,0.348600,41.995841,, +0.875650,3.681327,0.350260,42.000956,, +0.879800,3.601413,0.351920,42.003364,, +0.883950,3.268858,0.353580,41.995136,, +0.888100,4.651331,0.355240,42.006786,, +0.892250,4.162225,0.356900,42.000663,, +0.896400,3.563863,0.358560,42.001739,, +0.900550,4.294932,0.360220,41.998111,, +0.904700,2.928575,0.361880,42.005203,, +0.908850,3.194928,0.363540,41.999513,, +0.913000,3.928053,0.365200,41.999657,, +0.917150,4.745630,0.366860,42.002473,, +0.921300,3.718258,0.368520,42.000888,, +0.925450,3.691581,0.370180,41.993680,, +0.929600,3.507054,0.371840,41.995239,, +0.933750,3.961487,0.373500,42.000680,, +0.937900,4.198274,0.375160,42.001109,, +0.942050,4.321616,0.376820,42.005376,, +0.946200,3.833401,0.378480,42.003919,, +0.950350,4.600807,0.380140,41.996466,, +0.954500,4.561145,0.381800,41.995393,, +0.958650,3.645218,0.383460,42.007933,, +0.962800,3.723544,0.385120,41.998764,, +0.966950,4.625668,0.386780,42.001379,, +0.971100,3.324873,0.388440,41.996463,, +0.975250,3.272107,0.390100,41.998512,, +0.979400,3.813349,0.391760,41.998637,, +0.983550,2.994482,0.393420,41.998430,, +0.987700,5.015050,0.395080,42.000891,, +0.991850,4.261983,0.396740,41.997324,, +0.996000,3.320372,0.398400,41.992324,, +1.000150,3.521473,0.400060,41.998716,, +1.004300,3.298846,0.401720,42.000950,, +1.008450,4.032081,0.403380,42.002054,, +1.012600,3.304598,0.405040,41.999584,, +1.016750,4.022544,0.406700,42.000017,, +1.020900,2.898246,0.408360,42.002261,, +1.025050,4.005750,0.410020,42.000774,, +1.029200,3.146875,0.411680,42.000530,, +1.033350,2.811754,0.413340,41.998967,, +1.037500,3.772338,0.415000,41.996549,, +1.041650,3.791017,0.416660,41.998103,, +1.045800,3.402800,0.418320,42.005751,, +1.049950,4.037281,0.419980,42.000736,, +1.054100,4.447746,0.421640,42.000618,, +1.058250,3.503057,0.423300,42.000976,, +1.062400,4.620089,0.424960,42.001832,, +1.066550,3.093050,0.426620,42.001451,, +1.070700,4.054109,0.428280,41.999495,, +1.074850,4.497154,0.429940,42.001779,, +1.079000,3.801731,0.431600,42.002559,, +1.083150,3.798883,0.433260,42.000038,, +1.087300,4.315642,0.434920,41.998629,, +1.091450,4.481108,0.436580,41.996964,, +1.095600,4.708693,0.438240,42.002144,, +1.099750,3.512063,0.439900,41.995429,, +1.103900,3.799895,0.441560,41.994794,, +1.108050,3.705371,0.443220,42.003069,, +1.112200,2.882451,0.444880,42.008285,, +1.116350,4.337786,0.446540,41.990158,, +1.120500,4.052135,0.448200,41.991403,, +1.124650,3.696486,0.449860,41.992640,, +1.128800,4.219463,0.451520,41.995494,, +1.132950,3.375834,0.453180,42.004816,, +1.137100,3.412115,0.454840,42.004928,, +1.141250,4.109389,0.456500,42.001725,, +1.145400,4.081453,0.458160,42.002719,, +1.149550,3.914569,0.459820,41.992276,, +1.153700,4.547893,0.461480,41.997653,, +1.157850,3.578525,0.463140,41.994241,, +1.162000,3.869936,0.464800,42.001722,, +1.166150,3.795464,0.466460,42.002518,, +1.170300,3.503051,0.468120,42.000258,, +1.174450,5.066872,0.469780,41.999874,, +1.178600,4.171699,0.471440,42.000751,, +1.182750,4.360029,0.473100,41.998681,, +1.186900,2.951358,0.474760,41.995874,, +1.191050,4.922291,0.476420,41.995063,, +1.195200,4.807158,0.478080,42.001100,, +1.199350,3.781831,0.479740,42.001377,, +1.203500,3.972532,0.481400,42.002518,, +1.207650,3.366189,0.483060,41.998190,, +1.211800,4.473479,0.484720,41.996694,, +1.215950,3.911091,0.486380,42.000314,, +1.220100,3.868127,0.488040,42.002812,, +1.224250,4.579212,0.489700,42.004056,, +1.228400,3.143262,0.491360,42.003284,, +1.232550,4.178205,0.493020,41.993660,, +1.236700,4.063350,0.494680,41.997223,, +1.240850,4.371473,0.496340,42.004738,, +1.245000,3.472090,0.498000,42.003480,, +1.249150,3.861025,0.499660,41.999068,, +1.253300,4.234013,0.501320,42.002517,, +1.257450,3.294398,0.502980,42.004798,, +1.261600,3.455450,0.504640,42.003815,, +1.265750,3.504734,0.506300,42.006187,, +1.269900,4.082503,0.507960,41.996866,, +1.274050,3.371060,0.509620,42.004941,, +1.278200,3.878651,0.511280,42.009838,, +1.282350,3.058487,0.512940,41.996709,, +1.286500,4.427744,0.514600,41.993449,, +1.290650,4.272085,0.516260,41.997514,, +1.294800,3.237046,0.517920,42.001734,, +1.298950,3.753016,0.519580,41.999093,, +1.303100,3.749581,0.521240,42.000579,, +1.307250,4.596294,0.522900,42.006785,, +1.311400,3.706987,0.524560,41.991169,, +1.315550,4.733141,0.526220,41.994079,, +1.319700,3.974703,0.527880,41.996118,, +1.323850,3.403765,0.529540,42.009541,, +1.328000,4.514185,0.531200,42.002183,, +1.332150,3.638195,0.532860,41.994633,, +1.336300,3.875434,0.534520,41.996829,, +1.340450,3.780068,0.536180,42.003465,, +1.344600,4.134789,0.537840,41.996663,, +1.348750,4.632348,0.539500,41.997145,, +1.352900,3.898233,0.541160,41.999673,, +1.357050,3.394713,0.542820,41.996411,, +1.361200,3.941021,0.544480,41.992154,, +1.365350,3.872176,0.546140,41.993133,, +1.369500,4.787359,0.547800,41.998987,, +1.373650,3.256038,0.549460,42.002237,, +1.377800,3.902157,0.551120,41.998661,, +1.381950,3.789549,0.552780,42.001626,, +1.386100,4.048530,0.554440,41.996335,, +1.390250,4.339325,0.556100,41.996536,, +1.394400,3.689264,0.557760,41.997848,, +1.398550,3.670159,0.559420,42.001392,, +1.402700,4.158217,0.561080,42.010330,, +1.406850,3.473188,0.562740,41.989410,, +1.411000,4.651581,0.564400,41.992637,, +1.415150,4.471706,0.566060,42.009119,, +1.419300,5.058747,0.567720,41.999351,, +1.423450,4.057174,0.569380,41.998973,, +1.427600,4.328398,0.571040,41.998155,, +1.431750,3.698393,0.572700,42.001296,, +1.435900,4.167356,0.574360,42.002918,, +1.440050,3.901478,0.576020,42.002218,, +1.444200,3.814975,0.577680,42.001858,, +1.448350,4.187763,0.579340,41.996030,, +1.452500,3.674224,0.581000,41.998500,, +1.456650,3.899654,0.582660,42.008166,, +1.460800,3.716358,0.584320,41.997576,, +1.464950,3.438806,0.585980,42.002392,, +1.469100,4.538963,0.587640,41.992911,, +1.473250,2.740724,0.589300,42.006761,, +1.477400,3.778453,0.590960,42.004518,, +1.481550,3.887821,0.592620,42.002457,, +1.485700,3.638239,0.594280,41.998295,, +1.489850,3.693170,0.595940,41.996685,, +1.494000,3.698280,0.597600,41.999060,, +1.498150,3.491768,0.599260,42.004090,, +1.502300,3.710854,0.600920,42.000155,, +1.506450,4.437092,0.602580,41.999755,, +1.510600,3.862138,0.604240,42.006565,, +1.514750,3.391699,0.605900,42.004573,, +1.518900,5.037037,0.607560,41.998905,, +1.523050,4.109149,0.609220,41.997750,, +1.527200,3.624877,0.610880,41.994702,, +1.531350,3.244898,0.612540,41.993465,, +1.535500,4.070445,0.614200,42.004241,, +1.539650,3.673121,0.615860,41.999950,, +1.543800,4.423862,0.617520,42.002117,, +1.547950,4.132243,0.619180,42.010433,, +1.552100,3.866801,0.620840,41.997009,, +1.556250,4.287639,0.622500,41.995306,, +1.560400,3.060056,0.624160,42.003950,, +1.564550,3.919224,0.625820,42.004281,, +1.568700,3.471477,0.627480,41.994244,, +1.572850,2.804789,0.629140,42.005613,, +1.577000,4.608759,0.630800,41.998416,, +1.581150,3.910259,0.632460,42.003135,, +1.585300,4.028721,0.634120,42.002855,, +1.589450,4.556621,0.635780,41.995507,, +1.593600,3.972916,0.637440,41.997631,, +1.597750,3.744519,0.639100,41.999899,, +1.601900,3.756621,0.640760,41.997697,, +1.606050,3.541883,0.642420,42.003279,, +1.610200,4.181907,0.644080,42.008708,, +1.614350,4.792337,0.645740,41.999225,, +1.618500,4.269564,0.647400,42.007513,, +1.622650,4.777135,0.649060,41.997880,, +1.626800,3.449167,0.650720,42.000815,, +1.630950,3.839652,0.652380,41.999809,, +1.635100,4.058336,0.654040,41.994355,, +1.639250,4.406796,0.655700,41.999137,, +1.643400,3.883178,0.657360,42.000102,, +1.647550,3.732233,0.659020,42.000764,, +1.651700,3.711449,0.660680,42.004589,, +1.655850,4.217291,0.662340,41.998311,, +1.660000,4.746431,0.664000,41.999341,, +1.664150,4.971547,0.665660,42.000037,, +1.668300,3.028967,0.667320,41.998172,, +1.672450,3.513423,0.668980,42.008365,, +1.676600,3.735204,0.670640,42.002862,, +1.680750,4.029828,0.672300,42.000895,, +1.684900,3.306935,0.673960,41.998153,, +1.689050,3.991406,0.675620,41.997230,, +1.693200,4.011256,0.677280,42.001956,, +1.697350,3.462580,0.678940,42.004932,, +1.701500,3.938839,0.680600,41.998368,, +1.705650,4.499712,0.682260,41.999094,, +1.709800,3.959818,0.683920,42.000914,, +1.713950,3.950800,0.685580,42.004284,, +1.718100,4.115951,0.687240,41.997883,, +1.722250,3.899524,0.688900,41.997899,, +1.726400,4.295290,0.690560,41.997285,, +1.730550,3.586877,0.692220,41.997064,, +1.734700,3.429788,0.693880,42.008219,, +1.738850,3.637739,0.695540,42.001634,, +1.743000,2.757945,0.697200,41.996353,, +,,0.698860,42.000795,, +,,0.700520,41.993569,, +,,0.702180,42.003262,, +,,0.703840,42.004579,, +,,0.705500,42.001496,, +,,0.707160,41.999015,, +,,0.708820,41.993618,, +,,0.710480,41.996799,, +,,0.712140,41.996209,, +,,0.713800,42.007399,, +,,0.715460,41.998380,, +,,0.717120,42.005194,, +,,0.718780,42.000194,, +,,0.720440,42.000679,, +,,0.722100,42.001216,, +,,0.723760,42.000237,, +,,0.725420,41.997615,, +,,0.727080,41.995171,, +,,0.728740,41.997205,, +,,0.730400,42.004478,, +,,0.732060,42.005214,, +,,0.733720,41.993842,, +,,0.735380,42.002129,, +,,0.737040,41.998275,, +,,0.738700,41.996883,, +,,0.740360,41.998415,, +,,0.742020,41.994820,, +,,0.743680,42.001511,, +,,0.745340,41.999544,, +,,0.747000,42.005894,, +,,0.748660,42.002330,, +,,0.750320,41.996178,, +,,0.751980,41.993335,, +,,0.753640,42.001889,, +,,0.755300,42.004836,, +,,0.756960,42.003446,, +,,0.758620,42.005133,, +,,0.760280,41.998884,, +,,0.761940,42.001541,, +,,0.763600,41.999020,, +,,0.765260,41.999379,, +,,0.766920,41.999968,, +,,0.768580,41.997030,, +,,0.770240,42.001115,, +,,0.771900,42.001602,, +,,0.773560,41.997719,, +,,0.775220,42.003974,, +,,0.776880,42.002929,, +,,0.778540,42.005047,, +,,0.780200,42.002462,, +,,0.781860,42.002063,, +,,0.783520,41.999638,, +,,0.785180,42.009987,, +,,0.786840,41.997862,, +,,0.788500,42.001300,, +,,0.790160,42.001894,, +,,0.791820,41.995896,, +,,0.793480,42.001864,, +,,0.795140,41.992508,, +,,0.796800,41.996880,, +,,0.798460,42.002525,, +,,0.800120,41.993796,, +,,0.801780,42.002378,, +,,0.803440,41.999200,, +,,0.805100,42.006176,, +,,0.806760,42.001298,, +,,0.808420,42.002761,, +,,0.810080,41.999953,, +,,0.811740,41.998836,, +,,0.813400,41.996185,, +,,0.815060,42.000057,, +,,0.816720,42.004555,, +,,0.818380,41.994584,, +,,0.820040,42.005574,, +,,0.821700,41.996440,, +,,0.823360,42.000749,, +,,0.825020,42.001699,, +,,0.826680,42.001519,, +,,0.828340,42.001625,, +,,0.830000,41.994304,, +,,0.831660,41.994542,, +,,0.833320,42.007254,, +,,0.834980,41.997407,, +,,0.836640,41.997103,, +,,0.838300,41.998102,, +,,0.839960,41.997933,, +,,0.841620,42.008634,, +,,0.843280,42.013792,, +,,0.844940,42.000792,, +,,0.846600,41.999406,, +,,0.848260,42.000850,, +,,0.849920,41.999596,, +,,0.851580,42.002402,, +,,0.853240,41.997586,, +,,0.854900,42.011462,, +,,0.856560,42.000233,, +,,0.858220,42.003806,, +,,0.859880,42.005111,, +,,0.861540,42.002058,, +,,0.863200,41.997155,, +,,0.864860,41.994560,, +,,0.866520,42.001558,, +,,0.868180,41.996598,, +,,0.869840,42.005040,, +,,0.871500,41.996582,, +,,0.873160,41.997320,, +,,0.874820,41.997867,, +,,0.876480,42.002608,, +,,0.878140,42.005191,, +,,0.879800,41.992543,, +,,0.881460,42.005614,, +,,0.883120,41.999276,, +,,0.884780,41.996833,, +,,0.886440,42.002133,, +,,0.888100,41.997664,, +,,0.889760,41.998095,, +,,0.891420,42.002211,, +,,0.893080,42.002436,, +,,0.894740,41.992941,, +,,0.896400,42.001158,, +,,0.898060,41.997809,, +,,0.899720,42.000818,, +,,0.901380,41.997920,, +,,0.903040,42.006763,, +,,0.904700,41.996910,, +,,0.906360,41.996271,, +,,0.908020,42.002559,, +,,0.909680,41.995894,, +,,0.911340,41.997085,, +,,0.913000,41.997003,, +,,0.914660,41.989010,, +,,0.916320,41.999753,, +,,0.917980,42.004376,, +,,0.919640,41.999085,, +,,0.921300,41.992140,, +,,0.922960,42.004846,, +,,0.924620,41.999644,, +,,0.926280,41.999858,, +,,0.927940,42.001859,, +,,0.929600,42.002501,, +,,0.931260,41.998895,, +,,0.932920,41.995097,, +,,0.934580,41.993904,, +,,0.936240,41.997168,, +,,0.937900,42.000578,, +,,0.939560,42.002240,, +,,0.941220,41.995625,, +,,0.942880,41.999743,, +,,0.944540,41.998282,, +,,0.946200,41.999474,, +,,0.947860,42.000246,, +,,0.949520,41.997529,, +,,0.951180,42.004165,, +,,0.952840,41.997568,, +,,0.954500,41.993583,, +,,0.956160,41.998281,, +,,0.957820,41.996918,, +,,0.959480,41.991094,, +,,0.961140,42.003981,, +,,0.962800,41.999083,, +,,0.964460,41.999240,, +,,0.966120,42.012726,, +,,0.967780,42.002159,, +,,0.969440,42.008870,, +,,0.971100,42.000030,, +,,0.972760,41.997402,, +,,0.974420,41.998304,, +,,0.976080,41.996289,, +,,0.977740,41.994683,, +,,0.979400,42.003372,, +,,0.981060,41.997846,, +,,0.982720,42.001443,, +,,0.984380,41.994698,, +,,0.986040,41.994872,, +,,0.987700,41.999501,, +,,0.989360,42.006591,, +,,0.991020,41.996549,, +,,0.992680,41.998961,, +,,0.994340,41.994085,, +,,0.996000,42.001940,, +,,0.997660,41.994726,, +,,0.999320,42.003087,, +,,1.000980,41.999596,, +,,1.002640,42.002330,, +,,1.004300,41.995873,, +,,1.005960,41.992139,, +,,1.007620,41.997148,, +,,1.009280,41.997045,, +,,1.010940,41.999361,, +,,1.012600,41.996605,, +,,1.014260,42.000069,, +,,1.015920,42.003694,, +,,1.017580,41.995554,, +,,1.019240,41.990835,, +,,1.020900,41.995727,, +,,1.022560,41.998186,, +,,1.024220,41.992631,, +,,1.025880,41.998151,, +,,1.027540,42.005599,, +,,1.029200,42.001777,, +,,1.030860,42.003616,, +,,1.032520,41.996984,, +,,1.034180,42.002529,, +,,1.035840,41.996375,, +,,1.037500,41.999632,, +,,1.039160,42.006355,, +,,1.040820,42.000783,, +,,1.042480,41.995847,, +,,1.044140,42.003547,, +,,1.045800,41.990247,, +,,1.047460,42.001468,, +,,1.049120,41.995241,, +,,1.050780,41.999275,, +,,1.052440,41.990955,, +,,1.054100,41.997246,, +,,1.055760,42.000720,, +,,1.057420,41.994623,, +,,1.059080,42.001212,, +,,1.060740,42.000596,, +,,1.062400,42.001201,, +,,1.064060,41.994290,, +,,1.065720,41.997391,, +,,1.067380,42.007342,, +,,1.069040,41.998167,, +,,1.070700,41.992663,, +,,1.072360,42.003597,, +,,1.074020,42.002737,, +,,1.075680,41.995404,, +,,1.077340,41.998651,, +,,1.079000,42.000860,, +,,1.080660,41.999442,, +,,1.082320,42.005234,, +,,1.083980,42.002395,, +,,1.085640,42.001747,, +,,1.087300,42.001552,, +,,1.088960,41.999027,, +,,1.090620,42.008124,, +,,1.092280,42.001974,, +,,1.093940,42.004457,, +,,1.095600,42.002838,, +,,1.097260,41.996993,, +,,1.098920,41.998923,, +,,1.100580,41.996884,, +,,1.102240,41.990211,, +,,1.103900,42.004630,, +,,1.105560,41.992091,, +,,1.107220,42.007151,, +,,1.108880,41.995394,, +,,1.110540,41.998384,, +,,1.112200,42.000979,, +,,1.113860,41.995693,, +,,1.115520,41.999889,, +,,1.117180,42.002831,, +,,1.118840,41.995691,, +,,1.120500,41.997877,, +,,1.122160,42.004406,, +,,1.123820,41.999408,, +,,1.125480,42.004773,, +,,1.127140,41.997888,, +,,1.128800,42.000364,, +,,1.130460,42.000159,, +,,1.132120,41.991859,, +,,1.133780,41.990386,, +,,1.135440,42.001590,, +,,1.137100,41.999825,, +,,1.138760,42.002696,, +,,1.140420,42.004623,, +,,1.142080,41.999801,, +,,1.143740,42.005993,, +,,1.145400,41.993928,, +,,1.147060,42.008215,, +,,1.148720,42.005477,, +,,1.150380,42.009128,, +,,1.152040,41.998619,, +,,1.153700,42.000963,, +,,1.155360,42.001961,, +,,1.157020,41.997594,, +,,1.158680,41.999916,, +,,1.160340,41.997691,, +,,1.162000,41.994961,, +,,1.163660,41.999524,, +,,1.165320,42.008793,, +,,1.166980,41.998169,, +,,1.168640,41.999426,, +,,1.170300,42.003508,, +,,1.171960,42.001072,, +,,1.173620,42.000809,, +,,1.175280,42.000743,, +,,1.176940,42.001448,, +,,1.178600,42.004327,, +,,1.180260,42.001729,, +,,1.181920,42.002655,, +,,1.183580,42.005326,, +,,1.185240,42.001765,, +,,1.186900,41.997100,, +,,1.188560,42.001400,, +,,1.190220,42.001193,, +,,1.191880,41.998636,, +,,1.193540,42.004793,, +,,1.195200,41.996565,, +,,1.196860,41.996906,, +,,1.198520,42.002075,, +,,1.200180,42.007835,, +,,1.201840,42.001667,, +,,1.203500,41.993380,, +,,1.205160,42.001056,, +,,1.206820,41.995618,, +,,1.208480,41.992452,, +,,1.210140,42.002606,, +,,1.211800,41.998510,, +,,1.213460,41.994166,, +,,1.215120,42.001220,, +,,1.216780,42.004303,, +,,1.218440,41.993019,, +,,1.220100,41.993823,, +,,1.221760,41.999669,, +,,1.223420,41.991888,, +,,1.225080,42.007196,, +,,1.226740,41.998336,, +,,1.228400,42.006042,, +,,1.230060,41.997281,, +,,1.231720,41.996522,, +,,1.233380,41.996228,, +,,1.235040,41.999213,, +,,1.236700,42.001010,, +,,1.238360,42.003942,, +,,1.240020,42.005234,, +,,1.241680,42.002027,, +,,1.243340,41.991223,, +,,1.245000,41.999239,, +,,1.246660,41.997390,, +,,1.248320,42.008400,, +,,1.249980,42.001939,, +,,1.251640,42.000739,, +,,1.253300,41.998370,, +,,1.254960,42.002747,, +,,1.256620,41.998547,, +,,1.258280,41.998419,, +,,1.259940,41.993475,, +,,1.261600,42.000428,, +,,1.263260,41.998279,, +,,1.264920,42.001978,, +,,1.266580,42.001064,, +,,1.268240,42.003970,, +,,1.269900,42.003907,, +,,1.271560,42.006414,, +,,1.273220,41.998734,, +,,1.274880,41.996607,, +,,1.276540,41.990894,, +,,1.278200,41.999962,, +,,1.279860,42.001732,, +,,1.281520,41.994704,, +,,1.283180,42.001025,, +,,1.284840,42.001168,, +,,1.286500,42.003787,, +,,1.288160,41.990572,, +,,1.289820,41.992589,, +,,1.291480,42.002896,, +,,1.293140,42.000874,, +,,1.294800,42.004998,, +,,1.296460,42.009439,, +,,1.298120,41.998250,, +,,1.299780,41.994375,, +,,1.301440,42.001384,, +,,1.303100,42.002910,, +,,1.304760,42.000598,, +,,1.306420,42.007617,, +,,1.308080,41.998568,, +,,1.309740,42.005074,, +,,1.311400,42.003130,, +,,1.313060,42.005520,, +,,1.314720,42.003404,, +,,1.316380,42.003039,, +,,1.318040,42.008800,, +,,1.319700,41.992539,, +,,1.321360,41.999186,, +,,1.323020,41.998464,, +,,1.324680,42.000217,, +,,1.326340,41.997495,, +,,1.328000,41.999846,, +,,1.329660,41.992695,, +,,1.331320,42.003976,, +,,1.332980,42.002329,, +,,1.334640,42.005639,, +,,1.336300,42.003857,, +,,1.337960,41.996448,, +,,1.339620,42.001531,, +,,1.341280,41.995486,, +,,1.342940,41.997565,, +,,1.344600,42.000181,, +,,1.346260,42.000992,, +,,1.347920,42.003045,, +,,1.349580,41.995458,, +,,1.351240,42.008589,, +,,1.352900,41.992606,, +,,1.354560,42.000635,, +,,1.356220,41.998316,, +,,1.357880,42.002048,, +,,1.359540,41.998780,, +,,1.361200,42.009390,, +,,1.362860,42.003599,, +,,1.364520,42.000821,, +,,1.366180,41.999767,, +,,1.367840,41.995410,, +,,1.369500,41.999418,, +,,1.371160,42.000153,, +,,1.372820,42.000612,, +,,1.374480,42.000134,, +,,1.376140,42.004747,, +,,1.377800,42.005487,, +,,1.379460,41.995990,, +,,1.381120,41.999267,, +,,1.382780,41.998409,, +,,1.384440,41.996007,, +,,1.386100,42.009087,, +,,1.387760,42.000579,, +,,1.389420,42.000229,, +,,1.391080,42.006351,, +,,1.392740,41.999906,, +,,1.394400,41.989837,, +,,1.396060,41.999222,, +,,1.397720,42.001007,, +,,1.399380,42.002720,, +,,1.401040,42.002089,, +,,1.402700,42.002240,, +,,1.404360,42.001067,, +,,1.406020,42.001110,, +,,1.407680,41.994653,, +,,1.409340,42.009653,, +,,1.411000,41.999533,, +,,1.412660,41.994214,, +,,1.414320,42.003496,, +,,1.415980,41.998576,, +,,1.417640,42.005988,, +,,1.419300,42.007897,, +,,1.420960,42.001441,, +,,1.422620,42.003292,, +,,1.424280,41.992103,, +,,1.425940,41.994370,, +,,1.427600,42.003800,, +,,1.429260,42.004914,, +,,1.430920,41.994280,, +,,1.432580,42.006132,, +,,1.434240,42.004909,, +,,1.435900,41.994568,, +,,1.437560,42.004142,, +,,1.439220,41.990577,, +,,1.440880,42.009738,, +,,1.442540,41.996195,, +,,1.444200,42.000014,, +,,1.445860,42.003078,, +,,1.447520,41.999175,, +,,1.449180,42.000395,, +,,1.450840,41.994453,, +,,1.452500,41.991556,, +,,1.454160,42.001504,, +,,1.455820,42.007364,, +,,1.457480,41.994302,, +,,1.459140,41.997399,, +,,1.460800,42.003132,, +,,1.462460,41.996031,, +,,1.464120,42.000826,, +,,1.465780,41.998905,, +,,1.467440,42.001443,, +,,1.469100,41.999652,, +,,1.470760,41.994044,, +,,1.472420,42.001477,, +,,1.474080,42.010930,, +,,1.475740,41.996716,, +,,1.477400,41.996037,, +,,1.479060,41.996412,, +,,1.480720,42.000048,, +,,1.482380,41.999784,, +,,1.484040,41.994167,, +,,1.485700,42.003281,, +,,1.487360,41.998659,, +,,1.489020,42.001964,, +,,1.490680,42.005832,, +,,1.492340,41.999012,, +,,1.494000,41.997315,, +,,1.495660,41.996755,, +,,1.497320,42.006160,, +,,1.498980,41.995039,, +,,1.500640,42.002530,, +,,1.502300,41.997745,, +,,1.503960,42.002441,, +,,1.505620,42.003505,, +,,1.507280,42.002536,, +,,1.508940,42.002520,, +,,1.510600,42.004921,, +,,1.512260,42.004658,, +,,1.513920,41.994810,, +,,1.515580,42.004583,, +,,1.517240,42.002747,, +,,1.518900,42.001730,, +,,1.520560,42.003723,, +,,1.522220,42.005410,, +,,1.523880,42.005149,, +,,1.525540,42.008710,, +,,1.527200,42.004715,, +,,1.528860,42.003944,, +,,1.530520,41.994698,, +,,1.532180,41.996288,, +,,1.533840,41.998861,, +,,1.535500,42.000499,, +,,1.537160,41.997616,, +,,1.538820,42.006840,, +,,1.540480,42.001555,, +,,1.542140,41.998595,, +,,1.543800,42.005445,, +,,1.545460,42.001051,, +,,1.547120,42.002835,, +,,1.548780,42.003639,, +,,1.550440,41.999725,, +,,1.552100,41.997067,, +,,1.553760,41.998292,, +,,1.555420,41.999233,, +,,1.557080,42.000159,, +,,1.558740,42.003553,, +,,1.560400,42.003457,, +,,1.562060,42.005650,, +,,1.563720,41.996434,, +,,1.565380,41.996786,, +,,1.567040,42.003823,, +,,1.568700,41.999132,, +,,1.570360,41.998316,, +,,1.572020,41.994770,, +,,1.573680,42.005880,, +,,1.575340,42.005651,, +,,1.577000,41.995402,, +,,1.578660,42.005313,, +,,1.580320,41.998487,, +,,1.581980,41.996915,, +,,1.583640,42.002770,, +,,1.585300,42.000121,, +,,1.586960,42.000518,, +,,1.588620,42.000494,, +,,1.590280,41.997778,, +,,1.591940,42.004324,, +,,1.593600,42.001572,, +,,1.595260,41.999940,, +,,1.596920,42.004441,, +,,1.598580,41.996953,, +,,1.600240,41.997439,, +,,1.601900,41.991820,, +,,1.603560,41.992722,, +,,1.605220,41.999950,, +,,1.606880,41.996765,, +,,1.608540,42.006278,, +,,1.610200,42.003855,, +,,1.611860,42.000487,, +,,1.613520,42.004110,, +,,1.615180,41.999633,, +,,1.616840,41.997146,, +,,1.618500,41.999332,, +,,1.620160,41.992990,, +,,1.621820,42.000737,, +,,1.623480,42.004971,, +,,1.625140,41.999780,, +,,1.626800,41.999670,, +,,1.628460,41.996393,, +,,1.630120,42.006426,, +,,1.631780,41.999061,, +,,1.633440,41.996364,, +,,1.635100,42.002602,, +,,1.636760,41.992294,, +,,1.638420,42.004700,, +,,1.640080,41.997019,, +,,1.641740,41.998593,, +,,1.643400,42.003944,, +,,1.645060,41.993530,, +,,1.646720,42.001955,, +,,1.648380,41.993444,, +,,1.650040,42.009218,, +,,1.651700,42.004444,, +,,1.653360,41.999521,, +,,1.655020,41.999823,, +,,1.656680,42.002656,, +,,1.658340,41.997733,, +,,1.660000,42.002177,, +,,1.661660,41.998962,, +,,1.663320,41.997390,, +,,1.664980,42.000368,, +,,1.666640,41.997836,, +,,1.668300,41.995212,, +,,1.669960,41.993566,, +,,1.671620,42.004290,, +,,1.673280,42.001399,, +,,1.674940,41.998409,, +,,1.676600,41.997607,, +,,1.678260,42.004644,, +,,1.679920,41.996691,, +,,1.681580,42.005742,, +,,1.683240,42.002659,, +,,1.684900,41.997947,, +,,1.686560,42.004334,, +,,1.688220,42.001766,, +,,1.689880,42.003462,, +,,1.691540,41.996136,, +,,1.693200,42.000558,, +,,1.694860,41.996047,, +,,1.696520,41.997861,, +,,1.698180,41.997889,, +,,1.699840,42.002806,, +,,1.701500,42.000634,, +,,1.703160,41.999692,, +,,1.704820,42.004908,, +,,1.706480,42.002496,, +,,1.708140,42.001620,, +,,1.709800,42.002155,, +,,1.711460,41.995873,, +,,1.713120,41.998793,, +,,1.714780,42.004541,, +,,1.716440,42.002224,, +,,1.718100,41.997694,, +,,1.719760,41.993545,, +,,1.721420,42.000812,, +,,1.723080,41.991637,, +,,1.724740,42.001861,, +,,1.726400,42.001664,, +,,1.728060,41.994811,, +,,1.729720,42.003302,, +,,1.731380,42.003742,, +,,1.733040,42.005949,, +,,1.734700,42.006102,, +,,1.736360,41.999256,, +,,1.738020,41.997350,, +,,1.739680,42.000088,, +,,1.741340,42.002171,, +,,1.743000,42.002742,, diff --git a/examples/data/example_binding_signal.csv b/examples/data/example_binding_signal.csv index b90c6e1..390b1c6 100644 --- a/examples/data/example_binding_signal.csv +++ b/examples/data/example_binding_signal.csv @@ -1,202 +1,1002 @@ time_min,signal -0.00,0.0246 -0.10,0.0672 -0.20,0.0314 -0.30,0.0888 -0.40,0.0490 -0.50,0.1583 +0.00,0.0307 +0.01,0.0840 +0.02,0.0393 +0.03,0.1110 +0.04,0.0613 +0.05,0.1979 +0.06,0.0000 +0.07,0.0995 +0.08,0.0000 +0.09,0.0261 +0.10,0.0000 +0.11,0.0000 +0.12,0.0000 +0.13,0.0686 +0.14,0.0000 +0.15,0.1001 +0.16,0.0000 +0.17,0.0599 +0.18,0.0109 +0.19,0.0508 +0.20,0.0392 +0.21,0.0000 +0.22,0.0832 +0.23,0.1071 +0.24,0.0000 +0.25,0.0000 +0.26,0.0000 +0.27,0.0797 +0.28,0.0290 +0.29,0.0000 +0.30,0.0977 +0.31,0.1059 +0.32,0.0000 +0.33,0.0009 +0.34,0.2219 +0.35,0.0811 +0.36,0.0644 +0.37,0.0331 +0.38,0.1413 +0.39,0.1378 +0.40,0.0000 +0.41,0.0000 +0.42,0.0000 +0.43,0.0366 +0.44,0.0087 +0.45,0.0000 +0.46,0.0000 +0.47,0.0031 +0.48,0.0000 +0.49,0.0000 +0.50,0.0913 +0.51,0.0000 +0.52,0.0559 +0.53,0.0660 +0.54,0.0000 +0.55,0.0018 +0.56,0.0659 +0.57,0.0127 +0.58,0.1395 +0.59,0.0000 0.60,0.0000 -0.70,0.0796 -0.80,0.0000 -0.90,0.0209 -1.00,0.0000 -1.10,0.0000 -1.20,0.0000 -1.30,0.0549 -1.40,0.0000 -1.50,0.0801 -1.60,0.0000 -1.70,0.0479 -1.80,0.0087 -1.90,0.0407 -2.00,0.0314 -2.10,0.0000 -2.20,0.0680 -2.30,0.1457 -2.40,1.5960 -2.50,8.1918 -2.60,9.8408 -2.70,10.0592 -2.80,10.0231 -2.90,9.9425 -3.00,10.0782 -3.10,10.0847 -3.20,9.8244 -3.30,9.9407 -3.40,8.4908 -3.50,1.8128 -3.60,0.1607 -3.70,0.0313 -3.80,0.1137 -3.90,0.1109 -4.00,0.0000 -4.10,0.0000 -4.20,0.0000 -4.30,0.0321 -4.40,0.0109 -4.50,0.0000 -4.60,0.0000 -4.70,0.0116 -4.80,0.0000 -4.90,0.0000 -5.00,0.0923 -5.10,0.0000 -5.20,0.0747 -5.30,0.0897 -5.40,0.0000 -5.50,0.0558 -5.60,0.1180 -5.70,0.0878 -5.80,0.2033 -5.90,0.0357 -6.00,0.1024 -6.10,0.0969 -6.20,0.1719 -6.30,0.2147 -6.40,0.2292 -6.50,0.2418 -6.60,0.3396 -6.70,0.3476 -6.80,0.2960 -6.90,0.4843 -7.00,0.3868 -7.10,0.5362 -7.20,0.4302 -7.30,0.4679 -7.40,0.5861 -7.50,0.4938 -7.60,0.7142 -7.70,0.8270 -7.80,0.8424 -7.90,0.9110 -8.00,1.0119 -8.10,1.0292 -8.20,0.9252 -8.30,1.1678 -8.40,1.0886 -8.50,1.2667 -8.60,1.2770 -8.70,1.2679 -8.80,1.4237 -8.90,1.3877 -9.00,1.4507 -9.10,1.4383 -9.20,1.6438 -9.30,1.4612 -9.40,1.5259 -9.50,1.6924 -9.60,1.4821 -9.70,1.6292 -9.80,1.6964 -9.90,1.6462 -10.00,1.6869 -10.10,1.7606 -10.20,1.6771 -10.30,1.5837 -10.40,1.6892 -10.50,1.7338 -10.60,1.7976 -10.70,1.6429 -10.80,1.6414 -10.90,1.7939 -11.00,1.7263 -11.10,1.7664 -11.20,1.7441 -11.30,1.5847 -11.40,1.5992 -11.50,1.6254 -11.60,1.4819 -11.70,1.3681 -11.80,1.5745 -11.90,1.4676 -12.00,1.2713 -12.10,1.4011 -12.20,1.3005 -12.30,1.2649 -12.40,1.2899 -12.50,1.1462 -12.60,1.1255 -12.70,1.1755 -12.80,0.9734 -12.90,1.2102 -13.00,1.0788 -13.10,0.9436 -13.20,1.0166 -13.30,0.7669 -13.40,0.6642 -13.50,0.8410 -13.60,0.8720 -13.70,0.6474 -13.80,0.7081 -13.90,0.6730 -14.00,0.5485 -14.10,0.5332 -14.20,0.5820 -14.30,0.6281 -14.40,0.4884 -14.50,0.5675 -14.60,0.4470 -14.70,0.4443 -14.80,0.5016 -14.90,0.2464 -15.00,0.2658 -15.10,0.3309 -15.20,0.4260 -15.30,0.3441 -15.40,0.2630 -15.50,0.2108 -15.60,0.2448 -15.70,0.2664 -15.80,0.1348 -15.90,0.2640 -16.00,0.1896 -16.10,0.2404 -16.20,0.0948 -16.30,0.0629 -16.40,0.1818 -16.50,0.0071 -16.60,0.1020 -16.70,0.0144 -16.80,0.1115 -16.90,0.0365 -17.00,0.0662 -17.10,0.0750 -17.20,0.0000 -17.30,0.0482 -17.40,0.0790 -17.50,0.1089 -17.60,0.0000 -17.70,0.0947 -17.80,0.0370 -17.90,0.0000 -18.00,0.0000 -18.10,0.0020 -18.20,0.0000 -18.30,0.1201 -18.40,0.0836 -18.50,0.1783 -18.60,0.0308 -18.70,0.0000 -18.80,0.0282 -18.90,0.0000 -19.00,0.0000 -19.10,0.0660 -19.20,0.0000 -19.30,0.0000 -19.40,0.0388 -19.50,0.0000 -19.60,0.0616 -19.70,0.0000 -19.80,0.0756 -19.90,0.0000 -20.00,0.0385 +0.61,0.0000 +0.62,0.0109 +0.63,0.0393 +0.64,0.0307 +0.65,0.0171 +0.66,0.1066 +0.67,0.0803 +0.68,0.0000 +0.69,0.1693 +0.70,0.0024 +0.71,0.1417 +0.72,0.0000 +0.73,0.0000 +0.74,0.0487 +0.75,0.0000 +0.76,0.0964 +0.77,0.1793 +0.78,0.1395 +0.79,0.1656 +0.80,0.2319 +0.81,0.1936 +0.82,0.0040 +0.83,0.2486 +0.84,0.0918 +0.85,0.2581 +0.86,0.2164 +0.87,0.1525 +0.88,0.2970 +0.89,0.2045 +0.90,0.2388 +0.91,0.1821 +0.92,0.4011 +0.93,0.1390 +0.94,0.1897 +0.95,0.3718 +0.96,0.0872 +0.97,0.2538 +0.98,0.3249 +0.99,0.2539 +1.00,0.3012 +1.01,0.3942 +1.02,0.2954 +1.03,0.1889 +1.04,0.3354 +1.05,0.4106 +1.06,0.5147 +1.07,0.3484 +1.08,0.3781 +1.09,0.6051 +1.10,0.5604 +1.11,0.6539 +1.12,0.6730 +1.13,0.5238 +1.14,0.5950 +1.15,0.6838 +1.16,0.5629 +1.17,0.4816 +1.18,0.8027 +1.19,0.7341 +1.20,0.5555 +1.21,0.7861 +1.22,0.7300 +1.23,0.7562 +1.24,0.8592 +1.25,0.7519 +1.26,0.7990 +1.27,0.9349 +1.28,0.7559 +1.29,1.1254 +1.30,1.0348 +1.31,0.9392 +1.32,1.1035 +1.33,0.8639 +1.34,0.8077 +1.35,1.1000 +1.36,1.2095 +1.37,0.9986 +1.38,1.1436 +1.39,1.1678 +1.40,1.0793 +1.41,1.1263 +1.42,1.2525 +1.43,1.3741 +1.44,1.2624 +1.45,1.4232 +1.46,1.3334 +1.47,1.3897 +1.48,1.5200 +1.49,1.2585 +1.50,1.3393 +1.51,1.4760 +1.52,1.6493 +1.53,1.6003 +1.54,1.5513 +1.55,1.5375 +1.56,1.6306 +1.57,1.7073 +1.58,1.5916 +1.59,1.8011 +1.60,1.7554 +1.61,1.8653 +1.62,1.7291 +1.63,1.7342 +1.64,1.9273 +1.65,1.7528 +1.66,1.9146 +1.67,1.8478 +1.68,2.0113 +1.69,1.9592 +1.70,2.0377 +1.71,2.0894 +1.72,1.9138 +1.73,2.1364 +1.74,2.2146 +1.75,2.2915 +1.76,2.1888 +1.77,2.3516 +1.78,2.3181 +1.79,2.2809 +1.80,2.3019 +1.81,2.3889 +1.82,2.3656 +1.83,2.6119 +1.84,2.6038 +1.85,2.7596 +1.86,2.6124 +1.87,2.5851 +1.88,2.6834 +1.89,2.6196 +1.90,2.7046 +1.91,2.8415 +1.92,2.7655 +1.93,2.7731 +1.94,2.9178 +1.95,2.6708 +1.96,3.0197 +1.97,2.8205 +1.98,3.1105 +1.99,3.0099 +2.00,3.1373 +2.01,3.1844 +2.02,3.2685 +2.03,3.3049 +2.04,3.1271 +2.05,3.1520 +2.06,3.3183 +2.07,3.3287 +2.08,3.4905 +2.09,3.4457 +2.10,3.5834 +2.11,3.3446 +2.12,3.5485 +2.13,3.8880 +2.14,3.4870 +2.15,3.7680 +2.16,3.4819 +2.17,3.6661 +2.18,3.9672 +2.19,3.6471 +2.20,3.8439 +2.21,3.8876 +2.22,3.8203 +2.23,3.8812 +2.24,3.7867 +2.25,3.9658 +2.26,4.0585 +2.27,4.0945 +2.28,4.2065 +2.29,4.1354 +2.30,4.3763 +2.31,4.1653 +2.32,4.3437 +2.33,4.3661 +2.34,4.3677 +2.35,4.3365 +2.36,4.4560 +2.37,4.5548 +2.38,4.4916 +2.39,4.5751 +2.40,4.4365 +2.41,4.7004 +2.42,4.4509 +2.43,4.7582 +2.44,4.7288 +2.45,4.9203 +2.46,4.7033 +2.47,4.7962 +2.48,4.7646 +2.49,4.8255 +2.50,5.0336 +2.51,4.9796 +2.52,4.8493 +2.53,5.2002 +2.54,4.9428 +2.55,5.0368 +2.56,5.2352 +2.57,5.2345 +2.58,5.1614 +2.59,5.0765 +2.60,5.2748 +2.61,5.2162 +2.62,5.4737 +2.63,5.4136 +2.64,5.3388 +2.65,5.4680 +2.66,5.4731 +2.67,5.3473 +2.68,5.5264 +2.69,5.4387 +2.70,5.6092 +2.71,5.5335 +2.72,5.8480 +2.73,5.6142 +2.74,5.7245 +2.75,5.4913 +2.76,5.7660 +2.77,5.6563 +2.78,5.8503 +2.79,5.9422 +2.80,5.9092 +2.81,5.9271 +2.82,6.0135 +2.83,5.7875 +2.84,5.9600 +2.85,6.0522 +2.86,6.0857 +2.87,6.2227 +2.88,6.1763 +2.89,6.1124 +2.90,6.0360 +2.91,6.2150 +2.92,6.1365 +2.93,6.4599 +2.94,6.3030 +2.95,6.2391 +2.96,6.3861 +2.97,6.3882 +2.98,6.5538 +2.99,6.3031 +3.00,6.5513 +3.01,6.5557 +3.02,6.5442 +3.03,6.5821 +3.04,6.5667 +3.05,6.6202 +3.06,6.8243 +3.07,6.6406 +3.08,6.6568 +3.09,6.6089 +3.10,6.8534 +3.11,6.8098 +3.12,6.7185 +3.13,6.6847 +3.14,6.9284 +3.15,7.0273 +3.16,6.9824 +3.17,6.8431 +3.18,7.0772 +3.19,7.0133 +3.20,7.0270 +3.21,7.1917 +3.22,7.0250 +3.23,7.1351 +3.24,7.2746 +3.25,7.1240 +3.26,7.3371 +3.27,7.1621 +3.28,7.2517 +3.29,7.3482 +3.30,7.2084 +3.31,7.4167 +3.32,7.4526 +3.33,7.4141 +3.34,7.3377 +3.35,7.4592 +3.36,7.4006 +3.37,7.6493 +3.38,7.5651 +3.39,7.5577 +3.40,7.5396 +3.41,7.5729 +3.42,7.5286 +3.43,7.6857 +3.44,7.5604 +3.45,7.7965 +3.46,7.6487 +3.47,7.6131 +3.48,7.7698 +3.49,7.6848 +3.50,7.6911 +3.51,7.6135 +3.52,7.6819 +3.53,7.9208 +3.54,7.8748 +3.55,7.9777 +3.56,7.8820 +3.57,7.8387 +3.58,7.6451 +3.59,8.0765 +3.60,7.8835 +3.61,7.9721 +3.62,7.8768 +3.63,8.0541 +3.64,8.0263 +3.65,8.0190 +3.66,8.0612 +3.67,8.0193 +3.68,8.0356 +3.69,8.3157 +3.70,8.0610 +3.71,8.1966 +3.72,8.2839 +3.73,8.2387 +3.74,8.3245 +3.75,8.2594 +3.76,8.1416 +3.77,8.3869 +3.78,8.2745 +3.79,8.3837 +3.80,8.4475 +3.81,8.5207 +3.82,8.3676 +3.83,8.3395 +3.84,8.4352 +3.85,8.4051 +3.86,8.3476 +3.87,8.3431 +3.88,8.6229 +3.89,8.4530 +3.90,8.2838 +3.91,8.5603 +3.92,8.4403 +3.93,8.6198 +3.94,8.6772 +3.95,8.6979 +3.96,8.5041 +3.97,8.5225 +3.98,8.5664 +3.99,8.8175 +4.00,8.6465 +4.01,8.6338 +4.02,8.8924 +4.03,8.7054 +4.04,8.6899 +4.05,8.7511 +4.06,8.8064 +4.07,8.6076 +4.08,8.8112 +4.09,8.6618 +4.10,8.7398 +4.11,8.7978 +4.12,8.7661 +4.13,8.8277 +4.14,8.6589 +4.15,8.7215 +4.16,8.7979 +4.17,8.8771 +4.18,8.8942 +4.19,8.8095 +4.20,8.7371 +4.21,8.9830 +4.22,8.9490 +4.23,9.0409 +4.24,9.0206 +4.25,8.8518 +4.26,8.8636 +4.27,8.8671 +4.28,8.9459 +4.29,8.9900 +4.30,8.9433 +4.31,8.7879 +4.32,8.9854 +4.33,9.1284 +4.34,9.1458 +4.35,9.0079 +4.36,8.9216 +4.37,9.0468 +4.38,8.9267 +4.39,9.1651 +4.40,9.1693 +4.41,9.0931 +4.42,8.9117 +4.43,9.2571 +4.44,9.2864 +4.45,9.1434 +4.46,9.2163 +4.47,9.1172 +4.48,9.1134 +4.49,9.2884 +4.50,9.1697 +4.51,9.4058 +4.52,9.3918 +4.53,9.2918 +4.54,9.2412 +4.55,9.3549 +4.56,9.3155 +4.57,9.4394 +4.58,9.3676 +4.59,9.3068 +4.60,9.4337 +4.61,9.4273 +4.62,9.4205 +4.63,9.2181 +4.64,9.3827 +4.65,9.2043 +4.66,9.2237 +4.67,9.3703 +4.68,9.3263 +4.69,9.2456 +4.70,9.5728 +4.71,9.4482 +4.72,9.3577 +4.73,9.3593 +4.74,9.5845 +4.75,9.5055 +4.76,9.5269 +4.77,9.4101 +4.78,9.4503 +4.79,9.5091 +4.80,9.5168 +4.81,9.5316 +4.82,9.4101 +4.83,9.5249 +4.84,9.3783 +4.85,9.4525 +4.86,9.4835 +4.87,9.3804 +4.88,9.4981 +4.89,9.3955 +4.90,9.5566 +4.91,9.6331 +4.92,9.4635 +4.93,9.4878 +4.94,9.3634 +4.95,9.5330 +4.96,9.5034 +4.97,9.3879 +4.98,9.5265 +4.99,9.7244 +5.00,9.4730 +5.01,9.5975 +5.02,9.7170 +5.03,9.6594 +5.04,9.5794 +5.05,9.7349 +5.06,9.5078 +5.07,9.5719 +5.08,9.5815 +5.09,9.6151 +5.10,9.5421 +5.11,9.4270 +5.12,9.7168 +5.13,9.5247 +5.14,9.6453 +5.15,9.8428 +5.16,9.6342 +5.17,9.6556 +5.18,9.5042 +5.19,9.5995 +5.20,9.6064 +5.21,9.6062 +5.22,9.7442 +5.23,9.6910 +5.24,9.7081 +5.25,9.8506 +5.26,9.7130 +5.27,9.6777 +5.28,9.5330 +5.29,9.6975 +5.30,9.6231 +5.31,9.7712 +5.32,9.7291 +5.33,9.7043 +5.34,9.8805 +5.35,9.7256 +5.36,9.7740 +5.37,9.7277 +5.38,9.6693 +5.39,9.8115 +5.40,9.7616 +5.41,9.7477 +5.42,9.7701 +5.43,9.8140 +5.44,9.7171 +5.45,9.6455 +5.46,9.8261 +5.47,9.6837 +5.48,9.8021 +5.49,9.7840 +5.50,9.7598 +5.51,9.8137 +5.52,9.8110 +5.53,9.8191 +5.54,9.7060 +5.55,9.6834 +5.56,9.7091 +5.57,9.8229 +5.58,9.7061 +5.59,9.7853 +5.60,9.9713 +5.61,9.8577 +5.62,9.6498 +5.63,9.7444 +5.64,9.7926 +5.65,9.9235 +5.66,10.0574 +5.67,9.7671 +5.68,9.7885 +5.69,9.8224 +5.70,9.8637 +5.71,9.7723 +5.72,10.0145 +5.73,9.9022 +5.74,9.9206 +5.75,9.8826 +5.76,9.6515 +5.77,9.9213 +5.78,9.7906 +5.79,10.1086 +5.80,9.8828 +5.81,9.8247 +5.82,9.8360 +5.83,9.9896 +5.84,9.8516 +5.85,9.8709 +5.86,9.9287 +5.87,9.9772 +5.88,9.9364 +5.89,9.8770 +5.90,9.6919 +5.91,9.7809 +5.92,10.0508 +5.93,9.8047 +5.94,9.8583 +5.95,9.8182 +5.96,9.9181 +5.97,10.0325 +5.98,9.9899 +5.99,10.0728 +6.00,9.8655 +6.01,9.6676 +6.02,9.8084 +6.03,9.8481 +6.04,9.9209 +6.05,9.9745 +6.06,9.7708 +6.07,9.9969 +6.08,10.0137 +6.09,9.8429 +6.10,9.7223 +6.11,9.9661 +6.12,9.9530 +6.13,9.7435 +6.14,9.8951 +6.15,9.8882 +6.16,9.9125 +6.17,9.9649 +6.18,9.9966 +6.19,9.8538 +6.20,9.8775 +6.21,9.7798 +6.22,9.9273 +6.23,10.0418 +6.24,9.8323 +6.25,10.0808 +6.26,10.0522 +6.27,9.8046 +6.28,9.8298 +6.29,9.8736 +6.30,9.8034 +6.31,9.9354 +6.32,9.9482 +6.33,9.9835 +6.34,9.8520 +6.35,9.8288 +6.36,9.9797 +6.37,9.7493 +6.38,9.9073 +6.39,9.9935 +6.40,9.8890 +6.41,9.9714 +6.42,9.9099 +6.43,10.1303 +6.44,9.7685 +6.45,9.9024 +6.46,9.8136 +6.47,9.8623 +6.48,9.8788 +6.49,10.0108 +6.50,9.9780 +6.51,9.7280 +6.52,9.9060 +6.53,9.8911 +6.54,10.1404 +6.55,9.8974 +6.56,10.0220 +6.57,9.8597 +6.58,9.8780 +6.59,9.9826 +6.60,10.0086 +6.61,10.0536 +6.62,9.9252 +6.63,9.9740 +6.64,9.9682 +6.65,9.8942 +6.66,9.8270 +6.67,9.8601 +6.68,9.9041 +6.69,10.0253 +6.70,9.7403 +6.71,9.8227 +6.72,9.9565 +6.73,9.9343 +6.74,9.8787 +6.75,10.0089 +6.76,10.0902 +6.77,9.8769 +6.78,10.1184 +6.79,9.8851 +6.80,9.9342 +6.81,10.0051 +6.82,9.9122 +6.83,10.0105 +6.84,10.1402 +6.85,10.0780 +6.86,9.8633 +6.87,9.6633 +6.88,10.0563 +6.89,9.9238 +6.90,10.0371 +6.91,10.0159 +6.92,10.0679 +6.93,9.9179 +6.94,9.9870 +6.95,9.9408 +6.96,9.9698 +6.97,9.9444 +6.98,10.1534 +6.99,9.8709 +7.00,9.9653 +7.01,9.9257 +7.02,10.0415 +7.03,9.9212 +7.04,10.1583 +7.05,9.8893 +7.06,10.0494 +7.07,10.1786 +7.08,10.1407 +7.09,9.8670 +7.10,10.0419 +7.11,9.8927 +7.12,10.0503 +7.13,9.9531 +7.14,9.7219 +7.15,9.9973 +7.16,9.9251 +7.17,10.1655 +7.18,9.9460 +7.19,9.7340 +7.20,9.8641 +7.21,10.0702 +7.22,9.9364 +7.23,9.8635 +7.24,9.9116 +7.25,9.9135 +7.26,10.0255 +7.27,9.9745 +7.28,10.0854 +7.29,10.1287 +7.30,10.0092 +7.31,10.1992 +7.32,10.0463 +7.33,9.8006 +7.34,10.0225 +7.35,10.0875 +7.36,10.0906 +7.37,10.0587 +7.38,9.8399 +7.39,10.1772 +7.40,9.9720 +7.41,9.9233 +7.42,9.7682 +7.43,10.2149 +7.44,9.9824 +7.45,9.9456 +7.46,9.9726 +7.47,9.8550 +7.48,9.8635 +7.49,9.8654 +7.50,10.1167 +7.51,9.8939 +7.52,10.0516 +7.53,9.9488 +7.54,9.9488 +7.55,10.0111 +7.56,10.0504 +7.57,9.9387 +7.58,9.8879 +7.59,9.9935 +7.60,10.1877 +7.61,10.1356 +7.62,10.0072 +7.63,9.8880 +7.64,9.8971 +7.65,10.0179 +7.66,10.1138 +7.67,10.1078 +7.68,9.8715 +7.69,9.9421 +7.70,9.8178 +7.71,10.1045 +7.72,10.0388 +7.73,9.9584 +7.74,10.0900 +7.75,9.8706 +7.76,9.9830 +7.77,10.0479 +7.78,10.2389 +7.79,9.9610 +7.80,9.9309 +7.81,10.0159 +7.82,10.1031 +7.83,9.9506 +7.84,9.9805 +7.85,9.9405 +7.86,10.0357 +7.87,10.0661 +7.88,9.9773 +7.89,9.7758 +7.90,9.8328 +7.91,9.9393 +7.92,9.9210 +7.93,10.0271 +7.94,10.0439 +7.95,9.9148 +7.96,9.9694 +7.97,9.9757 +7.98,9.7364 +7.99,10.1171 +8.00,10.1138 +8.01,9.9129 +8.02,10.1857 +8.03,9.9893 +8.04,9.9543 +8.05,10.1939 +8.06,10.1629 +8.07,9.9516 +8.08,9.9301 +8.09,10.1436 +8.10,9.9229 +8.11,10.1368 +8.12,10.0422 +8.13,9.8991 +8.14,9.9203 +8.15,9.9380 +8.16,9.9039 +8.17,9.7818 +8.18,10.1465 +8.19,9.9498 +8.20,10.2036 +8.21,9.9682 +8.22,10.0131 +8.23,9.9590 +8.24,10.0267 +8.25,10.1725 +8.26,10.0228 +8.27,9.9586 +8.28,10.0536 +8.29,10.1255 +8.30,10.1722 +8.31,9.9865 +8.32,10.0276 +8.33,9.9279 +8.34,10.0773 +8.35,9.9816 +8.36,9.8815 +8.37,10.1209 +8.38,10.0663 +8.39,9.7815 +8.40,10.0866 +8.41,9.9771 +8.42,9.9279 +8.43,10.1166 +8.44,10.0869 +8.45,9.9507 +8.46,9.9176 +8.47,10.1254 +8.48,10.0359 +8.49,9.9428 +8.50,10.0669 +8.51,10.2310 +8.52,9.9727 +8.53,10.0075 +8.54,9.9437 +8.55,10.0886 +8.56,10.0744 +8.57,9.8719 +8.58,10.1929 +8.59,10.1018 +8.60,9.8640 +8.61,10.1535 +8.62,9.9383 +8.63,10.1099 +8.64,9.9854 +8.65,9.9971 +8.66,10.0454 +8.67,10.0443 +8.68,10.1165 +8.69,10.0483 +8.70,9.9771 +8.71,9.8981 +8.72,10.1110 +8.73,10.1175 +8.74,10.0124 +8.75,10.0358 +8.76,10.0589 +8.77,10.0071 +8.78,9.9995 +8.79,10.0405 +8.80,9.9529 +8.81,9.8435 +8.82,10.0321 +8.83,9.9338 +8.84,10.0165 +8.85,10.0502 +8.86,9.9708 +8.87,9.9617 +8.88,9.8431 +8.89,9.8729 +8.90,10.0022 +8.91,9.9034 +8.92,10.0881 +8.93,9.8932 +8.94,9.8105 +8.95,9.8964 +8.96,9.8737 +8.97,10.1037 +8.98,9.9029 +8.99,10.1126 +9.00,10.0399 +9.01,9.8478 +9.02,10.0294 +9.03,10.0290 +9.04,9.9807 +9.05,9.9208 +9.06,9.9017 +9.07,10.0060 +9.08,9.8800 +9.09,9.9900 +9.10,9.9992 +9.11,9.7623 +9.12,9.9816 +9.13,10.1079 +9.14,9.9892 +9.15,10.0079 +9.16,10.1105 +9.17,10.0603 +9.18,9.9460 +9.19,9.9146 +9.20,10.0695 +9.21,9.9066 +9.22,10.0234 +9.23,9.9842 +9.24,10.0622 +9.25,10.1034 +9.26,9.9805 +9.27,10.0048 +9.28,9.9976 +9.29,10.0572 +9.30,9.9323 +9.31,10.1054 +9.32,9.9471 +9.33,10.0358 +9.34,10.0227 +9.35,9.9454 +9.36,10.0859 +9.37,10.0885 +9.38,10.0611 +9.39,9.9636 +9.40,9.9463 +9.41,9.9480 +9.42,9.8644 +9.43,9.9412 +9.44,10.0557 +9.45,9.9626 +9.46,10.0730 +9.47,9.9166 +9.48,9.9411 +9.49,10.1821 +9.50,10.1167 +9.51,9.9784 +9.52,9.9695 +9.53,9.9471 +9.54,9.9974 +9.55,10.1950 +9.56,9.9686 +9.57,9.9561 +9.58,9.9321 +9.59,9.7401 +9.60,10.0851 +9.61,10.1420 +9.62,10.0024 +9.63,10.1282 +9.64,9.9906 +9.65,9.9519 +9.66,9.9606 +9.67,10.0157 +9.68,9.9875 +9.69,9.9114 +9.70,9.8924 +9.71,10.0695 +9.72,9.8398 +9.73,10.0441 +9.74,10.0102 +9.75,10.1391 +9.76,9.8921 +9.77,9.9638 +9.78,10.1971 +9.79,9.8601 +9.80,10.0069 +9.81,10.0039 +9.82,10.1090 +9.83,9.9098 +9.84,9.9894 +9.85,10.0913 +9.86,9.9160 +9.87,9.9210 +9.88,10.0023 +9.89,9.9965 +9.90,10.0023 +9.91,10.2568 +9.92,9.8587 +9.93,10.1142 +9.94,9.9393 +9.95,9.8619 +9.96,9.9646 +9.97,10.0215 +9.98,10.0762 +9.99,9.9239 +10.00,10.0035 diff --git a/examples/data/example_raw_signal.csv b/examples/data/example_raw_signal.csv index eca5b41..79ca9cb 100644 --- a/examples/data/example_raw_signal.csv +++ b/examples/data/example_raw_signal.csv @@ -1,102 +1,502 @@ time_min,signal 0.00,0.0441 -0.10,0.0000 -0.20,0.0055 +0.01,0.0000 +0.02,0.0055 +0.03,0.0000 +0.04,0.0150 +0.05,0.1124 +0.06,0.0000 +0.07,0.2431 +0.08,0.0000 +0.09,0.1088 +0.10,0.1245 +0.11,0.2655 +0.12,0.1014 +0.13,0.0000 +0.14,0.0000 +0.15,0.0532 +0.16,0.0000 +0.17,0.0649 +0.18,0.2884 +0.19,0.0982 +0.20,0.0000 +0.21,0.1949 +0.22,0.1754 +0.23,0.0596 +0.24,0.1508 +0.25,0.0116 +0.26,0.0826 +0.27,0.0000 +0.28,0.2333 +0.29,0.0000 0.30,0.0000 -0.40,0.0150 -0.50,0.1124 -0.60,0.0000 -0.70,0.2431 -0.80,0.0000 -0.90,0.1088 -1.00,0.1245 -1.10,0.2655 -1.20,0.1014 -1.30,0.0000 -1.40,0.0000 -1.50,0.0532 -1.60,0.0000 -1.70,0.0649 -1.80,0.2884 -1.90,0.0982 -2.00,0.0000 -2.10,0.2183 -2.20,1.1801 -2.30,7.9484 -2.40,10.0445 -2.50,10.0082 -2.60,10.0825 -2.70,9.6497 -2.80,10.2333 -2.90,9.8778 -3.00,9.9455 -3.10,9.9317 -3.20,8.7358 -3.30,1.9668 -3.40,0.0000 -3.50,0.1201 -3.60,0.0268 -3.70,0.0000 -3.80,0.1062 -3.90,0.0000 -4.00,0.0000 -4.10,0.0000 -4.20,0.0000 -4.30,0.0000 -4.40,0.0000 -4.50,0.0336 -4.60,0.0000 -4.70,0.1754 -4.80,0.0000 -4.90,0.0000 -5.00,0.0470 -5.10,0.0000 -5.20,0.0000 -5.30,0.0000 -5.40,0.0999 -5.50,0.2454 -5.60,0.0452 -5.70,0.0000 -5.80,0.0000 -5.90,0.0546 -6.00,0.1533 -6.10,0.0000 -6.20,0.0000 -6.30,0.1075 -6.40,0.0572 -6.50,0.0000 -6.60,0.0000 -6.70,0.0000 -6.80,0.0447 -6.90,0.0490 -7.00,0.0000 -7.10,0.0000 -7.20,0.0153 -7.30,0.1387 -7.40,0.0000 -7.50,0.0667 -7.60,0.0000 -7.70,0.1723 -7.80,0.1003 -7.90,0.0000 -8.00,0.0000 -8.10,0.0332 -8.20,0.0000 -8.30,0.0000 -8.40,0.0128 -8.50,0.0000 -8.60,0.0000 -8.70,0.0000 -8.80,0.0242 -8.90,0.2287 -9.00,0.0075 -9.10,0.1882 -9.20,0.0000 -9.30,0.0000 -9.40,0.0228 -9.50,0.0000 -9.60,0.0502 -9.70,0.0000 -9.80,0.0000 -9.90,0.0000 -10.00,0.0000 +0.31,0.0000 +0.32,0.0000 +0.33,0.0000 +0.34,0.0000 +0.35,0.1167 +0.36,0.0267 +0.37,0.0000 +0.38,0.1062 +0.39,0.0000 +0.40,0.0000 +0.41,0.0000 +0.42,0.0000 +0.43,0.0000 +0.44,0.0000 +0.45,0.0336 +0.46,0.0000 +0.47,0.1754 +0.48,0.0000 +0.49,0.0000 +0.50,0.0470 +0.51,0.0000 +0.52,0.0000 +0.53,0.0000 +0.54,0.1178 +0.55,0.3206 +0.56,0.3349 +0.57,0.8903 +0.58,2.7763 +0.59,5.4443 +0.60,7.8735 +0.61,8.7494 +0.62,9.5700 +0.63,10.0074 +0.64,10.0272 +0.65,9.9557 +0.66,9.8969 +0.67,9.9655 +0.68,10.0445 +0.69,10.0490 +0.70,9.9196 +0.71,9.7464 +0.72,10.0153 +0.73,10.1387 +0.74,9.9154 +0.75,10.0667 +0.76,9.9205 +0.77,10.1723 +0.78,10.1003 +0.79,9.9400 +0.80,9.9116 +0.81,10.0332 +0.82,9.5439 +0.83,9.8535 +0.84,10.0128 +0.85,9.9467 +0.86,9.9255 +0.87,9.7976 +0.88,10.0242 +0.89,10.2287 +0.90,10.0075 +0.91,10.1882 +0.92,9.8926 +0.93,9.9076 +0.94,10.0228 +0.95,9.9431 +0.96,10.0502 +0.97,9.4950 +0.98,9.8190 +0.99,9.6944 +1.00,9.8403 +1.01,9.7873 +1.02,10.2034 +1.03,9.8723 +1.04,9.9761 +1.05,10.0148 +1.06,10.1072 +1.07,10.0302 +1.08,9.8582 +1.09,9.8175 +1.10,9.7474 +1.11,10.2450 +1.12,9.8060 +1.13,10.3727 +1.14,9.8527 +1.15,9.8699 +1.16,9.7746 +1.17,9.8837 +1.18,9.7694 +1.19,9.8201 +1.20,9.8829 +1.21,9.8349 +1.22,9.9210 +1.23,10.4872 +1.24,10.1820 +1.25,10.0681 +1.26,9.9120 +1.27,10.4764 +1.28,9.9865 +1.29,10.1396 +1.30,10.0981 +1.31,10.1370 +1.32,9.8888 +1.33,9.8274 +1.34,10.2646 +1.35,10.1816 +1.36,9.9738 +1.37,9.5389 +1.38,10.0092 +1.39,9.9902 +1.40,10.0696 +1.41,10.1531 +1.42,9.7882 +1.43,10.1896 +1.44,9.9817 +1.45,9.6847 +1.46,9.9715 +1.47,10.3106 +1.48,10.1065 +1.49,10.0627 +1.50,10.0647 +1.51,9.6919 +1.52,10.1853 +1.53,10.0925 +1.54,9.9891 +1.55,9.9844 +1.56,9.8252 +1.57,9.9084 +1.58,9.9606 +1.59,10.1494 +1.60,9.8036 +1.61,10.1479 +1.62,9.9112 +1.63,9.9095 +1.64,10.0169 +1.65,10.0431 +1.66,9.9387 +1.67,10.1166 +1.68,9.9809 +1.69,9.8307 +1.70,9.9931 +1.71,9.9907 +1.72,10.0988 +1.73,9.9782 +1.74,10.0068 +1.75,10.1110 +1.76,10.0184 +1.77,9.7963 +1.78,9.9751 +1.79,10.1832 +1.80,9.8047 +1.81,9.9299 +1.82,9.6878 +1.83,10.0799 +1.84,10.0727 +1.85,9.6516 +1.86,10.0614 +1.87,9.9744 +1.88,10.1190 +1.89,10.1304 +1.90,9.8771 +1.91,9.8049 +1.92,9.8406 +1.93,9.9585 +1.94,10.2089 +1.95,10.1146 +1.96,9.9569 +1.97,10.3043 +1.98,9.9203 +1.99,10.0359 +2.00,9.9685 +2.01,10.0560 +2.02,9.7804 +2.03,9.5371 +2.04,9.9591 +2.05,9.9467 +2.06,9.9899 +2.07,10.0632 +2.08,9.9569 +2.09,9.8710 +2.10,10.2838 +2.11,10.0109 +2.12,10.0146 +2.13,10.1220 +2.14,10.1194 +2.15,10.1924 +2.16,10.1397 +2.17,10.0040 +2.18,10.0593 +2.19,9.9706 +2.20,9.9110 +2.21,10.0277 +2.22,9.9719 +2.23,10.1883 +2.24,9.7444 +2.25,9.8217 +2.26,9.7977 +2.27,9.8520 +2.28,10.3162 +2.29,10.0357 +2.30,10.1286 +2.31,9.6720 +2.32,9.9889 +2.33,10.2714 +2.34,9.9849 +2.35,9.8425 +2.36,9.9379 +2.37,10.2195 +2.38,10.1278 +2.39,9.8186 +2.40,9.9593 +2.41,10.0356 +2.42,10.3422 +2.43,9.8739 +2.44,9.8332 +2.45,9.9403 +2.46,10.0357 +2.47,10.3798 +2.48,10.0282 +2.49,9.8656 +2.50,9.8992 +2.51,9.9787 +2.52,10.0176 +2.53,10.2031 +2.54,9.7740 +2.55,9.9007 +2.56,9.8967 +2.57,10.2537 +2.58,10.1214 +2.59,9.8629 +2.60,10.2423 +2.61,9.8217 +2.62,9.9274 +2.63,10.2636 +2.64,9.6610 +2.65,10.1214 +2.66,10.0947 +2.67,9.9717 +2.68,9.9509 +2.69,9.9467 +2.70,10.0551 +2.71,10.0844 +2.72,10.0532 +2.73,10.0090 +2.74,10.2312 +2.75,10.0140 +2.76,10.4024 +2.77,10.3756 +2.78,9.9789 +2.79,10.0183 +2.80,9.9649 +2.81,10.0319 +2.82,9.9313 +2.83,10.0622 +2.84,9.9090 +2.85,9.7368 +2.86,10.0878 +2.87,9.8557 +2.88,10.1311 +2.89,10.1578 +2.90,10.2485 +2.91,9.9462 +2.92,9.7721 +2.93,10.2395 +2.94,10.2389 +2.95,10.2525 +2.96,10.1421 +2.97,10.3708 +2.98,10.1989 +2.99,10.1122 +3.00,9.7235 +3.01,10.0398 +3.02,9.9749 +3.03,9.7610 +3.04,10.0910 +3.05,9.6731 +3.06,9.9670 +3.07,10.1643 +3.08,9.9320 +3.09,9.7067 +3.10,9.8489 +3.11,9.8074 +3.12,10.1658 +3.13,9.8179 +3.14,9.8021 +3.15,9.9832 +3.16,9.9230 +3.17,10.3381 +3.18,9.9836 +3.19,10.1688 +3.20,9.8609 +3.21,10.0094 +3.22,9.9627 +3.23,9.6369 +3.24,9.8803 +3.25,10.1252 +3.26,10.1085 +3.27,10.0476 +3.28,9.8576 +3.29,10.2281 +3.30,10.2259 +3.31,10.2075 +3.32,10.1645 +3.33,9.9602 +3.34,10.0817 +3.35,10.1197 +3.36,10.1910 +3.37,10.0178 +3.38,10.2645 +3.39,10.0724 +3.40,10.3581 +3.41,10.1329 +3.42,9.8159 +3.43,9.8144 +3.44,9.9856 +3.45,10.3504 +3.46,9.9645 +3.47,10.1463 +3.48,9.7804 +3.49,10.1379 +3.50,10.1915 +3.51,9.9645 +3.52,10.0441 +3.53,9.9153 +3.54,10.0834 +3.55,9.7303 +3.56,9.8350 +3.57,9.8598 +3.58,10.0131 +3.59,9.9670 +3.60,9.8317 +3.61,10.0716 +3.62,9.8761 +3.63,10.0693 +3.64,10.1321 +3.65,9.9836 +3.66,9.8142 +3.67,10.0080 +3.68,9.8045 +3.69,10.0414 +3.70,10.0714 +3.71,10.0422 +3.72,9.9564 +3.73,10.0352 +3.74,10.0345 +3.75,9.9541 +3.76,10.0005 +3.77,9.9247 +3.78,10.0121 +3.79,10.0374 +3.80,9.9321 +3.81,10.4199 +3.82,10.1521 +3.83,9.9958 +3.84,9.9601 +3.85,9.8813 +3.86,10.0410 +3.87,10.1027 +3.88,9.8745 +3.89,10.1051 +3.90,10.2288 +3.91,10.2724 +3.92,10.0296 +3.93,10.1923 +3.94,10.2087 +3.95,10.3465 +3.96,10.2718 +3.97,10.0455 +3.98,9.9953 +3.99,9.8763 +4.00,10.1139 +4.01,10.0207 +4.02,10.2303 +4.03,10.0242 +4.04,9.8360 +4.05,9.9239 +4.06,10.0046 +4.07,10.1774 +4.08,9.9015 +4.09,9.7264 +4.10,9.7085 +4.11,9.9820 +4.12,9.8642 +4.13,9.8825 +4.14,9.9035 +4.15,10.1906 +4.16,9.9827 +4.17,10.0741 +4.18,10.0148 +4.19,10.1657 +4.20,9.8578 +4.21,9.9130 +4.22,10.1498 +4.23,9.8638 +4.24,10.2234 +4.25,10.0426 +4.26,9.9473 +4.27,9.8221 +4.28,9.9227 +4.29,10.1430 +4.30,9.9312 +4.31,10.0523 +4.32,10.1172 +4.33,10.1917 +4.34,10.0162 +4.35,10.1711 +4.36,10.1650 +4.37,10.1213 +4.38,10.3635 +4.39,9.9388 +4.40,10.1070 +4.41,10.0583 +4.42,9.9789 +4.43,10.0294 +4.44,9.7346 +4.45,9.9946 +4.46,9.7879 +4.47,10.0051 +4.48,10.0160 +4.49,10.1413 +4.50,10.3365 +4.51,9.9445 +4.52,10.1708 +4.53,10.0772 +4.54,9.7959 +4.55,10.0891 +4.56,10.0314 +4.57,10.1629 +4.58,10.0224 +4.59,9.9508 +4.60,10.0073 +4.61,10.1137 +4.62,9.8309 +4.63,10.0871 +4.64,9.8291 +4.65,10.1492 +4.66,10.1121 +4.67,9.9001 +4.68,9.9903 +4.69,10.1715 +4.70,10.0551 +4.71,9.8851 +4.72,9.8762 +4.73,10.0131 +4.74,9.7906 +4.75,9.9157 +4.76,10.1188 +4.77,9.7752 +4.78,9.9469 +4.79,9.9310 +4.80,9.9906 +4.81,9.9552 +4.82,10.2679 +4.83,10.2203 +4.84,9.8949 +4.85,10.0023 +4.86,10.2092 +4.87,9.8432 +4.88,10.0587 +4.89,10.2128 +4.90,10.2117 +4.91,10.0056 +4.92,9.8219 +4.93,10.0293 +4.94,9.9956 +4.95,10.0632 +4.96,10.0775 +4.97,9.9415 +4.98,9.8903 +4.99,9.5833 +5.00,9.8965 diff --git a/examples/data/example_uv_signal.csv b/examples/data/example_uv_signal.csv index b9296a7..a6715a9 100644 --- a/examples/data/example_uv_signal.csv +++ b/examples/data/example_uv_signal.csv @@ -1,102 +1,502 @@ -time_min,UV280_mAU -0.0,0.0 -0.1,1.223 -0.2,0.0 -0.3,0.0 -0.4,0.0 -0.5,0.563 -0.6,0.0 -0.7,0.0 -0.8,0.975 -0.9,0.0 -1.0,1.275 -1.1,0.0 -1.2,0.0 -1.3,1.648 -1.4,0.0 -1.5,0.0 -1.6,0.0 -1.7,1.694 -1.8,0.0 -1.9,0.611 -2.0,0.263 -2.1,0.0 -2.2,1.147 -2.3,0.444 -2.4,0.851 -2.5,1.719 -2.6,4.817 -2.7,41.538 -2.8,90.194 -2.9,103.065 -3.0,99.535 -3.1,102.161 -3.2,99.512 -3.3,100.319 -3.4,100.616 -3.5,100.187 -3.6,98.642 -3.7,56.355 -3.8,9.539 -3.9,0.887 -4.0,0.159 -4.1,1.187 -4.2,0.0 -4.3,0.0 -4.4,0.0 -4.5,0.75 -4.6,1.149 -4.7,0.264 -4.8,2.74 -4.9,1.518 -5.0,0.0 -5.1,0.0 -5.2,2.618 -5.3,0.0 -5.4,0.0 -5.5,3.148 -5.6,0.0 -5.7,0.213 -5.8,1.33 -5.9,0.0 -6.0,0.979 -6.1,0.0 -6.2,2.096 -6.3,0.874 -6.4,2.159 -6.5,0.0 -6.6,0.0 -6.7,0.0 -6.8,0.0 -6.9,0.0 -7.0,3.126 -7.1,0.0 -7.2,0.213 -7.3,0.0 -7.4,4.296 -7.5,1.126 -7.6,0.0 -7.7,0.0 -7.8,0.0 -7.9,0.0 -8.0,1.843 -8.1,0.179 -8.2,0.0 -8.3,0.0 -8.4,0.0 -8.5,0.0 -8.6,0.0 -8.7,1.161 -8.8,1.725 -8.9,1.447 -9.0,0.0 -9.1,0.0 -9.2,0.731 -9.3,0.399 -9.4,0.0 -9.5,0.0 -9.6,0.0 -9.7,0.0 -9.8,0.0 -9.9,0.227 -10.0,0.0 +time_min,UV280_mAU +0.00,0.0000 +0.01,1.6313 +0.02,0.0000 +0.03,0.0000 +0.04,0.0000 +0.05,0.7502 +0.06,0.0000 +0.07,0.0000 +0.08,1.2996 +0.09,0.0000 +0.10,1.6996 +0.11,0.0000 +0.12,0.0000 +0.13,2.1969 +0.14,0.0000 +0.15,0.0000 +0.16,0.0000 +0.17,2.2589 +0.18,0.0000 +0.19,0.8152 +0.20,0.3510 +0.21,0.0000 +0.22,1.5294 +0.23,0.5919 +0.24,1.1305 +0.25,2.1313 +0.26,2.3628 +0.27,0.0000 +0.28,0.0000 +0.29,4.9051 +0.30,0.0000 +0.31,2.8827 +0.32,0.0000 +0.33,0.4248 +0.34,0.8255 +0.35,0.4109 +0.36,2.2486 +0.37,0.0000 +0.38,1.3645 +0.39,0.3644 +0.40,0.1669 +0.41,1.5813 +0.42,0.0000 +0.43,0.0000 +0.44,0.0000 +0.45,0.9999 +0.46,1.5323 +0.47,0.3517 +0.48,3.6529 +0.49,2.0242 +0.50,0.0000 +0.51,0.0000 +0.52,3.4903 +0.53,0.0000 +0.54,0.0000 +0.55,4.1976 +0.56,0.0000 +0.57,0.2840 +0.58,1.7729 +0.59,0.0000 +0.60,1.3049 +0.61,0.0000 +0.62,2.7943 +0.63,1.1673 +0.64,2.8837 +0.65,0.0000 +0.66,0.0000 +0.67,0.0000 +0.68,0.0000 +0.69,2.3082 +0.70,10.7929 +0.71,16.5979 +0.72,41.2413 +0.73,75.9951 +0.74,123.0348 +0.75,154.0800 +0.76,175.3174 +0.77,188.2322 +0.78,195.1306 +0.79,196.1993 +0.80,201.7472 +0.81,199.9708 +0.82,199.6453 +0.83,196.6888 +0.84,198.0582 +0.85,197.7706 +0.86,197.0350 +0.87,201.5480 +0.88,202.2994 +0.89,201.9291 +0.90,197.7294 +0.91,197.4435 +0.92,200.9739 +0.93,200.5321 +0.94,199.0446 +0.95,197.9883 +0.96,197.8185 +0.97,199.8277 +0.98,198.1509 +0.99,200.3021 +1.00,197.4366 +1.01,199.3533 +1.02,204.8044 +1.03,201.3039 +1.04,203.3882 +1.05,202.4250 +1.06,198.3030 +1.07,200.4504 +1.08,200.0255 +1.09,200.9586 +1.10,197.0340 +1.11,198.0132 +1.12,200.1965 +1.13,198.3875 +1.14,200.3094 +1.15,198.2943 +1.16,200.0306 +1.17,196.0494 +1.18,201.6957 +1.19,197.5211 +1.20,199.8524 +1.21,201.7392 +1.22,200.5306 +1.23,195.9637 +1.24,200.2509 +1.25,200.7056 +1.26,198.3428 +1.27,198.6474 +1.28,199.4045 +1.29,198.4945 +1.30,198.9518 +1.31,200.3722 +1.32,201.1808 +1.33,199.3462 +1.34,199.6201 +1.35,199.7909 +1.36,203.5900 +1.37,198.8313 +1.38,199.7292 +1.39,202.0180 +1.40,202.2118 +1.41,203.6302 +1.42,204.3561 +1.43,201.7740 +1.44,198.6620 +1.45,201.9815 +1.46,201.1974 +1.47,199.2268 +1.48,199.6832 +1.49,199.8991 +1.50,203.6116 +1.51,199.6334 +1.52,203.4821 +1.53,201.7162 +1.54,202.1011 +1.55,196.8492 +1.56,198.6714 +1.57,199.1552 +1.58,199.7993 +1.59,201.5428 +1.60,201.7646 +1.61,198.8270 +1.62,201.8582 +1.63,199.8255 +1.64,203.0512 +1.65,199.4043 +1.66,201.5648 +1.67,197.6788 +1.68,204.3537 +1.69,197.1840 +1.70,196.8764 +1.71,199.8642 +1.72,201.1687 +1.73,196.8737 +1.74,200.7945 +1.75,201.5905 +1.76,200.2391 +1.77,198.9913 +1.78,198.1396 +1.79,198.2647 +1.80,200.4555 +1.81,198.1770 +1.82,201.2743 +1.83,197.3932 +1.84,198.4934 +1.85,197.9082 +1.86,201.3134 +1.87,201.0958 +1.88,198.9946 +1.89,199.6238 +1.90,197.0458 +1.91,195.5878 +1.92,199.1647 +1.93,198.0854 +1.94,202.3929 +1.95,200.1366 +1.96,199.4625 +1.97,203.2172 +1.98,200.1634 +1.99,202.9093 +2.00,198.5413 +2.01,203.2885 +2.02,200.0554 +2.03,199.0232 +2.04,202.0227 +2.05,201.0295 +2.06,199.0958 +2.07,195.8574 +2.08,196.9206 +2.09,202.1454 +2.10,200.6710 +2.11,200.8646 +2.12,202.0652 +2.13,199.6702 +2.14,200.0600 +2.15,199.8178 +2.16,200.6599 +2.17,198.1633 +2.18,204.6263 +2.19,201.5322 +2.20,196.5904 +2.21,199.3209 +2.22,196.2356 +2.23,200.3097 +2.24,201.1306 +2.25,199.5694 +2.26,199.3963 +2.27,202.8765 +2.28,200.2107 +2.29,200.3009 +2.30,198.8010 +2.31,200.7640 +2.32,200.8553 +2.33,198.1556 +2.34,200.4726 +2.35,199.5190 +2.36,199.6852 +2.37,199.5279 +2.38,199.9058 +2.39,202.1355 +2.40,202.5800 +2.41,198.7378 +2.42,202.1077 +2.43,201.0920 +2.44,202.5082 +2.45,198.0601 +2.46,203.1204 +2.47,199.6277 +2.48,203.2232 +2.49,202.3538 +2.50,196.4097 +2.51,196.5046 +2.52,202.0959 +2.53,203.1006 +2.54,196.3914 +2.55,197.8660 +2.56,202.0340 +2.57,200.4131 +2.58,198.8650 +2.59,199.8340 +2.60,200.5544 +2.61,199.6271 +2.62,203.2620 +2.63,200.4165 +2.64,198.2230 +2.65,199.6914 +2.66,200.1663 +2.67,200.6063 +2.68,202.3000 +2.69,200.6645 +2.70,199.0258 +2.71,201.3070 +2.72,203.6803 +2.73,202.2074 +2.74,199.8217 +2.75,199.6941 +2.76,200.3393 +2.77,201.2556 +2.78,198.0493 +2.79,197.3206 +2.80,204.0743 +2.81,201.0714 +2.82,201.1529 +2.83,197.0594 +2.84,195.7432 +2.85,197.8041 +2.86,198.0730 +2.87,197.4716 +2.88,196.5959 +2.89,202.0145 +2.90,198.8672 +2.91,198.4488 +2.92,201.9274 +2.93,202.4408 +2.94,202.6702 +2.95,202.1566 +2.96,199.8767 +2.97,198.6859 +2.98,195.5680 +2.99,199.7525 +3.00,199.0648 +3.01,199.4294 +3.02,201.3253 +3.03,196.7759 +3.04,200.0287 +3.05,201.6852 +3.06,197.8755 +3.07,201.1126 +3.08,198.2445 +3.09,204.3639 +3.10,199.7486 +3.11,198.4578 +3.12,202.4381 +3.13,200.4629 +3.14,197.5346 +3.15,197.9544 +3.16,201.5447 +3.17,197.5684 +3.18,201.7371 +3.19,203.4379 +3.20,199.2419 +3.21,201.3256 +3.22,202.2501 +3.23,200.8951 +3.24,202.3770 +3.25,197.7783 +3.26,197.9950 +3.27,200.1986 +3.28,203.2634 +3.29,201.1435 +3.30,199.5429 +3.31,201.8689 +3.32,202.7938 +3.33,199.4053 +3.34,201.9902 +3.35,200.2672 +3.36,200.4989 +3.37,198.9636 +3.38,201.5322 +3.39,198.4622 +3.40,200.9978 +3.41,201.1078 +3.42,197.6573 +3.43,199.0227 +3.44,200.4261 +3.45,199.9235 +3.46,196.4174 +3.47,201.9745 +3.48,199.2966 +3.49,196.6098 +3.50,199.7866 +3.51,197.7990 +3.52,201.3208 +3.53,193.8119 +3.54,204.5183 +3.55,197.0947 +3.56,198.4285 +3.57,198.2577 +3.58,201.8318 +3.59,196.0718 +3.60,202.1257 +3.61,206.1437 +3.62,198.5436 +3.63,199.7203 +3.64,202.6012 +3.65,201.8907 +3.66,201.9526 +3.67,200.4769 +3.68,200.8730 +3.69,202.8160 +3.70,199.7659 +3.71,200.9872 +3.72,197.0329 +3.73,198.4505 +3.74,201.4607 +3.75,198.3487 +3.76,198.2717 +3.77,198.3582 +3.78,195.0879 +3.79,198.2075 +3.80,201.0940 +3.81,201.9210 +3.82,201.5902 +3.83,201.5882 +3.84,194.8571 +3.85,203.4918 +3.86,199.4673 +3.87,198.4177 +3.88,197.3475 +3.89,199.7982 +3.90,196.9765 +3.91,196.7081 +3.92,196.3258 +3.93,198.1213 +3.94,201.2745 +3.95,200.4418 +3.96,203.4699 +3.97,202.6205 +3.98,199.8048 +3.99,200.5069 +4.00,201.0357 +4.01,200.4145 +4.02,203.1007 +4.03,200.6364 +4.04,200.4625 +4.05,199.2819 +4.06,197.2186 +4.07,203.1825 +4.08,204.1434 +4.09,202.8286 +4.10,202.4382 +4.11,202.6150 +4.12,197.6123 +4.13,201.9985 +4.14,199.1937 +4.15,202.6856 +4.16,199.2302 +4.17,198.0281 +4.18,200.5413 +4.19,202.3055 +4.20,199.2061 +4.21,199.1793 +4.22,199.3503 +4.23,202.4096 +4.24,200.4872 +4.25,203.4232 +4.26,198.6766 +4.27,197.6019 +4.28,199.9856 +4.29,201.4564 +4.30,199.6930 +4.31,205.3241 +4.32,200.7852 +4.33,198.5379 +4.34,199.6771 +4.35,202.2817 +4.36,197.2412 +4.37,202.2675 +4.38,199.0158 +4.39,203.6755 +4.40,200.4133 +4.41,199.3465 +4.42,200.1173 +4.43,200.6784 +4.44,196.2816 +4.45,198.5812 +4.46,201.0917 +4.47,198.0385 +4.48,201.7777 +4.49,200.4746 +4.50,197.9206 +4.51,197.3988 +4.52,199.4957 +4.53,197.4630 +4.54,199.3810 +4.55,199.5877 +4.56,199.0451 +4.57,195.6281 +4.58,200.2001 +4.59,197.4088 +4.60,199.6137 +4.61,199.1090 +4.62,200.7038 +4.63,198.0810 +4.64,199.1049 +4.65,201.0298 +4.66,203.5931 +4.67,202.7532 +4.68,203.3890 +4.69,197.1015 +4.70,197.8598 +4.71,200.0976 +4.72,201.0958 +4.73,199.6915 +4.74,197.2047 +4.75,199.4923 +4.76,199.0175 +4.77,196.8038 +4.78,199.1159 +4.79,197.6681 +4.80,199.5970 +4.81,198.7477 +4.82,196.2610 +4.83,199.7928 +4.84,201.8481 +4.85,199.1085 +4.86,202.0164 +4.87,202.1173 +4.88,198.3287 +4.89,200.8089 +4.90,201.9488 +4.91,199.8287 +4.92,203.1617 +4.93,199.3880 +4.94,199.1795 +4.95,201.1832 +4.96,201.6154 +4.97,202.5181 +4.98,201.0511 +4.99,200.2747 +5.00,199.2072 diff --git a/examples/generate_characterization_chain_data.py b/examples/generate_characterization_chain_data.py new file mode 100644 index 0000000..ae23338 --- /dev/null +++ b/examples/generate_characterization_chain_data.py @@ -0,0 +1,559 @@ +"""Generate a synthetic two-step characterization chain, shaped like Äkta/Unicorn exports. + +Step 1 ("Extra-column volume", `CharacterizeTubing`) fits `tubing_pre_column`'s length and +axial dispersion from two replicate pulse-tracer runs with the column bypassed. Step 2 +("Column packing", `CharacterizeBed`) fits the column's bed porosity, particle +porosity and axial dispersion from two small-tracer and two large-tracer pulse runs with +the column in line, carrying step 1's fitted tubing forward. +Every "true" value is invented for this synthetic instrument, and every measured +CSV is a real CADET-Process simulation reshaped into Äkta form (volume axis, per-channel +sampling, detector units, fixed-seed noise) rather than anything recorded by an instrument. + + python3 examples/generate_characterization_chain_data.py + +Deterministic (fixed seeds): re-running produces byte-identical files. +""" +from __future__ import annotations + +import csv +import json +import re +import warnings +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Callable, Optional, Sequence + +import numpy as np + +warnings.filterwarnings("ignore") + +from cadetgui.characterization.guide import ( # noqa: E402 + CHAIN_BY_ID, + LARGE_TRACER, + SMALL_TRACER, + SYSTEM_TRACER, +) +from cadetgui.characterization.parameter_store import ( # noqa: E402 + ParameterSpec, + ParameterStore, + Provenance, + apply_store, +) +from cadetgui.io.configuration_store import ( # noqa: E402 + ConfigurationState, + InstrumentState, +) +from cadetgui.process_builder import build_process # noqa: E402 +from CADETProcess.simulator import Cadet # noqa: E402 + +OUT_DIR = Path(__file__).parent / "data" / "characterization_akta" +STEP1_NAME = CHAIN_BY_ID["system_periphery"].title +STEP2_NAME = CHAIN_BY_ID["column_packing"].title + +# %% Physical constants -- all invented for this synthetic instrument. + +FLOW_RATE = 8.3e-9 # m^3/s, shared by every run +LOOP_VOLUME = 20e-9 # m^3, sample loop +LOOP_DIAMETER = 0.75e-3 # m +C_SAMPLE = 50.0 # mol/m^3, tracer concentration in the sample loop +TARGET_AREA = C_SAMPLE * LOOP_VOLUME # mol, injected amount (flow-weighted integral) + +TUBING_PRE_COLUMN_DIAMETER = 0.8e-3 # m, known (not fitted) +TUBING_PRE_COLUMN_LENGTH = 0.42 # m, step-1 truth +TUBING_PRE_COLUMN_AXIAL_DISPERSION = 4.5e-7 # m^2/s, step-1 truth +CYCLE_TIME_TUBING = 150.0 # s + +COLUMN_DIAMETER = 5e-3 # m, known +COLUMN_LENGTH = 0.02 # m, known +PARTICLE_RADIUS = 3.0e-5 # m, known +BED_POROSITY = 0.36 # step-2 truth +PARTICLE_POROSITY = 0.68 # step-2 truth +COLUMN_AXIAL_DISPERSION = 5.5e-8 # m^2/s, step-2 truth +SMALL_TRACER_FILM_DIFFUSION = 8e-5 # m/s, known +LARGE_TRACER_FILM_DIFFUSION = 0.0 # m/s, known -- pore-excluded (200 nm vs. a 60 µm bead) + +TUBING_DETECTORS_DIAMETER = 0.5e-3 # m, known -- small detector-to-detector volume +TUBING_DETECTORS_LENGTH = 0.05 # m, known +CYCLE_TIME_BED = 300.0 # s + +# The measured export starts this many seconds before the aligned (t=0) injection: the +# Pulse Injection template has no pre-injection equilibration phase to simulate, so this +# dead time exists only in the "measured" clock, recovered by the injection marker. +PRE_OFFSET_S = 60.0 + +PATHS = { + "tubing_pre_column_diameter": "flow_sheet.tubing_pre_column.diameter", + "tubing_pre_column_length": "flow_sheet.tubing_pre_column.length", + "tubing_pre_column_axial_dispersion": "flow_sheet.tubing_pre_column.axial_dispersion", + "tubing_detectors_diameter": "flow_sheet.tubing_detectors.diameter", + "tubing_detectors_length": "flow_sheet.tubing_detectors.length", + "tubing_detectors_axial_dispersion": "flow_sheet.tubing_detectors.axial_dispersion", + "column_particle_radius": "flow_sheet.column.particle_radius", + "column_film_diffusion": "flow_sheet.column.film_diffusion", + "column_bed_porosity": "flow_sheet.column.bed_porosity", + "column_particle_porosity": "flow_sheet.column.particle_porosity", + "column_axial_dispersion": "flow_sheet.column.axial_dispersion", +} +_SPECIES_INDEXED = {"tubing_pre_column_axial_dispersion", "tubing_detectors_axial_dispersion", + "column_film_diffusion", "column_axial_dispersion"} +SPECS: dict[str, ParameterSpec] = { + path: ParameterSpec(path, species_indexed=key in _SPECIES_INDEXED) + for key, path in PATHS.items() +} + +STEP1_TRUTH = { + PATHS["tubing_pre_column_length"]: TUBING_PRE_COLUMN_LENGTH, + PATHS["tubing_pre_column_axial_dispersion"]: TUBING_PRE_COLUMN_AXIAL_DISPERSION, +} +STEP2_TRUTH = { + PATHS["column_bed_porosity"]: BED_POROSITY, + PATHS["column_particle_porosity"]: PARTICLE_POROSITY, + PATHS["column_axial_dispersion"]: COLUMN_AXIAL_DISPERSION, +} + + +# %% Parameter store chain (initial -> step 1 -> step 2) + + +def initial_store() -> ParameterStore: + """Known hardware values, fitted by neither step.""" + return ParameterStore(specs=SPECS).updated( + { + PATHS["tubing_pre_column_diameter"]: TUBING_PRE_COLUMN_DIAMETER, + PATHS["tubing_detectors_diameter"]: TUBING_DETECTORS_DIAMETER, + PATHS["tubing_detectors_length"]: TUBING_DETECTORS_LENGTH, + PATHS["column_particle_radius"]: PARTICLE_RADIUS, + PATHS["column_film_diffusion"]: { + "SmallTracer": SMALL_TRACER_FILM_DIFFUSION, + "LargeTracer": LARGE_TRACER_FILM_DIFFUSION, + }, + }, + Provenance(step="initial", note="known hardware, not determined by either step"), + ) + + +def step1_store(prior: ParameterStore) -> ParameterStore: + """Prior plus the extra-column volume truth, probed by `SystemTracer`.""" + return prior.updated( + { + PATHS["tubing_pre_column_length"]: TUBING_PRE_COLUMN_LENGTH, + PATHS["tubing_pre_column_axial_dispersion"]: { + "SystemTracer": TUBING_PRE_COLUMN_AXIAL_DISPERSION, + }, + }, + Provenance( + step=STEP1_NAME, + probe="SystemTracer", + source="System pulse 1 (UV), System pulse 2 (UV)", + ), + ) + + +def step2_store(step1: ParameterStore) -> ParameterStore: + """Step 1's store, its periphery re-keyed to the C-run species, plus the bed truth. + + Mirrors `CharacterizeColumnVoidVolume`'s own chain handoff: the periphery dispersion + fitted with one probe is carried to the others unchanged (a modelling assumption, not + a second measurement), and `tubing_detectors` reuses it at the same flow rate. + """ + dispersion = step1.value(PATHS["tubing_pre_column_axial_dispersion"], species="SystemTracer") + carried = step1.updated( + { + PATHS["tubing_pre_column_axial_dispersion"]: { + "SmallTracer": dispersion, "LargeTracer": dispersion, + }, + PATHS["tubing_detectors_axial_dispersion"]: {"SmallTracer": dispersion}, + }, + Provenance( + step=STEP1_NAME, + probe="SystemTracer", + source="System pulse 1 (UV), System pulse 2 (UV)", + note="reused for the C-run species and the detector tubing at the same flow rate", + ), + ) + return carried.updated( + { + PATHS["column_bed_porosity"]: BED_POROSITY, + PATHS["column_particle_porosity"]: PARTICLE_POROSITY, + PATHS["column_axial_dispersion"]: { + "SmallTracer": COLUMN_AXIAL_DISPERSION, "LargeTracer": COLUMN_AXIAL_DISPERSION, + }, + }, + Provenance( + step=STEP2_NAME, + probe="SmallTracer, LargeTracer", + source=( + "Small tracer pulse 1 (conductivity), Small tracer pulse 2 (conductivity), " + "Large tracer pulse 1 (UV), Large tracer pulse 2 (UV)" + ), + ), + ) + + +# %% Recipes + + +def _instrument(bypass: Sequence[str]) -> InstrumentState: + return InstrumentState( + include_sample_loop=True, + sample_loop_volume=LOOP_VOLUME, + sample_loop_diameter_auto=False, + sample_loop_diameter=LOOP_DIAMETER, + bypass_units=list(bypass), + ) + + +def tubing_recipe(species: str) -> ConfigurationState: + """System pulses: pulse tracer with the column and every other periphery segment bypassed.""" + return ConfigurationState( + components=[species], + column_key="Lumped Rate Model With Pores (LRMP)", + binding_key="None", + template_key="Pulse Injection", + instrument=_instrument( + ["tubing_pre_injection", "column", "tubing_post_column", "tubing_detectors"] + ), + model_values={ + "c_buffer_a": [0.0], "c_sample": [C_SAMPLE], + "cycle_time": CYCLE_TIME_TUBING, "flow_rate": FLOW_RATE, + }, + ) + + +def bed_recipe(species: str, tracer: str) -> ConfigurationState: + """C-*: pulse tracer with the column in line; `tracer` is `"small"` or `"large"`. + + Column values are placeholders (`apply_store` overwrites bed/particle porosity, axial + dispersion and film diffusion); geometry (diameter, length) is the recipe's own, not + the store's, since which column is in the flow path is part of a run's identity, not + a fitted or looked-up value. + """ + bypass = ["tubing_pre_injection", "tubing_post_column"] + if tracer == "large": + bypass.append("tubing_detectors") + return ConfigurationState( + components=[species], + column_key="Lumped Rate Model With Pores (LRMP)", + binding_key="None", + template_key="Pulse Injection", + instrument=_instrument(bypass), + column_values={ + "diameter": COLUMN_DIAMETER, "length": COLUMN_LENGTH, + "particle_radius": 1.0e-5, "bed_porosity": 0.5, "particle_porosity": 0.5, + "axial_dispersion": 1e-8, "film_diffusion": 1e-4, + }, + model_values={ + "c_buffer_a": [0.0], "c_sample": [C_SAMPLE], + "cycle_time": CYCLE_TIME_BED, "flow_rate": FLOW_RATE, + }, + ) + + +# %% Simulation + + +def simulate_clean( + recipe: ConfigurationState, store: ParameterStore, solution_path: str, +) -> tuple[np.ndarray, np.ndarray]: + """Build `recipe`, apply `store`, simulate, and return `(time_s, concentration)`. + + `solution_path` is `".outlet"`; the process carries exactly one species, so + only its own (sole) component is returned. Discretization is left at its default + (not coarsened): `cadetgui.characterization.comparison.Comparison.build_process` + simulates the same recipe at the default resolution, and the two must agree for a + fit against this data to recover the truth rather than a numerical-dispersion + artifact. + """ + process = build_process(recipe) + apply_store(process, store) + results = Cadet().simulate(process) + unit, _, port = solution_path.partition(".") + solution = getattr(getattr(results.solution, unit), port) + return np.asarray(solution.time), np.asarray(solution.solution[:, 0]) + + +# %% Äkta/Unicorn shaping + + +def _time_grid(total_s: float, dt: float) -> np.ndarray: + return np.arange(int(round(total_s / dt)) + 1) * dt + + +def _shape_channel( + t_meas: np.ndarray, + true_time: np.ndarray, + true_signal: np.ndarray, + *, + baseline: float, + response: float, + noise_sigma: float, + seed: int, + drift: Optional[Callable[[np.ndarray], np.ndarray]] = None, +) -> np.ndarray: + """Detector trace on the measured clock: baseline + response * concentration + noise. + + `t_meas` is seconds on the measured clock (injection at `PRE_OFFSET_S`); concentration + outside the simulated domain (before injection, or after the cycle ends) is zero. + """ + t_sim = t_meas - PRE_OFFSET_S + concentration = np.interp(t_sim, true_time, true_signal, left=0.0, right=0.0) + signal = baseline + response * concentration + if drift is not None: + signal = signal + drift(t_meas) + rng = np.random.default_rng(seed) + return signal + rng.normal(0.0, noise_sigma, size=signal.shape) + + +def _to_ml(time_s: "float | np.ndarray") -> np.ndarray: + return np.asarray(time_s) * FLOW_RATE * 1e6 + + +def _cell(values: Sequence[float], i: int, fmt: str = "{:.6f}") -> str: + return fmt.format(values[i]) if i < len(values) else "" + + +def write_akta_csv( + path: Path, + uv: tuple[np.ndarray, np.ndarray], + cond: tuple[np.ndarray, np.ndarray], + markers: list[tuple[float, str]], +) -> None: + """Write a 3-header-row Äkta/Unicorn-style export: UV 1_280, Cond, Run Log.""" + uv_v, uv_s = _to_ml(uv[0]), uv[1] + cond_v, cond_s = _to_ml(cond[0]), cond[1] + log_v = [m[0] for m in markers] + log_t = [m[1] for m in markers] + n_rows = max(len(uv_v), len(cond_v), len(log_v)) + + rows = [ + ["Chromatogram", "", "", "", "", ""], + ["UV 1_280", "", "Cond", "", "Run Log", ""], + ["ml", "mAU", "ml", "mS/cm", "ml", ""], + ] + for i in range(n_rows): + rows.append([ + _cell(uv_v, i), _cell(uv_s, i), + _cell(cond_v, i), _cell(cond_s, i), + _cell(log_v, i), (log_t[i] if i < len(log_t) else ""), + ]) + + with open(path, "w", newline="") as handle: + csv.writer(handle).writerows(rows) + + +# %% Detector calibration -- arbitrary response factors and baselines. + +UV_BASELINE, UV_RESPONSE, UV_NOISE = 4.0, 6.0, 0.5 # mAU, mAU / (mol/m^3), mAU +COND_BASELINE, COND_RESPONSE, COND_NOISE = 42.0, 0.15, 0.004 # mS/cm, mS/cm / (mol/m^3), mS/cm + +UV_DT, COND_DT = 0.5, 0.2 # s -- channels sampled at different rates + + +# `cadetgui.characterization.guide.EXPERIMENT_TYPES` ids, per `RunSpec.tracer`. +EXPERIMENT_TYPE_IDS = { + "system": "system_pulse", + "small": "column_pulse_small_tracer", + "large": "column_pulse_large_tracer", +} + + +@dataclass(frozen=True) +class RunSpec: + """One measured run: its recipe, truth store, detector channel and shaping seed.""" + + name: str + species: str + tracer: str # "system", "small", "large" + recipe: ConfigurationState + store: ParameterStore + solution_path: str + channel: str # informative channel: "UV 1_280" or "Cond" + cycle_time: float + seed: int + drift: bool = False + + +def _drift(seed: int) -> Callable[[np.ndarray], np.ndarray]: + """Pre-injection valve-transition artifact: a sharp, narrow dip early in the export. + + Narrow enough that its own contribution to the whole-trace area-normalization + integral is small next to the tracer peak's, but deep enough to dominate the + default (no explicit window) whole-trace baseline threshold, which picks the + lowest ~2.5% of points: this is what makes the default baseline wrong and an + explicit post-peak `baseline_window` necessary. + """ + def _f(t_meas: np.ndarray) -> np.ndarray: + return -2.0 * np.exp(-0.5 * ((t_meas - 5.0) / 0.4) ** 2) + return _f + + +def generate_run(run: RunSpec) -> dict: + """Simulate, shape and write one run's CSV; return its comparison dict.""" + true_time, true_signal = simulate_clean(run.recipe, run.store, run.solution_path) + + total_s = PRE_OFFSET_S + run.cycle_time + t_uv = _time_grid(total_s, UV_DT) + t_cond = _time_grid(total_s, COND_DT) + + uv_is_informative = run.channel == "UV 1_280" + uv_signal = _shape_channel( + t_uv, true_time, true_signal, + baseline=UV_BASELINE, response=UV_RESPONSE if uv_is_informative else 0.0, + noise_sigma=UV_NOISE, seed=run.seed, + drift=_drift(run.seed) if (run.drift and uv_is_informative) else None, + ) + cond_signal = _shape_channel( + t_cond, true_time, true_signal, + baseline=COND_BASELINE, response=COND_RESPONSE if not uv_is_informative else 0.0, + noise_sigma=COND_NOISE, seed=run.seed + 1, + drift=_drift(run.seed) if (run.drift and not uv_is_informative) else None, + ) + + markers = [ + (0.0, "Phase Equilibration"), + (float(_to_ml(PRE_OFFSET_S)), "Phase Elution"), + (float(_to_ml(0.85 * total_s)), "Phase Wash"), + ] + write_akta_csv( + OUT_DIR / f"{_file_stem(run.name)}.csv", (t_uv, uv_signal), (t_cond, cond_signal), markers + ) + + baseline_window = None + if run.drift: + # Wide, stable post-peak window: far enough past the peak to be flat, far + # enough from the run end to hold many points (a narrow window lets a linear + # baseline fit's slope noise blow up once extrapolated across the whole run). + window_s = (PRE_OFFSET_S + 0.35 * run.cycle_time, PRE_OFFSET_S + 0.95 * run.cycle_time) + baseline_window = (float(_to_ml(window_s[0])), float(_to_ml(window_s[1]))) + + comparison = { + "name": run.name, + "data_file": f"{_file_stem(run.name)}.csv", + "channel": run.channel, + "flow_rate": None, + "recipe": asdict(run.recipe), + "overrides": {}, + "injection_marker": "Phase Elution", + "measured_injection": None, + "baseline_window": list(baseline_window) if baseline_window is not None else None, + "normalization": {"kind": "area", "target_area": TARGET_AREA}, + "solution_path": run.solution_path, + "components": [run.species], + "metric": "NRMSE", + "window": [None, None], + "probe": run.species, + "experiment_type": EXPERIMENT_TYPE_IDS[run.tracer], + } + return comparison + + +# %% Manifest assembly + + +def _file_stem(name: str) -> str: + return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") + + +def build_runs() -> list[RunSpec]: + """Return the six runs: two system pulses (step 1), small and large tracer pulses (step 2).""" + s1 = step1_store(initial_store()) + s2 = step2_store(s1) + + return [ + RunSpec( + "System pulse 1 (UV)", "SystemTracer", "system", tubing_recipe("SystemTracer"), s1, + "tubing_pre_column.outlet", "UV 1_280", CYCLE_TIME_TUBING, seed=20260928_01, + ), + RunSpec( + "System pulse 2 (UV)", "SystemTracer", "system", tubing_recipe("SystemTracer"), s1, + "tubing_pre_column.outlet", "UV 1_280", CYCLE_TIME_TUBING, seed=20260928_02, + ), + RunSpec( + "Small tracer pulse 1 (conductivity)", "SmallTracer", "small", + bed_recipe("SmallTracer", "small"), s2, + "tubing_detectors.outlet", "Cond", CYCLE_TIME_BED, seed=20260928_03, drift=True, + ), + RunSpec( + "Small tracer pulse 2 (conductivity)", "SmallTracer", "small", + bed_recipe("SmallTracer", "small"), s2, + "tubing_detectors.outlet", "Cond", CYCLE_TIME_BED, seed=20260928_04, + ), + RunSpec( + "Large tracer pulse 1 (UV)", "LargeTracer", "large", + bed_recipe("LargeTracer", "large"), s2, + "column.outlet", "UV 1_280", CYCLE_TIME_BED, seed=20260928_05, + ), + RunSpec( + "Large tracer pulse 2 (UV)", "LargeTracer", "large", + bed_recipe("LargeTracer", "large"), s2, + "column.outlet", "UV 1_280", CYCLE_TIME_BED, seed=20260928_06, + ), + ] + + +def build_manifest() -> dict: + """Simulate and write every run's CSV, and return the manifest dict written beside them.""" + OUT_DIR.mkdir(parents=True, exist_ok=True) + runs = build_runs() + comparisons = [generate_run(run) for run in runs] + + return { + "description": ( + "Synthetic two-step characterization chain (extra-column volume, then column " + "packing), shaped like Äkta/Unicorn exports. Every number is invented " + "for this synthetic instrument." + ), + "initial_store": initial_store().to_dict(), + "steps": [ + { + "name": STEP1_NAME, + "stage": "tubing", + "options": {"tubing": "tubing_pre_column"}, + "comparisons": ["System pulse 1 (UV)", "System pulse 2 (UV)"], + "bounds": { + "tubing_pre_column_length": [0.1, 1.0], + "tubing_pre_column_axial_dispersion": [1e-8, 1e-5], + }, + "frozen": [], + "truth": STEP1_TRUTH, + }, + { + "name": STEP2_NAME, + "stage": "bed", + "options": {"include_particle_porosity": True}, + "comparisons": [ + "Small tracer pulse 1 (conductivity)", + "Small tracer pulse 2 (conductivity)", + "Large tracer pulse 1 (UV)", + "Large tracer pulse 2 (UV)", + ], + "bounds": { + "bed_porosity": [0.25, 0.5], + "particle_porosity": [0.55, 0.85], + "axial_dispersion": [1e-9, 1e-6], + }, + "frozen": [], + "truth": STEP2_TRUTH, + }, + ], + "comparisons": comparisons, + "components": [ + {"name": "SystemTracer", "role": SYSTEM_TRACER}, + {"name": "SmallTracer", "role": SMALL_TRACER}, + {"name": "LargeTracer", "role": LARGE_TRACER}, + ], + } + + +def main() -> None: + """Write every CSV and `manifest.json` to `OUT_DIR`.""" + manifest = build_manifest() + path = OUT_DIR / "manifest.json" + with open(path, "w") as handle: + json.dump(manifest, handle, indent=2) + handle.write("\n") + print(f"wrote {path} and {len(manifest['comparisons'])} CSVs to {OUT_DIR}") + + +if __name__ == "__main__": + main() diff --git a/examples/generate_example_data.py b/examples/generate_example_data.py new file mode 100644 index 0000000..dfad5dc --- /dev/null +++ b/examples/generate_example_data.py @@ -0,0 +1,93 @@ +"""Generate the synthetic parameter-estimation examples `examples/data/example_*.csv`. + +Every file is a CADET-Process simulation of the default configuration of +`ConfigurationWidget(instrument=InstrumentWidget())` (lab scale, Breakthrough template), +or of a named deviation from it, sampled at the column outlet with fixed-seed noise. + + python examples/generate_example_data.py + +Deterministic (fixed seeds): re-running produces byte-identical files. +""" +from __future__ import annotations + +import dataclasses +import warnings +from pathlib import Path + +import numpy as np + +warnings.filterwarnings("ignore") + +from cadetgui.io.configuration_store import ConfigurationState # noqa: E402 +from cadetgui.process_builder import build_process # noqa: E402 +from cadetgui.simulation import run_process # noqa: E402 +from cadetgui.widgets.composite import ( # noqa: E402 + ConfigurationWidget, + InstrumentWidget, +) + +OUT_DIR = Path(__file__).parent / "data" +EXTINCTION_COEFFICIENT = 100.0 # L/(mol·cm) +PATH_LENGTH = 0.2 # cm + + +def default_state() -> ConfigurationState: + """Return the configuration a fresh `ConfigurationWidget` with an instrument starts with.""" + return ConfigurationWidget(instrument=InstrumentWidget()).snapshot() + + +def with_values( + state: ConfigurationState, column: dict | None = None, binding: dict | None = None +) -> ConfigurationState: + """Return `state` with some column and binding values replaced.""" + return dataclasses.replace( + state, + column_values={**state.column_values, **(column or {})}, + binding_values={**state.binding_values, **(binding or {})}, + ) + + +def outlet_signal(state: ConfigurationState, time_min: np.ndarray) -> np.ndarray: + """Simulate `state` and return the outlet's total concentration at `time_min`.""" + solution = run_process(build_process(state)).solution["outlet"]["inlet"] + total = np.asarray(solution.solution).reshape(len(solution.time), -1).sum(axis=1) + return np.interp(time_min * 60.0, np.asarray(solution.time), total) + + +def noisy(signal: np.ndarray, scale: float, seed: int) -> np.ndarray: + """Add Gaussian noise of `scale` times the signal maximum, clipped at 0.""" + noise = np.random.default_rng(seed).normal(0.0, scale * signal.max(), signal.shape) + return np.clip(signal + noise, 0.0, None) + + +def write_csv(name: str, header: str, time_min: np.ndarray, signal: np.ndarray) -> None: + """Write a two-column CSV with the time in minutes.""" + lines = [header] + [f"{t:.2f},{s:.4f}" for t, s in zip(time_min, signal)] + (OUT_DIR / name).write_text("\n".join(lines) + "\n") + + +def main() -> None: + """Write the three example files.""" + base = default_state() + time_min = np.round(np.arange(0.0, 5.0 + 1e-9, 0.01), 2) + long_time_min = np.round(np.arange(0.0, 10.0 + 1e-9, 0.01), 2) + + uv = outlet_signal(base, time_min) * EXTINCTION_COEFFICIENT * PATH_LENGTH + write_csv("example_uv_signal.csv", "time_min,UV280_mAU", time_min, + noisy(uv, 0.01, 20260911)) + + raw = outlet_signal(with_values(base, column={"total_porosity": 0.60}), time_min) + write_csv("example_raw_signal.csv", "time_min,signal", time_min, + noisy(raw, 0.015, 20260916)) + + binding = with_values( + base, + column={"total_porosity": 0.65, "axial_dispersion": 2e-8}, + binding={"adsorption_rate": [0.3], "desorption_rate": [0.05]}, + ) + write_csv("example_binding_signal.csv", "time_min,signal", long_time_min, + noisy(outlet_signal(binding, long_time_min), 0.01, 20260917)) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 2aab486..5ca8f54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ dev = [ ] [tool.ruff] -src = ["CADET-GUI", "tests"] +src = ["cadetgui", "tests"] exclude = [] line-length = 88 fix = true diff --git a/tests/test_characterization_chain_data.py b/tests/test_characterization_chain_data.py new file mode 100644 index 0000000..4dd48a3 --- /dev/null +++ b/tests/test_characterization_chain_data.py @@ -0,0 +1,224 @@ +"""Checks for the synthetic Äkta-style characterization chain example data. + +`examples/generate_characterization_chain_data.py` is loaded by path (it lives under +`examples/`, not the `cadetgui` package) and exercised directly against +`cadetgui.characterization.comparison.Comparison` and +`cadetgui.characterization.runner.StepSetup`, the same way a real consumer of +`examples/data/characterization_akta/` would use it. +""" +from __future__ import annotations + +import filecmp +import importlib.util +import json +import sys +import warnings +from pathlib import Path + +import pytest + +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=UserWarning) + +from cadetgui.characterization.comparison import ( # noqa: E402 + Comparison, + recipe_from_dict, +) +from cadetgui.characterization.runner import StepSetup, build # noqa: E402 +from cadetgui.io.experimental_data import ( # noqa: E402 + is_akta_format, + read_experimental_csv, +) +from cadetgui.process_builder import build_process # noqa: E402 + +EXAMPLES_DIR = Path(__file__).parents[1] / "examples" +DATA_DIR = EXAMPLES_DIR / "data" / "characterization_akta" +GENERATOR_PATH = EXAMPLES_DIR / "generate_characterization_chain_data.py" +GENERATOR_NAME = "generate_characterization_chain_data" + + +def _load_generator(): + spec = importlib.util.spec_from_file_location(GENERATOR_NAME, GENERATOR_PATH) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module # dataclasses resolves string annotations via sys.modules + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def gen(): + return _load_generator() + + +@pytest.fixture(scope="module") +def manifest(): + with open(DATA_DIR / "manifest.json") as handle: + return json.load(handle) + + +def test_manifest_parses(manifest): + assert set(manifest) == {"description", "initial_store", "steps", "comparisons", "components"} + assert len(manifest["steps"]) == 2 + assert {c["name"] for c in manifest["comparisons"]} == { + "System pulse 1 (UV)", + "System pulse 2 (UV)", + "Small tracer pulse 1 (conductivity)", + "Small tracer pulse 2 (conductivity)", + "Large tracer pulse 1 (UV)", + "Large tracer pulse 2 (UV)", + } + for step in manifest["steps"]: + assert step["comparisons"], f"step {step['name']!r} has no comparisons" + assert step["truth"], f"step {step['name']!r} has no truth" + for path in step["truth"]: + assert path.startswith("flow_sheet."), path + + +def test_manifest_steps_reference_declared_comparisons(manifest): + names = {c["name"] for c in manifest["comparisons"]} + for step in manifest["steps"]: + assert set(step["comparisons"]) <= names + + +def test_bounds_contain_the_truth(manifest): + """Bounds are keyed by variable name (`CharacterizeBase._default_variables()`'s + `"name"`), truth by parameter path; both steps declare both consistently.""" + tubing_step, bed_step = manifest["steps"] + + tubing_bounds = tubing_step["bounds"] + lb, ub = tubing_bounds["tubing_pre_column_length"] + assert lb < tubing_step["truth"]["flow_sheet.tubing_pre_column.length"] < ub + lb, ub = tubing_bounds["tubing_pre_column_axial_dispersion"] + assert lb < tubing_step["truth"]["flow_sheet.tubing_pre_column.axial_dispersion"] < ub + + bed_bounds = bed_step["bounds"] + lb, ub = bed_bounds["bed_porosity"] + assert lb < bed_step["truth"]["flow_sheet.column.bed_porosity"] < ub + lb, ub = bed_bounds["particle_porosity"] + assert lb < bed_step["truth"]["flow_sheet.column.particle_porosity"] < ub + lb, ub = bed_bounds["axial_dispersion"] + assert lb < bed_step["truth"]["flow_sheet.column.axial_dispersion"] < ub + + +def test_every_comparison_reads_as_akta_with_channel_and_marker(manifest): + for raw in manifest["comparisons"]: + content = (DATA_DIR / raw["data_file"]).read_bytes() + assert is_akta_format(content), raw["name"] + run = read_experimental_csv(content, raw["name"]) + assert run.x_basis == "volume" + assert raw["channel"] in run.channels, (raw["name"], list(run.channels)) + marker = raw["injection_marker"] + assert any(text.startswith(marker) for _, text in run.markers), ( + raw["name"], run.markers, + ) + if raw["components"]: + assert set(raw["components"]) <= set(raw["recipe"]["components"]) + + +def test_recipes_rebuild(manifest): + for raw in manifest["comparisons"]: + state = recipe_from_dict(raw["recipe"]) + process = build_process(state, overrides=raw["overrides"]) + assert process.check_config() + assert len(state.components) == 1 + + +def test_generator_stores_chain_matches_manifest(gen, manifest): + """`gen.step1_store`/`gen.step2_store` are what the manifest's own truth/initial_store + encode; a store built independently from the manifest's JSON must agree.""" + from cadetgui.characterization.parameter_store import ParameterStore + + initial = ParameterStore.from_dict(manifest["initial_store"], gen.SPECS) + for path, entry in gen.initial_store().entries.items(): + assert initial.entries[path].value == entry.value + + tubing_truth = manifest["steps"][0]["truth"] + bed_truth = manifest["steps"][1]["truth"] + assert tubing_truth == { + "flow_sheet.tubing_pre_column.length": gen.TUBING_PRE_COLUMN_LENGTH, + "flow_sheet.tubing_pre_column.axial_dispersion": gen.TUBING_PRE_COLUMN_AXIAL_DISPERSION, + } + assert bed_truth == { + "flow_sheet.column.bed_porosity": gen.BED_POROSITY, + "flow_sheet.column.particle_porosity": gen.PARTICLE_POROSITY, + "flow_sheet.column.axial_dispersion": gen.COLUMN_AXIAL_DISPERSION, + } + + +@pytest.mark.slow +def test_generator_is_byte_deterministic(gen, tmp_path): + """Regenerating (recipe -> build_process -> apply_store -> CADET-Core simulation -> + Äkta shaping) twice, independently, gives byte-identical output -- the same check + that rebuilding each process from its recipe, `initial_store` and truth reproduces + the clean simulated signal exactly, since a mismatch anywhere in that chain would + change the shaped CSV bytes. + """ + out_a, out_b = tmp_path / "a", tmp_path / "b" + for out_dir in (out_a, out_b): + gen.OUT_DIR = out_dir + gen.build_manifest() + + files_a = sorted(p.name for p in out_a.iterdir()) + files_b = sorted(p.name for p in out_b.iterdir()) + assert files_a == files_b + mismatch = filecmp.cmpfiles(out_a, out_b, files_a, shallow=False)[1] + assert mismatch == [] + + +@pytest.mark.slow +def test_comparisons_have_no_problems_and_evaluate_near_noise_level(gen, manifest): + """Every comparison builds and simulates cleanly, and matches its own truth.""" + comparisons = { + raw["name"]: Comparison.from_dict(raw, base_dir=DATA_DIR) + for raw in manifest["comparisons"] + } + step1 = gen.step1_store(gen.initial_store()) + step2 = gen.step2_store(step1) + store_by_name = { + "System pulse 1 (UV)": step1, + "System pulse 2 (UV)": step1, + "Small tracer pulse 1 (conductivity)": step2, + "Small tracer pulse 2 (conductivity)": step2, + "Large tracer pulse 1 (UV)": step2, + "Large tracer pulse 2 (UV)": step2, + } + + for name, comparison in comparisons.items(): + assert comparison.problems() == [], name + preview = comparison.evaluate(store_by_name[name]) + assert preview.value < 0.05, (name, preview.value) + + +@pytest.mark.slow +def test_missing_baseline_window_breaks_the_drifting_run(gen, manifest): + """`Small tracer pulse 1 (conductivity)`'s explicit `baseline_window` is load-bearing: + without it, the default whole-trace baseline fit fails on the pre-injection dip (too few + points survive its lowest-2.5% threshold), demonstrating why the window is set in the + manifest.""" + name = "Small tracer pulse 1 (conductivity)" + raw = dict(next(r for r in manifest["comparisons"] if r["name"] == name)) + raw["baseline_window"] = None + comparison = Comparison.from_dict(raw, base_dir=DATA_DIR) + store = gen.step2_store(gen.step1_store(gen.initial_store())) + with pytest.raises(ValueError): + comparison.evaluate(store) + + +@pytest.mark.slow +def test_steps_build_with_characterization_runner(gen, manifest): + """The manifest's steps build through `StepSetup.from_dict` + `build`, the same + entry point a real characterization run would use.""" + comparisons = { + raw["name"]: Comparison.from_dict(raw, base_dir=DATA_DIR) + for raw in manifest["comparisons"] + } + step1 = gen.step1_store(gen.initial_store()) + step2 = gen.step2_store(step1) + + tubing_setup = StepSetup.from_dict(manifest["steps"][0], comparisons) + built = build(tubing_setup, step1) + assert built.problem.n_variables == 2 + + bed_setup = StepSetup.from_dict(manifest["steps"][1], comparisons) + built = build(bed_setup, step2) + assert built.problem.n_variables == 3 diff --git a/tests/test_characterization_guide.py b/tests/test_characterization_guide.py new file mode 100644 index 0000000..7dd7d54 --- /dev/null +++ b/tests/test_characterization_guide.py @@ -0,0 +1,312 @@ +"""Tests for the lab-language characterization guide.""" + +from __future__ import annotations + +import json +import warnings +from pathlib import Path + +import numpy as np +import pytest + +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=UserWarning) + +from cadetgui.cadetprocessadapter import UNIT_LABELS # noqa: E402 +from cadetgui.characterization.comparison import ( # noqa: E402 + Comparison, + recipe_from_dict, +) +from cadetgui.characterization.guide import ( # noqa: E402 + CHAIN_BY_ID, + DEFAULT_CHAIN, + EXPERIMENT_TYPES, + GLOSSARY, + GLOSSARY_ALIASES, + SALT, + WORKFLOW_GUIDE, + find_injection_marker, + guide_for_step, + measurement_from_run, + new_step_setup, + with_implied_values, +) +from cadetgui.characterization.parameter_store import ( # noqa: E402 + ChainError, + ParameterStore, + Provenance, + check_chain, + has_parameter, + spec_for, +) +from cadetgui.characterization.stages import describe_stage # noqa: E402 +from cadetgui.characterization.step_checks import study_steps_status # noqa: E402 +from cadetgui.characterization.study import Study # noqa: E402 +from cadetgui.io.configuration_store import ( # noqa: E402 + ConfigurationState, + InstrumentState, +) +from cadetgui.io.experimental_data import Channel, ExperimentalRun # noqa: E402 +from cadetgui.process_builder import build_process # noqa: E402 + +DATA_DIR = Path(__file__).parents[1] / "examples" / "data" / "characterization_akta" + +BASE = ConfigurationState( + components=["A", "B"], + column_key="General Rate Model (GRM)", + binding_key="None", + template_key="Pulse Injection", + instrument=InstrumentState(include_sample_loop=True, sample_loop_volume=20e-9), + column_values={"film_diffusion": [1e-5, 2e-5], "length": 0.02}, + model_values={"c_buffer_a": [0.0, 0.0], "c_sample": [3.0, 0.0], "flow_rate": 1e-8, + "cycle_time": 300.0}, +) + + +@pytest.fixture(scope="module") +def manifest(): + return json.loads((DATA_DIR / "manifest.json").read_text()) + + +@pytest.mark.parametrize("type_id", list(EXPERIMENT_TYPES)) +def test_every_experiment_type_builds_and_feeds_its_step(type_id): + experiment_type = EXPERIMENT_TYPES[type_id] + recipe = experiment_type.apply(BASE, component="Probe") + process = build_process(recipe) + assert process.check_config() + assert recipe.components[-1] == "Probe" + assert (recipe.components[0] == SALT) == experiment_type.with_salt + + unit, _, port = experiment_type.solution_path.partition(".") + assert unit in process.flow_sheet.units_dict and port in ("inlet", "outlet") + + guide = CHAIN_BY_ID[experiment_type.step_id] + assert type_id in guide.experiment_types + for variable in describe_stage(guide.stage, **guide.options): + if variable.parameter_path is not None: + assert has_parameter(process, variable.parameter_path), variable.name + + +def test_apply_is_pure_and_maps_species_by_name(): + experiment_type = EXPERIMENT_TYPES["column_pulse_small_tracer"] + recipe = experiment_type.apply(BASE, component="B") + assert BASE.components == ["A", "B"] + assert recipe.column_values["film_diffusion"] == [2e-5] + assert recipe.model_values["c_sample"] == [0.0] + assert recipe.model_values["flow_rate"] == 1e-8 + assert experiment_type.apply(BASE, component="New").model_values["c_sample"] == [3.0] + + +def test_apply_reproduces_the_example_recipes(manifest): + recipes = {c["name"]: recipe_from_dict(c["recipe"]) for c in manifest["comparisons"]} + large = EXPERIMENT_TYPES["column_pulse_large_tracer"].apply( + recipes["Small tracer pulse 1 (conductivity)"], component="LargeTracer" + ) + assert large == recipes["Large tracer pulse 1 (UV)"] + system = EXPERIMENT_TYPES["system_pulse"].apply( + recipes["Small tracer pulse 1 (conductivity)"], component="SystemTracer" + ) + assert system.instrument == recipes["System pulse 1 (UV)"].instrument + assert ( + system.model_values["c_sample"] == recipes["System pulse 1 (UV)"].model_values["c_sample"] + ) + + +def test_large_tracer_implies_pore_exclusion(): + experiment_type = EXPERIMENT_TYPES["column_pulse_large_tracer"] + (entry,) = experiment_type.store_entries("Dextran").values() + assert entry.value == {"Dextran": 0.0} + assert "assumed" in entry.provenance.note + + store = with_implied_values(ParameterStore(), experiment_type, "Dextran") + path = "flow_sheet.column.film_diffusion" + assert store.spec(path).species_indexed + assert store.value(path, "Dextran") == 0.0 + assert EXPERIMENT_TYPES["system_pulse"].store_entries("X") == {} + + +def test_salt_is_reserved_for_salt_types(): + with pytest.raises(ValueError): + EXPERIMENT_TYPES["linear_gradient_elution"].apply(BASE, component=SALT) + + +def _chain_specs(): + processes = [ + build_process(t.apply(BASE, component="Probe")) for t in EXPERIMENT_TYPES.values() + ] + setups = [new_step_setup(g, []) for g in DEFAULT_CHAIN] + paths = {p for s in setups for p in (*s.requires, *s.provides)} + return { + path: spec_for(next(p for p in processes if has_parameter(p, path)), path) + for path in paths + }, setups + + +def test_default_chain_is_consistent(): + specs, setups = _chain_specs() + check_chain([s.step for s in setups], ParameterStore(specs=specs)) + with pytest.raises(ChainError): + check_chain([s.step for s in reversed(setups)], ParameterStore(specs=specs)) + + +def test_chain_guides_are_complete(): + assert [g.title for g in DEFAULT_CHAIN] == [ + "Extra-column volume", "Column packing", "Particle transport", "Binding", "Capacity", + ] + for guide in DEFAULT_CHAIN: + assert guide.determines and guide.reading_results + assert 1 <= len(guide.advice) <= 3 + assert set(guide.system_units) <= set(UNIT_LABELS) + for type_id in guide.experiment_types: + assert EXPERIMENT_TYPES[type_id].step_id == guide.id + + +def test_glossary_and_workflow_cover_the_terms(): + for term in ( + "comparison", "measurement", "recipe", "flow path", "observation point", + "injection marker", "alignment", "baseline window", "normalization", "prior", + "posterior", "parameter store", "provenance", "probe", "species transfer", + "Pareto candidate", "best average", "fixed variable", "NRMSE", + "extra-column volume", "characterization step", "step type", + ): + assert GLOSSARY[term] + assert GLOSSARY_ALIASES["frozen variable"] == "fixed variable" + assert "frozen variable" not in GLOSSARY + for guide in DEFAULT_CHAIN: + assert guide.title in WORKFLOW_GUIDE + + +def _akta_run(label, markers): + volume = np.linspace(0.0, 5.0, 501) + peak = np.exp(-0.5 * ((volume - 2.0) / 0.1) ** 2) + return ExperimentalRun( + label=label, + channels={ + "UV 1_280": Channel("UV 1_280", volume, 1.0 + 10 * peak, "mAU"), + "Cond": Channel("Cond", volume[::2], 40.0 + peak[::2], "mS/cm"), + }, + x_basis="volume", + x_unit="mL", + markers=markers, + ) + + +def test_measurement_from_run_pulse(): + run = _akta_run("run1.csv", [(0.0, "Phase Equilibration"), (0.5, "Phase Elution")]) + experiment_type = EXPERIMENT_TYPES["column_pulse_small_tracer"] + comparison = measurement_from_run("M1", run, "Cond", experiment_type, BASE, component="Acetone") + assert comparison.problems() == [] + assert comparison.injection_marker == "Phase Elution" + assert comparison.solution_path == "tubing_detectors.outlet" + assert comparison.experiment_type == "column_pulse_small_tracer" + assert comparison.normalization == {"kind": "area", "target_area": pytest.approx(3.0 * 20e-9)} + assert Comparison.from_dict(comparison.to_dict()).experiment_type == comparison.experiment_type + + +def test_measurement_from_run_gradient(): + run = _akta_run("lge.csv", [(0.0, "Phase Sample Application"), (1.0, "Phase Elution")]) + comparison = measurement_from_run( + "LGE", run, "UV 1_280", EXPERIMENT_TYPES["linear_gradient_elution"], BASE, + component="mAb", + ) + assert comparison.problems() == [] + assert comparison.injection_marker == "Phase Sample Application" + assert comparison.recipe.components == [SALT, "mAb"] + assert comparison.flow_rate == 1e-8 + + +def test_find_injection_marker_none_without_match(): + run = _akta_run("x", [(0.0, "Phase Equilibration")]) + assert find_injection_marker(run) is None + + +def test_new_step_setup_rejects_foreign_experiment_types(): + run = _akta_run("run1.csv", [(0.5, "Phase Elution")]) + comparison = measurement_from_run( + "S", run, "UV 1_280", EXPERIMENT_TYPES["system_pulse"], BASE, component="Acetone" + ) + setup = new_step_setup(CHAIN_BY_ID["system_periphery"], [comparison]) + assert setup.stage == "tubing" and setup.options == {"tubing": "tubing_pre_column"} + assert setup.name == "Extra-column volume" + assert guide_for_step(setup).id == "system_periphery" + with pytest.raises(ValueError): + new_step_setup(CHAIN_BY_ID["column_packing"], [comparison]) + + +def test_example_manifest_carries_experiment_types(manifest): + types = {c["name"]: c["experiment_type"] for c in manifest["comparisons"]} + assert types == { + "System pulse 1 (UV)": "system_pulse", + "System pulse 2 (UV)": "system_pulse", + "Small tracer pulse 1 (conductivity)": "column_pulse_small_tracer", + "Small tracer pulse 2 (conductivity)": "column_pulse_small_tracer", + "Large tracer pulse 1 (UV)": "column_pulse_large_tracer", + "Large tracer pulse 2 (UV)": "column_pulse_large_tracer", + } + + +def test_study_steps_status_before_and_after_accepting_step_one(): + study = Study.load(DATA_DIR / "manifest.json") + assert [guide_for_step(s).id for s in study.steps] == ["system_periphery", "column_packing"] + + first, second = study_steps_status(study) + assert first.ready and not first.accepted and first.next_action == "Run the fit." + assert first.measurements == {"system_pulse": ["System pulse 1 (UV)", "System pulse 2 (UV)"]} + assert second.measurements == { + "column_pulse_small_tracer": [ + "Small tracer pulse 1 (conductivity)", "Small tracer pulse 2 (conductivity)", + ], + "column_pulse_large_tracer": ["Large tracer pulse 1 (UV)", "Large tracer pulse 2 (UV)"], + } + assert not second.ready + assert second.next_action.endswith("accept Extra-column volume first.") + + (first,) = study_steps_status(study, fitted={"Extra-column volume"})[:1] + assert first.next_action == "Choose a Pareto candidate and accept it." + + posterior = study.initial_store.updated( + { + "flow_sheet.tubing_pre_column.length": 0.4, + "flow_sheet.tubing_pre_column.axial_dispersion": {"SystemTracer": 5e-7}, + }, + Provenance(step="Extra-column volume", probe="SystemTracer"), + ) + study.accept("Extra-column volume", posterior) + + first, second = study_steps_status(study) + assert first.accepted and first.next_action == "Accepted. Continue with Column packing." + assert second.species_gaps == { + "flow_sheet.tubing_pre_column.axial_dispersion": ["SmallTracer", "LargeTracer"], + } + assert not second.ready + assert "Carry them over explicitly" in second.next_action + + +def test_every_step_type_has_one_line_of_help_and_no_stage_wording(): + from cadetgui.characterization.guide import STEP_TYPE_HELP, step_type_help + from cadetgui.characterization.stages import STAGES + + assert set(STEP_TYPE_HELP) == set(STAGES) + for stage_id in STAGES: + assert step_type_help(stage_id) and "\n" not in step_type_help(stage_id) + texts = [*STEP_TYPE_HELP.values(), *GLOSSARY.values(), WORKFLOW_GUIDE] + texts += [s.label for s in STAGES.values()] + assert not any("stage" in t.lower() or "fit step" in t.lower() for t in texts) + + +def test_role_fix_offers_a_role_only_when_the_other_uses_accept_it(): + from cadetgui.characterization.guide import role_fix + + study = Study.load(DATA_DIR / "manifest.json") + small = EXPERIMENT_TYPES["column_pulse_small_tracer"] + large = EXPERIMENT_TYPES["column_pulse_large_tracer"] + + text, role = role_fix(study, "SystemTracer", small) + assert role == "small tracer" + assert text == ( + "SystemTracer is declared as a system tracer. This experiment needs a small tracer." + ) + text, role = role_fix(study, "SystemTracer", large) + assert role is None and "System pulse 1 (UV)" in text and "need that role" in text + assert role_fix(study, "SmallTracer", small) == ("", None) + assert role_fix(study, "Unknown", small) == ("", None) diff --git a/tests/test_characterization_guide_pane.py b/tests/test_characterization_guide_pane.py new file mode 100644 index 0000000..c372069 --- /dev/null +++ b/tests/test_characterization_guide_pane.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import html + +from cadetgui.characterization.guide import DEFAULT_CHAIN, EXPERIMENT_TYPES, GLOSSARY +from cadetgui.widgets.composite.characterization_guide_pane import ( + CharacterizationGuideWidget, + markdown_html, +) + + +def test_markdown_subset(): + out = markdown_html("# Title\n\nSome **bold**\ntext.\n\n1. one\n more\n2. two\n\n- a\n- b\n") + + assert out == ( + "

    Title

    Some bold text.

    " + "
    1. one more
    2. two
    • a
    • b
    " + ) + assert markdown_html("a < b") == "

    a < b

    " + + +def test_renders_the_workflow_the_glossary_and_every_experiment_type(): + guide = CharacterizationGuideWidget() + + assert "

    Characterizing a column, step by step

    " in guide.workflow.value + assert guide.workflow.value.count("
  • ") == 8 + for term, definition in GLOSSARY.items(): + assert f"
    {html.escape(term)}
    " in guide.glossary.value + assert html.escape(definition) in guide.glossary.value + for experiment in EXPERIMENT_TYPES.values(): + assert html.escape(experiment.label) in guide.experiment_types.value + assert html.escape(experiment.what_you_run) in guide.experiment_types.value + for step in DEFAULT_CHAIN: + assert step.title in guide.chain.value + + +def test_lab_checks_are_listed_per_step_in_the_guide(): + from cadetgui.widgets.composite import CharacterizationGuideWidget + + text = CharacterizationGuideWidget().lab_checks.value + assert "Lab checks the software cannot do" in text + for step in DEFAULT_CHAIN: + for item in step.advice: + assert html.escape(item, quote=False) in text + + +def test_optimizer_choice_is_explained_in_the_guide(): + text = CharacterizationGuideWidget().optimizers.value + assert "Nelder-Mead minimizes a single objective" in text and "U-NSGA-III" in text diff --git a/tests/test_characterization_runner.py b/tests/test_characterization_runner.py new file mode 100644 index 0000000..6b12fe0 --- /dev/null +++ b/tests/test_characterization_runner.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +import threading +import warnings +from types import SimpleNamespace + +import numpy as np +import pytest +from cadetgui.characterization.parameter_store import ( + ParameterSpec, + ParameterStore, + Provenance, + check_chain, +) +from cadetgui.characterization.runner import ( + AVERAGE_TAG, + Candidate, + StepResult, + StepSetup, + build, + current_best_average, + default_optimizer, + default_optimizer_knobs, + estimated_simulations, + missing_requirements, + pareto_candidates, + posterior, + run, +) + +from test_comparison import ( + DISPERSION, + LENGTH, + PRIOR, + TRUE_DISPERSION, + TRUTH, + TUBING, + make_comparison, + store_with, + synthetic_run, +) + +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=UserWarning) + +BOUNDS = { + f"{TUBING}_length": (0.2, 1.5), + f"{TUBING}_axial_dispersion": (1e-7, 1e-4), +} + + +@pytest.fixture(scope="module") +def comparisons(): + """Two pulse runs through the same tubing at two flow rates.""" + a = make_comparison("System pulse 1 (UV)") + b = make_comparison("System pulse 2 (UV)", overrides={"flow_rate": 2e-8, "cycle_time": 250.0}) + a.run = synthetic_run(a, seed=1) + b.run = synthetic_run(b, seed=2) + return [a, b] + + +@pytest.fixture +def setup(comparisons): + return StepSetup( + name="Extra-column volume", stage="tubing", options={"tubing": TUBING}, + comparisons=comparisons, bounds=BOUNDS, + ) + + +def coarsen(built, ncol: int = 20) -> None: + for process in built.processes: + process.flow_sheet[TUBING].discretization.ncol = ncol + + +def front(x, f, objective_names=("System pulse 1 (UV)", "System pulse 2 (UV)")) -> StepResult: + return StepResult( + step="s", variable_names=["a", "b"], objective_names=list(objective_names), + x=np.asarray(x, dtype=float), f=np.asarray(f, dtype=float), success=True, + message="", cancelled=False, optimizer_name="U-NSGA-III", built=None, + ) + + +class TestStepSetup: + def test_provides_the_stage_write_targets(self, setup): + assert setup.provides == [LENGTH, DISPERSION] + + def test_frozen_variables_are_not_provided(self, setup): + setup.frozen = (f"{TUBING}_axial_dispersion",) + assert setup.provides == [LENGTH] + + def test_missing_requirements_are_reported_not_raised(self, setup): + setup.requires = (LENGTH,) + assert missing_requirements(setup, store_with({DISPERSION: {"A": 1e-6}})) == [LENGTH] + assert missing_requirements(setup, store_with(PRIOR)) == [] + + def test_step_feeds_check_chain(self, setup): + check_chain([setup.step], store_with(PRIOR)) + + def test_from_dict_resolves_comparison_names(self, comparisons): + raw = { + "name": "Extra-column volume", + "stage": "tubing", + "options": {"tubing": TUBING}, + "comparisons": ["System pulse 2 (UV)"], + "bounds": {f"{TUBING}_length": [0.1, 2.0]}, + "frozen": [], + } + setup = StepSetup.from_dict(raw, {c.name: c for c in comparisons}) + assert setup.comparisons == [comparisons[1]] + assert setup.bounds == {f"{TUBING}_length": (0.1, 2.0)} + + +class TestBuild: + def test_one_objective_per_comparison(self, setup): + built = build(setup, store_with(PRIOR)) + assert [p.name for p in built.processes] == ["System pulse 1 (UV)", "System pulse 2 (UV)"] + assert built.problem.n_objectives == 2 + assert built.problem.independent_variable_names == [ + f"{TUBING}_length", f"{TUBING}_axial_dispersion", + ] + assert built.problem.lower_bounds_independent[0] == pytest.approx(0.2) + + def test_duplicate_comparison_names_raise(self, comparisons): + setup = StepSetup("s", "tubing", {"tubing": TUBING}, [comparisons[0], comparisons[0]]) + with pytest.raises(ValueError, match="duplicate"): + build(setup, store_with(PRIOR)) + + +class TestParetoCandidates: + def test_average_and_each_objective_winner(self): + result = front( + x=[[1, 1], [2, 2], [3, 3]], + f=[[0.1, 0.9], [0.4, 0.4], [0.9, 0.05]], + ) + candidates = pareto_candidates(result) + assert [c.tags for c in candidates] == [ + (AVERAGE_TAG,), ("best System pulse 1 (UV)",), ("best System pulse 2 (UV)",), + ] + assert [c.index for c in candidates] == [1, 0, 2] + assert candidates[1].f == (0.1, 0.9) + + def test_duplicates_merge_their_tags(self): + result = front(x=[[1, 1], [2, 2]], f=[[0.1, 0.2], [0.5, 0.1]]) + candidates = pareto_candidates(result) + assert [c.tags for c in candidates] == [ + (AVERAGE_TAG, "best System pulse 1 (UV)"), + ("best System pulse 2 (UV)",), + ] + + def test_identical_x_at_different_rows_merge(self): + result = front(x=[[1, 1], [1, 1]], f=[[0.1, 0.9], [0.9, 0.1]]) + (candidate,) = pareto_candidates(result) + assert set(candidate.tags) == { + AVERAGE_TAG, "best System pulse 1 (UV)", "best System pulse 2 (UV)", + } + + def test_single_objective_gives_one_candidate(self): + result = front( + x=[[1, 1], [2, 2]], f=[[0.3], [0.2]], objective_names=["System pulse 1 (UV)"], + ) + (candidate,) = pareto_candidates(result) + assert candidate.tags == (AVERAGE_TAG, "best System pulse 1 (UV)") + assert candidate.index == 1 + + def test_empty_front_gives_none(self): + assert pareto_candidates(front(np.empty((0, 2)), np.empty((0, 2)))) == [] + + +class TestPosterior: + def test_writes_the_candidate_with_provenance(self, setup): + store = store_with(PRIOR) + built = build(setup, store) + candidate = Candidate( + index=0, x=(TRUTH[LENGTH], TRUE_DISPERSION), f=(0.01, 0.02), tags=(AVERAGE_TAG,), + ) + updated = posterior(setup, built, store, candidate) + + assert updated.value(LENGTH) == pytest.approx(TRUTH[LENGTH]) + assert updated.value(DISPERSION) == { + "SmallTracer": pytest.approx(TRUE_DISPERSION), "A": TRUE_DISPERSION, + "B": TRUE_DISPERSION, + } + assert store.value(LENGTH) == pytest.approx(PRIOR[LENGTH]) + assert updated.step == "Extra-column volume" + assert updated.prior == "test" + + provenance = updated.entries[LENGTH].provenance + assert provenance.step == "Extra-column volume" + assert provenance.source == "System pulse 1 (UV), System pulse 2 (UV)" + assert provenance.probe == "SmallTracer" + assert provenance.model is None + assert provenance.metric == { + "System pulse 1 (UV)_NRMSE": 0.01, + "System pulse 2 (UV)_NRMSE": 0.02, + } + + def test_declares_specs_for_paths_the_store_has_not_seen(self, setup): + store = ParameterStore().updated({}, Provenance(step="empty")) + built = build(setup, store) + candidate = Candidate(0, (0.5, 1e-6), (0.1, 0.1), (AVERAGE_TAG,)) + updated = posterior(setup, built, store, candidate) + assert updated.spec(LENGTH) == ParameterSpec(LENGTH) + assert updated.spec(DISPERSION) == ParameterSpec(DISPERSION, species_indexed=True) + assert updated.value(DISPERSION) == {"SmallTracer": pytest.approx(1e-6)} + assert updated.value(LENGTH) == pytest.approx(0.5) + + +@pytest.mark.slow +def test_cancel_before_the_first_generation_returns_an_empty_front(setup): + store = store_with(PRIOR) + built = build(setup, store) + coarsen(built) + cancel = threading.Event() + cancel.set() + result = run( + setup, store, optimizer_kwargs={"pop_size": 4, "n_max_gen": 2}, + cancel_event=cancel, built=built, + ) + assert result.cancelled + assert not result.success + assert result.x.shape == (0, 2) + assert pareto_candidates(result) == [] + + +@pytest.mark.slow +def test_tubing_step_recovers_the_length(setup): + """Two comparisons, U-NSGA-III, small budget: the best-average length is within 10 %.""" + store = store_with(PRIOR) + built = build(setup, store) + coarsen(built, ncol=40) + result = run( + setup, store, optimizer_kwargs={"pop_size": 16, "n_max_gen": 8}, built=built, + ) + assert result.success, result.message + assert result.f.shape[1] == 2 + assert len(result.x) == len(result.f) > 0 + + candidates = pareto_candidates(result) + best = next(c for c in candidates if AVERAGE_TAG in c.tags) + updated = posterior(setup, built, store, best) + assert updated.value(LENGTH) == pytest.approx(TRUTH[LENGTH], rel=0.1) + assert set(updated.entries[LENGTH].provenance.metric) == { + "System pulse 1 (UV)_NRMSE", + "System pulse 2 (UV)_NRMSE", + } + + +@pytest.mark.parametrize( + ("name", "n_variables", "expected"), + [ + ("U-NSGA-III", 1, {"pop_size": 16, "n_max_gen": 12}), + ("U-NSGA-III", 3, {"pop_size": 24, "n_max_gen": 12}), + ("Nelder-Mead", 0, {"maxiter": 200}), + ("Nelder-Mead", 2, {"maxiter": 400}), + ("Nelder-Mead", 7, {"maxiter": 1000}), + ], +) +def test_default_optimizer_knobs_scale_with_the_fitted_variables(name, n_variables, expected): + assert default_optimizer_knobs(name, n_variables) == expected + + +def test_default_optimizer_sizes_only_when_given_the_variable_count(): + assert default_optimizer()["knobs"] == {"pop_size": 0, "n_max_gen": 0} + assert default_optimizer(n_variables=3, n_max_gen=5)["knobs"] == { + "pop_size": 24, "n_max_gen": 5, + } + + +def test_new_steps_are_sized_for_their_unfrozen_variables(): + assert StepSetup("s", "adsorption", {"is_kinetic": True}).optimizer["knobs"] == { + "pop_size": 24, "n_max_gen": 12, + } + frozen = StepSetup("s", "bed", frozen=("bed_porosity",), optimizer=None) + assert frozen.n_fitted_variables() == 1 + assert StepSetup("s", "tubing").n_fitted_variables() is None + assert StepSetup("s", "tubing").optimizer["knobs"] == {"pop_size": 16, "n_max_gen": 12} + explicit = default_optimizer("Nelder-Mead", maxiter=5) + assert StepSetup("s", "bed", optimizer=explicit).optimizer == explicit + + +def test_estimated_simulations(): + assert estimated_simulations(default_optimizer(n_variables=2), 3) == 16 * 12 * 3 + assert estimated_simulations(default_optimizer("Nelder-Mead", maxiter=300), 2) == 600 + assert estimated_simulations(default_optimizer(), 2) is None + + +def test_current_best_average_reads_the_running_front(): + optimizer = SimpleNamespace(results=None) + assert current_best_average(optimizer) is None + + front = SimpleNamespace( + x_independent=np.array([[0.3, 1e-7], [0.4, 2e-7], [0.5, 3e-7]]), + f=np.array([[0.01, 0.9], [0.2, 0.2], [0.9, 0.01]]), + ) + optimizer.results = SimpleNamespace(pareto_fronts=[front], meta_front=front) + candidate = current_best_average(optimizer) + assert candidate == Candidate(index=1, x=(0.4, 2e-7), f=(0.2, 0.2), tags=(AVERAGE_TAG,)) diff --git a/tests/test_characterization_setup.py b/tests/test_characterization_setup.py new file mode 100644 index 0000000..f499904 --- /dev/null +++ b/tests/test_characterization_setup.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import warnings +from pathlib import Path + +import pytest +from cadetgui.characterization.guide import DEFAULT_CHAIN +from cadetgui.characterization.parameter_store import Provenance +from cadetgui.characterization.runner import StepSetup +from cadetgui.characterization.study import Study +from cadetgui.widgets.composite.characterization_setup import ( + ADD_ANOTHER, + ADD_MEASUREMENT, + OPEN_STEP, + TRANSFER, + CharacterizationSetupWidget, + runs_use_text, + set_up_label, + step_diagrams_html, +) + +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=UserWarning) + +MANIFEST = ( + Path(__file__).parent.parent / "examples" / "data" / "characterization_akta" / "manifest.json" +) +LENGTH = "flow_sheet.tubing_pre_column.length" +DISPERSION = "flow_sheet.tubing_pre_column.axial_dispersion" +DIAGRAM = 'aria-label="Flow path of the LC system"' + + +class Spy: + def __init__(self) -> None: + self.calls: list = [] + + def __call__(self, target: str, context: dict) -> None: + self.calls.append((target, context)) + + +@pytest.fixture +def study() -> Study: + return Study.load(MANIFEST) + + +def _labels(setup_pane): + return {key: button.description for key, button in setup_pane.buttons.items()} + + +def _card_text(setup_pane, index: int) -> str: + def walk(widget): + yield widget + for child in getattr(widget, "children", ()): + yield from walk(child) + + return " ".join( + w.value for w in walk(setup_pane._cards.children[index]) + if isinstance(getattr(w, "value", None), str) + ) + + +def test_one_card_per_chain_step_in_lab_order(study): + setup_pane = CharacterizationSetupWidget(study) + + assert len(setup_pane._cards.children) == len(DEFAULT_CHAIN) + assert list(setup_pane.buttons) == [g.id for g in DEFAULT_CHAIN] + for index, guide in enumerate(DEFAULT_CHAIN): + text = _card_text(setup_pane, index) + assert f"{index + 1}. {guide.title}" in text + assert 'data-highlight="true"' in text + assert "Measurements" in _card_text(setup_pane, 0) + assert "System pulse 1 (UV), System pulse 2 (UV)" in _card_text(setup_pane, 0) + assert "not started" in _card_text(setup_pane, 2) + + +def test_status_before_and_after_accepting_the_first_step(study): + setup_pane = CharacterizationSetupWidget(study) + + assert setup_pane.statuses["Extra-column volume"].ready + assert _labels(setup_pane) == { + "system_periphery": OPEN_STEP, + "column_packing": OPEN_STEP, + "particle_transport": ADD_MEASUREMENT, + "binding": ADD_MEASUREMENT, + "capacity": ADD_MEASUREMENT, + } + assert "accept Extra-column volume first." in _card_text(setup_pane, 1) + + study.accept("Extra-column volume", study.initial_store.updated( + {LENGTH: 0.42, DISPERSION: {"SystemTracer": 4.5e-7}}, + Provenance(step="Extra-column volume", probe="SystemTracer"), + )) + + assert setup_pane.statuses["Extra-column volume"].accepted + assert "accepted" in _card_text(setup_pane, 0) + assert _labels(setup_pane)["column_packing"] == TRANSFER + assert "Carry them over explicitly" in _card_text(setup_pane, 1) + + +def test_fitted_steps_are_shown_as_waiting_for_a_choice(study): + fitted: set = set() + setup_pane = CharacterizationSetupWidget(study, fitted_steps=lambda: fitted) + + fitted.add("Extra-column volume") + setup_pane.refresh() + + assert setup_pane.statuses["Extra-column volume"].fitted + assert "fitted, choose a candidate" in _card_text(setup_pane, 0) + + +def test_open_step_and_transfer_navigate_to_the_step(study): + spy = Spy() + setup_pane = CharacterizationSetupWidget(study, on_navigate=spy) + + setup_pane.buttons["column_packing"].click() + + assert spy.calls == [("step", {"name": "Column packing"})] + + +def _protein_pulse(study, name="Protein pulse 1"): + import dataclasses + + large = study.comparison("Large tracer pulse 1 (UV)") + return dataclasses.replace(large, name=name, experiment_type="nonbinding_protein_pulse") + + +def test_a_step_without_measurements_only_offers_adding_one(study): + spy = Spy() + setup_pane = CharacterizationSetupWidget(study, on_navigate=spy) + + assert setup_pane.buttons["particle_transport"].description == ADD_MEASUREMENT + assert "particle_transport" not in setup_pane.secondary_buttons + assert "Add a measurement for this step first" in _card_text(setup_pane, 2) + setup_pane.buttons["particle_transport"].click() + assert spy.calls == [("measurements", {"step_id": "particle_transport"})] + + +def test_a_step_with_measurements_offers_set_up_and_another_measurement(study): + spy = Spy() + setup_pane = CharacterizationSetupWidget(study, on_navigate=spy) + study.upsert_comparison(_protein_pulse(study)) + + assert setup_pane.buttons["particle_transport"].description == set_up_label(1) + assert set_up_label(2) == "Set up this step (2 measurements)" + assert setup_pane.buttons["particle_transport"].button_style == "primary" + secondary = setup_pane.secondary_buttons["particle_transport"] + assert secondary.description == ADD_ANOTHER + assert secondary.button_style == "" + secondary.click() + assert spy.calls == [("measurements", {"step_id": "particle_transport"})] + + +def test_set_up_this_step_upserts_the_step_with_tagged_measurements(study): + spy = Spy() + upserted = [] + upsert = study.upsert_step + study.upsert_step = lambda step: (upserted.append(step), upsert(step)) + setup_pane = CharacterizationSetupWidget(study, on_navigate=spy) + study.upsert_comparison(_protein_pulse(study)) + + setup_pane.buttons["particle_transport"].click() + + assert [s.name for s in upserted] == ["Particle transport"] + assert study.steps[-1].stage == "particles" + assert [c.name for c in study.steps[-1].comparisons] == ["Protein pulse 1"] + assert spy.calls == [("step", {"name": "Particle transport"})] + assert "not started" not in _card_text(setup_pane, 2) + assert setup_pane.statuses["Particle transport"].guide.id == "particle_transport" + + +def test_set_up_keeps_chain_order(): + study = Study() + setup_pane = CharacterizationSetupWidget(study) + + setup_pane.set_up("binding") + setup_pane.set_up("system_periphery") + + assert [s.name for s in study.steps] == ["Extra-column volume", "Binding"] + + +def test_add_measurement_navigates_with_the_step(): + study = Study() + study.upsert_step(StepSetup(name="system_periphery", stage="tubing")) + spy = Spy() + setup_pane = CharacterizationSetupWidget(study, on_navigate=spy) + + assert setup_pane.buttons["system_periphery"].description == ADD_MEASUREMENT + setup_pane.buttons["system_periphery"].click() + + assert spy.calls == [("measurements", {"step_id": "system_periphery"})] + + +def test_custom_steps_get_their_own_card(): + study = Study() + study.upsert_step( + StepSetup(name="mine", stage="tubing", options={"tubing": "tubing_detectors"}) + ) + setup_pane = CharacterizationSetupWidget(study) + + assert len(setup_pane._cards.children) == len(DEFAULT_CHAIN) + 1 + assert setup_pane.buttons["mine"].description == OPEN_STEP + + +def test_manual_refresh_only_when_auto_refresh_is_off(study): + setup_pane = CharacterizationSetupWidget(study, auto_refresh=False) + + study.accept("Extra-column volume", study.initial_store) + + assert setup_pane.stale + assert not setup_pane.statuses["Extra-column volume"].accepted + setup_pane.refresh() + assert setup_pane.statuses["Extra-column volume"].accepted + + +def test_step_diagrams_mark_each_observation_point(study): + guide = DEFAULT_CHAIN[1] + comparisons = [c for c in study.comparisons if c.experiment_type in guide.experiment_types] + + svg = step_diagrams_html(guide, comparisons) + + assert svg.count(DIAGRAM) == 2 + assert 'data-observe="tubing_detectors"' in svg + assert 'data-observe="column"' in svg + assert step_diagrams_html(guide).count(DIAGRAM) == 2 + + +def test_system_card_summarizes_the_setup_and_links_to_the_system_pane(study): + import dataclasses + + recipe = study.comparisons[0].recipe + calls = [] + setup_pane = CharacterizationSetupWidget( + study, system=lambda: recipe, + on_navigate=lambda target, context: calls.append((target, context)), + ) + html = setup_pane._system_card.children[0].children[1].value + + assert "0. Your system" in setup_pane._system_card.children[0].children[0].children[0].value + assert recipe.column_key in html and " PulseInjection: + """A small real LRMP + SMA pulse process: fast to simulate, physically consistent.""" + flow_sheet = LCFlowSheet( + COMPONENT_SYSTEM, + sample_loop_volume=1e-7, + ColumnModel=LumpedRateModelWithPores, + BindingModel=StericMassAction, + bypass_units=["tubing_post_column", "tubing_detectors"], + ) + flow_sheet.mixer.init_liquid_volume = 1e-9 + flow_sheet.mixer.flow_rate = 1e-6 + flow_sheet.tubing_pre_injection.length = 0.2 + flow_sheet.tubing_pre_injection.diameter = 1e-3 + flow_sheet.tubing_pre_injection.axial_dispersion = 1e-7 + flow_sheet.tubing_pre_column.length = 0.1 + flow_sheet.tubing_pre_column.diameter = 1e-3 + flow_sheet.tubing_pre_column.axial_dispersion = 1e-7 + + column = flow_sheet.column + column.length = 0.1 + column.diameter = 0.01 + column.bed_porosity = 0.4 + column.particle_porosity = 0.6 + column.particle_radius = 5e-5 + column.axial_dispersion = 1e-7 + column.film_diffusion = [1e-5, 1e-5] + column.discretization.ncol = 20 + + binding = column.binding_model + binding.is_kinetic = False + binding.adsorption_rate = [0, 1.0] + binding.desorption_rate = [1, 1] + binding.characteristic_charge = [0, 5.0] + binding.steric_factor = [0, 10.0] + binding.capacity = 1000.0 + + return PulseInjection( + name, flow_sheet, c_buffer_a=[0, 0], c_sample=[0, 1.0], cycle_time=200.0, flow_rate=1e-6 + ) + + +@pytest.fixture(scope="module") +def reference() -> ReferenceIO: + """Synthetic reference from one fixed-seed simulation of `_process()` -- no measured data.""" + result = Cadet().simulate(_process("reference")) + outlet = result.solution.column.outlet + return ReferenceIO("reference", outlet.time, outlet.solution, component_system=COMPONENT_SYSTEM) + + +def _comparator(process, reference): + (comparator,) = setup_comparators( + process, reference, "column.outlet", metrics=["NRMSE"], components=["A"] + ) + return comparator + + +# -- STAGES registry -------------------------------------------------------------------- + + +def test_stage_ids_match_the_stable_set(): + assert set(STAGES) == { + "tubing", "pre_injection", "bed", "particles", "capacity", "adsorption", + } + + +@pytest.mark.parametrize( + ("stage_id", "expected_options"), + [ + ("tubing", {"tubing"}), + ("pre_injection", set()), + ("bed", {"include_particle_porosity"}), + ( + "particles", + { + "include_axial_dispersion", "include_particle_porosity", + "include_film_diffusion", "include_pore_diffusion", "component_index", + }, + ), + ("capacity", set()), + ( + "adsorption", + { + "is_kinetic", "include_steric_factor", "include_film_diffusion", + "include_pore_diffusion", "component_index", + }, + ), + ], +) +def test_stage_options_are_discovered_from_the_constructor_signature(stage_id, expected_options): + assert set(STAGES[stage_id].options) == expected_options + + +# -- describe_stage ----------------------------------------------------------------------- + + +def test_describe_stage_requires_options_with_no_constructor_default(): + with pytest.raises(ValueError, match="tubing"): + describe_stage("tubing") + + +def test_describe_stage_rejects_an_unknown_option(): + with pytest.raises(ValueError, match="bogus"): + describe_stage("bed", bogus=True) + + +def test_tubing_variable_names_follow_the_tubing_unit(): + variables = describe_stage("tubing", tubing="tubing_pre_column") + assert [v.name for v in variables] == [ + "tubing_pre_column_length", "tubing_pre_column_axial_dispersion", + ] + assert [v.parameter_path for v in variables] == [ + "flow_sheet.tubing_pre_column.length", "flow_sheet.tubing_pre_column.axial_dispersion", + ] + assert not any(v.is_dependent for v in variables) + + renamed = describe_stage("tubing", tubing="tubing_pre_injection") + assert [v.name for v in renamed] == [ + "tubing_pre_injection_length", "tubing_pre_injection_axial_dispersion", + ] + + +def test_bed_default_options_omit_particle_porosity(): + default = describe_stage("bed") + assert [v.name for v in default] == ["bed_porosity", "axial_dispersion"] + + with_porosity = describe_stage("bed", include_particle_porosity=True) + assert [v.name for v in with_porosity] == [ + "bed_porosity", "axial_dispersion", "particle_porosity", + ] + + +def test_particles_requires_at_least_one_include_flag(): + with pytest.raises(ValueError, match="include_"): + describe_stage("particles") + + +def test_particles_variable_uses_component_index_for_its_indices(): + variables = describe_stage("particles", include_film_diffusion=True, component_index=1) + (film_diffusion,) = variables + assert film_diffusion.name == "film_diffusion" + assert film_diffusion.indices == [1] + + +@pytest.mark.parametrize( + ("options", "expected_names"), + [ + ( + {"is_kinetic": False}, + {"characteristic_charge", "adsorption_rate"}, + ), + ( + {"is_kinetic": True}, + { + "characteristic_charge", "adsorption_rate", "desorption_rate", + "equilibrium_constant", "kinetic_constant", + }, + ), + ], +) +def test_adsorption_kinetic_vs_rapid_equilibrium_variable_sets(options, expected_names): + variables = describe_stage("adsorption", **options) + assert {v.name for v in variables} == expected_names + + +def test_adsorption_kinetic_mode_marks_the_dependency_chain_as_dependent(): + variables = {v.name: v for v in describe_stage("adsorption", is_kinetic=True)} + + assert not variables["characteristic_charge"].is_dependent + for name in ("adsorption_rate", "desorption_rate", "equilibrium_constant", "kinetic_constant"): + assert variables[name].is_dependent + + # Free helper variables have no process write target. + assert variables["equilibrium_constant"].parameter_path is None + assert variables["kinetic_constant"].parameter_path is None + # The derived binding-model rates still resolve to a real path. + assert variables["adsorption_rate"].parameter_path == ( + "flow_sheet.column.binding_model.adsorption_rate" + ) + + +def test_capacity_has_a_single_non_dependent_variable(): + (capacity,) = describe_stage("capacity") + assert capacity.name == "capacity" + assert not capacity.is_dependent + + +# -- write_targets ------------------------------------------------------------------------ + + +def test_write_targets_maps_names_to_parameter_paths(): + variables = describe_stage("bed", include_particle_porosity=True) + assert write_targets(variables) == { + "bed_porosity": "flow_sheet.column.bed_porosity", + "axial_dispersion": "flow_sheet.column.axial_dispersion", + "particle_porosity": "flow_sheet.column.particle_porosity", + } + + +def test_write_targets_omits_free_helper_variables_but_keeps_derived_rates(): + variables = describe_stage("adsorption", is_kinetic=True) + targets = write_targets(variables) + assert "equilibrium_constant" not in targets + assert "kinetic_constant" not in targets + assert targets["adsorption_rate"] == "flow_sheet.column.binding_model.adsorption_rate" + assert targets["desorption_rate"] == "flow_sheet.column.binding_model.desorption_rate" + + +# -- build_problem -------------------------------------------------------------------------- + + +def test_build_problem_applies_bounds_and_excludes_frozen_variables(reference): + process = _process("tubing_test") + problem = build_problem( + "tubing", process, _comparator(process, reference), Cadet(), + options={"tubing": "tubing_pre_column"}, + bounds={"tubing_pre_column_length": (0.05, 0.2)}, + frozen={"tubing_pre_column_axial_dispersion"}, + ) + + assert problem.variable_names == ["tubing_pre_column_length"] + assert problem.variables_dict["tubing_pre_column_length"].lb == 0.05 + assert problem.variables_dict["tubing_pre_column_length"].ub == 0.2 + + +def test_build_problem_clears_the_broken_plot_callback(reference): + process = _process("callback_test") + problem = build_problem( + "tubing", process, _comparator(process, reference), Cadet(), + options={"tubing": "tubing_pre_column"}, + ) + + assert problem.n_callbacks == 0 + + +def test_build_problem_without_frozen_evaluates_at_the_reference_value(reference): + process = _process("eval_test") + problem = build_problem( + "tubing", process, _comparator(process, reference), Cadet(), + options={"tubing": "tubing_pre_column"}, + frozen={"tubing_pre_column_axial_dispersion"}, + ) + + # tubing_pre_column.length == 0.1 in `_process()`, the same value `reference` was + # generated from, so NRMSE at that point should be (numerically) zero. + objective = problem.evaluate_objectives([0.1]) + assert objective == pytest.approx([0.0], abs=1e-6) + + +def test_build_problem_frozen_dependent_variable_raises(reference): + process = _process("kinetic_freeze_test") + with pytest.raises(CADETProcessError, match="kinetic_constant"): + build_problem( + "adsorption", process, _comparator(process, reference), Cadet(), + options={"is_kinetic": True}, frozen={"kinetic_constant"}, + ) + + +def test_build_problem_kinetic_mode_dependent_variables_still_evaluate(reference): + process = _process("kinetic_test") + problem = build_problem( + "adsorption", process, _comparator(process, reference), Cadet(), + options={"is_kinetic": True}, frozen={"characteristic_charge"}, + ) + + assert "characteristic_charge" not in problem.variable_names + assert problem.independent_variable_names == ["equilibrium_constant", "kinetic_constant"] + + objective = problem.evaluate_objectives([1.0, 0.01]) + assert objective[0] < float("inf") + + +def test_build_problem_propagates_particles_missing_include_flag(reference): + process = _process("particles_test") + with pytest.raises(ValueError, match="include_"): + build_problem("particles", process, _comparator(process, reference), Cadet()) diff --git a/tests/test_characterization_step.py b/tests/test_characterization_step.py new file mode 100644 index 0000000..8ac7873 --- /dev/null +++ b/tests/test_characterization_step.py @@ -0,0 +1,1110 @@ +from __future__ import annotations + +import csv +import threading +import time +import warnings +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace + +import ipywidgets as W +import numpy as np +import pytest +from cadetgui.characterization.parameter_store import Provenance +from cadetgui.characterization.runner import AVERAGE_TAG, StepResult, build +from cadetgui.characterization.study import Study +from cadetgui.widgets.composite import CharacterizationStepWidget, ParameterStoreWidget + +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=UserWarning) + +MANIFEST = ( + Path(__file__).parent.parent / "examples" / "data" / "characterization_akta" / "manifest.json" +) +LENGTH = "flow_sheet.tubing_pre_column.length" +DISPERSION = "flow_sheet.tubing_pre_column.axial_dispersion" + + +@pytest.fixture +def study() -> Study: + return Study.load(MANIFEST) + + +@pytest.fixture +def periphery(study) -> CharacterizationStepWidget: + return CharacterizationStepWidget(study, "Extra-column volume") + + +def periphery_posterior(study: Study): + return study.initial_store.updated( + {LENGTH: 0.42, DISPERSION: {"SystemTracer": 4.5e-7}}, + Provenance(step="Extra-column volume", probe="SystemTracer"), + ) + + +class TestSetup: + def test_shows_the_manifest_step(self, periphery): + form = periphery.form + assert form.name.value == "Extra-column volume" + assert form.stage.value == "tubing" + assert form._option_fields["tubing"].value == "tubing_pre_column" + assert form._option_fields["tubing"].option_labels == ["Tubing (pre column)"] + assert form.selected_names() == ["System pulse 1 (UV)", "System pulse 2 (UV)"] + lb, ub = form._bound_fields["tubing_pre_column_length"] + assert (lb.value, ub.value) == (0.1, 1.0) + assert periphery.get_value().bounds == periphery.study.steps[0].bounds + + def test_constructing_does_not_touch_the_study(self, study): + before = list(study.steps) + CharacterizationStepWidget(study, "Extra-column volume") + assert all(a is b for a, b in zip(study.steps, before)) + assert len(study.steps) == 2 + + def test_multi_objective_step_only_offers_u_nsga3(self, periphery): + form = periphery.form + assert form.optimizer.option_labels == ["U-NSGA-III"] + + form._comparison_boxes["System pulse 2 (UV)"].value = False + assert set(form.optimizer.option_labels) == {"Nelder-Mead", "U-NSGA-III"} + + def test_chain_is_a_line_for_template_steps_and_an_editor_for_custom_ones(self, study): + widget = CharacterizationStepWidget(study, "Column packing") + form = widget.form + assert "Uses from earlier steps:" in form._chain_line.value + assert "Determines:" in form._chain_line.value + assert form._chain_box not in form._advanced_body.children + + def test_optimizer_settings_are_part_of_the_step(self, study): + study.steps[0].optimizer = {"name": "U-NSGA-III", "knobs": {"pop_size": 6, "n_max_gen": 3}} + widget = CharacterizationStepWidget(study, "Extra-column volume") + form = widget.form + assert [f.value for f in form._knob_fields["U-NSGA-III"]] == [6, 3] + + form._knob_fields["U-NSGA-III"][1].value = 9 + assert study.steps[0].optimizer == { + "name": "U-NSGA-III", "knobs": {"pop_size": 6, "n_max_gen": 9} + } + + form._comparison_boxes["System pulse 2 (UV)"].value = False + form.optimizer.value = "Nelder-Mead" + form._knob_fields["Nelder-Mead"][0].value = 25 + assert study.steps[0].optimizer == {"name": "Nelder-Mead", "knobs": {"maxiter": 25}} + assert widget.get_value().optimizer == study.steps[0].optimizer + + def test_optimizer_knobs_are_sized_and_estimated(self, periphery): + form = periphery.form + assert [f.value for f in form._knob_fields["U-NSGA-III"]] == [16, 12] + assert [f.value for f in form._knob_fields["Nelder-Mead"]] == [400] + assert "≈ 384 simulations" in form._estimate.value + assert "0 lets CADET-Process size it" in form._knob_fields["U-NSGA-III"][0].tooltip + + form._knob_fields["U-NSGA-III"][0].value = 0 + assert "CADET-Process sizes the run" in form._estimate.value + + def test_prefill_follows_the_frozen_set_but_keeps_user_edits(self, periphery, study): + form = periphery.form + maxiter = form._knob_fields["Nelder-Mead"][0] + form._fit_boxes["tubing_pre_column_axial_dispersion"].value = False + assert maxiter.value == 200 + + maxiter.value = 300 + form._fit_boxes["tubing_pre_column_axial_dispersion"].value = True + assert maxiter.value == 300 + + pop_size = form._knob_fields["U-NSGA-III"][0] + pop_size.value = 40 + form.stage.value = "adsorption" + form._option_fields["is_kinetic"].value = True + assert [f.value for f in form._knob_fields["U-NSGA-III"]] == [40, 12] + assert study.steps[0].optimizer == { + "name": "U-NSGA-III", "knobs": {"pop_size": 40, "n_max_gen": 12} + } + + def test_loaded_knobs_that_differ_from_the_sizing_count_as_edited(self, study): + study.steps[0].optimizer = {"name": "U-NSGA-III", "knobs": {"pop_size": 6, "n_max_gen": 12}} + form = CharacterizationStepWidget(study, "Extra-column volume").form + form.stage.value = "adsorption" + form._option_fields["is_kinetic"].value = True + assert [f.value for f in form._knob_fields["U-NSGA-III"]] == [6, 12] + assert [f.value for f in form._knob_fields["Nelder-Mead"]] == [600] + + def test_edits_upsert_the_step(self, periphery, study): + lb, _ = periphery.form._bound_fields["tubing_pre_column_length"] + lb.value = 0.2 + periphery.form._fit_boxes["tubing_pre_column_axial_dispersion"].value = False + + step = study.steps[0] + assert step.name == "Extra-column volume" + assert step.bounds["tubing_pre_column_length"] == (0.2, 1.0) + assert step.frozen == ("tubing_pre_column_axial_dispersion",) + assert step.provides == [LENGTH] + assert len(study.steps) == 2 + + def test_rename_keeps_position_and_posterior(self, periphery, study): + study.accept("Extra-column volume", periphery_posterior(study)) + periphery.form.name.value = "periphery" + assert [s.name for s in study.steps] == ["periphery", "Column packing"] + assert set(study.posteriors) == {"periphery"} + + def test_rename_onto_another_step_is_refused(self, periphery, study): + periphery.form.name.value = "Column packing" + assert [s.name for s in study.steps] == ["Extra-column volume", "Column packing"] + assert "already called" in periphery.status.value + + def test_new_step_is_appended_on_first_edit(self, study): + widget = CharacterizationStepWidget(study) + assert widget.step_name == "step_3" + assert len(study.steps) == 2 + + widget.form.stage.value = "bed" + assert [s.name for s in study.steps][-1] == "step_3" + assert study.steps[-1].stage == "bed" + + def test_stage_options_drive_the_variables(self, study): + widget = CharacterizationStepWidget(study, "Column packing") + form = widget.form + assert "particle_porosity" in form._bound_fields + + form._option_fields["include_particle_porosity"].value = False + assert "particle_porosity" not in form._bound_fields + assert study.steps[1].options == {"include_particle_porosity": False} + + def test_component_index_lists_component_names(self, study): + widget = CharacterizationStepWidget(study, "Column packing") + widget.form.stage.value = "particles" + field = widget.form._option_fields["component_index"] + assert field.option_labels == ["SmallTracer"] + assert field.value == 0 + + def test_tubing_choices_are_units_all_selected_processes_have(self, study): + widget = CharacterizationStepWidget(study, "Column packing") + widget.form.stage.value = "tubing" + assert widget.form._option_fields["tubing"].option_labels == ["Tubing (pre column)"] + + for name in ("Large tracer pulse 1 (UV)", "Large tracer pulse 2 (UV)"): + widget.form._comparison_boxes[name].value = False + assert widget.form._option_fields["tubing"].option_labels == [ + "Tubing (pre column)", "Tubing (detectors)", + ] + + def test_comparison_problems_show_as_a_chip(self, study): + study.upsert_comparison(replace(study.comparison("System pulse 2 (UV)"), channel="nope")) + widget = CharacterizationStepWidget(study, "Extra-column volume") + rows = widget.form._comparison_rows.children + chips = {row.children[1].value.split("")[0][3:]: row.children[2].value for row in rows} + assert "cadetgui-msg-ok" in chips["System pulse 1 (UV)"] + assert "Unknown channel" in chips["System pulse 2 (UV)"] + + widget.start_run() + assert not widget.running + assert "Unknown channel" in widget.status.value + + def test_run_needs_a_comparison(self, periphery): + for box in periphery.form._comparison_boxes.values(): + box.value = False + periphery.start_run() + assert not periphery.running + assert "at least one measurement" in periphery.status.value + + def test_missing_requirements_warn_and_block_the_run(self, study): + widget = CharacterizationStepWidget(study, "Column packing") + widget.form.requires.value = (LENGTH,) + assert study.steps[1].requires == (LENGTH,) + (check,) = [c for c in widget.checks if c.id.startswith("requires")] + assert check.status == "warn" + assert "length (Tubing (pre column))" in check.text + assert "accept Extra-column volume first" in check.text + + widget.start_run() + assert not widget.running + assert "Missing requirements" in widget.status.value + + study.accept("Extra-column volume", periphery_posterior(study)) + (check,) = [c for c in widget.checks if c.id.startswith("requires")] + assert check.status == "ok" + + def test_follows_step_changes_made_elsewhere(self, periphery, study): + study.upsert_step(replace(study.steps[0], bounds={"tubing_pre_column_length": (0.3, 0.9)})) + lb, ub = periphery.form._bound_fields["tubing_pre_column_length"] + assert (lb.value, ub.value) == (0.3, 0.9) + + +class TestSpeciesGaps: + def test_gap_after_the_periphery_step_is_transferred_into_its_posterior(self, study): + study.accept("Extra-column volume", periphery_posterior(study)) + widget = CharacterizationStepWidget(study, "Column packing") + assert ( + "Column packing needs axial dispersion (Tubing (pre column)) for SmallTracer, " + "LargeTracer; Extra-column volume fitted it for SystemTracer" in widget._gaps.value + ) + assert widget._btn_transfer.layout.display == "" + + widget.start_run() + assert not widget.running + assert "transfer them first" in widget.status.value + + widget.transfer_species_gaps() + prior = study.prior_for("Column packing") + assert prior is study.posteriors["Extra-column volume"] + assert prior.value(DISPERSION) == { + "SystemTracer": 4.5e-7, "SmallTracer": 4.5e-7, "LargeTracer": 4.5e-7, + } + assert "transferred" in prior.entries[DISPERSION].provenance.note + assert widget._gaps.value == "" + + def test_without_an_accepted_step_the_initial_store_is_updated(self, study): + study.initial_store = study.initial_store.updated( + {DISPERSION: {"SystemTracer": 1e-7}}, Provenance(step="initial", note="assumed") + ) + widget = CharacterizationStepWidget(study, "Column packing") + widget.transfer_species_gaps() + assert not study.posteriors + assert study.prior_for("Column packing").value(DISPERSION)["SmallTracer"] == 1e-7 + + +def fake_result(widget: CharacterizationStepWidget) -> StepResult: + setup = widget.get_value() + built = build(setup, widget.study.prior_for(setup.name)) + return StepResult( + step=setup.name, variable_names=list(built.problem.independent_variable_names), + objective_names=setup.objective_names, + x=np.array([[0.42, 4.5e-7], [0.6, 2e-6], [0.3, 1e-7]]), + f=np.array([[0.05, 0.06], [0.2, 0.01], [0.01, 0.3]]), + success=True, message="Optimization finished.", cancelled=False, + optimizer_name="U-NSGA-III", built=built, + ) + + +def show_result(widget: CharacterizationStepWidget, result: StepResult) -> None: + widget._run_setup = widget.get_value() + widget._run_prior = widget.study.prior_for(widget.step_name) + widget._progress = {"result": result} + widget.form.set_locked(True) + widget._finish(time.monotonic()) + + +class TestResults: + def test_candidates_preview_and_accept(self, periphery, study, tmp_path): + result = fake_result(periphery) + length_before = result.built.processes[0].flow_sheet.tubing_pre_column.length + show_result(periphery, result) + + assert periphery.results.table.columns == [ + "Tags", *result.variable_names, + "System pulse 1 (UV) (NRMSE)", "System pulse 2 (UV) (NRMSE)", + ] + assert periphery.candidates[0].tags[0] == AVERAGE_TAG + assert periphery.results.selected is periphery.candidates[0] + assert len(periphery.results._charts.children) == 2 + assert result.built.processes[0].flow_sheet.tubing_pre_column.length == length_before + assert not study.posteriors + assert periphery.form.name.disabled + assert periphery._btn_edit.layout.display == "" + + path = periphery.export_candidates_csv(tmp_path / "candidates.csv") + rows = list(csv.reader(path.open())) + assert rows[0] == [ + "tags", *result.variable_names, "System pulse 1 (UV)", "System pulse 2 (UV)", + ] + assert len(rows) == 1 + len(periphery.candidates) + + periphery.results.table.value = 1 + periphery.results.btn_accept.click() + store = study.posteriors["Extra-column volume"] + chosen = periphery.candidates[1] + assert store.value(LENGTH) == pytest.approx(chosen.x[0]) + assert store.entries[LENGTH].provenance.step == "Extra-column volume" + assert periphery.results.btn_accept.disabled + + def test_edit_setup_discards_an_unaccepted_result(self, periphery, study): + show_result(periphery, fake_result(periphery)) + periphery.edit_setup() + assert periphery.result is None + assert not periphery.form.name.disabled + assert not periphery.running + assert not study.posteriors + + +@pytest.mark.slow +def test_runs_accepts_and_the_store_view_shows_the_provenance(study): + widget = CharacterizationStepWidget(study, "Extra-column volume") + view = ParameterStoreWidget(study) + fields = widget.form._knob_fields["U-NSGA-III"] + fields[0].value, fields[1].value = 4, 2 + + widget.start_run() + assert widget.running + assert widget.form.name.disabled + assert widget.wait(timeout=600) + + assert not widget.running + assert widget.result is not None and widget.result.success, widget.status.value + assert widget.candidates + assert widget.results.selected.tags[0] == AVERAGE_TAG + assert widget._progress_chart.series + series_names = [s["name"] for s in widget._progress_chart.series] + assert series_names == ["System pulse 1 (UV)", "System pulse 2 (UV)"] + assert "Final front: best-average candidate" in widget._live_title.value + assert all(chart.series for _label, chart in widget._live_chart_widgets) + assert "tubing_pre_column_length" in widget._best_values.value + assert not study.posteriors + + widget.accept() + + assert study.current_store.entries[LENGTH].provenance.step == "Extra-column volume" + labels = view._table.option_labels + row = view._table.rows[labels.index(LENGTH)] + assert row[4]["text"] == "Extra-column volume" + assert "System pulse 1 (UV)_NRMSE" in row[7]["text"] + + +class TestHeader: + def test_describes_the_chain_step_open_until_accepted(self, periphery, study): + from cadetgui.characterization.guide import CHAIN_BY_ID + + guide = CHAIN_BY_ID["system_periphery"] + header = periphery.header.value + + assert periphery.guide is guide + assert header.startswith("
    ") + assert "About this step: Extra-column volume" in header + assert "
      " not in header + assert all(item not in header for item in guide.advice) + assert "System pulse (no column in line)" in header + assert "Reading the results" in header + assert 'data-highlight="true"' in header + assert 'data-observe="tubing_pre_column"' in header + + study.accept("Extra-column volume", periphery_posterior(study)) + + assert periphery.header.value.startswith("
      ") + + def test_hidden_for_a_step_outside_the_chain(self, study): + from cadetgui.characterization.runner import StepSetup + + study.upsert_step( + StepSetup(name="mine", stage="tubing", options={"tubing": "tubing_detectors"}) + ) + widget = CharacterizationStepWidget(study, "mine") + + assert widget.guide is None + assert widget.header.value == "" + assert widget._header_box.layout.display == "none" + + def test_labels_carry_glossary_tips(self, periphery): + import html + + from cadetgui.characterization.guide import GLOSSARY + + texts = " ".join( + w.value for w in _walk(periphery.root) if isinstance(getattr(w, "value", None), str) + ) + for term in ("prior", "posterior", "Pareto candidate", "best average", "NRMSE", + "fixed variable", "measurement"): + assert f'data-tip="{html.escape(GLOSSARY[term], quote=True)}"' in texts, term + + def test_old_frozen_term_resolves_to_the_fixed_entry(self): + from cadetgui.characterization.guide import GLOSSARY + from cadetgui.widgets._help import term_html + + assert term_html("frozen variable") == term_html("fixed variable", "frozen variable") + assert "cadetgui-term" in term_html("frozen variable") + assert "Fit box" in GLOSSARY["fixed variable"] + + def test_species_gap_links_the_glossary(self, study): + study.accept("Extra-column volume", periphery_posterior(study)) + widget = CharacterizationStepWidget(study, "Column packing") + + assert "(species transfer)" in widget._gaps.value + + +def _walk(widget): + yield widget + for child in getattr(widget, "children", ()): + yield from _walk(child) + + +def _texts(widget) -> str: + return " ".join( + w.value for w in _walk(widget) if isinstance(getattr(w, "value", None), str) + ) + + +class TestGuidedSetup: + def test_chain_step_shows_a_sentence_and_hides_stage_and_options(self, periphery): + form = periphery.form + + assert form.guided + assert ( + "Fits the extra-column dead volume and its band broadening, represented as the " + "length and axial dispersion of the pre-column tubing." in form.summary.value + ) + assert form._plain_slot.children == () + assert form.advanced.layout.display == "" + assert form.advanced.selected_index is None + assert form._stage_controls in form._advanced_body.children + assert form.stage in list(_walk(form.advanced)) + + def test_step_type_is_labelled_and_explained_without_stage_wording(self, periphery): + from cadetgui.characterization.guide import STEP_TYPE_HELP + + form = periphery.form + assert form.stage.label == "Step type:" + assert form.advanced.titles[0] == "Advanced: step type and options" + assert STEP_TYPE_HELP["tubing"] in form._stage_help.value + form.stage.value = "bed" + assert STEP_TYPE_HELP["bed"] in form._stage_help.value + + def test_rendered_step_has_no_stage_wording(self, study): + for name in ("Extra-column volume", "Column packing"): + root = CharacterizationStepWidget(study, name).root + parts = [] + for w in _walk(root): + if isinstance(w, W.HTML) and "from the process setup" + ) + assert "m2IV/s" in cells["tubing_pre_column_axial_dispersion"].value + + def test_a_later_step_starts_from_the_accepted_value(self, study): + from cadetgui.characterization.runner import StepSetup + + pulses = [c for c in study.comparisons if c.experiment_type == "system_pulse"] + study.upsert_step(StepSetup( + name="Refit tubing", stage="tubing", options={"tubing": "tubing_pre_column"}, + comparisons=pulses, + )) + widget = CharacterizationStepWidget(study, "Refit tubing") + cell = widget.form._current_cells["tubing_pre_column_length"] + assert "from the process setup" in cell.value + + study.accept("Extra-column volume", periphery_posterior(study)) + + assert cell.value == "0.42 m
      from Extra-column volume" + dispersion = widget.form._current_cells["tubing_pre_column_axial_dispersion"].value + assert dispersion.startswith("4.5e-07 ") + + def test_species_values_are_shown_per_component_with_their_source(self, study): + from cadetgui.characterization.guide import ( + ASSUMED_BY_TYPE, + EXPERIMENT_TYPES, + STARTING_VALUE, + starting_values, + with_implied_values, + ) + from cadetgui.characterization.runner import StepSetup + + tracers = study.steps[1].comparisons + setup = StepSetup( + name="Tracer transport", stage="particles", + options={"include_film_diffusion": True}, comparisons=tracers, + ) + values = starting_values(study, setup) + film = values["film_diffusion"] + assert film.source == STARTING_VALUE + assert film.text == "SmallTracer: 8e-05; LargeTracer: 0" + assert film.unit == r"\frac{\mathrm{m}}{\mathrm{s}}" + + large = [c for c in tracers if c.probe == "LargeTracer"] + study.initial_store = with_implied_values( + study.initial_store, EXPERIMENT_TYPES["column_pulse_large_tracer"], "LargeTracer" + ) + values = starting_values(study, StepSetup( + name="Large only", stage="particles", + options={"include_film_diffusion": True}, comparisons=large, + )) + assert values["film_diffusion"].text == "0" + assert values["film_diffusion"].source == ASSUMED_BY_TYPE + + +class TestFitCheckbox: + def test_unticking_fit_fixes_the_variable(self, periphery, study): + box = periphery.form._fit_boxes["tubing_pre_column_axial_dispersion"] + assert box.value and box.description == "Fit" + + box.value = False + assert study.steps[0].frozen == ("tubing_pre_column_axial_dispersion",) + box.value = True + assert study.steps[0].frozen == () + + def test_a_loaded_frozen_variable_shows_unticked(self, study): + study.steps[0].frozen = ("tubing_pre_column_length",) + form = CharacterizationStepWidget(study, "Extra-column volume").form + assert not form._fit_boxes["tubing_pre_column_length"].value + assert form._fit_boxes["tubing_pre_column_axial_dispersion"].value + assert "fixed" in _texts(form._variables_box) + assert "Frozen" not in _texts(form._variables_box) + + +class Navigation: + def __init__(self) -> None: + self.calls: list = [] + + def __call__(self, target, context) -> None: + self.calls.append((target, dict(context))) + + +def pulse_copy(study: Study, name: str, source: str = "System pulse 1 (UV)"): + return replace(study.comparison(source), name=name) + + +class TestMeasurementSelection: + def test_a_chain_step_lists_only_measurements_that_feed_it(self, periphery): + assert list(periphery.form._comparison_boxes) == [ + "System pulse 1 (UV)", "System pulse 2 (UV)", + ] + assert "untick one to leave it out" in periphery.form._comparison_note.value + + def test_exclusion_persists_and_new_fitting_measurements_are_included(self, study): + widget = CharacterizationStepWidget(study, "Extra-column volume") + widget.form._comparison_boxes["System pulse 2 (UV)"].value = False + assert study.steps[0].objective_names == ["System pulse 1 (UV)"] + + study.upsert_comparison(pulse_copy(study, "System pulse 3 (UV)")) + study.upsert_comparison(pulse_copy(study, "Small 3", "Small tracer pulse 1 (conductivity)")) + + assert study.steps[0].objective_names == ["System pulse 1 (UV)", "System pulse 3 (UV)"] + boxes = widget.form._comparison_boxes + assert list(boxes) == [ + "System pulse 1 (UV)", "System pulse 2 (UV)", "System pulse 3 (UV)", + ] + assert not boxes["System pulse 2 (UV)"].value + packing = study.steps[1].objective_names + assert "Small 3" not in packing + + def test_a_widget_constructed_later_keeps_the_exclusion(self, study): + study.upsert_step(replace(study.steps[0], comparisons=study.steps[0].comparisons[:1])) + widget = CharacterizationStepWidget(study, "Extra-column volume") + assert not widget.form._comparison_boxes["System pulse 2 (UV)"].value + study.notify() + assert study.steps[0].objective_names == ["System pulse 1 (UV)"] + + def test_an_unticked_measurement_is_saved_with_the_study(self, study, tmp_path): + widget = CharacterizationStepWidget(study, "Extra-column volume") + widget.form._comparison_boxes["System pulse 2 (UV)"].value = False + assert study.steps[0].excluded == ("System pulse 2 (UV)",) + + reloaded = Study.load(study.save(tmp_path / "study.json")) + reopened = CharacterizationStepWidget(reloaded, "Extra-column volume") + boxes = reopened.form._comparison_boxes + assert {n: b.value for n, b in boxes.items()} == { + "System pulse 1 (UV)": True, "System pulse 2 (UV)": False, + } + + reloaded.upsert_comparison(pulse_copy(reloaded, "System pulse 3 (UV)")) + assert reloaded.steps[0].objective_names == ["System pulse 1 (UV)", "System pulse 3 (UV)"] + + reopened.form._comparison_boxes["System pulse 2 (UV)"].value = True + assert reloaded.steps[0].excluded == () + + def test_only_recorded_exclusions_are_kept_from_a_loaded_step(self, study): + step = study.steps[0] + study.upsert_step(replace(step, comparisons=step.comparisons[:1], excluded=())) + widget = CharacterizationStepWidget(study, "Extra-column volume") + assert not widget.form._comparison_boxes["System pulse 2 (UV)"].value + study.notify() + assert study.steps[0].objective_names == ["System pulse 1 (UV)", "System pulse 2 (UV)"] + + def test_a_custom_step_lists_every_measurement_and_includes_none_automatically(self, study): + from cadetgui.characterization.runner import StepSetup + + study.upsert_step( + StepSetup(name="mine", stage="bed", options={"include_particle_porosity": False}) + ) + widget = CharacterizationStepWidget(study, "mine") + assert list(widget.form._comparison_boxes) == [c.name for c in study.comparisons] + assert widget.form._comparison_note.value == "" + + study.upsert_comparison(pulse_copy(study, "System pulse 3 (UV)")) + assert "System pulse 3 (UV)" in widget.form._comparison_boxes + assert study.steps[-1].comparisons == [] + + def test_a_member_that_does_not_fit_is_warned_and_can_be_removed(self, study): + legacy = [*study.steps[0].comparisons, study.comparison("Large tracer pulse 1 (UV)")] + study.upsert_step(replace(study.steps[0], comparisons=legacy)) + widget = CharacterizationStepWidget(study, "Extra-column volume") + + rows = {row.children[0]: row for row in widget.form._comparison_rows.children} + box = widget.form._comparison_boxes["Large tracer pulse 1 (UV)"] + warning = rows[box].children[3].value + assert box.value + assert "Does not fit this step" in warning + assert "Column pulse, large tracer (UV) feeds Column packing" in warning + + box.value = False + assert "Large tracer pulse 1 (UV)" not in study.steps[0].objective_names + widget.form.refresh_comparisons() + assert "Large tracer pulse 1 (UV)" not in widget.form._comparison_boxes + + def test_no_fitting_measurement_offers_to_add_one(self, study): + from cadetgui.characterization.guide import CHAIN_BY_ID, new_step_setup + + study.upsert_step(new_step_setup(CHAIN_BY_ID["particle_transport"], [])) + navigate = Navigation() + widget = CharacterizationStepWidget(study, "Particle transport", on_navigate=navigate) + form = widget.form + + assert form._comparison_boxes == {} + assert "No measurement fits this step yet" in form._no_fitting.value + assert "Non-binding protein pulse" in form._no_fitting.value + assert form._btn_add_measurement.layout.display == "" + form._btn_add_measurement.click() + assert navigate.calls == [("measurements", {"step_id": "particle_transport"})] + + standalone = CharacterizationStepWidget(study, "Particle transport") + assert standalone.form._btn_add_measurement.layout.display == "none" + + def test_a_result_on_screen_defers_new_measurements_until_edit(self, periphery, study): + show_result(periphery, fake_result(periphery)) + study.upsert_comparison(pulse_copy(study, "System pulse 3 (UV)")) + assert "System pulse 3 (UV)" not in study.steps[0].objective_names + + periphery.edit_setup() + assert "System pulse 3 (UV)" in study.steps[0].objective_names + + +def checks_by_id(widget) -> dict: + return {c.id: c for c in widget.checks} + + +class TestLiveChecks: + def test_the_example_step_passes(self, periphery): + checks = checks_by_id(periphery) + assert checks["measurements"].status == "ok" + assert checks["measurements"].text == "2 measurements attached (replicates)." + assert checks["markers"].status == "ok" + assert "Injection marker found in every measurement (2)" in checks["markers"].text + assert checks["problems"].status == "ok" + known = checks["known:flow_sheet.tubing_pre_column.diameter"] + assert known.status == "ok" and "the parameter store" in known.text + assert "Before you run" in _texts(periphery.root) + assert "cadetgui-msg-ok" in _texts(periphery._checks_box) + + def test_a_single_measurement_asks_for_a_replicate(self, study): + navigate = Navigation() + widget = CharacterizationStepWidget(study, "Extra-column volume", on_navigate=navigate) + widget.form._comparison_boxes["System pulse 2 (UV)"].value = False + check = checks_by_id(widget)["measurements"] + assert check.status == "info" and "replicate" in check.text + widget.check_buttons()["Add a replicate"].click() + assert navigate.calls == [("measurements", {"step_id": "system_periphery"})] + + def test_a_broken_measurement_links_to_it(self, study): + study.upsert_comparison(replace( + study.comparison("System pulse 2 (UV)"), injection_marker="Phase Nope", + )) + study.upsert_comparison(replace( + study.comparison("System pulse 1 (UV)"), baseline_window=(1e6, 2e6), + )) + navigate = Navigation() + widget = CharacterizationStepWidget(study, "Extra-column volume", on_navigate=navigate) + checks = checks_by_id(widget) + + marker = checks["marker:System pulse 2 (UV)"] + assert marker.status == "warn" + assert "'Phase Nope' not found in the run log" in marker.text + assert "problems:System pulse 2 (UV)" not in checks + baseline = checks["problems:System pulse 1 (UV)"] + assert "No points in the baseline fitting window" in baseline.text + + buttons = widget.check_buttons() + buttons["Open System pulse 2 (UV)"].click() + buttons["Open System pulse 1 (UV)"].click() + assert navigate.calls == [ + ("measurements", {"select": "System pulse 2 (UV)"}), + ("measurements", {"select": "System pulse 1 (UV)"}), + ] + assert "Open System pulse 2 (UV)" not in CharacterizationStepWidget( + study, "Extra-column volume" + ).check_buttons() + + def test_missing_requirement_links_the_earlier_step(self, study): + navigate = Navigation() + widget = CharacterizationStepWidget(study, "Column packing", on_navigate=navigate) + widget.check_buttons()["Open Extra-column volume"].click() + assert navigate.calls == [("step", {"name": "Extra-column volume"})] + + def test_species_gap_offers_the_transfer(self, study): + study.accept("Extra-column volume", periphery_posterior(study)) + widget = CharacterizationStepWidget(study, "Column packing") + assert checks_by_id(widget)["species_gaps"].status == "warn" + assert widget._btn_transfer in list(_walk(widget._checks_box)) + + widget.check_buttons()["Transfer to this step's species"].click() + assert "species_gaps" not in checks_by_id(widget) + assert study.prior_for("Column packing").value(DISPERSION)["LargeTracer"] == 4.5e-7 + + def test_a_missing_assumption_is_recorded_explicitly(self, study): + from cadetgui.characterization.step_checks import study_steps_status + + film = "flow_sheet.column.film_diffusion" + entry = study.initial_store.entries[film] + entries = dict(study.initial_store.entries) + entries[film] = replace(entry, value={"SmallTracer": entry.value["SmallTracer"]}) + study.initial_store = replace(study.initial_store, entries=entries) + study.accept("Extra-column volume", periphery_posterior(study)) + widget = CharacterizationStepWidget(study, "Column packing") + + status = study_steps_status(study)[1] + assert status.next_action.startswith("The experiment types assume values") + assert all("film_diffusion" not in path for path in status.species_gaps) + + check = checks_by_id(widget)["assumptions"] + assert "film diffusion (Column) = 0 for LargeTracer" in check.text + assert "film diffusion" not in checks_by_id(widget)["species_gaps"].text + widget.start_run() + assert "Record the values the experiment types assume first" in widget.status.value + + widget.transfer_species_gaps() + assert "LargeTracer" not in study.prior_for("Column packing").value(film) + widget.check_buttons()["Record the assumptions"].click() + prior = study.prior_for("Column packing") + assert prior.value(film)["LargeTracer"] == 0.0 + assert "assumed" in prior.entries[film].provenance.step + assert "assumptions" not in checks_by_id(widget) + + @staticmethod + def _without_pre_column_diameter(study): + from cadetgui.characterization.parameter_store import ParameterStore + + store = study.initial_store + study.initial_store = ParameterStore( + specs=store.specs, + entries={ + k: v for k, v in store.entries.items() if not k.endswith("pre_column.diameter") + }, + step=store.step, + ) + + def test_a_default_hardware_value_is_a_note_with_its_value_and_two_actions(self, study): + self._without_pre_column_diameter(study) + navigate = Navigation() + widget = CharacterizationStepWidget(study, "Extra-column volume", on_navigate=navigate) + check = checks_by_id(widget)["known:flow_sheet.tubing_pre_column.diameter"] + + assert check.status == "info" + assert check.text == ( + "Pre-column tubing diameter: using the default 0.5 mm. Is that your hardware?" + ) + assert "not set" not in check.text + buttons = widget.check_buttons() + assert {"Use this value", "Edit in System"} <= set(buttons) + assert widget._known_fields["flow_sheet.tubing_pre_column.diameter"].value == 0.5 + + buttons["Edit in System"].click() + assert navigate.calls == [("system", {"unit": "tubing_pre_column"})] + assert "Before you run" in _texts(widget.root) + standalone = CharacterizationStepWidget(study, "Extra-column volume").check_buttons() + assert "Use this value" in standalone and "Edit in System" not in standalone + + def test_an_unconfirmed_default_does_not_block_the_run(self, study, monkeypatch): + self._without_pre_column_diameter(study) + widget = CharacterizationStepWidget(study, "Extra-column volume") + started = [] + monkeypatch.setattr(threading.Thread, "start", lambda self: started.append(self)) + widget.start_run() + assert len(started) == 2 and "Fitting Extra-column volume" in widget.status.value + + def test_use_this_value_records_the_confirmed_default(self, study): + self._without_pre_column_diameter(study) + study.accept("Extra-column volume", periphery_posterior(study)) + widget = CharacterizationStepWidget(study, "Extra-column volume") + path = "flow_sheet.tubing_pre_column.diameter" + + widget.check_buttons()["Use this value"].click() + + entry = study.initial_store.entries[path] + assert entry.value == pytest.approx(0.5e-3) + assert entry.provenance.step == "initial" + assert entry.provenance.note == "confirmed default" + assert entry.provenance.source == "known hardware" + assert study.initial_store.step == "initial" + assert study.posteriors["Extra-column volume"].entries[path].value == pytest.approx(5e-4) + check = checks_by_id(widget)[f"known:{path}"] + assert check.status == "ok" + assert check.text == ( + "Pre-column tubing diameter: 0.5 mm (confirmed default, in the parameter store)." + ) + assert "Use this value" not in widget.check_buttons() + assert "known hardware" in widget.status.value + + def test_an_edited_value_is_recorded_as_known_hardware(self, study): + self._without_pre_column_diameter(study) + widget = CharacterizationStepWidget(study, "Extra-column volume") + path = "flow_sheet.tubing_pre_column.diameter" + + widget._known_fields[path].value = 0.75 + widget.check_buttons()["Use this value"].click() + + entry = study.initial_store.entries[path] + assert entry.value == pytest.approx(0.75e-3) + assert entry.provenance.note == "known hardware" + check = checks_by_id(widget)[f"known:{path}"] + assert check.status == "ok" and "0.75 mm" in check.text + + def test_advice_moves_into_the_help(self, periphery): + from cadetgui.characterization.guide import CHAIN_BY_ID + + header = periphery.header.value + assert "Also check" not in header and "are in the Guide" in header + assert "baseline window is set" not in header + assert all("marker" not in item for item in CHAIN_BY_ID["system_periphery"].advice) + + def test_the_workbench_wires_navigation_and_selects_measurements(self, study): + from cadetgui.widgets.composite import CharacterizationWorkbenchWidget + + workbench = CharacterizationWorkbenchWidget(study=study) + step = workbench.steps["Extra-column volume"] + assert step.on_navigate == workbench.navigate + + workbench.navigate("measurements", {"select": "System pulse 2 (UV)"}) + assert workbench.comparisons.selected.name == "System pulse 2 (UV)" + + +class FakeOptimizer: + """Mimics the part of a running optimizer the progress ticker reads.""" + + def __init__(self) -> None: + self.results = SimpleNamespace(populations=[], pareto_fronts=[], meta_front=None) + + def add_generation(self, x, f) -> None: + f = np.asarray(f, dtype=float) + self.results.populations.append(SimpleNamespace(f_min=f.min(axis=0))) + front = SimpleNamespace(x_independent=np.asarray(x, dtype=float), f=f) + self.results.pareto_fronts.append(front) + self.results.meta_front = front + + +def fake_run(widget, generations, delay): + """Return a stand-in for `runner.run` that yields `generations`.""" + result = fake_result(widget) + + def run(setup, store, *, on_optimizer_ready, cancel_event, **_kwargs): + optimizer = FakeOptimizer() + on_optimizer_ready(optimizer) + for x, f in generations: + time.sleep(delay) + optimizer.add_generation(x, f) + if cancel_event.is_set(): + break + time.sleep(delay) + return result + + return run, result + + +class TestLiveFitPlot: + GENERATIONS = [ + ([[0.3, 1e-7], [0.6, 2e-6]], [[0.4, 0.5], [0.3, 0.2]]), + ([[0.42, 4.5e-7], [0.6, 2e-6]], [[0.05, 0.06], [0.3, 0.2]]), + ([[0.42, 4.5e-7], [0.41, 4e-7]], [[0.05, 0.06], [0.04, 0.05]]), + ([[0.42, 4.5e-7], [0.41, 4e-7]], [[0.05, 0.06], [0.04, 0.05]]), + ] + + @pytest.fixture + def spied(self, monkeypatch, periphery): + from cadetgui.characterization import runner as characterization_runner + from cadetgui.characterization.comparison import Comparison + + run, result = fake_run(periphery, self.GENERATIONS, delay=0.15) + monkeypatch.setattr(characterization_runner, "run", run) + periphery._TICK_SECONDS = 0.02 + + spy = SimpleNamespace(active=0, max_active=0, live_calls=0, built=[], lock=threading.Lock()) + evaluate, posterior = Comparison.evaluate, characterization_runner.posterior + + def slow_evaluate(comparison, store): + live = threading.current_thread().name == "live-fit-plot" + if live: + with spy.lock: + spy.active += 1 + spy.live_calls += 1 + spy.max_active = max(spy.max_active, spy.active) + time.sleep(0.2) + try: + return evaluate(comparison, store) + finally: + if live: + with spy.lock: + spy.active -= 1 + + def spy_posterior(setup, built, store, candidate): + spy.built.append(built) + return posterior(setup, built, store, candidate) + + monkeypatch.setattr(Comparison, "evaluate", slow_evaluate) + monkeypatch.setattr(characterization_runner, "posterior", spy_posterior) + return periphery, result, spy + + def test_updates_from_the_run_without_touching_its_processes(self, spied): + widget, result, spy = spied + length = result.built.processes[0].flow_sheet.tubing_pre_column.length + + widget.start_run() + assert widget.live_plot.value + assert len(widget._live_chart_widgets) == 2 + assert widget.wait(timeout=120) + + assert widget.live_update_seconds + assert spy.live_calls == 2 * len(widget.live_update_seconds) + assert spy.max_active == 1 + assert all(built is not result.built for built in spy.built) + assert result.built.processes[0].flow_sheet.tubing_pre_column.length == length + assert widget._live_built is not result.built + + assert "Final front: best-average candidate" in widget._live_title.value + for label, chart in widget._live_chart_widgets: + assert "NRMSE" in label.value + assert [s["name"] for s in chart.series] == ["Simulated", "Measured"] + + def test_updates_never_overlap(self, spied): + widget, _result, spy = spied + widget.start_run() + assert widget.wait(timeout=120) + assert spy.max_active == 1 + assert 1 <= len(widget.live_update_seconds) < len(self.GENERATIONS) + + def test_a_busy_update_is_skipped(self, periphery): + from cadetgui.characterization.runner import Candidate + + candidate = Candidate(index=0, x=(0.42, 4.5e-7), f=(0.05, 0.06), tags=("best average",)) + periphery._run_setup = periphery.get_value() + periphery._run_prior = periphery.study.prior_for(periphery.step_name) + periphery._live_busy.acquire() + try: + assert not periphery._start_live_update(candidate, 1) + finally: + periphery._live_busy.release() + assert periphery._live_thread is None + + def test_can_be_switched_off(self, spied): + widget, _result, spy = spied + widget.live_plot.value = False + widget.start_run() + assert widget.wait(timeout=120) + assert spy.live_calls == 0 + assert "Final front" in widget._live_title.value + + def test_best_values_show_in_the_results_after_the_run_only(self, spied): + widget, _result, _spy = spied + seen = [] + widget._best_values.observe(lambda c: seen.append((widget.running, c["new"])), "value") + widget.start_run() + assert widget.wait(timeout=120) + assert all(not value for running, value in seen if running) + assert widget._best_values not in list(_walk(widget._progress_box)) + table = widget._best_values.value + assert "Best average of the final front" in table + assert "tubing_pre_column_length" in table + assert "0.42m" in table + assert "tubing_pre_column_axial_dispersion" in table + assert "System pulse 1 (UV)0.05" in table + + def test_variable_names_match_the_problem(self, study): + from cadetgui.characterization.runner import fitted_variables + from cadetgui.characterization.stages import describe_stage + + for setup in study.steps: + built = build(setup, study.initial_store.updated( + {LENGTH: 0.4, DISPERSION: {"SystemTracer": 1e-7, "SmallTracer": 1e-7, + "LargeTracer": 1e-7}}, + Provenance(step="test"), + )) + names = fitted_variables(describe_stage(setup.stage, **setup.options), setup.frozen) + assert names == list(built.problem.independent_variable_names) + + +def test_posterior_reads_each_probe_from_its_own_process(study): + from cadetgui.characterization.runner import Candidate, posterior + + prior = periphery_posterior(study).updated( + {DISPERSION: {"SmallTracer": 4.5e-7, "LargeTracer": 4.5e-7}}, Provenance(step="t") + ) + setup = study.steps[1] + built = build(setup, prior) + problem = built.problem + x = tuple( + (lb + ub) / 2 + for lb, ub in zip(problem.lower_bounds_independent, problem.upper_bounds_independent) + ) + store = posterior(setup, built, prior, Candidate(0, x, (0.0,) * 4, ("best average",))) + assert set(store.value("flow_sheet.column.axial_dispersion")) == {"SmallTracer", "LargeTracer"} + + +def test_hardware_values_read_in_lab_units(): + from cadetgui.characterization.step_checks import known_value_label, quantity_text + + assert quantity_text(0.5e-3, r"\mathrm{m}") == ("0.5 mm", 1e-3, "mm") + assert quantity_text(5e-6, r"\mathrm{m}")[0] == "5 µm" + assert quantity_text(1.2, r"\frac{\mathrm{mol}}{\mathrm{m}^{3}}")[0] == "1.2 mol/m³" + assert known_value_label("flow_sheet.tubing_pre_column.diameter") == ( + "Pre-column tubing diameter" + ) + assert known_value_label("flow_sheet.column.length") == "Column length" + assert known_value_label("flow_sheet.column.binding_model.capacity") == "Ionic capacity" + + +def test_removing_a_measurement_from_measurements_discards_the_shown_fit(periphery, study): + from cadetgui.widgets.composite import ComparisonsWidget + + show_result(periphery, fake_result(periphery)) + assert periphery._lock_note.layout.display == "" + measurements = ComparisonsWidget(study) + measurements.select("System pulse 2 (UV)") + + measurements._btn_remove.click() + assert measurements._remove_confirm.layout.display == "" + assert "Extra-column volume" in measurements._remove_note.value + assert "System pulse 2 (UV)" in [c.name for c in study.comparisons] + + measurements._btn_remove_confirm.click() + assert "System pulse 2 (UV)" not in [c.name for c in study.comparisons] + assert [c.name for c in study.steps[0].comparisons] == ["System pulse 1 (UV)"] + assert periphery.result is None + assert not periphery.form.name.disabled + assert "fit result was discarded" in periphery.status.value diff --git a/tests/test_characterization_workbench.py b/tests/test_characterization_workbench.py new file mode 100644 index 0000000..90ce4b5 --- /dev/null +++ b/tests/test_characterization_workbench.py @@ -0,0 +1,507 @@ +from __future__ import annotations + +import warnings +from pathlib import Path + +import cadetgui.io.configuration_store as configuration_store +import ipywidgets as W +import pytest +from cadetgui import process_builder +from cadetgui.characterization.guide import guide_for_step +from cadetgui.characterization.parameter_store import Provenance +from cadetgui.characterization.study import Study +from cadetgui.widgets.composite import ( + CharacterizationStepWidget, + CharacterizationWorkbenchWidget, + ConfigurationWidget, + InstrumentWidget, +) +from cadetgui.widgets.composite.characterization_workbench import CONFIGURATION_INTRO +from cadetgui.widgets.composite.parameter_store_view import chain_warnings + +warnings.filterwarnings("ignore", category=UserWarning) +warnings.filterwarnings("ignore", category=DeprecationWarning) + +MANIFEST = ( + Path(__file__).parent.parent / "examples" / "data" / "characterization_akta" / "manifest.json" +) +FIXED_PANES = [ + "Setup", "System: Instrument", "System: Advanced configuration", "Measurements", +] + + +@pytest.fixture(autouse=True) +def _isolated_store(tmp_path, monkeypatch): + """Redirect the configuration store to a tmp dir -- never touch the real one.""" + monkeypatch.setattr(configuration_store, "default_store_dir", lambda: tmp_path) + + +@pytest.fixture(scope="module") +def example() -> CharacterizationWorkbenchWidget: + return CharacterizationWorkbenchWidget.from_file(MANIFEST) + + +def _keys(wb): + return [o[1] if isinstance(o, tuple) else o for o in wb._nav.options] + + +def _warnings(wb): + return dict(zip(_keys(wb), wb._nav.tooltips)) + + +def test_loading_and_switching_panes_build_each_recipe_once(monkeypatch): + process_builder.clear_recipe_checks() + calls = [] + original = process_builder.build_process + + def counting(*args, **kwargs): + calls.append(args) + return original(*args, **kwargs) + + monkeypatch.setattr(process_builder, "build_process", counting) + wb = CharacterizationWorkbenchWidget.from_file(MANIFEST) + recipes = {process_builder.recipe_key(c.recipe, c.overrides) for c in wb.study.comparisons} + assert len(calls) == len(recipes) + + calls.clear() + wb.navigate("measurements") + for name in wb.steps: + wb.navigate("step", {"name": name}) + for target in ("parameters", "guide", "system", "setup"): + wb.navigate(target) + assert calls == [] + + +def test_empty_study_builds_the_fixed_panes(): + wb = CharacterizationWorkbenchWidget() + + assert list(wb._shell.panes) == [*FIXED_PANES, "Parameters", "Guide"] + assert _keys(wb) == [ + "Setup", "System", "Measurements", "Parameters", "Guide", + ] + assert wb._shell.current == "Setup" + assert wb.steps == {} + assert wb.comparisons.study is wb.study is wb.parameters.study is wb.setup.study + + +def test_default_instrument_and_configuration_are_seeded(): + wb = CharacterizationWorkbenchWidget() + + for unit in ("tubing_pre_injection", "tubing_detectors", "mixer"): + assert unit not in wb.instrument.bypass_units() + column = wb.configuration.process.flow_sheet.column + assert hasattr(column, "bed_porosity") + assert hasattr(column.binding_model, "capacity") + + +def test_accepts_prebuilt_instrument_and_configuration_unmodified(): + iw = InstrumentWidget() + cw = ConfigurationWidget(instrument=iw) + + wb = CharacterizationWorkbenchWidget(instrument=iw, configuration=cw) + + assert wb.instrument is iw + assert wb.configuration is cw + assert wb.comparisons.configuration is cw + assert "mixer" in wb.instrument.bypass_units() + + +def test_include_narrows_the_panes(): + wb = CharacterizationWorkbenchWidget(include=("Measurements", "Parameters")) + + assert list(wb._shell.panes) == ["Measurements", "Parameters"] + assert wb.setup is wb.guide is None + assert wb.add_step("extra") is None + assert [s.name for s in wb.study.steps] == ["extra"] + assert wb.steps == {} + for target in ("setup", "system", "guide"): + wb.navigate(target, {"unit": "column"}) + wb.navigate("step", {"name": "extra"}) + assert wb._shell.current == "Measurements" + + +def test_excluded_panes_are_not_constructed(monkeypatch): + import cadetgui.widgets.composite.characterization_workbench as module + + def refuse(*_args, **_kwargs): + raise AssertionError("an excluded pane was built") + + for name in ( + "CharacterizationSetupWidget", "ComparisonsWidget", "ParameterStoreWidget", + "CharacterizationGuideWidget", "CharacterizationStepWidget", + ): + monkeypatch.setattr(module, name, refuse) + + wb = CharacterizationWorkbenchWidget( + study=Study.load(MANIFEST), include=("System", "Advanced configuration") + ) + + assert list(wb._shell.panes) == ["System: Instrument", "System: Advanced configuration"] + assert wb.comparisons is wb.parameters is None + wb.add_step("extra") + wb.study.notify() + + +def test_include_rejects_unknown_pane(): + with pytest.raises(ValueError, match="Not A Pane"): + CharacterizationWorkbenchWidget(include=("Measurements", "Not A Pane")) + + +def test_one_step_pane_per_study_step_in_chain_order(example): + assert list(example._shell.panes) == [ + *FIXED_PANES, "Characterization steps: Extra-column volume", + "Characterization steps: Column packing", + "Parameters", "Guide", + ] + assert list(example.steps) == ["Extra-column volume", "Column packing"] + for name, widget in example.steps.items(): + assert isinstance(widget, CharacterizationStepWidget) + assert widget.study is example.study + assert example._shell.panes[f"Characterization steps: {name}"] is widget.root + + example._shell.show("Characterization steps: Column packing") + labels = [label for label, _ in example._nav.options] + assert labels[3:6] == ["▾ Characterization steps", "Extra-column volume", "Column packing"] + + +def test_example_study_flags_only_the_step_waiting_for_an_earlier_one(example): + example._shell.show("Characterization steps: Column packing") + warned = {key: text for key, text in _warnings(example).items() if text} + assert list(warned) == ["Characterization steps: Column packing"] + assert "accept Extra-column volume first" in warned["Characterization steps: Column packing"] + + +def test_add_step_adds_and_shows_a_pane_that_follows_renames(): + wb = CharacterizationWorkbenchWidget(study=Study.load(MANIFEST)) + + widget = wb.add_step() + + assert widget.step_name == "step_3" + assert wb._shell.current == "Characterization steps: step_3" + assert list(wb._shell.panes)[-3:] == ["Characterization steps: step_3", "Parameters", "Guide"] + assert "No measurements attached." in _warnings(wb)["Characterization steps: step_3"] + with pytest.raises(ValueError, match="already exists"): + wb.add_step("step_3") + + widget.form.name.value = "renamed" + + assert "Characterization steps: renamed" in wb._shell.panes + assert "Characterization steps: step_3" not in wb._shell.panes + assert wb._shell.current == "Characterization steps: renamed" + assert wb.steps["renamed"] is widget + + +def test_overview_add_custom_step_opens_it_and_reports_errors(): + wb = CharacterizationWorkbenchWidget() + setup_pane = wb.setup + assert setup_pane._custom_step.selected_index is None + setup_pane._new_step_name.value = "fit" + setup_pane._btn_add_step.click() + assert [s.name for s in wb.study.steps] == ["fit"] + assert setup_pane._new_step_name.value == "" + assert wb._shell.current == "Characterization steps: fit" + + setup_pane._new_step_name.value = "fit" + setup_pane._btn_add_step.click() + assert "already exists" in setup_pane.custom_step_status.value + + +def test_loading_another_study_rebuilds_the_step_panes(tmp_path): + wb = CharacterizationWorkbenchWidget(study=Study.load(MANIFEST)) + other = Study.load(MANIFEST) + other.steps = other.steps[1:] + path = other.save(tmp_path / "study.json") + + wb.parameters.load_study(path) + + assert list(wb.steps) == ["Column packing"] + assert "Characterization steps: Extra-column volume" not in wb._shell.panes + + +def test_comparison_problems_are_flagged(): + wb = CharacterizationWorkbenchWidget(study=Study.load(MANIFEST)) + comparison = wb.study.comparison("System pulse 1 (UV)") + + comparison.channel = "no such channel" + wb.study.notify() + + warnings_ = _warnings(wb) + assert "System pulse 1 (UV): Unknown channel" in warnings_["Measurements"] + wb._shell.show("Characterization steps: Extra-column volume") + assert "System pulse 1 (UV)" in _warnings(wb)["Characterization steps: Extra-column volume"] + + +def test_step_flags_missing_requirements_until_the_earlier_step_is_accepted(): + wb = CharacterizationWorkbenchWidget(study=Study.load(MANIFEST)) + wb._shell.show("Characterization steps: Extra-column volume") + periphery, bed = wb.study.steps + bed.requires = ("flow_sheet.tubing_pre_column.length",) + wb.study.notify() + + warnings_ = _warnings(wb) + periphery_pane = "Characterization steps: Extra-column volume" + bed_pane = "Characterization steps: Column packing" + assert warnings_[periphery_pane] == "" + assert "accept Extra-column volume first" in warnings_[bed_pane] + + posterior = wb.study.initial_store.updated( + {"flow_sheet.tubing_pre_column.length": 0.42}, Provenance(step="Extra-column volume"), + ) + wb.study.accept("Extra-column volume", posterior) + + warnings_ = _warnings(wb) + assert warnings_[periphery_pane] == "" + assert "length (Tubing (pre column))" not in warnings_[bed_pane] + + +def test_parameters_pane_flags_chain_warnings(): + wb = CharacterizationWorkbenchWidget(study=Study.load(MANIFEST)) + wb.study.steps[0].requires = ("flow_sheet.column.bed_porosity",) + wb.study.notify() + + assert "Extra-column volume" in _warnings(wb)["Parameters"] + + +def test_root_keeps_the_top_bar_and_header_layout(): + wb = CharacterizationWorkbenchWidget() + + assert "Characterization" in wb.root.children[1].value + assert wb.root.children[2] is wb.configuration.workspace_header.root + assert wb.root.children[3] is wb._shell.body + + +def test_navigate_shows_panes_and_highlights_the_step_on_the_system_diagram(): + wb = CharacterizationWorkbenchWidget(study=Study.load(MANIFEST)) + + wb.navigate("step", {"name": "Column packing"}) + + assert wb._shell.current == "Characterization steps: Column packing" + assert set(wb.instrument._diagram._highlight) == {"column"} + assert 'data-highlight="true"' in wb.instrument._diagram.root.value + + wb.navigate("guide") + assert wb._shell.current == "Guide" + with pytest.raises(ValueError, match="Unknown navigation target"): + wb.navigate("nowhere") + + +def test_navigate_to_a_system_unit_expands_the_hardware_and_highlights_it(): + wb = CharacterizationWorkbenchWidget() + wb.instrument.set_hardware_expanded(False) + + wb.navigate("system", {"unit": "tubing_pre_column"}) + + assert wb._shell.current == "System: Instrument" + assert wb.instrument.hardware_expanded + assert set(wb.instrument._diagram._highlight) == {"tubing_pre_column"} + + +def _walk(widget): + yield widget + for child in getattr(widget, "children", ()): + yield from _walk(child) + + +def _rendered_text(widget) -> str: + """Visible text of a widget tree: HTML, labels, descriptions, options, titles.""" + parts = [] + for w in _walk(widget): + if isinstance(w, W.HTML) and " ParameterStore: + return ParameterStore(specs=SPECS).updated(values, Provenance(step="test")) + + +def pulse_recipe(components=("SmallTracer",), flow_rate=FLOW_RATE) -> ConfigurationState: + """Pulse through `tubing_pre_column` only; other periphery units and the column bypassed.""" + n = len(components) + return ConfigurationState( + components=list(components), + column_key="Lumped Rate Model Without Pores (LRM)", + binding_key="None", + template_key="Pulse Injection", + instrument=InstrumentState( + include_sample_loop=True, + sample_loop_volume=LOOP_VOLUME, + bypass_units=[ + "mixer", "tubing_pre_injection", "column", "tubing_post_column", + "tubing_detectors", + ], + unit_values={TUBING: {"diameter": 0.5e-3, "length": 0.1, "axial_dispersion": 1e-7}}, + ), + model_values={ + "c_sample": [1.0] * n, "c_buffer_a": [0.0] * n, + "cycle_time": 400.0, "flow_rate": flow_rate, + }, + ) + + +def make_comparison(name="System pulse 1 (UV)", **kwargs) -> Comparison: + defaults = dict( + name=name, + data_file=f"{name}.csv", + channel="UV 1_280", + recipe=pulse_recipe(), + solution_path=f"{TUBING}.outlet", + injection_marker="Phase Elution", + baseline_window=(0.0, 4.0), + normalization={"kind": "area", "target_area": LOOP_VOLUME * 1.0}, + components=["SmallTracer"], + probe="SmallTracer", + ) + return Comparison(**{**defaults, **kwargs}) + + +def simulate_signal(comparison: Comparison, values: dict) -> tuple[np.ndarray, np.ndarray]: + """Simulated total outlet concentration at `values`, on the process clock.""" + process = comparison.build_process(store_with(values)) + solution = run_process(process).solution_cycles[TUBING]["outlet"][-1] + return np.asarray(solution.time), np.asarray(solution.solution).sum(axis=1) + + +def synthetic_run( + comparison: Comparison, + values: dict = TRUTH, + *, + injection_s: float = 100.0, + noise: float = 0.02, + seed: int = 0, + basis: str = "volume", +) -> ExperimentalRun: + """A measured run: the truth trace delayed by `injection_s`, scaled, drifting, noisy.""" + sim_time, sim_values = simulate_signal(comparison, values) + flow_rate = comparison.resolved_flow_rate + time = np.arange(0.0, injection_s + sim_time[-1], 1.0) + concentration = np.interp(time - injection_s, sim_time, sim_values, left=0.0, right=0.0) + rng = np.random.default_rng(seed) + signal = SIGNAL_SCALE * concentration + 3.0 + 1e-3 * time + rng.normal(0, noise, time.size) + if basis == "time": + return ExperimentalRun( + comparison.name, {comparison.channel: Channel(comparison.channel, time, signal)}, + "time", "s", + ) + volume = time * flow_rate * 1e6 + injection_ml = injection_s * flow_rate * 1e6 + return ExperimentalRun( + comparison.name, + {comparison.channel: Channel(comparison.channel, volume, signal)}, + "volume", "mL", + markers=[(0.0, "Phase Equilibration"), (injection_ml, "Phase Elution")], + ) + + +def akta_bytes(run: ExperimentalRun) -> bytes: + """Serialize a volume-axis run as an Äkta/Unicorn-style export (3 header rows).""" + (channel,) = run.channels.values() + rows = [ + ["Run", "", "Run", ""], + [channel.name, "", "Run Log", ""], + ["mL", "mAU", "mL", ""], + ] + for i, (x, v) in enumerate(zip(channel.x, channel.values)): + marker = run.markers[i] if i < len(run.markers) else ("", "") + rows.append([repr(float(x)), repr(float(v)), str(marker[0]), marker[1]]) + buffer = io.StringIO() + csv.writer(buffer).writerows(rows) + return buffer.getvalue().encode() + + +def _centroid(time: np.ndarray, values: np.ndarray) -> float: + return float(np.sum(time * values) / np.sum(values)) + + +@pytest.fixture(scope="module") +def comparison() -> Comparison: + comparison = make_comparison() + comparison.run = synthetic_run(comparison) + return comparison + + +class TestSerialization: + def test_round_trip_through_json_drops_the_run(self, comparison): + raw = json.loads(json.dumps(comparison.to_dict())) + assert "run" not in raw + rebuilt = Comparison.from_dict(raw) + assert rebuilt == comparison + assert rebuilt.run is None + assert rebuilt.recipe == comparison.recipe + + def test_to_dict_has_exactly_the_schema_fields(self, comparison): + assert set(comparison.to_dict()) == { + "name", "data_file", "channel", "flow_rate", "recipe", "overrides", + "injection_marker", "measured_injection", "baseline_window", "normalization", + "solution_path", "components", "metric", "window", "probe", + "experiment_type", + } + + def test_from_dict_with_base_dir_loads_the_data_file(self, comparison, tmp_path): + (tmp_path / comparison.data_file).write_bytes(akta_bytes(comparison.run)) + loaded = Comparison.from_dict(comparison.to_dict(), base_dir=tmp_path) + assert loaded.run.x_basis == "volume" + assert comparison.channel in loaded.run.channels + assert any(text == "Phase Elution" for _, text in loaded.run.markers) + + def test_recipe_from_dict_handles_a_standalone_recipe(self): + state = replace(pulse_recipe(), instrument=None) + assert recipe_from_dict(asdict(state)) == state + + +class TestBuildProcess: + def test_names_the_process_and_applies_the_store(self, comparison): + process = comparison.build_process(store_with(TRUTH)) + assert process.name == "System pulse 1 (UV)" + assert process.flow_sheet[TUBING].length == pytest.approx(0.8) + + def test_overrides_reach_the_template(self): + comparison = make_comparison(overrides={"cycle_time": 250.0}) + assert comparison.build_process(ParameterStore()).cycle_time == pytest.approx(250.0) + + def test_flow_rate_falls_back_to_overrides_then_recipe(self): + assert make_comparison().resolved_flow_rate == FLOW_RATE + assert make_comparison(overrides={"flow_rate": 2e-8}).resolved_flow_rate == 2e-8 + assert make_comparison(flow_rate=3e-8).resolved_flow_rate == 3e-8 + + +class TestReference: + def test_injection_is_aligned_to_the_simulated_injection(self, comparison): + process = comparison.build_process(store_with(TRUTH)) + reference = comparison.build_reference(process) + sim_time, sim_values = simulate_signal(comparison, TRUTH) + ref_values = np.asarray(reference.solution)[:, 0] + assert reference.time[0] >= 0.0 + assert reference.time[-1] <= process.cycle_time + assert _centroid(reference.time, ref_values) == pytest.approx( + _centroid(sim_time, sim_values), abs=1.0 + ) + + def test_area_normalization_matches_the_injected_amount(self, comparison): + reference = comparison.build_reference(comparison.build_process(store_with(TRUTH))) + assert np.asarray(reference.solution).max() == pytest.approx(1.0, rel=0.05) + + def test_wrong_marker_raises_with_the_available_markers(self, comparison): + wrong = replace(comparison, injection_marker="Phase Sample") + with pytest.raises(ValueError, match="Phase Elution"): + wrong.build_reference(wrong.build_process(ParameterStore())) + + def test_measured_injection_aligns_a_time_axis_run(self): + comparison = make_comparison( + injection_marker=None, measured_injection=100.0, baseline_window=(0.0, 400.0), + ) + comparison.run = synthetic_run(comparison, basis="time") + assert comparison.evaluate(store_with(TRUTH)).value < 0.02 + + +class TestEvaluate: + def test_truth_scores_at_noise_level_and_the_prior_scores_worse(self, comparison): + at_truth = comparison.evaluate(store_with(TRUTH)) + at_prior = comparison.evaluate(store_with(PRIOR)) + assert at_truth.metric == "NRMSE" + assert at_truth.value < 0.02 + assert at_prior.value > 5 * at_truth.value + + def test_preview_carries_both_traces(self, comparison): + preview = comparison.evaluate(store_with(TRUTH)) + assert preview.name == "System pulse 1 (UV)" + assert preview.simulated_values.shape == preview.simulated_time.shape + assert preview.reference_values.shape == preview.reference_time.shape + assert preview.simulated_values.max() == pytest.approx(1.0, rel=0.05) + + def test_total_concentration_sums_components(self): + recipe = pulse_recipe(components=("A", "B")) + comparison = make_comparison( + recipe=recipe, components=None, + normalization={"kind": "area", "target_area": 2 * LOOP_VOLUME}, + ) + comparison.run = synthetic_run(comparison) + preview = comparison.evaluate(store_with(TRUTH)) + assert preview.simulated_values.max() == pytest.approx(2.0, rel=0.05) + assert preview.value < 0.02 + + +class TestProblems: + def test_valid_comparison_has_none(self, comparison): + assert comparison.problems() == [] + + def test_reports_each_problem(self, comparison): + broken = replace( + comparison, + channel="Cond", + injection_marker="Phase Load", + solution_path="tubing_detectors.outlet", + components=["Salt"], + recipe=replace( + comparison.recipe, + model_values={ + k: v for k, v in comparison.recipe.model_values.items() if k != "flow_rate" + }, + ), + ) + text = "\n".join(broken.problems()) + assert "Unknown channel 'Cond'" in text + assert "Phase Load" in text + assert "tubing_detectors.outlet" in text + assert "['Salt']" in text + assert "flow rate is required" in text + + def test_reports_a_missing_run(self): + assert any("No data loaded" in p for p in make_comparison().problems()) diff --git a/tests/test_comparisons_widget.py b/tests/test_comparisons_widget.py new file mode 100644 index 0000000..6b4c266 --- /dev/null +++ b/tests/test_comparisons_widget.py @@ -0,0 +1,673 @@ +from __future__ import annotations + +import dataclasses +from pathlib import Path +from types import SimpleNamespace + +import ipywidgets as W +import pytest +from cadetgui.cadetprocessadapter import PARAMS +from cadetgui.characterization.study import Study +from cadetgui.io import configuration_store +from cadetgui.widgets.composite import ComparisonsWidget + +MANIFEST = ( + Path(__file__).parent.parent / "examples" / "data" / "characterization_akta" / "manifest.json" +) +FILM = "flow_sheet.column.film_diffusion" + + +@pytest.fixture +def study() -> Study: + return Study.load(MANIFEST) + + +@pytest.fixture +def widget(study) -> ComparisonsWidget: + return ComparisonsWidget(study) + + +def _row_text(widget, group, index): + return [cell["text"] for cell in widget._tables[group].rows[index]] + + +def _pick_component(flow, name): + if name in [value for _, value in flow._component._options]: + flow._component.value = name + elif name: + flow.new_component(name) + else: + flow._component.value = None + + +def _walk_to_confirm(widget, file_name, channel, type_id, component): + flow = widget._flow + widget.start_add() + flow.load_bytes(file_name, (MANIFEST.parent / file_name).read_bytes()) + flow.next() + flow._channel.value = channel + flow.next() + flow._type_table.value = type_id + flow.next() + _pick_component(flow, component) + flow.next() + assert flow.page == 4 + return flow + + +def test_lists_measurements_grouped_by_the_step_they_feed(widget, study): + assert widget._tables["system_periphery"].option_labels == [ + "System pulse 1 (UV)", "System pulse 2 (UV)", + ] + assert widget._tables["column_packing"].option_labels == [ + "Small tracer pulse 1 (conductivity)", + "Small tracer pulse 2 (conductivity)", + "Large tracer pulse 1 (UV)", + "Large tracer pulse 2 (UV)", + ] + assert _row_text(widget, "system_periphery", 0) == [ + "System pulse 1 (UV)", "System pulse (no column in line)", "UV (UV 1_280)", + "Extra-column volume", "ok", + ] + assert widget.selected is study.comparisons[0] + assert widget._tables["system_periphery"].value == "System pulse 1 (UV)" + assert widget._tables["column_packing"].value is None + assert "No problems" in widget._problems_html.value + headings = " ".join(c.value for c in widget._list_box.children if isinstance(c, W.HTML)) + assert "Binding" in headings and "Capacity" in headings + + widget._tables["column_packing"].value = "Large tracer pulse 1 (UV)" + assert widget.selected.name == "Large tracer pulse 1 (UV)" + assert widget._tables["system_periphery"].value is None + + +def test_untagged_measurements_are_not_assigned(widget, study): + widget._experiment_type.value = None + assert study.comparison("System pulse 1 (UV)").experiment_type is None + assert widget._tables[None].option_labels == ["System pulse 1 (UV)"] + assert widget._tables["system_periphery"].option_labels == ["System pulse 2 (UV)"] + assert widget.selected.name == "System pulse 1 (UV)" + + +def test_recipe_card_is_plain_text_with_the_diagram(widget): + widget.select("Small tracer pulse 1 (conductivity)") + summary = widget._recipe_summary.value + assert "{" not in summary and "}" not in summary + assert "mL/min" in summary and "µL" in summary + assert "Pulse Injection" in summary and "SmallTracer" in summary + assert "Tubing (post column)" in summary + assert "= 2 + + +def test_types_without_implied_values_hide_the_option(widget): + flow = _walk_to_confirm(widget, "system_pulse_1_uv.csv", "UV 1_280", "system_pulse", "Acetone") + assert flow._implied.layout.display == "none" + + +def test_step_button_preselects_the_step_type(widget): + widget._group_button("binding").click() + assert widget._flow.is_open + assert widget._flow._type_table.value == "linear_gradient_elution" + + +def test_other_type_adds_an_unassigned_measurement(widget, study): + flow = _walk_to_confirm(widget, "system_pulse_1_uv.csv", "Cond", "__other__", "") + flow.confirm() + added = study.comparison(flow._name.value) + assert added.experiment_type is None and added.channel == "Cond" + assert added.recipe == study.comparison("System pulse 1 (UV)").recipe + assert widget._tables[None].option_labels == [added.name] + + +def test_generic_csv_asks_for_column_roles(widget, tmp_path): + flow = widget._flow + widget.start_add() + content = b"Time (min),UV,Cond\n0,1,2\n1,3,4\n2,5,6\n" + flow.load_bytes("run.csv", content) + assert flow.run is None + roles = [d.value for d in flow._role_dropdowns] + assert roles == ["time_min", "signal", "signal"] + flow._apply_roles() + assert list(flow.run.channels) == ["UV", "Cond"] and flow.data_file == "run.csv" + + flow.load_bytes("two.csv", b"Time,Signal\n0,1\n1,2\n") + assert flow.run.x_basis == "time" and flow.run.channels["Signal"].x[1] == 60.0 + + +def test_no_base_recipe_blocks_confirm(): + widget = ComparisonsWidget(Study()) + flow = widget._flow + widget.start_add() + flow.load_bytes( + "system_pulse_1_uv.csv", (MANIFEST.parent / "system_pulse_1_uv.csv").read_bytes(), + ) + flow.next() + flow.next() + flow._type_table.value = "system_pulse" + flow.next() + flow.new_component("Acetone") + flow.next() + assert "Process Configuration" in flow._confirm_problems.value + assert flow._btn_add.disabled + assert flow.confirm() is None + assert widget.study.comparisons == [] + + +def test_measured_trace_is_shown_as_the_fit_sees_it_without_simulating(widget, study): + widget.select("System pulse 1 (UV)") + (series,) = widget._measured_chart.series + assert series["name"] == "System pulse 1 (UV)" and series["reference"] + assert len(series["times"]) == len(series["values"]) > 10 + assert widget._measured_note.value == "" + + +def test_a_trace_that_cannot_be_prepared_is_shown_raw_with_the_reason(widget, study): + comparison = study.comparison("Small tracer pulse 1 (conductivity)") + study.replace_comparison(comparison.name, dataclasses.replace(comparison, baseline_window=None)) + widget.select(comparison.name) + assert widget._measured_chart.series + assert "raw trace" in widget._measured_note.value + assert "No flat baseline found" in widget._problems_html.value + assert "Set a baseline window" in widget._problems_html.value + + +def test_add_flow_opens_under_its_step_heading_and_follows_the_type(widget): + from cadetgui.characterization.guide import CHAIN_BY_ID + + def position(step_id): + children = list(widget._list_box.children) + title = CHAIN_BY_ID[step_id].title + heading = next( + i for i, c in enumerate(children) + if isinstance(c, type(children[0])) and hasattr(c, "value") + and title in getattr(c, "value", "") + ) + return heading, children.index(widget._flow.root) + + widget._group_button("column_packing").click() + heading, flow = position("column_packing") + assert flow == heading + 1 + assert widget._group_button("column_packing") not in widget._list_box.children + assert CHAIN_BY_ID["column_packing"].title in widget._flow._title.value + + widget._flow._type_table.value = "system_pulse" + heading, flow = position("system_periphery") + assert flow == heading + 1 + assert CHAIN_BY_ID["system_periphery"].title in widget._flow._title.value + + widget._flow.close() + assert widget._flow.root not in widget._list_box.children + assert widget._group_button("column_packing") in widget._list_box.children + + +def test_other_type_offers_a_process_template_that_sets_the_new_recipe(widget, study): + from cadetgui.cadetprocessadapter import INSTRUMENT_TEMPLATES + + flow = widget._flow + widget.start_add() + flow.load_bytes( + "system_pulse_1_uv.csv", (MANIFEST.parent / "system_pulse_1_uv.csv").read_bytes() + ) + flow.next() + flow.next() + flow._type_table.value = "system_pulse" + assert flow._other_template_box.layout.display == "none" + flow._type_table.value = "__other__" + assert flow._other_template_box.layout.display == "" + assert flow._other_template.option_labels == list(INSTRUMENT_TEMPLATES) + base = study.comparison("System pulse 1 (UV)").recipe + assert flow._other_template.value == base.template_key + + flow._other_template.value = "Step" + flow.next() + flow.next() + assert "Step" in flow._summary.value + added = flow.confirm() + + assert added.recipe.template_key == "Step" + assert set(added.recipe.model_values) == {"c_buffer_a", "c_buffer_b", "cycle_time", "flow_rate"} + assert added.recipe.model_values["flow_rate"] == base.model_values["flow_rate"] + assert added.recipe.instrument == base.instrument + + +def test_recipe_with_template_uses_defaults_keeps_flow_rate_and_adds_the_loop(study): + import dataclasses + + from cadetgui.widgets.composite._measurement_common import recipe_with_template + + base = study.comparison("System pulse 1 (UV)").recipe + no_loop = dataclasses.replace( + base, instrument=dataclasses.replace(base.instrument, include_sample_loop=False) + ) + lwe = recipe_with_template(no_loop, "Load–Wash–Elute (LWE)") + + assert lwe.template_key == "Load–Wash–Elute (LWE)" + assert lwe.model_values["flow_rate_wash"] == base.model_values["flow_rate"] + assert lwe.model_values["delta_t_wash"] == PARAMS["delta_t_wash"].default + assert lwe.instrument.include_sample_loop is True + assert lwe.column_values == base.column_values + + +def test_editor_changes_the_process_template_with_a_method_warning(widget, study): + name = "System pulse 1 (UV)" + widget.select(name) + before = study.comparison(name).recipe + assert widget._template.value == "Pulse Injection" + assert "changes this measurement's method" in widget._template_note.value + + widget._template.value = "Breakthrough" + + edited = study.comparison(name) + assert edited.recipe.template_key == "Breakthrough" + assert "sample_buffer" in edited.recipe.model_values + assert edited.recipe.model_values["flow_rate"] == before.model_values["flow_rate"] + assert "Breakthrough" in widget._recipe_summary.value + + +def _upload(flow, file_name): + flow.load_bytes(file_name, (MANIFEST.parent / file_name).read_bytes()) + + +def test_flow_from_a_one_type_step_skips_the_type_page(widget, study): + flow = widget._flow + widget._group_button("system_periphery").click() + assert flow.pages == ["Upload", "Channel", "Details", "Confirm"] + assert "Experiment type" not in flow._progress.value + assert "4 Confirm" in flow._progress.value + _upload(flow, "system_pulse_1_uv.csv") + flow.next() + flow.next() + assert flow.page == 3 + assert flow._step_type.layout.display == "none" + info = flow._step_type_info.value + assert "System pulse (no column in line)" in info and "what you run" in info + assert flow.experiment_type.id == "system_pulse" + assert "Extra-column volume" in flow._title.value + flow.back() + assert flow.page == 1 + flow.next() + flow.next() + assert flow.page == 4 + added = flow.confirm() + assert added.experiment_type == "system_pulse" + + +@pytest.mark.parametrize("channel, expected", [ + ("Cond", "column_pulse_small_tracer"), + ("UV 1_280", "column_pulse_large_tracer"), +]) +def test_flow_from_column_packing_offers_its_types_by_detector(widget, channel, expected): + flow = widget._flow + widget.start_add(step_id="column_packing") + assert "Experiment type" not in flow.pages + _upload(flow, "large_tracer_pulse_1_uv.csv") + flow.next() + flow._channel.value = channel + flow.next() + assert flow._step_type.layout.display == "" + assert [v for _, v in flow._step_type.options] == [ + "column_pulse_small_tracer", "column_pulse_large_tracer", + ] + assert flow._step_type.value == expected + assert flow.experiment_type.id == expected + assert "excluded from the pores" in flow._step_type_info.value + + +def test_flow_step_type_choice_updates_the_details(widget): + flow = widget._flow + widget.start_add(step_id="column_packing") + assert "Experiment type" not in flow.pages + _upload(flow, "large_tracer_pulse_1_uv.csv") + flow.next() + flow.next() + assert flow._step_type.value == "column_pulse_large_tracer" + assert flow._component.value == "LargeTracer" + flow._step_type.value = "column_pulse_small_tracer" + assert flow.experiment_type.id == "column_pulse_small_tracer" + assert flow._component.value == "SmallTracer" + flow.back() + flow._channel.value = "Cond" + flow.next() + assert flow._step_type.value == "column_pulse_small_tracer" + + +def test_an_explicit_experiment_type_is_not_overridden_by_the_detector(widget): + flow = widget._flow + widget.start_add(experiment_type="column_pulse_small_tracer") + _upload(flow, "large_tracer_pulse_1_uv.csv") + flow.next() + flow.next() + assert flow._step_type.value == "column_pulse_small_tracer" + + +def test_programmatic_start_keeps_the_type_page(widget): + flow = widget._flow + widget.start_add() + assert flow.pages == ["Upload", "Channel", "Experiment type", "Details", "Confirm"] + assert flow._step_type_box.layout.display == "none" + assert "3 Experiment type" in flow._progress.value + assert "Other: set up everything by hand" in flow._type_table.option_labels + + +def _walk_to_details(widget, file_name, channel, type_id): + flow = widget._flow + widget.start_add() + flow.load_bytes(file_name, (MANIFEST.parent / file_name).read_bytes()) + flow.next() + flow._channel.value = channel + flow.next() + flow._type_table.value = type_id + flow.next() + assert flow.page == 3 + return flow + + +def test_a_component_with_another_role_is_listed_and_can_take_the_needed_role(widget, study): + flow = _walk_to_details( + widget, "small_tracer_pulse_1_conductivity.csv", "Cond", "column_pulse_small_tracer" + ) + labels = flow._component.option_labels + other = "SystemTracer (system tracer — not a small tracer)" + assert labels.index("SmallTracer (small tracer)") < labels.index(other) + + flow.new_component("SystemTracer") + assert "pick it from the list" not in flow._new_component.error + assert "already declared as system tracer" in flow._new_component.error + + flow._component.value = "SystemTracer" + assert ( + "SystemTracer is declared as a system tracer. This experiment needs a small tracer." + in flow._role_fix.value + ) + assert flow._btn_role_fix.description == "Make SystemTracer a small tracer" + flow.next() + assert flow.page == 3 and "Change its role" in flow.status.value + assert study.component("SystemTracer").role == "system tracer" + + flow._btn_role_fix.click() + assert study.component("SystemTracer").role == "small tracer" + assert flow._component.value == "SystemTracer" + assert flow._role_fix.value == "" and flow._btn_role_fix.layout.display == "none" + assert study.measurement_problems(study.comparison("System pulse 1 (UV)")) == [] + + +def test_a_role_change_that_would_break_other_measurements_is_explained(widget, study): + flow = _walk_to_details( + widget, "large_tracer_pulse_1_uv.csv", "UV 1_280", "column_pulse_large_tracer" + ) + flow._component.value = "SystemTracer" + + text = flow._role_fix.value + assert "This experiment needs a large tracer" in text + assert "System pulse 1 (UV) (System pulse" in text and "need that role" in text + assert "name a new one" in text + assert flow._btn_role_fix.layout.display == "none" + flow.make_component_fit() + assert study.component("SystemTracer").role == "system tracer" + + +def test_the_advanced_component_picker_offers_the_role_change(widget, study): + widget.select("Small tracer pulse 1 (conductivity)") + study.add_component("Acetone", "system tracer") + assert "Acetone (system tracer — not a small tracer)" in widget._probe.option_labels + + widget._probe.value = "Acetone" + assert "Acetone is declared as a system tracer" in widget._probe_role_fix.value + assert widget._btn_probe_role_fix.description == "Make Acetone a small tracer" + + widget._btn_probe_role_fix.click() + assert study.component("Acetone").role == "small tracer" + assert widget._btn_probe_role_fix.layout.display == "none" + assert study.component_problems(study.comparison("Small tracer pulse 1 (conductivity)")) == [] + + +def test_advanced_is_split_into_titled_sections_with_every_control(widget): + widget.select("Small tracer pulse 1 (conductivity)") + assert widget._advanced.titles[0].startswith( + "Advanced: fine-tune how this measurement is compared" + ) + assert "defaults from its experiment type are usually right" in widget._advanced.titles[0] + assert list(widget._advanced_sections) == [ + "Data and alignment", "Baseline and scaling", "Simulated signal", "Comparison", + "Process", "Name and type", + ] + expected = { + "Data and alignment": [ + widget._run, widget._channel, widget._marker, widget._injection_x, + widget._flow_rate, + ], + "Baseline and scaling": [ + widget._baseline_start, widget._baseline_end, widget._normalization, + widget._target_area, + ], + "Simulated signal": [widget._solution_path, widget._components_box, widget._probe], + "Comparison": [widget._metric, widget._window_start, widget._window_end], + "Process": [ + widget._template, widget._overrides_box, widget._recipe_source, + widget._btn_use_recipe, + ], + "Name and type": [widget._name, widget._experiment_type], + } + for title, controls in expected.items(): + section = widget._advanced_sections[title] + inside = list(_walk(section)) + assert title in section.children[0].value + assert "Change" in section.children[1].value + for control in controls: + assert any(w is control for w in inside), (title, control) + assert any(w is section for w in _walk(widget._advanced)) + card = list(_walk(widget._card)) + assert any(w is widget._measured_chart for w in card) + + +def _walk(widget): + yield widget + for child in getattr(widget, "children", ()): + yield from _walk(child) + + +def _rendered(widget) -> str: + parts = [] + for w in _walk(widget): + if isinstance(w, W.HTML) and "= typed + + +def _sma_pulse_with_edited_buffer(): + iw, cw = built() + cw.components = ["Salt", "Protein"] + cw.select_models(template="Pulse Injection", binding="Steric Mass Action (SMA)") + cw._model_form.element("c_buffer_a").value = [123.0, 0.0] + return iw, cw + + +def test_edited_method_values_survive_showing_optional_binding_parameters(): + _, cw = _sma_pulse_with_edited_buffer() + + cw._show_optional_binding_checkbox.value = True + + state = cw.snapshot() + assert state.model_values["c_buffer_a"] == [123.0, 0.0] + assert state.column_values["c"] == [123.0, 0.0] + assert cw.process is not None + + +def test_edited_method_values_survive_an_instrument_unit_change(): + iw, cw = _sma_pulse_with_edited_buffer() + + iw._unit_checkboxes["mixer"].value = not iw._unit_checkboxes["mixer"].value + + assert cw.snapshot().model_values["c_buffer_a"] == [123.0, 0.0] + + +def test_template_or_binding_change_starts_from_the_standard_method_values(): + _, cw = _sma_pulse_with_edited_buffer() + + cw.select_models(template="Load–Wash–Elute (LWE)") + assert cw.snapshot().model_values["c_buffer_a"] == [50.0, 0.0] + + cw.select_models(binding="Linear") + default = cw.snapshot().model_values["c_buffer_a"] + assert default != [50.0, 0.0] + + cw.select_models(binding="Steric Mass Action (SMA)") + assert cw.snapshot().model_values["c_buffer_a"] == [50.0, 0.0] + + +def test_loading_a_state_restores_its_method_values_exactly(): + _, cw = _sma_pulse_with_edited_buffer() + saved = cw.snapshot() + cw._model_form.element("c_buffer_a").value = [77.0, 0.0] + + cw._apply_state("saved", saved) + + assert cw.snapshot().model_values == saved.model_values diff --git a/tests/test_configuration_column_bypass.py b/tests/test_configuration_column_bypass.py index 727edaf..3525795 100644 --- a/tests/test_configuration_column_bypass.py +++ b/tests/test_configuration_column_bypass.py @@ -79,7 +79,7 @@ def test_values_survive_a_bypass_round_trip(): _column_off(instrument) assert cfg._column_form.collect_values() == column_before assert cfg._binding_form.collect_values() == binding_before - snap = cfg._snapshot_state() + snap = cfg.snapshot() assert snap.column_values == column_before assert snap.binding_values == binding_before @@ -128,7 +128,7 @@ def test_loading_a_state_with_the_column_bypassed_comes_up_disabled(): source, source_instrument = _bound() source._column_form.set_values({"length": 0.222}) _column_off(source_instrument) - state = source._snapshot_state() + state = source.snapshot() assert "column" in state.instrument.bypass_units target, target_instrument = _bound() @@ -148,7 +148,7 @@ def test_loading_a_state_with_the_column_bypassed_comes_up_disabled(): def test_loading_a_column_state_re_enables_a_bypassed_widget(): cfg, instrument = _bound() - with_column = cfg._snapshot_state() + with_column = cfg.snapshot() _column_off(instrument) assert _all_disabled(cfg) cfg._apply_state("loaded", with_column) diff --git a/tests/test_configuration_store.py b/tests/test_configuration_store.py index fa2443d..e2c2260 100644 --- a/tests/test_configuration_store.py +++ b/tests/test_configuration_store.py @@ -4,7 +4,7 @@ import pytest from cadet import H5 -from cadetgui.configuration_store import ( +from cadetgui.io.configuration_store import ( ConfigurationState, InstrumentState, compute_hash, diff --git a/tests/test_data_import.py b/tests/test_data_import.py index 2a9df75..c9a33f8 100644 --- a/tests/test_data_import.py +++ b/tests/test_data_import.py @@ -2,8 +2,27 @@ import datetime as dt +import ipywidgets as W +import pytest from cadetgui.widgets.composite import DataImportWidget +_MULTI_CHANNEL_CSV = ( + "Time [s],Conductivity [mS/cm],Absorbance [mAU]\n0,1.9,0\n10,1.9,5\n20,1.9,10\n" +) + +# 3 header rows (block ids, channel names, units); UV_1_280 is a numeric channel, Run +# Log a text channel whose "Phase " entries become markers. UV_1_280 is sampled every +# row; Run Log only has one entry, the rest of its column is blank. +_AKTA_CSV = ( + "BLOCK1,,BLOCK2,\n" + "UV_1_280,,Run Log,\n" + "mL,AU,mL,\n" + "0,0.0,1.0,Phase Elution\n" + "5,1.0,,\n" + "10,2.0,,\n" + "15,1.0,,\n" +) + def _upload(widget: DataImportWidget, filename: str, csv_text: str) -> None: content = memoryview(csv_text.encode("utf-8")) @@ -82,3 +101,95 @@ def test_listener_fires_on_upload_and_remove(): di._on_remove(None) assert events == [1, 0] + + +def _panel_dropdowns(panel: W.VBox) -> list[W.Dropdown]: + return [c for c in panel.children if isinstance(c, W.Dropdown)] + + +def _panel_flow_rate_field(panel: W.VBox) -> W.FloatText: + return panel.children[-2].children[0] + + +def _panel_import_button(panel: W.VBox) -> W.Button: + return panel.children[-1].children[0] + + +def test_multi_column_csv_queues_a_mapping_panel_with_prefilled_roles(): + di = DataImportWidget() + _upload(di, "run1.csv", _MULTI_CHANNEL_CSV) + + assert di.datasets == [] + assert len(di._mapping_box.children) == 1 + roles = [d.value for d in _panel_dropdowns(di._mapping_box.children[0])] + assert roles == ["time_s", "signal", "signal"] + + +def test_multi_column_csv_import_creates_one_dataset_per_signal_channel(): + di = DataImportWidget() + _upload(di, "run1.csv", _MULTI_CHANNEL_CSV) + panel = di._mapping_box.children[0] + + _panel_import_button(panel).click() + + assert {ds.channel for ds in di.datasets} == {"Conductivity [mS/cm]", "Absorbance [mAU]"} + assert {ds.label for ds in di.datasets} == { + "run1 — Conductivity [mS/cm]", + "run1 — Absorbance [mAU]", + } + assert di._mapping_box.children == () + ds = next(d for d in di.datasets if d.channel == "Absorbance [mAU]") + assert ds.time_min.tolist() == pytest.approx([0.0, 10 / 60, 20 / 60]) + assert ds.signal.tolist() == [0.0, 5.0, 10.0] + assert ds.run.x_basis == "time" + + +def test_multi_column_csv_dropdown_can_skip_a_column(): + di = DataImportWidget() + _upload(di, "run1.csv", _MULTI_CHANNEL_CSV) + panel = di._mapping_box.children[0] + _panel_dropdowns(panel)[1].value = "skip" # Conductivity + + _panel_import_button(panel).click() + + assert [ds.channel for ds in di.datasets] == ["Absorbance [mAU]"] + + +def test_akta_export_is_auto_detected_and_queues_a_flow_rate_panel(): + di = DataImportWidget() + _upload(di, "run1.csv", _AKTA_CSV) + + assert di.datasets == [] + assert len(di._mapping_box.children) == 1 + # Äkta channels/markers are auto-detected; no per-column dropdowns needed. + assert _panel_dropdowns(di._mapping_box.children[0]) == [] + + +def test_akta_export_import_converts_volume_to_time_via_flow_rate(): + di = DataImportWidget() + _upload(di, "run1.csv", _AKTA_CSV) + panel = di._mapping_box.children[0] + _panel_flow_rate_field(panel).value = 60.0 # mL/min, i.e. 1 mL/s + + _panel_import_button(panel).click() + + assert len(di.datasets) == 1 + ds = di.datasets[0] + assert ds.label == "run1" + assert ds.channel == "UV_1_280" + assert ds.time_min.tolist() == pytest.approx([0.0, 5 / 60, 10 / 60, 15 / 60]) + assert ds.signal.tolist() == [0.0, 1.0, 2.0, 1.0] + assert ds.run.x_basis == "volume" + assert ds.run.markers == [(1.0, "Phase Elution")] + + +def test_mapping_panel_cancel_discards_the_pending_file(): + di = DataImportWidget() + _upload(di, "run1.csv", _MULTI_CHANNEL_CSV) + panel = di._mapping_box.children[0] + cancel_btn = panel.children[-1].children[1] + + cancel_btn.click() + + assert di.datasets == [] + assert di._mapping_box.children == () diff --git a/tests/test_event_timeline.py b/tests/test_event_timeline.py index a1a62a7..4b66d64 100644 --- a/tests/test_event_timeline.py +++ b/tests/test_event_timeline.py @@ -2,16 +2,20 @@ import warnings -import cadetgui.configuration_store as configuration_store +import cadetgui.io.configuration_store as configuration_store import pytest -from cadetgui.cadetprocessadapter import INSTRUMENT_TEMPLATES +from cadetgui.cadetprocessadapter import INSTRUMENT_TEMPLATES, PARAMS from cadetgui.widgets.composite import ConfigurationWidget, InstrumentWidget warnings.filterwarnings("ignore", category=UserWarning) LWE = INSTRUMENT_TEMPLATES["Load–Wash–Elute (LWE)"] ML_MIN_LABEL = r"Flow rate / \frac{\mathrm{mL}}{\mathrm{min}}" -DEFAULT_FLOW_ML_MIN = 60.0 # 1e-6 m^3/s +DEFAULT_FLOW_ML_MIN = PARAMS["flow_rate_wash"].default / (1e-6 / 60.0) +WASH_MIN = PARAMS["delta_t_wash"].default / 60.0 +ELUTE_MIN = PARAMS["delta_t_elute"].default / 60.0 +FINAL_WASH_MIN = PARAMS["delta_t_final_wash"].default / 60.0 +GRADIENT_END_MIN = WASH_MIN + ELUTE_MIN @pytest.fixture(autouse=True) @@ -47,42 +51,50 @@ def test_lwe_flow_rates_step_and_ramp_between_phases_in_ml_per_min(lwe_widget): series = _series(lwe_widget._event_chart) a, b = series["Buffer a"], series["Buffer b"] - assert _value_at(a, 5.0) == pytest.approx(DEFAULT_FLOW_ML_MIN) - assert _value_at(b, 5.0) == pytest.approx(0.0, abs=1e-9) - assert 0.0 < _value_at(a, 20.0) < DEFAULT_FLOW_ML_MIN - assert _value_at(a, 20.0) + _value_at(b, 20.0) == pytest.approx(DEFAULT_FLOW_ML_MIN) - assert _value_at(a, 35.0) == pytest.approx(0.0, abs=1e-9) - assert _value_at(b, 35.0) == pytest.approx(DEFAULT_FLOW_ML_MIN) + wash, gradient = WASH_MIN / 2, WASH_MIN + ELUTE_MIN / 2 + final_wash = GRADIENT_END_MIN + FINAL_WASH_MIN / 2 + assert _value_at(a, wash) == pytest.approx(DEFAULT_FLOW_ML_MIN) + assert _value_at(b, wash) == pytest.approx(0.0, abs=1e-9) + assert 0.0 < _value_at(a, gradient) < DEFAULT_FLOW_ML_MIN + assert _value_at(a, gradient) + _value_at(b, gradient) == pytest.approx(DEFAULT_FLOW_ML_MIN) + assert _value_at(a, final_wash) == pytest.approx(0.0, abs=1e-9) + assert _value_at(b, final_wash) == pytest.approx(DEFAULT_FLOW_ML_MIN) def test_lwe_phases_and_sample_injection_marker(lwe_widget): chart = lwe_widget._event_chart + end = GRADIENT_END_MIN + FINAL_WASH_MIN assert chart.phases == [ - {"name": "Wash", "start": 0.0, "end": 10.0}, - {"name": "Elute (gradient)", "start": 10.0, "end": 30.0}, - {"name": "Final wash", "start": 30.0, "end": 40.0}, + {"name": "Wash", "start": 0.0, "end": pytest.approx(WASH_MIN)}, + {"name": "Elute (gradient)", "start": pytest.approx(WASH_MIN), + "end": pytest.approx(GRADIENT_END_MIN)}, + {"name": "Final wash", "start": pytest.approx(GRADIENT_END_MIN), "end": pytest.approx(end)}, ] assert chart.markers == [{"name": "Sample injection", "time": 0.0}] - assert chart.series[0]["times"][-1] == pytest.approx(40.0) + assert chart.series[0]["times"][-1] == pytest.approx(end) def test_changing_a_duration_moves_the_phase_boundaries(lwe_widget): chart = lwe_widget._event_chart - lwe_widget._model_form.element("delta_t_wash").value = 300.0 + wash = WASH_MIN / 2 + lwe_widget._model_form.element("delta_t_wash").value = wash * 60.0 - assert [(p["start"], p["end"]) for p in chart.phases] == [(0.0, 5.0), (5.0, 25.0), (25.0, 35.0)] + gradient_end = wash + ELUTE_MIN + assert [(p["start"], p["end"]) for p in chart.phases] == pytest.approx( + [(0.0, wash), (wash, gradient_end), (gradient_end, gradient_end + FINAL_WASH_MIN)] + ) a = _series(chart)["Buffer a"] - assert _value_at(a, 4.0) == pytest.approx(DEFAULT_FLOW_ML_MIN) - assert _value_at(a, 6.0) < DEFAULT_FLOW_ML_MIN + assert _value_at(a, wash - 0.5) == pytest.approx(DEFAULT_FLOW_ML_MIN) + assert _value_at(a, wash + 0.5) < DEFAULT_FLOW_ML_MIN def test_changing_the_flow_rate_changes_the_step_heights(lwe_widget): - lwe_widget._model_form.element("flow_rate_wash").value = 2.0e-6 + lwe_widget._model_form.element("flow_rate_wash").value = 2 * PARAMS["flow_rate_wash"].default a = _series(lwe_widget._event_chart)["Buffer a"] - assert _value_at(a, 5.0) == pytest.approx(2 * DEFAULT_FLOW_ML_MIN) + assert _value_at(a, WASH_MIN / 2) == pytest.approx(2 * DEFAULT_FLOW_ML_MIN) @pytest.mark.parametrize( diff --git a/tests/test_experimental_data.py b/tests/test_experimental_data.py new file mode 100644 index 0000000..96298e9 --- /dev/null +++ b/tests/test_experimental_data.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +import numpy as np +import pytest +from cadetgui.io.experimental_data import ( + Channel, + ExperimentalRun, + detect_column_roles, + is_akta_format, + read_akta_csv, + read_experimental_csv, + read_generic_csv, + read_headers, + to_reference, + to_time, + write_experimental_csv, +) + + +def _akta_text( + channels: dict[str, tuple[list[float], list[float]]], + text_channels: dict[str, list[tuple[float, str]]], +) -> str: + """A synthetic Äkta/Unicorn-style export: 3 header rows, one column pair per channel. + + Each channel (numeric or text) is sampled independently, so pairs may have + different lengths; short columns are padded with empty cells. + """ + names = list(channels) + list(text_channels) + block_row = [""] * (2 * len(names)) + name_row = [cell for name in names for cell in (name, "")] + unit_row = [cell for name in names for cell in ("mL", "AU")] + + pairs: list[list[tuple[str, str]]] = [] + for vol, val in channels.values(): + pairs.append([(str(v), str(s)) for v, s in zip(vol, val)]) + for entries in text_channels.values(): + pairs.append([(str(v), t) for v, t in entries]) + + n_rows = max((len(p) for p in pairs), default=0) + data_rows = [] + for r in range(n_rows): + row: list[str] = [] + for p in pairs: + row.extend(p[r] if r < len(p) else ("", "")) + data_rows.append(row) + + rows = [block_row, name_row, unit_row, *data_rows] + return "\n".join(",".join(row) for row in rows) + + +class TestDetectColumnRoles: + def test_prefers_volume_over_time(self): + roles = detect_column_roles(["Time [s]", "Volume [mL]", "Absorbance [mAU]"]) + assert roles == ["signal", "volume_ml", "signal"] + + def test_falls_back_to_time_seconds(self): + assert detect_column_roles(["time", "signal"]) == ["time_s", "signal"] + + def test_detects_time_minutes(self): + assert detect_column_roles(["Time [min]", "signal"]) == ["time_min", "signal"] + + def test_falls_back_to_first_column_with_no_recognizable_header(self): + assert detect_column_roles(["A", "B", "C"]) == ["time_s", "signal", "signal"] + + +class TestGenericCsv: + def test_auto_detects_time_and_two_signal_channels(self): + content = ( + b"Time [s],Conductivity [mS/cm],Absorbance [mAU]\n" + b"0,1.9,0\n10,1.9,5\n20,1.9,10\n" + ) + run = read_generic_csv(content, "run1") + assert run.x_basis == "time" + assert run.x_unit == "s" + assert set(run.channels) == {"Conductivity [mS/cm]", "Absorbance [mAU]"} + assert run.channels["Absorbance [mAU]"].values.tolist() == [0.0, 5.0, 10.0] + assert run.channels["Absorbance [mAU]"].x.tolist() == [0.0, 10.0, 20.0] + + def test_volume_basis_column(self): + content = b"Volume [mL],Absorbance [mAU]\n0,0\n5,1\n10,2\n" + run = read_generic_csv(content, "run1") + assert run.x_basis == "volume" + assert run.x_unit == "mL" + + def test_time_minutes_normalized_to_seconds(self): + content = b"Time [min],signal\n0,0\n1,1\n2,2\n" + run = read_generic_csv(content, "run1") + channel = next(iter(run.channels.values())) + assert run.x_basis == "time" + assert channel.x.tolist() == [0.0, 60.0, 120.0] + + def test_explicit_roles_skip_a_column(self): + content = b"time,notes,signal\n0,foo,1\n1,bar,2\n" + run = read_generic_csv(content, "run1", roles=["time_s", "skip", "signal"]) + assert set(run.channels) == {"signal"} + + def test_requires_exactly_one_x_column(self): + content = b"a,b\n0,1\n2,3\n" + with pytest.raises(ValueError): + read_generic_csv(content, "run1", roles=["signal", "signal"]) + with pytest.raises(ValueError): + read_generic_csv(content, "run1", roles=["time_s", "volume_ml"]) + + def test_skips_unparseable_rows(self): + content = b"time,signal\n0,0\nnot,a,number\n2,1\n" + run = read_generic_csv(content, "run1") + channel = next(iter(run.channels.values())) + assert channel.x.tolist() == [0.0, 2.0] + + def test_read_headers(self): + assert read_headers(b"a,b,c\n1,2,3\n") == ["a", "b", "c"] + assert read_headers(b"") == [] + + +class TestAktaCsv: + def test_is_akta_format_detects_export(self): + text = _akta_text({"UV": ([0.0, 1.0], [0.0, 1.0])}, {"Run Log": []}) + assert is_akta_format(text.encode("utf-8")) + + def test_is_akta_format_rejects_generic_csv(self): + content = b"Time [s],Absorbance [mAU]\n0,0\n1,1\n" + assert not is_akta_format(content) + + def test_two_channels_sampled_at_different_rates_and_run_log_markers(self): + text = _akta_text( + channels={ + "UV_1_280": ([0.0, 5.0, 10.0, 15.0], [0.0, 1.0, 2.0, 1.0]), + "Cond": ([0.0, 7.5, 15.0], [1.5, 1.5, 1.5]), + }, + text_channels={ + "Run Log": [ + (3.0, "Phase Equilibration"), + (8.0, "Phase Elution"), + (12.0, "Note: pH check"), + ] + }, + ) + run = read_akta_csv(text.encode("utf-8"), "run1") + + assert run.x_basis == "volume" + assert run.x_unit == "mL" + assert set(run.channels) == {"UV_1_280", "Cond"} + assert run.channels["UV_1_280"].x.tolist() == [0.0, 5.0, 10.0, 15.0] + assert run.channels["UV_1_280"].values.tolist() == [0.0, 1.0, 2.0, 1.0] + assert run.channels["Cond"].x.tolist() == [0.0, 7.5, 15.0] + # "Note: pH check" does not start with "Phase " and is dropped. + assert run.markers == [(3.0, "Phase Equilibration"), (8.0, "Phase Elution")] + + def test_fraction_channel_becomes_markers_not_a_channel(self): + text = _akta_text( + channels={"UV": ([0.0, 1.0, 2.0], [0.0, 1.0, 0.0])}, + text_channels={"Fraction": [(0.5, "A1"), (1.5, "A2")]}, + ) + run = read_akta_csv(text.encode("utf-8"), "run1") + + assert "Fraction" not in run.channels + assert run.markers == [(0.5, "A1"), (1.5, "A2")] + + def test_rejects_generic_csv(self): + with pytest.raises(ValueError): + read_akta_csv(b"Time [s],Absorbance [mAU]\n0,0\n1,1\n", "run1") + + def test_handles_utf16_encoding(self): + text = _akta_text( + {"UV": ([0.0, 1.0, 2.0, 3.0], [0.0, 2.0, 4.0, 2.0])}, + {"Run Log": [(0.5, "Phase Elution")]}, + ) + run = read_akta_csv(text.encode("utf-16"), "run1") + assert run.channels["UV"].values.tolist() == [0.0, 2.0, 4.0, 2.0] + assert run.markers == [(0.5, "Phase Elution")] + + def test_handles_latin1_encoding(self): + text = _akta_text({"Cond [µS/cm]": ([0.0, 1.0], [1.5, 1.6])}, {"Run Log": []}) + run = read_akta_csv(text.encode("latin-1"), "run1") + assert "Cond [µS/cm]" in run.channels + + +class TestReadExperimentalCsv: + def test_dispatches_to_akta_reader(self): + text = _akta_text({"UV": ([0.0, 1.0], [0.0, 1.0])}, {"Run Log": []}) + run = read_experimental_csv(text.encode("utf-8"), "run1") + assert run.x_basis == "volume" + + def test_dispatches_to_generic_reader(self): + run = read_experimental_csv(b"time,signal\n0,0\n1,1\n", "run1") + assert run.x_basis == "time" + + +class TestToTime: + def test_time_basis_passes_through(self): + channel = Channel( + name="signal", x=np.array([0.0, 1.0, 2.0]), values=np.array([0.0, 1.0, 0.5]) + ) + run = ExperimentalRun( + label="r", channels={"signal": channel}, x_basis="time", x_unit="s" + ) + time_s, values = to_time(run, "signal") + assert time_s.tolist() == [0.0, 1.0, 2.0] + assert values.tolist() == [0.0, 1.0, 0.5] + + def test_volume_basis_requires_flow_rate(self): + channel = Channel(name="signal", x=np.array([0.0, 10.0]), values=np.array([0.0, 1.0])) + run = ExperimentalRun( + label="r", channels={"signal": channel}, x_basis="volume", x_unit="mL" + ) + with pytest.raises(ValueError): + to_time(run, "signal") + + def test_volume_to_time_conversion(self): + channel = Channel( + name="signal", x=np.array([0.0, 10.0, 20.0]), values=np.array([0.0, 1.0, 2.0]) + ) + run = ExperimentalRun( + label="r", channels={"signal": channel}, x_basis="volume", x_unit="mL" + ) + flow_rate = 1e-8 # m^3/s + time_s, values = to_time(run, "signal", flow_rate) + # time_s = volume_mL * 1e-6 / flow_rate + assert time_s.tolist() == pytest.approx([0.0, 1000.0, 2000.0]) + assert values.tolist() == [0.0, 1.0, 2.0] + + +class TestToReference: + def test_builds_reference_io(self): + x = np.linspace(0.0, 50.0, 6) + values = np.linspace(0.0, 1.0, 6) + channel = Channel(name="signal", x=x, values=values) + run = ExperimentalRun( + label="run1", channels={"signal": channel}, x_basis="volume", x_unit="mL" + ) + + reference = to_reference(run, "signal", flow_rate=1e-8) + + assert reference.name == "run1: signal" + assert reference.time.tolist() == pytest.approx((x * 1e-6 / 1e-8).tolist()) + + +def _assert_same_run(read: ExperimentalRun, run: ExperimentalRun) -> None: + assert list(read.channels) == list(run.channels) + assert (read.x_basis, read.x_unit) == (run.x_basis, run.x_unit) + assert read.markers == run.markers + for name, channel in run.channels.items(): + np.testing.assert_array_equal(read.channels[name].x, channel.x) + np.testing.assert_array_equal(read.channels[name].values, channel.values) + + +class TestWriteExperimentalCsv: + def test_shared_axis_without_markers_round_trips_as_generic_csv(self): + x = np.linspace(0.0, 10.0, 11) / 3.0 + run = ExperimentalRun( + label="r", + channels={ + "UV": Channel("UV", x, np.sin(x)), + "Cond": Channel("Cond", x, np.cos(x)), + }, + x_basis="time", x_unit="s", + ) + content = write_experimental_csv(run) + assert not is_akta_format(content) + _assert_same_run(read_experimental_csv(content, "r"), run) + + def test_volume_axis_generic_round_trip(self): + x = np.array([0.0, 0.5, 1.0]) + run = ExperimentalRun( + label="r", channels={"UV 1_280": Channel("UV 1_280", x, x * 2)}, + x_basis="volume", x_unit="mL", + ) + _assert_same_run(read_experimental_csv(write_experimental_csv(run), "r"), run) + + def test_markers_and_per_channel_axes_round_trip_as_akta_export(self): + run = ExperimentalRun( + label="r", + channels={ + "UV 1_280": Channel("UV 1_280", np.array([0.0, 0.1, 0.2, 0.3]), + np.array([1.0, 2.0, 3.0, 4.0]), unit="mAU"), + "Cond": Channel("Cond", np.array([0.0, 0.25]), np.array([40.0, 41.0])), + }, + x_basis="volume", x_unit="mL", + markers=[(0.0, "Phase Equilibration"), (0.1, "A1"), (0.2, "Phase Elution")], + ) + content = write_experimental_csv(run) + assert is_akta_format(content) + _assert_same_run(read_experimental_csv(content, "r"), run) + + def test_the_example_akta_export_round_trips(self): + from pathlib import Path + + path = ( + Path(__file__).parent.parent / "examples" / "data" / "characterization_akta" + / "system_pulse_1_uv.csv" + ) + run = read_experimental_csv(path.read_bytes(), "System pulse 1 (UV)") + _assert_same_run( + read_experimental_csv(write_experimental_csv(run), "System pulse 1 (UV)"), run, + ) + + def test_time_axis_with_markers_is_rejected(self): + x = np.array([0.0, 1.0]) + run = ExperimentalRun( + label="r", channels={"UV": Channel("UV", x, x)}, x_basis="time", x_unit="s", + markers=[(0.0, "Phase Elution")], + ) + with pytest.raises(ValueError, match="volume-axis"): + write_experimental_csv(run) + + +def test_semicolon_decimal_comma_and_metadata_lines_are_read(): + content = ( + "Instrument: LC-1\nOperator: someone\n\n" + "Time (min);UV (mAU);Cond (mS/cm)\n" + "0,0;1,5;40,0\n0,5;2,5;40,1\n1,0;3,0;40,2\n" + ).encode("utf-8") + assert not is_akta_format(content) + assert read_headers(content) == ["Time (min)", "UV (mAU)", "Cond (mS/cm)"] + run = read_experimental_csv(content, "run") + assert list(run.channels) == ["UV (mAU)", "Cond (mS/cm)"] + np.testing.assert_allclose(run.channels["UV (mAU)"].values, [1.5, 2.5, 3.0]) + np.testing.assert_allclose(run.channels["UV (mAU)"].x, [0.0, 30.0, 60.0]) + + +def test_tab_separated_export_is_read(): + content = b"time_s\tsignal\n0\t0.1\n1\t0.2\n2\t0.4\n" + run = read_experimental_csv(content, "run") + np.testing.assert_allclose(run.channels["signal"].values, [0.1, 0.2, 0.4]) diff --git a/tests/test_help.py b/tests/test_help.py new file mode 100644 index 0000000..5140aff --- /dev/null +++ b/tests/test_help.py @@ -0,0 +1,42 @@ +from cadetgui.characterization.guide import GLOSSARY +from cadetgui.widgets._help import checklist_html, info_box_html, term_html + + +def test_known_term_carries_its_definition_as_tip(): + term = next(iter(GLOSSARY)) + out = term_html(term, label="x") + assert "cadetgui-term" in out + assert ">x" in out + assert "data-tip=" in out + + +def test_unknown_term_is_plain_escaped_text(): + assert term_html("") == "<nope>" + + +def test_info_box_and_checklist_escape_text(): + box = info_box_html("A & B", "body", open=True) + assert "
      " in box + assert "A & B" in box and "body" in box + assert "
    • 1 < 2
    • " in checklist_html(["1 < 2"]) + + +def test_determines_table_has_one_row_per_parameter(): + from cadetgui.characterization.guide import CHAIN_BY_ID + from cadetgui.widgets._help import determines_table_html + + guide = CHAIN_BY_ID["column_packing"] + out = determines_table_html(guide.determines) + assert out.count("") == len(guide.determines) + 1 + assert "Unit" in out and "Bed porosity" in out and "m²/s" in out + + +def test_experiments_table_lists_attached_measurements_or_none_yet(): + from cadetgui.characterization.guide import CHAIN_BY_ID + from cadetgui.widgets._help import experiments_table_html + + types = CHAIN_BY_ID["column_packing"].experiment_types + out = experiments_table_html(types, {types[0]: ["Small tracer pulse 1 (conductivity)"]}) + assert "Measurements" in out + assert "Small tracer pulse 1 (conductivity)" in out and "none yet" in out + assert "Measurements" not in experiments_table_html(types) diff --git a/tests/test_instrument.py b/tests/test_instrument.py index 9b377be..43ed4b7 100644 --- a/tests/test_instrument.py +++ b/tests/test_instrument.py @@ -248,3 +248,27 @@ def test_set_required_units_does_not_rebuild_when_nothing_changes(): def test_set_required_units_rejects_unknown_units(): with pytest.raises(ValueError): InstrumentWidget().set_required_units({"mixer"}) + + +def test_components_section_sits_above_the_hardware_and_drives_the_flow_sheet(): + iw = InstrumentWidget() + children = list(iw.root.children) + + assert children.index(iw._components_section) < children.index(iw._flow_path_section) + assert iw._components_field.value == ["Component 1"] + + iw._components_field.value = ["Salt", "Protein"] + assert iw.components == ["Salt", "Protein"] + assert iw.flow_sheet.component_system.names == ["Salt", "Protein"] + + iw.components = ["A"] + assert iw._components_field.value == ["A"] + + +def test_min_components_note_shows_only_when_needed(): + iw = InstrumentWidget() + iw.set_min_components(2, "Needs two.") + assert iw._components_field.min_components == 2 + assert iw._components_note.layout.display == "" + iw.set_min_components(1) + assert iw._components_note.layout.display == "none" diff --git a/tests/test_instrument_hardware_section.py b/tests/test_instrument_hardware_section.py index 7673778..3a1edcb 100644 --- a/tests/test_instrument_hardware_section.py +++ b/tests/test_instrument_hardware_section.py @@ -2,7 +2,11 @@ import warnings -from cadetgui.widgets.composite import InstrumentWidget, WorkbenchWidget +from cadetgui.widgets.composite import ( + CharacterizationWorkbenchWidget, + InstrumentWidget, + WorkbenchWidget, +) warnings.filterwarnings("ignore", category=UserWarning) @@ -137,8 +141,18 @@ def test_workbench_builds_its_instrument_collapsed_with_column_toggle(): assert _column_toggle_shown(wb.instrument) is True +def test_characterization_workbench_builds_its_instrument_expanded_with_column_toggle(): + wb = CharacterizationWorkbenchWidget() + assert wb.instrument.hardware_expanded is True + assert _column_toggle_shown(wb.instrument) is True + + def test_passed_in_instrument_is_left_alone(): iw = InstrumentWidget() wb = WorkbenchWidget(instrument=iw) assert wb.instrument is iw assert iw.hardware_expanded is False + + iw2 = InstrumentWidget(hardware_expanded=True) + cw = CharacterizationWorkbenchWidget(instrument=iw2) + assert cw.instrument is iw2 diff --git a/tests/test_lab_scale_defaults.py b/tests/test_lab_scale_defaults.py new file mode 100644 index 0000000..0ed76b6 --- /dev/null +++ b/tests/test_lab_scale_defaults.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import warnings + +import cadetgui.io.configuration_store as configuration_store +import numpy as np +import pytest +from cadetgui.cadetprocessadapter import INSTRUMENT_TEMPLATES +from cadetgui.characterization.guide import EXPERIMENT_TYPES, with_implied_values +from cadetgui.characterization.parameter_store import ParameterStore, apply_store +from cadetgui.process_builder import build_process +from cadetgui.simulation import run_process +from cadetgui.widgets.composite import ( + CharacterizationWorkbenchWidget, + ConfigurationWidget, + InstrumentWidget, +) + +warnings.filterwarnings("ignore", category=UserWarning) + + +@pytest.fixture(autouse=True) +def _isolated_store(tmp_path, monkeypatch): + monkeypatch.setattr(configuration_store, "default_store_dir", lambda: tmp_path) + + +def _rise_and_end(result, unit, port): + """Return `(max change, time of 99 % of it, end/max)` of the last component's signal.""" + solution = result.solution[unit][port] + signal = np.asarray(solution.solution).reshape(len(solution.time), -1)[:, -1] + change = signal - signal[0] + peak = change.max() + t_99 = float(np.asarray(solution.time)[np.argmax(change >= 0.99 * peak)]) + return peak, t_99, change[-1] / peak + + +def _assert_signal_inside_the_cycle(process, unit, port, label): + peak, t_99, end = _rise_and_end(run_process(process), unit, port) + assert peak > 1e-2, label + assert t_99 < 0.9 * process.cycle_time, label + assert end < 0.05 or end > 0.95, label + + +@pytest.mark.slow +@pytest.mark.parametrize("template", list(INSTRUMENT_TEMPLATES)) +def test_default_template_signal_peaks_or_breaks_through_inside_the_cycle(template): + cw = ConfigurationWidget(instrument=InstrumentWidget()) + cw.select_models(template=template) + _assert_signal_inside_the_cycle(cw.process, "outlet", "inlet", template) + + +@pytest.mark.slow +@pytest.mark.parametrize("type_id", list(EXPERIMENT_TYPES)) +def test_experiment_type_signal_peaks_or_breaks_through_inside_the_cycle(type_id): + experiment = EXPERIMENT_TYPES[type_id] + base = CharacterizationWorkbenchWidget().configuration.snapshot() + process = build_process(experiment.apply(base, component="Probe")) + apply_store(process, with_implied_values(ParameterStore(), experiment, "Probe")) + unit, port = experiment.solution_path.split(".")[:2] + _assert_signal_inside_the_cycle(process, unit, port, type_id) diff --git a/tests/test_parameter_estimation.py b/tests/test_parameter_estimation.py index d7c589b..9f5528c 100644 --- a/tests/test_parameter_estimation.py +++ b/tests/test_parameter_estimation.py @@ -4,7 +4,7 @@ import threading import warnings -import cadetgui.configuration_store as configuration_store +import cadetgui.io.configuration_store as configuration_store import cadetgui.widgets.composite._optimizer_runner_panel as runner_panel import pytest from cadetgui.optimizer_runner import OptimizerRunResult diff --git a/tests/test_parameter_estimation_adapter.py b/tests/test_parameter_estimation_adapter.py index 5c4349b..8876442 100644 --- a/tests/test_parameter_estimation_adapter.py +++ b/tests/test_parameter_estimation_adapter.py @@ -270,6 +270,7 @@ def test_run_estimation_with_a_component_name_ignores_other_components(): def test_run_estimation_stops_early_when_cancelled(optimizer_name, optimizer_kwargs, max_elapsed): process, column, params, porosity_idx = porosity_fit(built_widget()) reference = total_signal_reference(process) + original_porosity = column.total_porosity cancel_event = threading.Event() @@ -292,7 +293,7 @@ def _cancel_soon() -> None: assert not result.success assert "cancelled" in result.message.lower() assert elapsed < max_elapsed # stopped promptly, not after the full run - assert column.total_porosity == 0.72 # the live object is still untouched + assert column.total_porosity == original_porosity # the live object is still untouched def test_list_signal_ports_matches_the_simulated_classification_without_simulating(): diff --git a/tests/test_parameter_store.py b/tests/test_parameter_store.py new file mode 100644 index 0000000..57b2526 --- /dev/null +++ b/tests/test_parameter_store.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import pytest +from cadetgui.characterization.parameter_store import ( + ChainError, + Entry, + ParameterSpec, + ParameterStore, + Provenance, + Step, + apply_store, + check_chain, + missing_requirements, + read_process, + spec_for, + species_index, +) +from CADETProcess.instruments import LCFlowSheet, LCProcess +from CADETProcess.processModel import ComponentSystem, GeneralRateModel + +BED_POROSITY = "flow_sheet.column.bed_porosity" +FILM_DIFFUSION = "flow_sheet.column.film_diffusion" +DETECTOR_LENGTH = "flow_sheet.tubing_detectors.length" + +_SPECS = { + BED_POROSITY: ParameterSpec(BED_POROSITY), + FILM_DIFFUSION: ParameterSpec(FILM_DIFFUSION, species_indexed=True), + DETECTOR_LENGTH: ParameterSpec(DETECTOR_LENGTH), +} + + +@pytest.fixture +def component_system(): + return ComponentSystem(["A", "B"]) + + +@pytest.fixture +def process(component_system): + flow_sheet = LCFlowSheet(component_system, ColumnModel=GeneralRateModel) + return LCProcess("demo", flow_sheet) + + +@pytest.fixture +def bypassed_process(component_system): + flow_sheet = LCFlowSheet( + component_system, ColumnModel=GeneralRateModel, bypass_units=["tubing_detectors"] + ) + return LCProcess("demo", flow_sheet) + + +def _seeded_store() -> ParameterStore: + return ParameterStore(specs=_SPECS).updated( + {BED_POROSITY: 0.4, FILM_DIFFUSION: {"A": 1e-5, "B": 2e-5}, DETECTOR_LENGTH: 0.5}, + Provenance(step="seed"), + ) + + +def test_species_index_resolves_against_a_real_component_system(component_system): + assert species_index("A", component_system) == 0 + assert species_index("B", component_system) == 1 + + +def test_species_index_rejects_an_unknown_species(component_system): + with pytest.raises(KeyError): + species_index("C", component_system) + + +def test_value_round_trips_scalar_and_species_indexed(): + store = _seeded_store() + assert store.value(BED_POROSITY) == 0.4 + assert store.value(FILM_DIFFUSION) == {"A": 1e-5, "B": 2e-5} + assert store.value(FILM_DIFFUSION, species="B") == 2e-5 + + +def test_value_rejects_species_for_a_scalar_parameter(): + store = _seeded_store() + with pytest.raises(ValueError): + store.value(BED_POROSITY, species="A") + + +def test_updated_returns_a_new_store_and_leaves_the_original_untouched(): + base = ParameterStore(specs=_SPECS) + updated = base.updated({BED_POROSITY: 0.4}, Provenance(step="seed")) + + assert BED_POROSITY not in base + assert BED_POROSITY in updated + assert updated.step == "seed" + assert updated.prior is None + + +def test_updated_merges_species_indexed_values_instead_of_overwriting(): + store = ParameterStore(specs=_SPECS).updated( + {FILM_DIFFUSION: {"A": 1e-5}}, Provenance(step="one") + ) + store = store.updated({FILM_DIFFUSION: {"B": 2e-5}}, Provenance(step="two")) + + assert store.value(FILM_DIFFUSION) == {"A": 1e-5, "B": 2e-5} + assert store.step == "two" + assert store.prior == "one" + + +def test_updated_rejects_undeclared_paths(): + with pytest.raises(KeyError): + ParameterStore().updated({BED_POROSITY: 0.4}, Provenance(step="seed")) + + +def test_updated_accepts_new_specs_declared_inline(): + store = ParameterStore().updated( + {BED_POROSITY: 0.4}, Provenance(step="seed"), specs={BED_POROSITY: _SPECS[BED_POROSITY]} + ) + assert store.value(BED_POROSITY) == 0.4 + assert BED_POROSITY in store.specs + + +def test_updated_rejects_a_mapping_for_a_non_species_indexed_parameter(): + with pytest.raises(TypeError): + ParameterStore(specs=_SPECS).updated({BED_POROSITY: {"A": 0.4}}, Provenance(step="seed")) + + +def test_updated_rejects_a_scalar_for_a_species_indexed_parameter(): + with pytest.raises(TypeError): + ParameterStore(specs=_SPECS).updated({FILM_DIFFUSION: 1e-5}, Provenance(step="seed")) + + +def test_to_cadet_orders_species_by_index(component_system): + store = _seeded_store() + assert store.to_cadet(FILM_DIFFUSION, component_system) == [1e-5, 2e-5] + assert store.to_cadet(BED_POROSITY, component_system) == 0.4 + + +def test_to_cadet_raises_for_a_species_missing_from_the_store(component_system): + store = ParameterStore(specs=_SPECS).updated( + {FILM_DIFFUSION: {"A": 1e-5}}, Provenance(step="seed") + ) + with pytest.raises(KeyError): + store.to_cadet(FILM_DIFFUSION, component_system) + + +def test_from_cadet_is_the_inverse_of_to_cadet(component_system): + store = _seeded_store() + cadet_value = store.to_cadet(FILM_DIFFUSION, component_system) + assert store.from_cadet(FILM_DIFFUSION, cadet_value, component_system) == { + "A": 1e-5, + "B": 2e-5, + } + + +def test_json_round_trip(tmp_path): + store = _seeded_store() + path = store.save(tmp_path / "store.json") + + loaded = ParameterStore.load(path, specs=_SPECS) + assert loaded.entries == store.entries + assert loaded.step == store.step + assert loaded.prior == store.prior + + +def test_from_dict_is_the_inverse_of_to_dict(): + store = _seeded_store() + rebuilt = ParameterStore.from_dict(store.to_dict(), specs=_SPECS) + assert rebuilt.entries == store.entries + + +def test_to_dict_omits_unset_provenance_fields(): + store = ParameterStore(specs=_SPECS).updated({BED_POROSITY: 0.4}, Provenance(step="seed")) + assert store.to_dict()["entries"][BED_POROSITY]["provenance"] == {"step": "seed"} + + +def test_spec_for_infers_species_indexed_from_the_descriptor(process): + assert spec_for(process, FILM_DIFFUSION) == ParameterSpec(FILM_DIFFUSION, species_indexed=True) + assert spec_for(process, BED_POROSITY) == ParameterSpec(BED_POROSITY, species_indexed=False) + + +def test_spec_for_raises_for_an_absent_path(process): + with pytest.raises(KeyError): + spec_for(process, "flow_sheet.column.not_a_parameter") + + +def test_apply_store_sets_parameters_the_process_has(process): + store = _seeded_store() + applied = apply_store(process, store) + + assert sorted(applied) == sorted([BED_POROSITY, FILM_DIFFUSION, DETECTOR_LENGTH]) + assert process.flow_sheet.column.bed_porosity == 0.4 + assert process.flow_sheet.column.film_diffusion == [1e-5, 2e-5] + assert process.flow_sheet.tubing_detectors.length == 0.5 + + +def test_apply_store_skips_paths_for_a_bypassed_unit(bypassed_process): + store = _seeded_store() + applied = apply_store(bypassed_process, store) + + assert DETECTOR_LENGTH not in applied + assert sorted(applied) == [BED_POROSITY, FILM_DIFFUSION] + + +def test_read_process_mirrors_apply_store(process): + store = _seeded_store() + apply_store(process, store) + + values = read_process(process, [BED_POROSITY, FILM_DIFFUSION], store) + assert values == {BED_POROSITY: 0.4, FILM_DIFFUSION: {"A": 1e-5, "B": 2e-5}} + + +def test_read_process_raises_for_a_path_the_process_does_not_have(bypassed_process): + store = _seeded_store() + with pytest.raises(KeyError): + read_process(bypassed_process, [DETECTOR_LENGTH], store) + + +def test_missing_requirements_is_non_raising(): + step = Step(name="fit", requires=(BED_POROSITY, "nope.path"), provides=()) + assert missing_requirements(step, ParameterStore()) == [BED_POROSITY, "nope.path"] + + store = ParameterStore(specs=_SPECS).updated({BED_POROSITY: 0.4}, Provenance(step="seed")) + assert missing_requirements(step, store) == ["nope.path"] + + +def test_check_chain_accepts_a_valid_ordering(): + initial = ParameterStore(specs=_SPECS) + steps = [ + Step(name="tubing", requires=(), provides=(DETECTOR_LENGTH,)), + Step(name="column", requires=(DETECTOR_LENGTH,), provides=(BED_POROSITY,)), + ] + check_chain(steps, initial) + + +def test_check_chain_rejects_a_step_that_requires_an_unprovided_path(): + initial = ParameterStore(specs=_SPECS) + steps = [Step(name="column", requires=(DETECTOR_LENGTH,), provides=(BED_POROSITY,))] + with pytest.raises(ChainError): + check_chain(steps, initial) + + +def test_check_chain_rejects_a_duplicate_step_name(): + initial = ParameterStore(specs=_SPECS) + steps = [ + Step(name="column", requires=(), provides=(BED_POROSITY,)), + Step(name="column", requires=(), provides=(DETECTOR_LENGTH,)), + ] + with pytest.raises(ChainError): + check_chain(steps, initial) + + +def test_check_chain_rejects_an_undeclared_path(): + initial = ParameterStore() + steps = [Step(name="column", requires=(), provides=(BED_POROSITY,))] + with pytest.raises(ChainError): + check_chain(steps, initial) + + +def test_entry_is_a_plain_value_provenance_pair(): + entry = Entry(value=0.4, provenance=Provenance(step="seed")) + assert entry.value == 0.4 + assert entry.provenance.step == "seed" diff --git a/tests/test_parameter_store_widget.py b/tests/test_parameter_store_widget.py new file mode 100644 index 0000000..65c90c3 --- /dev/null +++ b/tests/test_parameter_store_widget.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import shutil +import warnings +from dataclasses import replace +from pathlib import Path + +import pytest +from cadetgui.characterization.parameter_store import ParameterStore, Provenance +from cadetgui.characterization.study import Study +from cadetgui.widgets.composite import ParameterStoreWidget +from cadetgui.widgets.composite.parameter_store_view import ( + chain_warnings, + parameter_label, +) + +warnings.filterwarnings("ignore", category=DeprecationWarning) +warnings.filterwarnings("ignore", category=UserWarning) + +MANIFEST = ( + Path(__file__).parent.parent / "examples" / "data" / "characterization_akta" / "manifest.json" +) +LENGTH = "flow_sheet.tubing_pre_column.length" +DISPERSION = "flow_sheet.tubing_pre_column.axial_dispersion" +FILM = "flow_sheet.column.film_diffusion" +POROSITY = "flow_sheet.column.bed_porosity" + + +@pytest.fixture +def study() -> Study: + return Study.load(MANIFEST) + + +@pytest.fixture +def view(study) -> ParameterStoreWidget: + return ParameterStoreWidget(study) + + +def accept_periphery(study: Study) -> None: + study.accept("Extra-column volume", study.initial_store.updated( + {LENGTH: 0.42, DISPERSION: {"SystemTracer": 4.5e-7}}, + Provenance( + step="Extra-column volume", + probe="SystemTracer", + source="System pulse 1 (UV), System pulse 2 (UV)", + metric={"System pulse 1 (UV)_NRMSE": 0.01, "System pulse 2 (UV)_NRMSE": 0.02}, + ), + )) + + +def rows_by_label(view: ParameterStoreWidget) -> dict: + return { + label: [cell["text"] for cell in row] + for label, row in zip(view._table.option_labels, view._table.rows) + } + + +def test_friendly_labels(): + assert parameter_label(LENGTH) == "Tubing (pre column) · length" + assert parameter_label("flow_sheet.unknown.x") == "flow_sheet.unknown.x" + + +def test_lists_the_initial_store_one_row_per_species(view): + rows = rows_by_label(view) + assert rows[f"{FILM} SmallTracer"][:5] == [ + "Column · film diffusion", FILM, "SmallTracer", "8e-05", "initial", + ] + assert f"{FILM} LargeTracer" in rows + assert rows["flow_sheet.column.particle_radius"][2] == "" + assert rows["flow_sheet.column.particle_radius"][8].startswith("known hardware") + + +def test_refreshes_on_accept_and_shows_any_step(view, study): + accept_periphery(study) + + rows = rows_by_label(view) + assert rows[LENGTH][3] == "0.42" + assert rows[LENGTH][4:8] == [ + "Extra-column volume", + "SystemTracer", + "System pulse 1 (UV), System pulse 2 (UV)", + "System pulse 1 (UV)_NRMSE 0.01, System pulse 2 (UV)_NRMSE 0.02", + ] + assert view._step_picker.option_labels == [ + "Current (after the last accepted step)", "Initial", + "After Extra-column volume", "After Column packing (not accepted)", + ] + + view.show_after("") + assert view.get_value() is study.initial_store + assert LENGTH not in rows_by_label(view) + view.show_after("Column packing") + assert view.get_value() is study.posteriors["Extra-column volume"] + + +def test_chain_warnings(view, study): + assert chain_warnings(study) == [] + + study.upsert_step(replace(study.steps[0], requires=(POROSITY,))) + warnings_ = chain_warnings(study) + assert any("no earlier step provides" in w for w in warnings_) + assert any("not in the store it starts from" in w for w in warnings_) + assert POROSITY in view._warnings.value + + study.upsert_step(replace(study.steps[0], requires=())) + study.upsert_step(replace(study.steps[1], requires=(LENGTH,))) + assert [w for w in chain_warnings(study) if "no earlier step" in w] == [] + assert any("not in the store it starts from" in w for w in chain_warnings(study)) + accept_periphery(study) + assert chain_warnings(study) == [] + + +def test_incomplete_step_is_reported_not_raised(study): + study.upsert_step(replace(study.steps[0], options={})) + assert any("incomplete" in w for w in chain_warnings(study)) + + +def test_save_and_load_replace_the_study_in_place(view, study, tmp_path): + for comparison in study.comparisons: + shutil.copy(MANIFEST.parent / comparison.data_file, tmp_path / comparison.data_file) + accept_periphery(study) + view._path.value = str(tmp_path / "study.json") + view._btn_save.click() + assert "cadetgui-msg-ok" in view.status.value + + other = Study.load(MANIFEST) + calls = [] + other.add_listener(lambda: calls.append(1)) + other_view = ParameterStoreWidget(other) + assert not other.posteriors + + other_view.load_study(tmp_path / "study.json") + + assert calls == [1] + assert other.posteriors["Extra-column volume"].value(LENGTH) == 0.42 + assert other.base_dir == tmp_path + assert all(c.run is not None for c in other.comparisons) + assert rows_by_label(other_view)[LENGTH][3] == "0.42" + + +def test_load_error_is_shown(view, tmp_path): + view._path.value = str(tmp_path / "missing.json") + view._btn_load.click() + assert "cadetgui-msg-error" in view.status.value + + +def test_exports_the_current_store(view, study, tmp_path): + accept_periphery(study) + view._store_path.value = str(tmp_path / "store.json") + view._btn_export.click() + assert ParameterStore.load(tmp_path / "store.json") == study.current_store + + +def test_implied_values_are_labelled_as_assumed_by_the_experiment_type(study): + from cadetgui.characterization.guide import EXPERIMENT_TYPES, with_implied_values + from cadetgui.widgets.composite.parameter_store_view import set_by_label + + study.initial_store = with_implied_values( + study.initial_store, EXPERIMENT_TYPES["column_pulse_large_tracer"], "Dextran" + ) + view = ParameterStoreWidget(study) + + row = rows_by_label(view)[f"{FILM} Dextran"] + assert row[4] == "Assumed by experiment type: Column pulse, large tracer (UV)" + assert set_by_label("Extra-column volume") == "Extra-column volume" + assert "data-tip=" in view.root.children[1].value diff --git a/tests/test_process_builder.py b/tests/test_process_builder.py new file mode 100644 index 0000000..87ec9f9 --- /dev/null +++ b/tests/test_process_builder.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import warnings +from dataclasses import replace + +import numpy as np +import pytest +from cadetgui import process_builder +from cadetgui.cadetprocessadapter import BINDING_MODELS, INSTRUMENT_TEMPLATES +from cadetgui.process_builder import build_process, check_recipe +from cadetgui.simulation import run_process +from cadetgui.widgets.composite import ConfigurationWidget, InstrumentWidget +from CADETProcess.simulator import Cadet + +warnings.filterwarnings("ignore", category=UserWarning) + + +def _configs_equal(a, b) -> bool: + """Recursively compare two `Cadet().get_process_config()` trees, array-safe.""" + if isinstance(a, dict) and isinstance(b, dict): + return set(a) == set(b) and all(_configs_equal(a[k], b[k]) for k in a) + if isinstance(a, (list, tuple, np.ndarray)) or isinstance(b, (list, tuple, np.ndarray)): + return np.array_equal(np.asarray(a), np.asarray(b)) + return a == b + + +def _config(process): + return Cadet().get_process_config(process) + + +def _bound( + instrument: InstrumentWidget | None = None, +) -> tuple[ConfigurationWidget, InstrumentWidget]: + instrument = instrument or InstrumentWidget() + return ConfigurationWidget(instrument=instrument), instrument + + +def _assert_equivalent(cw: ConfigurationWidget) -> None: + """Snapshot `cw`, rebuild headlessly, and assert the CADET-Core configs match.""" + state = cw.snapshot() + rebuilt = build_process(state) + assert _configs_equal(_config(cw.process), _config(rebuilt)) + + +def test_build_process_matches_the_default_pulse_injection_configuration(): + cw, _ = _bound() + cw._model_picker.value = INSTRUMENT_TEMPLATES["Pulse Injection"] + _assert_equivalent(cw) + + +def test_build_process_matches_a_configuration_with_bypassed_tubing(): + cw, instrument = _bound() + instrument._unit_checkboxes["tubing_pre_column"].value = False + instrument._unit_checkboxes["tubing_detectors"].value = False + _assert_equivalent(cw) + + +def test_build_process_matches_a_configuration_with_the_sample_loop_included(): + cw, instrument = _bound() + instrument._sample_loop_checkbox.value = True + instrument._loop_diameter_auto_checkbox.value = False + instrument._loop_diameter_field.value = 1e-3 + instrument._loop_volume_field.value = 80e-9 + cw._model_picker.value = INSTRUMENT_TEMPLATES["Pulse Injection"] + _assert_equivalent(cw) + + +def test_build_process_matches_an_sma_binding_configuration_with_multiple_components(): + cw, _ = _bound() + cw.components = ["Salt", "Protein", "Impurity"] + cw._binding_picker.value = BINDING_MODELS["Steric Mass Action (SMA)"] + _assert_equivalent(cw) + + +def test_build_process_matches_a_configuration_with_the_column_bypassed(): + cw, instrument = _bound() + instrument._unit_checkboxes["column"].value = False + _assert_equivalent(cw) + + +def test_build_process_matches_a_standalone_configuration(): + cw = ConfigurationWidget() + _assert_equivalent(cw) + + +def test_build_process_overrides_replace_model_values_without_touching_the_saved_state(): + cw, _ = _bound() + state = cw.snapshot() + + baseline = build_process(state) + overridden = build_process(state, overrides={"flow_rate": 5e-6}) + + flow_rate_event = next(e for e in overridden.events if e.name == "phase_0_F") + assert flow_rate_event.state == 5e-6 + assert not _configs_equal(_config(baseline), _config(overridden)) + assert state.model_values.get("flow_rate") != 5e-6 + + +def test_build_process_raises_for_an_unregistered_column_key(): + cw, _ = _bound() + state = replace(cw.snapshot(), column_key="Not a real column") + with pytest.raises(ValueError, match="column"): + build_process(state) + + +def test_build_process_raises_for_an_unregistered_template_key(): + cw, _ = _bound() + state = replace(cw.snapshot(), template_key="Not a real template") + with pytest.raises(ValueError, match="template"): + build_process(state) + + +def test_build_process_simulates_to_the_same_outlet_as_the_widget(): + cw, _ = _bound() + state = cw.snapshot() + rebuilt = build_process(state) + + result_widget = run_process(cw.process) + result_rebuilt = run_process(rebuilt) + + assert np.allclose( + result_widget.solution["outlet"]["outlet"].solution, + result_rebuilt.solution["outlet"]["outlet"].solution, + ) + + +def test_check_recipe_builds_once_per_content_and_reports_errors(monkeypatch): + cw, _ = _bound() + state = cw.snapshot() + process_builder.clear_recipe_checks() + calls = [] + original = process_builder.build_process + + def counting(*args, **kwargs): + calls.append(args) + return original(*args, **kwargs) + + monkeypatch.setattr(process_builder, "build_process", counting) + + check = check_recipe(state) + assert check_recipe(replace(state)) is check + assert len(calls) == 1 + assert check.error is None + assert "column" in check.units + assert check.species == tuple(state.components) + assert check.signal_options + assert check.has_parameter("flow_sheet.column.length") + assert not check.has_parameter("flow_sheet.column.not_a_parameter") + + check_recipe(state, {"flow_rate": 5e-6}) + assert len(calls) == 2 + + broken = check_recipe(replace(state, column_key="Not a real column")) + assert broken.error and "column" in broken.error + assert broken.units == () and not broken.has_parameter("flow_sheet.column.length") diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..92de4f8 --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,35 @@ +import subprocess +import sys + +import cadetgui +import cadetgui.widgets + + +def test_benches_import_from_the_top_level(): + from cadetgui import CharacterizationWorkbenchWidget, WorkbenchWidget + from cadetgui.widgets import composite + + assert WorkbenchWidget is composite.WorkbenchWidget + assert CharacterizationWorkbenchWidget is composite.CharacterizationWorkbenchWidget + + +def test_top_level_import_does_not_load_the_widgets(): + code = "import sys, cadetgui; print('cadetgui.widgets' in sys.modules)" + out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert out.stdout.strip() == "False" + + +def test_bench_kit_is_exported_from_widgets(): + for name in cadetgui.widgets.__all__: + assert getattr(cadetgui.widgets, name) is not None + assert {"SidebarShell", "bench_root", "status_html", "style_tag"} <= set( + cadetgui.widgets.__all__ + ) + + +def test_unknown_top_level_attribute_raises(): + try: + cadetgui.NotAThing # noqa: B018 + except AttributeError: + return + raise AssertionError("expected AttributeError") diff --git a/tests/test_record_store.py b/tests/test_record_store.py index d730ff9..9288bb6 100644 --- a/tests/test_record_store.py +++ b/tests/test_record_store.py @@ -3,7 +3,12 @@ from dataclasses import dataclass import pytest -from cadetgui._record_store import delete_record, list_records, load_record, save_record +from cadetgui.io._record_store import ( + delete_record, + list_records, + load_record, + save_record, +) @dataclass diff --git a/tests/test_run_history.py b/tests/test_run_history.py index 4f146e7..b4e331f 100644 --- a/tests/test_run_history.py +++ b/tests/test_run_history.py @@ -1,6 +1,6 @@ from __future__ import annotations -import cadetgui.run_store as run_store +import cadetgui.io.run_store as run_store from cadetgui.widgets.composite import RunHistoryWidget diff --git a/tests/test_run_store.py b/tests/test_run_store.py index 76e1a63..d83adf5 100644 --- a/tests/test_run_store.py +++ b/tests/test_run_store.py @@ -2,7 +2,7 @@ import warnings -import cadetgui.run_store as run_store +import cadetgui.io.run_store as run_store import pytest from cadetgui.widgets.composite import ConfigurationWidget, InstrumentWidget diff --git a/tests/test_sensitivity.py b/tests/test_sensitivity.py new file mode 100644 index 0000000..1e1bffa --- /dev/null +++ b/tests/test_sensitivity.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import cadetgui.sensitivity as sensitivity +import numpy as np +import pytest +from cadetgui.sensitivity import ( + _get_by_path, + _report_from_matrix, + _set_by_path, + analyze_sensitivity, + perturb_parameters, + response_matrix, +) +from cadetgui.widgets.composite import ConfigurationWidget, InstrumentWidget + + +class _FakeSolution: + def __init__(self, values): + self.solution = np.asarray(values) + + +class _FakeResult: + def __init__(self, values): + self.solution = {"outlet": {"outlet": _FakeSolution(values)}} + + +def _fake_perturbed(responses: dict[str, np.ndarray]) -> dict: + """Build a `perturb_parameters`-shaped dict with a hand-chosen response + vector per parameter, bypassing real simulation entirely -- the response + for path `p` is exactly `(value_plus - value_minus) * responses[p]`, so + with `value_plus - value_minus == 1.0` the resulting `response_matrix` + row is exactly `responses[p]`.""" + return { + path: (_FakeResult(-vector / 2), _FakeResult(vector / 2), -0.5, 0.5) + for path, vector in responses.items() + } + + +def test_response_matrix_reads_the_signal_path_and_normalizes_by_the_step(): + perturbed = _fake_perturbed({"a": np.array([1.0, 2.0, 3.0])}) + + matrix = response_matrix(perturbed, ("outlet", "outlet")) + + assert matrix.shape == (1, 3) + np.testing.assert_allclose(matrix[0], [1.0, 2.0, 3.0]) + + +def test_identical_response_vectors_are_flagged_as_a_correlated_pair(): + vector = np.array([1.0, 2.0, 3.0, 0.5]) + orthogonal = np.array([0.0, 1.0, 0.0, -1.0]) + perturbed = _fake_perturbed({"a": vector, "b": 2.0 * vector, "c": orthogonal}) + matrix = response_matrix(perturbed, ("outlet", "outlet")) + + report = _report_from_matrix(["a", "b", "c"], matrix, similarity_threshold=0.99) + + assert report.cosine_similarity[0, 1] == pytest.approx(1.0, abs=1e-9) + assert report.flagged_pairs == [("a", "b", pytest.approx(1.0, abs=1e-9))] + + +def test_orthogonal_response_vectors_are_not_flagged(): + perturbed = _fake_perturbed({"a": np.array([1.0, 0.0]), "b": np.array([0.0, 1.0])}) + matrix = response_matrix(perturbed, ("outlet", "outlet")) + + report = _report_from_matrix(["a", "b"], matrix, similarity_threshold=0.99) + + assert report.cosine_similarity[0, 1] == pytest.approx(0.0, abs=1e-9) + assert report.flagged_pairs == [] + + +def test_get_and_set_by_path_round_trip_a_list_wrapped_scalar(): + class Unit: + axial_dispersion = [1e-8] + + unit = Unit() + assert _get_by_path(unit, "axial_dispersion") == pytest.approx(1e-8) + + _set_by_path(unit, "axial_dispersion", 2e-8) + + assert unit.axial_dispersion == [2e-8] + + +def test_get_by_path_rejects_a_multi_component_list(): + class Unit: + axial_dispersion = [1e-8, 2e-8] + + with pytest.raises(ValueError, match="single scalar"): + _get_by_path(Unit(), "axial_dispersion") + + +@pytest.mark.parametrize( + ("base", "expected_step"), [(2.0, 2.0e-2), (0.0, 1e-2)] +) +def test_perturb_parameters_steps_relative_to_the_value_or_absolutely_at_zero( + monkeypatch, base, expected_step +): + class Process: + x = base + + monkeypatch.setattr(sensitivity, "run_process", lambda process: process) + process = Process() + + (minus, plus, value_minus, value_plus), = perturb_parameters( + process, ["x"], relative_step=1e-2 + ).values() + + assert (value_minus, value_plus) == pytest.approx((base - expected_step, base + expected_step)) + assert (minus.x, plus.x) == (value_minus, value_plus) + assert process.x == base + + +def _built_process(): + iw = InstrumentWidget() + cw = ConfigurationWidget(instrument=iw) + return cw.process + + +@pytest.mark.slow +def test_analyze_sensitivity_runs_end_to_end_against_a_real_process(): + process = _built_process() + + report = analyze_sensitivity( + process, + ["flow_sheet.column.total_porosity", "flow_sheet.column.axial_dispersion"], + ("outlet", "outlet"), + relative_step=1e-2, + ) + + assert report.parameter_names == [ + "flow_sheet.column.total_porosity", "flow_sheet.column.axial_dispersion", + ] + assert report.cosine_similarity.shape == (2, 2) + assert report.cosine_similarity[0, 0] == pytest.approx(1.0, abs=1e-6) + assert report.condition_number > 0 + for a, b, score in report.flagged_pairs: + assert -1.0 <= score <= 1.0 + + +@pytest.mark.slow +def test_perturb_parameters_never_mutates_the_caller_s_process(): + process = _built_process() + original_value = process.flow_sheet.column.total_porosity + + perturb_parameters(process, ["flow_sheet.column.total_porosity"], relative_step=1e-2) + + assert process.flow_sheet.column.total_porosity == original_value diff --git a/tests/test_sidebar_shell.py b/tests/test_sidebar_shell.py index 12bcf3e..af77386 100644 --- a/tests/test_sidebar_shell.py +++ b/tests/test_sidebar_shell.py @@ -2,7 +2,7 @@ import ipywidgets as W import pytest -from cadetgui.widgets._sidebar_shell import ( +from cadetgui.widgets.shell import ( SidebarShell, collect_panes, resolve_step, @@ -206,3 +206,34 @@ def test_group_validation(): SidebarShell([("A", _pane())], groups={"G": ["A"], "H": ["A"]}) shell = SidebarShell([("A", _pane())], groups={"G": ["Z"]}) assert list(shell.nav.options) == ["A"] + + +def test_set_panes_replaces_panes_and_keeps_warnings_of_remaining_labels(): + a, b, c = _pane(), _pane(), _pane() + shell = SidebarShell([("A", a), ("B", b)]) + shell.show("B") + shell.set_warning("A", "careful") + shell.set_warning("B", "gone") + + shell.set_panes([("A", a), ("G: B2", b), ("G: C", c)], groups={"G": ["G: B2", "G: C"]}) + + assert list(shell.panes) == ["A", "G: B2", "G: C"] + assert shell.nav.value == "A" + assert (a.layout.display, b.layout.display) == ("", "none") + assert shell._content.children[1:] == (a, b, c) + assert shell.nav.tooltips == ("careful", "") + + shell.set_panes([("A", a), ("B", b), ("C", c)], show="C") + assert c.layout.display == "" + assert shell.nav.tooltips == ("careful", "", "") + + +def test_set_panes_keeps_the_current_pane_when_it_remains(): + a, b = _pane(), _pane() + shell = SidebarShell([("A", a), ("B", b)]) + shell.show("B") + + shell.set_panes([("B", b), ("A", a)]) + + assert shell.nav.value == "B" + assert b.layout.display == "" diff --git a/tests/test_simulation.py b/tests/test_simulation.py new file mode 100644 index 0000000..8670355 --- /dev/null +++ b/tests/test_simulation.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np +import pytest +from cadetgui.simulation import Simulator, output_time_step + + +def _process(*section_times: float) -> SimpleNamespace: + return SimpleNamespace(section_times=list(section_times), cycle_time=section_times[-1]) + + +@pytest.mark.parametrize( + "process, step", + [ + (_process(0.0, 600.0), 0.12), + (_process(0.0, 2.0, 600.0), 0.1), + (_process(0.0, 0.01, 600.0), 0.03), + (_process(0.0, 20000.0), 1.0), + ], +) +def test_output_time_step_follows_cycle_and_shortest_section(process, step): + assert output_time_step(process) == pytest.approx(step) + + +def test_simulator_output_grid_resolves_a_short_lab_scale_run(): + time = Simulator().get_solution_time(_process(0.0, 600.0)) + assert len(time) == 5001 + assert np.diff(time).max() == pytest.approx(0.12) diff --git a/tests/test_solution.py b/tests/test_solution.py index b776163..bcb4ab5 100644 --- a/tests/test_solution.py +++ b/tests/test_solution.py @@ -7,7 +7,7 @@ matplotlib.use("Agg") # headless test environment, no display needed -import cadetgui.configuration_store as configuration_store +import cadetgui.io.configuration_store as configuration_store from cadetgui.cadetprocessadapter import INSTRUMENT_TEMPLATES, classify_signal_ports from cadetgui.simulation import run_process as _default_runner from cadetgui.widgets.composite import ( @@ -433,7 +433,7 @@ def test_solutionwidget_run_auto_saves_config_to_store(): sw._on_run(None) name, state = configuration_store.load_from_store(cw.config_hash) - assert state == cw._snapshot_state() + assert state == cw.snapshot() def test_solutionwidget_run_without_bound_config_leaves_history_untagged(): @@ -468,7 +468,7 @@ def test_solutionwidget_process_label_shows_the_configuration_name_not_the_proce cw = built_configuration() sw = SolutionWidget() sw.bind_to_config(cw) - assert "New Experiment" in sw._process_label.value # the default name, not "pulse_injection" + assert "New Project" in sw._process_label.value # the default name, not "pulse_injection" assert "pulse_injection" not in sw._process_label.value cw.persistence._name_field.value = "My Named Config" @@ -520,7 +520,7 @@ def test_solutionwidget_run_without_a_store_dir_reserves_no_run_id(): def test_solutionwidget_run_with_a_store_dir_persists_the_run(tmp_path): - import cadetgui.run_store as run_store + import cadetgui.io.run_store as run_store sw = SolutionWidget(process=built_process()) sw.history.store_dir = tmp_path @@ -639,7 +639,7 @@ def test_solutionwidget_delete_button_follows_the_selection(tmp_path): def test_solutionwidget_delete_needs_a_second_click_to_confirm(tmp_path): - import cadetgui.run_store as run_store + import cadetgui.io.run_store as run_store sw = _persisted_run_dir(tmp_path, n=1) @@ -672,7 +672,7 @@ def test_solutionwidget_delete_confirmation_reverts_when_another_button_is_click def test_solutionwidget_confirmed_delete_removes_run_files_and_clears_the_view(tmp_path): - import cadetgui.run_store as run_store + import cadetgui.io.run_store as run_store sw = _persisted_run_dir(tmp_path) keep, drop = sw.history.runs @@ -699,7 +699,7 @@ def test_solutionwidget_confirmed_delete_removes_run_files_and_clears_the_view(t def test_solutionwidget_delete_a_failed_run_removes_its_manifest(tmp_path): - import cadetgui.run_store as run_store + import cadetgui.io.run_store as run_store def boom(process, **_kwargs): raise RuntimeError("nope") diff --git a/tests/test_starting_values.py b/tests/test_starting_values.py new file mode 100644 index 0000000..3800f93 --- /dev/null +++ b/tests/test_starting_values.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import warnings + +from cadetgui.characterization.guide import EXPERIMENT_TYPES +from cadetgui.io.configuration_store import ConfigurationState +from cadetgui.starting_values import ( + binding_starting_values, + starting_values, + with_starting_values, +) + +warnings.filterwarnings("ignore", category=UserWarning) +warnings.filterwarnings("ignore", category=DeprecationWarning) + +SMA = "Steric Mass Action (SMA)" + + +def _recipe(**kwargs): + values = dict( + components=["Salt", "Protein"], column_key="Lumped Rate Model With Pores (LRMP)", + binding_key=SMA, template_key="Load–Wash–Elute (LWE)", + model_values={"c_buffer_a": [50.0, 0.0]}, + ) + values.update(kwargs) + return ConfigurationState(**values) + + +def test_sma_starting_values_fill_zeros_and_keep_the_salt_at_zero(): + seeded = binding_starting_values(SMA, ["Salt", "Protein"], { + "capacity": 0.0, "adsorption_rate": [0.0, 0.0], "is_kinetic": True, + }) + start = starting_values()["binding"][SMA] + assert seeded["capacity"] == start["scalar"]["capacity"] + assert seeded["is_kinetic"] is False + assert seeded["adsorption_rate"] == [0.0, start["per_component"]["adsorption_rate"]] + assert seeded["steric_factor"] == [0.0, start["per_component"]["steric_factor"]] + + +def test_values_already_set_are_kept(): + seeded = binding_starting_values(SMA, ["Salt", "A", "B"], { + "capacity": 800.0, "characteristic_charge": [0.0, 3.0, 0.0], "is_kinetic": True, + }) + assert seeded["capacity"] == 800.0 + assert seeded["is_kinetic"] is True + assert seeded["characteristic_charge"] == [0.0, 3.0, 4.7] + + +def test_models_without_starting_values_are_left_alone(): + values = {"adsorption_rate": [0.0], "desorption_rate": [0.0], "is_kinetic": True} + assert binding_starting_values("Linear", ["A"], values) == values + recipe = _recipe(binding_key="Linear", components=["A"]) + assert with_starting_values(recipe) is recipe + + +def test_method_values_follow_the_template_and_reset_the_column_start(): + seeded = with_starting_values(_recipe(column_values={"c": [10.0, 0.0]})) + assert seeded.model_values["c_buffer_a"] == [50.0, 0.0] + assert seeded.model_values["c_sample"] == [50.0, 1.0] + assert seeded.column_values["c"] == seeded.column_values["cp"] == [50.0, 0.0] + assert seeded.column_values["q"] == [1200.0, 0.0] + assert seeded.show_optional_column + + pulse = with_starting_values(_recipe(template_key="Pulse Injection")) + assert pulse.model_values["c_buffer_a"] == [400.0, 0.0] + assert pulse.column_values["c"] == [400.0, 0.0] + breakthrough = with_starting_values(_recipe(template_key="Breakthrough")) + assert breakthrough.model_values["cycle_time"] == 1800.0 + + +def test_without_method_only_the_binding_is_filled(): + recipe = _recipe(column_values={"c": [10.0, 0.0]}) + seeded = with_starting_values(recipe, method=False) + assert seeded.model_values == recipe.model_values + assert seeded.column_values == recipe.column_values + assert seeded.binding_values["capacity"] == 1200.0 + + unset = with_starting_values(_recipe(), method=False) + assert unset.model_values == {"c_buffer_a": [50.0, 0.0]} + assert unset.column_values["c"] == [50.0, 0.0] + assert with_starting_values(_recipe(), method=False, equilibrate=False).column_values == {} + + +def test_every_listed_method_names_real_template_fields(): + from types import SimpleNamespace + + from cadetgui.cadetprocessadapter import INSTRUMENT_TEMPLATES + from CADETProcess.processModel import ComponentSystem + + stub = SimpleNamespace(component_system=ComponentSystem(["Salt", "Protein"])) + for entry in starting_values()["binding"].values(): + for template, method in entry.get("methods", {}).items(): + fields = {f.name for f in INSTRUMENT_TEMPLATES[template](stub).fields} + assert set(method) - {"note"} <= fields, template + + +def test_binding_experiment_types_get_starting_values(): + base = _recipe(binding_key="None", components=["Salt"], model_values={}) + recipe = EXPERIMENT_TYPES["linear_gradient_elution"].apply(base, component="Lysozyme") + assert recipe.binding_values["capacity"] > 0 + assert recipe.binding_values["characteristic_charge"][1] > 0 + + +def test_configuration_fills_starting_values_into_a_fresh_sma_setup(tmp_path, monkeypatch): + import cadetgui.io.configuration_store as configuration_store + from cadetgui.widgets.composite import ConfigurationWidget, InstrumentWidget + + monkeypatch.setattr(configuration_store, "default_store_dir", lambda: tmp_path) + + cw = ConfigurationWidget(instrument=InstrumentWidget()) + cw.components = ["Salt", "Protein"] + assert all(v == 0 for v in cw.binding_form.collect_values()["adsorption_rate"]) + + cw.select_models(template="Load–Wash–Elute (LWE)", binding=SMA) + state = cw.snapshot() + assert state.binding_values["capacity"] == 1200.0 + assert state.binding_values["adsorption_rate"] == [0.0, 35.5] + assert state.model_values["c_buffer_a"] == [50.0, 0.0] + assert state.column_values["c"] == [50.0, 0.0] + assert "cp" not in state.column_values + + cw.select_models(template="Pulse Injection") + assert cw.snapshot().model_values["c_buffer_a"] == [400.0, 0.0] diff --git a/tests/test_study.py b/tests/test_study.py new file mode 100644 index 0000000..a5162f4 --- /dev/null +++ b/tests/test_study.py @@ -0,0 +1,240 @@ +import dataclasses +import json +from pathlib import Path + +import numpy as np +import pytest +from cadetgui.characterization.parameter_store import ( + ParameterSpec, + ParameterStore, + Provenance, +) +from cadetgui.characterization.runner import default_optimizer +from cadetgui.characterization.study import Study +from cadetgui.io.experimental_data import Channel, ExperimentalRun + +MANIFEST = ( + Path(__file__).parent.parent / "examples" / "data" / "characterization_akta" / "manifest.json" +) +LENGTH = "flow_sheet.tubing_pre_column.length" + + +@pytest.fixture +def study() -> Study: + return Study.load(MANIFEST) + + +def test_loads_the_example_manifest_with_data(study): + assert [s.name for s in study.steps] == ["Extra-column volume", "Column packing"] + assert all(c.run is not None for c in study.comparisons) + assert study.steps[1].comparisons[0] is study.comparison(study.steps[1].comparisons[0].name) + + +def test_round_trips_through_json(study, tmp_path): + posterior = study.initial_store.updated( + {LENGTH: 0.5}, Provenance(step="Extra-column volume"), specs={LENGTH: ParameterSpec(LENGTH)} + ) + study.accept("Extra-column volume", posterior) + for comparison in study.comparisons: + (tmp_path / comparison.data_file).write_bytes( + (MANIFEST.parent / comparison.data_file).read_bytes() + ) + + reloaded = Study.load(study.save(tmp_path / "study.json")) + + assert reloaded.to_dict() == study.to_dict() + assert reloaded.posteriors["Extra-column volume"].value(LENGTH) == 0.5 + + +def test_prior_and_current_store_follow_the_chain(study): + first = study.initial_store.updated( + {LENGTH: 0.5}, Provenance(step="Extra-column volume"), specs={LENGTH: ParameterSpec(LENGTH)} + ) + assert study.prior_for("Column packing") is study.initial_store + + study.accept("Extra-column volume", first) + + assert study.prior_for("Extra-column volume") is study.initial_store + assert study.prior_for("Column packing") is first + assert study.current_store is first + + +def test_listeners_fire_on_changes(study): + calls = [] + study.add_listener(lambda: calls.append(1)) + study.accept("Extra-column volume", study.initial_store) + assert len(calls) == 1 + + +def test_removing_a_comparison_a_step_uses_raises(study): + with pytest.raises(ValueError, match="Extra-column volume"): + study.remove_comparison("System pulse 1 (UV)") + study.steps = [] + study.remove_comparison("System pulse 1 (UV)") + assert "System pulse 1 (UV)" not in [c.name for c in study.comparisons] + + +def test_excluded_measurements_persist_and_follow_removal(study, tmp_path): + step = study.steps[0] + assert step.excluded is None + study.upsert_step(dataclasses.replace( + step, comparisons=step.comparisons[:1], excluded=("System pulse 2 (UV)",) + )) + + reloaded = Study.load(study.save(tmp_path / "study.json")) + assert reloaded.steps[0].excluded == ("System pulse 2 (UV)",) + assert reloaded.steps[1].excluded is None + + reloaded.remove_comparison("System pulse 2 (UV)") + assert reloaded.steps[0].excluded == () + + +def test_store_specs_persist_and_conflicts_raise(): + store = ParameterStore().updated( + {"a.b": {"X": 1.0}}, Provenance(step="s"), + specs={"a.b": ParameterSpec("a.b", species_indexed=True)}, + ) + raw = json.loads(json.dumps(store.to_dict())) + + assert ParameterStore.from_dict(raw).spec("a.b").species_indexed + with pytest.raises(ValueError, match="a.b"): + ParameterStore.from_dict(raw, {"a.b": ParameterSpec("a.b", species_indexed=False)}) + + +def test_replace_comparison_renames_in_place_and_in_steps(study): + old = study.comparison("System pulse 1 (UV)") + renamed = dataclasses.replace(old, name="S-A2") + calls = [] + study.add_listener(lambda: calls.append(1)) + + study.replace_comparison("System pulse 1 (UV)", renamed) + + assert study.comparisons[0] is renamed + assert study.steps[0].comparisons[0] is renamed + assert study.steps_using("S-A2") == ["Extra-column volume"] + assert study.steps_using("System pulse 1 (UV)") == [] + assert calls == [1] + + +def test_replace_comparison_rejects_a_name_clash(study): + clash = dataclasses.replace(study.comparison("System pulse 1 (UV)"), name="System pulse 2 (UV)") + with pytest.raises(ValueError, match="already exists"): + study.replace_comparison("System pulse 1 (UV)", clash) + with pytest.raises(KeyError): + study.replace_comparison("nope", dataclasses.replace(clash, name="nope")) + + +def test_species_gaps_and_explicit_transfer(study): + from cadetgui.characterization.parameter_store import transfer_species + from cadetgui.characterization.runner import species_gaps + + dispersion = "flow_sheet.tubing_pre_column.axial_dispersion" + periphery = study.initial_store.updated( + {dispersion: {"SystemTracer": 4e-7}}, + Provenance(step="Extra-column volume", probe="SystemTracer"), + specs={dispersion: ParameterSpec(dispersion, species_indexed=True)}, + ) + column_step = study.steps[1] + + gaps = species_gaps(column_step, periphery) + assert sorted(gaps[dispersion]) == ["LargeTracer", "SmallTracer"] + + transferred = transfer_species(periphery, [dispersion], gaps[dispersion]) + + assert dispersion not in species_gaps(column_step, transferred) + assert transferred.value(dispersion) == { + "SystemTracer": 4e-7, "SmallTracer": 4e-7, "LargeTracer": 4e-7, + } + assert "transferred unchanged" in transferred.entries[dispersion].provenance.note + assert transferred.entries[dispersion].provenance.probe == "SystemTracer" + + +def _imported_run() -> ExperimentalRun: + x = np.linspace(0.0, 5.0, 51) + return ExperimentalRun( + label="imported run", + channels={"UV 1_280": Channel("UV 1_280", x, np.exp(-((x - 2.0) ** 2)))}, + x_basis="volume", x_unit="mL", markers=[(0.5, "Phase Elution")], + ) + + +def test_save_to_a_new_folder_is_self_contained(study, tmp_path): + imported = dataclasses.replace( + study.comparison("System pulse 1 (UV)"), name="S-imported", data_file="imported run.csv", + run=_imported_run(), + ) + study.upsert_comparison(imported) + calls = [] + study.add_listener(lambda: calls.append(1)) + + path = study.save(tmp_path / "out" / "study.json") + + assert study.base_dir == path.parent + assert imported.data_file == "imported run.csv" + assert calls + names = sorted(p.name for p in path.parent.iterdir()) + data_file_stems = ( + "system_pulse_1_uv", + "system_pulse_2_uv", + "small_tracer_pulse_1_conductivity", + "small_tracer_pulse_2_conductivity", + "large_tracer_pulse_1_uv", + "large_tracer_pulse_2_uv", + ) + assert names == sorted( + ["study.json", "imported run.csv"] + [f"{c}.csv" for c in data_file_stems] + ) + saved_pulse = (path.parent / "system_pulse_1_uv.csv").read_bytes() + assert saved_pulse == (MANIFEST.parent / "system_pulse_1_uv.csv").read_bytes() + + loaded = Study.load(path) + run = loaded.comparison("S-imported").run + np.testing.assert_array_equal(run.channels["UV 1_280"].values, + imported.run.channels["UV 1_280"].values) + assert run.markers == imported.run.markers + assert loaded.comparison("S-imported").problems() == imported.problems() + + +def test_saving_again_to_the_same_folder_writes_nothing_new(study, tmp_path): + path = study.save(tmp_path / "study.json") + before = {p.name: p.stat().st_mtime_ns for p in tmp_path.iterdir() if p.suffix == ".csv"} + Study.load(path).save(path) + after = {p.name: p.stat().st_mtime_ns for p in tmp_path.iterdir() if p.suffix == ".csv"} + assert after == before + + +def test_save_never_overwrites_a_different_file(study, tmp_path): + (tmp_path / "system_pulse_1_uv.csv").write_text("unrelated") + study.save(tmp_path / "study.json") + + assert (tmp_path / "system_pulse_1_uv.csv").read_text() == "unrelated" + assert study.comparison("System pulse 1 (UV)").data_file == "system_pulse_1_uv_2.csv" + assert Study.load(tmp_path / "study.json").comparison("System pulse 1 (UV)").run is not None + + +def test_step_optimizer_round_trips(study, tmp_path): + first = study.steps[0] + assert first.optimizer == default_optimizer(n_variables=first.n_fitted_variables()) + assert first.optimizer == { + "name": "U-NSGA-III", "knobs": {"pop_size": 16, "n_max_gen": 12} + } + second = study.steps[1].optimizer + study.steps[0].optimizer = default_optimizer("Nelder-Mead", maxiter=50) + + loaded = Study.load(study.save(tmp_path / "study.json")) + + assert loaded.steps[0].optimizer == {"name": "Nelder-Mead", "knobs": {"maxiter": 50}} + assert loaded.steps[1].optimizer == second + + +def test_remove_listener(study): + calls = [] + + def listener(): + calls.append(1) + + study.add_listener(listener) + study.remove_listener(listener) + study.remove_listener(listener) + study.notify() + assert calls == [] diff --git a/tests/test_study_components.py b/tests/test_study_components.py new file mode 100644 index 0000000..823cf25 --- /dev/null +++ b/tests/test_study_components.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import dataclasses +import json +from pathlib import Path + +import pytest +from cadetgui.characterization.guide import ( + COMPONENT_ROLES, + EXPERIMENT_TYPES, + GLOSSARY, + SALT, + WORKFLOW_GUIDE, + measurement_from_run, + parameter_label, +) +from cadetgui.characterization.parameter_store import Provenance +from cadetgui.characterization.step_checks import study_steps_status +from cadetgui.characterization.study import Study, StudyComponent, derive_components + +MANIFEST = ( + Path(__file__).parent.parent / "examples" / "data" / "characterization_akta" / "manifest.json" +) +FILM = "flow_sheet.column.film_diffusion" +DISPERSION = "flow_sheet.tubing_pre_column.axial_dispersion" +EXAMPLE_COMPONENTS = [ + StudyComponent("SystemTracer", "system tracer"), + StudyComponent("SmallTracer", "small tracer"), + StudyComponent("LargeTracer", "large tracer"), +] + + +@pytest.fixture +def study() -> Study: + return Study.load(MANIFEST) + + +def _gradient(study: Study, name: str = "Gradient 1", component: str = "IgG"): + source = study.comparison("Large tracer pulse 1 (UV)") + return measurement_from_run( + name, source.run, "UV 1_280", EXPERIMENT_TYPES["linear_gradient_elution"], + source.recipe, component=component, + ) + + +def test_roles_and_experiment_types(): + assert list(COMPONENT_ROLES) == [ + "system tracer", "small tracer", "large tracer", "protein", "salt", + ] + assert all(COMPONENT_ROLES.values()) + roles = {t.id: t.probe_roles for t in EXPERIMENT_TYPES.values()} + assert roles == { + "system_pulse": ("small tracer", "system tracer"), + "column_pulse_small_tracer": ("small tracer",), + "column_pulse_large_tracer": ("large tracer",), + "nonbinding_protein_pulse": ("protein",), + "linear_gradient_elution": ("protein",), + "breakthrough": ("protein",), + } + for term in ("component", "component role", *COMPONENT_ROLES): + assert GLOSSARY[term] + assert "**Components.**" in WORKFLOW_GUIDE + assert WORKFLOW_GUIDE.index("**System.**") < WORKFLOW_GUIDE.index("**Components.**") + + +def test_example_declares_its_components_and_round_trips(study, tmp_path): + assert study.components == EXAMPLE_COMPONENTS + raw = json.loads(MANIFEST.read_text()) + assert raw["components"][0] == {"name": "SystemTracer", "role": "system tracer"} + + study.add_component("IgG", "protein") + path = study.save(tmp_path / "study.json") + loaded = Study.load(path) + assert loaded.components == [*EXAMPLE_COMPONENTS, StudyComponent("IgG", "protein")] + + +def test_loading_without_components_derives_them(study): + raw = study.to_dict() + del raw["components"] + loaded = Study.from_dict(raw, base_dir=MANIFEST.parent) + assert loaded.components == [ + StudyComponent("SystemTracer", "small tracer"), *EXAMPLE_COMPONENTS[1:] + ] + + +def test_derivation_puts_salt_first_and_finds_a_common_role(study): + gradient = _gradient(study) + small = dataclasses.replace( + study.comparison("Small tracer pulse 1 (conductivity)"), name="small", + probe="Acetone", + ) + system = dataclasses.replace( + study.comparison("System pulse 1 (UV)"), name="system", probe="Acetone", + ) + untyped = dataclasses.replace(small, name="other", probe="Mystery", experiment_type=None) + assert derive_components([system, small, gradient, untyped]) == [ + StudyComponent(SALT, "salt"), + StudyComponent("Acetone", "small tracer"), + StudyComponent("IgG", "protein"), + StudyComponent("Mystery", "protein"), + ] + + +def test_adding_a_measurement_declares_its_probe_and_salt(study): + study.upsert_comparison(_gradient(study)) + assert study.components[0] == StudyComponent(SALT, "salt") + assert study.component("IgG") == StudyComponent("IgG", "protein") + assert study.measurements_using(SALT) == ["Gradient 1"] + + +def test_add_component_rules(study): + calls = [] + study.add_listener(lambda: calls.append(1)) + study.add_component(" IgG ", "protein") + assert study.component("IgG").role == "protein" and calls == [1] + with pytest.raises(ValueError, match="already declared"): + study.add_component("IgG", "protein") + with pytest.raises(ValueError, match="Unknown role"): + study.add_component("X", "enzyme") + with pytest.raises(ValueError, match="reserved"): + study.add_component(SALT, "protein") + with pytest.raises(ValueError, match="called 'Salt'"): + study.add_component("NaCl", "salt") + study.add_component(SALT, "salt") + assert study.components[0].name == SALT + + +def test_rename_updates_every_measurement_when_no_values_are_stored(study): + study.add_component("IgGG", "protein") + study.upsert_comparison(_gradient(study, component="IgGG")) + step_view = study.comparison("Gradient 1") + + study.rename_component("IgGG", "IgG") + + renamed = study.comparison("Gradient 1") + assert renamed is step_view + assert renamed.probe == "IgG" and renamed.components == ["IgG"] + assert renamed.recipe.components == [SALT, "IgG"] + assert study.component("IgGG") is None and study.component("IgG").role == "protein" + assert renamed.problems() == [] + + +def test_rename_is_refused_while_values_are_stored_under_the_name(study): + with pytest.raises(ValueError, match="film diffusion \\(Column\\)"): + study.rename_component("SmallTracer", "Acetone") + assert study.comparison("Small tracer pulse 1 (conductivity)").probe == "SmallTracer" + + study.accept("Extra-column volume", study.initial_store.updated( + {DISPERSION: {"SystemTracer": 4.5e-7}}, + Provenance(step="Extra-column volume", probe="SystemTracer"), + )) + with pytest.raises(ValueError, match="accepted step Extra-column volume"): + study.rename_component("SystemTracer", "Acetone") + with pytest.raises(ValueError, match="already declared"): + study.rename_component("SystemTracer", "SmallTracer") + + +def test_salt_cannot_be_renamed(study): + study.upsert_comparison(_gradient(study)) + with pytest.raises(ValueError, match="fixed"): + study.rename_component(SALT, "NaCl") + + +def test_remove_is_refused_while_used(study): + with pytest.raises(ValueError, match="System pulse 1"): + study.remove_component("SystemTracer") + study.add_component("IgG", "protein") + study.remove_component("IgG") + assert study.component("IgG") is None + with pytest.raises(KeyError): + study.remove_component("IgG") + + +def test_role_mismatch_and_undeclared_probes_are_problems(study): + comparison = study.comparison("Small tracer pulse 1 (conductivity)") + assert study.measurement_problems(comparison) == [] + + study.set_component_role("SmallTracer", "protein") + assert study.component_problems(comparison) == [ + "Column pulse, small tracer (conductivity) uses 'SmallTracer' as a small tracer, " + "but SmallTracer is declared as a protein." + ] + status = study_steps_status(study)[1] + assert not status.ready + assert any("declared as a protein" in p for p in status.problems) + + study.set_component_role("SystemTracer", "small tracer") + system = study.comparison("System pulse 1 (UV)") + assert study.component_problems(system) == [] + + comparison.probe = "Unknown" + assert study.component_problems(comparison) == ["'Unknown' is not a declared component."] + comparison.probe = None + assert "needs a small tracer" in study.component_problems(comparison)[0] + + +def test_species_gap_problems_name_the_component_and_the_step(study): + study.accept("Extra-column volume", study.initial_store.updated( + {DISPERSION: {"SystemTracer": 4.5e-7}}, + Provenance(step="Extra-column volume", probe="SystemTracer"), + )) + problems = study_steps_status(study)[1].problems + assert any( + "Column packing needs axial dispersion (Tubing (pre column)) for SmallTracer, " + "LargeTracer; Extra-column volume fitted it for SystemTracer" in p for p in problems + ) + assert parameter_label(FILM) == "film diffusion (Column)" diff --git a/tests/test_study_components_widget.py b/tests/test_study_components_widget.py new file mode 100644 index 0000000..fb4c1ab --- /dev/null +++ b/tests/test_study_components_widget.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import warnings +from pathlib import Path + +import cadetgui.io.configuration_store as configuration_store +import pytest +from cadetgui.characterization.study import Study, StudyComponent +from cadetgui.widgets.composite import ( + CharacterizationWorkbenchWidget, + ComparisonsWidget, + ConfigurationWidget, + InstrumentWidget, + StudyComponentsWidget, +) +from cadetgui.widgets.composite.characterization_setup import ( + CharacterizationSetupWidget, +) + +warnings.filterwarnings("ignore", category=UserWarning) +warnings.filterwarnings("ignore", category=DeprecationWarning) + +MANIFEST = ( + Path(__file__).parent.parent / "examples" / "data" / "characterization_akta" / "manifest.json" +) +EXAMPLE = ["SystemTracer", "SmallTracer", "LargeTracer"] + + +@pytest.fixture(autouse=True) +def _isolated_store(tmp_path, monkeypatch): + monkeypatch.setattr(configuration_store, "default_store_dir", lambda: tmp_path) + + +@pytest.fixture +def study() -> Study: + return Study.load(MANIFEST) + + +def _to_details(widget, file_name, type_id): + flow = widget._flow + widget.start_add() + flow.load_bytes(file_name, (MANIFEST.parent / file_name).read_bytes()) + flow.next() + flow.next() + flow._type_table.value = type_id + flow.next() + assert flow.page == 3 + return flow + + +def test_editor_lists_components_and_adds_one(study): + editor = StudyComponentsWidget(study) + assert list(editor.rows) == EXAMPLE + text, role, remove = editor.rows["SmallTracer"] + assert role.value == "small tracer" and remove.disabled + assert "used by 2 measurements" in editor._rows_box.children[1].children[3].value + assert "What goes here?" in editor.root.children[2].value + + assert editor.add("IgG", "protein") + assert study.component("IgG") == StudyComponent("IgG", "protein") + assert not editor.rows["IgG"][2].disabled + assert not editor.add("IgG", "protein") + assert "already declared" in editor.status.value + + +def test_editor_renames_changes_roles_and_removes(study): + editor = StudyComponentsWidget(study) + editor.add("IgGG", "protein") + editor.rows["IgGG"][0].value = "IgG" + assert study.component("IgG") is not None and "IgG" in editor.rows + + editor.rows["SmallTracer"][0].value = "Acetone" + assert "cannot be renamed" in editor.status.value + assert editor.rows["SmallTracer"][0].value == "SmallTracer" + + editor.rows["IgG"][1].value = "large tracer" + assert study.component("IgG").role == "large tracer" + + editor.rows["IgG"][2].click() + assert study.component("IgG") is None and "IgG" not in editor.rows + + +def test_salt_row_is_fixed(study): + study.add_component("Salt", "salt") + editor = StudyComponentsWidget(study) + text, role, _ = editor.rows["Salt"] + assert text.disabled and role.disabled + + +def test_system_pane_edits_components_with_roles_above_the_hardware(study): + wb = CharacterizationWorkbenchWidget(study=study) + assert wb._shell.panes["System: Instrument"] is wb.instrument.root + assert wb.components is wb.instrument.study_components + children = list(wb.instrument.root.children) + section = wb.instrument._components_section + assert children.index(section) < children.index(wb.instrument._diagram.root) + assert children.index(section) < children.index(wb.instrument._flow_path_section) + assert wb.components.root in section.children + assert list(wb.components.rows) == EXAMPLE + + +def test_workbench_components_follow_the_study(study): + wb = CharacterizationWorkbenchWidget(study=study) + assert wb.instrument.components == EXAMPLE + assert wb.configuration.components == EXAMPLE + assert wb.configuration._components_section.layout.display == "none" + + study.add_component("IgG", "protein") + assert wb.configuration.components == [*EXAMPLE, "IgG"] + study.add_component("Salt", "salt") + assert wb.instrument.components[0] == "Salt" + assert wb.configuration.components[0] == "Salt" + + +def test_roles_only_when_a_study_is_bound(study): + iw = InstrumentWidget() + assert iw.study_components is None + assert iw._components_field in iw._components_section.children + + iw.bind_study(study) + assert iw._components_field not in iw._components_section.children + assert iw.study_components.rows["SmallTracer"][1].value == "small tracer" + assert iw.components == EXAMPLE + + +def test_standalone_configuration_keeps_its_component_editor(): + cw = ConfigurationWidget() + assert cw._components_section.layout.display != "none" + bound = ConfigurationWidget(instrument=InstrumentWidget()) + assert bound._components_section.layout.display == "none" + + +def test_add_flow_offers_only_fitting_components(study): + widget = ComparisonsWidget(study) + flow = _to_details(widget, "system_pulse_1_uv.csv", "system_pulse") + assert flow._component.option_labels == [ + "SystemTracer (system tracer)", "SmallTracer (small tracer)", + "LargeTracer (large tracer — not a small tracer or system tracer)", "New component…", + ] + assert flow._component.value == "SystemTracer" + assert flow._new_component.layout.display == "none" + + +def test_add_flow_declares_a_new_component_with_the_types_role(study): + widget = ComparisonsWidget(study) + flow = _to_details(widget, "large_tracer_pulse_1_uv.csv", "nonbinding_protein_pulse") + assert flow._component.option_labels == [ + "SystemTracer (system tracer — not a protein)", + "SmallTracer (small tracer — not a protein)", + "LargeTracer (large tracer — not a protein)", + "New component…", + ] + assert flow._component.value == "__new__" + assert flow._new_component.layout.display == "" + assert "a protein" in flow._component_note.value + assert flow._new_role.value == "protein" + + flow.next() + assert flow.page == 3 and "New component" in flow.status.value + + flow.new_component("SmallTracer") + assert "already declared as small tracer" in flow._new_component.error + flow.new_component("IgG") + flow.next() + added = flow.confirm() + assert added.probe == "IgG" + assert study.component("IgG") == StudyComponent("IgG", "protein") + assert study.measurement_problems(added) == [] + assert "Declared IgG (protein)" in widget.status.value + + flow = _to_details(widget, "large_tracer_pulse_2_uv.csv", "nonbinding_protein_pulse") + assert flow._component.option_labels[0] == "IgG (protein)" + assert flow._component.option_labels[-1] == "New component…" + assert flow._component.value == "IgG" + + +def test_advanced_editor_picks_a_declared_component(study): + widget = ComparisonsWidget(study) + widget.select("Small tracer pulse 1 (conductivity)") + assert widget._probe.option_labels[:2] == ["None", "SmallTracer (small tracer)"] + assert "SystemTracer (system tracer — not a small tracer)" in widget._probe.option_labels + + study.add_component("Acetone", "small tracer") + assert "Acetone (small tracer)" in widget._probe.option_labels + widget._probe.value = "Acetone" + edited = study.comparison("Small tracer pulse 1 (conductivity)") + assert edited.probe == "Acetone" + assert edited.recipe.components == ["Acetone"] and edited.components == ["Acetone"] + assert list(widget._component_fields) == ["Acetone"] + + study.set_component_role("Acetone", "protein") + assert "Acetone (protein — not a small tracer)" in widget._probe.option_labels + assert "declared as a protein" in widget._problems_html.value + assert "System → Components" in widget._problems_html.value + + +def test_overview_system_card_lists_the_components(study): + recipe = study.comparisons[0].recipe + setup_pane = CharacterizationSetupWidget(study, system=lambda: recipe) + + def card(): + return setup_pane._system_card.children[0].children[1].value + + assert "SmallTracer" in card() + assert "small tracer2" in card() + assert "Names link the steps" not in card() + + study.add_component("IgG", "protein") + assert "IgG" in card() + + +def test_step_header_names_the_components(study): + from cadetgui.widgets.composite import CharacterizationStepWidget + + widget = CharacterizationStepWidget(study, "Column packing") + assert "SmallTracer (small tracer), LargeTracer (large tracer)" in widget.header.value + + +def test_role_selectors_are_styled_dropdowns(study): + from cadetgui.widgets._chrome import style_tag + from cadetgui.widgets.elements import ChoiceField + + editor = StudyComponentsWidget(study) + selectors = [row[1] for row in editor.rows.values()] + [editor._new_role] + assert all("cadetgui-select" in s._dom_classes for s in selectors) + css = style_tag() + chevron = css[css.index(".cadetgui-select select:not([multiple]):not([size]) {"):] + chevron = chevron[:chevron.index("}")] + assert "appearance: none" in chevron and "linear-gradient" in chevron + assert ".cadetgui-select select:hover:not(:disabled)" in css + field_css = ChoiceField._css + rule = field_css[field_css.index(".cadetgui-field-select {"):] + assert "appearance: none" in rule[:rule.index("}")] + + +def test_salt_row_name_is_read_only_with_a_note(study): + study.add_component("Salt", "salt") + editor = StudyComponentsWidget(study) + row = editor._rows_box.children[0] + assert row.children[0].value == "Salt" and row.children[0].disabled + assert "fixed name, required by the binding model" in row.children[3].value + + +def test_add_row_salt_role_locks_the_name(study): + editor = StudyComponentsWidget(study) + editor._new_name.value = "NaCl" + editor._new_role.value = "salt" + assert editor._new_name.value == "Salt" and editor._new_name.disabled + assert "fixed name" in editor._role_help.value + editor._new_role.value = "protein" + assert editor._new_name.value == "" and not editor._new_name.disabled + editor._new_role.value = "salt" + editor._btn_add.click() + assert study.component("Salt").role == "salt" + assert "salt" not in list(editor._new_role.options) + assert not editor._new_name.disabled + assert "Salt is already declared" in editor._role_help.value + + +def test_role_change_to_salt_is_refused_for_other_names(study): + editor = StudyComponentsWidget(study) + editor.rows["SmallTracer"][1].value = "salt" + assert study.component("SmallTracer").role == "small tracer" + assert editor.rows["SmallTracer"][1].value == "small tracer" + assert "only the component named Salt" in editor.status.value + assert "add Salt with the salt role" in editor.status.value diff --git a/tests/test_system_diagram.py b/tests/test_system_diagram.py index f8d2c4d..60e5d4e 100644 --- a/tests/test_system_diagram.py +++ b/tests/test_system_diagram.py @@ -5,17 +5,24 @@ import xml.etree.ElementTree as ET from pathlib import Path -import cadetgui.configuration_store as configuration_store +import cadetgui.io.configuration_store as configuration_store import pytest from cadetgui.cadetprocessadapter import ( + BYPASSABLE_UNITS, INSTRUMENT_TEMPLATES, active_inlets, friendly_signal_options, inlet_contents, signal_label, ) +from cadetgui.io.configuration_store import ConfigurationState, InstrumentState from cadetgui.widgets.composite import ConfigurationWidget, InstrumentWidget -from cadetgui.widgets.composite.system_diagram import SystemDiagram, render_system_svg +from cadetgui.widgets.composite.system_diagram import ( + HARDWARE_ONLY_NOTE, + SystemDiagram, + recipe_diagram_svg, + render_system_svg, +) warnings.filterwarnings("ignore", category=UserWarning) @@ -452,6 +459,18 @@ def test_system_dropdown_pick_builds_the_process_once(): assert iw._template_picker.value == "Step" +def test_characterization_workbench_shows_hardware_only_without_a_template_picker(): + from cadetgui.widgets.composite import CharacterizationWorkbenchWidget + + wb = CharacterizationWorkbenchWidget() + html = wb.instrument._diagram.root.value + + assert wb.instrument.hardware_only and not wb.configuration.show_process_template + assert wb.instrument._template_picker.layout.display == "none" + assert "unused" not in html and HARDWARE_ONLY_NOTE in html + assert not wb.instrument._sample_loop_checkbox.disabled + + def _column_caption(html: str) -> list[str]: group = next( g for g in _root(html).iter(_SVG + "g") if g.get("data-unit") == "column" @@ -506,3 +525,201 @@ def test_column_caption_stays_inside_the_canvas_and_clear_of_other_symbols(): for x0, y0, x1, y1 in boxes: if (x0, x1) != (cx0, cx1): assert x1 <= cx0 or x0 >= cx1 or y0 >= ty + 4 or y1 <= ty - 14 + + +def test_highlighted_units_carry_the_data_attribute_and_accent_halo(): + html = render_system_svg(_ALL_UNITS, (), _ALL_INLETS, highlight=["column"]) + root = _root(html) + column = next(g for g in root.iter(_SVG + "g") if g.get("data-unit") == "column") + + assert column.get("data-highlight") == "true" + assert column.find(f".//{_SVG}rect[@rx='8']") is not None + other = next(g for g in root.iter(_SVG + "g") if g.get("data-unit") == "mixer") + assert other.get("data-highlight") is None + assert "Characterized here: Column" in html + + +def test_observe_marker_is_drawn_at_the_units_outlet(): + html = render_system_svg(_ALL_UNITS, (), _ALL_INLETS, observe="column") + root = _root(html) + markers = [g for g in root.iter(_SVG + "g") if g.get("data-observe")] + + assert len(markers) == 1 + assert markers[0].get("data-observe") == "column" + assert "measured here" in "".join(markers[0].itertext()) + + +def test_observe_on_a_unit_not_drawn_is_named_in_the_caption_not_silently_dropped(): + units = _ALL_UNITS - {"column"} + html = render_system_svg(units, (), _ALL_INLETS, observe="column") + + assert not any(g.get("data-observe") for g in _root(html).iter(_SVG + "g")) + assert "Measured here: Column" in html + + +def test_compact_diagram_is_narrower_than_the_full_diagram_and_keeps_bypass_captions(): + full = render_system_svg(_ALL_UNITS, ("tubing_pre_column",), _ALL_INLETS) + compact = render_system_svg(_ALL_UNITS, ("tubing_pre_column",), _ALL_INLETS, compact=True) + + full_w = float(_root(full).get("viewBox").split()[2]) + compact_w = float(_root(compact).get("viewBox").split()[2]) + + assert compact_w < full_w + assert "Not in the flow path" in compact + assert "Buffers via mixer" not in compact + + +def test_compact_diagram_keeps_highlight_and_observe_captions(): + html = render_system_svg( + _ALL_UNITS, (), _ALL_INLETS, highlight=["mixer"], observe="mixer", compact=True + ) + + assert "Characterized here: Mixer" in html + assert "measured here" in html + mixer = next(g for g in _root(html).iter(_SVG + "g") if g.get("data-unit") == "mixer") + assert mixer.get("data-highlight") == "true" + + +def test_default_call_is_unaffected_by_the_new_keyword_only_arguments(): + baseline = render_system_svg(_ALL_UNITS, (), _ALL_INLETS) + same = render_system_svg(_ALL_UNITS, (), _ALL_INLETS, None, (), None) + + assert baseline == same + assert "data-highlight" not in baseline + assert "data-observe" not in baseline + assert "measured here" not in baseline + + +def _recipe_state(*, bypass=(), include_sample_loop=False, instrument=True): + return ConfigurationState( + components=["A"], + column_key="Lumped Rate Model Without Pores (LRM)", + binding_key="Linear", + template_key="Breakthrough", + instrument=( + InstrumentState(bypass_units=list(bypass), include_sample_loop=include_sample_loop) + if instrument + else None + ), + ) + + +def test_recipe_diagram_svg_omits_a_bypassed_column(): + html = recipe_diagram_svg(_recipe_state(bypass=["column"])) + states = _states(html) + + assert "column" not in states + assert states["mixer"] == "active" + + +def test_recipe_diagram_svg_shows_the_sample_loop_when_enabled(): + without_loop = _states(recipe_diagram_svg(_recipe_state())) + with_loop = _states(recipe_diagram_svg(_recipe_state(include_sample_loop=True))) + + assert "sample_loop" not in without_loop + assert with_loop["sample_loop"] == "active" + + +def test_recipe_diagram_svg_without_an_instrument_is_a_minimal_column_diagram(): + html = recipe_diagram_svg(_recipe_state(instrument=False)) + states = _states(html) + + assert states == {"column": "active"} + + +def test_recipe_diagram_svg_is_compact_by_default_and_valid_xml(): + default_html = recipe_diagram_svg(_recipe_state()) + full_html = recipe_diagram_svg(_recipe_state(), compact=False) + + default_w = float(_root(default_html).get("viewBox").split()[2]) + full_w = float(_root(full_html).get("viewBox").split()[2]) + + assert default_w <= full_w + + +def test_set_highlight_redraws_the_bound_diagram(): + diagram = SystemDiagram() + iw = InstrumentWidget() + diagram.update(iw.flow_sheet, iw.bypass_units(), ["buffer_a"]) + + diagram.set_highlight(["column"], observe="column") + + html = diagram.root.value + assert "Characterized here: Column" in html + column = next(g for g in _root(html).iter(_SVG + "g") if g.get("data-unit") == "column") + assert column.get("data-highlight") == "true" + + diagram.set_highlight([]) + assert "data-highlight" not in diagram.root.value + + +@pytest.mark.parametrize("compact", [False, True]) +@pytest.mark.parametrize( + "observe, bypassed", + [ + ("column", ["tubing_pre_injection", "tubing_post_column", "tubing_detectors"]), + ("tubing_detectors", ["tubing_pre_injection", "tubing_post_column"]), + ("tubing_pre_column", ["column", "tubing_pre_injection", "tubing_post_column", + "tubing_detectors"]), + ], +) +def test_measured_here_label_sits_above_every_other_label(observe, bypassed, compact): + units = { + "buffer_a", "buffer_b", "buffer_c", "buffer_d", "feed_inlet", "mixer", + "sample_loop", "outlet", "waste", *BYPASSABLE_UNITS, + } - set(bypassed) + out = render_system_svg( + units, bypassed, observe=observe, highlight={"column"}, + column_model="Lumped Rate Model With Pores", compact=compact, + ) + root = ET.fromstring(out[out.index("") + 6]) + label_y, tops = None, [] + + def walk(el, in_marker=False): + nonlocal label_y + tag = el.tag.split("}")[-1] + in_marker = in_marker or el.get("data-observe") is not None + if tag == "svg" and el is not root: + tops.append(float(el.get("y", 0))) + return + if tag == "tspan": + if in_marker: + label_y = float(el.get("y")) + else: + tops.append(float(el.get("y")) - 14) + for child in el: + walk(child, in_marker) + + walk(root) + assert label_y is not None + assert label_y < min(tops) + + +def test_hardware_only_diagram_draws_every_inlet_plainly_with_the_method_note(): + iw = InstrumentWidget(hardware_only=True) + cw = ConfigurationWidget(instrument=iw, show_process_template=False) + cw._model_picker.value = INSTRUMENT_TEMPLATES["Pulse Injection"] + html = iw._diagram.root.value + + states = _states(html) + assert {states[u] for u in ("buffer_a", "buffer_b", "buffer_c", "buffer_d", "feed_inlet")} == { + "active" + } + assert "unused" not in html and "carries" not in html + assert HARDWARE_ONLY_NOTE in html + assert iw._template_picker.layout.display == "none" + + +def test_default_diagram_still_marks_unused_inlets_from_the_template(): + iw, _cw = _bound("Pulse Injection") + html = iw._diagram.root.value + + assert "(unused)" in html and HARDWARE_ONLY_NOTE not in html + assert iw._template_picker.layout.display == "" + + +def test_hardware_only_is_ignored_for_compact_diagrams(): + units = {"buffer_a", "feed_inlet", "mixer", "column", "outlet"} + assert render_system_svg(units, compact=True, hardware_only=True) == render_system_svg( + units, compact=True + ) diff --git a/tests/test_workbench.py b/tests/test_workbench.py index d9ac15e..c820d2d 100644 --- a/tests/test_workbench.py +++ b/tests/test_workbench.py @@ -2,7 +2,7 @@ import warnings -import cadetgui.configuration_store as configuration_store +import cadetgui.io.configuration_store as configuration_store import pytest from cadetgui.widgets.composite import WorkbenchWidget @@ -28,10 +28,25 @@ def test_workbench_wires_parameter_estimation_to_configuration(): assert wb.parameter_estimation.param_space._param_add_picker.option_labels # populated on bind assert wb.parameter_estimation._signal_picker.option_labels # populated on bind - assert wb.parameter_estimation._display_result is not None # preview refreshes on its own + assert wb.parameter_estimation._display_result is None # no preview until the pane opens + wb._shell.show("Parameter Estimation") + assert wb.parameter_estimation._display_result is not None assert wb.solution.result is None # the Simulation tab's own run state is untouched +def test_estimation_preview_waits_for_its_pane_after_a_configuration_change(): + wb = WorkbenchWidget() + wb._shell.show("Parameter Estimation") + before = wb.parameter_estimation._display_result + + wb.configuration._model_form.element("flow_rate").value = 2e-6 + + assert wb.parameter_estimation._display_result is before + assert "updates when this pane is opened" in wb.parameter_estimation._preview_status.value + wb._shell.show("Parameter Estimation") + assert wb.parameter_estimation._display_result is not before + + def test_workbench_shows_only_the_system_pane_initially(): wb = WorkbenchWidget() @@ -39,13 +54,17 @@ def test_workbench_shows_only_the_system_pane_initially(): assert wb.configuration.root.layout.display == "none" assert wb.solution.root.layout.display == "none" assert wb.parameter_estimation.root.layout.display == "none" - assert wb._shell.nav.index == 0 + assert wb._shell.nav.value == "System: Instrument" + + +def _keys(wb): + return [o[1] if isinstance(o, tuple) else o for o in wb._shell.nav.options] def test_workbench_nav_switches_the_visible_pane(): wb = WorkbenchWidget() - wb._shell.nav.index = 2 # Simulation + wb._shell.nav.index = _keys(wb).index("Simulation") assert wb.instrument.root.layout.display == "none" assert wb.configuration.root.layout.display == "none" @@ -93,21 +112,58 @@ def test_workbench_accepts_prebuilt_widgets(): assert sw.process is cw.process +def test_workbench_sidebar_nests_instrument_and_process_under_system(): + wb = WorkbenchWidget() + + assert list(wb._shell.panes) == [ + "System: Instrument", "System: Process configuration", "Simulation", + "Parameter Estimation", + ] + assert wb._shell._groups == { + "System": ["System: Instrument", "System: Process configuration"], + } + assert _keys(wb) == [ + "System", "System: Instrument", "System: Process configuration", "Simulation", + "Parameter Estimation", + ] + assert wb._shell.panes["System: Instrument"] is wb.instrument.root + assert wb._shell.panes["System: Process configuration"] is wb.configuration.root + + +def test_workbench_edits_components_on_the_instrument_pane(): + wb = WorkbenchWidget() + + assert wb.configuration._components_section.layout.display == "none" + wb.instrument._components_field.value = ["Salt", "Protein"] + + assert wb.configuration.components == ["Salt", "Protein"] + assert wb.configuration.process.component_system.names == ["Salt", "Protein"] + + def test_workbench_include_builds_only_the_requested_steps(): - wb = WorkbenchWidget(include=("System Configuration", "Process Configuration", "Simulation")) + wb = WorkbenchWidget(include=("System", "Process configuration", "Simulation")) assert wb.instrument is not None assert wb.configuration is not None assert wb.solution is not None assert wb.parameter_estimation is None - assert list(wb._shell.nav.options) == [ - "System Configuration", "Process Configuration", "Simulation", + assert list(wb._shell.panes) == [ + "System: Instrument", "System: Process configuration", "Simulation", ] assert wb.solution.process is wb.configuration.process +def test_workbench_include_accepts_the_old_step_names(): + wb = WorkbenchWidget(include=("System", "Process configuration", "Simulation")) + + assert wb.instrument is not None and wb.parameter_estimation is None + assert list(wb._shell.panes) == [ + "System: Instrument", "System: Process configuration", "Simulation", + ] + + def test_workbench_excluding_system_still_builds_a_standalone_configuration(): - wb = WorkbenchWidget(include=("Process Configuration", "Simulation")) + wb = WorkbenchWidget(include=("Process configuration", "Simulation")) assert wb.instrument is None assert wb.configuration.process is not None @@ -116,7 +172,7 @@ def test_workbench_excluding_system_still_builds_a_standalone_configuration(): def test_workbench_include_rejects_unknown_step(): with pytest.raises(ValueError): - WorkbenchWidget(include=("Process Configuration", "Not A Step")) + WorkbenchWidget(include=("Process configuration", "Not A Step")) def test_workbench_include_rejects_widget_for_excluded_step(): @@ -124,5 +180,5 @@ def test_workbench_include_rejects_widget_for_excluded_step(): with pytest.raises(ValueError): WorkbenchWidget( - include=("Process Configuration",), parameter_estimation=ParameterEstimationWidget() + include=("Process configuration",), parameter_estimation=ParameterEstimationWidget() ) diff --git a/tests/test_workspace_header.py b/tests/test_workspace_header.py index c20dac4..48bb8fe 100644 --- a/tests/test_workspace_header.py +++ b/tests/test_workspace_header.py @@ -7,7 +7,7 @@ matplotlib.use("Agg") -import cadetgui.configuration_store as configuration_store +import cadetgui.io.configuration_store as configuration_store from cadetgui.widgets.composite import ( ConfigurationWidget, InstrumentWidget, @@ -135,7 +135,7 @@ def test_workbench_places_the_header_between_top_bar_and_sidebar(): def test_workbench_without_process_configuration_has_no_header(): from cadetgui.widgets.composite import WorkbenchWidget - wb = WorkbenchWidget(include=("System Configuration", "Simulation")) + wb = WorkbenchWidget(include=("System", "Simulation")) assert wb.configuration is None assert len(wb.root.children) == 3 @@ -162,6 +162,29 @@ def test_workbench_simulation_follows_the_shared_headers_store_dir(tmp_path): assert wb.solution.history.store_dir == expected +def test_characterization_workbench_places_the_same_header_above_the_sidebar(): + from cadetgui.widgets.composite import CharacterizationWorkbenchWidget + + wb = CharacterizationWorkbenchWidget() + header = wb.configuration.workspace_header + assert wb.root.children[2] is header.root + assert wb.root.children[3] is wb._shell.body + assert not contains(wb.configuration.root, header.root) + wb.configuration.config_name = "Char config" + assert wb.configuration.persist_to_store().exists() + assert header.counts == (1, 0) + + +def test_characterization_workbench_with_configuration_given_keeps_its_own_header(): + from cadetgui.widgets.composite import CharacterizationWorkbenchWidget + + iw = InstrumentWidget() + config = ConfigurationWidget(instrument=iw) + wb = CharacterizationWorkbenchWidget(instrument=iw, configuration=config) + assert contains(config.root, config.workspace_header.root) + assert len(wb.root.children) == 3 + + def test_header_is_a_single_compact_row_with_the_persistence_widgets(): config = ConfigurationWidget(instrument=InstrumentWidget()) header = config.workspace_header