STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
solenoid_characterization_gui.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2from __future__ import annotations
3
4"""
5Solenoid Characterization GUI
6Clone of combined_gui for root GUI, packet reading, and config.
7Select 1 PT and 1 actuator; graph shows that PT's pressure and vertical dashed lines
8mark each actuator OPEN/CLOSED command time.
9
10Requirements: pip install pyqt6 pyqtgraph numpy pandas
11"""
12
13import csv
14import json
15import os
16import re
17import socket
18import struct
19import sys
20import time
21from pathlib import Path
22from typing import Optional, Tuple, List, Dict
23from collections import deque
24
25# Default PT calibration path when config paths are empty
26PT_CALIBRATION_CSV = str(
27 Path(__file__).parent.parent
28 / "PT_Board"
29 / "Calibration"
30 / "PT Calibration Attempt 2026-02-04_test2.csv"
31)
32
33# Fix Qt "cocoa" platform plugin on macOS when Homebrew Qt plugins path lacks platforms/
34if sys.platform == "darwin":
35 _qt_plugins = os.environ.get("QT_QPA_PLATFORM_PLUGIN_PATH")
36 if not _qt_plugins or not os.path.isdir(_qt_plugins):
37 _candidates = [
38 "/opt/homebrew/share/qt/plugins/platforms",
39 "/opt/homebrew/Cellar/qtbase/6.10.1/share/qt/plugins/platforms",
40 ]
41 if os.path.isdir("/opt/homebrew/Cellar/qtbase"):
42 for _name in sorted(
43 os.listdir("/opt/homebrew/Cellar/qtbase"), reverse=True
44 ):
45 _p = os.path.join(
46 "/opt/homebrew/Cellar/qtbase",
47 _name,
48 "share",
49 "qt",
50 "plugins",
51 "platforms",
52 )
53 if os.path.isdir(_p):
54 _candidates.insert(1, _p)
55 break
56 for _p in _candidates:
57 if os.path.isdir(_p) and any(
58 _f.startswith("libqcocoa") for _f in os.listdir(_p)
59 ):
60 os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = _p
61 break
62
63from PyQt6 import QtCore, QtGui, QtWidgets
64import pyqtgraph as pg
65import numpy as np
66import pandas as pd
67
68# Protocol constants from DAQv2-Comms.h
69DIABLO_COMMS_VERSION = 0
70MAX_PACKET_SIZE = 512
71
72
74 BOARD_HEARTBEAT = 1
75 SERVER_HEARTBEAT = 2
76 SENSOR_DATA = 3
77 ACTUATOR_COMMAND = 4
78 SENSOR_CONFIG = 5
79 ACTUATOR_CONFIG = 6
80 ABORT = 7
81 ABORT_DONE = 8
82 CLEAR_ABORT = 9
83 PWM_ACTUATOR_COMMAND = 10
84 NO_CONNECTION_ABORT = 11
85
86
87DEFAULT_SENSOR_IP = "192.168.2.21"
88DEFAULT_ACTUATOR_IP = "192.168.2.12"
89DEFAULT_DEVICE_PORT = 5005
90DEFAULT_RECEIVE_PORT = 5006
91
92PACKET_HEADER_FORMAT = "<BBI"
93PACKET_HEADER_SIZE = 6
94ACTUATOR_COMMAND_PACKET_FORMAT = "<B"
95ACTUATOR_COMMAND_PACKET_SIZE = 1
96ACTUATOR_COMMAND_FORMAT = "<BB"
97ACTUATOR_COMMAND_SIZE = 2
98PWM_ACTUATOR_COMMAND_PACKET_FORMAT = "<B"
99PWM_ACTUATOR_COMMAND_PACKET_SIZE = 1
100# PWM Command: actuator_id (u8), duration_ms (u32), duty_cycle (float), frequency (float)
101PWM_ACTUATOR_COMMAND_FORMAT = "<BIff"
102PWM_ACTUATOR_COMMAND_SIZE = 13
103SENSOR_DATA_PACKET_FORMAT = "<BB"
104SENSOR_DATA_PACKET_SIZE = 2
105SENSOR_DATA_CHUNK_FORMAT = "<I"
106SENSOR_DATA_CHUNK_SIZE = 4
107SENSOR_DATAPOINT_FORMAT = "<BI"
108SENSOR_DATAPOINT_SIZE = 5
109
110DEFAULT_WINDOW_SECONDS = 10.0
111# Buffer must hold at least (max window s) * (sample rate). Window up to 120 s; at 1 kHz that's 120k points.
112MAX_POINTS = 150000
113UPDATE_INTERVAL_MS = 50
114# Board sends one timestamp per chunk; samples within a chunk are spaced at this rate (match stream_one_adc DATA_RATE).
115SAMPLE_RATE_HZ = 7200
116DEFAULT_ACTUATOR_ROLE_NAMES = [
117 "LOX Main",
118 "Fuel Main",
119 "Fuel Vent",
120 "Fuel Press",
121 "LOX Vent",
122 "LOX Press",
123]
124DEFAULT_SENSOR_ROLE_NAMES = [
125 "LOX Upstream",
126 "LOX Downstream",
127 "Low Press PT",
128 "Fuel Downstream",
129]
130
131CONFIG_FILE = Path(__file__).parent / "config.json"
132
133
135 """Manages loading and saving of application configuration (shared config.json)."""
136
137 def __init__(self):
138 self.config = {
139 "actuator_role_names": list(DEFAULT_ACTUATOR_ROLE_NAMES),
140 "actuator_roles": {name: 0 for name in DEFAULT_ACTUATOR_ROLE_NAMES},
141 "sensor_roles": {name: 0 for name in DEFAULT_SENSOR_ROLE_NAMES},
142 "network": {
143 "actuator_ip": DEFAULT_ACTUATOR_IP,
144 "actuator_port": DEFAULT_DEVICE_PORT,
145 "sensor_ip_filter": DEFAULT_SENSOR_IP,
146 "receive_port": DEFAULT_RECEIVE_PORT,
147 },
148 "display": {
149 "adc_bits": 32,
150 "ref_voltage": 2.5,
151 "window_seconds": DEFAULT_WINDOW_SECONDS,
152 "y_axis_min": 0.0,
153 "y_axis_max": 700.0,
154 "y_axis_autoscale": True,
155 },
156 "num_connectors": 10,
157 "num_actuators": 10,
158 "paths": {"pt_calibration_csv": []},
159 }
160 self.load()
161
162 def load(self):
163 if CONFIG_FILE.exists():
164 try:
165 with open(CONFIG_FILE, "r") as f:
166 loaded = json.load(f)
167 self._update_dict(self.config, loaded)
168 if "sensor_roles" in loaded and isinstance(
169 loaded["sensor_roles"], dict
170 ):
171 self.config["sensor_roles"] = dict(loaded["sensor_roles"])
173 except Exception as e:
174 print(f"Error loading config: {e}")
175 else:
176 self.save()
177
179 roles = self.config.setdefault("actuator_roles", {})
180 names = (
181 self.config.get("actuator_role_names")
182 or list(roles.keys())
183 or list(DEFAULT_ACTUATOR_ROLE_NAMES)
184 )
185 self.config["actuator_role_names"] = names
186 for name in names:
187 if name not in roles:
188 roles[name] = 0
189 if "num_connectors" not in self.config:
190 self.config["num_connectors"] = 10
191 if "num_actuators" not in self.config:
192 self.config["num_actuators"] = 10
193 if "paths" not in self.config:
194 self.config["paths"] = {"pt_calibration_csv": []}
195 if isinstance(self.config.get("paths", {}).get("pt_calibration_csv"), str):
196 old_val = self.config["paths"]["pt_calibration_csv"]
197 self.config["paths"]["pt_calibration_csv"] = (
198 [old_val] if old_val and str(old_val).strip() else []
199 )
200
201 def save(self):
202 try:
203 with open(CONFIG_FILE, "w") as f:
204 json.dump(self.config, f, indent=4)
205 except Exception as e:
206 print(f"Error saving config: {e}")
207
208 def _update_dict(self, target, source):
209 for k, v in source.items():
210 if isinstance(v, dict) and k in target and isinstance(target[k], dict):
211 self._update_dict(target[k], v)
212 else:
213 target[k] = v
214
216 return (
217 self.config.get("actuator_role_names")
218 or list(self.config.get("actuator_roles", {}).keys())
219 or list(DEFAULT_ACTUATOR_ROLE_NAMES)
220 )
221
223 return list(self.config.get("sensor_roles", {}).keys())
224
226 return int(self.config.get("num_connectors", 10))
227
229 return int(self.config.get("num_actuators", 10))
230
232 p = (self.config.get("paths") or {}).get("pt_calibration_csv", "")
233 if isinstance(p, list):
234 return [path.strip() for path in p if path and path.strip()]
235 return [p.strip()] if p and str(p).strip() else []
236
237 def get_actuator_label(self, idx):
238 roles = self.config.get("actuator_roles", {})
239 for role, aid in roles.items():
240 actuator_id = aid[1] if isinstance(aid, list) and len(aid) == 2 else aid
241 if actuator_id == idx:
242 return role
243 return ""
244
245 def get_sensor_label(self, idx):
246 roles = self.config.get("sensor_roles", {})
247 for role, cid in roles.items():
248 if cid == idx:
249 return role
250 return ""
251
252 def get_actuator_role(self, role_name):
253 aid = self.config.get("actuator_roles", {}).get(role_name, 0)
254 if isinstance(aid, list) and len(aid) == 2:
255 return aid[1]
256 return aid
257
258 def get_actuator_type_by_id(self, actuator_id):
259 roles = self.config.get("actuator_roles", {})
260 for role, aid in roles.items():
261 if isinstance(aid, list) and len(aid) == 2 and aid[1] == actuator_id:
262 return aid[0]
263 elif aid == actuator_id:
264 return "NC"
265 return "NC"
266
267
269NUM_CONNECTORS = CONFIG.get_num_connectors()
270NUM_ACTUATORS = CONFIG.get_num_actuators()
271
272pg.setConfigOptions(antialias=False)
273
274
275# ---------------------- PT calibration ----------------------
277 adc_code: float, PT_A: float, PT_B: float, PT_C: float, PT_D: float
278) -> float:
279 return (PT_A * (adc_code**3)) + (PT_B * (adc_code**2)) + (PT_C * adc_code) + PT_D
280
281
283 csv_paths,
284) -> Tuple[Dict[int, Tuple[float, float, float, float]], Optional[str]]:
285 result = {}
286 duplicates = {}
287 if isinstance(csv_paths, str):
288 csv_paths = [csv_paths] if csv_paths.strip() else []
289 elif not isinstance(csv_paths, list):
290 csv_paths = []
291 if not csv_paths:
292 csv_paths = [PT_CALIBRATION_CSV]
293 csv_pt_map = {}
294 for csv_path in csv_paths:
295 if not csv_path or not str(csv_path).strip():
296 continue
297 csv_path = str(csv_path).strip()
298 if not os.path.isfile(csv_path):
299 continue
300 try:
301 with open(csv_path, newline="", encoding="utf-8") as f:
302 reader = csv.DictReader(f)
303 rows = list(reader)
304 fieldnames = reader.fieldnames or []
305 if not rows:
306 continue
307 pt_nums = set()
308 for col in fieldnames:
309 m = re.match(r"PT(\d+)\s+Coefficient\s+0", col, re.IGNORECASE)
310 if m:
311 pt_nums.add(int(m.group(1)))
312 csv_pt_map[csv_path] = pt_nums
313 last = rows[-1]
314 for pt_num in sorted(pt_nums):
315 if pt_num in result:
316 if pt_num not in duplicates:
317 duplicates[pt_num] = []
318 for prev_path, prev_pt_nums in csv_pt_map.items():
319 if prev_path != csv_path and pt_num in prev_pt_nums:
320 duplicates[pt_num].append(os.path.basename(prev_path))
321 duplicates[pt_num].append(os.path.basename(csv_path))
322 continue
323 a = float(last.get(f"PT{pt_num} Coefficient 0", 0))
324 b = float(last.get(f"PT{pt_num} Coefficient 1", 0))
325 c = float(last.get(f"PT{pt_num} Coefficient 2", 0))
326 d = float(last.get(f"PT{pt_num} Coefficient 3", 0))
327 result[pt_num] = (a, b, c, d)
328 except Exception as e:
329 return result, f"Error loading {os.path.basename(csv_path)}: {str(e)}"
330 if duplicates:
331 error_parts = ["Duplicate PT numbers found:"]
332 for pt_num, files in sorted(duplicates.items()):
333 error_parts.append(f" PT{pt_num} appears in: {', '.join(files)}")
334 return result, "\n".join(error_parts)
335 return result, None
336
337
338# ---------------------- Solenoid timing extraction ----------------------
340 t: np.ndarray,
341 P: np.ndarray,
342 t_open_cmd: float,
343 t_close_cmd: float,
344 *,
345 smooth_window_sec: float = 0.020,
346 derivative_threshold_psi_s: float = 50.0,
347 settle_threshold_psi_s: float = 20.0,
348 pre_window_sec: float = 0.5,
349 guard_sec: float = 0.005,
350 min_step_psi: float = 5.0,
351 sustain_samples: int = 10,
352 search_max_sec: float = 5.0,
353 open_is_drop: bool = True,
354) -> Dict:
355 """
356 Extract solenoid valve timing from pressure data using derivative-based edge detection.
357
358 Detects 4 events:
359 - t_open_start: When valve starts opening (pressure begins changing after OPEN cmd)
360 - t_open_end: When valve is fully open (pressure stabilizes at low point)
361 - t_close_start: When valve starts closing (pressure begins recovering)
362 - t_close_end: When valve is fully closed (pressure returns to baseline)
363
364 This algorithm handles overlapping command/response timing where the physical valve
365 response lags behind electrical commands by hundreds of milliseconds.
366
367 Args:
368 t: Time array in seconds
369 P: Pressure array in psi
370 t_open_cmd: Time of OPEN command
371 t_close_cmd: Time of CLOSE command
372 smooth_window_sec: Window for smoothing pressure (default: 20ms)
373 derivative_threshold_psi_s: Rate of change to detect transition start (default: 50 psi/s)
374 settle_threshold_psi_s: Rate of change threshold for "settled" state (default: 20 psi/s)
375 pre_window_sec: Time before command to calculate baseline (default: 0.5s)
376 guard_sec: Minimum time after command before looking for response (default: 5ms)
377 min_step_psi: Minimum pressure change to consider valid (default: 5 psi)
378 sustain_samples: Number of consecutive samples to confirm a state (default: 10)
379 search_max_sec: Maximum time after command to search for events (default: 5s)
380 open_is_drop: If True, opening causes pressure drop; if False, opening causes pressure rise
381
382 Returns:
383 Dictionary with timing results including t_open_start, t_open_end, t_close_start, t_close_end
384 """
385 t = np.asarray(t, dtype=float)
386 P = np.asarray(P, dtype=float)
387 if t.ndim != 1 or P.ndim != 1 or len(t) != len(P):
388 raise ValueError("t and P must be 1D arrays of the same length")
389 if len(t) < 20:
390 raise ValueError("Need at least 20 samples")
391 if not np.all(np.diff(t) > 0):
392 raise ValueError("t must be strictly increasing")
393 if t_open_cmd >= t_close_cmd:
394 raise ValueError("t_open_cmd must be < t_close_cmd")
395 if t[0] > t_open_cmd or t[-1] < t_close_cmd:
396 raise ValueError("t must cover both commands")
397
398 dt_med = float(np.median(np.diff(t)))
399 fs = 1.0 / dt_med
400
401 # Smooth pressure signal
402 n = int(round(smooth_window_sec * fs))
403 n = max(3, n)
404 if n % 2 == 0:
405 n += 1
406 n = min(n, len(P) - 2)
407 if n >= 3:
408 k = np.ones(n) / n
409 P_s = np.convolve(P, k, mode="same")
410 else:
411 P_s = P.copy()
412
413 # Compute derivative with additional smoothing for stability
414 dP_dt_raw = np.gradient(P_s, t)
415 n_deriv = max(3, int(round(0.010 * fs))) # 10ms smoothing on derivative
416 if n_deriv % 2 == 0:
417 n_deriv += 1
418 n_deriv = min(n_deriv, len(dP_dt_raw) - 2)
419 if n_deriv >= 3:
420 k_deriv = np.ones(n_deriv) / n_deriv
421 dP_dt = np.convolve(dP_dt_raw, k_deriv, mode="same")
422 else:
423 dP_dt = dP_dt_raw
424
425 def _median_in_window(t0: float, t1: float) -> float:
426 idx = np.nonzero((t >= t0) & (t <= t1))[0]
427 if len(idx) < 5:
428 # Fall back to closest samples if window is too small
429 i_center = int(np.searchsorted(t, (t0 + t1) / 2))
430 i_start = max(0, i_center - 10)
431 i_end = min(len(P_s), i_center + 10)
432 return float(np.median(P_s[i_start:i_end]))
433 return float(np.median(P_s[idx]))
434
435 def _first_sustained_idx(
436 cond: np.ndarray, start_idx: int, end_idx: Optional[int] = None
437 ) -> Optional[int]:
438 """Find first index where condition is sustained for sustain_samples consecutive samples."""
439 s = max(1, int(sustain_samples))
440 if end_idx is None:
441 end_idx = len(cond)
442 for i in range(start_idx, min(end_idx, len(cond)) - s + 1):
443 if np.all(cond[i : i + s]):
444 return i
445 return None
446
447 # Get baseline pressure before OPEN command
448 P_baseline = _median_in_window(
449 max(t[0], t_open_cmd - pre_window_sec), t_open_cmd - 0.001
450 )
451
452 # Define search region
453 i_open_cmd = int(np.searchsorted(t, t_open_cmd + guard_sec))
454 i_search_end = int(np.searchsorted(t, t_open_cmd + search_max_sec))
455 i_search_end = min(i_search_end, len(t) - 1)
456
457 # For OPEN detection: look for derivative in expected direction
458 # If open_is_drop=True, opening causes negative derivative (pressure falls)
459 if open_is_drop:
460 # Opening: look for sustained negative derivative
461 cond_open_start = dP_dt < -derivative_threshold_psi_s
462 cond_open_settled = np.abs(dP_dt) < settle_threshold_psi_s
463 else:
464 # Opening: look for sustained positive derivative
465 cond_open_start = dP_dt > derivative_threshold_psi_s
466 cond_open_settled = np.abs(dP_dt) < settle_threshold_psi_s
467
468 # Find when opening starts (first sustained derivative exceeding threshold)
469 # Validate that it results in a real pressure change (not just noise)
470 i_open_start = None
471 t_open_start = None
472 t_open_end = None
473 P_open_post = None
474
475 search_from = i_open_cmd
476 while search_from < i_search_end:
477 candidate = _first_sustained_idx(cond_open_start, search_from, i_search_end)
478 if candidate is None:
479 break
480
481 # Check if this leads to a real pressure change
482 # Look ahead 100ms and check if pressure has actually changed (not just spiked)
483 i_look_ahead = min(candidate + int(0.1 * fs), i_search_end - 1)
484 P_at_candidate = float(P_s[candidate])
485 P_ahead = float(P_s[i_look_ahead])
486
487 if open_is_drop:
488 pressure_change = P_at_candidate - P_ahead
489 else:
490 pressure_change = P_ahead - P_at_candidate
491
492 if pressure_change >= min_step_psi:
493 # Valid opening detected
494 i_open_start = candidate
495 t_open_start = float(t[i_open_start])
496
497 # Find when opening ends: User requested "when peak pressure drop rate starts"
498 # We interpret this as the time of maximum derivative magnitude (peak flow acceleration)
499 # Search for peak derivative between t_open_start and end of search window
500 search_peak_end_idx = min(len(dP_dt), i_search_end)
501 if search_peak_end_idx > i_open_start:
502 peak_search_region = slice(i_open_start, search_peak_end_idx)
503 if open_is_drop:
504 # Look for MINIMUM derivative (most negative)
505 i_peak = i_open_start + np.argmin(dP_dt[peak_search_region])
506 else:
507 # Look for MAXIMUM derivative (most positive)
508 i_peak = i_open_start + np.argmax(dP_dt[peak_search_region])
509
510 t_open_end = float(t[i_peak])
511 P_open_post = float(P_s[i_peak])
512 else:
513 t_open_end = t_open_start # Should not happen given logic above
514 P_open_post = float(P_s[i_open_start])
515
516 # Since we found t_open_start, we break (only one open event expected)
517 break
518 else:
519 # False positive - skip ahead and keep searching
520 search_from = candidate + sustain_samples
521
522 # For CLOSE detection: look for reverse derivative direction
523 # The minimum pressure point marks the transition from opening to closing
524 # We search from after t_open_end (if found) or after i_open_start
525 if i_open_start is not None:
526 search_start_for_close = i_open_start + sustain_samples
527 else:
528 search_start_for_close = i_open_cmd
529
530 # Find minimum pressure point in the search region (this is "fully open")
531 search_region = slice(search_start_for_close, i_search_end)
532 if search_region.stop > search_region.start:
533 i_min_local = search_start_for_close + int(np.argmin(P_s[search_region]))
534 P_min = float(P_s[i_min_local])
535 t_min = float(t[i_min_local])
536 else:
537 i_min_local = None
538 P_min = None
539 t_min = None
540
541 # Fallback: if t_open_end (settled) was not found but we have a min point, use that as open end
542 if t_open_end is None and t_min is not None:
543 t_open_end = t_min
544 P_open_post = P_min
545
546 # For CLOSE: look for derivative in opposite direction to open
547 if open_is_drop:
548 # Closing: look for sustained positive derivative (pressure rises)
549 cond_close_start = dP_dt > derivative_threshold_psi_s
550 else:
551 # Closing: look for sustained negative derivative (pressure falls)
552 cond_close_start = dP_dt < -derivative_threshold_psi_s
553 cond_close_settled = np.abs(dP_dt) < settle_threshold_psi_s
554
555 # Search for close start after the minimum pressure point
556 t_close_start = None
557 t_close_end = None
558 P_close_pre = P_min
559 P_close_post = None
560
561 if i_min_local is not None:
562 i_close_start = _first_sustained_idx(
563 cond_close_start, i_min_local, i_search_end
564 )
565 if i_close_start is not None:
566 t_close_start = float(t[i_close_start])
567
568 # Find when closing ends (derivative settles)
569 i_close_end = _first_sustained_idx(
570 cond_close_settled, i_close_start + sustain_samples, i_search_end
571 )
572 if i_close_end is not None:
573 t_close_end = float(t[i_close_end])
574 P_close_post = float(
575 np.median(P_s[i_close_end : min(i_close_end + 50, len(P_s))])
576 )
577
578 def _maybe_delay(t_cmd: float, t_evt: Optional[float]) -> Optional[float]:
579 return None if t_evt is None else float(t_evt - t_cmd)
580
581 def _maybe_dt(t0: Optional[float], t1: Optional[float]) -> Optional[float]:
582 return None if (t0 is None or t1 is None) else float(t1 - t0)
583
584 return {
585 "t_open_cmd": t_open_cmd,
586 "t_close_cmd": t_close_cmd,
587 "open_delay": _maybe_delay(t_open_cmd, t_open_start),
588 "open_time": _maybe_dt(t_open_start, t_open_end),
589 "close_delay": _maybe_delay(t_close_cmd, t_close_start),
590 "close_time": _maybe_dt(t_close_start, t_close_end),
591 "t_open_start": t_open_start,
592 "t_open_end": t_open_end,
593 "t_close_start": t_close_start,
594 "t_close_end": t_close_end,
595 "P_open_pre": P_baseline,
596 "P_open_post": P_open_post,
597 "P_close_pre": P_close_pre,
598 "P_close_post": P_close_post,
599 }
600
601
603 df: pd.DataFrame,
604 *,
605 time_col: str = "time_sec",
606 pressure_col: str = "pressure_psi",
607 actuation_col: str = "ACTUATION",
608 open_token: str = "OPEN",
609 close_token: str = "CLOSED",
610 smooth_window_sec: float = 0.020,
611 derivative_threshold_psi_s: float = 50.0,
612 settle_threshold_psi_s: float = 20.0,
613 pre_window_sec: float = 0.5,
614 guard_sec: float = 0.005,
615 min_step_psi: float = 5.0,
616 sustain_samples: int = 10,
617 search_max_sec: float = 5.0,
618 open_is_drop: bool = True,
619) -> Dict:
620 """
621 Pressure-based timing from a DataFrame with ACTUATION column.
622 Parses df to (t, P, t_open_cmd, t_close_cmd) then calls extract_solenoid_timing_live.
623 Use this for CSV/offline; use extract_solenoid_timing_live for live GUI.
624 """
625 if time_col not in df or pressure_col not in df or actuation_col not in df:
626 raise ValueError(
627 f"df must contain columns: {time_col}, {pressure_col}, {actuation_col}"
628 )
629 t = df[time_col].to_numpy(dtype=float)
630 P_raw = df[pressure_col].to_numpy(dtype=float)
631 finite = np.isfinite(P_raw)
632 if finite.sum() < 10:
633 raise ValueError("Not enough finite pressure samples")
634 P = np.interp(t, t[finite], P_raw[finite])
635 act = df[actuation_col].astype(str)
636 open_rows = df.index[act == open_token].to_list()
637 close_rows = df.index[act == close_token].to_list()
638 if len(open_rows) != 1 or len(close_rows) != 1:
639 raise ValueError(
640 f"Expected exactly 1 '{open_token}' and 1 '{close_token}' row in {actuation_col}"
641 )
642 t_open_cmd = float(t[open_rows[0]])
643 t_close_cmd = float(t[close_rows[0]])
644 if t_open_cmd >= t_close_cmd:
645 raise ValueError("OPEN command must occur before CLOSED command")
647 t,
648 P,
649 t_open_cmd,
650 t_close_cmd,
651 smooth_window_sec=smooth_window_sec,
652 derivative_threshold_psi_s=derivative_threshold_psi_s,
653 settle_threshold_psi_s=settle_threshold_psi_s,
654 pre_window_sec=pre_window_sec,
655 guard_sec=guard_sec,
656 min_step_psi=min_step_psi,
657 sustain_samples=sustain_samples,
658 search_max_sec=search_max_sec,
659 open_is_drop=open_is_drop,
660 )
661
662
663# ---------------------- Protocol ----------------------
664def parse_packet_header(data: bytes) -> Optional[Tuple[int, int, int]]:
665 if len(data) < PACKET_HEADER_SIZE:
666 return None
667 try:
668 packet_type, version, timestamp = struct.unpack(
669 PACKET_HEADER_FORMAT, data[:PACKET_HEADER_SIZE]
670 )
671 return (packet_type, version, timestamp)
672 except struct.error:
673 return None
674
675
676def parse_sensor_data_packet(data: bytes) -> Optional[Tuple[dict, List[dict]]]:
677 if len(data) < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE:
678 return None
679 header = parse_packet_header(data)
680 if header is None or header[0] != PacketType.SENSOR_DATA:
681 return None
682 packet_type, version, timestamp = header
683 offset = PACKET_HEADER_SIZE
684 try:
685 num_chunks, num_sensors = struct.unpack(
686 SENSOR_DATA_PACKET_FORMAT, data[offset : offset + SENSOR_DATA_PACKET_SIZE]
687 )
688 except struct.error:
689 return None
690 offset += SENSOR_DATA_PACKET_SIZE
691 per_chunk_size = SENSOR_DATA_CHUNK_SIZE + (num_sensors * SENSOR_DATAPOINT_SIZE)
692 expected_size = (
693 PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE + (num_chunks * per_chunk_size)
694 )
695 if len(data) < expected_size:
696 return None
697 chunks = []
698 for chunk_idx in range(num_chunks):
699 try:
700 (chunk_timestamp,) = struct.unpack(
701 SENSOR_DATA_CHUNK_FORMAT, data[offset : offset + SENSOR_DATA_CHUNK_SIZE]
702 )
703 except struct.error:
704 return None
705 offset += SENSOR_DATA_CHUNK_SIZE
706 datapoints = []
707 for sensor_idx in range(num_sensors):
708 try:
709 sensor_id, sensor_data = struct.unpack(
710 SENSOR_DATAPOINT_FORMAT,
711 data[offset : offset + SENSOR_DATAPOINT_SIZE],
712 )
713 datapoints.append({"sensor_id": sensor_id, "data": sensor_data})
714 offset += SENSOR_DATAPOINT_SIZE
715 except struct.error:
716 return None
717 chunks.append({"timestamp": chunk_timestamp, "datapoints": datapoints})
718 header_dict = {
719 "packet_type": packet_type,
720 "version": version,
721 "timestamp": timestamp,
722 }
723 return (header_dict, chunks)
724
725
726def create_actuator_command_packet(commands: List[Tuple[int, int]]) -> bytes:
727 if len(commands) == 0 or len(commands) > 255:
728 return b""
729 header_size = PACKET_HEADER_SIZE
730 body_size = ACTUATOR_COMMAND_PACKET_SIZE
731 commands_size = len(commands) * ACTUATOR_COMMAND_SIZE
732 total_size = header_size + body_size + commands_size
733 if total_size > MAX_PACKET_SIZE:
734 return b""
735 packet = bytearray(total_size)
736 offset = 0
737 struct.pack_into(
738 PACKET_HEADER_FORMAT,
739 packet,
740 offset,
741 PacketType.ACTUATOR_COMMAND,
742 DIABLO_COMMS_VERSION,
743 int(time.time() * 1000) & 0xFFFFFFFF,
744 )
745 offset += PACKET_HEADER_SIZE
746 struct.pack_into(ACTUATOR_COMMAND_PACKET_FORMAT, packet, offset, len(commands))
747 offset += ACTUATOR_COMMAND_PACKET_SIZE
748 for actuator_id, actuator_state in commands:
749 struct.pack_into(
750 ACTUATOR_COMMAND_FORMAT, packet, offset, actuator_id, actuator_state
751 )
752 offset += ACTUATOR_COMMAND_SIZE
753 return bytes(packet)
754
755
757 commands: List[Tuple[int, int, float, float]],
758) -> bytes:
759 """
760 Creates a PWM Actuator Command packet.
761 Packet layout: PacketHeader + PWMActuatorCommandPacket + N PWMActuatorCommand.
762
763 commands: List of tuples (actuator_id, duration_ms, duty_cycle, frequency)
764 Note: duration is in ms (uint32), duty_cycle is float (0.0-1.0), frequency is float (Hz)
765 """
766 if len(commands) == 0 or len(commands) > 255:
767 return b""
768
769 header_size = PACKET_HEADER_SIZE
770 body_size = PWM_ACTUATOR_COMMAND_PACKET_SIZE
771 commands_size = len(commands) * PWM_ACTUATOR_COMMAND_SIZE
772 total_size = header_size + body_size + commands_size
773
774 if total_size > MAX_PACKET_SIZE:
775 return b""
776
777 packet = bytearray(total_size)
778 offset = 0
779
780 # Header
781 struct.pack_into(
782 PACKET_HEADER_FORMAT,
783 packet,
784 offset,
785 PacketType.PWM_ACTUATOR_COMMAND,
786 DIABLO_COMMS_VERSION,
787 int(time.time() * 1000) & 0xFFFFFFFF,
788 )
789 offset += PACKET_HEADER_SIZE
790
791 # Body (num_commands)
792 struct.pack_into(PWM_ACTUATOR_COMMAND_PACKET_FORMAT, packet, offset, len(commands))
793 offset += PWM_ACTUATOR_COMMAND_PACKET_SIZE
794
795 # Commands
796 for actuator_id, duration_ms, duty_cycle, frequency in commands:
797 struct.pack_into(
798 PWM_ACTUATOR_COMMAND_FORMAT,
799 packet,
800 offset,
801 actuator_id,
802 duration_ms,
803 duty_cycle,
804 frequency,
805 )
806 offset += PWM_ACTUATOR_COMMAND_SIZE
807
808 return bytes(packet)
809
810
811# ---------------------- UDP Receiver ----------------------
812class UDPReceiver(QtCore.QThread):
813 sensor_data_received = QtCore.pyqtSignal(dict, list, str)
814 status_update = QtCore.pyqtSignal(str)
815
816 def __init__(self, port: int = DEFAULT_RECEIVE_PORT, bind_address: str = "0.0.0.0"):
817 super().__init__()
818 self.port = port
819 self.bind_address = bind_address
820 self._stop = False
821 self.sock = None
822
823 def stop(self):
824 self._stop = True
825 if self.sock:
826 try:
827 self.sock.close()
828 except Exception:
829 pass
830
831 def run(self):
832 self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
833 self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
834 self.sock.settimeout(0.1)
835 try:
836 self.sock.bind((self.bind_address, self.port))
837 self.status_update.emit(f"Listening on {self.bind_address}:{self.port}")
838 except OSError as e:
839 self.status_update.emit(f"Error binding: {e}")
840 return
841 while not self._stop:
842 try:
843 data, addr = self.sock.recvfrom(MAX_PACKET_SIZE)
844 header = parse_packet_header(data)
845 if header is None:
846 continue
847 packet_type, version, timestamp = header
848 if packet_type == PacketType.SENSOR_DATA:
849 result = parse_sensor_data_packet(data)
850 if result:
851 header_dict, chunks = result
852 self.sensor_data_received.emit(header_dict, chunks, addr[0])
853 except socket.timeout:
854 continue
855 except Exception as e:
856 if not self._stop:
857 self.status_update.emit(f"Error: {e}")
858 continue
859 if self.sock:
860 try:
861 self.sock.close()
862 except Exception:
863 pass
864 self.status_update.emit("Stopped")
865
866
867# ---------------------- Full Plot Window ----------------------
868class FullPlotWindow(QtWidgets.QMainWindow):
869 """
870 Full plot window with timing analysis for all open/close pairs.
871 Displays per-pair metrics (including ΔP) and averages in a table.
872 """
873
875 self,
876 times: np.ndarray,
877 pressures: np.ndarray,
878 actuator_events: List[Tuple[float, str]],
879 sensor_label: str,
880 ):
881 super().__init__()
882 self.setWindowTitle(f"Full Plot & Timing Analysis - {sensor_label}")
883 self.resize(1200, 800)
884
885 self.times = np.asarray(times, dtype=float)
886 self.pressures = np.asarray(pressures, dtype=float)
887 self.actuator_events = list(actuator_events)
888 self.sensor_label = sensor_label
889 self.timing_results: List[Dict] = []
890
891 self.widget = QtWidgets.QWidget()
892 self.setCentralWidget(self.widget)
893 layout = QtWidgets.QVBoxLayout(self.widget)
894 layout.setContentsMargins(8, 8, 8, 8)
895
896 # --- Toolbar ---
897 toolbar = QtWidgets.QHBoxLayout()
898 self.btn_reset = QtWidgets.QPushButton("Reset View")
899 self.btn_reset.clicked.connect(self.on_reset_view)
900 toolbar.addWidget(self.btn_reset)
901
902 self.btn_mode = QtWidgets.QPushButton("Drag Mode: Pan")
903 self.btn_mode.setCheckable(True)
904 self.btn_mode.clicked.connect(self.on_toggle_mode)
905 toolbar.addWidget(self.btn_mode)
906
907 self.btn_save_csv = QtWidgets.QPushButton("Save CSV")
908 self.btn_save_csv.clicked.connect(self.on_save_csv)
909 toolbar.addWidget(self.btn_save_csv)
910
911 toolbar.addStretch()
912 layout.addLayout(toolbar)
913
914 # --- Plot (top 65%) ---
915 self.plot_widget = pg.PlotWidget(title=f"Pressure: {sensor_label}")
916 self.plot_widget.setBackground("k")
917 self.plot_widget.setLabel("left", "Pressure (psi)")
918 self.plot_widget.setLabel("bottom", "Time (seconds)")
919 self.plot_widget.showGrid(x=True, y=True, alpha=0.5)
920 layout.addWidget(self.plot_widget, 65)
921
922 # Plot pressure curve
923 self.plot_widget.plot(
924 self.times,
925 self.pressures,
926 pen=pg.mkPen((80, 150, 255), width=2),
927 name="Pressure",
928 )
929
930 # --- Results table (bottom 35%) ---
931 self.table = QtWidgets.QTableWidget()
932 self.table.setColumnCount(12)
933 self.table.setHorizontalHeaderLabels(
934 [
935 "Pair #",
936 "Open Cmd (s)",
937 "Open Start (s)",
938 "Open End (s)",
939 "Close Cmd (s)",
940 "Close Start (s)",
941 "Close End (s)",
942 "Open Delay dT (s)",
943 "Open Full dT (s)",
944 "Close Delay dT (s)",
945 "Close Full dT (s)",
946 "Total dP (psi)",
947 ]
948 )
949 self.table.horizontalHeader().setStretchLastSection(True)
950 self.table.setEditTriggers(
951 QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers
952 )
953 self.table.setAlternatingRowColors(True)
954 # Set explicit colors for readable alternating rows
955 self.table.setStyleSheet(
956 """
957 QTableWidget {
958 background-color: #1e1e1e;
959 alternate-background-color: #2d2d2d;
960 color: #ffffff;
961 gridline-color: #444444;
962 }
963 QTableWidget::item {
964 padding: 4px;
965 }
966 QHeaderView::section {
967 background-color: #3c3c3c;
968 color: #ffffff;
969 padding: 4px;
970 border: 1px solid #444444;
971 }
972 """
973 )
974 layout.addWidget(self.table, 35)
975
976 # Crosshair for plot
977 self.v_line = pg.InfiniteLine(
978 angle=90,
979 movable=False,
980 pen=pg.mkPen("y", width=1, style=QtCore.Qt.PenStyle.DotLine),
981 )
982 self.h_line = pg.InfiniteLine(
983 angle=0,
984 movable=False,
985 pen=pg.mkPen("y", width=1, style=QtCore.Qt.PenStyle.DotLine),
986 )
987 self.plot_widget.addItem(self.v_line, ignoreBounds=True)
988 self.plot_widget.addItem(self.h_line, ignoreBounds=True)
989 self.hover_label = pg.TextItem(text="", anchor=(0, 1), color="y")
990 self.plot_widget.addItem(self.hover_label, ignoreBounds=True)
991 # Reduce rateLimit to 20 to reduce cursor lag
992 self.proxy = pg.SignalProxy(
993 self.plot_widget.scene().sigMouseMoved, rateLimit=20, slot=self._mouse_moved
994 )
995
996 # Run analysis and populate UI
997 self._run_analysis()
998 self._draw_event_lines()
999 self._populate_table()
1000
1001 def on_reset_view(self):
1002 self.plot_widget.plotItem.autoRange()
1003
1005 # If checked -> Rect Mode (Zoom), else Pan Mode
1006 if self.btn_mode.isChecked():
1007 self.btn_mode.setText("Drag Mode: Zoom Box")
1008 self.plot_widget.plotItem.getViewBox().setMouseMode(pg.ViewBox.RectMode)
1009 else:
1010 self.btn_mode.setText("Drag Mode: Pan")
1011 self.plot_widget.plotItem.getViewBox().setMouseMode(pg.ViewBox.PanMode)
1012
1013 def _pair_events(self) -> List[Tuple[float, float]]:
1014 """Pair each OPEN with the immediately following CLOSE."""
1015 sorted_events = sorted(self.actuator_events, key=lambda x: x[0])
1016 pairs = []
1017 i = 0
1018 while i < len(sorted_events):
1019 t_open, kind = sorted_events[i]
1020 if kind == "open":
1021 # Find next close
1022 for j in range(i + 1, len(sorted_events)):
1023 if sorted_events[j][1] == "closed":
1024 pairs.append((t_open, sorted_events[j][0]))
1025 i = j # Skip to after this close
1026 break
1027 i += 1
1028 return pairs
1029
1030 def _run_analysis(self):
1031 """Run timing analysis on all open/close pairs."""
1032 pairs = self._pair_events()
1033 for t_open, t_close in pairs:
1034 try:
1036 self.times, self.pressures, t_open, t_close
1037 )
1038 # Compute deltaP
1039 P_open_pre = result.get("P_open_pre")
1040 P_open_post = result.get("P_open_post")
1041 P_close_pre = result.get("P_close_pre")
1042 P_close_post = result.get("P_close_post")
1043 result["delta_P_open"] = (
1044 (P_open_pre - P_open_post)
1045 if (P_open_pre is not None and P_open_post is not None)
1046 else None
1047 )
1048 result["delta_P_close"] = (
1049 (P_close_post - P_close_pre)
1050 if (P_close_post is not None and P_close_pre is not None)
1051 else None
1052 )
1053 result["total_dP"] = (
1054 (P_open_pre - P_close_post)
1055 if (P_open_pre is not None and P_close_post is not None)
1056 else None
1057 )
1058 self.timing_results.append(result)
1059 except ValueError:
1060 # Skip pairs that fail analysis (e.g., not enough data)
1061 pass
1062
1064 """Draw vertical lines for command events and detected timing events."""
1065 # Command lines (OPEN = green dashed, CLOSED = red dashed)
1066 for t, kind in self.actuator_events:
1067 color = (80, 255, 80) if kind == "open" else (255, 80, 80)
1068 pen = pg.mkPen(color=color, width=1.5, style=QtCore.Qt.PenStyle.DashLine)
1069 line = pg.InfiniteLine(pos=t, angle=90, pen=pen, movable=False)
1070 self.plot_widget.addItem(line)
1071
1072 # Detected timing lines for each pair
1073 # Open Start/End = Cyan (dash/solid), Close Start/End = Magenta (dash/solid)
1074 for result in self.timing_results:
1075 lines_def = [
1076 (
1077 result.get("t_open_start"),
1078 (0, 255, 255),
1079 QtCore.Qt.PenStyle.DashLine,
1080 ),
1081 (result.get("t_open_end"), (0, 255, 255), QtCore.Qt.PenStyle.SolidLine),
1082 (
1083 result.get("t_close_start"),
1084 (255, 0, 255),
1085 QtCore.Qt.PenStyle.DashLine,
1086 ),
1087 (
1088 result.get("t_close_end"),
1089 (255, 0, 255),
1090 QtCore.Qt.PenStyle.SolidLine,
1091 ),
1092 ]
1093 for t_val, color, style in lines_def:
1094 if t_val is not None:
1095 pen = pg.mkPen(color=color, width=1.5, style=style)
1096 line = pg.InfiniteLine(pos=t_val, angle=90, pen=pen, movable=False)
1097 self.plot_widget.addItem(line)
1098
1100 """Populate table with per-pair results and averages."""
1101
1102 def _fmt(val, decimals=4):
1103 return f"{val:.{decimals}f}" if val is not None else "N/A"
1104
1105 num_pairs = len(self.timing_results)
1106 self.table.setRowCount(num_pairs + 1) # +1 for averages row
1107
1108 # Per-pair rows
1109 for i, result in enumerate(self.timing_results):
1110 self.table.setItem(i, 0, QtWidgets.QTableWidgetItem(str(i + 1)))
1111 # Timestamps
1112 self.table.setItem(
1113 i, 1, QtWidgets.QTableWidgetItem(_fmt(result.get("t_open_cmd")))
1114 )
1115 self.table.setItem(
1116 i, 2, QtWidgets.QTableWidgetItem(_fmt(result.get("t_open_start")))
1117 )
1118 self.table.setItem(
1119 i, 3, QtWidgets.QTableWidgetItem(_fmt(result.get("t_open_end")))
1120 )
1121 self.table.setItem(
1122 i, 4, QtWidgets.QTableWidgetItem(_fmt(result.get("t_close_cmd")))
1123 )
1124 self.table.setItem(
1125 i, 5, QtWidgets.QTableWidgetItem(_fmt(result.get("t_close_start")))
1126 )
1127 self.table.setItem(
1128 i, 6, QtWidgets.QTableWidgetItem(_fmt(result.get("t_close_end")))
1129 )
1130 # Delays & dP
1131 self.table.setItem(
1132 i, 7, QtWidgets.QTableWidgetItem(_fmt(result.get("open_delay")))
1133 )
1134 self.table.setItem(
1135 i, 8, QtWidgets.QTableWidgetItem(_fmt(result.get("open_time")))
1136 )
1137 self.table.setItem(
1138 i, 9, QtWidgets.QTableWidgetItem(_fmt(result.get("close_delay")))
1139 )
1140 self.table.setItem(
1141 i, 10, QtWidgets.QTableWidgetItem(_fmt(result.get("close_time")))
1142 )
1143 self.table.setItem(
1144 i, 11, QtWidgets.QTableWidgetItem(_fmt(result.get("total_dP"), 2))
1145 )
1146
1147 # Averages row
1148 def _avg(key):
1149 vals = [r.get(key) for r in self.timing_results if r.get(key) is not None]
1150 return sum(vals) / len(vals) if vals else None
1151
1152 avg_row = num_pairs
1153 avg_label = QtWidgets.QTableWidgetItem("AVG")
1154 avg_label.setFont(QtGui.QFont("", -1, QtGui.QFont.Weight.Bold))
1155 self.table.setItem(avg_row, 0, avg_label)
1156 # Skip averaging absolute timestamps (cols 1-6)
1157 for c in range(1, 7):
1158 self.table.setItem(avg_row, c, QtWidgets.QTableWidgetItem(""))
1159
1160 self.table.setItem(
1161 avg_row, 7, QtWidgets.QTableWidgetItem(_fmt(_avg("open_delay")))
1162 )
1163 self.table.setItem(
1164 avg_row, 8, QtWidgets.QTableWidgetItem(_fmt(_avg("open_time")))
1165 )
1166 self.table.setItem(
1167 avg_row, 9, QtWidgets.QTableWidgetItem(_fmt(_avg("close_delay")))
1168 )
1169 self.table.setItem(
1170 avg_row, 10, QtWidgets.QTableWidgetItem(_fmt(_avg("close_time")))
1171 )
1172 self.table.setItem(
1173 avg_row, 11, QtWidgets.QTableWidgetItem(_fmt(_avg("total_dP"), 2))
1174 )
1175
1176 # Bold the averages row and set distinct background color for readability
1177 avg_bg = QtGui.QColor(40, 60, 80) # Dark blue-gray
1178 avg_fg = QtGui.QColor(255, 255, 255) # White text
1179 for col in range(12):
1180 item = self.table.item(avg_row, col)
1181 if item:
1182 font = item.font()
1183 font.setBold(True)
1184 item.setFont(font)
1185 item.setBackground(avg_bg)
1186 item.setForeground(avg_fg)
1187
1188 self.table.resizeColumnsToContents()
1189
1190 def on_save_csv(self):
1191 """Save pressure data, actuator events, and timing results to CSV."""
1192 default_name = f"solenoid_char_{self.sensor_label.replace(' ', '_')}_{time.strftime('%Y%m%d_%H%M%S')}.csv"
1193 path, _ = QtWidgets.QFileDialog.getSaveFileName(
1194 self,
1195 "Save characterization CSV",
1196 default_name,
1197 "CSV (*.csv);;All files (*)",
1198 )
1199 if not path:
1200 return
1201 try:
1202 # Build timing event map from analysis results
1203 timing_map = {} # t -> "EVENT_NAME"
1204 for result in self.timing_results:
1205 for key in [
1206 "t_open_start",
1207 "t_open_end",
1208 "t_close_start",
1209 "t_close_end",
1210 ]:
1211 val = result.get(key)
1212 if val is not None:
1213 # Find closest timestamp in data
1214 idx = (np.abs(self.times - val)).argmin()
1215 closest_t = self.times[idx]
1216 if abs(closest_t - val) < 0.05: # 50ms tolerance
1217 timing_map[closest_t] = key.upper()
1218
1219 # Build actuator event map
1220 event_at_t = {
1221 t: ("OPEN" if k == "open" else "CLOSED")
1222 for t, k in self.actuator_events
1223 }
1224
1225 # Build rows
1226 rows = []
1227 for i, t in enumerate(self.times):
1228 psi = self.pressures[i]
1229 act = event_at_t.get(t, "")
1230 timing = timing_map.get(t, "")
1231 rows.append((t, f"{psi:.4f}", act, timing))
1232
1233 # Add command events if their times don't match samples exactly
1234 existing_ts = set(self.times)
1235 for t, kind in self.actuator_events:
1236 if not any(abs(t - et) < 1e-9 for et in existing_ts):
1237 rows.append((t, "", "OPEN" if kind == "open" else "CLOSED", ""))
1238
1239 rows.sort(key=lambda r: r[0])
1240
1241 with open(path, "w", newline="", encoding="utf-8") as f:
1242 w = csv.writer(f)
1243 w.writerow(["time_sec", "pressure_psi", "ACTUATION", "TIMING_EVENT"])
1244 for t, psi, act, timing in rows:
1245 w.writerow([f"{t:.6f}", psi, act, timing])
1246 QtWidgets.QMessageBox.information(self, "Save CSV", f"Saved to {path}")
1247 except OSError as e:
1248 QtWidgets.QMessageBox.critical(self, "Save CSV", f"Error saving file: {e}")
1249
1250 def _mouse_moved(self, evt):
1251 pos = evt[0]
1252 if self.plot_widget.sceneBoundingRect().contains(pos):
1253 mouse_point = self.plot_widget.plotItem.vb.mapSceneToView(pos)
1254 self.v_line.setPos(mouse_point.x())
1255 self.h_line.setPos(mouse_point.y())
1256 self.hover_label.setText(
1257 f"t={mouse_point.x():.4f}, P={mouse_point.y():.2f}"
1258 )
1259 self.hover_label.setPos(mouse_point.x(), mouse_point.y())
1260
1261
1262# ---------------------- Main Window ----------------------
1263class SolenoidCharacterizationWindow(QtWidgets.QMainWindow):
1264 """Single window: PT selector, actuator selector, one pressure graph, OPEN/CLOSED buttons, vertical event lines."""
1265
1267 self,
1268 receiver: UDPReceiver,
1269 device_ip: str,
1270 device_port: int,
1271 bind_address: str = "0.0.0.0",
1272 ):
1273 super().__init__()
1274 self.setWindowTitle("Solenoid Characterization")
1275 self.receiver = receiver
1276 self.device_ip = device_ip
1277 self.device_port = device_port
1278 self.bind_address = bind_address
1279
1281 self.sensor_data: Dict[int, deque] = {}
1282 self.sensor_adc_codes: Dict[int, deque] = {}
1283 self.sensor_psi_data: Dict[int, deque] = {}
1284 self.filter_source_ip = CONFIG.config["network"]["sensor_ip_filter"]
1285 self.adc_bits = CONFIG.config["display"]["adc_bits"]
1286 self.reference_voltage = CONFIG.config["display"]["ref_voltage"]
1287 # Sliding window: 10–120 s (slider updates this)
1288 config_window = CONFIG.config["display"].get(
1289 "window_seconds", DEFAULT_WINDOW_SECONDS
1290 )
1291 self.window_seconds = max(10.0, min(120.0, float(config_window)))
1292 self.y_axis_auto_scale = CONFIG.config["display"].get("y_axis_autoscale", True)
1293 self.y_axis_min = float(CONFIG.config["display"].get("y_axis_min", 0.0))
1294 self.y_axis_max = float(CONFIG.config["display"].get("y_axis_max", 700.0))
1295
1296 base = Path(__file__).parent
1297 paths = CONFIG.get_pt_calibration_csv_paths()
1298 resolved = [str(base / p) for p in paths] if paths else []
1300 resolved if resolved else []
1301 )
1302 if self.pt_calibration_error:
1303 print(f"PT Calibration Error: {self.pt_calibration_error}")
1304
1305 self.actuator_events: List[Tuple[float, str]] = [] # (t_sec, "open"|"closed")
1306 self.event_lines: List[pg.InfiniteLine] = []
1307
1308 # Board Sync state
1309 self.board_t0_ms: Optional[int] = None
1310 # (board_accum_ms, wall_time_sec) for the latest received packet
1311 self.last_sync_params: Optional[Tuple[float, float]] = None
1312
1313 self.command_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
1314
1315 self.init_ui()
1316
1317 self.receiver.sensor_data_received.connect(self.on_sensor_data)
1318 self.receiver.status_update.connect(self.on_status_update)
1319
1320 self.update_timer = QtCore.QTimer(self)
1321 self.update_timer.timeout.connect(self.update_plot)
1322 self.update_timer.start(UPDATE_INTERVAL_MS)
1323
1324 self.paused = False
1325 self.full_plot_window = None # Keep reference to prevent GC
1326
1327 def init_ui(self):
1328 central = QtWidgets.QWidget()
1329 self.setCentralWidget(central)
1330 main_h = QtWidgets.QHBoxLayout(central)
1331 main_h.setContentsMargins(4, 4, 4, 4)
1332 main_h.setSpacing(8)
1333
1334 # Left side: 70% — existing controls + plot + buttons
1335 left_widget = QtWidgets.QWidget()
1336 layout = QtWidgets.QVBoxLayout(left_widget)
1337 layout.setContentsMargins(0, 0, 0, 0)
1338
1339 top = QtWidgets.QHBoxLayout()
1340 top.addWidget(QtWidgets.QLabel("PT:"))
1341 self.pt_combo = QtWidgets.QComboBox()
1342 self._fill_pt_combo()
1343 self.pt_combo.currentIndexChanged.connect(self._on_pt_changed)
1344 top.addWidget(self.pt_combo)
1345
1346 top.addWidget(QtWidgets.QLabel("Actuator:"))
1347 self.actuator_combo = QtWidgets.QComboBox()
1348 for name in CONFIG.get_actuator_role_names():
1349 aid = CONFIG.get_actuator_role(name)
1350 if aid and 1 <= aid <= NUM_ACTUATORS:
1351 label = CONFIG.get_actuator_label(aid) or name
1352 self.actuator_combo.addItem(f"{label} (ID {aid})", aid)
1353 if self.actuator_combo.count() == 0:
1354 for aid in range(1, NUM_ACTUATORS + 1):
1355 self.actuator_combo.addItem(f"Actuator {aid}", aid)
1356
1357 # Default to Actuator 7 if present
1358 idx_7 = self.actuator_combo.findData(7)
1359 if idx_7 >= 0:
1360 self.actuator_combo.setCurrentIndex(idx_7)
1361
1362 self.actuator_combo.currentIndexChanged.connect(self._on_actuator_changed)
1363 top.addWidget(self.actuator_combo)
1364
1365 top.addWidget(QtWidgets.QLabel("Receive IP:"))
1366 self.receive_ip_edit = QtWidgets.QLineEdit(self.filter_source_ip)
1367 self.receive_ip_edit.setFixedWidth(120)
1368 self.receive_ip_edit.setToolTip("IP address to accept sensor data from")
1369 self.receive_ip_edit.editingFinished.connect(self._on_receive_ip_changed)
1370 top.addWidget(self.receive_ip_edit)
1371
1372 top.addWidget(QtWidgets.QLabel("Send IP:"))
1373 self.send_ip_edit = QtWidgets.QLineEdit(self.device_ip)
1374 self.send_ip_edit.setFixedWidth(120)
1375 self.send_ip_edit.setToolTip("IP address to send actuator commands to")
1376 self.send_ip_edit.editingFinished.connect(self._on_send_ip_changed)
1377 top.addWidget(self.send_ip_edit)
1378
1379 self.status_label = QtWidgets.QLabel("Starting...")
1380 self.status_label.setStyleSheet("font-weight: bold; padding: 5px;")
1381 top.addWidget(self.status_label)
1382 top.addWidget(QtWidgets.QLabel("Selected PT rate:"))
1383 self.pt_sample_rate_label = QtWidgets.QLabel("-- Hz")
1384 self.pt_sample_rate_label.setStyleSheet(
1385 "font-family: monospace; min-width: 90px;"
1386 )
1387 top.addWidget(self.pt_sample_rate_label)
1388 top.addStretch()
1389 layout.addLayout(top)
1390
1391 # Window size slider (10–120 s)
1392 window_row = QtWidgets.QHBoxLayout()
1393 window_row.addWidget(QtWidgets.QLabel("Window:"))
1394 self.window_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
1395 self.window_slider.setMinimum(10)
1396 self.window_slider.setMaximum(120)
1397 self.window_slider.setValue(int(self.window_seconds))
1398 self.window_slider.setTickPosition(QtWidgets.QSlider.TickPosition.TicksBelow)
1399 self.window_slider.setTickInterval(10)
1400 self.window_slider.valueChanged.connect(self._on_window_slider_changed)
1401 window_row.addWidget(self.window_slider, 1)
1402 self.window_label = QtWidgets.QLabel(f"{int(self.window_seconds)} s")
1403 self.window_label.setMinimumWidth(40)
1404 window_row.addWidget(self.window_label)
1405 layout.addLayout(window_row)
1406
1407 self.plot_widget = pg.GraphicsLayoutWidget()
1408 self.plot_widget.setBackground("k")
1409 layout.addWidget(self.plot_widget, 1)
1410
1411 self.plot_item = self.plot_widget.addPlot(title="Pressure (selected PT)")
1412 self.plot_item.setTitle("Pressure (selected PT)", color="w", size="14pt")
1413 self.plot_item.setLabel("left", "Pressure (psi)", color="w")
1414 self.plot_item.setLabel("bottom", "Time (seconds)", color="w")
1415 self.plot_item.showGrid(x=True, y=True, alpha=0.5)
1416 self.plot_item.getViewBox().setBackgroundColor("k")
1417 font = QtGui.QFont()
1418 font.setPointSize(12)
1419 for axis_name in ("left", "bottom"):
1420 ax = self.plot_item.getAxis(axis_name)
1421 ax.setStyle(tickFont=font)
1422 ax.setPen(pg.mkPen("w", width=0.5))
1423 ax.setTextPen("w")
1424 try:
1425 self.plot_item.getAxis("left").label.setFont(font)
1426 self.plot_item.getAxis("bottom").label.setFont(font)
1427 except AttributeError:
1428 pass
1429
1430 self.pt_curve = self.plot_item.plot(
1431 [], [], pen=pg.mkPen((80, 150, 255), width=2), name="PT (psi)"
1432 )
1433
1434 # Live PT readout (top-right of plot, in data coordinates)
1435 self.pt_readout = pg.TextItem(text="", anchor=(1, 1), color=(200, 220, 255))
1436 self.pt_readout.setFont(QtGui.QFont("Monospace", 14, QtGui.QFont.Weight.Bold))
1437 self.plot_item.addItem(self.pt_readout)
1438
1439 btn_row = QtWidgets.QHBoxLayout()
1440 self.save_csv_btn = QtWidgets.QPushButton("Save CSV")
1441 self.save_csv_btn.setMinimumHeight(40)
1442 self.save_csv_btn.clicked.connect(self.save_characterization_csv)
1443 btn_row.addWidget(self.save_csv_btn)
1444 self.open_btn = QtWidgets.QPushButton("OPEN")
1445 self.open_btn.setMinimumHeight(40)
1446 self.open_btn.clicked.connect(self.on_open_clicked)
1447 self.closed_btn = QtWidgets.QPushButton("CLOSED")
1448 self.closed_btn.setMinimumHeight(40)
1449 self.closed_btn.clicked.connect(self.on_closed_clicked)
1450 btn_row.addWidget(self.open_btn)
1451 btn_row.addWidget(self.closed_btn)
1452
1453 self.full_plot_btn = QtWidgets.QPushButton("Full Plot")
1454 self.full_plot_btn.setMinimumHeight(40)
1455 self.full_plot_btn.clicked.connect(self.on_full_plot_clicked)
1456 btn_row.addWidget(self.full_plot_btn)
1457
1458 self.pause_btn = QtWidgets.QPushButton("Pause")
1459 self.pause_btn.setMinimumHeight(40)
1460 self.pause_btn.setCheckable(True)
1461 self.pause_btn.clicked.connect(self.on_pause_clicked)
1462 btn_row.addWidget(self.pause_btn)
1463
1464 btn_row.addStretch()
1465
1466 # PWM actuation
1467 pwm_label = QtWidgets.QLabel("PWM:")
1468 pwm_label.setStyleSheet("font-weight: bold;")
1469 btn_row.addWidget(pwm_label)
1470 btn_row.addWidget(QtWidgets.QLabel("Duty %"))
1471 self.pwm_duty_spin = QtWidgets.QDoubleSpinBox()
1472 self.pwm_duty_spin.setRange(0, 100)
1473 self.pwm_duty_spin.setValue(50)
1474 self.pwm_duty_spin.setDecimals(1)
1475 self.pwm_duty_spin.setSuffix(" %")
1476 self.pwm_duty_spin.setMinimumHeight(32)
1477 btn_row.addWidget(self.pwm_duty_spin)
1478 btn_row.addWidget(QtWidgets.QLabel("Freq (Hz)"))
1479 self.pwm_freq_spin = QtWidgets.QDoubleSpinBox()
1480 self.pwm_freq_spin.setRange(0.1, 1000000.0)
1481 self.pwm_freq_spin.setValue(10)
1482 self.pwm_freq_spin.setDecimals(1)
1483 self.pwm_freq_spin.setMinimumHeight(32)
1484 btn_row.addWidget(self.pwm_freq_spin)
1485 btn_row.addWidget(QtWidgets.QLabel("Duration (s)"))
1486 self.pwm_duration_spin = QtWidgets.QDoubleSpinBox()
1487 self.pwm_duration_spin.setRange(0.1, 3600)
1488 self.pwm_duration_spin.setValue(2.0)
1489 self.pwm_duration_spin.setDecimals(2)
1490 self.pwm_duration_spin.setMinimumHeight(32)
1491 btn_row.addWidget(self.pwm_duration_spin)
1492 self.pwm_go_btn = QtWidgets.QPushButton("Go")
1493 self.pwm_go_btn.setMinimumHeight(40)
1494 self.pwm_go_btn.clicked.connect(self.on_pwm_go_clicked)
1495 btn_row.addWidget(self.pwm_go_btn)
1496
1497 layout.addLayout(btn_row)
1498
1499 main_h.addWidget(left_widget)
1500
1502 self.pt_combo.clear()
1503 for pt_id in sorted(self.pt_calibration.keys()):
1504 label = CONFIG.get_sensor_label(pt_id) or ""
1505 if label:
1506 self.pt_combo.addItem(f"{label} (PT {pt_id})", pt_id)
1507 else:
1508 self.pt_combo.addItem(f"PT {pt_id}", pt_id)
1509 if self.pt_combo.count() == 0:
1510 for pt_id in range(1, NUM_CONNECTORS + 1):
1511 self.pt_combo.addItem(f"PT {pt_id}", pt_id)
1512
1513 def _on_pt_changed(self, index: int):
1514 self.update_plot()
1515
1516 def _on_actuator_changed(self, index: int):
1517 pass
1518
1520 new_ip = self.receive_ip_edit.text().strip()
1521 if new_ip and new_ip != self.filter_source_ip:
1522 self.filter_source_ip = new_ip
1523 self.status_label.setText(f"Receive IP → {new_ip}")
1524
1526 new_ip = self.send_ip_edit.text().strip()
1527 if new_ip and new_ip != self.device_ip:
1528 self.device_ip = new_ip
1529 self.status_label.setText(f"Send IP → {new_ip}")
1530
1531 def _on_window_slider_changed(self, value: int):
1532 self.window_seconds = float(value)
1533 self.window_label.setText(f"{value} s")
1534
1536 """Save the selected PT pressure and solenoid actuations as one 4-column CSV: time_sec, pressure_psi, ACTUATION, TIMING_EVENT."""
1537 pt_id = self.get_selected_pt_id()
1538 if pt_id is None:
1539 QtWidgets.QMessageBox.warning(self, "Save CSV", "No PT selected.")
1540 return
1541 default_name = f"solenoid_char_pt{pt_id}_{time.strftime('%Y%m%d_%H%M%S')}.csv"
1542 path, _ = QtWidgets.QFileDialog.getSaveFileName(
1543 self,
1544 "Save characterization CSV",
1545 default_name,
1546 "CSV (*.csv);;All files (*)",
1547 )
1548 if not path:
1549 return
1550 try:
1551 pressure_data = (
1552 list(self.sensor_psi_data[pt_id])
1553 if pt_id in self.sensor_psi_data
1554 else []
1555 )
1556 if not pressure_data:
1557 QtWidgets.QMessageBox.warning(
1558 self, "Save CSV", "No pressure data to save."
1559 )
1560 return
1561
1562 # Analyze timing events for all pairs
1563 t_all = np.array([x[0] for x in pressure_data])
1564 P_all = np.array([x[1] for x in pressure_data])
1565 timing_map = {} # t -> "EVENT_NAME"
1566
1567 sorted_events = sorted(self.actuator_events, key=lambda x: x[0])
1568 # Find pairs of OPEN -> CLOSED
1569 # Find pairs of OPEN -> CLOSED
1570 processed_indices = set()
1571 for i in range(len(sorted_events)):
1572 if i in processed_indices:
1573 continue
1574
1575 t_open, kind = sorted_events[i]
1576 if kind == "open":
1577 # Find next closed
1578 t_close = None
1579 close_idx = -1
1580 for j in range(i + 1, len(sorted_events)):
1581 if sorted_events[j][1] == "closed":
1582 t_close = sorted_events[j][0]
1583 close_idx = j
1584 break
1585
1586 if t_close:
1587 # Mark close as processed so we don't use it again (though finding OPEN skips it anyway)
1588 processed_indices.add(close_idx)
1589
1590 try:
1591 # Run detection on full data window
1593 t_all, P_all, t_open, t_close
1594 )
1595 # Map results to closest samples
1596 for key in [
1597 "t_open_start",
1598 "t_open_end",
1599 "t_close_start",
1600 "t_close_end",
1601 ]:
1602 val = res.get(key)
1603 if val is not None:
1604 # Find closest timestamp in data
1605 idx = (np.abs(t_all - val)).argmin()
1606 closest_t = t_all[idx]
1607 # Use a small tolerance to ensure we map to the right sample
1608 if abs(closest_t - val) < 0.05: # 50ms tolerance
1609 timing_map[closest_t] = key.upper()
1610 except ValueError:
1611 pass
1612
1613 # Build merged rows: (time_sec, pressure_psi, ACTUATION, TIMING_EVENT)
1614 rows = []
1615 event_at_t = {
1616 t: ("OPEN" if k == "open" else "CLOSED")
1617 for t, k in self.actuator_events
1618 }
1619
1620 for t, psi in pressure_data:
1621 act = event_at_t.get(t, "")
1622 timing = timing_map.get(t, "")
1623 rows.append((t, f"{psi:.4f}", act, timing))
1624
1625 # Ensure command events are included if exact timestamps didn't align with samples
1626 # (Though map above handles existing pressure samples, commands are from different source)
1627 # Insert logic similar to original to ensure commands are present
1628 # But here we focus on adding content to existing pressure rows mostly.
1629 # If command time is distinct, we insert a new row.
1630
1631 # Copy existing rows to check against
1632 existing_ts = set(r[0] for r in rows)
1633
1634 for t, kind in self.actuator_events:
1635 if not any(abs(t - et) < 1e-9 for et in existing_ts):
1636 rows.append((t, "", "OPEN" if kind == "open" else "CLOSED", ""))
1637
1638 rows.sort(key=lambda r: r[0])
1639
1640 with open(path, "w", newline="", encoding="utf-8") as f:
1641 w = csv.writer(f)
1642 w.writerow(["time_sec", "pressure_psi", "ACTUATION", "TIMING_EVENT"])
1643 for t, psi, act, timing in rows:
1644 w.writerow([f"{t:.6f}", psi, act, timing])
1645 QtWidgets.QMessageBox.information(self, "Save CSV", f"Saved to {path}")
1646 except OSError as e:
1647 QtWidgets.QMessageBox.critical(self, "Save CSV", f"Error saving file: {e}")
1648
1649 def get_selected_pt_id(self) -> Optional[int]:
1650 if self.pt_combo.count() == 0:
1651 return None
1652 return self.pt_combo.currentData()
1653
1654 def get_selected_actuator_id(self) -> Optional[int]:
1655 if self.actuator_combo.count() == 0:
1656 return None
1657 return self.actuator_combo.currentData()
1658
1659 def code_to_voltage(self, code_uint32: int) -> float:
1660 code_int32 = code_uint32 if code_uint32 < 2**31 else code_uint32 - 2**32
1661 max_code = 2 ** (self.adc_bits - 1)
1662 return (code_int32 * self.reference_voltage) / max_code
1663
1664 def get_current_board_time(self) -> float:
1665 """Estimate current board time (in seconds relative to board_t0) using last sync."""
1666 if self.board_t0_ms is None or self.last_sync_params is None:
1667 # Fallback if no data yet: just use wall clock relative to start
1668 return time.time() - self.stats_start_time
1669
1670 last_board_ms, last_wall_sec = self.last_sync_params
1671 now_wall = time.time()
1672 elapsed_sec = now_wall - last_wall_sec
1673 # Estimate: board time has advanced by same amount as wall time since last packet
1674 current_board_ms = last_board_ms + (elapsed_sec * 1000.0)
1675 return (current_board_ms - self.board_t0_ms) / 1000.0
1676
1677 def on_status_update(self, message: str):
1678 self.status_label.setText(message)
1679
1680 def on_sensor_data(self, header: dict, chunks: List[dict], source_ip: str):
1681 if source_ip != self.filter_source_ip:
1682 return
1683
1684 dt_sec = 1.0 / SAMPLE_RATE_HZ
1685 arrival_wall_time = time.time()
1686
1687 for chunk in chunks:
1688 chunk_ts_ms = chunk["timestamp"]
1689 if self.board_t0_ms is None:
1690 self.board_t0_ms = chunk_ts_ms
1691
1692 # Base time for this chunk in plot seconds (relative to first packet)
1693 # Timestamp corresponds to START of chunk (based on firmware logic)
1694 chunk_base_sec = (chunk_ts_ms - self.board_t0_ms) / 1000.0
1695
1696 # Identify the latest sample time in this chunk for sync update
1697 last_sample_ms = (
1698 chunk_ts_ms + (len(chunk["datapoints"]) - 1) * dt_sec * 1000.0
1699 )
1700
1701 for i, dp in enumerate(chunk["datapoints"]):
1702 # Sample time based entirely on board crystal (timestamp + index)
1703 sample_time = chunk_base_sec + i * dt_sec
1704
1705 sensor_id = dp["sensor_id"]
1706 if sensor_id not in self.sensor_data:
1707 self.sensor_data[sensor_id] = deque(maxlen=MAX_POINTS)
1708 self.sensor_adc_codes[sensor_id] = deque(maxlen=MAX_POINTS)
1709 if sensor_id in self.pt_calibration:
1710 self.sensor_psi_data[sensor_id] = deque(maxlen=MAX_POINTS)
1711
1712 # Enforce monotonicity in case of timestamp jitter/resets (though rare with millis)
1713 deq = self.sensor_data[sensor_id]
1714 if len(deq) > 0 and sample_time <= deq[-1][0]:
1715 sample_time = deq[-1][0] + dt_sec
1716
1717 code_uint32 = dp["data"]
1718 voltage = self.code_to_voltage(code_uint32)
1719 self.sensor_data[sensor_id].append((sample_time, voltage))
1720 self.sensor_adc_codes[sensor_id].append((sample_time, code_uint32))
1721 if sensor_id in self.pt_calibration:
1722 a, b, c, d = self.pt_calibration[sensor_id]
1723 psi = calculate_pressure(code_uint32, a, b, c, d)
1724 self.sensor_psi_data[sensor_id].append((sample_time, psi))
1725
1726 # Update sync params with the effective board time of the last processed sample
1727 # and the current wall time.
1728 self.last_sync_params = (last_sample_ms, arrival_wall_time)
1729
1730 def _send_actuator_command(self, actuator_id: int, hardware_command: int):
1731 try:
1732 packet = create_actuator_command_packet([(actuator_id, hardware_command)])
1733 if packet:
1734 self.command_sock.sendto(packet, (self.device_ip, self.device_port))
1735 except OSError as e:
1736 print(f"Error sending command: {e}")
1737
1738 def _add_event_line(self, t_sec: float, kind: str):
1739 self.actuator_events.append((t_sec, kind))
1740 if kind == "open":
1741 pen = pg.mkPen(
1742 color=(80, 255, 80), width=1.5, style=QtCore.Qt.PenStyle.DashLine
1743 )
1744 else:
1745 pen = pg.mkPen(
1746 color=(255, 80, 80), width=1.5, style=QtCore.Qt.PenStyle.DashLine
1747 )
1748 line = pg.InfiniteLine(pos=t_sec, angle=90, pen=pen, movable=False)
1749 self.plot_item.addItem(line)
1750 self.event_lines.append(line)
1751
1753 aid = self.get_selected_actuator_id()
1754 if not aid:
1755 return
1756 # Use estimated board time for event
1757 t_sec = self.get_current_board_time()
1758 actuator_type = CONFIG.get_actuator_type_by_id(aid)
1759 if actuator_type == "NO":
1760 hardware_command = 0
1761 else:
1762 hardware_command = 1
1763 self._send_actuator_command(aid, hardware_command)
1764 self._add_event_line(t_sec, "open")
1765
1767 aid = self.get_selected_actuator_id()
1768 if not aid:
1769 return
1770 # Use estimated board time for event
1771 t_sec = self.get_current_board_time()
1772 actuator_type = CONFIG.get_actuator_type_by_id(aid)
1773 if actuator_type == "NO":
1774 hardware_command = 1
1775 else:
1776 hardware_command = 0
1777 self._send_actuator_command(aid, hardware_command)
1778 self._add_event_line(t_sec, "closed")
1779
1781 # Auto-pause when opening analysis
1782 if not self.paused:
1783 self.pause_btn.click()
1784
1785 pt_id = self.get_selected_pt_id()
1786 if pt_id is None or pt_id not in self.sensor_psi_data:
1787 QtWidgets.QMessageBox.warning(self, "Full Plot", "No data to plot.")
1788 return
1789
1790 psi_deque = self.sensor_psi_data[pt_id]
1791 if not psi_deque:
1792 QtWidgets.QMessageBox.warning(self, "Full Plot", "No data to plot.")
1793 return
1794
1795 times = np.array([t for t, _ in psi_deque])
1796 pressures = np.array([p for _, p in psi_deque])
1797
1798 # Filter actuator events to those within the data range (or all if we want context,
1799 # but the prompt implied "our test", maybe just current buffer is fine)
1800 # We'll pass all current events.
1801
1802 label = CONFIG.get_sensor_label(pt_id) or f"PT {pt_id}"
1803
1805 times, pressures, list(self.actuator_events), label
1806 )
1807 self.full_plot_window.show()
1808
1810 aid = self.get_selected_actuator_id()
1811 if not aid:
1812 QtWidgets.QMessageBox.warning(self, "PWM", "Select an actuator first.")
1813 return
1814
1815 duty = self.pwm_duty_spin.value() / 100.0
1816 freq = self.pwm_freq_spin.value()
1817 duration_s = self.pwm_duration_spin.value()
1818
1819 if freq <= 0 or duration_s <= 0:
1820 QtWidgets.QMessageBox.warning(
1821 self, "PWM", "Frequency and duration must be positive."
1822 )
1823 return
1824
1825 # Create and send PWM packet
1826 duration_ms = int(duration_s * 1000)
1827 # commands: List of tuples (actuator_id, duration_ms, duty_cycle, frequency)
1828 commands = [(aid, duration_ms, duty, freq)]
1829
1830 try:
1831 packet = create_pwm_actuator_command_packet(commands)
1832 if packet:
1833 self.command_sock.sendto(packet, (self.device_ip, self.device_port))
1834 self.status_label.setText(
1835 f"Sent PWM: ID={aid} {duration_s}s @ {freq}Hz {duty*100:.1f}%"
1836 )
1837
1838 # Add event lines for visualization
1839 # "Open" line at start
1840 t_start = self.get_current_board_time()
1841 self._add_event_line(t_start, "open")
1842
1843 # "Closed" line at end (estimated)
1844 t_end = t_start + duration_s
1845 self._add_event_line(t_end, "closed")
1846
1847 except OSError as e:
1848 self.status_label.setText(f"Error sending PWM: {e}")
1849 print(f"Error sending PWM command: {e}")
1850
1852 self.paused = self.pause_btn.isChecked()
1853 self.pause_btn.setText("Resume" if self.paused else "Pause")
1854
1855 def update_plot(self):
1856 if self.paused:
1857 return
1858
1859 current_time = time.time() - self.stats_start_time
1860 time_window = self.window_seconds
1861 window_start = current_time - time_window
1862
1863 # Trim event lines and events that have left the sliding window (keeps view and memory bounded)
1864 while (
1865 self.actuator_events
1866 and self.event_lines
1867 and self.actuator_events[0][0] < window_start
1868 ):
1869 self.actuator_events.pop(0)
1870 line = self.event_lines.pop(0)
1871 self.plot_item.removeItem(line)
1872
1873 pt_id = self.get_selected_pt_id()
1874 if pt_id is None or pt_id not in self.sensor_psi_data:
1875 chan = (
1876 (CONFIG.get_sensor_label(pt_id) or f"PT {pt_id}")
1877 if pt_id is not None
1878 else ""
1879 )
1880 self.pt_sample_rate_label.setText(
1881 f"{chan}: -- Hz".strip(": ") if chan else "-- Hz"
1882 )
1883 self.pt_curve.setData([], [])
1884 self.pt_readout.setText("")
1885 self.plot_item.setXRange(window_start, current_time, padding=0.02)
1886
1887 return
1888 psi_deque = self.sensor_psi_data[pt_id]
1889 if len(psi_deque) == 0:
1890 pt_label_empty = CONFIG.get_sensor_label(pt_id) or f"PT {pt_id}"
1891 self.pt_sample_rate_label.setText(f"{pt_label_empty}: -- Hz")
1892 self.pt_curve.setData([], [])
1893 self.pt_readout.setText("")
1894 self.plot_item.setXRange(window_start, current_time, padding=0.02)
1895
1896 return
1897 # PT sample rate for selected channel: count samples with receive-time in last 1 s
1898 # (Each (t, psi) is one PT sample; t is relative receive time. So count = samples/sec.)
1899 one_sec_ago = current_time - 1.0
1900 pt_sample_count = sum(1 for t, _ in psi_deque if t >= one_sec_ago)
1901 pt_label = CONFIG.get_sensor_label(pt_id) or f"PT {pt_id}"
1902 self.pt_sample_rate_label.setText(f"{pt_label}: {pt_sample_count} Hz")
1903 times = []
1904 psi_values = []
1905 for t, psi in psi_deque:
1906 if t >= window_start:
1907 times.append(t)
1908 psi_values.append(psi)
1909 if times:
1910 times_array = np.array(times)
1911 psi_array = np.array(psi_values)
1912 self.pt_curve.setData(times_array, psi_array)
1913 # Latest PT value readout (top-right)
1914 latest_psi = float(psi_values[-1])
1915 self.pt_readout.setText(f"{latest_psi:.1f} psi")
1916 self.pt_readout.setPos(current_time, float(np.max(psi_array)))
1917 self.plot_item.setXRange(window_start, current_time, padding=0.02)
1918 if self.y_axis_auto_scale and len(psi_array) > 0:
1919 y_min = float(np.min(psi_array))
1920 y_max = float(np.max(psi_array))
1921 margin = (y_max - y_min) * 0.05 + 1.0
1922 self.plot_item.setYRange(y_min - margin, y_max + margin, padding=0)
1923 else:
1924 self.plot_item.setYRange(self.y_axis_min, self.y_axis_max, padding=0)
1925 else:
1926 self.pt_curve.setData([], [])
1927 self.pt_readout.setText("")
1928 self.plot_item.setXRange(window_start, current_time, padding=0.02)
1929
1930 def closeEvent(self, event):
1931 if self.command_sock:
1932 try:
1933 self.command_sock.close()
1934 except Exception:
1935 pass
1936 event.accept()
1937
1938
1939def main():
1940 import argparse
1941
1942 parser = argparse.ArgumentParser(description="Solenoid Characterization GUI")
1943 parser.add_argument(
1944 "-i",
1945 "--ip",
1946 type=str,
1947 default=CONFIG.config["network"]["actuator_ip"],
1948 help="Actuator board IP",
1949 )
1950 parser.add_argument(
1951 "-p",
1952 "--port",
1953 type=int,
1954 default=CONFIG.config["network"]["receive_port"],
1955 help="UDP receive port",
1956 )
1957 parser.add_argument(
1958 "-d",
1959 "--device-port",
1960 type=int,
1961 default=CONFIG.config["network"]["actuator_port"],
1962 help="Actuator command port",
1963 )
1964 parser.add_argument(
1965 "-a", "--address", type=str, default="0.0.0.0", help="Bind address for receiver"
1966 )
1967 args = parser.parse_args()
1968
1969 app = QtWidgets.QApplication(sys.argv)
1970 app.setStyle("Fusion")
1971 palette = QtGui.QPalette()
1972 palette.setColor(
1973 QtGui.QPalette.ColorGroup.All,
1974 QtGui.QPalette.ColorRole.Window,
1975 QtGui.QColor(53, 53, 53),
1976 )
1977 palette.setColor(
1978 QtGui.QPalette.ColorGroup.All,
1979 QtGui.QPalette.ColorRole.WindowText,
1980 QtCore.Qt.GlobalColor.white,
1981 )
1982 palette.setColor(
1983 QtGui.QPalette.ColorGroup.All,
1984 QtGui.QPalette.ColorRole.Base,
1985 QtGui.QColor(25, 25, 25),
1986 )
1987 palette.setColor(
1988 QtGui.QPalette.ColorGroup.All,
1989 QtGui.QPalette.ColorRole.Text,
1990 QtCore.Qt.GlobalColor.white,
1991 )
1992 palette.setColor(
1993 QtGui.QPalette.ColorGroup.All,
1994 QtGui.QPalette.ColorRole.Button,
1995 QtGui.QColor(53, 53, 53),
1996 )
1997 palette.setColor(
1998 QtGui.QPalette.ColorGroup.All,
1999 QtGui.QPalette.ColorRole.ButtonText,
2000 QtCore.Qt.GlobalColor.white,
2001 )
2002 app.setPalette(palette)
2003
2004 receiver = UDPReceiver(port=args.port, bind_address=args.address)
2005 receiver.start()
2006
2008 receiver,
2009 device_ip=args.ip,
2010 device_port=args.device_port,
2011 bind_address=args.address,
2012 )
2013 window.showMaximized()
2014
2015 def cleanup():
2016 receiver.stop()
2017 receiver.wait(2000)
2018
2019 app.aboutToQuit.connect(cleanup)
2020 sys.exit(app.exec())
2021
2022
2023if __name__ == "__main__":
2024 main()
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
__init__(self, np.ndarray times, np.ndarray pressures, List[Tuple[float, str]] actuator_events, str sensor_label)
__init__(self, UDPReceiver receiver, str device_ip, int device_port, str bind_address="0.0.0.0")
__init__(self, int port=DEFAULT_RECEIVE_PORT, str bind_address="0.0.0.0")
static void start(gnss_frame_parser_t *p, uint8_t byte)
Definition gnss_frame.c:45
size_type count(const key_type &key) const
Definition json.hpp:19264
bool contains(const BasicJsonType *ptr) const
Definition json.hpp:14383
Definition run.py:1
Dict extract_solenoid_timing_from_commands(pd.DataFrame df, *, str time_col="time_sec", str pressure_col="pressure_psi", str actuation_col="ACTUATION", str open_token="OPEN", str close_token="CLOSED", float smooth_window_sec=0.020, float derivative_threshold_psi_s=50.0, float settle_threshold_psi_s=20.0, float pre_window_sec=0.5, float guard_sec=0.005, float min_step_psi=5.0, int sustain_samples=10, float search_max_sec=5.0, bool open_is_drop=True)
bytes create_actuator_command_packet(List[Tuple[int, int]] commands)
Optional[Tuple[dict, List[dict]]] parse_sensor_data_packet(bytes data)
Tuple[Dict[int, Tuple[float, float, float, float]], Optional[str]] load_pt_calibration(csv_paths)
bytes create_pwm_actuator_command_packet(List[Tuple[int, int, float, float]] commands)
Optional[Tuple[int, int, int]] parse_packet_header(bytes data)
float calculate_pressure(float adc_code, float PT_A, float PT_B, float PT_C, float PT_D)
Dict extract_solenoid_timing_live(np.ndarray t, np.ndarray P, float t_open_cmd, float t_close_cmd, *, float smooth_window_sec=0.020, float derivative_threshold_psi_s=50.0, float settle_threshold_psi_s=20.0, float pre_window_sec=0.5, float guard_sec=0.005, float min_step_psi=5.0, int sustain_samples=10, float search_max_sec=5.0, bool open_is_drop=True)
static void emit(sensors_core_t *c, uint8_t type, const void *data, uint8_t len)
TOML_NODISCARD_CTOR time() noexcept=default
int main()