STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
pt_multi_gui.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3PT_BOARD_multi_gui – Live plots for PT_BOARD_Multi_Send
4
5Receives DAQv2-Comms SENSOR_DATA packets over UDP from PT_BOARD_Multi_Send,
6decodes them, and displays real-time voltage plots for each PT (1–10).
7
8Ensure the firmware's receiverIP matches this machine's IP (or use 192.168.2.20)
9and that the GUI listens on port 5007 (PT board). Actuator_Testing uses 5006 so
10both can run together.
11
12 pip install -r requirements.txt
13 python pt_multi_gui.py [-p 5007] [-a 0.0.0.0]
14
15 On macOS, if you get a segfault: use PyQt5 instead of PyQt6
16 (pip install PyQt5; this script will try PyQt5 if PyQt6 is unavailable).
17"""
18
19import csv
20import os
21import socket
22import struct
23import sys
24import time
25from typing import Optional, Tuple, List, Dict
26from collections import deque
27
28# On macOS, PyQt6.5+ can segfault with pyqtgraph; prefer PyQt5 when available.
29if sys.platform == "darwin":
30 os.environ.setdefault("QT_MAC_WANTS_LAYER", "1")
31 try:
32 from PyQt5 import QtCore, QtGui, QtWidgets
33
34 _QT_HORIZ = QtCore.Qt.Horizontal
35 except ImportError:
36 from PyQt6 import QtCore, QtGui, QtWidgets
37
38 _QT_HORIZ = QtCore.Qt.Orientation.Horizontal
39else:
40 try:
41 from PyQt6 import QtCore, QtGui, QtWidgets
42
43 _QT_HORIZ = QtCore.Qt.Orientation.Horizontal
44 except ImportError:
45 from PyQt5 import QtCore, QtGui, QtWidgets
46
47 _QT_HORIZ = QtCore.Qt.Horizontal
48
49import pyqtgraph as pg
50import numpy as np
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 = 5007 # PT board; Actuator_Testing uses 5006 so both can run together
68DEFAULT_WINDOW_SECONDS = 30.0
69MAX_POINTS = 8000
70UPDATE_INTERVAL_MS = 50
71NUM_PTS = 10
72
73PT_COLORS = [
74 (255, 80, 80),
75 (80, 255, 80),
76 (80, 150, 255),
77 (255, 200, 80),
78 (200, 80, 255),
79 (80, 255, 255),
80 (255, 150, 150),
81 (150, 255, 150),
82 (150, 200, 255),
83 (255, 255, 80),
84]
85
86# Per-PT pressure calibration: pressure = A*V^3 + B*V^2 + C*V + D
87# (A, B, C, D) for each PT 1..10. PT 6–10 use PT 1 cal until you add values.
88PT_CALIBRATION = [
89 (
90 -1.14351422603069e-09,
91 6.07260454422966e-06,
92 0.0926898002815586,
93 -45.4811452217083,
94 ), # PT 1
95 (-8.46148513e-09, 3.85591637e-05, 1.50430101e-01, -8.31516318e01), # PT 2
96 (-1.12886040e-08, 5.37687043e-05, 1.24582809e-01, -7.92816379e01), # PT 3
97 (-2.92357884e-09, 1.60928197e-05, 1.73172679e-01, -8.41620248e01), # PT 4
98 (-2.07890243e-09, 7.98159784e-06, 1.92631458e-01, -7.84202084e01), # PT 5
99 (
100 -1.14351422603069e-09,
101 6.07260454422966e-06,
102 0.0926898002815586,
103 -45.4811452217083,
104 ), # PT 6 (placeholder)
105 (
106 -1.14351422603069e-09,
107 6.07260454422966e-06,
108 0.0926898002815586,
109 -45.4811452217083,
110 ), # PT 7 (placeholder)
111 (
112 -1.14351422603069e-09,
113 6.07260454422966e-06,
114 0.0926898002815586,
115 -45.4811452217083,
116 ), # PT 8 (placeholder)
117 (
118 -1.14351422603069e-09,
119 6.07260454422966e-06,
120 0.0926898002815586,
121 -45.4811452217083,
122 ), # PT 9 (placeholder)
123 (
124 -1.14351422603069e-09,
125 6.07260454422966e-06,
126 0.0926898002815586,
127 -45.4811452217083,
128 ), # PT 10 (placeholder)
129]
130
131PRESSURE_UNIT = "psi"
132
133
135 raw_value: float, a: float, b: float, c: float, d: float
136) -> float:
137 """Convert raw voltage to pressure using polynomial calibration."""
138 v = float(raw_value)
139 return (a * (v**3)) + (b * (v**2)) + (c * v) + d
140
141
142def parse_packet_header(data: bytes) -> Optional[Tuple[int, int, int]]:
143 if len(data) < PACKET_HEADER_SIZE:
144 return None
145 try:
146 return struct.unpack(PACKET_HEADER_FORMAT, data[:PACKET_HEADER_SIZE])
147 except struct.error:
148 return None
149
150
151def parse_sensor_data_packet(data: bytes) -> Optional[Tuple[dict, List[dict]]]:
152 if len(data) < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE:
153 return None
154 header = parse_packet_header(data)
155 if header is None or header[0] != PacketType.SENSOR_DATA:
156 return None
157 packet_type, version, timestamp = header
158 offset = PACKET_HEADER_SIZE
159 try:
160 num_chunks, num_sensors = struct.unpack(
161 SENSOR_DATA_PACKET_FORMAT,
162 data[offset : offset + SENSOR_DATA_PACKET_SIZE],
163 )
164 except struct.error:
165 return None
166 offset += SENSOR_DATA_PACKET_SIZE
167 per_chunk = SENSOR_DATA_CHUNK_SIZE + num_sensors * SENSOR_DATAPOINT_SIZE
168 if (
169 len(data)
170 < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE + num_chunks * per_chunk
171 ):
172 return None
173 chunks = []
174 for _ in range(num_chunks):
175 (chunk_ts,) = struct.unpack(
176 SENSOR_DATA_CHUNK_FORMAT, data[offset : offset + SENSOR_DATA_CHUNK_SIZE]
177 )
178 offset += SENSOR_DATA_CHUNK_SIZE
179 datapoints = []
180 for _ in range(num_sensors):
181 sid, val = struct.unpack(
182 SENSOR_DATAPOINT_FORMAT, data[offset : offset + SENSOR_DATAPOINT_SIZE]
183 )
184 datapoints.append({"sensor_id": sid, "data": val})
185 offset += SENSOR_DATAPOINT_SIZE
186 chunks.append({"timestamp": chunk_ts, "datapoints": datapoints})
187 return (
188 {"packet_type": packet_type, "version": version, "timestamp": timestamp},
189 chunks,
190 )
191
192
193class UDPReceiver(QtCore.QThread):
194 sensor_data_received = QtCore.pyqtSignal(dict, list)
195 status_update = QtCore.pyqtSignal(str)
196
197 def __init__(self, port: int, bind_address: str):
198 super().__init__()
199 self.port = port
200 self.bind_address = bind_address
201 self._stop = False
202 self.sock = None
204 self.total_bytes = 0
205 self.start_time = None
206
207 def stop(self):
208 self._stop = True
209 if self.sock:
210 try:
211 self.sock.close()
212 except Exception:
213 pass
214
215 def get_stats(self) -> Dict:
216 if self.start_time is None:
217 return {
218 "packets": 0,
219 "bytes": 0,
220 "packets_per_sec": 0.0,
221 "bytes_per_sec": 0.0,
222 }
223 elapsed = time.time() - self.start_time
224 pps = self.total_packets / elapsed if elapsed > 0 else 0.0
225 bps = self.total_bytes / elapsed if elapsed > 0 else 0.0
226 return {
227 "packets": self.total_packets,
228 "bytes": self.total_bytes,
229 "packets_per_sec": pps,
230 "bytes_per_sec": bps,
231 }
232
233 def run(self):
234 self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
235 self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
236 self.sock.settimeout(0.1)
237 try:
238 self.sock.bind((self.bind_address, self.port))
239 msg = f"Listening {self.bind_address}:{self.port} — Set ESP RECEIVER_IP to this PC. If Packets stay 0: Mac: allow Python in Firewall."
240 self.status_update.emit(msg)
241 self.start_time = time.time()
242 except OSError as e:
243 self.status_update.emit(f"Bind error: {e}")
244 return
245 while not self._stop:
246 try:
247 data, _ = self.sock.recvfrom(MAX_PACKET_SIZE)
248 self.total_packets += 1
249 self.total_bytes += len(data)
250 header = parse_packet_header(data)
251 if header is not None and header[0] == PacketType.SENSOR_DATA:
252 result = parse_sensor_data_packet(data)
253 if result:
254 self.sensor_data_received.emit(result[0], result[1])
255 except socket.timeout:
256 continue
257 except Exception as e:
258 if not self._stop:
259 self.status_update.emit(f"Error: {e}")
260 if self.sock:
261 try:
262 self.sock.close()
263 except Exception:
264 pass
265 self.status_update.emit("Stopped")
266
267
268class PTPlotWindow(QtWidgets.QMainWindow):
269 def __init__(self, port: int = DEFAULT_PORT, bind_address: str = "0.0.0.0"):
270 super().__init__()
271 self.port = port
272 self.bind_address = bind_address
273 self.window_seconds = DEFAULT_WINDOW_SECONDS
274 self.sensor_data: Dict[int, deque] = {}
275 self.sensor_plots: Dict[int, pg.PlotDataItem] = {}
276 self.plot_enabled: Dict[int, bool] = {i: True for i in range(NUM_PTS)}
278 self.receiver = None
279 self.init_ui()
280 self.start_receiver()
281 self.update_timer = QtCore.QTimer()
282 self.update_timer.timeout.connect(self.update_plots)
283 self.update_timer.start(UPDATE_INTERVAL_MS)
284 self.stats_timer = QtCore.QTimer()
285 self.stats_timer.timeout.connect(self.update_statistics)
286 self.stats_timer.start(500)
287
288 def pt_label(self, sensor_id: int) -> str:
289 return f"PT {sensor_id + 1}"
290
291 def init_ui(self):
292 self.setWindowTitle(f"PT Board Multi – Live Plots (port {self.port})")
293 self.setGeometry(100, 100, 1200, 700)
294 central = QtWidgets.QWidget()
295 self.setCentralWidget(central)
296 layout = QtWidgets.QVBoxLayout(central)
297 top = QtWidgets.QHBoxLayout()
298 self.status_label = QtWidgets.QLabel("Starting...")
299 self.status_label.setStyleSheet("font-weight: bold; padding: 5px;")
300 top.addWidget(self.status_label)
301 top.addStretch()
302 settings_btn = QtWidgets.QPushButton("Settings")
303 settings_btn.clicked.connect(self.show_settings)
304 top.addWidget(settings_btn)
305 csv_btn = QtWidgets.QPushButton("Download CSV")
306 csv_btn.clicked.connect(self.save_csv)
307 top.addWidget(csv_btn)
308 layout.addLayout(top)
309 plot_stats = QtWidgets.QHBoxLayout()
310 self.plot_widget = pg.GraphicsLayoutWidget()
311 self.plot_widget.setBackground("k")
312 plot_stats.addWidget(self.plot_widget, 1)
313 stats_group = QtWidgets.QGroupBox("Statistics")
314 sl = QtWidgets.QVBoxLayout()
315 self.packets_label = QtWidgets.QLabel("Packets: 0")
316 self.pps_label = QtWidgets.QLabel("Packets/sec: 0.0")
317 self.bytes_label = QtWidgets.QLabel("Bytes: 0")
318 self.bps_label = QtWidgets.QLabel("Bytes/sec: 0.0")
319 for w in (self.packets_label, self.pps_label, self.bytes_label, self.bps_label):
320 w.setFont(QtGui.QFont("", 11))
321 sl.addWidget(self.packets_label)
322 sl.addWidget(self.pps_label)
323 sl.addWidget(self.bytes_label)
324 sl.addWidget(self.bps_label)
325 sl.addStretch()
326 stats_group.setLayout(sl)
327 stats_group.setFixedWidth(180)
328 plot_stats.addWidget(stats_group)
329 # PT toggles and voltage displays
330 pt_group = QtWidgets.QGroupBox("Channels")
331 pt_layout = QtWidgets.QVBoxLayout()
332 self.pt_toggles: Dict[int, QtWidgets.QCheckBox] = {}
333 self.pt_voltage_labels: Dict[int, QtWidgets.QLabel] = {}
334 for i in range(NUM_PTS):
335 row = QtWidgets.QHBoxLayout()
336 cb = QtWidgets.QCheckBox(self.pt_label(i))
337 cb.setChecked(True)
338 cb.stateChanged.connect(lambda s, idx=i: self._on_plot_toggle(idx, s))
339 self.pt_toggles[i] = cb
340 row.addWidget(cb)
341 vl = QtWidgets.QLabel(f"-- {PRESSURE_UNIT}")
342 vl.setStyleSheet(
343 f"color: rgb{PT_COLORS[i % len(PT_COLORS)]}; font-weight: bold; min-width: 60px;"
344 )
345 self.pt_voltage_labels[i] = vl
346 row.addWidget(vl)
347 pt_layout.addLayout(row)
348 pt_group.setLayout(pt_layout)
349 pt_group.setFixedWidth(140)
350 plot_stats.addWidget(pt_group)
351 layout.addLayout(plot_stats, 1)
352 self.plot_item = self.plot_widget.addPlot(title="PT Voltages")
353 self.plot_item.setTitle("PT Voltages", color="w", size="14pt")
354 self.plot_item.setLabel("left", "Voltage (V)", color="w")
355 self.plot_item.setLabel("bottom", "Time (s)", color="w")
356 self.plot_item.addLegend()
357 self.plot_item.showGrid(x=True, y=True, alpha=0.5)
358 self.plot_item.getViewBox().setBackgroundColor("k")
359 f = QtGui.QFont("", 12)
360 for ax in ("left", "bottom"):
361 a = self.plot_item.getAxis(ax)
362 a.setStyle(tickFont=f)
363 try:
364 a.label.setFont(f)
365 except Exception:
366 pass
367 a.setPen("w")
368 a.setTextPen("w")
369 self.legend = self.plot_item.legend
370 if self.legend:
371 self.legend.setBrush(pg.mkBrush("k"))
372 self.legend.setPen(pg.mkPen("w"))
373 # Pre-create plots for PT 1..10
374 for i in range(NUM_PTS):
375 c = PT_COLORS[i % len(PT_COLORS)]
376 pen = pg.mkPen(color=c, width=2)
377 plot = self.plot_item.plot([], [], pen=pen, name=self.pt_label(i))
378 self.sensor_plots[i] = plot
379 self.sensor_data[i] = deque(maxlen=MAX_POINTS)
380 for it in self.legend.items or []:
381 if len(it) >= 2:
382 try:
383 it[1].setColor("w")
384 except Exception:
385 pass
386
387 def _on_plot_toggle(self, idx: int, state):
388 self.plot_enabled[idx] = bool(state)
389
390 def start_receiver(self):
391 self.receiver = UDPReceiver(port=self.port, bind_address=self.bind_address)
392 self.receiver.sensor_data_received.connect(self.on_sensor_data)
393 self.receiver.status_update.connect(self.status_label.setText)
394 self.receiver.start()
395
396 def on_sensor_data(self, header: dict, chunks: List[dict]):
397 t0 = time.time() - self.stats_start_time
398 for ch in chunks:
399 for dp in ch["datapoints"]:
400 sid = int(dp["sensor_id"])
401 if 0 <= sid < NUM_PTS:
402 self.sensor_data[sid].append((t0, float(dp["data"])))
403
404 def update_plots(self):
405 t = time.time() - self.stats_start_time
406 win = self.window_seconds
407 for i in range(NUM_PTS):
408 d = self.sensor_data[i]
409 # Update pressure display (converted from voltage)
410 if d:
411 pressure = calculate_pressure(d[-1][1], *PT_CALIBRATION[i])
412 self.pt_voltage_labels[i].setText(f"{pressure:.4f} {PRESSURE_UNIT}")
413 else:
414 self.pt_voltage_labels[i].setText(f"-- {PRESSURE_UNIT}")
415 # Show/hide plot based on toggle
416 self.sensor_plots[i].setVisible(self.plot_enabled[i])
417 if not self.plot_enabled[i] or not d:
418 continue
419 times, vals = [], []
420 for ti, v in d:
421 if t - ti <= win:
422 times.append(ti)
423 vals.append(v)
424 if times:
425 self.sensor_plots[i].setData(np.array(times), np.array(vals))
426 if t > win:
427 self.plot_item.setXRange(t - win, t, padding=0)
428 else:
429 self.plot_item.setXRange(0, win, padding=0)
430
432 if self.receiver is None:
433 return
434 s = self.receiver.get_stats()
435 self.packets_label.setText(f"Packets: {s['packets']}")
436 self.pps_label.setText(f"Packets/sec: {s['packets_per_sec']:.2f}")
437 b = s["bytes"]
438 self.bytes_label.setText(
439 f"Bytes: {b/1024:.2f} KB" if b >= 1024 else f"Bytes: {b} B"
440 )
441 bp = s["bytes_per_sec"]
442 self.bps_label.setText(
443 f"Bytes/sec: {bp/1024:.2f} KB/s"
444 if bp >= 1024
445 else f"Bytes/sec: {bp:.2f} B/s"
446 )
447
448 def save_csv(self):
449 """Save all PT timestamp and voltage data to a CSV file."""
450 filepath, _ = QtWidgets.QFileDialog.getSaveFileName(
451 self, "Save CSV", "", "CSV Files (*.csv);;All Files (*)"
452 )
453 if not filepath:
454 return
455 if not filepath.endswith(".csv"):
456 filepath += ".csv"
457 try:
458 with open(filepath, "w", newline="") as f:
459 writer = csv.writer(f)
460 # Header: timestamp, PT1_voltage, PT2_voltage, ... PT10_voltage
461 header = ["timestamp"] + [f"PT{i+1}_voltage" for i in range(NUM_PTS)]
462 writer.writerow(header)
463 # Collect all unique timestamps across all PTs
464 all_times = set()
465 for i in range(NUM_PTS):
466 for ti, _ in self.sensor_data[i]:
467 all_times.add(ti)
468 # For each timestamp, write the voltage for each PT (or empty if no data)
469 for ti in sorted(all_times):
470 row = [f"{ti:.6f}"]
471 for i in range(NUM_PTS):
472 # Find the voltage at this timestamp for this PT
473 val = ""
474 for t, v in self.sensor_data[i]:
475 if t == ti:
476 val = f"{v:.6f}"
477 break
478 row.append(val)
479 writer.writerow(row)
480 self.status_label.setText(f"Saved CSV to {filepath}")
481 except Exception as e:
482 self.status_label.setText(f"CSV save error: {e}")
483
484 def show_settings(self):
485 d = QtWidgets.QDialog(self)
486 d.setWindowTitle("Settings")
487 lo = QtWidgets.QVBoxLayout(d)
488 lo.addWidget(QtWidgets.QLabel("Time window (s)"))
489 row = QtWidgets.QHBoxLayout()
490 sld = QtWidgets.QSlider(_QT_HORIZ)
491 sld.setMinimum(5)
492 sld.setMaximum(120)
493 sld.setValue(int(self.window_seconds))
494 lbl = QtWidgets.QLabel(f"{self.window_seconds:.0f}")
495
496 def onv(v):
497 self.window_seconds = float(v)
498 lbl.setText(f"{v}")
499
500 sld.valueChanged.connect(onv)
501 row.addWidget(sld, 1)
502 row.addWidget(lbl)
503 lo.addLayout(row)
504 cb = QtWidgets.QPushButton("Close")
505 cb.clicked.connect(d.accept)
506 lo.addWidget(cb)
507 d.exec()
508
509 def closeEvent(self, e):
510 if self.receiver:
511 self.receiver.stop()
512 self.receiver.wait(2000)
513 e.accept()
514
515
516def main():
517 import argparse
518
519 p = argparse.ArgumentParser(
520 description="PT Board Multi – live PT plots from DAQv2-Comms UDP"
521 )
522 p.add_argument(
523 "-p",
524 "--port",
525 type=int,
526 default=DEFAULT_PORT,
527 help=f"UDP port (default {DEFAULT_PORT})",
528 )
529 p.add_argument("-a", "--address", default="0.0.0.0", help="Bind address")
530 a = p.parse_args()
531 app = QtWidgets.QApplication(sys.argv)
532 w = PTPlotWindow(port=a.port, bind_address=a.address)
533 w.show()
534 sys.exit(app.exec_() if hasattr(app, "exec_") else app.exec())
535
536
537if __name__ == "__main__":
538 main()
__init__(self, int port=DEFAULT_PORT, str bind_address="0.0.0.0")
str pt_label(self, int sensor_id)
_on_plot_toggle(self, int idx, state)
__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)
float calculate_pressure(float raw_value, float a, float b, float c, float d)
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()