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