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.
- PyTracerLab/__init__.py +8 -0
- PyTracerLab/__main__.py +7 -0
- PyTracerLab/gui/__init__.py +10 -0
- PyTracerLab/gui/app.py +15 -0
- PyTracerLab/gui/controller.py +419 -0
- PyTracerLab/gui/database.py +16 -0
- PyTracerLab/gui/main_window.py +75 -0
- PyTracerLab/gui/state.py +122 -0
- PyTracerLab/gui/tabs/file_input.py +436 -0
- PyTracerLab/gui/tabs/model_design.py +322 -0
- PyTracerLab/gui/tabs/parameters.py +131 -0
- PyTracerLab/gui/tabs/simulation.py +300 -0
- PyTracerLab/gui/tabs/solver_params.py +697 -0
- PyTracerLab/gui/tabs/tracer_tracer.py +331 -0
- PyTracerLab/gui/tabs/widgets.py +106 -0
- PyTracerLab/model/__init__.py +52 -0
- PyTracerLab/model/model.py +680 -0
- PyTracerLab/model/registry.py +25 -0
- PyTracerLab/model/solver.py +1335 -0
- PyTracerLab/model/units.py +626 -0
- pytracerlab-0.2.0.dist-info/METADATA +37 -0
- pytracerlab-0.2.0.dist-info/RECORD +26 -0
- pytracerlab-0.2.0.dist-info/WHEEL +5 -0
- pytracerlab-0.2.0.dist-info/entry_points.txt +5 -0
- pytracerlab-0.2.0.dist-info/licenses/LICENSE +21 -0
- pytracerlab-0.2.0.dist-info/top_level.txt +1 -0
PyTracerLab/__init__.py
ADDED
PyTracerLab/__main__.py
ADDED
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)
|
PyTracerLab/gui/state.py
ADDED
|
@@ -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
|