PyTracerLab 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ """PyTracerLab package initializer."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("PyTracerLab")
7
+ except PackageNotFoundError: # pragma: no cover
8
+ __version__ = "0.0.0"
@@ -0,0 +1,7 @@
1
+ """Console entry point for launching the PyTracerLab GUI."""
2
+
3
+ # from PyTracerLab.app import main
4
+ from PyTracerLab.gui.app import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -0,0 +1,10 @@
1
+ """
2
+ PyTracerLab GUI subpackage.
3
+
4
+ Contains the main PyQt5 application window, tabs, and controller/state logic.
5
+ """
6
+
7
+ from .app import main
8
+ from .main_window import MainWindow
9
+
10
+ __all__ = ["main", "MainWindow"]
PyTracerLab/gui/app.py ADDED
@@ -0,0 +1,15 @@
1
+ """Application entry point for the PyTracerLab GUI."""
2
+
3
+ import sys
4
+
5
+ from PyQt5.QtWidgets import QApplication
6
+
7
+ from .main_window import MainWindow
8
+
9
+
10
+ def main():
11
+ """Launch the Qt application and show the main window."""
12
+ app = QApplication(sys.argv)
13
+ w = MainWindow()
14
+ w.show()
15
+ sys.exit(app.exec_())
@@ -0,0 +1,419 @@
1
+ """Qt controller that builds the model and runs simulations/solvers."""
2
+
3
+ import matplotlib.pyplot as plt
4
+ import numpy as np
5
+ from PyQt5.QtCore import QObject, pyqtSignal
6
+
7
+ from ..model import model as mm
8
+ from ..model import solver as ms
9
+ from ..model.registry import UNIT_REGISTRY
10
+ from .database import Tracers
11
+
12
+
13
+ class Controller(QObject):
14
+ """Bridge between GUI state and the computational model.
15
+
16
+ Orchestrates model construction from GUI state, runs simulations and
17
+ calibrations, and emits signals with results or errors.
18
+
19
+ Signals
20
+ -------
21
+ simulated : object
22
+ Emitted after a forward simulation; carries a NumPy array.
23
+ calibrated : object
24
+ Emitted after calibration; carries a payload ``dict`` with simulation
25
+ and optional envelopes/metadata.
26
+ status : str
27
+ Short user-facing status messages.
28
+ error : str
29
+ Error messages suitable for display.
30
+ """
31
+
32
+ # Use generic object payloads to support arrays or dicts from different solvers
33
+ simulated = pyqtSignal(object)
34
+ calibrated = pyqtSignal(object)
35
+ tracer_tracer_ready = pyqtSignal(object)
36
+ status = pyqtSignal(str)
37
+ error = pyqtSignal(str)
38
+
39
+ def __init__(self, state):
40
+ """Initialize the controller with a shared :class:`AppState`."""
41
+ super().__init__()
42
+ self.state = state
43
+ self.ml = None
44
+
45
+ def build_model(self):
46
+ """Construct a :class:`~PyTracerLab.model.model.Model` from current state.
47
+
48
+ Creates per-instance units (including bounds and fixed flags) based on
49
+ the detailed design in ``state.design_instances`` and the per-instance
50
+ parameters in ``state.params``.
51
+ """
52
+ try:
53
+ # We generally work in months (dt = 1.0); for yearly calculations
54
+ # we therefore have to use dt = 12.0
55
+ dt = 1.0 if self.state.is_monthly else 12.0
56
+ # Determine tracer set from state (dual-tracer aware)
57
+ tracer_names = []
58
+ t1 = getattr(self.state, "tracer1", None)
59
+ t2 = getattr(self.state, "tracer2", None)
60
+ if t1:
61
+ tracer_names.append(t1)
62
+ elif hasattr(self.state, "tracer") and getattr(self.state, "tracer"):
63
+ # single-tracer fallback
64
+ tracer_names.append(getattr(self.state, "tracer"))
65
+ if t2 and str(t2).lower() not in {"none", ""}:
66
+ tracer_names.append(t2)
67
+
68
+ # Determine the decay constants for the selected tracers
69
+ lam = []
70
+ for name, half_life in Tracers.tracer_data.items():
71
+ if name in tracer_names:
72
+ lam.append(0.693 / (half_life * 12.0)) # convert to monthly
73
+ if len(lam) == 1:
74
+ lam = lam[0]
75
+
76
+ # Determine steady state input for the cases of one or two tracers
77
+ n_tracers = len(tracer_names) if tracer_names else 1
78
+ steady_state_input = getattr(self.state, "steady_state_input", None)
79
+ if steady_state_input is None:
80
+ ss_val = None
81
+ elif n_tracers == 1:
82
+ if isinstance(steady_state_input, (list, tuple)):
83
+ ss_val = float(steady_state_input[0]) if steady_state_input else 0.0
84
+ else:
85
+ ss_val = float(steady_state_input)
86
+ else:
87
+ if isinstance(steady_state_input, (list, tuple)):
88
+ ss_seq = [float(v) for v in steady_state_input]
89
+ else:
90
+ ss_seq = [float(steady_state_input)] * n_tracers
91
+ if len(ss_seq) != n_tracers:
92
+ if len(ss_seq) == 1:
93
+ ss_seq = ss_seq * n_tracers
94
+ else:
95
+ raise ValueError("steady_state_input must provide one value per tracer")
96
+ ss_val = ss_seq
97
+
98
+ steady_enabled = bool(getattr(self.state, "steady_state_enabled", False))
99
+ if not steady_enabled:
100
+ ss_val = None
101
+
102
+ # Set up the model
103
+ x = self.state.input_series
104
+ y = self.state.target_series
105
+ x = (x[0], x[1])
106
+
107
+ self.ml = mm.Model(
108
+ dt,
109
+ lam,
110
+ input_series=x[1] if x else None,
111
+ target_series=y[1] if y else None,
112
+ steady_state_input=ss_val,
113
+ n_warmup_half_lives=self.state.n_warmup_half_lives,
114
+ time_steps=x[0] if x else None,
115
+ )
116
+
117
+ # Build per-instance units based on the detailed design
118
+ instances = getattr(self.state, "design_instances", [])
119
+ for inst in instances:
120
+ unit_name: str = inst["name"]
121
+ prefix: str = inst["prefix"]
122
+ frac: float = float(inst.get("fraction", 0.0))
123
+
124
+ cls = UNIT_REGISTRY[unit_name]
125
+ spec = getattr(cls, "PARAMS", [])
126
+
127
+ # Parameter values with safe defaults
128
+ kwargs = {}
129
+ for p in spec:
130
+ key = p["key"]
131
+ default_val = p.get("default")
132
+ rec = self.state.params.get(prefix, {}).get(key)
133
+
134
+ # Handle parameters with time units
135
+ # We always work in monthly resolution but always
136
+ # get parameters with time units "years". We therefore
137
+ # have to convert them to monthly values. The default
138
+ # value is also given in years.
139
+ param_value = rec["val"] if rec is not None else default_val
140
+ param_name = p.get("key")
141
+
142
+ if param_name == "mtt":
143
+ param_value *= 12
144
+
145
+ kwargs[key] = param_value
146
+
147
+ unit = cls(**kwargs)
148
+
149
+ # Bounds with safe defaults
150
+ bounds = []
151
+ for p in spec:
152
+ key = p["key"]
153
+ default_lb, default_ub = p.get("bounds", (None, None))
154
+ rec = self.state.params.get(prefix, {}).get(key)
155
+ lb = rec["lb"] if rec is not None else default_lb
156
+ ub = rec["ub"] if rec is not None else default_ub
157
+
158
+ # convert mtt bounds to monthly time units
159
+ if key == "mtt":
160
+ lb *= 12
161
+ ub *= 12
162
+
163
+ bounds.append((lb, ub))
164
+
165
+ self.ml.add_unit(
166
+ unit=unit,
167
+ fraction=frac,
168
+ prefix=prefix,
169
+ bounds=bounds,
170
+ )
171
+
172
+ # Fixed flags
173
+ for p in spec:
174
+ key = p["key"]
175
+ rec = self.state.params.get(prefix, {}).get(key)
176
+ if rec is not None and rec.get("fixed", False):
177
+ self.ml.set_fixed(f"{prefix}.{key}", True)
178
+
179
+ except Exception as e:
180
+ self.error.emit(str(e))
181
+ self.ml = None
182
+
183
+ def simulate(self):
184
+ """Run a forward simulation and emit the result via ``simulated``."""
185
+ try:
186
+ self.build_model()
187
+ if self.ml is None:
188
+ return
189
+ sim = self.ml.simulate()
190
+ self.state.last_simulation = sim
191
+ self.simulated.emit(sim)
192
+ self.status.emit("Simulation finished.")
193
+ except Exception as e:
194
+ self.error.emit(str(e))
195
+
196
+ def calibrate(self):
197
+ """Run the selected solver and emit a standardized payload.
198
+
199
+ The solver is chosen from ``state.solver_key`` and configured using
200
+ ``state.solver_params``.
201
+ """
202
+ try:
203
+ self.build_model()
204
+ if self.ml is None:
205
+ return
206
+ # Run selected solver via registry and emit standardized payload
207
+ key = getattr(self.state, "solver_key", "de")
208
+ params = getattr(self.state, "solver_params", {}).get(key, {})
209
+ payload = ms.run_solver(self.ml, key, params)
210
+ self.state.last_simulation = payload
211
+ self.calibrated.emit(payload)
212
+ self.status.emit("Calibration finished.")
213
+ except Exception as e:
214
+ self.error.emit(str(e))
215
+
216
+ def plot_age_distribution(self):
217
+ """Plot the age distribution and emit the figure."""
218
+ # at this point, the model is already built and has parameters assigned
219
+ # get age distributions
220
+ age_distributions = self.ml.get_age_distributions()
221
+
222
+ # get highest mean travel time of model parameters
223
+ mtts = []
224
+ for p in self.ml.params.items():
225
+ if p[1]["local_name"] == "mtt":
226
+ mtts.append(p[1]["value"])
227
+
228
+ mtt_max = max(mtts)
229
+ step_limit = int(mtt_max * 3)
230
+ dt = self.ml.dt / 12.0 # convert to years
231
+
232
+ step_limit = int(min(step_limit, len(age_distributions["distributions"][0])))
233
+ t_plot = [dt * i for i in range(step_limit)]
234
+
235
+ # prepare full distribution from fracions
236
+ dist_plot = np.zeros(len(age_distributions["distributions"][0][:step_limit]))
237
+ for i, frac in enumerate(age_distributions["fractions"]):
238
+ dist_plot += frac * age_distributions["distributions"][i][:step_limit]
239
+
240
+ fig, ax = plt.subplots(figsize=(6, 3))
241
+ ax.plot(t_plot, dist_plot, c="k", zorder=10000)
242
+ ax.set_xlim(0.0)
243
+ ax.set_ylim(0.0)
244
+ ax.grid(True, alpha=0.3, zorder=0)
245
+ ax.set_xlabel("Time [years]")
246
+ ax.set_ylabel("Fraction [-]")
247
+ ax.set_title("Age distribution")
248
+
249
+ plt.tight_layout()
250
+ plt.show()
251
+
252
+ def run_tracer_tracer(self, start: float, stop: float, count: int, param_key: str) -> None:
253
+ """Sweep mean travel time values and emit tracer-tracer results."""
254
+ try:
255
+ if count <= 1:
256
+ raise ValueError("Please choose at least two mean travel time points.")
257
+ if not np.isfinite(start) or not np.isfinite(stop):
258
+ raise ValueError("Start and stop values must be finite numbers.")
259
+ if float(stop) <= float(start):
260
+ raise ValueError("Stop value must be greater than start value.")
261
+
262
+ if not getattr(self.state, "tracer2", None):
263
+ raise ValueError("Tracer-tracer analysis requires two tracers.")
264
+
265
+ target = self.state.target_series
266
+ if target is None or target[1] is None:
267
+ raise ValueError("No observation series available.")
268
+ obs_times = np.asarray(target[0])
269
+ obs_vals = np.asarray(target[1], dtype=float)
270
+ if obs_vals.ndim == 1:
271
+ obs_vals = obs_vals.reshape(-1, 1)
272
+ if obs_vals.shape[1] < 2:
273
+ raise ValueError("Observation series must contain two tracer columns.")
274
+
275
+ self.build_model()
276
+ if self.ml is None:
277
+ raise RuntimeError(
278
+ "Model is not available. \
279
+ Configure the model before running a tracer-tracer sweep."
280
+ )
281
+ if param_key not in self.ml.params:
282
+ raise ValueError(f"Unknown parameter: {param_key}")
283
+
284
+ # Before we run the sweep, we need to convert the parameters with time units
285
+ # to match the internal monthly model resolution.
286
+ base_value = float(self.ml.params[param_key]["value"])
287
+
288
+ # We always get parameters with time units "years". We therefore
289
+ # need to convert them to months because we always work in months.
290
+ if "mtt" in param_key:
291
+ start *= 12
292
+ stop *= 12
293
+
294
+ grid = np.linspace(float(start), float(stop), int(count))
295
+ results = None
296
+
297
+ try:
298
+ for idx, val in enumerate(grid):
299
+ self.ml.set_param(param_key, float(val))
300
+ sim = self.ml.simulate()
301
+ sim_arr = np.asarray(sim, dtype=float)
302
+ if sim_arr.ndim == 1:
303
+ sim_arr = sim_arr.reshape(-1, 1)
304
+ if results is None:
305
+ n_steps, n_tr = sim_arr.shape
306
+ results = np.zeros((n_tr, grid.size, n_steps), dtype=float)
307
+ elif sim_arr.shape[1] != results.shape[0]:
308
+ raise ValueError("Simulation returned an unexpected number of tracers.")
309
+ elif sim_arr.shape[0] != results.shape[2]:
310
+ raise ValueError(
311
+ "Simulation length changed across runs; cannot stack results."
312
+ )
313
+ for i in range(sim_arr.shape[1]):
314
+ results[i, idx, :] = sim_arr[:, i]
315
+ finally:
316
+ self.ml.set_param(param_key, base_value)
317
+
318
+ if results is None:
319
+ raise ValueError("Failed to compute tracer-tracer results.")
320
+
321
+ if obs_vals.shape[0] != results.shape[2]:
322
+ raise ValueError("Observation length does not match simulation output.")
323
+
324
+ obs_mask = ~np.isnan(obs_vals[:, 0]) & ~np.isnan(obs_vals[:, 1])
325
+ obs_indices = np.nonzero(obs_mask)[0].astype(int)
326
+ if obs_indices.size == 0:
327
+ raise ValueError("No observation dates with values for both tracers.")
328
+
329
+ payload = {
330
+ "results": results,
331
+ "mtt_values": grid,
332
+ "param_key": param_key,
333
+ "obs_indices": obs_indices,
334
+ "timestamps": obs_times,
335
+ "observations": obs_vals,
336
+ }
337
+
338
+ self.state.tt_results = results
339
+ self.state.tt_mtt_values = grid
340
+ self.state.tt_obs_indices = obs_indices
341
+ self.state.tt_param_key = param_key
342
+
343
+ self.tracer_tracer_ready.emit(payload)
344
+ self.status.emit("Tracer-tracer sweep finished.")
345
+ except Exception as exc:
346
+ self.error.emit(str(exc))
347
+
348
+ def write_report(self, filename):
349
+ """Write a plain-text model report to ``filename`` using current state."""
350
+ if self.ml is None:
351
+ return
352
+ if self.state.is_monthly:
353
+ frequency = "1 month"
354
+ else:
355
+ frequency = "1 year"
356
+ tracer_names = []
357
+ if self.state.tracer1 is not None:
358
+ tracer_names.append(self.state.tracer1)
359
+ if self.state.tracer2 is not None:
360
+ tracer_names.append(self.state.tracer2)
361
+ # we always work in monthly frequency, so we always want to
362
+ # convert to years as units for mean travel time in the report
363
+ self.ml.write_report(
364
+ filename=filename, frequency=frequency, tracer=tracer_names, convert_mtt_to_years=True
365
+ )
366
+
367
+ def save_data(self, filename):
368
+ """Save simulation data to ``filename`` using current state."""
369
+ # Initialize lines
370
+ lines = []
371
+
372
+ # Get parameters
373
+ params = self.ml.params
374
+ # Get data and time stamps
375
+ data = np.asarray(self.state.last_simulation.get("sim"))
376
+ times = self.state.input_series[0]
377
+
378
+ # We have to prepare the data for the report
379
+ two_tracers = True
380
+ if data.ndim == 1:
381
+ two_tracers = False
382
+ data = data.reshape(-1, 1)
383
+
384
+ # Fill lines
385
+ # Parameters
386
+ lines.append("Parameters")
387
+ lines.append("----------")
388
+ for key, val in params.items():
389
+ lines.append(str(key) + ": " + str(val))
390
+ lines.append("")
391
+ lines.append("Simulation Data")
392
+ lines.append("---------------")
393
+ lines.append("")
394
+ headers = "Time, " + self.state.tracer1
395
+ if two_tracers:
396
+ headers += ", " + self.state.tracer2
397
+ lines.append(headers)
398
+
399
+ for i in range(len(data)):
400
+ if self.state.is_monthly:
401
+ if not two_tracers:
402
+ lines.append(times[i].strftime("%Y-%m") + ", " + str(data[i, 0]))
403
+ else:
404
+ lines.append(
405
+ times[i].strftime("%Y-%m") + ", " + str(data[i, 0]) + ", " + str(data[i, 1])
406
+ )
407
+ else:
408
+ if not two_tracers:
409
+ lines.append(times[i].strftime("%Y") + ", " + str(data[i, 0]))
410
+ else:
411
+ lines.append(
412
+ times[i].strftime("%Y") + ", " + str(data[i, 0]) + ", " + str(data[i, 1])
413
+ )
414
+
415
+ file_text = "\n".join(lines)
416
+ # Write lines
417
+ with open(filename, "w", encoding="utf-8") as f:
418
+ f.write(file_text)
419
+ return
@@ -0,0 +1,16 @@
1
+ """Holds data of tracers supported in the PyTracerLab GUI."""
2
+
3
+ from abc import ABC
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass
8
+ class Tracers(ABC):
9
+ """Database of all tracers supported by the PyTracerLab GUI."""
10
+
11
+ tracer_data = {
12
+ "Carbon-14": 5700.0, # years
13
+ "Tritium": 12.32, # years
14
+ "Krypton-85": 10.73, # years
15
+ "Stable tracer (no decay)": 1e10, # artificially large decay time
16
+ }
@@ -0,0 +1,75 @@
1
+ """Main window for the PyTracerLab GUI, assembling all tabs."""
2
+
3
+ from PyQt5.QtWidgets import QMessageBox, QTabWidget, QVBoxLayout, QWidget
4
+
5
+ from ..model.registry import UNIT_REGISTRY
6
+ from .controller import Controller
7
+ from .state import AppState
8
+ from .tabs.file_input import FileInputTab
9
+ from .tabs.model_design import ModelDesignTab
10
+ from .tabs.parameters import ParametersTab
11
+ from .tabs.simulation import SimulationTab
12
+ from .tabs.tracer_tracer import TracerTracerTab
13
+
14
+
15
+ class MainWindow(QWidget):
16
+ """Top-level application window hosting the main tabs."""
17
+
18
+ def __init__(self):
19
+ """Create the main window and wire up tabs and controller."""
20
+ super().__init__()
21
+ # Initialize the window
22
+ self.setWindowTitle("PyTracerLab")
23
+ self.resize(800, 600)
24
+
25
+ self.state = AppState()
26
+ self.ctrl = Controller(self.state)
27
+
28
+ tabs = QTabWidget()
29
+ t1 = FileInputTab(self.state)
30
+ t2 = ModelDesignTab(self.state, UNIT_REGISTRY)
31
+ t3 = ParametersTab(self.state, UNIT_REGISTRY)
32
+ t4 = SimulationTab(self.state)
33
+ t5 = TracerTracerTab(self.state, UNIT_REGISTRY)
34
+
35
+ tabs.addTab(t1, "[1] Input")
36
+ tabs.addTab(t2, "[2] Model")
37
+ tabs.addTab(t3, "[3] Parameters")
38
+ tabs.addTab(t4, "[4] Simulation")
39
+ tabs.addTab(t5, "[5] Tracer-Tracer")
40
+
41
+ lay = QVBoxLayout(self)
42
+ lay.addWidget(tabs)
43
+
44
+ # wiring
45
+ t1.changed.connect(t2.refresh_tracer_inputs)
46
+ t1.changed.connect(t3.refresh)
47
+ t1.changed.connect(t5.reset_results)
48
+ t1.changed.connect(t5.refresh)
49
+ t2.selection_changed.connect(t3.refresh)
50
+ t2.selection_changed.connect(t5.refresh)
51
+ t2.selection_changed.connect(t5.reset_results)
52
+ t4.simulate_requested.connect(lambda: (t3.commit(), self.ctrl.simulate()))
53
+ t4.calibrate_requested.connect(lambda: (t3.commit(), self.ctrl.calibrate()))
54
+ t4.plot_age_distribution_requested.connect(
55
+ lambda: (t3.commit(), self.ctrl.plot_age_distribution())
56
+ )
57
+ t4.report_requested.connect(lambda fname: (t3.commit(), self.ctrl.write_report(fname)))
58
+ t4.savedata_requested.connect(lambda fname: (t3.commit(), self.ctrl.save_data(fname)))
59
+ t5.sweep_requested.connect(
60
+ lambda start, stop, count, key: (
61
+ t3.commit(),
62
+ self.ctrl.run_tracer_tracer(start, stop, count, key),
63
+ )
64
+ )
65
+
66
+ self.ctrl.simulated.connect(t4.show_results)
67
+ self.ctrl.calibrated.connect(t4.show_results)
68
+ self.ctrl.tracer_tracer_ready.connect(t5.handle_tracer_tracer_ready)
69
+ self.ctrl.status.connect(t4.show_status)
70
+
71
+ def _show_error(msg):
72
+ t5.sweep_failed()
73
+ QMessageBox.critical(self, "Error", msg)
74
+
75
+ self.ctrl.error.connect(_show_error)
@@ -0,0 +1,122 @@
1
+ """Shared GUI state container used across tabs and controller."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Dict, List, Optional, Tuple, Union
6
+
7
+ import numpy as np
8
+
9
+ # Store the current state of the app in a centralized fashion
10
+
11
+ ArrayLike = np.ndarray
12
+
13
+
14
+ @dataclass
15
+ class AppState:
16
+ """Central application state used by GUI components.
17
+
18
+ Attributes
19
+ ----------
20
+ is_monthly : bool
21
+ Whether input/target series are monthly (else yearly).
22
+ tracer : str
23
+ LEGACY: single-tracer selection kept for backward compatibility.
24
+ tracer1 : str
25
+ Primary tracer (e.g., ``"Tritium"``, ``"Carbon-14"``).
26
+ tracer2 : str | None
27
+ Optional second tracer; if ``None`` or ``"None"``, runs single-tracer mode.
28
+ solver_key : str
29
+ Selected solver registry key (``"de"``, ``"lsq"``, or ``"mcmc"``).
30
+ solver_params : dict
31
+ Per-solver configuration dictionary.
32
+ input_series : tuple(ndarray, ndarray) | None
33
+ Timestamps and input values.
34
+ target_series : tuple(ndarray, ndarray) | None
35
+ Timestamps and observation values (optional).
36
+ selected_units : list of str
37
+ Unique unit types (legacy support for parameters tab).
38
+ design_units : list of (str, float)
39
+ Flat list of chosen units and fractions (can repeat unit types).
40
+ unit_fractions : dict[str, float]
41
+ Per-instance fraction mapping keyed by unique instance prefix.
42
+ params : dict
43
+ Nested parameter values/bounds/fixed flags per instance prefix.
44
+ steady_state_input : float | list of float
45
+ Value(s) for optional steady-state warmup input. Provide a scalar for
46
+ single-tracer runs or one value per tracer for multi-tracer runs.
47
+ n_warmup_half_lives : int
48
+ Warmup length in half-lives of the tracer.
49
+ last_simulation : object | None
50
+ Last simulation result or solver payload for plotting.
51
+ last_times : ndarray | None
52
+ Cached timestamps for plotting (if needed).
53
+ """
54
+
55
+ is_monthly: bool = True
56
+ is_halfyearly: bool = False
57
+ tracer1: str = "Tritium"
58
+ tracer2: Optional[str] = None
59
+ # Selected solver (registry key); default to Differential Evolution
60
+ solver_key: str = "de"
61
+ # Per-solver parameters
62
+ solver_params: Dict[str, dict] = field(
63
+ default_factory=lambda: {
64
+ "de": {
65
+ "maxiter": 10000,
66
+ "popsize": 15,
67
+ "mutation": (0.5, 1.0),
68
+ "recombination": 0.7,
69
+ "tol": 0.01,
70
+ "sigma": None,
71
+ },
72
+ "lsq": {
73
+ "ftol": 1e-8,
74
+ "xtol": 1e-8,
75
+ "gtol": 1e-8,
76
+ "max_nfev": 10000,
77
+ "sigma": None,
78
+ },
79
+ "mcmc": {
80
+ "n_samples": 10000,
81
+ "burn_in": 2000,
82
+ "thin": 2,
83
+ "rw_scale": 0.05,
84
+ "sigma": None,
85
+ },
86
+ "dream": {
87
+ "n_samples": 2000,
88
+ "burn_in": 2000,
89
+ "thin": 1,
90
+ "n_chains": 3,
91
+ "n_diff_pairs": 2,
92
+ "n_cr": 4,
93
+ "sigma": None,
94
+ },
95
+ }
96
+ )
97
+ input_series: Optional[Tuple[ArrayLike, ArrayLike]] = None
98
+ target_series: Optional[Tuple[ArrayLike, ArrayLike]] = None
99
+ manual_observations: Dict[datetime, List[float]] = field(default_factory=dict)
100
+ selected_units: List[str] = field(default_factory=list) # unique registry keys for params tab
101
+ # detailed design: list of (unit_name, fraction) allowing duplicates
102
+ design_units: List[Tuple[str, float]] = field(default_factory=list)
103
+ # aggregated by prefix for controller/model: {"epm": 0.5, ...}
104
+ unit_fractions: Dict[str, float] = field(default_factory=dict)
105
+ params: Dict[str, Dict[str, Dict[str, float]]] = field(default_factory=dict)
106
+ # params[prefix][key] = {"val":..., "lb":..., "ub":..., "fixed":0/1}
107
+ steady_state_input: Union[float, List[float]] = 0.0
108
+ n_warmup_half_lives: int = 10
109
+ tt_results: Optional[ArrayLike] = None
110
+ tt_mtt_values: Optional[np.ndarray] = None
111
+ tt_obs_indices: Optional[np.ndarray] = None
112
+ tt_param_key: Optional[str] = None
113
+ # Can be either a plain ndarray (legacy) or a payload dict from solver.run_solver
114
+ last_simulation: Optional[object] = None
115
+ last_times: Optional[ArrayLike] = None
116
+
117
+ def clear_tracer_tracer(self) -> None:
118
+ """Reset cached tracer-tracer sweep results."""
119
+ self.tt_results = None
120
+ self.tt_mtt_values = None
121
+ self.tt_obs_indices = None
122
+ self.tt_param_key = None