STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
pt_calibration_gui.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3High-Performance PT Calibration GUI
4Real-time calibration with sub-10ms latency and professional interface
5"""
6
7import sys
8import os
9import time
10import threading
11import queue
12import json
13import numpy as np
14import tkinter as tk
15from tkinter import ttk, messagebox, filedialog
16import matplotlib.pyplot as plt
17from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
18from matplotlib.figure import Figure
19
20try:
21 import serial
22
23 SERIAL_AVAILABLE = True
24except ImportError:
25 SERIAL_AVAILABLE = False
26 print("⚠️ pyserial not available - serial data source disabled")
27import struct
28from collections import deque
29from datetime import datetime
30import logging
31from dataclasses import dataclass
32from typing import Dict, List, Optional, Tuple
33import subprocess
34import signal
35
36# Try to import Elodin client
37try:
38 from elodin_client import ElodinClient
39
40 ELODIN_AVAILABLE = True
41except ImportError:
42 ELODIN_AVAILABLE = False
43 print("⚠️ elodin_client not available - Elodin subscription disabled")
44
45# Configure logging
46logging.basicConfig(
47 level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
48)
49logger = logging.getLogger(__name__)
50
51
52@dataclass
54 """PT measurement data structure"""
55
56 sensor_id: int
57 voltage: float # For display only
58 raw_adc_code: int = 0 # Raw ADC code (32-bit signed) - used for calibration
59 timestamp: float = 0.0
60 pt_location: str = ""
61 filtered_adc_code: float = 0.0 # Filtered ADC code for calibration
62 calculated_pressure: float = 0.0
63 reference_pressure: float = 0.0
64 temperature: float = 25.0
65 humidity: float = 50.0
66
67
68@dataclass
70 """Calibration data point"""
71
72 adc_codes: List[int] # Raw ADC codes (one per sensor) - used for calibration
73 voltages: List[float] # Voltages (for display/reference only)
74 reference_pressures: List[float] # One per gauge
75 timestamp: float
76 environmental_conditions: Dict[str, float]
77
78
80 """High-performance calibration GUI with real-time feedback"""
81
82 def __init__(self):
83 self.root = tk.Tk()
84 self.root.title("PT Calibration System - Diablo FSW")
85 self.root.geometry("1600x1000")
86 self.root.configure(bg="#2b2b2b")
87
88 # Data structures
89 self.pt_measurements: Dict[int, deque] = {
90 i: deque(maxlen=1000) for i in range(9)
91 }
92 self.calibration_points: List[CalibrationPoint] = []
93 self.calibration_polynomials: Dict[int, np.ndarray] = {}
94 self.reference_gauges: Dict[int, float] = {}
95 self.gauge_mapping: Dict[int, int] = {} # PT sensor -> gauge mapping
96
97 # Real-time data
98 self.latest_measurements: Dict[int, PTMeasurement] = {}
99 self.data_queue = queue.Queue(maxsize=1000)
101 self.recording_data = False
102
103 # Serial connection
104 self.serial_connection: Optional[serial.Serial] = None
105 self.serial_thread: Optional[threading.Thread] = None
106 self.stop_serial = threading.Event()
107
108 # Elodin connection (for subscribing to PT messages)
109 self.elodin_client: Optional[ElodinClient] = None
110 self.elodin_thread: Optional[threading.Thread] = None
111 self.use_elodin = False
112 self.elodin_connected = False
113
114 # Performance tracking
116 "data_rate_hz": 0.0,
117 "latency_ms": 0.0,
118 "packet_loss_rate": 0.0,
119 "last_update": time.time(),
120 }
121
122 # Initialize GUI
123 self.setup_gui()
124
125 # Setup data source based on selection
126 if self.data_source_var.get() == "Elodin" and ELODIN_AVAILABLE:
127 self.setup_elodin()
128 elif SERIAL_AVAILABLE:
129 self.setup_serial()
130 else:
131 # No data sources available - default to Elodin if possible
132 if ELODIN_AVAILABLE:
133 self.data_source_var.set("Elodin")
134 self.setup_elodin()
135 else:
136 logger.error("❌ No data sources available (neither serial nor Elodin)")
137 messagebox.showerror(
138 "No Data Source",
139 "Neither serial nor Elodin is available.\n"
140 "Install pyserial or elodin-client to use calibration GUI.",
141 )
142
144
145 def setup_gui(self):
146 """Setup the GUI layout"""
147 # Main container
148 main_frame = ttk.Frame(self.root)
149 main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
150
151 # Control panel
152 self.setup_control_panel(main_frame)
153
154 # Data display area
155 self.setup_data_display(main_frame)
156
157 # Real-time plots
158 self.setup_realtime_plots(main_frame)
159
160 # Status bar
161 self.setup_status_bar(main_frame)
162
163 def setup_control_panel(self, parent):
164 """Setup control panel"""
165 control_frame = ttk.LabelFrame(parent, text="Calibration Controls", padding=10)
166 control_frame.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 10))
167
168 # Connection controls
169 conn_frame = ttk.Frame(control_frame)
170 conn_frame.pack(fill=tk.X, pady=(0, 10))
171
172 # Data source selection
173 source_frame = ttk.Frame(conn_frame)
174 source_frame.pack(side=tk.LEFT, padx=(0, 10))
175
176 ttk.Label(source_frame, text="Data Source:").pack(side=tk.LEFT)
177 self.data_source_var = tk.StringVar(value="Serial")
178 source_combo = ttk.Combobox(
179 source_frame,
180 textvariable=self.data_source_var,
181 values=["Serial", "Elodin"],
182 width=10,
183 state="readonly",
184 )
185 source_combo.pack(side=tk.LEFT, padx=(5, 0))
186 source_combo.bind("<<ComboboxSelected>>", self.on_data_source_changed)
187
188 # Serial port controls
189 serial_frame = ttk.Frame(conn_frame)
190 serial_frame.pack(side=tk.LEFT, padx=(0, 10))
191
192 ttk.Label(serial_frame, text="Serial Port:").pack(side=tk.LEFT)
193 self.port_var = tk.StringVar(value="/dev/ttyUSB0")
194 port_combo = ttk.Combobox(serial_frame, textvariable=self.port_var, width=15)
195 port_combo["values"] = self.get_available_ports()
196 port_combo.pack(side=tk.LEFT, padx=(5, 10))
197
198 self.connect_btn = ttk.Button(
199 conn_frame, text="Connect", command=self.toggle_connection
200 )
201 self.connect_btn.pack(side=tk.LEFT, padx=(0, 10))
202
203 # Elodin status
204 self.elodin_status_label = ttk.Label(
205 conn_frame, text="Elodin: Not Connected", foreground="gray"
206 )
207 self.elodin_status_label.pack(side=tk.LEFT, padx=(10, 0))
208
209 # Calibration controls
210 calib_frame = ttk.Frame(control_frame)
211 calib_frame.pack(fill=tk.X, pady=(0, 10))
212
213 self.calibration_btn = ttk.Button(
214 calib_frame,
215 text="Start Calibration",
216 command=self.toggle_calibration,
217 state="disabled",
218 )
219 self.calibration_btn.pack(side=tk.LEFT, padx=(0, 10))
220
221 self.record_btn = ttk.Button(
222 calib_frame,
223 text="Record Data Point",
224 command=self.record_calibration_point,
225 state="disabled",
226 )
227 self.record_btn.pack(side=tk.LEFT, padx=(0, 10))
228
229 self.fit_btn = ttk.Button(
230 calib_frame,
231 text="Fit Calibration",
232 command=self.fit_calibration,
233 state="disabled",
234 )
235 self.fit_btn.pack(side=tk.LEFT, padx=(0, 10))
236
237 self.save_btn = ttk.Button(
238 calib_frame,
239 text="Save Calibration",
240 command=self.save_calibration,
241 state="disabled",
242 )
243 self.save_btn.pack(side=tk.LEFT)
244
245 # Reference pressure input
246 ref_frame = ttk.Frame(control_frame)
247 ref_frame.pack(fill=tk.X)
248
249 ttk.Label(ref_frame, text="Reference Pressures (kPa):").pack(side=tk.LEFT)
251 for i in range(9):
252 ref_var = tk.StringVar()
253 ref_entry = ttk.Entry(ref_frame, textvariable=ref_var, width=8)
254 ref_entry.pack(side=tk.LEFT, padx=(5, 2))
255 self.ref_pressures.append(ref_var)
256
257 def setup_data_display(self, parent):
258 """Setup real-time data display"""
259 data_frame = ttk.LabelFrame(parent, text="Real-Time Data", padding=10)
260 data_frame.grid(row=1, column=0, sticky="nsew", padx=(0, 5))
261
262 # Create data display tree
263 columns = (
264 "Sensor",
265 "Location",
266 "Voltage (V)",
267 "Pressure (kPa)",
268 "Filtered (V)",
269 "Status",
270 )
271 self.data_tree = ttk.Treeview(
272 data_frame, columns=columns, show="headings", height=10
273 )
274
275 for col in columns:
276 self.data_tree.heading(col, text=col)
277 self.data_tree.column(col, width=100, anchor="center")
278
279 # Add scrollbar
280 scrollbar = ttk.Scrollbar(
281 data_frame, orient=tk.VERTICAL, command=self.data_tree.yview
282 )
283 self.data_tree.configure(yscrollcommand=scrollbar.set)
284
285 self.data_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
286 scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
287
288 # Initialize sensor rows
289 pt_locations = [
290 "Pressurant Tank",
291 "Kero Inlet",
292 "Kero Outlet",
293 "Lox Inlet",
294 "Lox Outlet",
295 "Injector",
296 "Chamber Wall #1",
297 "Chamber Wall #2",
298 "Nozzle Exit",
299 ]
300
301 for i, location in enumerate(pt_locations):
302 self.data_tree.insert(
303 "",
304 "end",
305 values=(f"PT {i}", location, "0.000", "0.000", "0.000", "Offline"),
306 )
307
308 def setup_realtime_plots(self, parent):
309 """Setup real-time plotting area"""
310 plot_frame = ttk.LabelFrame(parent, text="Real-Time Plots", padding=10)
311 plot_frame.grid(row=1, column=1, sticky="nsew", padx=(5, 0))
312
313 # Create matplotlib figure
314 self.fig = Figure(figsize=(8, 6), dpi=100)
315 self.fig.patch.set_facecolor("#2b2b2b")
316
317 # Create subplots
318 self.ax_voltage = self.fig.add_subplot(2, 1, 1)
319 self.ax_pressure = self.fig.add_subplot(2, 1, 2)
320
321 # Configure plots
322 self.ax_voltage.set_title("Real-Time Voltage Readings", color="white")
323 self.ax_voltage.set_ylabel("Voltage (V)", color="white")
324 self.ax_voltage.tick_params(colors="white")
325 self.ax_voltage.set_facecolor("#3b3b3b")
326
327 self.ax_pressure.set_title("Real-Time Pressure Readings", color="white")
328 self.ax_pressure.set_xlabel("Time (s)", color="white")
329 self.ax_pressure.set_ylabel("Pressure (kPa)", color="white")
330 self.ax_pressure.tick_params(colors="white")
331 self.ax_pressure.set_facecolor("#3b3b3b")
332
333 # Embed in tkinter
334 self.canvas = FigureCanvasTkAgg(self.fig, plot_frame)
335 self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
336
337 # Initialize plot data
338 self.plot_data = {
339 "time": deque(maxlen=1000),
340 "voltages": {i: deque(maxlen=1000) for i in range(9)},
341 "pressures": {i: deque(maxlen=1000) for i in range(9)},
342 }
343
344 def setup_status_bar(self, parent):
345 """Setup status bar"""
346 status_frame = ttk.Frame(parent)
347 status_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(10, 0))
348
349 self.status_var = tk.StringVar(value="Ready")
350 ttk.Label(status_frame, textvariable=self.status_var).pack(side=tk.LEFT)
351
352 # Performance indicators
353 perf_frame = ttk.Frame(status_frame)
354 perf_frame.pack(side=tk.RIGHT)
355
356 self.data_rate_var = tk.StringVar(value="Data Rate: 0 Hz")
357 self.latency_var = tk.StringVar(value="Latency: 0 ms")
358
359 ttk.Label(perf_frame, textvariable=self.data_rate_var).pack(
360 side=tk.LEFT, padx=(10, 0)
361 )
362 ttk.Label(perf_frame, textvariable=self.latency_var).pack(
363 side=tk.LEFT, padx=(10, 0)
364 )
365
366 # Configure grid weights
367 parent.grid_rowconfigure(1, weight=1)
368 parent.grid_columnconfigure(0, weight=1)
369 parent.grid_columnconfigure(1, weight=1)
370
371 def get_available_ports(self):
372 """Get list of available serial ports"""
373 ports = []
374 if not SERIAL_AVAILABLE:
375 # Return default ports if serial not available
376 for i in range(10):
377 ports.extend([f"/dev/ttyUSB{i}", f"/dev/ttyACM{i}", f"COM{i}"])
378 return ports
379
380 try:
381 import serial.tools.list_ports
382
383 for port in serial.tools.list_ports.comports():
384 ports.append(port.device)
385 except:
386 # Fallback for systems without pyserial tools
387 for i in range(10):
388 ports.extend([f"/dev/ttyUSB{i}", f"/dev/ttyACM{i}", f"COM{i}"])
389 return ports
390
391 def setup_serial(self):
392 """Setup serial connection"""
393 if not SERIAL_AVAILABLE:
394 logger.error("Serial not available - pyserial not installed")
395 messagebox.showerror(
396 "Serial Not Available",
397 "pyserial is not installed.\n" "Install with: pip install pyserial",
398 )
399 return False
400
401 try:
402 self.serial_connection = serial.Serial(
403 port=self.port_var.get(),
404 baudrate=115200,
405 timeout=0.001, # 1ms timeout for low latency
406 write_timeout=0.1,
407 )
408 self.serial_connection.reset_input_buffer()
409 self.serial_connection.reset_output_buffer()
410 logger.info(f"Serial connection established on {self.port_var.get()}")
411 return True
412 except Exception as e:
413 logger.error(f"Failed to connect to serial port: {e}")
414 messagebox.showerror(
415 "Connection Error", f"Failed to connect to {self.port_var.get()}: {e}"
416 )
417 return False
418
419 def on_data_source_changed(self, event=None):
420 """Handle data source change"""
421 source = self.data_source_var.get()
422 self.use_elodin = source == "Elodin" and ELODIN_AVAILABLE
423
424 # Disconnect current source
425 if self.serial_connection and self.serial_connection.is_open:
426 self.serial_connection.close()
427 self.serial_connection = None
428 if self.elodin_client and self.elodin_connected:
429 self.elodin_client.disconnect()
430 self.elodin_connected = False
431 self.elodin_status_label.config(
432 text="Elodin: Not Connected", foreground="gray"
433 )
434
435 # Connect to new source
436 if self.use_elodin:
437 if self.setup_elodin():
438 self.connect_btn.config(text="Disconnect")
439 self.calibration_btn.config(state="normal")
440 self.status_var.set("Connected to Elodin")
441 else:
442 self.status_var.set("Elodin Connection Failed")
443 # Fallback to serial if available
444 if SERIAL_AVAILABLE:
445 self.data_source_var.set("Serial")
446 self.use_elodin = False
447 elif SERIAL_AVAILABLE:
448 if self.setup_serial():
449 self.connect_btn.config(text="Disconnect")
450 self.calibration_btn.config(state="normal")
451 self.status_var.set("Connected")
452 else:
453 self.status_var.set("Connection Failed")
454 else:
455 # No data sources available
456 logger.error("No data sources available")
457 self.status_var.set("No Data Source Available")
458 messagebox.showerror(
459 "No Data Source",
460 "Neither serial nor Elodin is available.\n"
461 "Install pyserial or elodin-client to use calibration GUI.",
462 )
463
464 def start_data_processing(self):
465 """Start the data processing thread"""
466 self.data_thread = threading.Thread(target=self.data_processor, daemon=True)
467 self.data_thread.start()
468
469 # Start GUI update timer
470 self.update_gui()
471
472 def data_processor(self):
473 """High-performance data processing thread"""
474 while not self.stop_serial.is_set():
475 if self.use_elodin and self.elodin_connected:
476 # Process Elodin messages
477 try:
478 messages = self.elodin_client.poll_messages(
479 timeout=0.01
480 ) # 10ms timeout
481 for msg in messages:
482 self.process_elodin_message(msg)
483 except Exception as e:
484 logger.error(f"Error processing Elodin data: {e}")
485 time.sleep(0.01)
486 elif self.serial_connection and self.serial_connection.is_open:
487 try:
488 # Read binary data with minimal latency
489 if (
490 self.serial_connection.in_waiting >= 20
491 ): # ESP32SampleRecord size
492 data = self.serial_connection.read(20)
493 if len(data) == 20:
494 # Parse binary data (matching ESP32SampleRecord structure)
495 (
496 timestamp_us,
497 channel,
498 voltage_raw,
499 voltage,
500 read_time_us,
501 sps,
502 sent_us,
503 ) = struct.unpack("<I B i f I f I", data)
504
505 # Convert voltage_raw (int32) to signed int
506 if voltage_raw >= 0x80000000:
507 adc_code = voltage_raw - 0x100000000
508 else:
509 adc_code = voltage_raw
510
511 # Create measurement
512 measurement = PTMeasurement(
513 sensor_id=channel,
514 voltage=voltage, # For display
515 raw_adc_code=adc_code, # Raw ADC code for calibration
516 timestamp=time.time(),
517 pt_location=f"PT {channel}",
518 temperature=25.0, # Could be read from sensors
519 humidity=50.0,
520 )
521
522 # Apply high-performance filtering on ADC code
523 measurement.filtered_adc_code = (
524 self.apply_kalman_filter_adc(
525 measurement.raw_adc_code, channel
526 )
527 )
528
529 # Calculate pressure if calibrated (using ADC code directly)
530 if channel in self.calibration_polynomials:
531 a, b, c, d = self.calibration_polynomials[channel]
532 measurement.calculated_pressure = (
533 a * (measurement.filtered_adc_code**3)
534 + b * (measurement.filtered_adc_code**2)
535 + c * measurement.filtered_adc_code
536 + d
537 )
538
539 # Update latest measurements
540 self.latest_measurements[channel] = measurement
541
542 # Update performance stats
543 self.update_performance_stats()
544
545 except Exception as e:
546 logger.error(f"Error processing serial data: {e}")
547 time.sleep(0.001) # 1ms sleep on error
548 else:
549 time.sleep(0.01)
550
551 def process_elodin_message(self, msg):
552 """Process PT message from Elodin"""
553 try:
554 # Extract message fields based on message type
555 # RawPTMessage: [timestamp_ns, channel_id, padding, raw_adc_counts, sample_timestamp_ms, status_flags]
556 # CalibratedPTMessage: [timestamp_ns, channel_id, padding, calibrated_pressure_psi, raw_adc_counts, calibration_status]
557
558 packet_id = msg.get("packet_id", [0, 0])
559 table_id = (packet_id[0] << 8) | packet_id[1]
560
561 if table_id == 0x2000: # RawPTMessage
562 channel_id = msg.get("channel_id", 0)
563 raw_adc_counts = msg.get("raw_adc_counts", 0)
564
565 # Convert to signed int32 ADC code
566 if raw_adc_counts >= 0x80000000:
567 adc_code = raw_adc_counts - 0x100000000
568 else:
569 adc_code = raw_adc_counts
570
571 # Convert to voltage for display only
572 voltage = (adc_code * 2.5) / 2147483648.0
573
574 # Create measurement with raw ADC code
575 measurement = PTMeasurement(
576 sensor_id=channel_id,
577 voltage=voltage, # For display
578 raw_adc_code=adc_code, # Raw ADC code for calibration
579 timestamp=time.time(),
580 pt_location=f"PT {channel_id}",
581 temperature=25.0,
582 humidity=50.0,
583 )
584
585 # Apply filtering on ADC code
586 measurement.filtered_adc_code = self.apply_kalman_filter_adc(
587 measurement.raw_adc_code, channel_id
588 )
589
590 # Calculate pressure if calibrated (using ADC code directly)
591 if channel_id in self.calibration_polynomials:
592 a, b, c, d = self.calibration_polynomials[channel_id]
593 measurement.calculated_pressure = (
594 a * (measurement.filtered_adc_code**3)
595 + b * (measurement.filtered_adc_code**2)
596 + c * measurement.filtered_adc_code
597 + d
598 )
599
600 # Update latest measurements
601 self.latest_measurements[channel_id] = measurement
602 self.update_performance_stats()
603
604 elif table_id == 0x2001: # CalibratedPTMessage
605 channel_id = msg.get("channel_id", 0)
606 calibrated_pressure = msg.get("calibrated_pressure_psi", 0.0)
607 raw_adc_counts = msg.get("raw_adc_counts", 0)
608
609 # Convert to signed int32 ADC code
610 if raw_adc_counts >= 0x80000000:
611 adc_code = raw_adc_counts - 0x100000000
612 else:
613 adc_code = raw_adc_counts
614
615 # Convert to voltage for display only
616 voltage = (adc_code * 2.5) / 2147483648.0
617
618 # Create measurement with calibrated pressure
619 measurement = PTMeasurement(
620 sensor_id=channel_id,
621 voltage=voltage, # For display
622 raw_adc_code=adc_code, # Raw ADC code
623 timestamp=time.time(),
624 pt_location=f"PT {channel_id}",
625 temperature=25.0,
626 humidity=50.0,
627 calculated_pressure=calibrated_pressure,
628 )
629
630 # Apply filtering on ADC code
631 measurement.filtered_adc_code = self.apply_kalman_filter_adc(
632 measurement.raw_adc_code, channel_id
633 )
634
635 # Update latest measurements
636 self.latest_measurements[channel_id] = measurement
637 self.update_performance_stats()
638
639 except Exception as e:
640 logger.error(f"Error processing Elodin message: {e}")
641
642 def apply_kalman_filter_adc(self, adc_code: float, sensor_id: int) -> float:
643 """Apply Kalman filter for high-performance ADC code filtering"""
644 if not hasattr(self, "kalman_states_adc"):
645 self.kalman_states_adc = {}
646
647 if sensor_id not in self.kalman_states_adc:
648 # Initialize Kalman filter state
649 self.kalman_states_adc[sensor_id] = {
650 "x": adc_code, # State estimate
651 "P": 1000.0, # Error covariance (larger for ADC codes)
652 "Q": 10.0, # Process noise
653 "R": 100.0, # Measurement noise
654 }
655
656 state = self.kalman_states_adc[sensor_id]
657
658 # Predict step
659 state["P"] += state["Q"]
660
661 # Update step
662 K = state["P"] / (state["P"] + state["R"]) # Kalman gain
663 state["x"] += K * (adc_code - state["x"])
664 state["P"] *= 1 - K
665
666 return state["x"]
667
668 def apply_kalman_filter(self, voltage: float, sensor_id: int) -> float:
669 """Apply Kalman filter for voltage (for display only)"""
670 if not hasattr(self, "kalman_states"):
671 self.kalman_states = {}
672
673 if sensor_id not in self.kalman_states:
674 self.kalman_states[sensor_id] = {
675 "x": voltage,
676 "P": 1.0,
677 "Q": 0.01,
678 "R": 0.1,
679 }
680
681 state = self.kalman_states[sensor_id]
682 state["P"] += state["Q"]
683 K = state["P"] / (state["P"] + state["R"])
684 state["x"] += K * (voltage - state["x"])
685 state["P"] *= 1 - K
686
687 return state["x"]
688
689 def update_performance_stats(self):
690 """Update performance statistics"""
691 current_time = time.time()
692 time_diff = current_time - self.performance_stats["last_update"]
693
694 if time_diff > 0.1: # Update every 100ms
695 self.performance_stats["data_rate_hz"] = (
696 1.0 / time_diff if time_diff > 0 else 0
697 )
698 self.performance_stats["latency_ms"] = time_diff * 1000
699 self.performance_stats["last_update"] = current_time
700
701 def update_gui(self):
702 """Update GUI elements"""
703 # Update data tree
704 for item in self.data_tree.get_children():
705 values = list(self.data_tree.item(item)["values"])
706 sensor_id = int(values[0].split()[1])
707
708 if sensor_id in self.latest_measurements:
709 measurement = self.latest_measurements[sensor_id]
710 values[2] = f"{measurement.voltage:.3f}"
711 values[3] = f"{measurement.calculated_pressure:.1f}"
712 values[4] = f"{measurement.filtered_voltage:.3f}"
713 values[5] = (
714 "Online" if measurement.calculated_pressure > 0 else "Uncalibrated"
715 )
716 else:
717 values[2] = "0.000"
718 values[3] = "0.000"
719 values[4] = "0.000"
720 values[5] = "Offline"
721
722 self.data_tree.item(item, values=values)
723
724 # Update plots
725 self.update_plots()
726
727 # Update performance indicators
728 self.data_rate_var.set(
729 f"Data Rate: {self.performance_stats['data_rate_hz']:.1f} Hz"
730 )
731 self.latency_var.set(f"Latency: {self.performance_stats['latency_ms']:.1f} ms")
732
733 # Schedule next update
734 self.root.after(50, self.update_gui) # 20 FPS update rate
735
736 def update_plots(self):
737 """Update real-time plots"""
738 current_time = time.time()
739
740 # Clear and redraw voltage plot
741 self.ax_voltage.clear()
742 self.ax_voltage.set_title("Real-Time Voltage Readings", color="white")
743 self.ax_voltage.set_ylabel("Voltage (V)", color="white")
744 self.ax_voltage.tick_params(colors="white")
745 self.ax_voltage.set_facecolor("#3b3b3b")
746
747 # Plot voltage data for active sensors
748 for sensor_id, measurement in self.latest_measurements.items():
749 if len(self.pt_measurements[sensor_id]) > 1:
750 times = [
751 m.timestamp - current_time for m in self.pt_measurements[sensor_id]
752 ]
753 voltages = [m.filtered_voltage for m in self.pt_measurements[sensor_id]]
754 self.ax_voltage.plot(
755 times, voltages, label=f"PT {sensor_id}", linewidth=1
756 )
757
758 self.ax_voltage.legend()
759
760 # Clear and redraw pressure plot
761 self.ax_pressure.clear()
762 self.ax_pressure.set_title("Real-Time Pressure Readings", color="white")
763 self.ax_pressure.set_xlabel("Time (s)", color="white")
764 self.ax_pressure.set_ylabel("Pressure (kPa)", color="white")
765 self.ax_pressure.tick_params(colors="white")
766 self.ax_pressure.set_facecolor("#3b3b3b")
767
768 # Plot pressure data for calibrated sensors
769 for sensor_id, measurement in self.latest_measurements.items():
770 if (
771 sensor_id in self.calibration_polynomials
772 and len(self.pt_measurements[sensor_id]) > 1
773 ):
774 times = [
775 m.timestamp - current_time for m in self.pt_measurements[sensor_id]
776 ]
777 pressures = [
778 m.calculated_pressure for m in self.pt_measurements[sensor_id]
779 ]
780 self.ax_pressure.plot(
781 times, pressures, label=f"PT {sensor_id}", linewidth=1
782 )
783
784 self.ax_pressure.legend()
785
786 self.canvas.draw_idle()
787
788 def toggle_connection(self):
789 """Toggle data source connection"""
790 if self.use_elodin:
791 if self.elodin_connected:
792 self.disconnect()
793 else:
794 self.connect()
795 else:
796 if self.serial_connection and self.serial_connection.is_open:
797 self.disconnect()
798 else:
799 self.connect()
800
801 def connect(self):
802 """Connect to data source (Serial or Elodin)"""
803 if self.use_elodin:
804 if self.setup_elodin():
805 self.connect_btn.config(text="Disconnect")
806 self.calibration_btn.config(state="normal")
807 self.status_var.set("Connected to Elodin")
808 logger.info("Connected to Elodin")
809 else:
810 self.status_var.set("Elodin Connection Failed")
811 else:
812 if self.setup_serial():
813 self.connect_btn.config(text="Disconnect")
814 self.calibration_btn.config(state="normal")
815 self.status_var.set("Connected")
816 logger.info("Connected to serial port")
817 else:
818 self.status_var.set("Connection Failed")
819
820 def disconnect(self):
821 """Disconnect from data source"""
822 if self.use_elodin:
823 self.disconnect_elodin()
824 else:
825 if self.serial_connection:
826 self.serial_connection.close()
827 logger.info("Disconnected from serial port")
828
829 self.connect_btn.config(text="Connect")
830 self.calibration_btn.config(state="disabled")
831 self.record_btn.config(state="disabled")
832 self.fit_btn.config(state="disabled")
833 self.status_var.set("Disconnected")
834
835 def toggle_calibration(self):
836 """Toggle calibration mode"""
837 self.calibration_active = not self.calibration_active
838 if self.calibration_active:
839 self.calibration_btn.config(text="Stop Calibration")
840 self.record_btn.config(state="normal")
841 self.status_var.set("Calibration Mode Active")
842 else:
843 self.calibration_btn.config(text="Start Calibration")
844 self.record_btn.config(state="disabled")
845 self.fit_btn.config(state="normal")
846 self.status_var.set("Ready")
847
848 def record_calibration_point(self):
849 """Record current measurements as calibration point"""
850 if not self.calibration_active:
851 return
852
853 # Get reference pressures from GUI
854 reference_pressures = []
855 for i, ref_var in enumerate(self.ref_pressures):
856 try:
857 pressure = float(ref_var.get()) if ref_var.get() else 0.0
858 reference_pressures.append(pressure)
859 except ValueError:
860 messagebox.showerror(
861 "Invalid Input", f"Invalid pressure value for gauge {i+1}"
862 )
863 return
864
865 # Get current measurements (ADC codes for calibration, voltages for reference)
866 adc_codes = []
867 voltages = []
868 for sensor_id in range(9):
869 if sensor_id in self.latest_measurements:
870 adc_codes.append(
871 int(self.latest_measurements[sensor_id].filtered_adc_code)
872 )
873 voltages.append(self.latest_measurements[sensor_id].voltage)
874 else:
875 adc_codes.append(0)
876 voltages.append(0.0)
877
878 # Create calibration point
879 calibration_point = CalibrationPoint(
880 adc_codes=adc_codes, # Raw ADC codes for calibration
881 voltages=voltages, # Voltages for display/reference
882 reference_pressures=reference_pressures,
883 timestamp=time.time(),
884 environmental_conditions={"temperature": 25.0, "humidity": 50.0},
885 )
886
887 self.calibration_points.append(calibration_point)
888
889 # Show confirmation
890 messagebox.showinfo(
891 "Data Recorded",
892 f"Calibration point recorded!\nTotal points: {len(self.calibration_points)}",
893 )
894
895 def fit_calibration(self):
896 """Fit calibration polynomials"""
897 if len(self.calibration_points) < 3:
898 messagebox.showerror(
899 "Insufficient Data", "Need at least 3 calibration points"
900 )
901 return
902
903 # Fit polynomials for each sensor using ADC codes
904 for sensor_id in range(9):
905 adc_codes = []
906 pressures = []
907
908 for point in self.calibration_points:
909 if sensor_id < len(point.adc_codes) and sensor_id < len(
910 point.reference_pressures
911 ):
912 adc_codes.append(point.adc_codes[sensor_id])
913 pressures.append(point.reference_pressures[sensor_id])
914
915 if (
916 len(adc_codes) >= 3 and len(set(adc_codes)) > 1
917 ): # Need unique ADC code values
918 try:
919 # Fit cubic polynomial: psi = A*adc^3 + B*adc^2 + C*adc + D
920 # polyfit returns [A, B, C, D] for A*x^3 + B*x^2 + C*x + D
921 poly_coeffs = np.polyfit(adc_codes, pressures, 3)
922 self.calibration_polynomials[sensor_id] = tuple(poly_coeffs)
923
924 # Calculate R-squared
925 y_pred = np.polyval(poly_coeffs, adc_codes)
926 ss_res = np.sum((pressures - y_pred) ** 2)
927 ss_tot = np.sum((pressures - np.mean(pressures)) ** 2)
928 r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
929
930 logger.info(
931 f"PT {sensor_id} calibration fitted: R² = {r_squared:.3f}"
932 )
933
934 except Exception as e:
935 logger.error(f"Failed to fit calibration for PT {sensor_id}: {e}")
936
937 self.save_btn.config(state="normal")
938 messagebox.showinfo(
939 "Calibration Complete", "Calibration polynomials fitted successfully!"
940 )
941
942 def save_calibration(self):
943 """Save calibration data"""
944 filename = filedialog.asksaveasfilename(
945 defaultextension=".json",
946 filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
947 title="Save Calibration Data",
948 )
949
950 if filename:
951 calibration_data = {
952 "timestamp": datetime.now().isoformat(),
953 "calibration_points": [
954 {
955 "adc_codes": cp.adc_codes,
956 "voltages": cp.voltages, # For reference/display
957 "reference_pressures": cp.reference_pressures,
958 "timestamp": cp.timestamp,
959 "environmental_conditions": cp.environmental_conditions,
960 }
961 for cp in self.calibration_points
962 ],
963 "calibration_polynomials": {
964 str(sensor_id): coeffs.tolist()
965 for sensor_id, coeffs in self.calibration_polynomials.items()
966 },
967 }
968
969 with open(filename, "w") as f:
970 json.dump(calibration_data, f, indent=2)
971
972 messagebox.showinfo("Saved", f"Calibration data saved to {filename}")
973
974 def run(self):
975 """Run the GUI"""
976 try:
977 self.root.mainloop()
978 except KeyboardInterrupt:
979 logger.info("GUI interrupted by user")
980 finally:
981 self.cleanup()
982
983 def cleanup(self):
984 """Cleanup resources"""
985 self.stop_serial.set()
986 if self.serial_connection and self.serial_connection.is_open:
987 self.serial_connection.close()
988 if self.elodin_client and self.elodin_connected:
989 self.elodin_client.disconnect()
990 logger.info("GUI cleanup complete")
991
992
993def main():
994 """Main entry point"""
995 try:
996 app = HighPerformanceCalibrationGUI()
997 app.run()
998 except Exception as e:
999 logger.error(f"GUI failed to start: {e}")
1000 messagebox.showerror("Startup Error", f"Failed to start calibration GUI: {e}")
1001
1002
1003if __name__ == "__main__":
1004 main()
static void configure(gnss_core_t *c, uint32_t now_us)
Definition gnss_core.c:34
std::pair< iterator, bool > insert(value_type &&value)
Definition json.hpp:19328
TOML_NODISCARD_CTOR time() noexcept=default