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))
169 conn_frame = ttk.Frame(control_frame)
170 conn_frame.pack(fill=tk.X, pady=(0, 10))
173 source_frame = ttk.Frame(conn_frame)
174 source_frame.pack(side=tk.LEFT, padx=(0, 10))
176 ttk.Label(source_frame, text=
"Data Source:").pack(side=tk.LEFT)
178 source_combo = ttk.Combobox(
181 values=[
"Serial",
"Elodin"],
185 source_combo.pack(side=tk.LEFT, padx=(5, 0))
189 serial_frame = ttk.Frame(conn_frame)
190 serial_frame.pack(side=tk.LEFT, padx=(0, 10))
192 ttk.Label(serial_frame, text=
"Serial Port:").pack(side=tk.LEFT)
194 port_combo = ttk.Combobox(serial_frame, textvariable=self.
port_var, width=15)
196 port_combo.pack(side=tk.LEFT, padx=(5, 10))
205 conn_frame, text=
"Elodin: Not Connected", foreground=
"gray"
210 calib_frame = ttk.Frame(control_frame)
211 calib_frame.pack(fill=tk.X, pady=(0, 10))
215 text=
"Start Calibration",
223 text=
"Record Data Point",
227 self.
record_btn.pack(side=tk.LEFT, padx=(0, 10))
231 text=
"Fit Calibration",
235 self.
fit_btn.pack(side=tk.LEFT, padx=(0, 10))
239 text=
"Save Calibration",
246 ref_frame = ttk.Frame(control_frame)
247 ref_frame.pack(fill=tk.X)
249 ttk.Label(ref_frame, text=
"Reference Pressures (kPa):").pack(side=tk.LEFT)
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))
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))
272 data_frame, columns=columns, show=
"headings", height=10
277 self.
data_tree.column(col, width=100, anchor=
"center")
280 scrollbar = ttk.Scrollbar(
281 data_frame, orient=tk.VERTICAL, command=self.
data_tree.yview
285 self.
data_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=
True)
286 scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
301 for i, location
in enumerate(pt_locations):
305 values=(f
"PT {i}", location,
"0.000",
"0.000",
"0.000",
"Offline"),
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",
402 self.serial_connection = serial.Serial(
403 port=self.port_var.get(),
405 timeout=0.001, # 1ms timeout for low latency
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()}")
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}"
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
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"
435 # Connect to new source
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")
442 self.status_var.set("Elodin Connection Failed")
443 # Fallback to serial if 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")
453 self.status_var.set("Connection Failed")
455 # No data sources available
456 logger.error("No data sources available")
457 self.status_var.set("No Data Source Available")
458 messagebox.showerror(
460 "Neither serial nor Elodin is available.\n"
461 "Install pyserial or elodin-client to use calibration GUI.",
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
478 messages = self.elodin_client.poll_messages(
482 self.process_elodin_message(msg)
483 except Exception as e:
484 logger.error(f"Error processing Elodin data: {e}")
486 elif self.serial_connection and self.serial_connection.is_open:
488 # Read binary data with minimal latency
490 self.serial_connection.in_waiting >= 20
491 ): # ESP32SampleRecord size
492 data = self.serial_connection.read(20)
494 # Parse binary data (matching ESP32SampleRecord structure)
503 ) = struct.unpack("<I B i f I f I", data)
505 # Convert voltage_raw (int32) to signed int
506 if voltage_raw >= 0x80000000:
507 adc_code = voltage_raw - 0x100000000
509 adc_code = voltage_raw
512 measurement = PTMeasurement(
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
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
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
539 # Update latest measurements
540 self.latest_measurements[channel] = measurement
542 # Update performance stats
543 self.update_performance_stats()
545 except Exception as e:
546 logger.error(f"Error processing serial data: {e}")
547 time.sleep(0.001) # 1ms sleep on error
551 def process_elodin_message(self, msg):
552 """Process PT message from Elodin"""
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]
558 packet_id = msg.get("packet_id", [0, 0])
559 table_id = (packet_id[0] << 8) | packet_id[1]
561 if table_id == 0x2000: # RawPTMessage
562 channel_id = msg.get("channel_id", 0)
563 raw_adc_counts = msg.get("raw_adc_counts", 0)
565 # Convert to signed int32 ADC code
566 if raw_adc_counts >= 0x80000000:
567 adc_code = raw_adc_counts - 0x100000000
569 adc_code = raw_adc_counts
571 # Convert to voltage for display only
572 voltage = (adc_code * 2.5) / 2147483648.0
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}",
585 # Apply filtering on ADC code
586 measurement.filtered_adc_code = self.apply_kalman_filter_adc(
587 measurement.raw_adc_code, channel_id
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
600 # Update latest measurements
601 self.latest_measurements[channel_id] = measurement
602 self.update_performance_stats()
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)
609 # Convert to signed int32 ADC code
610 if raw_adc_counts >= 0x80000000:
611 adc_code = raw_adc_counts - 0x100000000
613 adc_code = raw_adc_counts
615 # Convert to voltage for display only
616 voltage = (adc_code * 2.5) / 2147483648.0
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}",
627 calculated_pressure=calibrated_pressure,
630 # Apply filtering on ADC code
631 measurement.filtered_adc_code = self.apply_kalman_filter_adc(
632 measurement.raw_adc_code, channel_id
635 # Update latest measurements
636 self.latest_measurements[channel_id] = measurement
637 self.update_performance_stats()
639 except Exception as e:
640 logger.error(f"Error processing Elodin message: {e}")
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 = {}
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
656 state = self.kalman_states_adc[sensor_id]
659 state["P"] += state["Q"]
662 K = state["P"] / (state["P"] + state["R"]) # Kalman gain
663 state["x"] += K * (adc_code - state["x"])
701 def update_gui(self):
702 """Update GUI elements"""
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])
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}"
714 "Online" if measurement.calculated_pressure > 0 else "Uncalibrated"
720 values[5] = "Offline"
722 self.data_tree.item(item, values=values)
727 # Update performance indicators
728 self.data_rate_var.set(
729 f"Data Rate: {self.performance_stats['data_rate_hz']:.1f} Hz"
731 self.latency_var.set(f"Latency: {self.performance_stats['latency_ms']:.1f} ms")
733 # Schedule next update
734 self.root.after(50, self.update_gui) # 20 FPS update rate
736 def update_plots(self):
737 """Update real-time plots"""
738 current_time = time.time()
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")
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:
751 m.timestamp - current_time for m in self.pt_measurements[sensor_id]
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
758 self.ax_voltage.legend()
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")
768 # Plot pressure data for calibrated sensors
769 for sensor_id, measurement in self.latest_measurements.items():
771 sensor_id in self.calibration_polynomials
772 and len(self.pt_measurements[sensor_id]) > 1
775 m.timestamp - current_time for m in self.pt_measurements[sensor_id]
778 m.calculated_pressure for m in self.pt_measurements[sensor_id]
780 self.ax_pressure.plot(
781 times, pressures, label=f"PT {sensor_id}", linewidth=1
784 self.ax_pressure.legend()
786 self.canvas.draw_idle()
802 """Connect to data source (Serial or 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")
810 self.status_var.set("Elodin Connection Failed")
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")
818 self.status_var.set("Connection Failed")
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")
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")
848 def record_calibration_point(self):
849 """Record current measurements as calibration point"""
850 if not self.calibration_active:
853 # Get reference pressures from GUI
854 reference_pressures = []
855 for i, ref_var in enumerate(self.ref_pressures):
857 pressure = float(ref_var.get()) if ref_var.get() else 0.0
858 reference_pressures.append(pressure)
860 messagebox.showerror(
861 "Invalid Input", f"Invalid pressure value for gauge {i+1}"
865 # Get current measurements (ADC codes for calibration, voltages for reference)
868 for sensor_id in range(9):
869 if sensor_id in self.latest_measurements:
871 int(self.latest_measurements[sensor_id].filtered_adc_code)
873 voltages.append(self.latest_measurements[sensor_id].voltage)
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},
887 self.calibration_points.append(calibration_point)
892 f"Calibration point recorded!\nTotal points: {len(self.calibration_points)}",
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"
903 # Fit polynomials for each sensor using ADC codes
904 for sensor_id in range(9):
908 for point in self.calibration_points:
909 if sensor_id < len(point.adc_codes) and sensor_id < len(
910 point.reference_pressures
912 adc_codes.append(point.adc_codes[sensor_id])
913 pressures.append(point.reference_pressures[sensor_id])
916 len(adc_codes) >= 3 and len(set(adc_codes)) > 1
917 ): # Need unique ADC code values
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)
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
931 f"PT {sensor_id} calibration fitted: R² = {r_squared:.3f}"
934 except Exception as e:
935 logger.error(f"Failed to fit calibration for PT {sensor_id}: {e}")
937 self.save_btn.config(state="normal")
939 "Calibration Complete", "Calibration polynomials fitted successfully!"
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",
952 "timestamp": datetime.now().isoformat(),
953 "calibration_points": [
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,
961 for cp in self.calibration_points
963 "calibration_polynomials": {
964 str(sensor_id): coeffs.tolist()
965 for sensor_id, coeffs in self.calibration_polynomials.items()
969 with open(filename, "w") as f:
970 json.dump(calibration_data, f, indent=2)
972 messagebox.showinfo("Saved", f"Calibration data saved to {filename}")