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