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,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