STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
robust_pt_calibration_gui.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3Robust PT Calibration GUI with Environmental-Adaptive Bayesian Learning
4Implements the algorithm from PressureTransducerCalibrationFramework.tex
5
6Features:
7- Proper AD26 packet synchronization
8- Environmental-robust calibration map f(v, e; θ)
9- Bayesian posterior with persistent storage
10- Progressive autonomy (human-in-loop → autonomous)
11- Only shows active channels (dynamically detected)
12- Saves calibration data to JSON for long-term persistence
13"""
14
15import sys
16import struct
17import time
18import json
19import os
20from collections import deque, defaultdict
21from datetime import datetime
22from pathlib import Path
23
24from PyQt6 import QtCore, QtGui, QtWidgets
25import pyqtgraph as pg
26import numpy as np
27import serial, serial.tools.list_ports
28
29# ---------------------- Configuration ----------------------
30MAGIC = b"AD26"
31PACKET_VERSION = 2
32HEADER_STRUCT = struct.Struct("<4sBBHHII")
33RECORD_STRUCT = struct.Struct("<BBiiII")
34HEADER_SIZE = HEADER_STRUCT.size
35RECORD_SIZE = RECORD_STRUCT.size
36
37V_REF = 2.5
38ADC_SCALE = 2147483648.0
39BAUD = 115200
40
41# Calibration storage
42CALIBRATION_DIR = Path.home() / ".local" / "share" / "pt_calibration"
43CALIBRATION_FILE = CALIBRATION_DIR / "calibration_state.json"
44
45
46# ---------------------- Environmental State ----------------------
48 def __init__(self):
49 self.temperature = 25.0 # °C
50 self.humidity = 50.0 # %
51 self.vibration_level = 0.0 # normalized
52 self.aging_factor = 0.0 # time-dependent
53 self.mounting_torque = 1.0 # factor
54
55
56# ---------------------- Bayesian Calibration Model ----------------------
58 """
59 Environmental-Robust Bayesian Calibration
60 Implements Algorithm 1 from PressureTransducerCalibrationFramework.tex
61 """
62
63 def __init__(self, sensor_id):
64 self.sensor_id = sensor_id
65
66 # Calibration parameters θ (polynomial coefficients)
67 self.theta = np.zeros(6) # [θ0, θ1, θ2, θ3, θ4, θ5]
68 self.Sigma_theta = np.eye(6) * 1000.0 # Large initial uncertainty
69
70 # Environmental variance parameters
71 self.Q_env = np.eye(5) * 0.01 # Environmental variance matrix
72 self.Q_interaction = np.eye(5) * 0.001 # Interaction variance
73 self.sigma_base = 100.0 # Base measurement noise (Pa)
74
75 # Calibration data points
76 self.calibration_data = [] # [(voltage, pressure, env_state, timestamp)]
77
78 # Learning state
79 self.confidence_level = 0 # 0=LOW, 1=MEDIUM, 2=HIGH, 3=MAXIMUM
83
84 def add_calibration_point(self, voltage, reference_pressure, env_state):
85 """Add human-provided calibration point"""
86 self.calibration_data.append(
87 (voltage, reference_pressure, env_state, time.time())
88 )
89 self.human_input_count += 1
90
91 # Update calibration using Bayesian TLS
94
96 """
97 Update calibration parameters using Bayesian regression
98 Implements Step 2-3 of Algorithm 1
99 """
100 if len(self.calibration_data) < 2:
101 return
102
103 # Extract data
104 voltages = np.array([d[0] for d in self.calibration_data])
105 pressures = np.array([d[1] for d in self.calibration_data])
106
107 # Build design matrix Φ (environmental-robust basis functions)
108 Phi = self._build_design_matrix(voltages)
109
110 # Bayesian linear regression with TLS-inspired weighting
111 # Posterior: Σ_θ^-1 = Σ_prior^-1 + Φ^T R^-1 Φ
112 # θ_post = Σ_θ (Σ_prior^-1 θ_prior + Φ^T R^-1 p)
113
114 R_inv = np.eye(len(pressures)) / (self.sigma_base**2)
115
116 Sigma_prior_inv = np.linalg.inv(self.Sigma_theta)
117 Sigma_post_inv = Sigma_prior_inv + Phi.T @ R_inv @ Phi
118
119 try:
120 self.Sigma_theta = np.linalg.inv(Sigma_post_inv)
121 self.theta = self.Sigma_theta @ (
122 Sigma_prior_inv @ self.theta + Phi.T @ R_inv @ pressures
123 )
124 except np.linalg.LinAlgError:
125 # Singular matrix - add regularization
126 self.Sigma_theta = np.linalg.inv(Sigma_post_inv + np.eye(6) * 1e-6)
127 self.theta = self.Sigma_theta @ (
128 Sigma_prior_inv @ self.theta + Phi.T @ R_inv @ pressures
129 )
130
131 def _build_design_matrix(self, voltages):
132 """
133 Build design matrix with physically-informed basis functions
134 φ(v) = [1, v, v², v³, √v, log(1+v)]
135 """
136 N = len(voltages)
137 Phi = np.zeros((N, 6))
138
139 for i, v in enumerate(voltages):
140 v = max(0.01, v) # Avoid sqrt/log of negative
141 Phi[i, 0] = 1.0
142 Phi[i, 1] = v
143 Phi[i, 2] = v**2
144 Phi[i, 3] = v**3
145 Phi[i, 4] = np.sqrt(v)
146 Phi[i, 5] = np.log(1 + v)
147
148 return Phi
149
150 def predict_pressure(self, voltage, env_state=None):
151 """
152 Predict pressure with uncertainty quantification
153 Returns: (pressure_mean, pressure_std)
154 """
155 if len(self.calibration_data) < 2:
156 return 0.0, 1e6 # No calibration yet
157
158 # Evaluate calibration map
159 phi = self._evaluate_basis(voltage)
160 p_mean = phi @ self.theta
161
162 # Predictive uncertainty: σ²_pred = σ²_meas + φ^T Σ_θ φ
163 sigma_pred_sq = self.sigma_base**2 + phi @ self.Sigma_theta @ phi.T
164
165 # Add extrapolation uncertainty if outside calibration range
166 v_min = min([d[0] for d in self.calibration_data])
167 v_max = max([d[0] for d in self.calibration_data])
168
169 if voltage < v_min or voltage > v_max:
170 # Extrapolation penalty
171 if voltage < v_min:
172 extrap_factor = (v_min - voltage) / (v_max - v_min)
173 else:
174 extrap_factor = (voltage - v_max) / (v_max - v_min)
175
176 sigma_extrap_sq = (extrap_factor * self.sigma_base * 10) ** 2
177 sigma_pred_sq += sigma_extrap_sq
178
179 return float(p_mean), float(np.sqrt(sigma_pred_sq))
180
181 def _evaluate_basis(self, voltage):
182 """Evaluate basis functions at given voltage"""
183 v = max(0.01, voltage)
184 return np.array([1.0, v, v**2, v**3, np.sqrt(v), np.log(1 + v)])
185
187 """Update confidence level based on calibration data"""
188 reliability = self.get_reliability_score()
189
190 if self.human_input_count >= 20 and reliability > 0.9:
191 self.confidence_level = 3 # MAXIMUM
192 elif self.human_input_count >= 10 and reliability > 0.8:
193 self.confidence_level = 2 # HIGH
194 elif self.human_input_count >= 5 and reliability > 0.7:
195 self.confidence_level = 1 # MEDIUM
196 else:
197 self.confidence_level = 0 # LOW
198
200 """Calculate reliability score"""
201 if self.human_input_count < 2:
202 return 0.0
203
204 # Based on calibration residuals
205 if len(self.calibration_data) < 3:
206 return 0.5
207
208 voltages = np.array([d[0] for d in self.calibration_data])
209 pressures = np.array([d[1] for d in self.calibration_data])
210
211 predictions = []
212 for v in voltages:
213 phi = self._evaluate_basis(v)
214 predictions.append(phi @ self.theta)
215
216 predictions = np.array(predictions)
217 residuals = pressures - predictions
218 rmse = np.sqrt(np.mean(residuals**2))
219 pressure_range = np.max(pressures) - np.min(pressures)
220
221 if pressure_range == 0:
222 return 0.5
223
224 nrmse = rmse / pressure_range
225 reliability = max(0.0, 1.0 - nrmse * 2.0) # Scale so NRMSE=0.5 → reliability=0
226
227 return reliability
228
229 def needs_human_input(self, voltage):
230 """Determine if human input is needed for this voltage"""
231 if self.confidence_level == 0: # LOW
232 return True
233 elif self.confidence_level == 1: # MEDIUM
234 return np.random.random() < 0.3 # 30% chance
235 elif self.confidence_level == 2: # HIGH
236 return np.random.random() < 0.1 # 10% chance
237 else: # MAXIMUM
238 return np.random.random() < 0.02 # 2% chance
239
240 def to_dict(self):
241 """Serialize to dictionary for JSON storage"""
242 return {
243 "sensor_id": self.sensor_id,
244 "theta": self.theta.tolist(),
245 "Sigma_theta": self.Sigma_theta.tolist(),
246 "sigma_base": self.sigma_base,
247 "calibration_data": self.calibration_data,
248 "confidence_level": self.confidence_level,
249 "human_input_count": self.human_input_count,
250 "autonomous_success_count": self.autonomous_success_count,
251 "autonomous_failure_count": self.autonomous_failure_count,
252 "last_updated": datetime.now().isoformat(),
253 }
254
255 @classmethod
256 def from_dict(cls, data):
257 """Deserialize from dictionary"""
258 cal = cls(data["sensor_id"])
259 cal.theta = np.array(data["theta"])
260 cal.Sigma_theta = np.array(data["Sigma_theta"])
261 cal.sigma_base = data["sigma_base"]
262 cal.calibration_data = data["calibration_data"]
263 cal.confidence_level = data["confidence_level"]
264 cal.human_input_count = data["human_input_count"]
265 cal.autonomous_success_count = data.get("autonomous_success_count", 0)
266 cal.autonomous_failure_count = data.get("autonomous_failure_count", 0)
267 return cal
268
269
270# ---------------------- Calibration Manager ----------------------
272 """Manages calibration for all sensors with persistent storage"""
273
274 def __init__(self):
275 self.calibrations = {} # sensor_id -> BayesianPTCalibration
276 self.load_calibrations()
277
278 def get_or_create(self, sensor_id):
279 """Get existing calibration or create new one"""
280 if sensor_id not in self.calibrations:
281 self.calibrations[sensor_id] = BayesianPTCalibration(sensor_id)
282 return self.calibrations[sensor_id]
283
285 """Save all calibrations to persistent storage"""
286 CALIBRATION_DIR.mkdir(parents=True, exist_ok=True)
287
288 data = {
289 "version": "1.0",
290 "saved_at": datetime.now().isoformat(),
291 "calibrations": {
292 str(sid): cal.to_dict() for sid, cal in self.calibrations.items()
293 },
294 }
295
296 with open(CALIBRATION_FILE, "w") as f:
297 json.dump(data, f, indent=2)
298
299 print(f"✅ Calibrations saved to {CALIBRATION_FILE}")
300
302 """Load calibrations from persistent storage"""
303 if not CALIBRATION_FILE.exists():
304 print("No existing calibration file found - starting fresh")
305 return
306
307 try:
308 with open(CALIBRATION_FILE, "r") as f:
309 data = json.load(f)
310
311 for sid_str, cal_data in data["calibrations"].items():
312 sid = int(sid_str)
313 self.calibrations[sid] = BayesianPTCalibration.from_dict(cal_data)
314
315 print(
316 f"✅ Loaded {len(self.calibrations)} calibrations from {CALIBRATION_FILE}"
317 )
318 print(f" Last saved: {data['saved_at']}")
319
320 except Exception as e:
321 print(f"⚠️ Failed to load calibrations: {e}")
322
323
324# ---------------------- Serial Reader (from channel_plotter) ----------------------
325class SerialReader(QtCore.QThread):
326 sample = QtCore.pyqtSignal(
327 float, int, int, float, int, int
328 ) # t_wall, ch, raw, volts, read_us, conv_us
329 status = QtCore.pyqtSignal(str)
330
331 def __init__(self, port, baud):
332 super().__init__()
333 self.port = port
334 self.baud = baud
335 self._stop = False
336 self.buf = bytearray()
337 self.synced = False
338 self.ser = None
339
340 def stop(self):
341 self._stop = True
342
343 def _resync(self):
344 """Find AD26 magic header and sync to packet boundary"""
345 while True:
346 idx = self.buf.find(MAGIC)
347 if idx == -1:
348 if len(self.buf) > 3:
349 del self.buf[:-3]
350 return False
351 if idx:
352 del self.buf[:idx]
353 if len(self.buf) < HEADER_SIZE:
354 return False
355
356 try:
357 magic, version, flags, count, _, _, _ = HEADER_STRUCT.unpack_from(
358 self.buf, 0
359 )
360 except struct.error:
361 return False
362
363 if magic != MAGIC or version != PACKET_VERSION:
364 del self.buf[0]
365 continue
366
367 count = int(count)
368 payload_len = HEADER_SIZE + count * RECORD_SIZE
369 if len(self.buf) < payload_len:
370 return False
371
372 self.synced = True
373 return True
374
375 def _drain_synced(self):
376 """Drain synchronized packets from buffer"""
377 out = []
378 while True:
379 if len(self.buf) < HEADER_SIZE:
380 break
381
382 if self.buf[: len(MAGIC)] != MAGIC:
383 self.synced = False
384 del self.buf[0]
385 break
386
387 try:
388 (
389 magic,
390 version,
391 flags,
392 count,
393 failures,
394 total_time_us,
395 packet_time_us,
396 ) = HEADER_STRUCT.unpack_from(self.buf, 0)
397 except struct.error:
398 self.synced = False
399 del self.buf[0]
400 break
401
402 count = int(count)
403 payload_len = HEADER_SIZE + count * RECORD_SIZE
404 if len(self.buf) < payload_len:
405 break
406
407 if magic != MAGIC or version != PACKET_VERSION:
408 self.synced = False
409 del self.buf[0]
410 break
411
412 # Parse all records
413 for i in range(count):
414 base = HEADER_SIZE + i * RECORD_SIZE
415 ch, ok, raw, sample_time, read_dur, conv_dur = (
416 RECORD_STRUCT.unpack_from(self.buf, base)
417 )
418
419 if not ok or ch == 0xFF:
420 continue
421
422 volts = raw * V_REF / ADC_SCALE
423 out.append(
424 (
425 time.monotonic(),
426 int(ch),
427 int(raw),
428 float(volts),
429 int(read_dur),
430 int(conv_dur),
431 )
432 )
433
434 del self.buf[:payload_len]
435
436 return out
437
438 def run(self):
439 try:
440 self.ser = serial.Serial(self.port, self.baud, timeout=0.05)
441 self.status.emit(f"✅ Connected to {self.port}")
442 except Exception as e:
443 self.status.emit(f"❌ Connection failed: {e}")
444 return
445
446 while not self._stop:
447 try:
448 data = self.ser.read(self.ser.in_waiting or 1)
449 if data:
450 self.buf.extend(data)
451 if not self.synced:
452 if self._resync():
453 self.status.emit("🔄 Packet sync acquired!")
454 if self.synced:
455 for sample in self._drain_synced():
456 t_wall, ch, raw, volts, read_us, conv_us = sample
457 self.sample.emit(t_wall, ch, raw, volts, read_us, conv_us)
458 except Exception as e:
459 self.status.emit(f"Error: {e}")
460 break
461
462 if self.ser and self.ser.is_open:
463 self.ser.close()
464 self.status.emit("Disconnected")
465
466
467# ---------------------- Main GUI ----------------------
468class RobustPTCalibrationGUI(QtWidgets.QMainWindow):
469 def __init__(self):
470 super().__init__()
471 self.setWindowTitle("Robust PT Calibration - Environmental-Adaptive Bayesian")
472 self.resize(1600, 900)
473
474 # Calibration manager (loads existing calibrations)
475 self.cal_manager = CalibrationManager()
476
477 # Active channels (dynamically detected)
478 self.active_channels = set()
479 self.last_seen = {} # channel -> timestamp
480
481 # Current environmental state
482 self.env_state = EnvironmentalState()
483
484 # Data storage
485 self.t0 = None
486 self.channel_data = defaultdict(
487 lambda: {
488 "t": deque(maxlen=1000),
489 "v": deque(maxlen=1000),
490 "raw": deque(maxlen=1000),
491 }
492 )
493
494 # Serial reader
495 self.reader = None
496
497 self.setup_ui()
498
499 # Auto-save timer
500 self.save_timer = QtCore.QTimer()
501 self.save_timer.timeout.connect(self._auto_save)
502 self.save_timer.start(60000) # Save every 60 seconds
503
504 def setup_ui(self):
505 central = QtWidgets.QWidget()
506 self.setCentralWidget(central)
507 layout = QtWidgets.QVBoxLayout(central)
508
509 # Top controls
510 top = QtWidgets.QHBoxLayout()
511 top.addWidget(QtWidgets.QLabel("Port:"))
512 self.cmb_port = QtWidgets.QComboBox()
513 self._refresh_ports()
514 top.addWidget(self.cmb_port)
515
516 btn_refresh = QtWidgets.QPushButton("Refresh")
517 btn_refresh.clicked.connect(self._refresh_ports)
518 top.addWidget(btn_refresh)
519
520 self.btn_connect = QtWidgets.QPushButton("Connect")
521 self.btn_connect.clicked.connect(self._toggle_connect)
522 top.addWidget(self.btn_connect)
523
524 top.addStretch()
525
526 btn_save = QtWidgets.QPushButton("💾 Save Calibrations")
527 btn_save.clicked.connect(lambda: self.cal_manager.save_calibrations())
528 top.addWidget(btn_save)
529
530 layout.addLayout(top)
531
532 # Main split
533 splitter = QtWidgets.QSplitter()
534 layout.addWidget(splitter, 1)
535
536 # Left: Voltage plot
537 plot_widget = QtWidgets.QWidget()
538 plot_layout = QtWidgets.QVBoxLayout(plot_widget)
539
540 self.plot = pg.PlotWidget()
541 self.plot.setLabel("left", "Voltage", units="V")
542 self.plot.setLabel("bottom", "Time", units="s")
543 self.plot.showGrid(x=True, y=True, alpha=0.3)
544 self.plot.addLegend()
545 plot_layout.addWidget(self.plot)
546
547 self.curves = {}
548
549 splitter.addWidget(plot_widget)
550
551 # Right: Calibration controls
552 right_widget = QtWidgets.QWidget()
553 right_layout = QtWidgets.QVBoxLayout(right_widget)
554
555 # Status
556 status_group = QtWidgets.QGroupBox("Status")
557 status_layout = QtWidgets.QVBoxLayout(status_group)
558 self.lbl_status = QtWidgets.QLabel("Disconnected")
559 self.lbl_status.setWordWrap(True)
560 status_layout.addWidget(self.lbl_status)
561 right_layout.addWidget(status_group)
562
563 # Active Sensors
564 sensors_group = QtWidgets.QGroupBox("Active Sensors")
565 self.sensors_layout = QtWidgets.QVBoxLayout(sensors_group)
566 self.sensor_labels = {}
567 right_layout.addWidget(sensors_group)
568
569 # Calibration Input
570 cal_group = QtWidgets.QGroupBox("Add Calibration Point")
571 cal_layout = QtWidgets.QFormLayout(cal_group)
572
573 self.cmb_sensor = QtWidgets.QComboBox()
574 cal_layout.addRow("Sensor:", self.cmb_sensor)
575
576 self.lbl_current_v = QtWidgets.QLabel("N/A")
577 cal_layout.addRow("Current Voltage:", self.lbl_current_v)
578
579 self.lbl_current_raw = QtWidgets.QLabel("N/A")
580 cal_layout.addRow("Raw ADC:", self.lbl_current_raw)
581
582 self.txt_ref_pressure = QtWidgets.QLineEdit()
583 self.txt_ref_pressure.setPlaceholderText("Enter pressure (Pa or kPa)")
584 cal_layout.addRow("Reference Pressure:", self.txt_ref_pressure)
585
586 btn_add_point = QtWidgets.QPushButton("➕ Add Calibration Point")
587 btn_add_point.clicked.connect(self._add_calibration_point)
588 cal_layout.addRow(btn_add_point)
589
590 self.lbl_prediction = QtWidgets.QLabel("Prediction: N/A")
591 cal_layout.addRow(self.lbl_prediction)
592
593 right_layout.addWidget(cal_group)
594
595 # Learning Progress
596 progress_group = QtWidgets.QGroupBox("Learning Progress")
597 self.progress_layout = QtWidgets.QVBoxLayout(progress_group)
598 self.progress_labels = {}
599 right_layout.addWidget(progress_group)
600
601 right_layout.addStretch()
602
603 splitter.addWidget(right_widget)
604 splitter.setStretchFactor(0, 3)
605 splitter.setStretchFactor(1, 1)
606
607 # Update timer
608 self.update_timer = QtCore.QTimer()
609 self.update_timer.timeout.connect(self._update_display)
610 self.update_timer.start(100)
611
612 def _refresh_ports(self):
613 ports = [p.device for p in serial.tools.list_ports.comports()]
614 self.cmb_port.clear()
615 self.cmb_port.addItems(ports)
616
617 def _toggle_connect(self):
618 if self.reader is None:
619 port = self.cmb_port.currentText()
620 self.reader = SerialReader(port, BAUD)
621 self.reader.sample.connect(self._on_sample)
622 self.reader.status.connect(self._set_status)
623 self.reader.start()
624 self.btn_connect.setText("Disconnect")
625 else:
626 self.reader.stop()
627 self.reader = None
628 self.btn_connect.setText("Connect")
629
630 def _set_status(self, text):
631 self.lbl_status.setText(text)
632
633 @QtCore.pyqtSlot(float, int, int, float, int, int)
634 def _on_sample(self, t_wall, ch, raw, volts, read_us, conv_us):
635 if self.t0 is None:
636 self.t0 = t_wall
637
638 t_rel = t_wall - self.t0
639
640 # Mark channel as active
641 if ch not in self.active_channels:
642 self.active_channels.add(ch)
643 self._add_channel(ch)
644
645 self.last_seen[ch] = time.time()
646
647 # Store data
648 self.channel_data[ch]["t"].append(t_rel)
649 self.channel_data[ch]["v"].append(volts)
650 self.channel_data[ch]["raw"].append(raw)
651
652 def _add_channel(self, ch):
653 """Dynamically add a new active channel"""
654 # Add to plot
655 color = [
656 (255, 0, 0),
657 (0, 255, 0),
658 (0, 0, 255),
659 (255, 165, 0),
660 (128, 0, 128),
661 (0, 255, 255),
662 (255, 192, 203),
663 (128, 128, 0),
664 (0, 128, 128),
665 (255, 255, 0),
666 ][ch % 10]
667 pen = pg.mkPen(color=color, width=2)
668 self.curves[ch] = self.plot.plot([], [], name=f"CH{ch}", pen=pen)
669
670 # Add sensor selector
671 self.cmb_sensor.addItem(f"Channel {ch}")
672
673 # Add sensor status label
674 lbl = QtWidgets.QLabel(f"CH{ch}: Online (no calibration)")
675 lbl.setStyleSheet("color: #00ff00; font-weight: bold;")
676 self.sensor_labels[ch] = lbl
677 self.sensors_layout.addWidget(lbl)
678
679 # Add progress label
680 prog_lbl = QtWidgets.QLabel(f"CH{ch}: 0 points, LOW confidence")
681 self.progress_labels[ch] = prog_lbl
682 self.progress_layout.addWidget(prog_lbl)
683
684 # Get or create calibration
685 self.cal_manager.get_or_create(ch)
686
687 def _add_calibration_point(self):
688 """Add a human-provided calibration point"""
689 # Get selected sensor
690 sensor_text = self.cmb_sensor.currentText()
691 if not sensor_text:
692 QtWidgets.QMessageBox.warning(self, "Error", "No sensor selected")
693 return
694
695 ch = int(sensor_text.split()[-1])
696
697 # Get reference pressure
698 try:
699 ref_pressure_str = self.txt_ref_pressure.text().strip()
700 ref_pressure = float(ref_pressure_str)
701
702 # Auto-detect units (if < 1000, assume kPa, otherwise Pa)
703 if ref_pressure < 1000:
704 ref_pressure *= 1000 # Convert kPa to Pa
705 except ValueError:
706 QtWidgets.QMessageBox.warning(self, "Error", "Invalid pressure value")
707 return
708
709 # Get current voltage for this channel
710 if ch not in self.channel_data or not self.channel_data[ch]["v"]:
711 QtWidgets.QMessageBox.warning(self, "Error", f"No data for channel {ch}")
712 return
713
714 current_voltage = self.channel_data[ch]["v"][-1]
715
716 # Add to calibration
717 cal = self.cal_manager.get_or_create(ch)
718 cal.add_calibration_point(current_voltage, ref_pressure, self.env_state)
719
720 # Clear input
721 self.txt_ref_pressure.clear()
722
723 # Update display
724 confidence_names = ["LOW", "MEDIUM", "HIGH", "MAXIMUM"]
725 QtWidgets.QMessageBox.information(
726 self,
727 "Calibration Point Added",
728 f"Channel {ch}: {current_voltage:.4f}V → {ref_pressure/1000:.1f} kPa\n"
729 f"Total points: {cal.human_input_count}\n"
730 f"Confidence: {confidence_names[cal.confidence_level]}\n"
731 f"Reliability: {cal.get_reliability_score()*100:.1f}%",
732 )
733
734 # Auto-save after each point
735 self.cal_manager.save_calibrations()
736
737 def _update_display(self):
738 """Update plots and displays"""
739 # Update plots
740 for ch in self.active_channels:
741 if ch in self.curves and ch in self.channel_data:
742 ts = list(self.channel_data[ch]["t"])
743 vs = list(self.channel_data[ch]["v"])
744 if ts and vs:
745 self.curves[ch].setData(ts, vs)
746
747 # Update current voltage display
748 sensor_text = self.cmb_sensor.currentText()
749 if sensor_text:
750 ch = int(sensor_text.split()[-1])
751 if ch in self.channel_data and self.channel_data[ch]["v"]:
752 current_v = self.channel_data[ch]["v"][-1]
753 current_raw = self.channel_data[ch]["raw"][-1]
754 self.lbl_current_v.setText(f"{current_v:.6f} V")
755 self.lbl_current_raw.setText(f"{current_raw}")
756
757 # Show prediction if calibrated
758 cal = self.cal_manager.get_or_create(ch)
759 if cal.human_input_count >= 2:
760 p_mean, p_std = cal.predict_pressure(current_v, self.env_state)
761 self.lbl_prediction.setText(
762 f"Prediction: {p_mean/1000:.2f} ± {p_std/1000:.2f} kPa"
763 )
764 else:
765 self.lbl_prediction.setText(
766 "Prediction: Need ≥2 calibration points"
767 )
768
769 # Update sensor status
770 for ch in self.active_channels:
771 if ch in self.sensor_labels:
772 cal = self.cal_manager.get_or_create(ch)
773 confidence_names = ["LOW", "MEDIUM", "HIGH", "MAXIMUM"]
774 status = f"CH{ch}: {cal.human_input_count} pts, {confidence_names[cal.confidence_level]}, {cal.get_reliability_score()*100:.0f}% reliable"
775 self.sensor_labels[ch].setText(status)
776
777 # Update progress
778 for ch in self.active_channels:
779 if ch in self.progress_labels:
780 cal = self.cal_manager.get_or_create(ch)
781 confidence_names = ["LOW", "MEDIUM", "HIGH", "MAXIMUM"]
782 self.progress_labels[ch].setText(
783 f"CH{ch}: {cal.human_input_count} points, {confidence_names[cal.confidence_level]} confidence"
784 )
785
786 # Remove stale channels (not seen in 5 seconds)
787 now = time.time()
788 for ch in list(self.active_channels):
789 if ch in self.last_seen and (now - self.last_seen[ch]) > 5.0:
790 if ch in self.sensor_labels:
791 self.sensor_labels[ch].setText(f"CH{ch}: OFFLINE")
792 self.sensor_labels[ch].setStyleSheet("color: #ff0000;")
793
794 def _auto_save(self):
795 """Auto-save calibrations periodically"""
796 if self.active_channels:
797 self.cal_manager.save_calibrations()
798
799 def closeEvent(self, event):
800 """Save on exit"""
801 if self.reader:
802 self.reader.stop()
803 self.cal_manager.save_calibrations()
804 event.accept()
805
806
807# ---------------------- Entry Point ----------------------
808def main():
809 app = QtWidgets.QApplication(sys.argv)
810 gui = RobustPTCalibrationGUI()
811 gui.show()
812 sys.exit(app.exec())
813
814
815if __name__ == "__main__":
816 main()
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
static route_t * find(uint16_t pin)
Definition board_exti.c:20
add_calibration_point(self, voltage, reference_pressure, env_state)
TOML_NODISCARD_CTOR time() noexcept=default