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,436 @@
|
|
|
1
|
+
"""Tab for frequency/tracer selection and CSV input loading."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from PyQt5.QtCore import QSize, pyqtSignal
|
|
8
|
+
from PyQt5.QtGui import QDoubleValidator
|
|
9
|
+
from PyQt5.QtWidgets import (
|
|
10
|
+
QAbstractItemView,
|
|
11
|
+
QButtonGroup,
|
|
12
|
+
QComboBox,
|
|
13
|
+
QDialog,
|
|
14
|
+
QDialogButtonBox,
|
|
15
|
+
QFileDialog,
|
|
16
|
+
QFormLayout,
|
|
17
|
+
QHBoxLayout,
|
|
18
|
+
QLabel,
|
|
19
|
+
QLineEdit,
|
|
20
|
+
QMessageBox,
|
|
21
|
+
QPushButton,
|
|
22
|
+
QRadioButton,
|
|
23
|
+
QTableWidget,
|
|
24
|
+
QTableWidgetItem,
|
|
25
|
+
QVBoxLayout,
|
|
26
|
+
QWidget,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from ..database import Tracers
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _current_tracer_names(state) -> List[str]:
|
|
33
|
+
"""Return tracer names selected in the shared state."""
|
|
34
|
+
names: List[str] = []
|
|
35
|
+
t1 = getattr(state, "tracer1", None)
|
|
36
|
+
if t1 and str(t1).lower() not in {"", "none"}:
|
|
37
|
+
names.append(t1)
|
|
38
|
+
t2 = getattr(state, "tracer2", None)
|
|
39
|
+
if t2 and str(t2).lower() not in {"", "none"}:
|
|
40
|
+
names.append(t2)
|
|
41
|
+
if not names:
|
|
42
|
+
t_fallback = getattr(state, "tracer", None)
|
|
43
|
+
if t_fallback:
|
|
44
|
+
names.append(t_fallback)
|
|
45
|
+
return names or ["Tracer 1"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _format_timestamp(ts: datetime, monthly: bool) -> str:
|
|
49
|
+
fmt = "%Y-%m" if monthly else "%Y"
|
|
50
|
+
return ts.strftime(fmt)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FileInputTab(QWidget):
|
|
54
|
+
"""Tab to choose frequency, tracer, and load input/observation CSV files.
|
|
55
|
+
|
|
56
|
+
Signals
|
|
57
|
+
-------
|
|
58
|
+
changed
|
|
59
|
+
Emitted whenever user selections or loaded files change.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# Define signals that this tab can emit
|
|
63
|
+
changed = pyqtSignal()
|
|
64
|
+
|
|
65
|
+
def __init__(self, state, parent=None):
|
|
66
|
+
"""Create the tab and wire UI controls to update ``state``."""
|
|
67
|
+
super().__init__(parent)
|
|
68
|
+
self.state = state
|
|
69
|
+
self._manual_target_active = False
|
|
70
|
+
lay = QVBoxLayout(self)
|
|
71
|
+
|
|
72
|
+
### Temporal reolution selection radio buttons
|
|
73
|
+
self.monthly = QRadioButton("Monthly")
|
|
74
|
+
self.monthly.setChecked(True)
|
|
75
|
+
self.yearly = QRadioButton("Yearly")
|
|
76
|
+
g1 = QButtonGroup(self)
|
|
77
|
+
g1.addButton(self.monthly)
|
|
78
|
+
g1.addButton(self.yearly)
|
|
79
|
+
# Set title, add widgets
|
|
80
|
+
lbl = QLabel("Frequency")
|
|
81
|
+
lbl.setStyleSheet("font-weight: 600;")
|
|
82
|
+
lay.addWidget(lbl)
|
|
83
|
+
lay.addWidget(self.monthly)
|
|
84
|
+
lay.addWidget(self.yearly)
|
|
85
|
+
|
|
86
|
+
### Tracer Selection dropdowns (dual-tracer)
|
|
87
|
+
lbl = QLabel("Tracers")
|
|
88
|
+
lbl.setStyleSheet("font-weight: 600;")
|
|
89
|
+
lay.addWidget(lbl)
|
|
90
|
+
self.cb_t1 = QComboBox()
|
|
91
|
+
self.cb_t1.setFixedSize(QSize(200, 20))
|
|
92
|
+
self.cb_t1.addItems(["None"] + list(Tracers.tracer_data.keys())) # use None initially
|
|
93
|
+
self.cb_t2 = QComboBox()
|
|
94
|
+
self.cb_t2.setFixedSize(QSize(200, 20))
|
|
95
|
+
self.cb_t2.addItems(
|
|
96
|
+
["None"] + list(Tracers.tracer_data.keys()) # use None to indicate no second tracer
|
|
97
|
+
) # optional second tracer
|
|
98
|
+
lay.addWidget(QLabel("Tracer 1"))
|
|
99
|
+
lay.addWidget(self.cb_t1)
|
|
100
|
+
lay.addWidget(QLabel("Tracer 2"))
|
|
101
|
+
lay.addWidget(self.cb_t2)
|
|
102
|
+
|
|
103
|
+
### Input and target series selection buttons
|
|
104
|
+
self.lbl_in = QLabel("No input series selected")
|
|
105
|
+
self.lbl_tg = QLabel("No observation series selected")
|
|
106
|
+
b_in = QPushButton("Select Input CSV")
|
|
107
|
+
b_in.setFixedSize(QSize(200, 40))
|
|
108
|
+
b_in.clicked.connect(self._open_input)
|
|
109
|
+
b_tg = QPushButton("Select Observation CSV")
|
|
110
|
+
b_tg.setFixedSize(QSize(200, 40))
|
|
111
|
+
b_tg.clicked.connect(self._open_target)
|
|
112
|
+
b_manual = QPushButton("Manual Observation Input")
|
|
113
|
+
b_manual.setFixedSize(QSize(200, 40))
|
|
114
|
+
b_manual.clicked.connect(self._open_manual_observations)
|
|
115
|
+
# Set title, add widgets
|
|
116
|
+
lbl = QLabel("Input and Observation Series")
|
|
117
|
+
lbl.setStyleSheet("font-weight: 600;")
|
|
118
|
+
lay.addWidget(lbl)
|
|
119
|
+
lay.addWidget(b_in)
|
|
120
|
+
lay.addWidget(self.lbl_in)
|
|
121
|
+
btn_row = QHBoxLayout()
|
|
122
|
+
btn_row.addWidget(b_tg)
|
|
123
|
+
btn_row.addWidget(b_manual)
|
|
124
|
+
btn_row.addStretch()
|
|
125
|
+
lay.addLayout(btn_row)
|
|
126
|
+
lay.addWidget(self.lbl_tg)
|
|
127
|
+
|
|
128
|
+
# Signal connections
|
|
129
|
+
self.monthly.toggled.connect(self._freq_changed)
|
|
130
|
+
self.cb_t1.currentTextChanged.connect(self._tracer_changed)
|
|
131
|
+
self.cb_t2.currentTextChanged.connect(self._tracer_changed)
|
|
132
|
+
|
|
133
|
+
def _freq_changed(self, checked):
|
|
134
|
+
self.state.is_monthly = checked
|
|
135
|
+
self._clear_manual_observations()
|
|
136
|
+
self.changed.emit()
|
|
137
|
+
|
|
138
|
+
def _tracer_changed(self):
|
|
139
|
+
self.state.tracer1 = self.cb_t1.currentText()
|
|
140
|
+
t2 = self.cb_t2.currentText()
|
|
141
|
+
self.state.tracer2 = None if t2 == "None" else t2
|
|
142
|
+
self._clear_manual_observations()
|
|
143
|
+
self.changed.emit()
|
|
144
|
+
|
|
145
|
+
def _read_csv(self, path, monthly=True):
|
|
146
|
+
"""Read a CSV with first column timestamps and remaining one or two tracer columns.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
path : str
|
|
151
|
+
File path to read.
|
|
152
|
+
monthly : bool
|
|
153
|
+
Interpret timestamp format as ``"%Y-%m"`` if ``True`` else ``"%Y"``.
|
|
154
|
+
|
|
155
|
+
Returns
|
|
156
|
+
-------
|
|
157
|
+
tuple(ndarray, ndarray)
|
|
158
|
+
Parsed datetimes and corresponding float matrix of shape (N, K).
|
|
159
|
+
"""
|
|
160
|
+
import csv
|
|
161
|
+
|
|
162
|
+
fmt = "%Y-%m" if monthly else "%Y"
|
|
163
|
+
times = []
|
|
164
|
+
rows: list[list[float]] = []
|
|
165
|
+
with open(path, "r", encoding="utf-8", newline="") as f:
|
|
166
|
+
# try reading with comma separator
|
|
167
|
+
try:
|
|
168
|
+
r = csv.reader(f, delimiter=";")
|
|
169
|
+
try:
|
|
170
|
+
r = csv.reader(f, delimiter=",")
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
173
|
+
except Exception:
|
|
174
|
+
# still continue
|
|
175
|
+
pass
|
|
176
|
+
# skip header
|
|
177
|
+
_ = next(r, None) # skip header
|
|
178
|
+
for rec in r:
|
|
179
|
+
if not rec:
|
|
180
|
+
continue
|
|
181
|
+
try:
|
|
182
|
+
t = datetime.strptime(rec[0].strip(), fmt)
|
|
183
|
+
except Exception:
|
|
184
|
+
# skip malformed timestamps
|
|
185
|
+
continue
|
|
186
|
+
times.append(t)
|
|
187
|
+
vals = []
|
|
188
|
+
for tok in rec[1:]:
|
|
189
|
+
tok = tok.strip()
|
|
190
|
+
if tok == "" or tok.lower() == "nan":
|
|
191
|
+
vals.append(np.nan)
|
|
192
|
+
else:
|
|
193
|
+
try:
|
|
194
|
+
# optionally replace commas with decimal points
|
|
195
|
+
if "," in tok:
|
|
196
|
+
tok = tok.replace(",", ".")
|
|
197
|
+
vals.append(float(tok))
|
|
198
|
+
except Exception:
|
|
199
|
+
vals.append(np.nan)
|
|
200
|
+
rows.append(vals)
|
|
201
|
+
if len(times) == 0:
|
|
202
|
+
return np.array([]), np.array([])
|
|
203
|
+
# Ensure at least one value column
|
|
204
|
+
max_cols = max((len(r) for r in rows), default=0)
|
|
205
|
+
if max_cols == 0:
|
|
206
|
+
vals_arr = np.full((len(times), 1), np.nan)
|
|
207
|
+
else:
|
|
208
|
+
# Pad rows to equal length
|
|
209
|
+
vals_arr = np.array([ri + [np.nan] * (max_cols - len(ri)) for ri in rows], dtype=float)
|
|
210
|
+
return np.array(times, dtype=object), vals_arr
|
|
211
|
+
|
|
212
|
+
def _clear_manual_observations(self) -> None:
|
|
213
|
+
"""Reset manual observations when context changes."""
|
|
214
|
+
if not self.state.manual_observations and not self._manual_target_active:
|
|
215
|
+
return
|
|
216
|
+
self.state.manual_observations.clear()
|
|
217
|
+
if self._manual_target_active:
|
|
218
|
+
self.state.target_series = None
|
|
219
|
+
self.lbl_tg.setText("No observation series selected")
|
|
220
|
+
self._manual_target_active = False
|
|
221
|
+
|
|
222
|
+
def _active_tracer_names(self) -> List[str]:
|
|
223
|
+
"""Return the currently selected tracers for manual observation input."""
|
|
224
|
+
return _current_tracer_names(self.state)
|
|
225
|
+
|
|
226
|
+
def _rebuild_manual_target_series(self) -> bool:
|
|
227
|
+
"""Convert manual observations into the ``state.target_series`` tuple."""
|
|
228
|
+
if not self.state.manual_observations:
|
|
229
|
+
self.state.target_series = None
|
|
230
|
+
self._manual_target_active = False
|
|
231
|
+
return False
|
|
232
|
+
if not self.state.input_series or len(self.state.input_series[0]) == 0:
|
|
233
|
+
return False
|
|
234
|
+
times = self.state.input_series[0]
|
|
235
|
+
tracer_names = self._active_tracer_names()
|
|
236
|
+
n_tracers = max(1, len(tracer_names))
|
|
237
|
+
obs = np.full((len(times), n_tracers), np.nan, dtype=float)
|
|
238
|
+
for idx, ts in enumerate(times):
|
|
239
|
+
vals = self.state.manual_observations.get(ts)
|
|
240
|
+
if not vals:
|
|
241
|
+
continue
|
|
242
|
+
for col in range(n_tracers):
|
|
243
|
+
if col < len(vals):
|
|
244
|
+
obs[idx, col] = float(vals[col])
|
|
245
|
+
self.state.target_series = (times.copy(), obs)
|
|
246
|
+
self._manual_target_active = True
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
def _manual_observations_updated(self) -> None:
|
|
250
|
+
"""Update labels/state when manual observations are changed."""
|
|
251
|
+
if not self.state.manual_observations:
|
|
252
|
+
self._clear_manual_observations()
|
|
253
|
+
self.changed.emit()
|
|
254
|
+
return
|
|
255
|
+
if not self._rebuild_manual_target_series():
|
|
256
|
+
QMessageBox.warning(self, "Missing Input Series", "Load an input series first.")
|
|
257
|
+
return
|
|
258
|
+
count = len(self.state.manual_observations)
|
|
259
|
+
label = f"Manual observations ({count} entries)"
|
|
260
|
+
self.lbl_tg.setText(label)
|
|
261
|
+
self.changed.emit()
|
|
262
|
+
|
|
263
|
+
def _open_manual_observations(self):
|
|
264
|
+
if not self.state.input_series or len(self.state.input_series[0]) == 0:
|
|
265
|
+
QMessageBox.warning(
|
|
266
|
+
self,
|
|
267
|
+
"No Input Series",
|
|
268
|
+
"Please load an input series before adding manual observations.",
|
|
269
|
+
)
|
|
270
|
+
return
|
|
271
|
+
dlg = ManualObservationDialog(self.state, self)
|
|
272
|
+
dlg.observations_changed.connect(self._manual_observations_updated)
|
|
273
|
+
dlg.exec_()
|
|
274
|
+
|
|
275
|
+
def _open_input(self):
|
|
276
|
+
file, _ = QFileDialog.getOpenFileName(
|
|
277
|
+
self, "Open Input Series CSV", "", "CSV Files (*.csv)"
|
|
278
|
+
)
|
|
279
|
+
if file:
|
|
280
|
+
self.state.input_series = self._read_csv(file, self.state.is_monthly)
|
|
281
|
+
self._clear_manual_observations()
|
|
282
|
+
shape = self.state.input_series[1].shape if self.state.input_series else None
|
|
283
|
+
cols = shape[1] if (shape is not None and len(shape) == 2) else 1
|
|
284
|
+
self.lbl_in.setText(f"Loaded: {file} (columns: {cols})")
|
|
285
|
+
self.changed.emit()
|
|
286
|
+
|
|
287
|
+
def _open_target(self):
|
|
288
|
+
file, _ = QFileDialog.getOpenFileName(
|
|
289
|
+
self, "Open Observation Series CSV", "", "CSV Files (*.csv)"
|
|
290
|
+
)
|
|
291
|
+
if file:
|
|
292
|
+
self.state.target_series = self._read_csv(file, self.state.is_monthly)
|
|
293
|
+
self.state.manual_observations.clear()
|
|
294
|
+
self._manual_target_active = False
|
|
295
|
+
shape = self.state.target_series[1].shape if self.state.target_series else None
|
|
296
|
+
cols = shape[1] if (shape is not None and len(shape) == 2) else 1
|
|
297
|
+
self.lbl_tg.setText(f"Loaded: {file} (columns: {cols})")
|
|
298
|
+
self.changed.emit()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class ManualObservationDialog(QDialog):
|
|
302
|
+
"""Dialog for managing manual observation entries."""
|
|
303
|
+
|
|
304
|
+
observations_changed = pyqtSignal()
|
|
305
|
+
|
|
306
|
+
def __init__(self, state, parent=None):
|
|
307
|
+
super().__init__(parent)
|
|
308
|
+
self.setWindowTitle("Manual Observation Input")
|
|
309
|
+
self.state = state
|
|
310
|
+
self._monthly = getattr(state, "is_monthly", True)
|
|
311
|
+
self._timestamps = list(state.input_series[0]) if state.input_series else []
|
|
312
|
+
self._tracer_names = _current_tracer_names(state)
|
|
313
|
+
|
|
314
|
+
layout = QVBoxLayout(self)
|
|
315
|
+
|
|
316
|
+
headers = ["Date"] + self._tracer_names
|
|
317
|
+
self._table = QTableWidget(0, len(headers))
|
|
318
|
+
self._table.setHorizontalHeaderLabels(headers)
|
|
319
|
+
self._table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
320
|
+
self._table.setSelectionMode(QAbstractItemView.NoSelection)
|
|
321
|
+
self._table.setAlternatingRowColors(True)
|
|
322
|
+
self._table.verticalHeader().setVisible(False)
|
|
323
|
+
self._table.horizontalHeader().setStretchLastSection(True)
|
|
324
|
+
layout.addWidget(self._table)
|
|
325
|
+
|
|
326
|
+
btn_row = QHBoxLayout()
|
|
327
|
+
add_btn = QPushButton("Add Observation")
|
|
328
|
+
add_btn.clicked.connect(self._add_observation)
|
|
329
|
+
btn_row.addWidget(add_btn)
|
|
330
|
+
btn_row.addStretch()
|
|
331
|
+
layout.addLayout(btn_row)
|
|
332
|
+
|
|
333
|
+
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
|
334
|
+
buttons.rejected.connect(self.reject)
|
|
335
|
+
layout.addWidget(buttons)
|
|
336
|
+
|
|
337
|
+
self._refresh_table()
|
|
338
|
+
|
|
339
|
+
def _refresh_table(self) -> None:
|
|
340
|
+
manual = self.state.manual_observations
|
|
341
|
+
rows = sorted(
|
|
342
|
+
((ts, manual[ts]) for ts in manual if ts in self._timestamps),
|
|
343
|
+
key=lambda item: item[0],
|
|
344
|
+
)
|
|
345
|
+
self._table.setRowCount(len(rows))
|
|
346
|
+
for row_idx, (ts, values) in enumerate(rows):
|
|
347
|
+
date_item = QTableWidgetItem(_format_timestamp(ts, self._monthly))
|
|
348
|
+
self._table.setItem(row_idx, 0, date_item)
|
|
349
|
+
for col_idx, tracer in enumerate(self._tracer_names, start=1):
|
|
350
|
+
val = values[col_idx - 1] if col_idx - 1 < len(values) else np.nan
|
|
351
|
+
if val is None or (isinstance(val, float) and np.isnan(val)):
|
|
352
|
+
text = ""
|
|
353
|
+
else:
|
|
354
|
+
text = f"{float(val):g}"
|
|
355
|
+
item = QTableWidgetItem(text)
|
|
356
|
+
self._table.setItem(row_idx, col_idx, item)
|
|
357
|
+
|
|
358
|
+
def _add_observation(self) -> None:
|
|
359
|
+
dlg = ObservationEntryDialog(self._timestamps, self._tracer_names, self._monthly, self)
|
|
360
|
+
if dlg.exec_() != QDialog.Accepted:
|
|
361
|
+
return
|
|
362
|
+
timestamp, values = dlg.result()
|
|
363
|
+
self.state.manual_observations[timestamp] = values
|
|
364
|
+
self._refresh_table()
|
|
365
|
+
self.observations_changed.emit()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class ObservationEntryDialog(QDialog):
|
|
369
|
+
"""Dialog that collects a timestamp and tracer concentrations from the user."""
|
|
370
|
+
|
|
371
|
+
def __init__(self, timestamps, tracer_names, monthly, parent=None):
|
|
372
|
+
super().__init__(parent)
|
|
373
|
+
self.setWindowTitle("Add Observation")
|
|
374
|
+
self._timestamps = timestamps
|
|
375
|
+
self._monthly = monthly
|
|
376
|
+
self._tracer_names = tracer_names
|
|
377
|
+
self._values: List[float] = []
|
|
378
|
+
self._selected_timestamp: Optional[datetime] = None
|
|
379
|
+
|
|
380
|
+
layout = QVBoxLayout(self)
|
|
381
|
+
form = QFormLayout()
|
|
382
|
+
|
|
383
|
+
self._timestamp_box = QComboBox()
|
|
384
|
+
for ts in self._timestamps:
|
|
385
|
+
self._timestamp_box.addItem(_format_timestamp(ts, self._monthly), ts)
|
|
386
|
+
form.addRow("Timestamp", self._timestamp_box)
|
|
387
|
+
|
|
388
|
+
self._value_edits: List[QLineEdit] = []
|
|
389
|
+
validator = QDoubleValidator(self)
|
|
390
|
+
validator.setNotation(QDoubleValidator.StandardNotation)
|
|
391
|
+
validator.setRange(-1e100, 1e100, 12)
|
|
392
|
+
for name in self._tracer_names:
|
|
393
|
+
line = QLineEdit()
|
|
394
|
+
line.setValidator(validator)
|
|
395
|
+
line.setPlaceholderText("Enter concentration")
|
|
396
|
+
form.addRow(name, line)
|
|
397
|
+
self._value_edits.append(line)
|
|
398
|
+
|
|
399
|
+
layout.addLayout(form)
|
|
400
|
+
|
|
401
|
+
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
|
402
|
+
buttons.accepted.connect(self._on_accept)
|
|
403
|
+
buttons.rejected.connect(self.reject)
|
|
404
|
+
layout.addWidget(buttons)
|
|
405
|
+
|
|
406
|
+
def _on_accept(self) -> None:
|
|
407
|
+
if not self._timestamps:
|
|
408
|
+
QMessageBox.warning(self, "No Timestamps", "No timestamps available for input.")
|
|
409
|
+
return
|
|
410
|
+
values: List[float] = []
|
|
411
|
+
for edit, tracer_name in zip(self._value_edits, self._tracer_names):
|
|
412
|
+
text = edit.text().strip()
|
|
413
|
+
if text == "":
|
|
414
|
+
QMessageBox.warning(
|
|
415
|
+
self,
|
|
416
|
+
"Missing Value",
|
|
417
|
+
f"Please enter a concentration for {tracer_name}.",
|
|
418
|
+
)
|
|
419
|
+
return
|
|
420
|
+
try:
|
|
421
|
+
values.append(float(text))
|
|
422
|
+
except ValueError:
|
|
423
|
+
QMessageBox.warning(
|
|
424
|
+
self,
|
|
425
|
+
"Invalid Value",
|
|
426
|
+
f"Could not parse the value for {tracer_name}.",
|
|
427
|
+
)
|
|
428
|
+
return
|
|
429
|
+
self._values = values
|
|
430
|
+
self._selected_timestamp = self._timestamp_box.currentData()
|
|
431
|
+
self.accept()
|
|
432
|
+
|
|
433
|
+
def result(self) -> Tuple[datetime, List[float]]:
|
|
434
|
+
if self._selected_timestamp is None:
|
|
435
|
+
raise RuntimeError("Dialog accepted without selecting a timestamp.")
|
|
436
|
+
return self._selected_timestamp, self._values
|