STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
calibration_orchestrator_gui.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3Calibration Orchestrator GUI
4PyQt6 GUI for the calibration orchestrator with live sensor data,
5calibration controls, and status display.
6"""
7
8import sys
9import time
10import threading
11from pathlib import Path
12from typing import Dict, List, Optional, Tuple
13from collections import deque, defaultdict
14
15from PyQt6 import QtCore, QtGui, QtWidgets
16import pyqtgraph as pg
17import numpy as np
18
19# Import orchestrator
20from calibration_orchestrator import (
21 CalibrationOrchestrator,
22 SENSOR_TYPES,
23 SensorTypeInfo,
24)
25
26# Import config loader for sensor roles
27try:
28 from config_loader import load_config
29
30 _cfg_loaded = True
31except ImportError:
32 _cfg_loaded = False
33
34# Color scheme
35COLORS = {
36 "bg": "#1e1e1e",
37 "fg": "#ffffff",
38 "accent": "#0078d4",
39 "success": "#00ff00",
40 "warning": "#ffaa00",
41 "error": "#ff0000",
42 "grid": "#333333",
43}
44
45
46class CalibrationOrchestratorGUI(QtWidgets.QMainWindow):
47 """Main GUI window for calibration orchestrator"""
48
49 def __init__(self, sensor_names: Optional[List[str]] = None):
50 super().__init__()
51 self.orchestrator = CalibrationOrchestrator(sensor_names)
52 self.running = False
53 self.phase = "INIT"
54
55 # Data buffers for plotting
56 self.plot_data: Dict[Tuple[str, int], deque] = {}
57 self.plot_times: Dict[Tuple[str, int], deque] = {}
58
59 # Channel number mapping (for simple "Channel 1", "Channel 2" labels)
60 self.channel_numbers: Dict[Tuple[str, int], int] = {}
61
62 # Reference input fields
63 self.ref_inputs: Dict[Tuple[str, int], QtWidgets.QLineEdit] = {}
64
65 self.init_ui()
66 self.setup_timers()
67
68 def init_ui(self):
69 """Initialize UI"""
70 self.setWindowTitle("Calibration Orchestrator")
71 self.setGeometry(100, 100, 1600, 1000)
72
73 # Central widget
74 central = QtWidgets.QWidget()
75 self.setCentralWidget(central)
76 layout = QtWidgets.QHBoxLayout(central)
77
78 # Left panel: Controls
79 left_panel = self.create_control_panel()
80 layout.addWidget(left_panel, 1)
81
82 # Right panel: Plots and status
83 right_panel = self.create_plot_panel()
84 layout.addWidget(right_panel, 2)
85
86 # Apply dark theme
87 self.setStyleSheet(
88 f"""
89 QMainWindow {{
90 background-color: {COLORS["bg"]};
91 color: {COLORS["fg"]};
92 }}
93 QPushButton {{
94 background-color: {COLORS["accent"]};
95 color: white;
96 border: none;
97 padding: 8px;
98 border-radius: 4px;
99 font-weight: bold;
100 }}
101 QPushButton:hover {{
102 background-color: #005a9e;
103 }}
104 QPushButton:pressed {{
105 background-color: #004578;
106 }}
107 QLineEdit {{
108 background-color: #2d2d2d;
109 color: {COLORS["fg"]};
110 border: 1px solid #555;
111 padding: 4px;
112 border-radius: 2px;
113 }}
114 QLabel {{
115 color: {COLORS["fg"]};
116 }}
117 QGroupBox {{
118 color: {COLORS["fg"]};
119 border: 1px solid #555;
120 border-radius: 4px;
121 margin-top: 10px;
122 padding-top: 10px;
123 }}
124 QGroupBox::title {{
125 subcontrol-origin: margin;
126 left: 10px;
127 padding: 0 5px;
128 }}
129 """
130 )
131
132 def create_control_panel(self) -> QtWidgets.QWidget:
133 """Create left control panel"""
134 panel = QtWidgets.QWidget()
135 layout = QtWidgets.QVBoxLayout(panel)
136
137 # Title
138 title = QtWidgets.QLabel("Calibration Orchestrator")
139 title.setStyleSheet("font-size: 18pt; font-weight: bold; padding: 10px;")
140 layout.addWidget(title)
141
142 # Phase indicator
143 self.phase_label = QtWidgets.QLabel("Phase: INIT")
144 self.phase_label.setStyleSheet("font-size: 14pt; padding: 5px;")
145 layout.addWidget(self.phase_label)
146
147 # Status
148 status_group = QtWidgets.QGroupBox("Status")
149 status_layout = QtWidgets.QVBoxLayout()
150
151 self.status_text = QtWidgets.QTextEdit()
152 self.status_text.setReadOnly(True)
153 self.status_text.setMaximumHeight(150)
154 status_layout.addWidget(self.status_text)
155 status_group.setLayout(status_layout)
156 layout.addWidget(status_group)
157
158 # Phase 1 Controls
159 phase1_group = QtWidgets.QGroupBox("Phase 1: Calibration")
160 phase1_layout = QtWidgets.QVBoxLayout()
161
162 # Reference input section
163 ref_group = QtWidgets.QGroupBox("Set Reference Values")
164 ref_group.setToolTip(
165 "Enter the known reference value (e.g., pressure in PSI, temperature in °C) "
166 "for each sensor channel. This is the true value you're calibrating against. "
167 "After setting references, click 'Collect' to gather calibration data points."
168 )
169 ref_layout = QtWidgets.QVBoxLayout()
170
171 # Number of reference gauges (1 = one value for all channels, N = one per channel)
172 gauge_group = QtWidgets.QGroupBox("Reference gauges")
173 gauge_layout = QtWidgets.QHBoxLayout()
174 gauge_layout.addWidget(QtWidgets.QLabel("Number of gauges:"))
175 self.num_gauges_spin = QtWidgets.QSpinBox()
176 self.num_gauges_spin.setMinimum(1)
177 self.num_gauges_spin.setMaximum(10)
178 self.num_gauges_spin.setValue(1)
179 self.num_gauges_spin.setToolTip(
180 "If all PTs share one reference gauge, set to 1 and enter one value for Phase 1."
181 )
182 gauge_layout.addWidget(self.num_gauges_spin)
183 gauge_group.setLayout(gauge_layout)
184 phase1_layout.addWidget(gauge_group)
185
186 # Per-sensor-type reference inputs (only for active connectors)
187 for stype, info in self.orchestrator.sensor_types.items():
188 # Collect active channels for this type (active_connectors key is (stype, board_ip))
189 channels_for_type: List[int] = []
190 for (
191 st,
192 _board_ip,
193 ), active_conns in self.orchestrator.active_connectors.items():
194 if st == stype:
195 channels_for_type.extend(active_conns)
196 channels_for_type = sorted(set(channels_for_type))
197 if not channels_for_type:
198 continue
199
200 # Get sensor role names if available
201 role_map = {}
202 if _cfg_loaded:
203 try:
204 from config_loader import load_config
205
206 cfg = load_config()
207 roles = cfg.get("sensor_roles", {})
208 for role, role_ch in roles.items():
209 if role_ch in channels_for_type and stype == "PT":
210 role_map[role_ch] = role
211 except Exception:
212 pass
213
214 type_group = QtWidgets.QGroupBox(f"{stype} ({info.unit})")
215 type_layout = QtWidgets.QGridLayout()
216
217 row = 0
218 for ch in channels_for_type:
219 key = (stype, ch)
220
221 # Create label with role name if available
222 label_text = f"CH{ch}"
223 if ch in role_map:
224 label_text = f"{role_map[ch]} (CH{ch})"
225
226 label = QtWidgets.QLabel(f"{label_text}:")
227 ref_input = QtWidgets.QLineEdit()
228 ref_input.setPlaceholderText(f"Reference {info.unit}")
229 ref_input.setToolTip(
230 f"Enter the known reference value in {info.unit}.\n"
231 f"For example, if calibrating a pressure sensor at 14.7 PSI, enter '14.7'.\n"
232 f"This value will be used as the ground truth for calibration."
233 )
234 ref_input.returnPressed.connect(
235 lambda checked, k=key, inp=ref_input: self.set_reference(
236 k, inp.text()
237 )
238 )
239
240 set_btn = QtWidgets.QPushButton("Set")
241 set_btn.setToolTip("Set this reference value for calibration")
242 set_btn.clicked.connect(
243 lambda checked, k=key, inp=ref_input: self.set_reference(
244 k, inp.text()
245 )
246 )
247
248 self.ref_inputs[key] = ref_input
249 type_layout.addWidget(label, row, 0)
250 type_layout.addWidget(ref_input, row, 1)
251 type_layout.addWidget(set_btn, row, 2)
252 row += 1
253
254 type_group.setLayout(type_layout)
255 ref_layout.addWidget(type_group)
256
257 ref_group.setLayout(ref_layout)
258 phase1_layout.addWidget(ref_group)
259
260 # Batch reference
261 batch_group = QtWidgets.QGroupBox("Batch Reference")
262 batch_layout = QtWidgets.QHBoxLayout()
263
264 self.batch_type_combo = QtWidgets.QComboBox()
265 self.batch_type_combo.addItems(list(self.orchestrator.sensor_types.keys()))
266 batch_layout.addWidget(QtWidgets.QLabel("Type:"))
267 batch_layout.addWidget(self.batch_type_combo)
268
269 self.batch_value_input = QtWidgets.QLineEdit()
270 self.batch_value_input.setPlaceholderText("Value")
271 batch_layout.addWidget(self.batch_value_input)
272
273 batch_set_btn = QtWidgets.QPushButton("Set All")
274 batch_set_btn.clicked.connect(self.set_batch_reference)
275 batch_layout.addWidget(batch_set_btn)
276
277 # When num_gauges == 1: single ref applies to all PT channels
278 self.single_ref_input = QtWidgets.QLineEdit()
279 self.single_ref_input.setPlaceholderText("One ref (PSI) for all PTs")
280 self.single_ref_btn = QtWidgets.QPushButton("Set ref for all PTs")
281 self.single_ref_btn.clicked.connect(self.set_single_ref_all_pts)
282 batch_layout.addWidget(QtWidgets.QLabel("1-gauge ref:"))
283 batch_layout.addWidget(self.single_ref_input)
284 batch_layout.addWidget(self.single_ref_btn)
285
286 batch_group.setLayout(batch_layout)
287 phase1_layout.addWidget(batch_group)
288
289 # Collection controls
290 collect_group = QtWidgets.QGroupBox("Collection")
291 collect_layout = QtWidgets.QHBoxLayout()
292
293 self.collect_duration_input = QtWidgets.QLineEdit("5.0")
294 self.collect_duration_input.setPlaceholderText("Duration (sec)")
295 collect_layout.addWidget(QtWidgets.QLabel("Duration:"))
296 collect_layout.addWidget(self.collect_duration_input)
297
298 self.collect_btn = QtWidgets.QPushButton("Collect")
299 self.collect_btn.clicked.connect(self.start_collection)
300 collect_layout.addWidget(self.collect_btn)
301
302 collect_group.setLayout(collect_layout)
303 phase1_layout.addWidget(collect_group)
304
305 # Fit and save
306 action_layout = QtWidgets.QHBoxLayout()
307
308 self.fit_btn = QtWidgets.QPushButton("Fit (TLS+Bayesian)")
309 self.fit_btn.clicked.connect(self.fit_calibrations)
310 action_layout.addWidget(self.fit_btn)
311
312 self.save_btn = QtWidgets.QPushButton("Save calibration")
313 self.save_btn.setToolTip(
314 "Save calibration constants to JSON in config calibration path"
315 )
316 self.save_btn.clicked.connect(self.save_calibrations)
317 action_layout.addWidget(self.save_btn)
318
319 self.clear_btn = QtWidgets.QPushButton("Clear calibration")
320 self.clear_btn.setToolTip("Clear all calibration data and start from scratch")
321 self.clear_btn.clicked.connect(self.clear_calibrations)
322 action_layout.addWidget(self.clear_btn)
323
324 phase1_layout.addLayout(action_layout)
325
326 # Phase transition
327 self.done_btn = QtWidgets.QPushButton("Done → Phase 2")
328 self.done_btn.clicked.connect(self.transition_to_phase2)
329 phase1_layout.addWidget(self.done_btn)
330
331 phase1_group.setLayout(phase1_layout)
332 layout.addWidget(phase1_group)
333
334 # Phase 2 Controls
335 phase2_group = QtWidgets.QGroupBox("Phase 2: Monitoring")
336 phase2_layout = QtWidgets.QVBoxLayout()
337
338 self.monitor_status_label = QtWidgets.QLabel("Not started")
339 phase2_layout.addWidget(self.monitor_status_label)
340
341 phase2_group.setLayout(phase2_layout)
342 layout.addWidget(phase2_group)
343
344 # Actuator controls (if available)
345 if (
346 hasattr(self.orchestrator, "actuator_comm")
347 and self.orchestrator.actuator_comm
348 ):
349 actuator_group = QtWidgets.QGroupBox("Actuator Control")
350 actuator_layout = QtWidgets.QGridLayout()
351
352 # Get number of actuators dynamically from config/CSV
353 try:
354 import sys
355 from pathlib import Path
356
357 # Try to import from combined_gui if available
358 sys.path.insert(
359 0,
360 str(
361 Path(__file__).parent.parent.parent
362 / "external"
363 / "DiabloAvionics"
364 / "test_guis"
365 ),
366 )
367 try:
368 from combined_gui import get_num_actuators
369
370 num_actuators = get_num_actuators()
371 except ImportError:
372 # Fallback: try to count from CSV
373 csv_path = (
374 Path(__file__).parent.parent.parent
375 / "config"
376 / "state_machine_actuators.csv"
377 )
378 if csv_path.exists():
379 import csv
380
381 with open(csv_path, "r") as f:
382 reader = csv.reader(f)
383 rows = list(reader)
384 num_actuators = (
385 sum(
386 1
387 for row in rows[1:]
388 if len(row) > 0 and row[0].strip()
389 )
390 if len(rows) > 1
391 else 10
392 )
393 else:
394 num_actuators = 10
395 except Exception:
396 num_actuators = 10
397
398 # Calculate optimal grid layout
399 if num_actuators <= 8:
400 cols = 2
401 elif num_actuators <= 16:
402 cols = 4
403 else:
404 cols = 4
405
406 for i in range(1, num_actuators + 1):
407 on_btn = QtWidgets.QPushButton(f"ON {i}")
408 off_btn = QtWidgets.QPushButton(f"OFF {i}")
409 on_btn.clicked.connect(
410 lambda checked, aid=i: self.send_actuator_command(aid, 1)
411 )
412 off_btn.clicked.connect(
413 lambda checked, aid=i: self.send_actuator_command(aid, 0)
414 )
415
416 row = (i - 1) // cols
417 col = (i - 1) % cols
418 actuator_layout.addWidget(
419 QtWidgets.QLabel(f"Act {i}:"), row * 2, col * 2
420 )
421 actuator_layout.addWidget(on_btn, row * 2, col * 2 + 1)
422 actuator_layout.addWidget(off_btn, row * 2 + 1, col * 2 + 1)
423
424 actuator_group.setLayout(actuator_layout)
425 layout.addWidget(actuator_group)
426
427 layout.addStretch()
428
429 return panel
430
431 def create_plot_panel(self) -> QtWidgets.QWidget:
432 """Create right plot panel"""
433 panel = QtWidgets.QWidget()
434 layout = QtWidgets.QVBoxLayout(panel)
435
436 # Tab widget for different views
437 tabs = QtWidgets.QTabWidget()
438
439 # Live data tab - grouped by sensor type
440 live_tab = QtWidgets.QWidget()
441 live_layout = QtWidgets.QVBoxLayout(live_tab)
442
443 # Scroll area for plots
444 scroll = QtWidgets.QScrollArea()
445 scroll.setWidgetResizable(True)
446 scroll_widget = QtWidgets.QWidget()
447 scroll_layout = QtWidgets.QVBoxLayout(scroll_widget)
448
449 # Group sensors by type
450 self.plot_widgets: Dict[str, pg.GraphicsLayoutWidget] = {}
451 self.plot_curves: Dict[Tuple[str, int], pg.PlotDataItem] = {}
452
453 # Collect sensors grouped by type
454 sensors_by_type: Dict[str, List[Tuple[str, int]]] = defaultdict(list)
455
456 for (
457 stype,
458 board_ip,
459 ), active_conns in self.orchestrator.active_connectors.items():
460 for ch in active_conns:
461 key = (stype, ch)
462 sensors_by_type[stype].append(key)
463
464 # Create a plot widget for each sensor type
465 for stype in sorted(sensors_by_type.keys()):
466 sensors = sorted(
467 sensors_by_type[stype], key=lambda x: x[1]
468 ) # Sort by channel
469
470 # Create plot widget for this sensor type
471 plot_widget = pg.GraphicsLayoutWidget()
472 plot_widget.setBackground(COLORS["bg"])
473 plot = plot_widget.addPlot(title=f"{stype} Sensors")
474 plot.setLabel("left", "ADC Code")
475 plot.setLabel("bottom", "Time (s)")
476 plot.showGrid(x=True, y=True, alpha=0.3)
477 plot.addLegend()
478
479 self.plot_widgets[stype] = plot_widget
480
481 # Create curves for each sensor in this type
482 for idx, key in enumerate(sensors):
483 stype_key, ch = key
484
485 # Initialize data buffers
486 self.plot_data[key] = deque(maxlen=1000)
487 self.plot_times[key] = deque(maxlen=1000)
488
489 # Create curve with channel label
490 color = pg.intColor(idx, len(sensors)) # Cycle through colors
491 curve = plot.plot([], [], pen=color, name=f"CH{ch}")
492 self.plot_curves[key] = curve
493
494 scroll_layout.addWidget(plot_widget)
495
496 scroll.setWidget(scroll_widget)
497 live_layout.addWidget(scroll)
498
499 tabs.addTab(live_tab, "Live Data")
500
501 # Calibration status tab
502 status_tab = QtWidgets.QWidget()
503 status_layout = QtWidgets.QVBoxLayout(status_tab)
504
505 self.status_table = QtWidgets.QTableWidget()
506 self.status_table.setColumnCount(7)
507 self.status_table.setHorizontalHeaderLabels(
508 ["Type", "Channel", "Points", "RMSE", "R²", "Confidence", "Status"]
509 )
510 status_layout.addWidget(self.status_table)
511
512 tabs.addTab(status_tab, "Calibration Status")
513
514 layout.addWidget(tabs)
515
516 return panel
517
518 def setup_timers(self):
519 """Setup update timers"""
520 # Data update timer (100ms)
521 self.data_timer = QtCore.QTimer(self)
522 self.data_timer.timeout.connect(self.update_data)
523 self.data_timer.start(100)
524
525 # Status update timer (1s)
526 self.status_timer = QtCore.QTimer(self)
527 self.status_timer.timeout.connect(self.update_status)
528 self.status_timer.start(1000)
529
530 def set_reference(self, key: Tuple[str, int], value_str: str):
531 """Set reference value for a channel"""
532 try:
533 value = float(value_str)
534 self.orchestrator.references[key] = value
535 stype, ch = key
536 info = self.orchestrator.sensor_types[stype]
537 self.status_text.append(f"✅ {stype} CH{ch} → {value} {info.unit}")
538 except ValueError:
539 self.status_text.append(f"❌ Invalid value: {value_str}")
540
541 def set_single_ref_all_pts(self):
542 """Set one reference value for all PT channels (when using 1 reference gauge)."""
543 try:
544 value = float(self.single_ref_input.text())
545 except ValueError:
546 self.status_text.append("❌ Invalid value for 1-gauge ref")
547 return
548 info = self.orchestrator.sensor_types.get("PT")
549 if not info:
550 self.status_text.append("❌ PT not in orchestrator")
551 return
552 count = 0
553 for (stype, ch), rcf in self.orchestrator.robust.items():
554 if stype != "PT":
555 continue
556 self.orchestrator.references[(stype, ch)] = value
557 if (stype, ch) in self.ref_inputs:
558 self.ref_inputs[(stype, ch)].setText(str(value))
559 count += 1
560 self.status_text.append(
561 f"✅ PT all ({count} ch) → {value} {info.unit} (trusted)"
562 )
563
564 def set_batch_reference(self):
565 """Set reference for all channels of a type"""
566 stype = self.batch_type_combo.currentText()
567 try:
568 value = float(self.batch_value_input.text())
569 info = self.orchestrator.sensor_types[stype]
570 channels = []
571 for (st, _), active_conns in self.orchestrator.active_connectors.items():
572 if st == stype:
573 channels.extend(active_conns)
574 channels = sorted(set(channels)) if channels else list(info.connectors)
575 for ch in channels:
576 key = (stype, ch)
577 self.orchestrator.references[key] = value
578 if key in self.ref_inputs:
579 self.ref_inputs[key].setText(str(value))
580 self.status_text.append(f"✅ {stype} all → {value} {info.unit} (trusted)")
581 except ValueError:
582 self.status_text.append(f"❌ Invalid value")
583
584 def clear_calibrations(self):
585 """Clear all calibration data and start from scratch."""
586 reply = QtWidgets.QMessageBox.question(
587 self,
588 "Clear calibration",
589 "Clear all calibration points and references and start from scratch?",
590 QtWidgets.QMessageBox.StandardButton.Yes
591 | QtWidgets.QMessageBox.StandardButton.No,
592 QtWidgets.QMessageBox.StandardButton.No,
593 )
594 if reply != QtWidgets.QMessageBox.StandardButton.Yes:
595 return
596 self.orchestrator.clear_calibration()
597 for inp in self.ref_inputs.values():
598 inp.clear()
599 self.single_ref_input.clear()
600 self.batch_value_input.clear()
601 self.status_text.append("🗑️ Calibration cleared — start from scratch")
602
603 def start_collection(self):
604 """Start collecting calibration points"""
605 try:
606 duration = float(self.collect_duration_input.text())
607 except ValueError:
608 duration = 5.0
609
610 if not self.orchestrator.references:
611 self.status_text.append("⚠️ No references set")
612 return
613
614 self.status_text.append(f"📊 Collecting for {duration:.1f}s...")
615 self.collect_btn.setEnabled(False)
616
617 # Set collecting flag and run collection in thread
618 def collect_thread():
619 self.orchestrator.collecting = True
620 try:
621 self.orchestrator._collect(duration)
622 finally:
623 self.orchestrator.collecting = False
624 QtCore.QTimer.singleShot(0, lambda: self.collect_btn.setEnabled(True))
625 QtCore.QTimer.singleShot(
626 0, lambda: self.status_text.append("✅ Collection complete")
627 )
628
629 threading.Thread(target=collect_thread, daemon=True).start()
630
631 def fit_calibrations(self):
632 """Run TLS + Bayesian fit"""
633 self.status_text.append("🔧 Running TLS + Bayesian fit...")
634 self.fit_btn.setEnabled(False)
635
636 def fit_thread():
637 self.orchestrator._tls_bayesian_fit_all()
638 QtCore.QTimer.singleShot(0, lambda: self.fit_btn.setEnabled(True))
639 QtCore.QTimer.singleShot(
640 0, lambda: self.status_text.append("✅ Fit complete")
641 )
642
643 threading.Thread(target=fit_thread, daemon=True).start()
644
645 def save_calibrations(self):
646 """Save calibrations"""
647 self.status_text.append("💾 Saving calibrations...")
648 self.orchestrator._save_all()
649 self.status_text.append("✅ Saved")
650
651 def transition_to_phase2(self):
652 """Transition to Phase 2"""
653 self.orchestrator._tls_bayesian_fit_all()
654 self.orchestrator._save_all()
655 self.status_text.append("➡️ Transitioning to Phase 2...")
656
657 def phase2_thread():
658 self.orchestrator.run_phase2()
659
660 threading.Thread(target=phase2_thread, daemon=True).start()
661 self.phase = "MONITORING"
662 self.phase_label.setText("Phase: MONITORING")
663
664 def send_actuator_command(self, actuator_id: int, state: int):
665 """Send actuator command"""
666 if (
667 hasattr(self.orchestrator, "actuator_comm")
668 and self.orchestrator.actuator_comm
669 ):
670 self.orchestrator.actuator_comm.send_command(actuator_id, state)
671 self.status_text.append(
672 f"📤 Actuator {actuator_id} → {'ON' if state else 'OFF'}"
673 )
674 else:
675 self.status_text.append(f"⚠️ Actuator communication not available")
676
677 def update_data(self):
678 """Update live data plots"""
679 if not self.running:
680 return
681
682 # Drain queue continuously (no timeout)
683 self.orchestrator._drain_queue()
684
685 # Update plots
686 now = time.time()
687 for key, curve in self.plot_curves.items():
688 # Access live_adc directly (it's a defaultdict, so key always exists)
689 # But we need to check if it has data
690 if key not in self.orchestrator.live_adc:
691 continue
692
693 buf = self.orchestrator.live_adc[key]
694
695 # Get new samples since last update
696 if len(buf) > 0:
697 # Get all samples from buffer (copy to avoid issues)
698 new_samples = list(buf)
699 # Add to plot data
700 self.plot_data[key].extend(new_samples)
701 self.plot_times[key].extend([now] * len(new_samples))
702
703 # Update curve
704 if len(self.plot_data[key]) > 0:
705 times = np.array(self.plot_times[key])
706 data = np.array(self.plot_data[key])
707 # Show last 30 seconds
708 cutoff = now - 30.0
709 mask = times >= cutoff
710 if np.any(mask):
711 curve.setData(times[mask] - cutoff, data[mask])
712
713 def update_status(self):
714 """Update status display"""
715 # Update phase
716 if self.orchestrator.phase != self.phase:
717 self.phase = self.orchestrator.phase
718 self.phase_label.setText(f"Phase: {self.phase}")
719
720 # Update status table
721 self.status_table.setRowCount(0)
722 for key, rcf in self.orchestrator.robust.items():
723 stype, ch = key
724 summary = rcf.get_calibration_summary()
725
726 row = self.status_table.rowCount()
727 self.status_table.insertRow(row)
728
729 self.status_table.setItem(row, 0, QtWidgets.QTableWidgetItem(stype))
730 self.status_table.setItem(row, 1, QtWidgets.QTableWidgetItem(str(ch)))
731 self.status_table.setItem(
732 row, 2, QtWidgets.QTableWidgetItem(str(len(rcf.calibration_points)))
733 )
734 self.status_table.setItem(
735 row, 3, QtWidgets.QTableWidgetItem(f"{summary.get('rmse', 0.0):.4f}")
736 )
737 self.status_table.setItem(
738 row,
739 4,
740 QtWidgets.QTableWidgetItem(f"{summary.get('r_squared', 0.0):.4f}"),
741 )
742 self.status_table.setItem(
743 row, 5, QtWidgets.QTableWidgetItem(rcf.get_confidence_level())
744 )
745 self.status_table.setItem(
746 row,
747 6,
748 QtWidgets.QTableWidgetItem(
749 "CALIBRATED"
750 if len(rcf.calibration_points) >= self.orchestrator.min_points
751 else "PENDING"
752 ),
753 )
754
755 def start(self):
756 """Start the orchestrator"""
757 if not self.orchestrator.start_receiver():
758 QtWidgets.QMessageBox.critical(
759 self, "Error", "Failed to start UDP receiver"
760 )
761 return
762
763 self.running = True
764 self.phase = "CALIBRATION"
765 self.phase_label.setText("Phase: CALIBRATION")
766 self.status_text.append("✅ Orchestrator started")
767 self.status_text.append(f"📡 Listening on UDP port {self.orchestrator.port}")
768 self.status_text.append(
769 f"📊 Monitoring {len(self.plot_curves)} sensor channels"
770 )
771
772 # Log receiver stats periodically
773 def log_stats():
774 if self.running and hasattr(self.orchestrator, "receiver"):
775 stats = self.orchestrator.receiver.stats
776 queue_size = len(self.orchestrator.receiver.sample_queue)
777 if stats.get("packets", 0) > 0 or queue_size > 0:
778 self.status_text.append(
779 f"📈 Stats: {stats.get('packets', 0)} packets, "
780 f"{stats.get('samples', 0)} samples, queue: {queue_size}"
781 )
782
783 # Log stats every 5 seconds
784 self.stats_timer = QtCore.QTimer(self)
785 self.stats_timer.timeout.connect(log_stats)
786 self.stats_timer.start(5000)
787
788 # Don't run the command loop - GUI handles it directly
789 self.orchestrator.phase = "CALIBRATION"
790
791 def closeEvent(self, event):
792 """Handle window close"""
793 self.running = False
794 self.orchestrator.stop()
795 event.accept()
796
797
798def main():
799 import argparse
800
801 parser = argparse.ArgumentParser(description="Calibration Orchestrator GUI")
802 parser.add_argument("--sensors", nargs="+", help="Sensor types (PT, TC, RTD, LC)")
803 args = parser.parse_args()
804
805 sensor_names = [s.upper() for s in args.sensors] if args.sensors else None
806
807 app = QtWidgets.QApplication(sys.argv)
808 app.setStyle("Fusion")
809
810 window = CalibrationOrchestratorGUI(sensor_names)
811 window.show()
812 window.start()
813
814 sys.exit(app.exec())
815
816
817if __name__ == "__main__":
818 main()