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
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
"""Model container with a parameter registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Dict, List, Optional, Sequence, Tuple, Union
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import scipy.signal
|
|
10
|
+
|
|
11
|
+
from .units import Unit
|
|
12
|
+
|
|
13
|
+
# Each registry record stores numeric state + metadata used by the solver.
|
|
14
|
+
ParamRecord = Dict[str, object]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Model:
|
|
19
|
+
"""Forward model container with a parameter registry.
|
|
20
|
+
|
|
21
|
+
The model aggregates units, keeps their mixing fractions, and performs the
|
|
22
|
+
convolution-based simulation. It also manages an explicit parameter
|
|
23
|
+
registry that stores **current values**, **initial values**, **optimizer
|
|
24
|
+
bounds**, and **fixed flags** per parameter.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
dt : float
|
|
29
|
+
Time step of the simulation (same units as ``mtt`` used by units).
|
|
30
|
+
lambda_ : float or ndarray
|
|
31
|
+
Decay constant(s) in 1/time units. Provide a scalar for single-tracer
|
|
32
|
+
runs or an array-like of length ``n_tracers`` for multi-tracer runs.
|
|
33
|
+
input_series : ndarray
|
|
34
|
+
Forcing time series of shape ``(N,)`` for single tracer or ``(N, K)``
|
|
35
|
+
for ``K`` tracers.
|
|
36
|
+
production : bool or sequence of bool, optional
|
|
37
|
+
If True, simulate production from decay. If a bool, this is a global
|
|
38
|
+
setting. Use a sequence of bools for per-tracer specification. The
|
|
39
|
+
order of the sequence must match the order of the columns in the
|
|
40
|
+
``input_series``. The input should contain the parent tracer from
|
|
41
|
+
which production is simulated.
|
|
42
|
+
target_series : ndarray, optional
|
|
43
|
+
Observed output series of shape ``(N,)`` or ``(N, K)``; used only for
|
|
44
|
+
calibration/loss and reporting.
|
|
45
|
+
steady_state_input : float or sequence of float, optional
|
|
46
|
+
If provided, a warmup of constant input is prepended. Supply a scalar
|
|
47
|
+
for single-tracer runs or one value per tracer for multi-tracer runs.
|
|
48
|
+
n_warmup_half_lives : int, optional
|
|
49
|
+
Heuristic warmup scaling in half-lives (kept for compatibility).
|
|
50
|
+
n_warmup_steps : int, optional
|
|
51
|
+
Number of warmup time steps prepended to the input series. If given,
|
|
52
|
+
it overrides the warmup steps calculated from ``n_warmup_half_lives``.
|
|
53
|
+
|
|
54
|
+
Notes
|
|
55
|
+
-----
|
|
56
|
+
- Units are added via :meth:`add_unit`. The method also registers unit
|
|
57
|
+
parameters into the model's registry.
|
|
58
|
+
- Bounds are **optimization bounds** only and can be provided at add time
|
|
59
|
+
or later via :meth:`set_bounds`.
|
|
60
|
+
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
dt: float
|
|
64
|
+
lambda_: Union[float, np.ndarray]
|
|
65
|
+
input_series: np.ndarray
|
|
66
|
+
production: Optional[Union[bool, Sequence[bool]]] = False
|
|
67
|
+
target_series: Optional[np.ndarray] = None
|
|
68
|
+
steady_state_input: Optional[Union[float, Sequence[float]]] = None
|
|
69
|
+
n_warmup_half_lives: int = 2
|
|
70
|
+
n_warmup_steps: int = None
|
|
71
|
+
time_steps: Optional[Union[Sequence, np.ndarray]] = None
|
|
72
|
+
|
|
73
|
+
units: List[Unit] = field(default_factory=list)
|
|
74
|
+
unit_fractions: List[float] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
# Parameter registry: key -> record
|
|
77
|
+
params: Dict[str, ParamRecord] = field(default_factory=dict, init=False)
|
|
78
|
+
|
|
79
|
+
# Parameter uncertainty utility for GUI
|
|
80
|
+
# We want to have a structure that allows us to transfer uncertainty
|
|
81
|
+
# estimates of model parameters from the GUI-solver utilities to the
|
|
82
|
+
# model. Only then can we easily pass those values to the report.
|
|
83
|
+
# Otherwise we would need to transfer everything via the GUI itself.
|
|
84
|
+
# For each parameter (keyed in the dict), we contain the 1%-50%-99%
|
|
85
|
+
# quantiles of parameters.
|
|
86
|
+
param_uncert: Dict[str, List[float, float, float]] = None
|
|
87
|
+
# We create a similar dict for the parameter maximum a posteriori (MAP)
|
|
88
|
+
# values.
|
|
89
|
+
param_map: Dict[str, float] = None
|
|
90
|
+
|
|
91
|
+
# Internal warmup state
|
|
92
|
+
_is_warm: bool = field(default=False, init=False, repr=False)
|
|
93
|
+
_n_warmup: int = field(default=0, init=False, repr=False)
|
|
94
|
+
|
|
95
|
+
def add_unit(
|
|
96
|
+
self,
|
|
97
|
+
unit: Unit,
|
|
98
|
+
fraction: float,
|
|
99
|
+
prefix: Optional[str] = None,
|
|
100
|
+
bounds: Optional[List[Tuple[float, float]]] = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Add a unit, register its parameters, and set its mixture fraction.
|
|
103
|
+
|
|
104
|
+
Parameters
|
|
105
|
+
----------
|
|
106
|
+
unit : :class:`~PyTracerLab.model.units.Unit`
|
|
107
|
+
The unit instance to add.
|
|
108
|
+
fraction : float
|
|
109
|
+
Mixture fraction of this unit in the overall response. Fractions
|
|
110
|
+
should sum to ~1 across all units.
|
|
111
|
+
prefix : str, optional
|
|
112
|
+
Namespace prefix for the unit's parameters (e.g., ``"epm"``). If
|
|
113
|
+
omitted, ``"u{index}"`` is used in insertion order.
|
|
114
|
+
bounds : list of (float, float), optional
|
|
115
|
+
Optimizer bounds for the unit's parameters in the same order as
|
|
116
|
+
returned by ``unit.param_values()``. If omitted, bounds are left
|
|
117
|
+
as ``None`` and can be supplied later via :meth:`set_bounds`.
|
|
118
|
+
|
|
119
|
+
Raises
|
|
120
|
+
------
|
|
121
|
+
ValueError
|
|
122
|
+
If ``bounds`` is provided and its length does not match the number
|
|
123
|
+
of unit parameters.
|
|
124
|
+
"""
|
|
125
|
+
idx = len(self.units)
|
|
126
|
+
self.units.append(unit)
|
|
127
|
+
self.unit_fractions.append(float(fraction))
|
|
128
|
+
|
|
129
|
+
prefix = prefix or f"u{idx}"
|
|
130
|
+
local_params = list(unit.param_values().items())
|
|
131
|
+
if bounds is not None and len(bounds) != len(local_params):
|
|
132
|
+
raise ValueError("Length of bounds list must match number of unit parameters")
|
|
133
|
+
|
|
134
|
+
for i, (local_name, val) in enumerate(local_params):
|
|
135
|
+
key = f"{prefix}.{local_name}"
|
|
136
|
+
b = bounds[i] if bounds is not None else None
|
|
137
|
+
self.params[key] = {
|
|
138
|
+
"value": float(val),
|
|
139
|
+
"initial": float(val),
|
|
140
|
+
"bounds": b,
|
|
141
|
+
"fixed": False,
|
|
142
|
+
"unit_index": idx,
|
|
143
|
+
"local_name": local_name,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def param_keys(self, free_only: bool = False) -> List[str]:
|
|
147
|
+
"""Return parameter keys in a stable order.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
free_only : bool, optional
|
|
152
|
+
If ``True``, return only parameters with ``fixed == False``.
|
|
153
|
+
|
|
154
|
+
Returns
|
|
155
|
+
-------
|
|
156
|
+
list of str
|
|
157
|
+
Fully-qualified parameter keys (e.g., ``"epm.mtt"``).
|
|
158
|
+
"""
|
|
159
|
+
items = sorted(
|
|
160
|
+
self.params.items(), key=lambda kv: (kv[1]["unit_index"], kv[1]["local_name"]) # type: ignore
|
|
161
|
+
)
|
|
162
|
+
return [k for k, rec in items if not (free_only and rec.get("fixed"))]
|
|
163
|
+
|
|
164
|
+
def get_vector(self, which: str = "value", free_only: bool = False) -> List[float]:
|
|
165
|
+
"""Export parameter values as a flat vector in registry order.
|
|
166
|
+
|
|
167
|
+
Parameters
|
|
168
|
+
----------
|
|
169
|
+
which : {"value", "initial"}
|
|
170
|
+
Whether to export current values or initial guesses.
|
|
171
|
+
free_only : bool, optional
|
|
172
|
+
If ``True``, export only free parameters.
|
|
173
|
+
|
|
174
|
+
Returns
|
|
175
|
+
-------
|
|
176
|
+
list of float
|
|
177
|
+
Parameter vector following :meth:`param_keys` order.
|
|
178
|
+
"""
|
|
179
|
+
assert which in {"value", "initial"}
|
|
180
|
+
keys = self.param_keys(free_only=free_only)
|
|
181
|
+
return [float(self.params[k][which]) for k in keys]
|
|
182
|
+
|
|
183
|
+
def set_vector(
|
|
184
|
+
self, vec: Sequence[float], which: str = "value", free_only: bool = False
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Write a vector into the registry (and units) in registry order.
|
|
187
|
+
|
|
188
|
+
Parameters
|
|
189
|
+
----------
|
|
190
|
+
vec : sequence of float
|
|
191
|
+
Values to assign (length must match the number of addressed params).
|
|
192
|
+
which : {"value", "initial"}
|
|
193
|
+
Destination field to write (``"value"`` also writes through to units).
|
|
194
|
+
free_only : bool, optional
|
|
195
|
+
If ``True``, write into free parameters only.
|
|
196
|
+
"""
|
|
197
|
+
assert which in {"value", "initial"}
|
|
198
|
+
keys = self.param_keys(free_only=free_only)
|
|
199
|
+
it = iter(map(float, vec))
|
|
200
|
+
for k in keys:
|
|
201
|
+
v = next(it)
|
|
202
|
+
self.params[k][which] = v
|
|
203
|
+
if which == "value":
|
|
204
|
+
# push through to owning unit immediately
|
|
205
|
+
idx = int(self.params[k]["unit_index"]) # type: ignore
|
|
206
|
+
local = str(self.params[k]["local_name"]) # type: ignore
|
|
207
|
+
self.units[idx].set_param_values({local: v})
|
|
208
|
+
|
|
209
|
+
def set_param(self, key: str, value: float) -> None:
|
|
210
|
+
"""Set a single parameter's **current** value and update the unit.
|
|
211
|
+
|
|
212
|
+
This is a convenience wrapper around :meth:`set_vector` for one value.
|
|
213
|
+
"""
|
|
214
|
+
self.params[key]["value"] = float(value)
|
|
215
|
+
idx = int(self.params[key]["unit_index"]) # type: ignore
|
|
216
|
+
local = str(self.params[key]["local_name"]) # type: ignore
|
|
217
|
+
self.units[idx].set_param_values({local: float(value)})
|
|
218
|
+
|
|
219
|
+
def set_initial(self, key: str, value: float) -> None:
|
|
220
|
+
"""Set a single parameter's **initial** value used for optimization seeding."""
|
|
221
|
+
self.params[key]["initial"] = float(value)
|
|
222
|
+
|
|
223
|
+
def set_bounds(self, key: str, bounds: Tuple[float, float]) -> None:
|
|
224
|
+
"""Set optimizer bounds for a single parameter.
|
|
225
|
+
|
|
226
|
+
Parameters
|
|
227
|
+
----------
|
|
228
|
+
key : str
|
|
229
|
+
Fully-qualified parameter key (e.g., ``"epm.mtt"``).
|
|
230
|
+
bounds : (float, float)
|
|
231
|
+
Lower and upper search bounds for the optimizer.
|
|
232
|
+
"""
|
|
233
|
+
lo, hi = bounds
|
|
234
|
+
self.params[key]["bounds"] = (float(lo), float(hi))
|
|
235
|
+
|
|
236
|
+
def set_fixed(self, key: str, fixed: bool = True) -> None:
|
|
237
|
+
"""Mark a parameter as fixed (not optimized)."""
|
|
238
|
+
self.params[key]["fixed"] = bool(fixed)
|
|
239
|
+
|
|
240
|
+
def get_bounds(self, free_only: bool = False) -> List[Tuple[float, float]]:
|
|
241
|
+
"""Return bounds for parameters in registry order.
|
|
242
|
+
|
|
243
|
+
Raises a ``ValueError`` if any addressed parameter has no bounds set.
|
|
244
|
+
"""
|
|
245
|
+
keys = self.param_keys(free_only=free_only)
|
|
246
|
+
out: List[Tuple[float, float]] = []
|
|
247
|
+
for k in keys:
|
|
248
|
+
b = self.params[k]["bounds"]
|
|
249
|
+
if b is None:
|
|
250
|
+
raise ValueError(f"Missing optimizer bounds for parameter: {k}")
|
|
251
|
+
out.append(b) # type: ignore[arg-type]
|
|
252
|
+
return out
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def n_warmup(self) -> int:
|
|
256
|
+
"""Number of warmup steps prepended to the series."""
|
|
257
|
+
return self._n_warmup
|
|
258
|
+
|
|
259
|
+
def _steady_state_vector(self, n_tracers: int) -> np.ndarray:
|
|
260
|
+
"""Return steady-state input as a 1D vector matching ``n_tracers``.
|
|
261
|
+
|
|
262
|
+
Parameters
|
|
263
|
+
----------
|
|
264
|
+
n_tracers : int
|
|
265
|
+
Number of tracer channels in the model input.
|
|
266
|
+
|
|
267
|
+
Returns
|
|
268
|
+
-------
|
|
269
|
+
ndarray
|
|
270
|
+
A vector of length ``n_tracers`` with steady-state input values.
|
|
271
|
+
|
|
272
|
+
Raises
|
|
273
|
+
------
|
|
274
|
+
ValueError
|
|
275
|
+
If provided values cannot be broadcast to ``n_tracers``.
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
if self.steady_state_input is None:
|
|
279
|
+
raise ValueError("steady_state_input is None")
|
|
280
|
+
arr = np.asarray(self.steady_state_input, dtype=float)
|
|
281
|
+
if arr.ndim == 0:
|
|
282
|
+
return np.full(n_tracers, float(arr))
|
|
283
|
+
if arr.shape == (n_tracers,):
|
|
284
|
+
return arr.astype(float, copy=False)
|
|
285
|
+
if arr.size == 1:
|
|
286
|
+
return np.full(n_tracers, float(arr.reshape(-1)[0]))
|
|
287
|
+
raise ValueError("steady_state_input must be scalar or length equal to number of tracers")
|
|
288
|
+
|
|
289
|
+
def _warmup(self) -> None:
|
|
290
|
+
"""Prepend a steady-state warmup to input/target and set bookkeeping.
|
|
291
|
+
|
|
292
|
+
Uses ``n_warmup_half_lives`` and the decay constant to determine the
|
|
293
|
+
warmup length. If ``steady_state_input`` is not provided or length is
|
|
294
|
+
non-positive, no warmup is applied.
|
|
295
|
+
"""
|
|
296
|
+
if self.n_warmup_half_lives is not None and self.n_warmup_steps is None:
|
|
297
|
+
t12 = 0.693 / np.asarray(self.lambda_)
|
|
298
|
+
t12 = np.asarray(t12, dtype=float)
|
|
299
|
+
self._n_warmup = int(np.max(t12)) * self.n_warmup_half_lives
|
|
300
|
+
else:
|
|
301
|
+
self._n_warmup = int(self.n_warmup_steps)
|
|
302
|
+
|
|
303
|
+
# Ensure that warmup is not too long
|
|
304
|
+
if self._n_warmup > 5000:
|
|
305
|
+
# limit warmup to 5000
|
|
306
|
+
self._n_warmup = 5000
|
|
307
|
+
|
|
308
|
+
if self.steady_state_input is None or self._n_warmup <= 0:
|
|
309
|
+
# no warmup requested → ensure we don't slice anything off
|
|
310
|
+
self._n_warmup = 0
|
|
311
|
+
self._is_warm = True
|
|
312
|
+
return
|
|
313
|
+
# prepend steady-state warmup to input; support 1D or 2D inputs
|
|
314
|
+
if self.input_series.ndim == 1:
|
|
315
|
+
val = self._steady_state_vector(1)[0]
|
|
316
|
+
warm = np.full(self._n_warmup, float(val))
|
|
317
|
+
else:
|
|
318
|
+
n_tr = int(self.input_series.shape[1])
|
|
319
|
+
vals = self._steady_state_vector(n_tr)
|
|
320
|
+
warm = np.repeat(vals[np.newaxis, :], self._n_warmup, axis=0)
|
|
321
|
+
self.input_series = np.concatenate((warm, self.input_series))
|
|
322
|
+
if self.target_series is not None:
|
|
323
|
+
if self.target_series.ndim == 1:
|
|
324
|
+
warm_nan = np.full(self._n_warmup, np.nan)
|
|
325
|
+
else:
|
|
326
|
+
n_tr_tg = int(self.target_series.shape[1])
|
|
327
|
+
warm_nan = np.full((self._n_warmup, n_tr_tg), np.nan)
|
|
328
|
+
self.target_series = np.concatenate((warm_nan, self.target_series))
|
|
329
|
+
self._is_warm = True
|
|
330
|
+
|
|
331
|
+
def _check(self) -> None:
|
|
332
|
+
"""Ensure warmup is applied and mixture fractions are properly normalized.
|
|
333
|
+
|
|
334
|
+
Raises
|
|
335
|
+
------
|
|
336
|
+
ValueError
|
|
337
|
+
If the sum of unit fractions deviates too much from 1.0.
|
|
338
|
+
"""
|
|
339
|
+
if not self._is_warm:
|
|
340
|
+
self._warmup()
|
|
341
|
+
s = sum(self.unit_fractions) if self.unit_fractions else 0.0
|
|
342
|
+
if not (0.99 <= s <= 1.01):
|
|
343
|
+
raise ValueError("Sum of unit fractions must be ~1.0.")
|
|
344
|
+
|
|
345
|
+
def get_age_distributions(self, n_steps: Optional[int] = None) -> List[np.ndarray]:
|
|
346
|
+
"""Return age distributions for all units using current registry
|
|
347
|
+
values.
|
|
348
|
+
|
|
349
|
+
Returns
|
|
350
|
+
-------
|
|
351
|
+
Dict
|
|
352
|
+
Dict of unit fractions and age distributions for all units.
|
|
353
|
+
Each age distribution is a numpy array with one element per
|
|
354
|
+
time step. Time steps and time step units correspond to general
|
|
355
|
+
model settings. Fractions can be used to determine a global
|
|
356
|
+
model-impulse response from the unit-specific responses.
|
|
357
|
+
"""
|
|
358
|
+
# initialize age distribution dict for all units
|
|
359
|
+
age_dists = {"fractions": self.unit_fractions, "distributions": []}
|
|
360
|
+
|
|
361
|
+
if n_steps is None:
|
|
362
|
+
# Determine number of time steps from input dimensionality
|
|
363
|
+
x = np.asarray(self.input_series, dtype=float)
|
|
364
|
+
if x.ndim == 1:
|
|
365
|
+
x = x.reshape(-1, 1)
|
|
366
|
+
n, k = x.shape
|
|
367
|
+
t = np.arange(0.0, n * self.dt, self.dt)
|
|
368
|
+
else:
|
|
369
|
+
t = np.arange(0.0, n_steps * self.dt, self.dt)
|
|
370
|
+
|
|
371
|
+
for unit in self.units:
|
|
372
|
+
# up to tracer-specific properties (decay), the impulse response
|
|
373
|
+
# is equal for all tracers in a model and just depends on the
|
|
374
|
+
# unit
|
|
375
|
+
h = unit.get_impulse_response(t, self.dt, 0.0, False)
|
|
376
|
+
age_dists["distributions"].append(h)
|
|
377
|
+
|
|
378
|
+
return age_dists
|
|
379
|
+
|
|
380
|
+
def simulate(self) -> np.ndarray:
|
|
381
|
+
"""Run the forward model using current registry values.
|
|
382
|
+
|
|
383
|
+
Returns
|
|
384
|
+
-------
|
|
385
|
+
ndarray
|
|
386
|
+
Simulated output aligned with ``target_series`` (warmup removed).
|
|
387
|
+
"""
|
|
388
|
+
# Check before simulating
|
|
389
|
+
self._check()
|
|
390
|
+
|
|
391
|
+
# Determine number of tracers from input dimensionality
|
|
392
|
+
x = np.asarray(self.input_series, dtype=float)
|
|
393
|
+
if x.ndim == 1:
|
|
394
|
+
x = x.reshape(-1, 1)
|
|
395
|
+
n, k = x.shape
|
|
396
|
+
t = np.arange(0.0, n * self.dt, self.dt)
|
|
397
|
+
|
|
398
|
+
# Support scalar or vector lambda_
|
|
399
|
+
if isinstance(self.lambda_, (list, tuple, np.ndarray)):
|
|
400
|
+
lam_vec = np.asarray(self.lambda_, dtype=float)
|
|
401
|
+
if lam_vec.ndim == 0:
|
|
402
|
+
lam_vec = np.full(k, float(lam_vec))
|
|
403
|
+
elif lam_vec.shape != (k,):
|
|
404
|
+
# broadcast a single value or truncate/extend conservatively
|
|
405
|
+
if lam_vec.size == 1:
|
|
406
|
+
lam_vec = np.full(k, float(lam_vec.ravel()[0]))
|
|
407
|
+
else:
|
|
408
|
+
raise ValueError("lambda_ must be scalar or length equal to number of tracers")
|
|
409
|
+
else:
|
|
410
|
+
lam_vec = np.full(k, float(self.lambda_))
|
|
411
|
+
|
|
412
|
+
# Handle production bool; make it a per-tracer-vector if a single bool
|
|
413
|
+
if isinstance(self.production, bool):
|
|
414
|
+
prod_vec = [self.production] * k
|
|
415
|
+
else:
|
|
416
|
+
prod_vec = self.production
|
|
417
|
+
|
|
418
|
+
sim = np.zeros((n, k), dtype=float)
|
|
419
|
+
for frac, unit in zip(self.unit_fractions, self.units):
|
|
420
|
+
# per-tracer impulse responses and contributions
|
|
421
|
+
for j in range(k):
|
|
422
|
+
h = unit.get_impulse_response(t, self.dt, float(lam_vec[j]), prod_vec[j])
|
|
423
|
+
|
|
424
|
+
# Normalization of the impulse response happens within the
|
|
425
|
+
# model units. If we normalize here, we remove the effect
|
|
426
|
+
# of radioactive decay.
|
|
427
|
+
|
|
428
|
+
contrib = scipy.signal.fftconvolve(x[:, j], h)[:n] # * self.dt
|
|
429
|
+
sim[:, j] += float(frac) * contrib
|
|
430
|
+
|
|
431
|
+
# Remove warmup
|
|
432
|
+
sim = sim[self._n_warmup :, :]
|
|
433
|
+
|
|
434
|
+
# Return 1D for single-tracer to preserve backward compatibility
|
|
435
|
+
if sim.shape[1] == 1:
|
|
436
|
+
return sim.ravel()
|
|
437
|
+
else:
|
|
438
|
+
return sim
|
|
439
|
+
|
|
440
|
+
def write_report(
|
|
441
|
+
self,
|
|
442
|
+
filename: str,
|
|
443
|
+
frequency: str,
|
|
444
|
+
tracer: Optional[str] = None,
|
|
445
|
+
sim: Optional[Union[str, Sequence[str]]] = None,
|
|
446
|
+
title: str = "Model Report",
|
|
447
|
+
include_initials: bool = True,
|
|
448
|
+
include_bounds: bool = True,
|
|
449
|
+
convert_mtt_to_years: bool = False,
|
|
450
|
+
) -> str:
|
|
451
|
+
"""
|
|
452
|
+
Create a simple text report of the current model configuration and fit.
|
|
453
|
+
|
|
454
|
+
Parameters
|
|
455
|
+
----------
|
|
456
|
+
filename : str
|
|
457
|
+
Path of the text file to write.
|
|
458
|
+
frequency : str
|
|
459
|
+
Simulation frequency (e.g., ``"1h"``). This is not checked
|
|
460
|
+
internally and directly written to the report.
|
|
461
|
+
tracer : str or sequence of str, optional
|
|
462
|
+
Name(s) of the tracer(s) in the report. If not given, decay
|
|
463
|
+
constants are shown for all tracers instead of tracer names.
|
|
464
|
+
sim : ndarray, optional
|
|
465
|
+
Simulated series corresponding to the *current* parameters. If not
|
|
466
|
+
provided and `target_series` is present, the method will call
|
|
467
|
+
:meth:`simulate` to compute one.
|
|
468
|
+
title : str, optional
|
|
469
|
+
Title shown at the top of the report.
|
|
470
|
+
include_initials : bool, optional
|
|
471
|
+
Whether to include initial values in the parameter table.
|
|
472
|
+
include_bounds : bool, optional
|
|
473
|
+
Whether to include optimizer bounds in the parameter table.
|
|
474
|
+
convert_mtt_to_years : bool, optional
|
|
475
|
+
Whether to convert mean travel times to years from months.
|
|
476
|
+
|
|
477
|
+
Returns
|
|
478
|
+
-------
|
|
479
|
+
str
|
|
480
|
+
The full report text that was written to `filename`.
|
|
481
|
+
|
|
482
|
+
Notes
|
|
483
|
+
-----
|
|
484
|
+
- Parameters are grouped by their namespace prefix (e.g., ``"epm"`` in
|
|
485
|
+
keys like ``"epm.mtt"``).
|
|
486
|
+
- If `target_series` is available, the report includes the mean squared
|
|
487
|
+
error (MSE) between the simulation and observations using overlapping,
|
|
488
|
+
non-NaN entries.
|
|
489
|
+
|
|
490
|
+
"""
|
|
491
|
+
lines: list[str] = []
|
|
492
|
+
|
|
493
|
+
# Header
|
|
494
|
+
lines.append(f"{title}")
|
|
495
|
+
lines.append("=" * max(len(title), 20))
|
|
496
|
+
lines.append("")
|
|
497
|
+
|
|
498
|
+
# Model settings
|
|
499
|
+
lines.append("Model settings")
|
|
500
|
+
lines.append("--------------")
|
|
501
|
+
lines.append(f"Time step (dt): {frequency}")
|
|
502
|
+
if tracer is None:
|
|
503
|
+
# If there is no tracer name given, we use the decay constants
|
|
504
|
+
lines.append(
|
|
505
|
+
"Decay constant (lambda): "
|
|
506
|
+
+ (
|
|
507
|
+
", ".join(f"{float(v):.6g}" for v in np.atleast_1d(self.lambda_))
|
|
508
|
+
if isinstance(self.lambda_, (list, tuple, np.ndarray))
|
|
509
|
+
else f"{float(self.lambda_):.6g}"
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
else:
|
|
513
|
+
if isinstance(tracer, str):
|
|
514
|
+
lines.append(f"Tracer: {tracer}")
|
|
515
|
+
elif isinstance(tracer, (list, tuple, np.ndarray)):
|
|
516
|
+
lines.append(f"Tracers: {', '.join(tracer)}")
|
|
517
|
+
else:
|
|
518
|
+
raise ValueError("Tracer must be a string or sequence of strings.")
|
|
519
|
+
lines.append(f"Warmup steps: {self._n_warmup} (auto)")
|
|
520
|
+
if self.steady_state_input is None:
|
|
521
|
+
steady = "n/a"
|
|
522
|
+
else:
|
|
523
|
+
arr = np.asarray(self.steady_state_input, dtype=float)
|
|
524
|
+
if arr.ndim == 0 or arr.size == 1:
|
|
525
|
+
steady = f"{float(arr.reshape(-1)[0]):.6g}"
|
|
526
|
+
else:
|
|
527
|
+
steady = ", ".join(f"{float(v):.6g}" for v in arr.ravel())
|
|
528
|
+
lines.append(f"Steady-state input: {steady}")
|
|
529
|
+
lines.append(f"Units count: {len(self.units)}")
|
|
530
|
+
lines.append("")
|
|
531
|
+
|
|
532
|
+
# MSE and data if possible
|
|
533
|
+
mse_text = "n/a"
|
|
534
|
+
if self.target_series is not None:
|
|
535
|
+
if sim is None:
|
|
536
|
+
sim = self.simulate()
|
|
537
|
+
y = self.target_series[self._n_warmup :]
|
|
538
|
+
# coerce to 2D for uniform handling
|
|
539
|
+
y2 = np.asarray(y, dtype=float)
|
|
540
|
+
s2 = np.asarray(sim, dtype=float)
|
|
541
|
+
if y2.ndim == 1:
|
|
542
|
+
y2 = y2.reshape(-1, 1)
|
|
543
|
+
if s2.ndim == 1:
|
|
544
|
+
s2 = s2.reshape(-1, 1)
|
|
545
|
+
if y2.shape[0] == s2.shape[0] and y2.shape[1] == s2.shape[1]:
|
|
546
|
+
per_tr_mse: list[str] = []
|
|
547
|
+
for j in range(y2.shape[1]):
|
|
548
|
+
mask = ~np.isnan(y2[:, j]) & ~np.isnan(s2[:, j])
|
|
549
|
+
if np.any(mask):
|
|
550
|
+
mse_j = float(np.mean((s2[mask, j] - y2[mask, j]) ** 2))
|
|
551
|
+
per_tr_mse.append(f"T{j+1}={mse_j:.6g}")
|
|
552
|
+
if per_tr_mse:
|
|
553
|
+
mse_text = ", ".join(per_tr_mse)
|
|
554
|
+
|
|
555
|
+
lines.append("Global fit")
|
|
556
|
+
lines.append("----------")
|
|
557
|
+
lines.append(f"MSE: {mse_text}")
|
|
558
|
+
lines.append("")
|
|
559
|
+
|
|
560
|
+
lines.append("Observed and Simulated Data")
|
|
561
|
+
lines.append("---------------------------")
|
|
562
|
+
lines.append("")
|
|
563
|
+
|
|
564
|
+
# We make a separate table for each tracer. This is mainly because
|
|
565
|
+
# for multiple tracers, the number of observations may be different
|
|
566
|
+
|
|
567
|
+
# Make list of tracer names if not given
|
|
568
|
+
if tracer is None:
|
|
569
|
+
tracer_ = [str(i) for i in range(1, y2.shape[1] + 1)]
|
|
570
|
+
else:
|
|
571
|
+
tracer_ = tracer
|
|
572
|
+
|
|
573
|
+
# Make list of time steps if not given
|
|
574
|
+
if self.time_steps is None:
|
|
575
|
+
timesteps = [i for i in range(y2.shape[0])]
|
|
576
|
+
else:
|
|
577
|
+
timesteps = self.time_steps
|
|
578
|
+
|
|
579
|
+
for i, tracer in enumerate(list(tracer_)):
|
|
580
|
+
# append column headers
|
|
581
|
+
lines.append(f"Tracer {tracer}")
|
|
582
|
+
lines.append("\t".join(["Time", "Obs.", "Sim."]))
|
|
583
|
+
|
|
584
|
+
# Get mask for dates where observations are available
|
|
585
|
+
mask = ~np.isnan(y2[:, i]) & ~np.isnan(s2[:, i])
|
|
586
|
+
for t in range(len(timesteps)):
|
|
587
|
+
# Only print if observation is available
|
|
588
|
+
if mask[t]:
|
|
589
|
+
# Try to format timesteps as "YYYY-MM"
|
|
590
|
+
try:
|
|
591
|
+
timestamp = timesteps[t].strftime("%Y-%m")
|
|
592
|
+
except AttributeError:
|
|
593
|
+
timestamp = timesteps[t]
|
|
594
|
+
lines.append("\t".join([f"{timestamp}", f"{y2[t, i]:.3e}", f"{s2[t, i]:.3e}"]))
|
|
595
|
+
lines.append("")
|
|
596
|
+
|
|
597
|
+
# Parameter table grouped by unit prefix
|
|
598
|
+
lines.append("Parameters by unit")
|
|
599
|
+
lines.append("------------------")
|
|
600
|
+
grouped: dict[str, list[str]] = {}
|
|
601
|
+
for key in self.param_keys(free_only=False):
|
|
602
|
+
prefix = key.split(".", 1)[0] if "." in key else "(root)"
|
|
603
|
+
grouped.setdefault(prefix, []).append(key)
|
|
604
|
+
|
|
605
|
+
# Determine stable group order based on the units' insertion order via recorded unit_index
|
|
606
|
+
prefix_order: list[tuple[str, int]] = []
|
|
607
|
+
for prefix, keys in grouped.items():
|
|
608
|
+
if not keys:
|
|
609
|
+
continue
|
|
610
|
+
one_key = keys[0]
|
|
611
|
+
try:
|
|
612
|
+
uidx = int(self.params[one_key]["unit_index"]) # type: ignore[index]
|
|
613
|
+
except Exception:
|
|
614
|
+
uidx = 10**9
|
|
615
|
+
prefix_order.append((prefix, uidx))
|
|
616
|
+
prefix_order.sort(key=lambda t: t[1])
|
|
617
|
+
|
|
618
|
+
# print warning regarding model units
|
|
619
|
+
lines.append("-- --\nATTENTION: Travel Time Parameters are Always Given in [Years]!\n-- --")
|
|
620
|
+
|
|
621
|
+
# pretty print per group with correct fraction association
|
|
622
|
+
for prefix, uidx in prefix_order:
|
|
623
|
+
frac = self.unit_fractions[uidx] if 0 <= uidx < len(self.unit_fractions) else None
|
|
624
|
+
frac_str = f"fraction={frac:.3f}" if frac is not None else ""
|
|
625
|
+
lines.append(f"[{prefix}] {frac_str}")
|
|
626
|
+
keys = sorted(grouped[prefix], key=lambda k: self.params[k]["local_name"]) # type: ignore[index]
|
|
627
|
+
for k in keys:
|
|
628
|
+
rec = self.params[k]
|
|
629
|
+
val = float(rec["value"])
|
|
630
|
+
# convert to yearly units
|
|
631
|
+
# we always work in months as base units, so we always have to convert
|
|
632
|
+
if "mtt" in k and convert_mtt_to_years:
|
|
633
|
+
val /= 12.0
|
|
634
|
+
fixed = bool(rec.get("fixed", False))
|
|
635
|
+
row = f" {k:15s} value={val:.6g}"
|
|
636
|
+
if include_initials:
|
|
637
|
+
ini = float(rec["initial"])
|
|
638
|
+
# convert to yearly units
|
|
639
|
+
if "mtt" in k and convert_mtt_to_years:
|
|
640
|
+
ini /= 12.0
|
|
641
|
+
row += f", initial={ini:.6g}"
|
|
642
|
+
if include_bounds and rec.get("bounds") is not None:
|
|
643
|
+
lo, hi = rec["bounds"] # type: ignore
|
|
644
|
+
# convert to yearly units
|
|
645
|
+
if "mtt" in k and convert_mtt_to_years:
|
|
646
|
+
lo /= 12.0
|
|
647
|
+
hi /= 12.0
|
|
648
|
+
row += f", bounds=({float(lo):.6g}, {float(hi):.6g})"
|
|
649
|
+
row += f", fixed={fixed}"
|
|
650
|
+
lines.append(row)
|
|
651
|
+
# check if we have uncertainty estimates
|
|
652
|
+
if self.param_uncert is not None and self.param_map is not None:
|
|
653
|
+
if k in self.param_map:
|
|
654
|
+
map = self.param_map[k]
|
|
655
|
+
# convert to yearly units
|
|
656
|
+
if "mtt" in k and convert_mtt_to_years:
|
|
657
|
+
map /= 12.0
|
|
658
|
+
row = f" {k:15s} MAP (maximum a posteriori estimate)={map:.6g}"
|
|
659
|
+
lines.append(row)
|
|
660
|
+
if k in self.param_uncert:
|
|
661
|
+
unc = self.param_uncert[k]
|
|
662
|
+
# convert to yearly units
|
|
663
|
+
if "mtt" in k and convert_mtt_to_years:
|
|
664
|
+
lower = unc[0] / 12.0
|
|
665
|
+
median = unc[1] / 12.0
|
|
666
|
+
upper = unc[2] / 12.0
|
|
667
|
+
else:
|
|
668
|
+
lower = unc[0]
|
|
669
|
+
median = unc[1]
|
|
670
|
+
upper = unc[2]
|
|
671
|
+
row = f" {k:15s} 1%-Quantile={lower:.6g}, "
|
|
672
|
+
row += f"50%-Quantile (Median)={median:.6g}, 99%-Quantile={upper:.6g}"
|
|
673
|
+
lines.append(row)
|
|
674
|
+
lines.append("")
|
|
675
|
+
lines.append("")
|
|
676
|
+
|
|
677
|
+
report_text = "\n".join(lines)
|
|
678
|
+
with open(filename, "w", encoding="utf-8") as f:
|
|
679
|
+
f.write(report_text)
|
|
680
|
+
return report_text
|