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,331 @@
1
+ """Tab for tracer-tracer sweep configuration and visualization."""
2
+
3
+ import matplotlib.pyplot as plt
4
+ import numpy as np
5
+ from PyQt5.QtCore import QSize, pyqtSignal
6
+ from PyQt5.QtWidgets import (
7
+ QComboBox,
8
+ QDoubleSpinBox,
9
+ QHBoxLayout,
10
+ QLabel,
11
+ QMessageBox,
12
+ QPushButton,
13
+ QSpinBox,
14
+ QVBoxLayout,
15
+ QWidget,
16
+ )
17
+
18
+
19
+ class TracerTracerTab(QWidget):
20
+ """UI for running tracer-tracer sweeps and plotting results."""
21
+
22
+ # Define signals that this tab can emit
23
+ sweep_requested = pyqtSignal(float, float, int, str)
24
+
25
+ def __init__(self, state, registry, parent=None):
26
+ super().__init__(parent)
27
+ self.state = state
28
+ self.registry = registry
29
+ self._param_keys = []
30
+ self._date_indices = []
31
+ self._timestamps = None
32
+ self._observations = None
33
+ self._is_running = False
34
+
35
+ outer = QVBoxLayout(self)
36
+ outer.setContentsMargins(12, 12, 12, 12)
37
+ outer.setSpacing(8)
38
+
39
+ lbl_sweep = QLabel("Tracer-Tracer Sweep")
40
+ lbl_sweep.setStyleSheet("font-weight: 600;")
41
+ outer.addWidget(lbl_sweep)
42
+
43
+ row_param = QHBoxLayout()
44
+ lbl_param = QLabel("Mean Travel Time Parameter:")
45
+ row_param.addWidget(lbl_param)
46
+
47
+ self.cb_param = QComboBox(self)
48
+ self.cb_param.setFixedSize(QSize(260, 22))
49
+ row_param.addWidget(self.cb_param)
50
+ row_param.addStretch(1)
51
+ outer.addLayout(row_param)
52
+
53
+ row_range = QHBoxLayout()
54
+ lbl_start = QLabel("Start:")
55
+ row_range.addWidget(lbl_start)
56
+
57
+ self.sb_start = QDoubleSpinBox(self)
58
+ self.sb_start.setDecimals(2)
59
+ self.sb_start.setRange(1.0, 50000.0)
60
+ self.sb_start.setValue(5.0)
61
+ self.sb_start.setSingleStep(1.0)
62
+ row_range.addWidget(self.sb_start)
63
+
64
+ lbl_stop = QLabel("Stop:")
65
+ row_range.addWidget(lbl_stop)
66
+
67
+ self.sb_stop = QDoubleSpinBox(self)
68
+ self.sb_stop.setDecimals(2)
69
+ self.sb_stop.setRange(2.0, 200000.0)
70
+ self.sb_stop.setValue(20.0)
71
+ self.sb_stop.setSingleStep(1.0)
72
+ row_range.addWidget(self.sb_stop)
73
+
74
+ lbl_count = QLabel("Points:")
75
+ row_range.addWidget(lbl_count)
76
+
77
+ self.sb_count = QSpinBox(self)
78
+ self.sb_count.setRange(2, 400)
79
+ self.sb_count.setValue(21)
80
+ row_range.addWidget(self.sb_count)
81
+
82
+ self.lbl_units = QLabel("[years]")
83
+ self.lbl_units.setStyleSheet("color: #666;")
84
+ row_range.addWidget(self.lbl_units)
85
+
86
+ row_range.addStretch(1)
87
+ outer.addLayout(row_range)
88
+
89
+ self.btn_run = QPushButton("Run Sweep", self)
90
+ self.btn_run.setFixedSize(QSize(200, 40))
91
+ self.btn_run.clicked.connect(self._on_run_clicked)
92
+ outer.addWidget(self.btn_run)
93
+
94
+ outer.addStretch(1)
95
+
96
+ lbl_obs = QLabel("Observation Selection")
97
+ lbl_obs.setStyleSheet("font-weight: 600;")
98
+ outer.addWidget(lbl_obs)
99
+
100
+ row_obs = QHBoxLayout()
101
+ self.cb_date = QComboBox(self)
102
+ self.cb_date.setFixedSize(QSize(260, 22))
103
+ self.cb_date.currentIndexChanged.connect(self._on_date_changed)
104
+ row_obs.addWidget(self.cb_date)
105
+ row_obs.addStretch(1)
106
+ outer.addLayout(row_obs)
107
+
108
+ self.btn_plot = QPushButton("Plot Tracer-Tracer", self)
109
+ self.btn_plot.setFixedSize(QSize(200, 40))
110
+ self.btn_plot.clicked.connect(self._plot)
111
+ outer.addWidget(self.btn_plot)
112
+
113
+ outer.addStretch(2)
114
+
115
+ self._refresh_param_choices()
116
+ self.reset_results()
117
+ self.notify_sweep_finished()
118
+
119
+ def _unit_label_text(self) -> str:
120
+ return "Units: [years]"
121
+
122
+ def refresh(self) -> None:
123
+ """Refresh parameter choices and enable states."""
124
+ self.lbl_units.setText(self._unit_label_text())
125
+ self._refresh_param_choices()
126
+ self._update_enable_state()
127
+
128
+ def reset_results(self) -> None:
129
+ """Clear cached sweep outputs and disable plotting controls."""
130
+ self._date_indices = []
131
+ self._timestamps = None
132
+ self._observations = None
133
+ self._is_running = False
134
+ self.cb_date.clear()
135
+ self.cb_date.setEnabled(False)
136
+ self.btn_plot.setEnabled(False)
137
+
138
+ def handle_tracer_tracer_ready(self, payload) -> None:
139
+ """Populate observation selectors after a sweep finished."""
140
+ if payload is None:
141
+ return
142
+ self._timestamps = payload.get("timestamps")
143
+ self._observations = payload.get("observations")
144
+ indices = payload.get("obs_indices")
145
+ self._date_indices = (
146
+ list(int(i) for i in np.asarray(indices, dtype=int)) if indices is not None else []
147
+ )
148
+
149
+ self.cb_date.clear()
150
+ if self._timestamps is not None:
151
+ for idx in self._date_indices:
152
+ label = self._format_timestamp(self._timestamps[idx])
153
+ self.cb_date.addItem(label, idx)
154
+
155
+ has_entries = self.cb_date.count() > 0
156
+ self.cb_date.setEnabled(has_entries)
157
+ if has_entries:
158
+ self.cb_date.setCurrentIndex(0)
159
+ self.btn_plot.setEnabled(has_entries)
160
+ self.notify_sweep_finished()
161
+
162
+ def _refresh_param_choices(self) -> None:
163
+ """Fill parameter combo box with available mean travel time keys."""
164
+ self.cb_param.blockSignals(True)
165
+ self.cb_param.clear()
166
+ self._param_keys = []
167
+
168
+ instances = list(getattr(self.state, "design_instances", []) or [])
169
+ for inst in instances:
170
+ unit_name = inst.get("name")
171
+ prefix = inst.get("prefix")
172
+ if not unit_name or unit_name not in self.registry:
173
+ continue
174
+ cls = self.registry[unit_name]
175
+ for meta in getattr(cls, "PARAMS", []):
176
+ if meta.get("key") != "mtt":
177
+ continue
178
+ param_key = f"{prefix}.{meta['key']}"
179
+ label = meta.get("label", meta["key"])
180
+ display = f"{unit_name} ({prefix}) - {label}"
181
+ self.cb_param.addItem(display, param_key)
182
+ self._param_keys.append(param_key)
183
+
184
+ self.cb_param.blockSignals(False)
185
+ if self._param_keys:
186
+ self.cb_param.setCurrentIndex(0)
187
+
188
+ def _on_run_clicked(self) -> None:
189
+ if not self._param_keys:
190
+ QMessageBox.information(self, "Tracer-Tracer", "No mean travel time parameter found.")
191
+ return
192
+ start = float(self.sb_start.value())
193
+ stop = float(self.sb_stop.value())
194
+ if stop <= start:
195
+ QMessageBox.information(
196
+ self, "Tracer-Tracer", "Stop value must be greater than start value."
197
+ )
198
+ return
199
+ param_key = self.cb_param.currentData()
200
+ if not param_key:
201
+ QMessageBox.information(self, "Tracer-Tracer", "Select a parameter to sweep.")
202
+ return
203
+ count = int(self.sb_count.value())
204
+ self._is_running = True
205
+ self.btn_run.setEnabled(False)
206
+ self.sweep_requested.emit(start, stop, count, param_key)
207
+
208
+ def notify_sweep_finished(self) -> None:
209
+ """Re-enable sweep button after controller finished processing."""
210
+ self._is_running = False
211
+ self._update_enable_state()
212
+
213
+ def sweep_failed(self) -> None:
214
+ """Ensure sweep button is re-enabled after an error."""
215
+ self._is_running = False
216
+ self._update_enable_state()
217
+
218
+ def _on_date_changed(self, index: int) -> None:
219
+ self.btn_plot.setEnabled(
220
+ index >= 0 and index < len(self._date_indices) and self.state.tt_results is not None
221
+ )
222
+
223
+ def _update_enable_state(self) -> None:
224
+ has_two_tracers = self._has_dual_tracers()
225
+ has_param = bool(self._param_keys)
226
+ can_run = (
227
+ has_two_tracers
228
+ and has_param
229
+ and self.state.input_series is not None
230
+ and self.state.target_series is not None
231
+ )
232
+ self.cb_param.setEnabled(has_param)
233
+ self.btn_run.setEnabled(can_run and not self._is_running)
234
+ has_results = self.state.tt_results is not None and self.state.tt_mtt_values is not None
235
+ enable_plot = has_results and self.cb_date.currentIndex() >= 0 and self.cb_date.count() > 0
236
+ self.btn_plot.setEnabled(enable_plot)
237
+
238
+ def _has_dual_tracers(self) -> bool:
239
+ tracer2 = getattr(self.state, "tracer2", None)
240
+ if not tracer2:
241
+ return False
242
+ target = self.state.target_series
243
+ if target is None or target[1] is None:
244
+ return False
245
+ obs = np.asarray(target[1], dtype=float)
246
+ if obs.ndim == 1:
247
+ return False
248
+ return obs.shape[1] >= 2
249
+
250
+ def _format_timestamp(self, ts) -> str:
251
+ try:
252
+ if hasattr(ts, "strftime"):
253
+ fmt = "%Y-%m" if getattr(self.state, "is_monthly", True) else "%Y"
254
+ return ts.strftime(fmt)
255
+ except Exception:
256
+ pass
257
+ return str(ts)
258
+
259
+ def _plot(self) -> None:
260
+ if self.state.tt_results is None or self.state.tt_mtt_values is None:
261
+ QMessageBox.information(self, "Tracer-Tracer", "Run a sweep before plotting.")
262
+ return
263
+ if not self._date_indices or self.cb_date.currentIndex() < 0:
264
+ QMessageBox.information(self, "Tracer-Tracer", "Select an observation date.")
265
+ return
266
+
267
+ obs_idx = self._date_indices[self.cb_date.currentIndex()]
268
+ results = np.asarray(self.state.tt_results, dtype=float)
269
+ if results.shape[0] < 2:
270
+ QMessageBox.information(
271
+ self, "Tracer-Tracer", "Results must contain two tracers to plot."
272
+ )
273
+ return
274
+
275
+ x = results[0, :, obs_idx]
276
+ y = results[1, :, obs_idx]
277
+ mtt_values = np.asarray(self.state.tt_mtt_values, dtype=float)
278
+ scale = 12.0 # we always run the model in months so we always have to convert
279
+ mtt_years = mtt_values / scale
280
+
281
+ obs_vals = None
282
+ if self._observations is not None and len(self._observations) > obs_idx:
283
+ obs_array = np.asarray(self._observations, dtype=float)
284
+ if obs_array.ndim == 1:
285
+ obs_array = obs_array.reshape(-1, 1)
286
+ if obs_array.shape[0] > obs_idx and obs_array.shape[1] >= 2:
287
+ obs_vals = obs_array[obs_idx, :2]
288
+
289
+ fig, ax = plt.subplots(1, 1, figsize=(6, 6))
290
+ ax.plot(x, y, c="black", lw=2.0)
291
+ scatter = ax.scatter(x, y, c=mtt_years, edgecolor="k", s=60, zorder=10)
292
+
293
+ if obs_vals is not None and not np.isnan(obs_vals).any():
294
+ ax.scatter(
295
+ obs_vals[0],
296
+ obs_vals[1],
297
+ c="r",
298
+ edgecolor="k",
299
+ marker="X",
300
+ s=200,
301
+ zorder=20,
302
+ label="Observation",
303
+ )
304
+
305
+ ax.scatter([0.0], [0.0], c="k", edgecolor="k", marker="o", s=20, zorder=15, label="Origin")
306
+
307
+ step = max(1, len(mtt_values) // 10)
308
+ for idx in range(0, len(mtt_values), step):
309
+ label = "Binary Mixing" if idx == 0 else None
310
+ ax.plot([0.0, x[idx]], [0.0, y[idx]], c="k", lw=1.0, ls="--", alpha=0.3, label=label)
311
+
312
+ for frac in (0.75, 0.5, 0.25):
313
+ label = f"{int((1 - frac) * 100)}% Tracer-Free Water"
314
+ ax.plot(x * frac, y * frac, c="k", lw=1.0, alpha=0.8 * frac, label=label)
315
+
316
+ plt.colorbar(scatter, ax=ax, label="Mean residence time [years]")
317
+
318
+ tracer1 = getattr(self.state, "tracer1", "Tracer 1")
319
+ tracer2 = getattr(self.state, "tracer2", "Tracer 2") or "Tracer 2"
320
+ ax.set_xlabel(tracer1 or "Tracer 1")
321
+ ax.set_ylabel(tracer2)
322
+
323
+ if self._timestamps is not None and len(self._timestamps) > obs_idx:
324
+ ax.set_title(
325
+ f"Date of Observation: {self._format_timestamp(self._timestamps[obs_idx])}"
326
+ )
327
+
328
+ ax.legend()
329
+ ax.grid(True, alpha=0.2)
330
+ fig.tight_layout()
331
+ plt.show()
@@ -0,0 +1,106 @@
1
+ """Reusable GUI widgets used across tabs (parameter editors)."""
2
+
3
+ from PyQt5.QtCore import Qt
4
+ from PyQt5.QtGui import QDoubleValidator
5
+ from PyQt5.QtWidgets import QCheckBox, QGridLayout, QLabel, QLineEdit, QSizePolicy, QWidget
6
+
7
+
8
+ class ParameterEditor(QWidget):
9
+ """Composite editor for a single parameter.
10
+
11
+ Renders four controls for lower bound, value, upper bound, and a fixed
12
+ checkbox. The line edits are accessible via attributes ``lb``, ``val``,
13
+ and ``ub`` so that parent layouts can place them in a grid.
14
+
15
+ Parameters
16
+ ----------
17
+ prefix : str
18
+ Instance prefix used to namespace parameters (e.g., ``"epm1"``).
19
+ meta : dict
20
+ Parameter metadata with keys ``"key"``, ``"label"``, ``"bounds"``,
21
+ and ``"default"``.
22
+ initial : dict, optional
23
+ Optional initial record with keys ``"val"``, ``"lb"``, ``"ub"``, and
24
+ ``"fixed"``.
25
+
26
+ Notes
27
+ -----
28
+ The widget is self-contained but can also be embedded into an external
29
+ grid via its exposed sub-widgets.
30
+ """
31
+
32
+ def __init__(self, prefix: str, meta: dict, initial: dict | None = None, parent=None):
33
+ super().__init__(parent)
34
+ self.prefix = prefix
35
+ self.key = meta["key"]
36
+ lb, ub = meta["bounds"]
37
+ init = initial or {"val": meta["default"], "lb": lb, "ub": ub, "fixed": False}
38
+ if self.key == "exp_part":
39
+ init["fixed"] = True
40
+ if self.key == "piston_part":
41
+ init["fixed"] = True
42
+
43
+ # Validator: float, right-aligned entries
44
+ validator = QDoubleValidator(self)
45
+ validator.setNotation(QDoubleValidator.StandardNotation)
46
+ validator.setDecimals(12) # generous; UI can show fewer
47
+
48
+ # Consistent width hint so all rows look aligned
49
+ # (Let the external grid handle the final column width; we set a reasonable min.)
50
+ probe = QLineEdit()
51
+ fm = probe.fontMetrics()
52
+ minw = fm.horizontalAdvance("-1234.12")
53
+
54
+ grid = QGridLayout(self)
55
+ grid.setHorizontalSpacing(8)
56
+ grid.setVerticalSpacing(0)
57
+ grid.setContentsMargins(0, 0, 0, 0)
58
+
59
+ # Inline label (kept for backward-compat; external grids can ignore this widget)
60
+ title = QLabel(f"{prefix.upper()} — {meta.get('label', self.key)}")
61
+ title.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
62
+ grid.addWidget(title, 0, 0, alignment=Qt.AlignLeft)
63
+
64
+ # Editors
65
+ self.lb = QLineEdit(str(init["lb"]))
66
+ self.lb.setAlignment(Qt.AlignRight)
67
+ self.lb.setValidator(validator)
68
+ self.lb.setMinimumWidth(minw)
69
+ self.lb.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
70
+
71
+ self.val = QLineEdit(str(init["val"]))
72
+ self.val.setAlignment(Qt.AlignRight)
73
+ self.val.setValidator(validator)
74
+ self.val.setMinimumWidth(minw)
75
+ self.val.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
76
+
77
+ self.ub = QLineEdit(str(init["ub"]))
78
+ self.ub.setAlignment(Qt.AlignRight)
79
+ self.ub.setValidator(validator)
80
+ self.ub.setMinimumWidth(minw)
81
+ self.ub.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
82
+
83
+ self.fixed = QCheckBox("Fixed")
84
+ self.fixed.setChecked(bool(init.get("fixed", False)))
85
+
86
+ # Default internal layout (still works if someone adds ParameterEditor as a single widget)
87
+ # External table can ignore this and use .lb/.val/.ub directly.
88
+ grid.addWidget(self.lb, 0, 1)
89
+ grid.addWidget(self.val, 0, 2)
90
+ grid.addWidget(self.ub, 0, 3)
91
+ grid.addWidget(self.fixed, 0, 4)
92
+
93
+ def to_dict(self):
94
+ """Return the current editor state as a serializable dict."""
95
+
96
+ # Convert safely; fall back to current text->float conversion
97
+ def _f(edit: QLineEdit):
98
+ txt = edit.text().strip()
99
+ return float(txt) if txt else 0.0
100
+
101
+ return {
102
+ "val": _f(self.val),
103
+ "lb": _f(self.lb),
104
+ "ub": _f(self.ub),
105
+ "fixed": self.fixed.isChecked(),
106
+ }
@@ -0,0 +1,52 @@
1
+ """Public API for the :mod:`PyTracerLab.model` package."""
2
+
3
+ # we intentionally omit the Solver class from __all__ to avoid trouble
4
+ # with autodoc (through Solver we otherwise hava a duplication of Model)
5
+ __all__ = ["Model", "Unit", "EPMUnit", "EMUnit", "PMUnit", "DMUnit"]
6
+
7
+
8
+ def __getattr__(name):
9
+ """Lazy attribute-based re-exports for convenient imports.
10
+
11
+ This keeps import time light while still allowing patterns like
12
+ ``from PyTracerLab.model import Model``.
13
+ """
14
+ # Lazy re-exports so runtime imports still work:
15
+ if name == "Solver":
16
+ from .solver import Solver
17
+
18
+ return Solver
19
+ if name == "Model":
20
+ from .model import Model
21
+
22
+ return Model
23
+ if name == "Unit":
24
+ from .units import Unit
25
+
26
+ return Unit
27
+ if name == "EPMUnit":
28
+ from .units import EPMUnit
29
+
30
+ return EPMUnit
31
+ if name == "EMUnit":
32
+ from .units import EMUnit
33
+
34
+ return EMUnit
35
+ if name == "PMUnit":
36
+ from .units import PMUnit
37
+
38
+ return PMUnit
39
+ if name == "DMUnit":
40
+ from .units import DMUnit
41
+
42
+ return DMUnit
43
+ raise AttributeError(name)
44
+
45
+
46
+ def __dir__():
47
+ """Limit ``dir(PyTracerLab.model)`` to the public API listed in ``__all__``.
48
+
49
+ This avoids showing ``Solver`` at the package level in autodoc listings.
50
+ """
51
+ # Keep Solver out of dir() so autodoc doesn't list it at the package level
52
+ return sorted(__all__)