STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
lc_calibration_gui.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3LC Calibration GUI – Live plots for LC_Calibration
4
5Receives DAQv2-Comms SENSOR_DATA packets over UDP from LC_Calibration firmware,
6decodes them, and displays real-time voltage plots for each LC connector (1, 2, 3, 6, 7).
7
8Ensure the firmware's receiverIP matches this machine's IP (or use 192.168.2.20)
9and that the GUI listens on port 5006.
10
11 pip install -r requirements.txt
12 python lc_calibration_gui.py [-p 5006] [-a 0.0.0.0]
13
14 On macOS, if you get a segfault: use PyQt5 instead of PyQt6
15 (pip install PyQt5; this script will try PyQt5 if PyQt6 is unavailable).
16"""
17
18import os
19import socket
20import struct
21import sys
22import time
23from typing import Optional, Tuple, List, Dict
24from collections import deque
25from datetime import datetime
26
27# On macOS, PyQt6.5+ can segfault with pyqtgraph; prefer PyQt5 when available.
28if sys.platform == "darwin":
29 os.environ.setdefault("QT_MAC_WANTS_LAYER", "1")
30 try:
31 from PyQt5 import QtCore, QtGui, QtWidgets
32
33 _QT_HORIZ = QtCore.Qt.Horizontal
34 except ImportError:
35 from PyQt6 import QtCore, QtGui, QtWidgets
36
37 _QT_HORIZ = QtCore.Qt.Orientation.Horizontal
38else:
39 try:
40 from PyQt6 import QtCore, QtGui, QtWidgets
41
42 _QT_HORIZ = QtCore.Qt.Orientation.Horizontal
43 except ImportError:
44 from PyQt5 import QtCore, QtGui, QtWidgets
45
46 _QT_HORIZ = QtCore.Qt.Horizontal
47
48import pyqtgraph as pg
49import numpy as np
50import csv
51
52# Reduce chance of pyqtgraph/Qt segfaults on macOS (OpenGL + Qt on Mac can crash)
53pg.setConfigOptions(antialias=False, useOpenGL=False)
54
55# DAQv2-Comms protocol (match firmware)
56MAX_PACKET_SIZE = 512
57PacketType = type("PacketType", (), {"SENSOR_DATA": 3})
58PACKET_HEADER_FORMAT = "<BBI"
59PACKET_HEADER_SIZE = 6
60SENSOR_DATA_PACKET_FORMAT = "<BB"
61SENSOR_DATA_PACKET_SIZE = 2
62SENSOR_DATA_CHUNK_FORMAT = "<I"
63SENSOR_DATA_CHUNK_SIZE = 4
64SENSOR_DATAPOINT_FORMAT = "<Bf" # sensor_id uint8, data float (sent as uint32 bits)
65SENSOR_DATAPOINT_SIZE = 5
66
67DEFAULT_PORT = 5006
68DEFAULT_WINDOW_SECONDS = 30.0
69MAX_POINTS = 10000
70UPDATE_INTERVAL_MS = 100
71NUM_LC_CONNECTORS = 10
72NUM_LC_SENSORS = 5 # Connectors 1, 2, 3, 6, 7
73# Map sensor index (0-4) to connector number (1, 2, 3, 6, 7)
74SENSOR_TO_CONNECTOR = [1, 2, 3, 6, 7]
75
76# Colors for the 10 LC connectors
77LC_COLORS = [
78 (255, 80, 80), # Red - LC 1
79 (80, 255, 80), # Green - LC 2
80 (80, 150, 255), # Blue - LC 3
81 (255, 200, 80), # Orange - LC 4
82 (200, 80, 255), # Purple - LC 5
83 (80, 255, 255), # Cyan - LC 6
84 (255, 150, 150), # Light Red - LC 7
85 (150, 255, 150), # Light Green - LC 8
86 (150, 200, 255), # Light Blue - LC 9
87 (255, 255, 80), # Yellow - LC 10
88]
89
90
91def parse_packet_header(data: bytes) -> Optional[Tuple[int, int, int]]:
92 if len(data) < PACKET_HEADER_SIZE:
93 return None
94 try:
95 return struct.unpack(PACKET_HEADER_FORMAT, data[:PACKET_HEADER_SIZE])
96 except struct.error:
97 return None
98
99
100def parse_sensor_data_packet(data: bytes) -> Optional[Tuple[dict, List[dict]]]:
101 if len(data) < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE:
102 return None
103 header = parse_packet_header(data)
104 if header is None or header[0] != PacketType.SENSOR_DATA:
105 return None
106 packet_type, version, timestamp = header
107 offset = PACKET_HEADER_SIZE
108 try:
109 num_chunks, num_sensors = struct.unpack(
110 SENSOR_DATA_PACKET_FORMAT,
111 data[offset : offset + SENSOR_DATA_PACKET_SIZE],
112 )
113 except struct.error:
114 return None
115 offset += SENSOR_DATA_PACKET_SIZE
116 per_chunk = SENSOR_DATA_CHUNK_SIZE + num_sensors * SENSOR_DATAPOINT_SIZE
117 if (
118 len(data)
119 < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE + num_chunks * per_chunk
120 ):
121 return None
122 chunks = []
123 for _ in range(num_chunks):
124 (chunk_ts,) = struct.unpack(
125 SENSOR_DATA_CHUNK_FORMAT, data[offset : offset + SENSOR_DATA_CHUNK_SIZE]
126 )
127 offset += SENSOR_DATA_CHUNK_SIZE
128 datapoints = []
129 for _ in range(num_sensors):
130 sid, val = struct.unpack(
131 SENSOR_DATAPOINT_FORMAT, data[offset : offset + SENSOR_DATAPOINT_SIZE]
132 )
133 datapoints.append({"sensor_id": sid, "data": val})
134 offset += SENSOR_DATAPOINT_SIZE
135 chunks.append({"timestamp": chunk_ts, "datapoints": datapoints})
136 return (
137 {"packet_type": packet_type, "version": version, "timestamp": timestamp},
138 chunks,
139 )
140
141
142class UDPReceiver(QtCore.QThread):
143 sensor_data_received = QtCore.pyqtSignal(dict, list)
144 status_update = QtCore.pyqtSignal(str)
145
146 def __init__(self, port: int, bind_address: str):
147 super().__init__()
148 self.port = port
149 self.bind_address = bind_address
150 self._stop = False
151 self.sock = None
153 self.total_bytes = 0
154 self.start_time = None
155
156 def stop(self):
157 self._stop = True
158 if self.sock:
159 try:
160 self.sock.close()
161 except Exception:
162 pass
163
164 def get_stats(self) -> Dict:
165 if self.start_time is None:
166 return {
167 "packets": 0,
168 "bytes": 0,
169 "packets_per_sec": 0.0,
170 "bytes_per_sec": 0.0,
171 }
172 elapsed = time.time() - self.start_time
173 pps = self.total_packets / elapsed if elapsed > 0 else 0.0
174 bps = self.total_bytes / elapsed if elapsed > 0 else 0.0
175 return {
176 "packets": self.total_packets,
177 "bytes": self.total_bytes,
178 "packets_per_sec": pps,
179 "bytes_per_sec": bps,
180 }
181
182 def run(self):
183 self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
184 self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
185 self.sock.settimeout(0.1)
186 try:
187 self.sock.bind((self.bind_address, self.port))
188 msg = f"Listening {self.bind_address}:{self.port} — Set ESP RECEIVER_IP to this PC. If Packets stay 0: Mac: allow Python in Firewall."
189 self.status_update.emit(msg)
190 self.start_time = time.time()
191 except OSError as e:
192 self.status_update.emit(f"Bind error: {e}")
193 return
194 while not self._stop:
195 try:
196 data, _ = self.sock.recvfrom(MAX_PACKET_SIZE)
197 self.total_packets += 1
198 self.total_bytes += len(data)
199 header = parse_packet_header(data)
200 if header is not None and header[0] == PacketType.SENSOR_DATA:
201 result = parse_sensor_data_packet(data)
202 if result:
203 self.sensor_data_received.emit(result[0], result[1])
204 except socket.timeout:
205 continue
206 except Exception as e:
207 if not self._stop:
208 self.status_update.emit(f"Error: {e}")
209 if self.sock:
210 try:
211 self.sock.close()
212 except Exception:
213 pass
214 self.status_update.emit("Stopped")
215
216
217# ---------------------- Settings window ----------------------
218class SettingsWindow(QtWidgets.QDialog):
219 def __init__(self, parent):
220 super().__init__(parent)
221 self.parent_app = parent
222 self.setWindowTitle("Settings")
223 self.resize(360, 200)
224
225 layout = QtWidgets.QVBoxLayout(self)
226
227 # Window seconds
228 layout.addWidget(QtWidgets.QLabel("Viewing window (seconds)"))
229 row = QtWidgets.QHBoxLayout()
230 self.slider = QtWidgets.QSlider(_QT_HORIZ)
231 self.slider.setMinimum(5)
232 self.slider.setMaximum(120)
233 self.slider.setValue(int(self.parent_app.window_seconds))
234 self.slider.valueChanged.connect(self._on_change)
235 self.lbl = QtWidgets.QLabel(f"{self.parent_app.window_seconds:.1f}s")
236 row.addWidget(self.slider, 1)
237 row.addWidget(self.lbl)
238 layout.addLayout(row)
239
240 btn = QtWidgets.QPushButton("Close")
241 btn.clicked.connect(self.accept)
242 layout.addWidget(btn)
243
244 def _on_change(self, val):
245 self.parent_app.window_seconds = float(val)
246 self.lbl.setText(f"{float(val):.1f}s")
247
248
249# ---------------------- Main application window ----------------------
250class LCPlotWindow(QtWidgets.QMainWindow):
251 def __init__(self, port: int = DEFAULT_PORT, bind_address: str = "0.0.0.0"):
252 super().__init__()
253 self.setWindowTitle("LC Calibration - Live Plots")
254 self.resize(1400, 800)
255
256 self.port = port
257 self.bind_address = bind_address
258 self.window_seconds = DEFAULT_WINDOW_SECONDS
259 self.test_start_time = None
262
263 central = QtWidgets.QWidget()
264 self.setCentralWidget(central)
265 root = QtWidgets.QVBoxLayout(central)
266
267 # Top bar
268 top = QtWidgets.QHBoxLayout()
269 self.status_label = QtWidgets.QLabel("Starting...")
270 self.status_label.setStyleSheet("font-weight: bold; padding: 5px;")
271 top.addWidget(self.status_label)
272 top.addStretch()
273
274 btn_settings = QtWidgets.QPushButton("Settings")
275 btn_settings.clicked.connect(self._open_settings)
276 top.addWidget(btn_settings)
277
278 btn_export = QtWidgets.QPushButton("Export CSV")
279 btn_export.clicked.connect(self._export_csv)
280 top.addWidget(btn_export)
281
282 self.chk_autoscale = QtWidgets.QCheckBox("Autoscale Y")
283 self.chk_autoscale.setChecked(True)
284 self.chk_autoscale.stateChanged.connect(self._on_autoscale)
285 top.addWidget(self.chk_autoscale)
286
287 root.addLayout(top)
288
289 # Main split: plot on left, controls on right
290 main = QtWidgets.QHBoxLayout()
291 root.addLayout(main, 1)
292
293 # Plot
294 self.plot = pg.PlotWidget()
295 self.plot.setLabel("bottom", "Time", units="s")
296 self.plot.setLabel("left", "Voltage", units="V")
297 self.plot.showGrid(x=True, y=True, alpha=0.3)
298 self.plot.setTitle("LC Calibration - All Connectors")
299 self.plot.setClipToView(True)
300 self.plot.setDownsampling(mode="peak")
301 self.plot.setMouseEnabled(x=True, y=True)
302 self.legend = self.plot.addLegend(labelTextSize="10pt")
303 main.addWidget(self.plot, 1)
304
305 # Right panel
306 right = QtWidgets.QVBoxLayout()
307 main.addLayout(right)
308
309 # Channel toggles
310 right.addWidget(QtWidgets.QLabel("Show connectors:"))
311 self.chk = {}
312 self.curves = {}
313 for conn in range(1, NUM_LC_CONNECTORS + 1):
314 cb = QtWidgets.QCheckBox(f"LC {conn}")
315 # Only enable connectors that we're actually reading (1, 2, 3, 6, 7)
316 cb.setChecked(conn in SENSOR_TO_CONNECTOR)
317 cb.setEnabled(conn in SENSOR_TO_CONNECTOR)
318 cb.stateChanged.connect(self._on_toggle)
319 right.addWidget(cb)
320 self.chk[conn] = cb
321 color = LC_COLORS[conn - 1]
322 pen = pg.mkPen(color=color, width=2)
323 self.curves[conn] = self.plot.plot([], [], name=f"LC {conn}", pen=pen)
324
325 # Statistics box
326 box = QtWidgets.QGroupBox("Statistics")
327 form = QtWidgets.QVBoxLayout(box)
328
329 self.lbl_sps = QtWidgets.QLabel("Packets/sec: n/a")
330 form.addWidget(self.lbl_sps)
331
332 form.addWidget(self._hline())
333 form.addWidget(QtWidgets.QLabel("Current voltage:"))
334
335 self.per_lc = {}
336 for conn in SENSOR_TO_CONNECTOR: # Only show connectors we're reading
337 lbl = QtWidgets.QLabel(f"LC {conn}: n/a")
338 form.addWidget(lbl)
339 self.per_lc[conn] = lbl
340 right.addWidget(box)
341
342 # Status box
343 status_box = QtWidgets.QGroupBox("Status")
344 status_layout = QtWidgets.QVBoxLayout(status_box)
345 self.lbl_status = QtWidgets.QLabel("Idle")
346 self.lbl_status.setWordWrap(True)
347 status_layout.addWidget(self.lbl_status)
348 right.addWidget(status_box)
349 right.addStretch(1)
350
351 # Data storage
352 self.t0 = None # First timestamp (wall clock time)
353 self.t = {conn: deque(maxlen=MAX_POINTS) for conn in SENSOR_TO_CONNECTOR}
354 self.v = {conn: deque(maxlen=MAX_POINTS) for conn in SENSOR_TO_CONNECTOR}
355
356 # For SPS calculation
357 self.last_sps_time = None
359 self.autoscale = True
360
361 # Timer for plot updates
362 self.timer = QtCore.QTimer(self)
363 self.timer.timeout.connect(self._update_plot)
364 self.timer.start(UPDATE_INTERVAL_MS)
366
367 # UDP receiver
368 self.receiver = None
369 self.start_receiver()
370
371 # Statistics timer
372 self.stats_timer = QtCore.QTimer(self)
373 self.stats_timer.timeout.connect(self.update_statistics)
374 self.stats_timer.start(500)
375
376 def _hline(self):
377 line = QtWidgets.QFrame()
378 line.setFrameShape(QtWidgets.QFrame.Shape.HLine)
379 line.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken)
380 return line
381
382 def start_receiver(self):
383 self.receiver = UDPReceiver(port=self.port, bind_address=self.bind_address)
384 self.receiver.sensor_data_received.connect(self.on_sensor_data)
385 self.receiver.status_update.connect(self.status_label.setText)
386 self.receiver.start()
387
388 def on_sensor_data(self, header: dict, chunks: List[dict]):
389 t0 = time.time() - self.stats_start_time
390 for ch in chunks:
391 for dp in ch["datapoints"]:
392 sensor_id = int(dp["sensor_id"])
393 if 0 <= sensor_id < len(SENSOR_TO_CONNECTOR):
394 connector = SENSOR_TO_CONNECTOR[sensor_id]
395 voltage = float(dp["data"])
396 if connector in self.t:
397 self.t[connector].append(t0)
398 self.v[connector].append(voltage)
399 self.sample_count += 1
400 self._plot_needs_update = True
401
402 def _on_toggle(self):
403 self._plot_needs_update = True
404
405 def _update_plot(self):
406 has_data = any(len(self.t.get(conn, [])) > 0 for conn in SENSOR_TO_CONNECTOR)
407 if not has_data and not self._plot_needs_update:
408 return
409
410 self._plot_needs_update = False
411
412 # Update curves
413 for conn in SENSOR_TO_CONNECTOR:
414 cb = self.chk.get(conn)
415 if cb and cb.isChecked():
416 t_data = self.t.get(conn, [])
417 v_data = self.v.get(conn, [])
418 if t_data and v_data:
419 self.curves[conn].setData(list(t_data), list(v_data))
420 else:
421 self.curves[conn].setData([], [])
422 else:
423 self.curves[conn].setData([], [])
424
425 # Calculate latest time
426 latest = 0.0
427 for conn in SENSOR_TO_CONNECTOR:
428 if self.chk[conn].isChecked() and self.t.get(conn):
429 latest = max(latest, self.t[conn][-1] if self.t[conn] else 0.0)
430
431 if has_data and latest > 0:
432 xmin = max(0.0, latest - self.window_seconds)
433 self.plot.setXRange(xmin, max(xmin + 1e-3, latest), padding=0)
434
435 # Y limits
436 if self.autoscale:
437 values = []
438 for conn in SENSOR_TO_CONNECTOR:
439 if self.chk[conn].isChecked():
440 v_data = self.v.get(conn, [])
441 if v_data:
442 values.extend(v_data)
443 if values:
444 vmin, vmax = min(values), max(values)
445 if vmax == vmin:
446 pad = 0.1 if vmax == 0 else abs(vmax) * 0.1
447 self.plot.setYRange(vmin - pad, vmax + pad, padding=0)
448 else:
449 rng = vmax - vmin
450 self.plot.setYRange(
451 vmin - 0.1 * rng, vmax + 0.1 * rng, padding=0
452 )
453
454 # Current voltage display
455 if has_data:
456 for conn in SENSOR_TO_CONNECTOR:
457 v_data = self.v.get(conn, [])
458 if v_data:
459 current_v = v_data[-1]
460 self.per_lc[conn].setText(f"LC {conn}: {current_v:.6f} V")
461 else:
462 self.per_lc[conn].setText(f"LC {conn}: n/a")
463 else:
464 for conn in SENSOR_TO_CONNECTOR:
465 self.per_lc[conn].setText(f"LC {conn}: n/a")
466
468 if self.receiver is None:
469 return
470 s = self.receiver.get_stats()
471 self.lbl_sps.setText(
472 f"Packets/sec: {s['packets_per_sec']:.2f} | Total: {s['packets']}"
473 )
474
475 def _open_settings(self):
476 dlg = SettingsWindow(self)
477 dlg.exec()
478
479 def _on_autoscale(self):
480 self.autoscale = self.chk_autoscale.isChecked()
481 self._plot_needs_update = True
482 self._update_plot()
483
484 def _export_csv(self):
485 """Export current data to CSV file"""
486 total_points = sum(len(self.t[conn]) for conn in SENSOR_TO_CONNECTOR)
487 if total_points == 0:
488 QtWidgets.QMessageBox.warning(
489 self, "No Data", "No data available to export."
490 )
491 return
492
493 if self.test_start_time:
494 timestamp = self.test_start_time.strftime("%Y%m%d_%H%M%S")
495 else:
496 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
497 default_filename = f"lc_calibration_{timestamp}.csv"
498
499 filename, _ = QtWidgets.QFileDialog.getSaveFileName(
500 self, "Save CSV File", default_filename, "CSV Files (*.csv);;All Files (*)"
501 )
502
503 if not filename:
504 return
505
506 try:
507 with open(filename, "w", newline="", encoding="utf-8") as csvfile:
508 writer = csv.writer(csvfile)
509
510 # Write header
511 header = ["Time (s)"]
512 for conn in SENSOR_TO_CONNECTOR:
513 header.append(f"LC {conn} (V)")
514 writer.writerow(header)
515
516 # Find max points across all connectors
517 max_points = max(len(self.t[conn]) for conn in SENSOR_TO_CONNECTOR)
518
519 # Write data
520 for i in range(max_points):
521 row = []
522
523 # Use first connector's time as reference
524 if i < len(self.t[SENSOR_TO_CONNECTOR[0]]):
525 row.append(f"{self.t[SENSOR_TO_CONNECTOR[0]][i]:.6f}")
526 else:
527 row.append("")
528
529 # Add voltage for each connector
530 for conn in SENSOR_TO_CONNECTOR:
531 if i < len(self.v[conn]):
532 row.append(f"{self.v[conn][i]:.6f}")
533 else:
534 row.append("")
535
536 writer.writerow(row)
537
538 points_per_connector = [len(self.t[conn]) for conn in SENSOR_TO_CONNECTOR]
539 QtWidgets.QMessageBox.information(
540 self,
541 "Export Successful",
542 f"Data exported successfully to:\n{filename}\n\n"
543 f"Points per connector: {points_per_connector}",
544 )
545 except Exception as e:
546 QtWidgets.QMessageBox.critical(
547 self, "Export Failed", f"Failed to export data:\n{str(e)}"
548 )
549
550 def closeEvent(self, event: QtGui.QCloseEvent):
551 if self.receiver:
552 self.receiver.stop()
553 self.receiver.wait(2000)
554 event.accept()
555
556
557# ---------------------- Entry point ----------------------
558def main():
559 import argparse
560
561 parser = argparse.ArgumentParser(
562 description="LC Calibration GUI – live LC plots from DAQv2-Comms UDP"
563 )
564 parser.add_argument(
565 "-p",
566 "--port",
567 type=int,
568 default=DEFAULT_PORT,
569 help=f"UDP port (default {DEFAULT_PORT})",
570 )
571 parser.add_argument("-a", "--address", default="0.0.0.0", help="Bind address")
572 args = parser.parse_args()
573
574 app = QtWidgets.QApplication(sys.argv)
575 pg.setConfigOptions(antialias=False)
576 w = LCPlotWindow(port=args.port, bind_address=args.address)
577 w.show()
578 try:
579 sys.exit(app.exec() if hasattr(app, "exec") else app.exec_())
580 except KeyboardInterrupt:
581 sys.exit(0)
582
583
584if __name__ == "__main__":
585 main()
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
closeEvent(self, QtGui.QCloseEvent event)
__init__(self, int port=DEFAULT_PORT, str bind_address="0.0.0.0")
__init__(self, int port, str bind_address)
static void stop(emmc_log_t *log, emmc_disk_state_t why)
Definition emmc_log.c:262
static void start(gnss_frame_parser_t *p, uint8_t byte)
Definition gnss_frame.c:45
Optional[Tuple[dict, List[dict]]] parse_sensor_data_packet(bytes data)
Optional[Tuple[int, int, int]] parse_packet_header(bytes data)
Definition run.py:1
static void emit(sensors_core_t *c, uint8_t type, const void *data, uint8_t len)
TOML_NODISCARD_CTOR time() noexcept=default
int main()