STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
combined_gui.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3Combined Sensor & Actuator Control GUI
4Opens one full-screen window with:
5- Left (66%): Sensor/PT data receiver and real-time plotting
6- Right (33%): Actuator control and voltage monitoring
7
8Requirements: pip install pyqt6 pyqtgraph numpy
9"""
10
11import csv
12import json
13import os
14import re
15import socket
16import struct
17import sys
18import time
19from pathlib import Path
20from typing import Optional, Tuple, List, Dict
21from collections import deque
22
23# Script directory (for resolving relative paths)
24_SCRIPT_DIR = Path(__file__).resolve().parent
25
26# PT calibration CSV (default when no config paths); project root -> PT_Board/Calibration
27PT_CALIBRATION_CSV = str(
28 _SCRIPT_DIR.parent.parent
29 / "PT_Board"
30 / "Calibration"
31 / "PT Calibration Attempt 2026-02-04_test2.csv"
32)
33
34# Fix Qt "cocoa" platform plugin on macOS when Homebrew Qt plugins path lacks platforms/
35if sys.platform == "darwin":
36 _qt_plugins = os.environ.get("QT_QPA_PLATFORM_PLUGIN_PATH")
37 if not _qt_plugins or not os.path.isdir(_qt_plugins):
38 _candidates = [
39 "/opt/homebrew/share/qt/plugins/platforms",
40 "/opt/homebrew/Cellar/qtbase/6.10.1/share/qt/plugins/platforms",
41 ]
42 if os.path.isdir("/opt/homebrew/Cellar/qtbase"):
43 for _name in sorted(
44 os.listdir("/opt/homebrew/Cellar/qtbase"), reverse=True
45 ):
46 _p = os.path.join(
47 "/opt/homebrew/Cellar/qtbase",
48 _name,
49 "share",
50 "qt",
51 "plugins",
52 "platforms",
53 )
54 if os.path.isdir(_p):
55 _candidates.insert(1, _p)
56 break
57 for _p in _candidates:
58 if os.path.isdir(_p) and any(
59 _f.startswith("libqcocoa") for _f in os.listdir(_p)
60 ):
61 os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = _p
62 break
63
64from PyQt6 import QtCore, QtGui, QtWidgets
65import pyqtgraph as pg
66
67# Performance optimization: Use OpenGL and disable anti-aliasing
68pg.setConfigOptions(useOpenGL=True, antialias=False)
69import numpy as np
70
71# Import robust calibration framework for Phase 1 & Phase 2
72import sys
73import os
74
75# Add scripts/calibration to path for robust_calibration import
76# Path: external/DiabloAvionics/test_guis -> DiabloAvionics -> external -> sensor_system (project root) -> scripts/calibration
77_file_dir = os.path.dirname(
78 os.path.abspath(__file__)
79) # external/DiabloAvionics/test_guis
80_level1 = os.path.dirname(_file_dir) # external/DiabloAvionics
81_level2 = os.path.dirname(_level1) # external
82_project_root = os.path.dirname(_level2) # sensor_system (project root)
83_calib_path = os.path.join(_project_root, "scripts", "calibration")
84if _calib_path not in sys.path:
85 sys.path.insert(0, _calib_path)
86try:
87 from robust_calibration import (
88 RobustCalibrationFramework,
89 EnvironmentalState,
90 CalibrationPoint,
91 )
92
93 ROBUST_CALIBRATION_AVAILABLE = True
94except ImportError as e:
95 ROBUST_CALIBRATION_AVAILABLE = False
96 print(
97 f"⚠️ Robust calibration framework not available: {e}. Using simple polynomial fit."
98 )
99 print(f" Tried path: {_calib_path}")
100 print(f" Path exists: {os.path.exists(_calib_path)}")
101 if os.path.exists(_calib_path):
102 print(f" Files in path: {os.listdir(_calib_path)[:5]}")
103
104# Protocol constants from DAQv2-Comms.h
105DIABLO_COMMS_VERSION = 0
106MAX_PACKET_SIZE = 512
107
108
109# PacketType enum from DiabloEnums.h
111 BOARD_HEARTBEAT = 1
112 SERVER_HEARTBEAT = 2
113 SENSOR_DATA = 3
114 ACTUATOR_COMMAND = 4
115 SENSOR_CONFIG = 5
116 ACTUATOR_CONFIG = 6
117 ABORT = 7
118 ABORT_DONE = 8
119 CLEAR_ABORT = 9
120 PWM_ACTUATOR_COMMAND = 10
121 NO_CONNECTION_ABORT = 11
122
123
124# Default configuration
125DEFAULT_SENSOR_IP = "192.168.2.101" # Sensor board IP address
126DEFAULT_ACTUATOR_IP = "192.168.2.201" # Actuator board IP address
127DEFAULT_DEVICE_PORT = 5005 # Port device listens on for actuator commands
128DEFAULT_RECEIVE_PORT = 5006 # Port device sends sensor data to
129
130# Struct format strings (little-endian, matching C++ packed structs)
131PACKET_HEADER_FORMAT = "<BBI" # 6 bytes total
132PACKET_HEADER_SIZE = 6
133
134ACTUATOR_COMMAND_PACKET_FORMAT = "<B" # 1 byte
135ACTUATOR_COMMAND_PACKET_SIZE = 1
136
137ACTUATOR_COMMAND_FORMAT = "<BB" # 2 bytes
138ACTUATOR_COMMAND_SIZE = 2
139
140PWM_ACTUATOR_COMMAND_PACKET_FORMAT = "<B"
141PWM_ACTUATOR_COMMAND_PACKET_SIZE = 1
142# PWM Command: actuator_id (u8), duration_ms (u32), duty_cycle (float), frequency (float)
143PWM_ACTUATOR_COMMAND_FORMAT = "<BIff"
144PWM_ACTUATOR_COMMAND_SIZE = 13
145
146SENSOR_DATA_PACKET_FORMAT = "<BB" # 2 bytes
147SENSOR_DATA_PACKET_SIZE = 2
148
149SENSOR_DATA_CHUNK_FORMAT = "<I" # 4 bytes
150SENSOR_DATA_CHUNK_SIZE = 4
151
152SENSOR_DATAPOINT_FORMAT = "<BI" # 5 bytes: uint8_t sensor_id + uint32_t data
153SENSOR_DATAPOINT_SIZE = 5
154
155# Actuator current sense: raw 12-bit ESP32 ADC counts, 3.3 V full scale.
156ACT_ADC_MAX = 4095
157ACT_ADC_VREF = 3.3
158
159
160def actuator_counts_to_volts(data_u32: int) -> float:
161 """Actuator current-sense datapoint -> volts.
162
163 Boards send raw 12-bit counts (<= 4095). Firmware from before that sent
164 float volts as bits; any nonzero voltage float encodes far above 4095, so
165 those are still read as floats until every board is reflashed.
166 """
167 if data_u32 <= ACT_ADC_MAX:
168 return data_u32 / ACT_ADC_MAX * ACT_ADC_VREF
169 return struct.unpack("<f", struct.pack("<I", data_u32 & 0xFFFFFFFF))[0]
170
171
172# Plotting constants
173DEFAULT_WINDOW_SECONDS = 10.0
174MAX_POINTS = 200000
175UPDATE_INTERVAL_MS = 100 # Update plots every 100ms
176NUM_CONNECTORS = 10 # Number of connectors being cycled (1-10)
177# NUM_ACTUATORS removed - use get_num_actuators() function instead
178# Default role names (used only when config is missing); actual names come from config
179DEFAULT_ACTUATOR_ROLE_NAMES = [
180 "LOX Main",
181 "Fuel Main",
182 "Fuel Vent",
183 "Fuel Press",
184 "LOX Vent",
185 "LOX Press",
186]
187DEFAULT_SENSOR_ROLE_NAMES = [
188 "LOX Upstream",
189 "LOX Downstream",
190 "Low Press PT",
191 "Fuel Downstream",
192]
193DEFAULT_ACTUATOR_ABBREV_TO_ROLE = {
194 "FV": "Fuel Vent",
195 "OV": "LOX Vent",
196 "FP": "Fuel Press",
197 "OP": "LOX Press",
198 "FM": "Fuel Main",
199 "OM": "LOX Main",
200}
201# When "only show actuators with roles" is true: display order in grid (row1 vents, row2 press, row3 mains; col1 fuel, col2 lox)
202ACTUATOR_DISPLAY_ORDER_WHEN_ROLES = [
203 "Fuel Vent",
204 "LOX Vent",
205 "Fuel Press",
206 "LOX Press",
207 "Fuel Main",
208 "LOX Main",
209 "Fuel Fill",
210]
211ACTUATOR_LABELS_FILE = _SCRIPT_DIR / "actuator_labels.json"
212SENSOR_LABELS_FILE = _SCRIPT_DIR / "sensor_labels.json"
213
214# Colors for sensors (cycle through if more than this)
215SENSOR_COLORS = [
216 (255, 80, 80), # Red
217 (80, 255, 80), # Green
218 (80, 150, 255), # Blue
219 (255, 200, 80), # Orange
220 (200, 80, 255), # Purple
221 (80, 255, 255), # Cyan
222 (255, 150, 150), # Light Red
223 (150, 255, 150), # Light Green
224 (150, 200, 255), # Light Blue
225 (255, 255, 80), # Yellow
226]
227
228
229CONFIG_FILE = _SCRIPT_DIR / "config.json"
230_DEFAULT_STATE_MACHINE_CSV = _SCRIPT_DIR / "state_machine_actuators.csv"
231_DEFAULT_STATE_TRANSITIONS_CSV = _SCRIPT_DIR / "state_transitions.csv"
232
233
235 """Manages loading and saving of application configuration.
236 Actuator/PT names and mapping are stored in config (actuator_role_names, actuator_roles,
237 sensor_roles, actuator_abbrev_to_role). Sensor role names and labels come from sensor_roles keys.
238 All settings read from and save to config.
239 """
240
241 def __init__(self):
242 self.config = {
243 "actuator_role_names": list(DEFAULT_ACTUATOR_ROLE_NAMES),
244 "actuator_roles": {name: 0 for name in DEFAULT_ACTUATOR_ROLE_NAMES},
245 "sensor_roles": {name: 0 for name in DEFAULT_SENSOR_ROLE_NAMES},
246 "actuator_abbrev_to_role": dict(DEFAULT_ACTUATOR_ABBREV_TO_ROLE),
247 "network": {
248 "actuator_ip": DEFAULT_ACTUATOR_IP,
249 "actuator_port": DEFAULT_DEVICE_PORT,
250 "sensor_ip_filter": DEFAULT_SENSOR_IP,
251 "receive_port": DEFAULT_RECEIVE_PORT,
252 },
253 "display": {
254 "adc_bits": 32,
255 "ref_voltage": 2.5,
256 "window_seconds": DEFAULT_WINDOW_SECONDS,
257 "y_axis_min": 0.0,
258 "y_axis_max": 700.0,
259 "y_axis_autoscale": True,
260 "only_show_actuators_with_roles": False,
261 "only_show_pt_with_roles": False,
262 "graph_ma_samples": 1,
263 "display_ma_samples": 1,
264 },
265 "mappings": {"GN2": 0, "ETH": 0, "LOX": 0},
266 "pressure_limits": {
267 "GN2": {"THRESH": 550, "NOP": 900, "MEOP": 950, "POP": 1000},
268 "ETH": {"THRESH": 550, "NOP": 600, "MEOP": 650, "POP": 750},
269 "LOX": {"THRESH": 550, "NOP": 600, "MEOP": 650, "POP": 750},
270 },
271 "num_connectors": 10,
272 "num_actuators": 10,
273 "paths": {
274 "pt_calibration_csv": [], # List of CSV paths (empty = use default)
275 "state_machine_csv": "",
276 "state_transitions_csv": "",
277 },
278 "simulate_chamber_pressure": False,
279 }
280 self.load()
281
282 def load(self):
283 if CONFIG_FILE.exists():
284 try:
285 with open(CONFIG_FILE, "r") as f:
286 loaded = json.load(f)
287 self._update_dict(self.config, loaded)
288 # sensor_roles: use only what's in the file (no merge with defaults)
289 if "sensor_roles" in loaded and isinstance(
290 loaded["sensor_roles"], dict
291 ):
292 self.config["sensor_roles"] = dict(loaded["sensor_roles"])
294 except Exception as e:
295 print(f"Error loading config: {e}")
296 else:
297 self.save()
298
300 """Ensure actuator_role_names/actuator_roles exist; sensor_roles is single source for sensor names and mappings."""
301 roles = self.config.setdefault("actuator_roles", {})
302 names = self.config.get("actuator_role_names")
303 if not names:
304 names = list(roles.keys()) if roles else list(DEFAULT_ACTUATOR_ROLE_NAMES)
305 self.config["actuator_role_names"] = names
306 # Ensure any role in actuator_roles is also in actuator_role_names
307 for name in roles:
308 if name not in names:
309 names.append(name)
310 for name in names:
311 if name not in roles:
312 roles[name] = 0
313 # Sensor roles: merge legacy sensor_role_names into sensor_roles, then remove sensor_role_names
314 roles = self.config.setdefault("sensor_roles", {})
315 legacy_names = self.config.pop("sensor_role_names", None)
316 if legacy_names:
317 # Preserve order: first legacy names, then any existing keys not in legacy
318 ordered = []
319 for name in legacy_names:
320 if name not in roles:
321 roles[name] = 0
322 ordered.append(name)
323 for k in roles:
324 if k not in ordered:
325 ordered.append(k)
326 self.config["sensor_roles"] = {k: roles[k] for k in ordered}
327 # Do not add default sensor role names; GUI shows only what is in config sensor_roles
328 if "actuator_abbrev_to_role" not in self.config:
329 self.config["actuator_abbrev_to_role"] = dict(
330 DEFAULT_ACTUATOR_ABBREV_TO_ROLE
331 )
332 if "num_connectors" not in self.config:
333 self.config["num_connectors"] = 10
334 if "num_actuators" not in self.config:
335 self.config["num_actuators"] = 10
336 if "paths" not in self.config:
337 self.config["paths"] = {
338 "pt_calibration_csv": [],
339 "state_machine_csv": "",
340 "state_transitions_csv": "",
341 }
342 # Migrate old string format to list format for pt_calibration_csv
343 if isinstance(self.config.get("paths", {}).get("pt_calibration_csv"), str):
344 old_val = self.config["paths"]["pt_calibration_csv"]
345 self.config["paths"]["pt_calibration_csv"] = (
346 [old_val] if old_val.strip() else []
347 )
348
349 def save(self):
350 try:
351 with open(CONFIG_FILE, "w") as f:
352 json.dump(self.config, f, indent=4)
353 print(f"Saved config to {CONFIG_FILE}")
354 except Exception as e:
355 print(f"Error saving config: {e}")
356
357 def _update_dict(self, target, source):
358 for k, v in source.items():
359 if isinstance(v, dict) and k in target and isinstance(target[k], dict):
360 self._update_dict(target[k], v)
361 else:
362 target[k] = v
363
365 """Ordered list of actuator role names (from config)."""
366 return (
367 self.config.get("actuator_role_names")
368 or list(self.config.get("actuator_roles", {}).keys())
369 or list(DEFAULT_ACTUATOR_ROLE_NAMES)
370 )
371
373 """Ordered list of PT/sensor role names (keys of sensor_roles in config). Only what's in config, no hardcoded defaults."""
374 return list(self.config.get("sensor_roles", {}).keys())
375
377 """Map state-machine abbreviation -> actuator role name (from config)."""
378 return self.config.get("actuator_abbrev_to_role") or dict(
379 DEFAULT_ACTUATOR_ABBREV_TO_ROLE
380 )
381
383 return int(self.config.get("num_connectors", 10))
384
386 """Get number of actuators, dynamically determined from CSV if available."""
387 # Try to get from CSV first
388 csv_path = self.get_state_machine_csv_path()
389 if csv_path.exists():
390 try:
391 _, num_actuators = load_state_machine_csv(csv_path)
392 if num_actuators > 0:
393 # Update config with discovered count
394 if self.config.get("num_actuators", 10) != num_actuators:
395 self.config["num_actuators"] = num_actuators
396 self.save()
397 return num_actuators
398 except Exception as e:
399 print(f"Warning: Could not determine actuator count from CSV: {e}")
400 # Fallback to config or default
401 return int(self.config.get("num_actuators", 10))
402
404 """Get list of PT calibration CSV paths from config.
405 Returns list of paths (empty list means use default).
406 Relative paths are resolved against the script directory."""
407 p = (self.config.get("paths") or {}).get("pt_calibration_csv", "")
408 if isinstance(p, list):
409 raw = [path.strip() for path in p if path and path.strip()]
410 elif isinstance(p, str):
411 raw = [p.strip()] if p.strip() else []
412 else:
413 raw = []
414 # Resolve relative paths against script directory
415 return [
416 str(_SCRIPT_DIR / path) if not os.path.isabs(path) else path for path in raw
417 ]
418
420 p = (self.config.get("paths") or {}).get("state_machine_csv", "").strip()
421 return Path(p) if p else _DEFAULT_STATE_MACHINE_CSV
422
424 p = (self.config.get("paths") or {}).get("state_transitions_csv", "").strip()
425 return Path(p) if p else _DEFAULT_STATE_TRANSITIONS_CSV
426
427 # Helpers: config stores role → id; these derive label for a slot/connector for display
428 def get_actuator_label(self, idx):
429 roles = self.config.get("actuator_roles", {})
430 for role, aid in roles.items():
431 # Handle both old format (int) and new format ([NC/NO, id])
432 actuator_id = aid[1] if isinstance(aid, list) and len(aid) == 2 else aid
433 if actuator_id == idx:
434 return role
435 return ""
436
437 def set_actuator_role(self, role_name, actuator_id):
438 if role_name not in self.get_actuator_role_names():
439 return
440 role_names = self.get_actuator_role_names()
441 roles = self.config.setdefault(
442 "actuator_roles", {name: 0 for name in role_names}
443 )
444 # Clear any existing assignment of this actuator_id to other roles
445 for r, aid in list(roles.items()):
446 # Handle both old format (int) and new format ([NC/NO, id])
447 existing_id = aid[1] if isinstance(aid, list) and len(aid) == 2 else aid
448 if existing_id == actuator_id and r != role_name:
449 roles[r] = 0
450 # Preserve NC/NO type if it exists, otherwise default to NC
451 existing_value = roles.get(role_name, 0)
452 if isinstance(existing_value, list) and len(existing_value) == 2:
453 # Preserve the NC/NO type, update the ID
454 roles[role_name] = [
455 existing_value[0],
456 int(actuator_id) if actuator_id else 0,
457 ]
458 else:
459 # New assignment or old format - default to NC
460 if actuator_id:
461 roles[role_name] = ["NC", int(actuator_id)]
462 else:
463 roles[role_name] = 0
464 self.save()
465
466 def get_sensor_label(self, idx):
467 roles = self.config.get("sensor_roles", {})
468 for role, cid in roles.items():
469 if cid == idx:
470 return role
471 return ""
472
473 def set_sensor_role(self, role_name, connector_id):
474 roles = self.config.setdefault("sensor_roles", {})
475 if role_name not in roles:
476 roles[role_name] = 0 # allow new role from settings (e.g. after migration)
477 for r, cid in list(roles.items()):
478 if cid == connector_id and r != role_name:
479 roles[r] = 0
480 roles[role_name] = int(connector_id) if connector_id else 0
481 self.save()
482
483 def get_actuator_role(self, role_name):
484 """Get actuator ID for a role. Returns 0 if not found or unassigned."""
485 aid = self.config.get("actuator_roles", {}).get(role_name, 0)
486 # Handle both old format (int) and new format ([NC/NO, id])
487 if isinstance(aid, list) and len(aid) == 2:
488 return aid[1] # Return the ID (second element)
489 return aid
490
491 def get_actuator_type(self, role_name):
492 """Get NC/NO type for an actuator role. Returns 'NC' if not found or unassigned."""
493 aid = self.config.get("actuator_roles", {}).get(role_name, 0)
494 # Handle both old format (int) and new format ([NC/NO, id])
495 if isinstance(aid, list) and len(aid) == 2:
496 return aid[0] # Return NC or NO (first element)
497 return "NC" # Default to NC for backward compatibility
498
499 def get_actuator_type_by_id(self, actuator_id):
500 """Get NC/NO type for an actuator by its ID. Returns 'NC' if not found."""
501 roles = self.config.get("actuator_roles", {})
502 for role, aid in roles.items():
503 # Handle both old format (int) and new format ([NC/NO, id])
504 if isinstance(aid, list) and len(aid) == 2:
505 if aid[1] == actuator_id:
506 return aid[0] # Return NC or NO
507 elif aid == actuator_id:
508 return "NC" # Default to NC for old format
509 return "NC" # Default to NC if not found
510
511 def get_sensor_role(self, role_name):
512 return self.config.get("sensor_roles", {}).get(role_name, 0)
513
514
516# Apply config-driven counts so rest of code can use NUM_CONNECTORS / NUM_ACTUATORS
517NUM_CONNECTORS = CONFIG.get_num_connectors()
518
519
520# NUM_ACTUATORS is now dynamic - use get_num_actuators() function instead of constant
522 """Get number of actuators dynamically from CSV/config."""
523 return CONFIG.get_num_actuators()
524
525
526pg.setConfigOptions(antialias=False)
527
528
529# ---------------------- State Machine CSV Loading ----------------------
530def load_state_machine_csv(csv_path: Path) -> tuple[Dict[str, Dict[str, str]], int]:
531 """
532 Load state machine CSV file.
533 Returns tuple: (state_machine_dict, num_actuators)
534 state_machine_dict: state_name -> {actuator_abbrev -> OPEN/CLOSE}
535 num_actuators: number of actuators found in CSV
536 CSV format: first row is header with state names (first column empty), first column of data rows is actuator abbreviations
537 """
538 state_machine = {}
539 num_actuators = 0
540 if not csv_path.exists():
541 print(f"Warning: State machine CSV not found at {csv_path}")
542 return state_machine, num_actuators
543
544 try:
545 with open(csv_path, "r", newline="", encoding="utf-8") as f:
546 reader = csv.reader(f)
547 rows = list(reader)
548
549 if len(rows) < 2:
550 print(f"Warning: State machine CSV has insufficient rows")
551 return state_machine, num_actuators
552
553 # First row is header: empty first cell, then state names
554 header = rows[0]
555 states = [
556 col.strip() for col in header[1:] if col.strip()
557 ] # Skip first empty column
558
559 # Remaining rows: first column is actuator abbrev, rest are state values
560 for row in rows[1:]:
561 if len(row) < 2:
562 continue
563 actuator_abbrev = row[0].strip()
564 if not actuator_abbrev:
565 continue
566
567 num_actuators += 1
568
569 # For each state, store the OPEN/CLOSE value
570 for i, state in enumerate(states):
571 if i + 1 < len(row):
572 value = row[i + 1].strip().upper()
573 # Skip Debug state column if present
574 if state.strip().lower() == "debug":
575 continue
576 if state not in state_machine:
577 state_machine[state] = {}
578 # Skip "NO CHANGE" values
579 if value in ["OPEN", "CLOSE", "CLOSED"]:
580 state_machine[state][actuator_abbrev] = (
581 "OPEN" if value == "OPEN" else "CLOSE"
582 )
583 # For "NO CHANGE", we don't store anything
584 except Exception as e:
585 print(f"Error loading state machine CSV: {e}")
586 import traceback
587
588 traceback.print_exc()
589
590 return state_machine, num_actuators
591
592
593def load_state_transitions_csv(csv_path: Path) -> Dict[str, Dict[str, bool]]:
594 """
595 Load state transitions CSV file.
596 Returns dict: current_state -> {next_state -> allowed (True/False)}
597 CSV format: first row is header with next states, first column is current states
598 """
599 transitions = {}
600 if not csv_path.exists():
601 print(f"Warning: State transitions CSV not found at {csv_path}")
602 return transitions
603
604 try:
605 with open(csv_path, "r", newline="", encoding="utf-8") as f:
606 reader = csv.reader(f)
607 rows = list(reader)
608
609 if len(rows) < 2:
610 print(f"Warning: State transitions CSV has insufficient rows")
611 return transitions
612
613 # First row is header: empty first cell, then next state names
614 header = rows[0]
615 next_states = [
616 col.strip() for col in header[1:] if col.strip()
617 ] # Skip first empty column
618
619 # Remaining rows: first column is current state, rest are transition values (1/0)
620 for row in rows[1:]:
621 if len(row) < 2:
622 continue
623 current_state = row[0].strip()
624 if not current_state:
625 continue
626
627 transitions[current_state] = {}
628 # For each next state, store whether transition is allowed
629 for i, next_state in enumerate(next_states):
630 if i + 1 < len(row):
631 value = row[i + 1].strip()
632 # 1 = allowed, 0 = not allowed
633 transitions[current_state][next_state] = value == "1"
634 except Exception as e:
635 print(f"Error loading state transitions CSV: {e}")
636 import traceback
637
638 traceback.print_exc()
639
640 return transitions
641
642
643def get_actuator_id_from_role(role_name: str) -> int:
644 """Get actuator ID from role name. Returns 0 if not found."""
645 return CONFIG.get_actuator_role(role_name)
646
647
649 actuator_widget, state_name: str, state_machine: Dict[str, Dict[str, str]]
650):
651 """
652 Apply actuator states from CSV for the given state name.
653
654 Mapping:
655 - FV = Fuel Vent
656 - OV = LOX Vent
657 - FP = Fuel Press
658 - OP = LOX Press
659 - FM = Fuel Main
660 - OM = LOX Main
661 """
662 if state_name not in state_machine:
663 print(f"Warning: State '{state_name}' not found in CSV")
664 return
665
666 # Map CSV abbreviations to role names
667 abbrev_to_role = CONFIG.get_actuator_abbrev_to_role()
668 state_config = state_machine[state_name]
669
670 # Build desired GUI state for all actuators (1..N).
671 # Default anything not specified by the CSV mapping to CLOSED (0) so the right-side
672 # actuator display always reflects the full state when a mode is pressed.
673 desired_by_actuator_id: Dict[int, int] = {
674 aid: 0 for aid in range(1, get_num_actuators() + 1)
675 }
676
677 # Fill desired states from CSV for the mapped actuators
678 for abbrev, action in state_config.items():
679 if abbrev not in abbrev_to_role:
680 print(f"Warning: Unknown actuator abbreviation '{abbrev}' in CSV")
681 continue
682
683 role_name = abbrev_to_role[abbrev]
684 actuator_id = get_actuator_id_from_role(role_name)
685
686 if actuator_id == 0:
687 print(
688 f"Warning: Actuator role '{role_name}' ({abbrev}) not assigned to any actuator"
689 )
690 continue
691
692 # Convert OPEN/CLOSE to GUI state (1 = OPEN, 0 = CLOSED)
693 gui_state = 1 if action == "OPEN" else 0
694 # Only write if actuator_id is in our expected range
695 if 1 <= actuator_id <= get_num_actuators():
696 desired_by_actuator_id[actuator_id] = gui_state
697
698 # Apply to widget: update UI + send commands for any actuators that change.
699 # Use force=True to bypass manual control lock
700 for actuator_id in range(1, get_num_actuators() + 1):
701 desired = desired_by_actuator_id.get(actuator_id, 0)
702 array_idx = actuator_id - 1
703 current = (
704 actuator_widget.actuator_states[array_idx]
705 if 0 <= array_idx < len(actuator_widget.actuator_states)
706 else None
707 )
708 if current is None or current != desired:
709 actuator_widget.set_actuator_state(actuator_id, desired, force=True)
710
711
712# ---------------------- PT pressure from calibration ----------------------
714 adc_code: float, PT_A: float, PT_B: float, PT_C: float, PT_D: float
715) -> float:
716 """Compute pressure (psi) from ADC code using cubic polynomial."""
717 return (PT_A * (adc_code**3)) + (PT_B * (adc_code**2)) + (PT_C * adc_code) + PT_D
718
719
721 csv_paths,
722) -> Tuple[Dict[int, Tuple[float, float, float, float]], Optional[str]]:
723 """
724 Load PT calibration coefficients from one or more CSV files.
725 Args:
726 csv_paths: Single CSV path (str) or list of CSV paths. Empty string/list uses default.
727 Returns:
728 Tuple of (result_dict, error_message):
729 - result_dict: connector_id -> (PT_A, PT_B, PT_C, PT_D) for each PT present in CSVs
730 - error_message: None if successful, error string if duplicates or other errors found
731 The number in the CSV (PT1, PT2, ...) is the board connector number; this same ID is used
732 everywhere: config.json sensor_roles, mappings, and packet sensor_id.
733 CSV columns per PT: ADC Code, Pressure, Coefficient 0 (A), 1 (B), 2 (C), 3 (D).
734 Uses the last data row as the calibration coefficients.
735 Works with any number of PTs; PT numbers are discovered from column names "PT{N} Coefficient 0".
736 If multiple CSVs contain the same PT number, returns error message listing duplicates.
737 """
738 result = {}
739 duplicates = {} # pt_num -> list of CSV files that contain it
740
741 # Normalize input: convert single string to list, handle empty/default
742 if isinstance(csv_paths, str):
743 csv_paths = [csv_paths] if csv_paths.strip() else []
744 elif not isinstance(csv_paths, list):
745 csv_paths = []
746
747 # If empty list, use default
748 if not csv_paths:
749 csv_paths = [PT_CALIBRATION_CSV]
750
751 # Track which CSV file contains which PT numbers (for duplicate reporting)
752 csv_pt_map = {} # csv_path -> set of pt_nums
753
754 # Load each file and merge results
755 for file_path in csv_paths:
756 if not file_path or not file_path.strip():
757 continue
758 file_path = file_path.strip()
759
760 # Resolve relative paths relative to this script's directory
761 if not os.path.isabs(file_path):
762 file_path = os.path.join(
763 os.path.dirname(os.path.abspath(__file__)), file_path
764 )
765
766 if not os.path.isfile(file_path):
767 continue
768
769 try:
770 if file_path.lower().endswith(".json"):
771 with open(file_path, "r", encoding="utf-8") as f:
772 data = json.load(f)
773 polys = data.get("calibration_polynomials", {})
774 for pt_id_str, coeffs in polys.items():
775 pt_num = int(pt_id_str)
776 if pt_num in result:
777 # Duplicate handling logic
778 if pt_num not in duplicates:
779 duplicates[pt_num] = []
780 for prev_path, prev_pt_nums in csv_pt_map.items():
781 if pt_num in prev_pt_nums:
782 duplicates[pt_num].append(
783 os.path.basename(prev_path)
784 )
785 duplicates[pt_num].append(os.path.basename(file_path))
786 continue
787
788 if len(coeffs) == 4:
789 result[pt_num] = tuple(coeffs)
790 if file_path not in csv_pt_map:
791 csv_pt_map[file_path] = set()
792 csv_pt_map[file_path].add(pt_num)
793 else:
794 with open(file_path, newline="", encoding="utf-8") as f:
795 reader = csv.DictReader(f)
796 rows = list(reader)
797 fieldnames = reader.fieldnames or []
798 if not rows:
799 continue
800
801 # Discover PT numbers from column names (e.g. "PT1 Coefficient 0" -> pt_num 1)
802 pt_nums = set()
803 for col in fieldnames:
804 m = re.match(r"PT(\d+)\s+Coefficient\s+0", col, re.IGNORECASE)
805 if m:
806 pt_nums.add(int(m.group(1)))
807
808 csv_pt_map[file_path] = pt_nums
809
810 # Use last row for coefficients (final calibration state)
811 last = rows[-1]
812 for pt_num in sorted(pt_nums):
813 # Check for duplicates
814 if pt_num in result:
815 if pt_num not in duplicates:
816 duplicates[pt_num] = []
817 # Find which file(s) already had this PT
818 for prev_path, prev_pt_nums in csv_pt_map.items():
819 if prev_path == file_path:
820 continue # Skip current file
821 if pt_num in prev_pt_nums:
822 duplicates[pt_num].append(
823 os.path.basename(prev_path)
824 )
825 duplicates[pt_num].append(os.path.basename(file_path))
826 continue # Skip this duplicate
827
828 a = float(last.get(f"PT{pt_num} Coefficient 0", 0))
829 b = float(last.get(f"PT{pt_num} Coefficient 1", 0))
830 c = float(last.get(f"PT{pt_num} Coefficient 2", 0))
831 d = float(last.get(f"PT{pt_num} Coefficient 3", 0))
832 result[pt_num] = (a, b, c, d)
833 except Exception as e:
834 return result, f"Error loading {os.path.basename(file_path)}: {str(e)}"
835
836 # Build error message if duplicates found
837 if duplicates:
838 error_parts = ["Duplicate PT numbers found:"]
839 for pt_num, files in sorted(duplicates.items()):
840 error_parts.append(f" PT{pt_num} appears in: {', '.join(files)}")
841 return result, "\n".join(error_parts)
842
843 return result, None
844
845
846# Demo mode: fake UDP packets from separate module
847from demo_sensor_sender import build_demo_packet
848
849
850# ---------------------- Protocol Functions ----------------------
851def parse_packet_header(data: bytes) -> Optional[Tuple[int, int, int]]:
852 """Parse the packet header. Returns: (packet_type, version, timestamp) or None"""
853 if len(data) < PACKET_HEADER_SIZE:
854 return None
855 try:
856 packet_type, version, timestamp = struct.unpack(
857 PACKET_HEADER_FORMAT, data[:PACKET_HEADER_SIZE]
858 )
859 return (packet_type, version, timestamp)
860 except struct.error:
861 return None
862
863
864def parse_sensor_data_packet(data: bytes) -> Optional[Tuple[dict, List[dict]]]:
865 """Parse a sensor data packet. Returns: (header_dict, chunks_list) or None"""
866 if len(data) < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE:
867 return None
868
869 header = parse_packet_header(data)
870 if header is None or header[0] != PacketType.SENSOR_DATA:
871 return None
872
873 packet_type, version, timestamp = header
874
875 offset = PACKET_HEADER_SIZE
876 try:
877 num_chunks, num_sensors = struct.unpack(
878 SENSOR_DATA_PACKET_FORMAT, data[offset : offset + SENSOR_DATA_PACKET_SIZE]
879 )
880 except struct.error:
881 return None
882
883 offset += SENSOR_DATA_PACKET_SIZE
884
885 per_chunk_size = SENSOR_DATA_CHUNK_SIZE + (num_sensors * SENSOR_DATAPOINT_SIZE)
886 expected_size = (
887 PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE + (num_chunks * per_chunk_size)
888 )
889
890 if len(data) < expected_size:
891 return None
892
893 chunks = []
894 for chunk_idx in range(num_chunks):
895 try:
896 (chunk_timestamp,) = struct.unpack(
897 SENSOR_DATA_CHUNK_FORMAT, data[offset : offset + SENSOR_DATA_CHUNK_SIZE]
898 )
899 except struct.error:
900 return None
901
902 offset += SENSOR_DATA_CHUNK_SIZE
903
904 datapoints = []
905 for sensor_idx in range(num_sensors):
906 try:
907 sensor_id, sensor_data = struct.unpack(
908 SENSOR_DATAPOINT_FORMAT,
909 data[offset : offset + SENSOR_DATAPOINT_SIZE],
910 )
911 datapoints.append({"sensor_id": sensor_id, "data": sensor_data})
912 offset += SENSOR_DATAPOINT_SIZE
913 except struct.error:
914 return None
915
916 chunks.append({"timestamp": chunk_timestamp, "datapoints": datapoints})
917
918 header_dict = {
919 "packet_type": packet_type,
920 "version": version,
921 "timestamp": timestamp,
922 }
923
924 return (header_dict, chunks)
925
926
927def create_actuator_command_packet(commands: List[Tuple[int, int]]) -> bytes:
928 """
929 Create an actuator command packet.
930 commands: List of (actuator_id, actuator_state) tuples
931 actuator_id: 1-10 (1-indexed)
932 actuator_state: 0 = OFF, non-zero = ON
933 """
934 if len(commands) == 0 or len(commands) > 255:
935 return b""
936
937 # Calculate packet size
938 header_size = PACKET_HEADER_SIZE
939 body_size = ACTUATOR_COMMAND_PACKET_SIZE
940 commands_size = len(commands) * ACTUATOR_COMMAND_SIZE
941 total_size = header_size + body_size + commands_size
942
943 if total_size > MAX_PACKET_SIZE:
944 return b""
945
946 # Create packet buffer
947 packet = bytearray(total_size)
948 offset = 0
949
950 # Packet header
951 packet_type = PacketType.ACTUATOR_COMMAND
952 version = DIABLO_COMMS_VERSION
953 timestamp = int(time.time() * 1000) & 0xFFFFFFFF # 32-bit timestamp in milliseconds
954
955 struct.pack_into(
956 PACKET_HEADER_FORMAT, packet, offset, packet_type, version, timestamp
957 )
958 offset += PACKET_HEADER_SIZE
959
960 # Actuator command packet body
961 num_commands = len(commands)
962 struct.pack_into(ACTUATOR_COMMAND_PACKET_FORMAT, packet, offset, num_commands)
963 offset += ACTUATOR_COMMAND_PACKET_SIZE
964
965 # Actuator commands
966 for actuator_id, actuator_state in commands:
967 struct.pack_into(
968 ACTUATOR_COMMAND_FORMAT, packet, offset, actuator_id, actuator_state
969 )
970 offset += ACTUATOR_COMMAND_SIZE
971
972 return bytes(packet)
973
974
976 commands: List[Tuple[int, int, float, float]],
977) -> bytes:
978 """
979 Creates a PWM Actuator Command packet.
980 Packet layout: PacketHeader + PWMActuatorCommandPacket + N PWMActuatorCommand.
981
982 commands: List of tuples (actuator_id, duration_ms, duty_cycle, frequency)
983 Note: duration is in ms (uint32), duty_cycle is float (0.0-1.0), frequency is float (Hz)
984 """
985 if len(commands) == 0 or len(commands) > 255:
986 return b""
987
988 header_size = PACKET_HEADER_SIZE
989 body_size = PWM_ACTUATOR_COMMAND_PACKET_SIZE
990 commands_size = len(commands) * PWM_ACTUATOR_COMMAND_SIZE
991 total_size = header_size + body_size + commands_size
992
993 if total_size > MAX_PACKET_SIZE:
994 return b""
995
996 packet = bytearray(total_size)
997 offset = 0
998
999 # Header
1000 struct.pack_into(
1001 PACKET_HEADER_FORMAT,
1002 packet,
1003 offset,
1004 PacketType.PWM_ACTUATOR_COMMAND,
1005 DIABLO_COMMS_VERSION,
1006 int(time.time() * 1000) & 0xFFFFFFFF,
1007 )
1008 offset += PACKET_HEADER_SIZE
1009
1010 # Body (num_commands)
1011 struct.pack_into(PWM_ACTUATOR_COMMAND_PACKET_FORMAT, packet, offset, len(commands))
1012 offset += PWM_ACTUATOR_COMMAND_PACKET_SIZE
1013
1014 # Commands
1015 for actuator_id, duration_ms, duty_cycle, frequency in commands:
1016 struct.pack_into(
1017 PWM_ACTUATOR_COMMAND_FORMAT,
1018 packet,
1019 offset,
1020 actuator_id,
1021 duration_ms,
1022 duty_cycle,
1023 frequency,
1024 )
1025 offset += PWM_ACTUATOR_COMMAND_SIZE
1026
1027 return bytes(packet)
1028
1029
1030# ---------------------- UDP Receiver Thread ----------------------
1031class UDPReceiver(QtCore.QThread):
1032 """Thread that receives UDP packets and emits decoded sensor data"""
1033
1034 sensor_data_received = QtCore.pyqtSignal(
1035 dict, list, str
1036 ) # header, chunks, source_ip
1037 status_update = QtCore.pyqtSignal(str)
1038 packet_received = QtCore.pyqtSignal(int, int) # packet_size, packet_type
1039
1040 def __init__(self, port: int = DEFAULT_RECEIVE_PORT, bind_address: str = "0.0.0.0"):
1041 super().__init__()
1042 self.port = port
1043 self.bind_address = bind_address
1044 self._stop = False
1045 self.sock = None
1048 self.start_time = None
1049
1050 def stop(self):
1051 """Stop the receiver thread"""
1052 self._stop = True
1053 if self.sock:
1054 try:
1055 self.sock.close()
1056 except:
1057 pass
1058
1059 def get_stats(self) -> Dict:
1060 """Get current statistics"""
1061 if self.start_time is None:
1062 return {
1063 "packets": 0,
1064 "bytes": 0,
1065 "packets_per_sec": 0.0,
1066 "bytes_per_sec": 0.0,
1067 }
1068
1069 elapsed = time.time() - self.start_time
1070 if elapsed > 0:
1071 pps = self.total_packets / elapsed
1072 bps = self.total_bytes / elapsed
1073 else:
1074 pps = 0.0
1075 bps = 0.0
1076
1077 return {
1078 "packets": self.total_packets,
1079 "bytes": self.total_bytes,
1080 "packets_per_sec": pps,
1081 "bytes_per_sec": bps,
1082 "elapsed": elapsed,
1083 }
1084
1085 def run(self):
1086 """Main receiver loop"""
1087 self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
1088 self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1089 self.sock.settimeout(0.1) # Non-blocking with timeout
1090
1091 try:
1092 self.sock.bind((self.bind_address, self.port))
1093 self.status_update.emit(f"Listening on {self.bind_address}:{self.port}")
1094 self.start_time = time.time()
1095 except OSError as e:
1096 self.status_update.emit(f"Error binding: {e}")
1097 return
1098
1099 while not self._stop:
1100 try:
1101 data, addr = self.sock.recvfrom(MAX_PACKET_SIZE)
1102 self.total_packets += 1
1103 self.total_bytes += len(data)
1104
1105 header = parse_packet_header(data)
1106 if header is None:
1107 continue
1108
1109 packet_type, version, timestamp = header
1110 self.packet_received.emit(len(data), packet_type)
1111
1112 if packet_type == PacketType.SENSOR_DATA:
1113 result = parse_sensor_data_packet(data)
1114 if result:
1115 header_dict, chunks = result
1116 source_ip = addr[0] # Extract IP address from (ip, port) tuple
1117 self.sensor_data_received.emit(header_dict, chunks, source_ip)
1118
1119 except socket.timeout:
1120 continue
1121 except Exception as e:
1122 if not self._stop:
1123 self.status_update.emit(f"Error: {e}")
1124 continue
1125
1126 if self.sock:
1127 try:
1128 self.sock.close()
1129 except:
1130 pass
1131 self.status_update.emit("Stopped")
1132
1133
1134# ---------------------- Debug dialog (C1, MA, raw values, network) ----------------------
1135class SensorDebugDialog(QtWidgets.QDialog):
1136 """Popup showing network stats, C1, MA, and psi for each connector."""
1137
1138 def __init__(self, parent: "SensorPlotWidget"):
1139 super().__init__(parent)
1140 self.plot_widget = parent
1141 self.setWindowTitle("Sensor debug")
1142 self.setMinimumWidth(320)
1143 layout = QtWidgets.QVBoxLayout(self)
1144 # Network statistics at top
1145 net_group = QtWidgets.QGroupBox("Network")
1146 net_layout = QtWidgets.QVBoxLayout()
1147 self.packets_lbl = QtWidgets.QLabel("Packets: 0")
1148 self.pps_lbl = QtWidgets.QLabel("Packets/sec: 0.0")
1149 self.bytes_lbl = QtWidgets.QLabel("Bytes: 0 B")
1150 self.bps_lbl = QtWidgets.QLabel("Bytes/sec: 0.0 B/s")
1151 for w in (self.packets_lbl, self.pps_lbl, self.bytes_lbl, self.bps_lbl):
1152 net_layout.addWidget(w)
1153 net_group.setLayout(net_layout)
1154 layout.addWidget(net_group)
1155 # Sensor table
1156 self.table = QtWidgets.QTableWidget()
1157 self.table.setColumnCount(4)
1158 self.table.setHorizontalHeaderLabels(["Connector", "C (V)", "MA (V)", "psi"])
1159 self.table.horizontalHeader().setStretchLastSection(True)
1160 layout.addWidget(self.table)
1161 close_btn = QtWidgets.QPushButton("Close")
1162 close_btn.clicked.connect(self.accept)
1163 layout.addWidget(close_btn)
1164 self.refresh_timer = QtCore.QTimer(self)
1165 self.refresh_timer.timeout.connect(self.refresh)
1166 self.refresh_timer.start(300)
1167
1168 def showEvent(self, event):
1169 super().showEvent(event)
1170 self.refresh()
1171
1172 def refresh(self):
1173 ns = self.plot_widget.network_stats
1174 self.packets_lbl.setText(f"Packets: {ns['packets']}")
1175 self.pps_lbl.setText(f"Packets/sec: {ns['pps']}")
1176 self.bytes_lbl.setText(f"Bytes: {ns['bytes']}")
1177 self.bps_lbl.setText(f"Bytes/sec: {ns['bps']}")
1178
1179 self.table.setRowCount(NUM_CONNECTORS)
1180 for i in range(1, NUM_CONNECTORS + 1):
1181 row = i - 1
1182 if self.table.item(row, 0) is None:
1183 self.table.setItem(row, 0, QtWidgets.QTableWidgetItem(f"PT {i}"))
1184 self.table.item(row, 0).setText(f"PT {i}")
1185 dv = self.plot_widget.debug_values.get(i, {})
1186 c = dv.get("current")
1187 ma = dv.get("moving_avg")
1188 psi = dv.get("psi")
1189 self._set_cell(row, 1, f"{c:.4f}" if c is not None else "---")
1190 self._set_cell(row, 2, f"{ma:.4f}" if ma is not None else "---")
1191 self._set_cell(row, 3, f"{psi:.2f}" if psi is not None else "---")
1192
1193 def _set_cell(self, row: int, col: int, text: str):
1194 item = self.table.item(row, col)
1195 if item is None:
1196 item = QtWidgets.QTableWidgetItem(text)
1197 self.table.setItem(row, col, item)
1198 else:
1199 item.setText(text)
1200
1201 def closeEvent(self, event):
1202 self.refresh_timer.stop()
1203 super().closeEvent(event)
1204
1205
1206# ---------------------- Sensor Plot Widget (reusable panel) ----------------------
1207class SensorPlotWidget(QtWidgets.QWidget):
1208 """Reusable sensor/PT plotting panel. Used in SensorPlotWindow and CombinedMainWindow."""
1209
1210 def __init__(self, receiver, bind_address: str = "0.0.0.0", parent=None):
1211 super().__init__(parent)
1212 self.receiver = receiver
1213 self.bind_address = bind_address
1214 self.window_seconds = DEFAULT_WINDOW_SECONDS
1215 self.display_moving_avg_samples = 10 # Moving average for displayed values
1217 1 # Moving average for graphed lines (1 = no smoothing)
1218 )
1219
1220 # ADC conversion settings
1221 self.adc_bits = 32 # ADC bit count (default: 32-bit)
1222 self.reference_voltage = 2.5 # Reference voltage in Volts (default: 2.5V)
1223
1224 # IP filter for sensor data (default to sensor board IP)
1225 self.filter_source_ip = DEFAULT_SENSOR_IP # Only accept data from this IP
1226
1227 # Y-axis settings
1228 self.y_axis_auto_scale = True # Auto-scale Y-axis by default
1229 self.y_axis_min = 0.0 # Minimum Y-axis value (psi)
1230 self.y_axis_max = 700.0 # Maximum Y-axis value (psi)
1231
1232 # Data storage: sensor_id -> deque of (timestamp_ms, value)
1233 self.sensor_data: Dict[int, deque] = {} # Voltage data for statistics display
1234 self.sensor_adc_codes: Dict[int, deque] = (
1235 {}
1236 ) # Store ADC codes for pressure calculation
1237 # Plot buffers: separate time and psi arrays for fast numpy access (no tuple unpacking)
1238 self.sensor_psi_plot_t: Dict[int, deque] = {} # time values for plotting
1239 self.sensor_psi_plot_v: Dict[int, deque] = {} # psi values for plotting
1240 self.sensor_psi_history: Dict[int, list] = (
1241 {}
1242 ) # PSI data for CSV saving (full history)
1243 self.sensor_plots: Dict[int, pg.PlotDataItem] = {}
1244 self.plot_enabled: Dict[int, bool] = {
1245 i: True for i in range(1, NUM_CONNECTORS + 1)
1246 }
1247
1248 # Statistics
1250
1252 CONFIG.get_pt_calibration_csv_paths()
1253 )
1254 if self.pt_calibration_error:
1255 print(f"PT Calibration Error: {self.pt_calibration_error}")
1256 # Per-sensor PSI offsets for zeroing (sensor_id -> psi offset to subtract)
1257 self.pt_offsets: Dict[int, float] = {}
1258
1259 # Reference to main window for event tracking (set by main window)
1261
1262 # Sensor labels (connector_id -> label string); all from config sensor_roles (role name = label)
1264 i: CONFIG.get_sensor_label(i) for i in range(1, NUM_CONNECTORS + 1)
1265 }
1266 # Debug values for popup: connector_id -> {'current': float, 'moving_avg': float, 'psi': float|None}
1267 self.debug_values: Dict[int, Dict] = {}
1268 # Network stats for debug popup
1270 "packets": "0",
1271 "pps": "0.0",
1272 "bytes": "0 B",
1273 "bps": "0.0 B/s",
1274 }
1275
1276 # SPS Calculation
1280
1281 # Load display settings from Config
1282 disp = CONFIG.config["display"]
1283 self.window_seconds = disp["window_seconds"]
1284 # Dynamic buffer size: window_seconds * 1000 samples/sec (approx) + safety margin
1285 self.plot_buffer_size = int(self.window_seconds * 1200) + 1000
1286 if self.plot_buffer_size < 1000:
1287 self.plot_buffer_size = 1000
1288
1289 self.adc_bits = disp["adc_bits"]
1290 self.reference_voltage = disp["ref_voltage"]
1291 self.y_axis_min = disp["y_axis_min"]
1292 self.y_axis_max = disp["y_axis_max"]
1293 self.y_axis_auto_scale = disp["y_axis_autoscale"]
1294 self.graph_moving_avg_samples = disp.get(
1295 "graph_ma_samples", self.graph_moving_avg_samples
1296 )
1297 self.display_moving_avg_samples = disp.get(
1298 "display_ma_samples", self.display_moving_avg_samples
1299 )
1300 self.only_show_pt_with_roles = disp.get("only_show_pt_with_roles", False)
1301 self.filter_source_ip = CONFIG.config["network"]["sensor_ip_filter"]
1302
1303 # Demo mode: synthetic PT data via UDP to localhost (same decode path as real hardware)
1304 self.demo_mode = False
1305 self.demo_start_time: Optional[float] = None
1306 self.demo_send_socket: Optional[socket.socket] = None
1307 self.demo_send_timer: Optional[QtCore.QTimer] = None
1308
1309 self.init_ui()
1310
1311 # Connect to receiver signals
1312 self.receiver.sensor_data_received.connect(self.on_sensor_data)
1313 self.receiver.status_update.connect(self.on_status_update)
1314 self.receiver.packet_received.connect(self.on_packet_received)
1315
1316 # Timer for updating plots and statistics
1317 self.update_timer = QtCore.QTimer(self)
1318 self.update_timer.timeout.connect(self.update_plots)
1319 self.update_timer.start(UPDATE_INTERVAL_MS)
1320
1321 # Timer for updating statistics display
1322 self.stats_timer = QtCore.QTimer(self)
1323 self.stats_timer.timeout.connect(self.update_statistics)
1324 self.stats_timer.start(500) # Update stats every 500ms
1325
1326 def format_label_two_rows(self, text: str) -> str:
1327 """Format label text to always be two words, one per row.
1328 Splits text into words and ensures exactly two words on separate lines."""
1329 if not text:
1330 return ""
1331 words = text.strip().split()
1332 if len(words) == 0:
1333 return ""
1334 elif len(words) == 1:
1335 # If only one word, try to split it (e.g., "PT1" -> "PT" and "1")
1336 word = words[0]
1337 # Try to split on number boundary (e.g., "PT1" -> ["PT", "1"])
1338 parts = re.split(r"(\d+)", word)
1339 parts = [p for p in parts if p] # Remove empty strings
1340 if len(parts) >= 2:
1341 return f"{parts[0]}\n{''.join(parts[1:])}"
1342 else:
1343 # Can't split, just put it on first row with empty second row
1344 return f"{word}\n "
1345 elif len(words) >= 2:
1346 # Take first two words, one per row
1347 return f"{words[0]}\n{words[1]}"
1348 return text
1349
1350 def on_sensor_label_changed(self, connector_id: int, text: str):
1351 """Update local sensor label and plot legend (config is written via set_sensor_role in settings)."""
1352 self.sensor_labels[connector_id] = text
1353
1354 # Update the under-graph name label
1355 if connector_id in self.under_graph_name_labels:
1356 display_name = text if text else f"PT {connector_id}"
1357 formatted_name = self.format_label_two_rows(display_name)
1358 self.under_graph_name_labels[connector_id].setText(formatted_name)
1359
1360 # Update the plot legend if this sensor has a plot
1361 if connector_id in self.sensor_plots:
1362 self.update_plot_legend(connector_id)
1363
1364 # If "only show PTs with roles" is enabled, update visibility immediately
1366 self.update_plots()
1367 self.update_statistics()
1368
1369 def update_plot_legend(self, connector_id: int):
1370 """Update the legend for a specific sensor plot"""
1371 if connector_id not in self.sensor_plots:
1372 return
1373
1374 plot = self.sensor_plots[connector_id]
1375 label = self.sensor_labels.get(connector_id, "")
1376 if label:
1377 new_name = f"PT {connector_id}: {label} (psi)"
1378 else:
1379 new_name = f"PT {connector_id} (psi)"
1380
1381 # Update legend by finding and modifying the legend item
1382 if self.legend:
1383 for item in self.legend.items:
1384 if len(item) >= 2 and item[0] == plot:
1385 label_item = item[1]
1386 label_item.setText(new_name)
1387 break
1388
1389 def init_ui(self):
1390 """Initialize the user interface"""
1391 layout = QtWidgets.QVBoxLayout(self)
1392 layout.setContentsMargins(0, 0, 0, 0)
1393
1394 # Top panel with controls
1395 top_panel = QtWidgets.QHBoxLayout()
1396
1397 # Connection info
1398 self.status_label = QtWidgets.QLabel("Starting...")
1399 self.status_label.setStyleSheet("font-weight: bold; padding: 5px;")
1400 self.status_label.setFixedWidth(
1401 400
1402 ) # Fixed width to prevent layout resize on text change
1403 top_panel.addWidget(self.status_label)
1404
1405 top_panel.addStretch()
1406
1407 # Y-axis auto-scale toggle
1408 self.auto_scale_checkbox = QtWidgets.QCheckBox("Auto-scale Y-axis")
1409 self.auto_scale_checkbox.setChecked(self.y_axis_auto_scale)
1410 self.auto_scale_checkbox.stateChanged.connect(self.on_auto_scale_toggled)
1411 self.auto_scale_checkbox.setStyleSheet("padding: 5px;")
1412 top_panel.addWidget(self.auto_scale_checkbox)
1413
1414 # CSV export buttons
1415 btn_save_pressures = QtWidgets.QPushButton("Save Pressures CSV")
1416 btn_save_pressures.clicked.connect(self.save_pressures_csv)
1417 btn_save_pressures.setStyleSheet("padding: 5px;")
1418 top_panel.addWidget(btn_save_pressures)
1419
1420 btn_save_events = QtWidgets.QPushButton("Save Events CSV")
1421 btn_save_events.clicked.connect(self.save_events_csv)
1422 btn_save_events.setStyleSheet("padding: 5px;")
1423 top_panel.addWidget(btn_save_events)
1424
1425 layout.addLayout(top_panel)
1426
1427 # Horizontal layout: left = plot + under-graph values, right = stats
1428 plot_stats_layout = QtWidgets.QHBoxLayout()
1429
1430 left_column = QtWidgets.QWidget()
1431 left_column_layout = QtWidgets.QVBoxLayout(left_column)
1432 left_column_layout.setContentsMargins(0, 0, 0, 0)
1433
1434 # Plot widget
1435 self.plot_widget = pg.GraphicsLayoutWidget()
1436 self.plot_widget.setBackground("k") # Black background
1437 left_column_layout.addWidget(self.plot_widget, 1)
1438
1439 # Under-graph strip: label on top, value below (large and easy to read)
1440 under_graph_widget = QtWidgets.QWidget()
1441 under_graph_layout = QtWidgets.QHBoxLayout(under_graph_widget)
1442 under_graph_layout.setContentsMargins(8, 8, 8, 8)
1443 under_graph_layout.setSpacing(16)
1444 label_font = QtGui.QFont()
1445 label_font.setPointSize(10)
1446 value_font = QtGui.QFont()
1447 value_font.setPointSize(16)
1448 value_font.setWeight(QtGui.QFont.Weight.DemiBold)
1449 self.under_graph_labels: Dict[int, QtWidgets.QLabel] = {} # value label only
1450 self.under_graph_name_labels: Dict[int, QtWidgets.QLabel] = (
1451 {}
1452 ) # name label (PT role)
1453 self.under_graph_containers: Dict[int, QtWidgets.QWidget] = (
1454 {}
1455 ) # cell to show/hide
1456 for i in range(1, NUM_CONNECTORS + 1):
1457 cell = QtWidgets.QWidget()
1458 cell_layout = QtWidgets.QVBoxLayout(cell)
1459 cell_layout.setContentsMargins(6, 4, 6, 4)
1460 cell_layout.setSpacing(2)
1461 label_text = self.sensor_labels.get(i, f"PT {i}")
1462 formatted_label = self.format_label_two_rows(label_text)
1463 name_lbl = QtWidgets.QLabel(formatted_label)
1464 name_lbl.setFont(label_font)
1465 name_lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
1466 name_lbl.setWordWrap(False)
1467 color_idx = i % len(SENSOR_COLORS)
1468 color_style = f"color: rgb{SENSOR_COLORS[color_idx]};"
1469 name_lbl.setStyleSheet(color_style)
1470 name_lbl.setMinimumWidth(72)
1471 cell_layout.addWidget(name_lbl)
1472 value_lbl = QtWidgets.QLabel("---")
1473 value_lbl.setFont(value_font)
1474 value_lbl.setTextFormat(QtCore.Qt.TextFormat.RichText)
1475 value_lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
1476 value_lbl.setStyleSheet(color_style)
1477 value_lbl.setMinimumWidth(72)
1478 cell_layout.addWidget(value_lbl)
1479 under_graph_layout.addWidget(cell)
1480 self.under_graph_containers[i] = cell
1481 self.under_graph_name_labels[i] = name_lbl
1482 self.under_graph_labels[i] = value_lbl
1483 under_graph_layout.addStretch()
1484 left_column_layout.addWidget(under_graph_widget)
1485
1486 plot_stats_layout.addWidget(left_column, 1)
1487 layout.addLayout(plot_stats_layout, 1)
1488
1489 # Create initial plot
1490 self.plot_item = self.plot_widget.addPlot(
1491 title="Pressure Data Over Time (Calibrated Sensors)"
1492 )
1493
1494 # Set title color and size to white for visibility on black background
1495 self.plot_item.setTitle(
1496 "Pressure Data Over Time (Calibrated Sensors)", color="w", size="14pt"
1497 )
1498
1499 # Set axis labels to white
1500 self.plot_item.setLabel("left", "Pressure (psi)", color="w")
1501 self.plot_item.setLabel("bottom", "Time (seconds)", color="w")
1502
1503 # Legend removed from pressure display graph
1504 self.legend = None
1505 # Show grid with white/gray lines for visibility on black background
1506 self.plot_item.showGrid(x=True, y=True, alpha=0.5)
1507 self.plot_item.setClipToView(True) # Optimize rendering by clipping to view
1508 # Set grid color to light gray/white
1509 self.plot_item.getViewBox().setBackgroundColor("k") # Ensure black background
1510
1511 # Increase font size for axis labels and ticks
1512 font = QtGui.QFont()
1513 font.setPointSize(12)
1514
1515 # Set all axis text to white
1516 left_axis = self.plot_item.getAxis("left")
1517 bottom_axis = self.plot_item.getAxis("bottom")
1518
1519 # Set font size for ticks
1520 left_axis.setStyle(tickFont=font)
1521 bottom_axis.setStyle(tickFont=font)
1522
1523 # Set label font size by accessing the label item directly
1524 try:
1525 left_axis.label.setFont(font)
1526 bottom_axis.label.setFont(font)
1527 except AttributeError:
1528 pass
1529
1530 # Set axis line and text colors to white; thin pen so gridlines (e.g. every 2s) are thinner
1531 thin_white = pg.mkPen("w", width=1.0)
1532 left_axis.setPen(thin_white)
1533 bottom_axis.setPen(thin_white)
1534 left_axis.setTextPen("w")
1535 bottom_axis.setTextPen("w")
1536
1537 # Pre-initialize plots for all 10 connectors
1539
1540 # Add SPS label to plot (bottom-left anchor)
1541 self.sps_text_item = pg.TextItem(
1542 text="SPS: 0", color=(200, 200, 200), anchor=(0, 1)
1543 )
1544 self.sps_text_item.setPos(
1545 0, 0
1546 ) # Initial position, will be updated in update_plots/resize
1547 self.plot_item.addItem(self.sps_text_item)
1548
1550 """Pre-initialize plots for all 10 connectors"""
1551 for connector_id in range(1, NUM_CONNECTORS + 1):
1552 self.sensor_data[connector_id] = deque(maxlen=MAX_POINTS)
1553 self.sensor_adc_codes[connector_id] = deque(maxlen=MAX_POINTS)
1554 # Only initialize PSI data and plots for calibrated sensors
1555 if connector_id in self.pt_calibration:
1556 self.sensor_psi_plot_t[connector_id] = deque(
1557 maxlen=self.plot_buffer_size
1558 )
1559 self.sensor_psi_plot_v[connector_id] = deque(
1560 maxlen=self.plot_buffer_size
1561 )
1562 self.sensor_psi_history[connector_id] = []
1563 self.add_sensor_plot(connector_id)
1564
1566 """Reload PT calibration from config paths (e.g. after user changes CSV in settings)."""
1568 CONFIG.get_pt_calibration_csv_paths()
1569 )
1570 if self.pt_calibration_error:
1571 print(f"PT Calibration Error: {self.pt_calibration_error}")
1572 # Show error in GUI if we have a reference to main window
1573 if self.main_window_ref:
1574 QtWidgets.QMessageBox.warning(
1575 self.main_window_ref,
1576 "PT Calibration Error",
1578 )
1579 for connector_id in range(1, NUM_CONNECTORS + 1):
1580 if (
1581 connector_id in self.pt_calibration
1582 and connector_id not in self.sensor_psi_plot_t
1583 ):
1584 self.sensor_psi_plot_t[connector_id] = deque(
1585 maxlen=self.plot_buffer_size
1586 )
1587 self.sensor_psi_plot_v[connector_id] = deque(
1588 maxlen=self.plot_buffer_size
1589 )
1590 self.sensor_psi_history[connector_id] = []
1591 self.add_sensor_plot(connector_id)
1592 self.update_plots()
1593 self.update_statistics()
1594
1595 def zero_pts(self):
1596 """Zero all PTs: capture current PSI reading for each calibrated sensor and store as offset."""
1597 for connector_id in list(self.pt_calibration.keys()):
1598 adc_d = self.sensor_adc_codes.get(connector_id)
1599 if not adc_d or len(adc_d) == 0:
1600 continue
1601 n = min(self.display_moving_avg_samples, len(adc_d))
1602 adc_tail = [adc_d[-1 - j][1] for j in range(n)]
1603 moving_avg_adc = sum(adc_tail) / n
1604 a, b, c, d = self.pt_calibration[connector_id]
1605 raw_psi = calculate_pressure(moving_avg_adc, a, b, c, d)
1606 self.pt_offsets[connector_id] = raw_psi
1607
1609 """Clear all PT zero offsets (restore raw calibration output)."""
1610 self.pt_offsets.clear()
1611
1612 def _on_plot_toggle(self, connector_id: int, state):
1613 self.plot_enabled[connector_id] = bool(state)
1614 if connector_id in self.under_graph_containers:
1615 self.under_graph_containers[connector_id].setVisible(bool(state))
1616
1618 """Open the debug popup showing C1, MA, psi per connector."""
1619 dlg = SensorDebugDialog(self)
1620 dlg.exec()
1621
1622 def on_auto_scale_toggled(self, state):
1623 """Handle auto-scale Y-axis toggle"""
1624 self.y_axis_auto_scale = bool(state)
1625 CONFIG.config["display"]["y_axis_autoscale"] = self.y_axis_auto_scale
1626 CONFIG.save()
1627 # Update settings checkbox if it exists
1628 if hasattr(self, "settings_widget_ref") and self.settings_widget_ref:
1629 # This is a bit circular, but we can emit a signal or just let the settings
1630 # tab refresh next time it is shown.
1631 pass
1632
1634 """Clear all sensor data deques so plot/stats show only new data."""
1635 for k in list(self.sensor_data.keys()):
1636 self.sensor_data[k] = deque(maxlen=MAX_POINTS)
1637 self.sensor_adc_codes[k] = deque(maxlen=MAX_POINTS)
1638 if k in self.sensor_psi_plot_t:
1639 self.sensor_psi_plot_t[k] = deque(maxlen=self.plot_buffer_size)
1640 self.sensor_psi_plot_v[k] = deque(maxlen=self.plot_buffer_size)
1641 if k in self.sensor_psi_history:
1642 self.sensor_psi_history[k] = []
1643
1644 def on_demo_mode_toggled(self, state):
1645 """Handle Demo mode checkbox: toggle demo_mode, start/stop UDP sender, clear deques.
1646
1647 Uses QTimer.singleShot to defer state changes to avoid Wayland buffer size mismatch
1648 crashes when the window is maximized (xdg_surface buffer error).
1649 """
1650 # Defer the actual state change to avoid Wayland crash when maximized
1651 QtCore.QTimer.singleShot(0, lambda: self._apply_demo_mode(bool(state)))
1652
1653 def _apply_demo_mode(self, enabled: bool):
1654 """Apply demo mode state change (called via QTimer to avoid Wayland crashes)."""
1655 self.demo_mode = enabled
1656 if self.demo_mode:
1657 self.demo_start_time = time.time()
1659 self.status_label.setText("Demo mode – synthetic data (UDP)")
1660 try:
1661 self.demo_send_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
1662 self.demo_send_timer = QtCore.QTimer(self)
1663 self.demo_send_timer.timeout.connect(self._send_demo_packet)
1664 self.demo_send_timer.start(UPDATE_INTERVAL_MS)
1665 except OSError:
1666 self.demo_send_socket = None
1667 self.demo_send_timer = None
1668 else:
1669 self.demo_start_time = None
1670 if self.demo_send_timer is not None:
1671 self.demo_send_timer.stop()
1672 self.demo_send_timer = None
1673 if self.demo_send_socket is not None:
1674 try:
1675 self.demo_send_socket.close()
1676 except OSError:
1677 pass
1678 self.demo_send_socket = None
1680 self.status_label.setText("Listening")
1681
1683 """Update plot buffer size based on current window_seconds."""
1684 new_size = int(self.window_seconds * 1200) + 1000
1685 if new_size < 1000:
1686 new_size = 1000
1687
1688 if new_size != self.plot_buffer_size:
1689 self.plot_buffer_size = new_size
1690 # Resize existing deques by creating new ones and copying data
1691 # This is expensive but only happens on config change
1692 for k in list(self.sensor_psi_plot_t.keys()):
1693 self.sensor_psi_plot_t[k] = deque(
1694 self.sensor_psi_plot_t[k], maxlen=self.plot_buffer_size
1695 )
1696 self.sensor_psi_plot_v[k] = deque(
1697 self.sensor_psi_plot_v[k], maxlen=self.plot_buffer_size
1698 )
1699
1701 """Handle graph moving average window size change"""
1702 self.graph_moving_avg_samples = value
1703
1705 """Handle display moving average window size change"""
1706 self.display_moving_avg_samples = value
1707
1708 def code_to_voltage(self, code_uint32: int) -> float:
1709 """
1710 Convert raw ADC code to voltage.
1711 code_uint32: uint32_t representation of the ADC code
1712 Returns: voltage in Volts
1713 """
1714 # Reinterpret uint32_t as int32_t (signed)
1715 if code_uint32 >= 0x80000000:
1716 code_int32 = code_uint32 - 0x100000000
1717 else:
1718 code_int32 = code_uint32
1719
1720 # Convert to voltage using user-specified settings
1721 # For signed ADC: voltage = (code * ref_voltage) / (2^(bits-1))
1722 max_code = 2 ** (self.adc_bits - 1)
1723 voltage = (code_int32 * self.reference_voltage) / max_code
1724 return voltage
1725
1726 def on_navigation_requested(self, target: str):
1727 """Handle navigation requests from TopBarWidget"""
1728 if target == "settings":
1729 self.open_settings()
1730 elif target == "dashboard":
1731 self.open_dashboard()
1732 elif target == "pwm":
1733 self.open_pwm_control()
1734
1735 def on_status_update(self, message: str):
1736 """Handle status updates from receiver thread"""
1737 if not self.demo_mode:
1738 self.status_label.setText(message)
1739
1740 def on_packet_received(self, packet_size: int, packet_type: int):
1741 """Handle packet received notification"""
1742 pass # Statistics are updated separately
1743
1744 def on_sensor_data(self, header: dict, chunks: List[dict], source_ip: str):
1745 """Handle received sensor data"""
1746 if self.demo_mode:
1747 if source_ip != "127.0.0.1":
1748 return # In demo mode only accept packets from local demo sender
1749 else:
1750 # If filter is empty string, accept from any source (useful in WSL/NAT where source IP is rewritten)
1751 if self.filter_source_ip and source_ip != self.filter_source_ip:
1752 return # Ignore data from other sources
1753
1754 current_time = time.time()
1755 self.total_samples_received += len(chunks)
1756
1757 for chunk in chunks:
1758 chunk_timestamp_ms = chunk["timestamp"]
1759 # Convert packet timestamp to relative time in seconds
1760 relative_time = current_time - self.stats_start_time
1761
1762 for dp in chunk["datapoints"]:
1763 sensor_id_packet = dp["sensor_id"] # 0-based from packet (0-9)
1764
1765 # Shift sensors down: PT 2→PT 1, PT 3→PT 2, ..., PT 11→PT 10
1766 # Skip packet 0 (inactive), then map packet 1-9 → GUI 1-9
1767 # This shifts everything down by 1 from current display
1768 if sensor_id_packet == 0:
1769 continue # Skip packet 0
1770
1771 # Map packet 1-9 → GUI 1-9 (direct mapping, no offset)
1772 sensor_id = sensor_id_packet
1773
1774 # Only process sensors 1-10 (NUM_CONNECTORS)
1775 if sensor_id < 1 or sensor_id > NUM_CONNECTORS:
1776 continue
1777
1778 code_uint32 = dp["data"] # Received as uint32_t from protocol
1779
1780 # Convert code to voltage
1781 voltage = self.code_to_voltage(code_uint32)
1782
1783 # Initialize sensor data storage if needed (for sensors outside 1-10 range)
1784 if sensor_id not in self.sensor_data:
1785 self.sensor_data[sensor_id] = deque(maxlen=MAX_POINTS)
1786 self.sensor_adc_codes[sensor_id] = deque(maxlen=MAX_POINTS)
1787 # Only create PSI storage and plot for calibrated sensors
1788 if sensor_id in self.pt_calibration:
1789 self.sensor_psi_plot_t[sensor_id] = deque(
1790 maxlen=self.plot_buffer_size
1791 )
1792 self.sensor_psi_plot_v[sensor_id] = deque(
1793 maxlen=self.plot_buffer_size
1794 )
1795 self.sensor_psi_history[sensor_id] = []
1796 self.add_sensor_plot(sensor_id)
1797
1798 # Add data point (use relative time from start)
1799 self.sensor_data[sensor_id].append((relative_time, voltage))
1800 self.sensor_adc_codes[sensor_id].append((relative_time, code_uint32))
1801
1802 # Calculate and store PSI if calibration exists
1803 if sensor_id in self.pt_calibration:
1804 a, b, c, d = self.pt_calibration[sensor_id]
1805 raw_psi = calculate_pressure(code_uint32, a, b, c, d)
1806 offset = self.pt_offsets.get(sensor_id, 0.0)
1807 self.sensor_psi_plot_t[sensor_id].append(relative_time)
1808 self.sensor_psi_plot_v[sensor_id].append(
1809 raw_psi - offset
1810 ) # plot shows zeroed
1811 self.sensor_psi_history[sensor_id].append(
1812 (relative_time, raw_psi)
1813 ) # history stores raw
1814
1816 """Build and send one demo UDP packet to localhost (called by demo timer)."""
1817 if (
1818 not self.demo_mode
1819 or self.demo_start_time is None
1820 or not self.pt_calibration
1821 ):
1822 return
1823 packet = build_demo_packet(
1824 self.pt_calibration,
1825 self.demo_start_time,
1826 self.stats_start_time,
1827 packet_type=PacketType.SENSOR_DATA,
1828 version=DIABLO_COMMS_VERSION,
1829 max_packet_size=MAX_PACKET_SIZE,
1830 psi_min=0.0,
1831 psi_max=700.0,
1832 )
1833 if packet and self.demo_send_socket is not None:
1834 try:
1835 self.demo_send_socket.sendto(packet, ("127.0.0.1", self.receiver.port))
1836 except OSError:
1837 pass
1838
1839 def add_sensor_plot(self, sensor_id: int):
1840 """Add a new sensor plot (for calibrated sensors only)"""
1841 if sensor_id not in self.plot_enabled:
1842 self.plot_enabled[sensor_id] = True
1843 color_idx = sensor_id % len(SENSOR_COLORS)
1844 color = SENSOR_COLORS[color_idx]
1845
1846 # Get label for this sensor
1847 label = self.sensor_labels.get(sensor_id, "")
1848 if label:
1849 plot_name = f"PT {sensor_id}: {label} (psi)"
1850 else:
1851 plot_name = f"PT {sensor_id} (psi)"
1852
1853 pen = pg.mkPen(color=color, width=2)
1854 plot = self.plot_item.plot([], [], pen=pen, name=plot_name)
1855
1856 # Update legend text color to white for this new item
1857 if self.legend:
1858 # Find the legend item for this plot and set text color to white
1859 for item in self.legend.items:
1860 if len(item) >= 2 and item[0] == plot:
1861 label_item = item[1]
1862 # Set text color to white
1863 label_item.setColor("w")
1864
1865 self.sensor_plots[sensor_id] = plot
1866
1867 def update_plots(self):
1868 """Update all sensor plots (PSI data for calibrated sensors only)"""
1869 if not self.sensor_psi_plot_t:
1870 return
1871
1872 current_time = time.time() - self.stats_start_time
1873 time_window = self.window_seconds
1874
1875 # Only plot calibrated sensors that have PSI data
1876 for sensor_id in self.sensor_psi_plot_t:
1877 if sensor_id not in self.sensor_plots:
1878 continue
1879 enabled = self.plot_enabled.get(sensor_id, True)
1880 # If "only show PTs with roles" is enabled, hide PTs without names
1881 if enabled and self.only_show_pt_with_roles:
1882 pt_label = self.sensor_labels.get(sensor_id, "")
1883 if not pt_label or pt_label.strip() == "":
1884 enabled = False
1885 self.sensor_plots[sensor_id].setVisible(enabled)
1886 if not enabled:
1887 continue
1888
1889 t_deque = self.sensor_psi_plot_t[sensor_id]
1890 v_deque = self.sensor_psi_plot_v[sensor_id]
1891 n = len(t_deque)
1892 if n == 0:
1893 continue
1894
1895 try:
1896 # Fast conversion: deques of floats -> numpy arrays (no tuple unpacking)
1897 times_all = np.array(t_deque)
1898 psi_all = np.array(v_deque)
1899
1900 # Filter by time window using searchsorted (time is sorted)
1901 start_time = current_time - time_window
1902 start_idx = np.searchsorted(times_all, start_time)
1903
1904 times_array = times_all[start_idx:]
1905 psi_array = psi_all[start_idx:]
1906
1907 if len(times_array) == 0:
1908 self.sensor_plots[sensor_id].setData([], [])
1909 continue
1910
1911 # Stable downsampling: use linspace indices so the same
1912 # points are selected even as the array grows by 1 each frame
1913 MAX_PLOT_POINTS = 2000
1914 if len(times_array) > MAX_PLOT_POINTS:
1915 indices = np.linspace(
1916 0, len(times_array) - 1, MAX_PLOT_POINTS, dtype=int
1917 )
1918 times_array = times_array[indices]
1919 psi_array = psi_array[indices]
1920
1921 # Apply moving average smoothing to graph if window > 1
1922 if (
1924 and len(psi_array) >= self.graph_moving_avg_samples
1925 ):
1926 kernel = (
1927 np.ones(self.graph_moving_avg_samples)
1929 )
1930 smoothed_psi = np.convolve(psi_array, kernel, mode="valid")
1931 smoothed_times = times_array[self.graph_moving_avg_samples - 1 :]
1932 self.sensor_plots[sensor_id].setData(smoothed_times, smoothed_psi)
1933 else:
1934 self.sensor_plots[sensor_id].setData(times_array, psi_array)
1935 except Exception as e:
1936 print(f"Error updating plot for sensor {sensor_id}: {e}")
1937 continue
1938
1939 # Update x-axis range
1940 if current_time > time_window:
1941 self.plot_item.setXRange(
1942 current_time - time_window, current_time, padding=0
1943 )
1944 else:
1945 self.plot_item.setXRange(0, time_window, padding=0)
1946
1947 # Update y-axis range based on auto-scale setting
1948 if self.y_axis_auto_scale:
1949 # Disable pyqtgraph's built-in auto-range and compute our own with smoothing
1950 self.plot_item.disableAutoRange(axis="y")
1951 # Compute actual min/max from all visible enabled plot data
1952 y_lo, y_hi = float("inf"), float("-inf")
1953 for sid in self.sensor_psi_plot_t:
1954 if (
1955 sid not in self.sensor_plots
1956 or not self.sensor_plots[sid].isVisible()
1957 ):
1958 continue
1959 vd = self.sensor_psi_plot_v[sid]
1960 td = self.sensor_psi_plot_t[sid]
1961 if len(vd) == 0:
1962 continue
1963 # Quick scan: only check last plot_buffer worth of data (already bounded)
1964 t_arr = np.array(td)
1965 v_arr = np.array(vd)
1966 mask = t_arr >= (current_time - time_window)
1967 visible = v_arr[mask]
1968 if len(visible) > 0:
1969 y_lo = min(y_lo, float(visible.min()))
1970 y_hi = max(y_hi, float(visible.max()))
1971 if y_lo < y_hi:
1972 pad = max((y_hi - y_lo) * 0.05, 1.0) # 5% padding, minimum 1 psi
1973 target_lo = y_lo - pad
1974 target_hi = y_hi + pad
1975 # Smooth transition: blend toward target (prevents frame-to-frame jitter)
1976 alpha = 0.15 # smoothing factor (0=no change, 1=instant snap)
1977 prev = self.plot_item.viewRange()[1] # [y_min, y_max]
1978 new_lo = prev[0] + alpha * (target_lo - prev[0])
1979 new_hi = prev[1] + alpha * (target_hi - prev[1])
1980 self.plot_item.setYRange(new_lo, new_hi, padding=0)
1981 else:
1982 self.plot_item.setYRange(self.y_axis_min, self.y_axis_max, padding=0)
1983 self.plot_item.disableAutoRange(axis="y")
1984
1985 # Update SPS label position to stay in bottom-left of view
1986 view_box = self.plot_item.getViewBox()
1987 view_range = view_box.viewRange()
1988 x_min = view_range[0][0]
1989 y_min = view_range[1][0]
1990 # Add small offset from corner
1991 x_range = view_range[0][1] - view_range[0][0]
1992 y_range = view_range[1][1] - view_range[1][0]
1993 self.sps_text_item.setPos(x_min + (x_range * 0.02), y_min + (y_range * 0.1))
1994
1996 """Update statistics display"""
1997 if self.receiver is None:
1998 return
1999
2000 stats = self.receiver.get_stats()
2001 self.network_stats["packets"] = str(stats["packets"])
2002 self.network_stats["pps"] = f"{stats['packets_per_sec']:.2f}"
2003 bytes_val = stats["bytes"]
2004 if bytes_val < 1024:
2005 self.network_stats["bytes"] = f"{bytes_val} B"
2006 elif bytes_val < 1024 * 1024:
2007 self.network_stats["bytes"] = f"{bytes_val / 1024:.2f} KB"
2008 else:
2009 self.network_stats["bytes"] = f"{bytes_val / (1024 * 1024):.2f} MB"
2010 bps = stats["bytes_per_sec"]
2011 if bps < 1024:
2012 self.network_stats["bps"] = f"{bps:.2f} B/s"
2013 elif bps < 1024 * 1024:
2014 self.network_stats["bps"] = f"{bps / 1024:.2f} KB/s"
2015 else:
2016 self.network_stats["bps"] = f"{bps / (1024 * 1024):.2f} MB/s"
2017
2018 # Update SPS display
2019 current_time = time.time()
2020 time_diff = current_time - self.last_sps_time
2021 sps = 0.0
2022 if time_diff >= 0.5: # Update every 500ms or so (matches stats timer)
2023 sample_diff = self.total_samples_received - self.last_sps_sample_count
2024 sps = sample_diff / time_diff
2026 self.last_sps_time = current_time
2027
2028 # If we haven't updated in a while, use the calculated value
2030 else:
2031 # maintain previous display value if called too fast
2032 sps = getattr(self, "current_sps_display", 0.0)
2033
2034 self.sps_text_item.setText(f"SPS: {int(sps)} / PT")
2035
2036 # Update per-connector: under-graph display value and debug_values for popup
2037 for connector_id in range(1, NUM_CONNECTORS + 1):
2038 display_text = "---"
2039 self.debug_values[connector_id] = {
2040 "current": None,
2041 "moving_avg": None,
2042 "psi": None,
2043 }
2044 d = self.sensor_data.get(connector_id)
2045 if d and len(d) > 0:
2046 # Only access last N elements — NOT the whole 200k deque
2047 n_samples = min(self.display_moving_avg_samples, len(d))
2048 # Iterate only the tail of the deque (O(n_samples) not O(len(d)))
2049 tail_values = [d[-1 - j][1] for j in range(n_samples)]
2050 current = d[-1][1]
2051 moving_avg = sum(tail_values) / n_samples
2052 self.debug_values[connector_id]["current"] = current
2053 self.debug_values[connector_id]["moving_avg"] = moving_avg
2054 psi_val = None
2055 adc_d = self.sensor_adc_codes.get(connector_id)
2056 if connector_id in self.pt_calibration and adc_d and len(adc_d) > 0:
2057 n_adc = min(self.display_moving_avg_samples, len(adc_d))
2058 adc_tail = [adc_d[-1 - j][1] for j in range(n_adc)]
2059 moving_avg_adc = sum(adc_tail) / n_adc
2060 a, b, c, dd = self.pt_calibration[connector_id]
2061 psi_val = calculate_pressure(
2062 moving_avg_adc, a, b, c, dd
2063 ) - self.pt_offsets.get(connector_id, 0.0)
2064 self.debug_values[connector_id]["psi"] = psi_val
2065 display_text = f"<span style='font-size:16pt; font-weight:600'>{psi_val:.2f}</span> <span style='font-size:8pt'>psi</span>"
2066 else:
2067 display_text = f"<span style='font-size:16pt; font-weight:600'>{moving_avg:.4f}</span> <span style='font-size:8pt'>V</span>"
2068 if connector_id in self.under_graph_labels:
2069 enabled = self.plot_enabled.get(connector_id, True)
2070 # If "only show PTs with roles" is enabled, hide PTs without names
2071 if enabled and self.only_show_pt_with_roles:
2072 pt_label = self.sensor_labels.get(connector_id, "")
2073 if not pt_label or pt_label.strip() == "":
2074 enabled = False
2075 if connector_id in self.under_graph_containers:
2076 self.under_graph_containers[connector_id].setVisible(enabled)
2077 if enabled:
2078 self.under_graph_labels[connector_id].setText(display_text)
2079
2081 """Save pressure data to CSV file, respecting only_show_pt_with_roles setting."""
2082 # Check if we have any pressure data
2083 if not self.sensor_psi_history:
2084 QtWidgets.QMessageBox.warning(
2085 self, "No Data", "No pressure data available to export."
2086 )
2087 return
2088
2089 # Filter sensors based on settings
2090 sensors_to_save = []
2091 for sensor_id in sorted(self.sensor_psi_history.keys()):
2093 # Only include sensors with roles (non-empty labels)
2094 label = self.sensor_labels.get(sensor_id, "")
2095 if label and label.strip():
2096 sensors_to_save.append(sensor_id)
2097 else:
2098 # Include all sensors with pressure data
2099 sensors_to_save.append(sensor_id)
2100
2101 if not sensors_to_save:
2102 QtWidgets.QMessageBox.warning(
2103 self, "No Data", "No sensors with roles available to export."
2104 )
2105 return
2106
2107 # Generate default filename with timestamp
2108 timestamp = time.strftime("%Y%m%d_%H%M%S")
2109 default_filename = f"pressures_{timestamp}.csv"
2110
2111 # Open file dialog
2112 filename, _ = QtWidgets.QFileDialog.getSaveFileName(
2113 self,
2114 "Save Pressures CSV File",
2115 default_filename,
2116 "CSV Files (*.csv);;All Files (*)",
2117 )
2118
2119 if not filename:
2120 return # User cancelled
2121
2122 try:
2123 with open(filename, "w", newline="", encoding="utf-8") as csvfile:
2124 writer = csv.writer(csvfile)
2125
2126 # Write header: Time (s), then sensor labels
2127 header = ["Time (s)"]
2128 for sensor_id in sensors_to_save:
2129 label = self.sensor_labels.get(sensor_id, "")
2130 if label:
2131 header.append(f"PT{sensor_id}: {label} (psi)")
2132 else:
2133 header.append(f"PT{sensor_id} (psi)")
2134 writer.writerow(header)
2135
2136 # Collect all data into a dictionary for O(1) lookup:
2137 # data_map[timestamp][sensor_id] = psi_value
2138 data_map = {}
2139 all_times = set()
2140
2141 for sensor_id in sensors_to_save:
2142 for t, psi in self.sensor_psi_history[sensor_id]:
2143 if t not in data_map:
2144 data_map[t] = {}
2145 data_map[t][sensor_id] = psi
2146 all_times.add(t)
2147
2148 if not all_times:
2149 QtWidgets.QMessageBox.warning(
2150 self, "No Data", "No timestamp data available."
2151 )
2152 return
2153
2154 # Write data row by row
2155 for t in sorted(all_times):
2156 row = [f"{t:.6f}"]
2157 row_data = data_map[t]
2158 for sensor_id in sensors_to_save:
2159 # Direct lookup instead of loop
2160 val = row_data.get(sensor_id, "")
2161 if val != "":
2162 row.append(f"{val:.6f}")
2163 else:
2164 row.append("")
2165 writer.writerow(row)
2166
2167 QtWidgets.QMessageBox.information(
2168 self, "Success", f"Saved pressures CSV to {filename}"
2169 )
2170
2171 # If PT offsets are active, also auto-save a companion _zeroed.csv
2172 if self.pt_offsets:
2173 zeroed_filename = filename.replace(".csv", "_zeroed.csv")
2174 if zeroed_filename == filename:
2175 zeroed_filename = filename + "_zeroed.csv"
2176 try:
2177 with open(zeroed_filename, "w", newline="", encoding="utf-8") as zf:
2178 zwriter = csv.writer(zf)
2179 # Header
2180 zheader = ["Time (s)"]
2181 for sensor_id in sensors_to_save:
2182 label = self.sensor_labels.get(sensor_id, "")
2183 col = (
2184 f"PT{sensor_id}: {label} (psi, zeroed)"
2185 if label
2186 else f"PT{sensor_id} (psi, zeroed)"
2187 )
2188 zheader.append(col)
2189 zwriter.writerow(zheader)
2190 # Data rows with offset applied
2191 for t in sorted(all_times):
2192 row = [f"{t:.6f}"]
2193 row_data = data_map[t]
2194 for sensor_id in sensors_to_save:
2195 val = row_data.get(sensor_id, "")
2196 if val != "":
2197 zeroed_val = val - self.pt_offsets.get(
2198 sensor_id, 0.0
2199 )
2200 row.append(f"{zeroed_val:.6f}")
2201 else:
2202 row.append("")
2203 zwriter.writerow(row)
2204 QtWidgets.QMessageBox.information(
2205 self,
2206 "Zeroed CSV Saved",
2207 f"Also saved zeroed pressures to:\n{zeroed_filename}",
2208 )
2209 except Exception as ze:
2210 QtWidgets.QMessageBox.warning(
2211 self, "Zeroed CSV Error", f"Could not save zeroed CSV: {ze}"
2212 )
2213 except Exception as e:
2214 QtWidgets.QMessageBox.critical(self, "Error", f"CSV save error: {e}")
2215
2217 """Save events (mode button clicks and actuator state changes) to CSV file."""
2218 if not self.main_window_ref:
2219 QtWidgets.QMessageBox.warning(
2220 self, "Error", "Main window reference not available."
2221 )
2222 return
2223
2224 events = self.main_window_ref.get_event_log()
2225 if not events:
2226 QtWidgets.QMessageBox.warning(
2227 self, "No Data", "No events available to export."
2228 )
2229 return
2230
2231 # Generate default filename with timestamp
2232 timestamp = time.strftime("%Y%m%d_%H%M%S")
2233 default_filename = f"events_{timestamp}.csv"
2234
2235 # Open file dialog
2236 filename, _ = QtWidgets.QFileDialog.getSaveFileName(
2237 self,
2238 "Save Events CSV File",
2239 default_filename,
2240 "CSV Files (*.csv);;All Files (*)",
2241 )
2242
2243 if not filename:
2244 return # User cancelled
2245
2246 try:
2247 with open(filename, "w", newline="", encoding="utf-8") as csvfile:
2248 writer = csv.writer(csvfile)
2249
2250 # Write header
2251 writer.writerow(["Time (s)", "Event Type", "Details"])
2252
2253 # Write events
2254 for event_time, event_type, details in events:
2255 writer.writerow([f"{event_time:.6f}", event_type, details])
2256
2257 QtWidgets.QMessageBox.information(
2258 self, "Success", f"Saved events CSV to {filename}"
2259 )
2260 except Exception as e:
2261 QtWidgets.QMessageBox.critical(self, "Error", f"CSV save error: {e}")
2262
2263
2264# ---------------------- Sensor Plot Window (standalone) ----------------------
2265class SensorPlotWindow(QtWidgets.QMainWindow):
2266 """Standalone window that embeds SensorPlotWidget."""
2267
2268 def __init__(self, receiver, bind_address: str = "0.0.0.0"):
2269 super().__init__()
2270 self.setWindowTitle(f"Sensor Data Receiver - Port {receiver.port}")
2271 self.setGeometry(100, 100, 1200, 800)
2272 self.setCentralWidget(SensorPlotWidget(receiver, bind_address, self))
2273
2274 def closeEvent(self, event):
2275 """Handle window close event"""
2276 w = self.centralWidget()
2277 if w and hasattr(w, "save_sensor_labels"):
2278 w.save_sensor_labels()
2279 event.accept()
2280
2281
2282# ---------------------- Actuator Control Widget (reusable panel) ----------------------
2283class ActuatorControlWidget(QtWidgets.QWidget):
2284 """Reusable actuator control panel. Used in ActuatorControlWindow and CombinedMainWindow."""
2285
2287 self, receiver, device_ip: str = None, device_port: int = None, parent=None
2288 ):
2289 super().__init__(parent)
2290 self.receiver = receiver
2291 # Load from CONFIG (ignoring args if None, effectively preferring config)
2292 self.device_ip = CONFIG.config["network"]["actuator_ip"]
2293 self.device_port = CONFIG.config["network"]["actuator_port"]
2294
2295 # Actuator state tracking (1-indexed: 1-N)
2296 # GUI state: 0 = CLOSED, 1 = OPEN
2298
2299 # Actuator NC/NO type mapping (1-indexed: 1-N)
2300 # Maps actuator ID to 'NC' or 'NO'
2302 i: CONFIG.get_actuator_type_by_id(i)
2303 for i in range(1, get_num_actuators() + 1)
2304 }
2305
2306 # Reference to main window for event tracking (set by main window)
2308
2309 # PWM control window instance (lazily created)
2310 self.pwm_window = None
2311
2312 # Manual control lock: True = manual control enabled (DEBUG button), False = locked
2313 self.manual_control_enabled = False # Default to False (locked) on startup
2314
2315 # Voltage readings (0-indexed: 0-(N-1), maps to actuator 1-N)
2316 # Store as voltage in Volts
2318
2319 # Actuator labels (1-indexed: 1-N)
2321 i: CONFIG.get_actuator_label(i) for i in range(1, get_num_actuators() + 1)
2322 }
2323
2324 # Load display settings from Config
2325 disp = CONFIG.config["display"]
2327 "only_show_actuators_with_roles", False
2328 )
2329
2330 # UDP socket for sending commands
2331 self.command_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
2332
2333 self.init_ui()
2334
2335 # Connect to receiver signals
2336 self.receiver.sensor_data_received.connect(self.on_sensor_data)
2337 self.receiver.status_update.connect(self.on_status_update)
2338
2339 # Timer for updating current display
2340 self.update_timer = QtCore.QTimer(self)
2341 self.update_timer.timeout.connect(self.update_current_display)
2342 self.update_timer.start(100) # Update every 100ms
2343
2344 def init_ui(self):
2345 """Initialize the user interface"""
2346 layout = QtWidgets.QVBoxLayout(self)
2347 layout.setContentsMargins(0, 0, 0, 0)
2348
2349 # Top panel removed - no status label needed
2350
2351 # Main content area with actuators in a dynamic grid
2352 self.grid_container = QtWidgets.QWidget()
2353 self.grid_layout = QtWidgets.QGridLayout(self.grid_container)
2354 self.grid_layout.setSpacing(5)
2355
2356 # Calculate optimal grid layout: use 2-4 columns based on number of actuators
2357 num_actuators = get_num_actuators()
2358 if num_actuators <= 8:
2359 cols = 2
2360 elif num_actuators <= 16:
2361 cols = 4
2362 else:
2363 cols = 4 # Default to 4 columns for larger numbers
2364
2365 # Create actuator controls in dynamic grid
2367 for i in range(num_actuators):
2368 actuator_id = i + 1 # 1-indexed
2369
2370 # Calculate grid position dynamically
2371 row = i // cols
2372 col = i % cols
2373
2374 # Create widget for each actuator
2375 actuator_frame = QtWidgets.QFrame()
2376 actuator_frame.setFrameShape(QtWidgets.QFrame.Shape.Box)
2377 actuator_frame.setFrameShadow(QtWidgets.QFrame.Shadow.Raised)
2378 actuator_frame.setStyleSheet(
2379 "padding: 2px 4px 4px 4px; margin: 1px; background-color: #353535;"
2380 )
2381
2382 actuator_layout = QtWidgets.QVBoxLayout(actuator_frame)
2383 actuator_layout.setContentsMargins(0, 0, 0, 0)
2384 actuator_layout.setSpacing(1)
2385
2386 # Actuator ID label
2387 # If label exists, show label. Else show "Actuator {id}"
2388 label_text = (
2389 self.actuator_labels.get(actuator_id, "") or f"Actuator {actuator_id}"
2390 )
2391 id_label = QtWidgets.QLabel(label_text)
2392 id_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
2393 id_label.setMinimumHeight(20)
2394 id_label.setStyleSheet(
2395 "font-weight: bold; font-size: 10pt; padding: 1px 2px; color: white; background-color: transparent;"
2396 )
2397 actuator_layout.addWidget(id_label)
2398
2399 # Button container
2400 button_container = QtWidgets.QHBoxLayout()
2401 button_container.setSpacing(3)
2402
2403 # OPEN button
2404 on_btn = QtWidgets.QPushButton("OPEN")
2405 on_btn.setMinimumHeight(30)
2406 bg_color = self.palette().color(QtGui.QPalette.ColorRole.Window).name()
2407 on_btn.setStyleSheet(
2408 f"""
2409 QPushButton {{
2410 font-size: 10pt;
2411 font-weight: bold;
2412 background-color: {bg_color};
2413 color: #FFFFFF;
2414 border: 2px solid {bg_color};
2415 border-radius: 4px;
2416 padding: 2px;
2417 }}
2418 """
2419 )
2420 on_btn.clicked.connect(
2421 lambda checked=False, aid=actuator_id: self.set_actuator_state(aid, 1)
2422 )
2423
2424 # CLOSED button
2425 off_btn = QtWidgets.QPushButton("CLOSED")
2426 off_btn.setMinimumHeight(30)
2427 off_btn.setStyleSheet(
2428 f"""
2429 QPushButton {{
2430 font-size: 10pt;
2431 font-weight: bold;
2432 background-color: {bg_color};
2433 color: #FFFFFF;
2434 border: 2px solid {bg_color};
2435 border-radius: 4px;
2436 padding: 2px;
2437 }}
2438 """
2439 )
2440 off_btn.clicked.connect(
2441 lambda checked=False, aid=actuator_id: self.set_actuator_state(aid, 0)
2442 )
2443
2444 button_container.addWidget(on_btn)
2445 button_container.addWidget(off_btn)
2446 actuator_layout.addLayout(button_container)
2447
2448 # Voltage reading label
2449 voltage_label = QtWidgets.QLabel("0.000 V")
2450 voltage_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
2451 voltage_label.setWordWrap(True)
2452 voltage_label.setMinimumHeight(12)
2453 voltage_label.setStyleSheet(
2454 "font-size: 7pt; padding: 0px 2px; color: white; background-color: transparent;"
2455 )
2456 actuator_layout.addWidget(voltage_label)
2457
2458 # Add to grid
2459 self.grid_layout.addWidget(actuator_frame, row, col)
2460
2461 # Store widget references
2462 self.actuator_widgets.append(
2463 {
2464 "frame": actuator_frame,
2465 "on_btn": on_btn,
2466 "off_btn": off_btn,
2467 "voltage_label": voltage_label,
2468 "id_label": id_label,
2469 }
2470 )
2471
2472 layout.addWidget(self.grid_container, 1)
2473
2474 # PWM Control Button
2475 self.pwm_btn = QtWidgets.QPushButton("PWM CONTROL")
2476 self.pwm_btn.setMinimumHeight(40)
2477 self.pwm_btn.setStyleSheet(
2478 """
2479 QPushButton {
2480 font-size: 10pt;
2481 font-weight: bold;
2482 background-color: #505050;
2483 color: #ffffff;
2484 border: 2px solid #404040;
2485 border-radius: 4px;
2486 padding: 4px 8px;
2487 margin-top: 5px;
2488 }
2489 QPushButton:hover {
2490 background-color: #606060;
2491 border-color: #505050;
2492 }
2493 QPushButton:pressed {
2494 background-color: #404040;
2495 border-color: #303030;
2496 }
2497 """
2498 )
2499 self.pwm_btn.clicked.connect(self.open_pwm_control)
2500 layout.addWidget(self.pwm_btn)
2501
2502 # Initialize actuators based on NC/NO type
2503 # NO actuators start OPEN (GUI state 1, hardware command 0)
2504 # NC actuators start CLOSED (GUI state 0, hardware command 0)
2505 # Note: We only set UI state during initialization, no hardware commands are sent
2506 for actuator_id in range(1, get_num_actuators() + 1):
2507 actuator_type = self.actuator_types.get(actuator_id, "NC")
2508 array_idx = actuator_id - 1
2509 if actuator_type == "NO":
2510 # NO actuators start OPEN (GUI state 1, hardware command 0)
2511 self.actuator_states[array_idx] = 1
2512 self.update_button_highlight(array_idx, 1)
2513 # Don't send hardware command during initialization
2514 else:
2515 # NC actuators start CLOSED (GUI state 0, hardware command 0)
2516 self.actuator_states[array_idx] = 0
2517 self.update_button_highlight(array_idx, 0)
2518 # Hardware command already 0, no need to send
2519
2520 # Apply initial visibility based on setting
2522
2524 """Open the PWM control window, creating it lazily."""
2525 # Use main window's PWM window if available, otherwise create our own
2526 if self.main_window_ref and hasattr(self.main_window_ref, "open_pwm_control"):
2528 return
2529
2530 # Fallback: create/show our own PWM window
2531 if self.pwm_window is None:
2532 self.pwm_window = PWMControlWindow(self.main_window_ref or self)
2533 self.pwm_window.show()
2534 self.pwm_window.raise_()
2535 self.pwm_window.activateWindow()
2536
2537 def update_label_display(self, actuator_id, text):
2538 """External method to update label display when changed in settings"""
2539 idx = actuator_id - 1
2540 if 0 <= idx < len(self.actuator_widgets):
2541 new_text = text or f"Actuator {actuator_id}"
2542 self.actuator_widgets[idx]["id_label"].setText(new_text)
2543
2544 def on_label_changed(self, actuator_id: int, text: str):
2545 """Update local actuator label and display (config is written via set_actuator_role in settings)."""
2546 self.actuator_labels[actuator_id] = text
2547 self.update_label_display(actuator_id, text)
2548 # Update visibility if filtering is enabled
2551
2553 """Update visibility of actuator widgets based on whether they have roles.
2554 When filtering is enabled, reflow visible actuators in order: row1 vents, row2 press, row3 mains; col1 fuel, col2 lox.
2555 """
2557 # Remove all widgets from grid
2558 for i in range(get_num_actuators()):
2559 widget = self.actuator_widgets[i]
2560 self.grid_layout.removeWidget(widget["frame"])
2561
2562 # Collect visible actuators from all assigned roles in config
2563 visible_widgets = []
2564 for role_name in CONFIG.get_actuator_role_names():
2565 actuator_id = CONFIG.get_actuator_role(role_name)
2566 if actuator_id and actuator_id > 0:
2567 array_idx = actuator_id - 1
2568 if 0 <= array_idx < len(self.actuator_widgets):
2569 widget = self.actuator_widgets[array_idx]
2570 visible_widgets.append(widget)
2571 widget["frame"].setVisible(True)
2572 # Hide any actuator not in the ordered list
2573 for i in range(get_num_actuators()):
2574 widget = self.actuator_widgets[i]
2575 if widget not in visible_widgets:
2576 widget["frame"].setVisible(False)
2577
2578 # Re-add visible widgets in 2-column layout (col 0 = fuel, col 1 = lox)
2579 for idx, widget in enumerate(visible_widgets):
2580 row = idx // 2
2581 col = idx % 2
2582 self.grid_layout.addWidget(widget["frame"], row, col)
2583 else:
2584 # Show all actuators in their original positions
2585 for i in range(get_num_actuators()):
2586 actuator_id = i + 1
2587 widget = self.actuator_widgets[i]
2588 widget["frame"].setVisible(True)
2589
2590 # Restore original grid positions
2591 for i in range(get_num_actuators()):
2592 widget = self.actuator_widgets[i]
2593 self.grid_layout.removeWidget(widget["frame"])
2594 row = i // 2
2595 col = i % 2
2596 self.grid_layout.addWidget(widget["frame"], row, col)
2597
2598 def on_status_update(self, message: str):
2599 """Handle status updates from receiver thread"""
2600 pass
2601
2602 def on_sensor_data(self, header: dict, chunks: List[dict], source_ip: str):
2603 """Handle received sensor data (actuator current-sense voltage readings)."""
2604 if not chunks:
2605 return
2606
2607 # Only accept sensor packets from the actuator board
2608 if self.device_ip and source_ip != self.device_ip:
2609 return
2610
2611 latest_chunk = chunks[-1]
2612 for dp in latest_chunk["datapoints"]:
2613 sensor_id_packet = int(dp.get("sensor_id", -1))
2614 data_u32 = int(dp.get("data", 0))
2615
2616 # Actuator_Testing firmware uses 1-indexed IDs (1..10); still accept legacy 0..9.
2617 if 1 <= sensor_id_packet <= get_num_actuators():
2618 actuator_id = sensor_id_packet
2619 elif 0 <= sensor_id_packet < get_num_actuators():
2620 actuator_id = sensor_id_packet + 1
2621 else:
2622 continue
2623
2624 array_idx = actuator_id - 1
2625 if not (0 <= array_idx < get_num_actuators()):
2626 continue
2627
2628 self.voltage_readings[array_idx] = actuator_counts_to_volts(data_u32)
2629
2630 def update_button_highlight(self, array_idx: int, actuator_state: int):
2631 """Update button highlighting based on actuator state. Selected OPEN = saturated green, selected CLOSED = saturated red."""
2632 widget = self.actuator_widgets[array_idx]
2633 bg_color = (
2634 widget["frame"].palette().color(QtGui.QPalette.ColorRole.Window).name()
2635 )
2636 # Same 2px border as active state so text doesn't shift when toggling
2637 inactive_style = f"""
2638 QPushButton {{
2639 font-size: 10pt;
2640 font-weight: bold;
2641 background-color: {bg_color};
2642 color: #FFFFFF;
2643 border: 2px solid {bg_color};
2644 border-radius: 4px;
2645 padding: 2px;
2646 }}
2647 """
2648 # Saturated green box for selected OPEN, saturated red box for selected CLOSED (only when selected)
2649 active_open_style = """
2650 QPushButton {
2651 font-size: 10pt;
2652 font-weight: bold;
2653 background-color: #008800;
2654 color: #ffffff;
2655 border: 2px solid #008800;
2656 border-radius: 4px;
2657 padding: 2px;
2658 }
2659 """
2660 active_closed_style = """
2661 QPushButton {
2662 font-size: 10pt;
2663 font-weight: bold;
2664 background-color: #dc143c;
2665 color: #ffffff;
2666 border: 2px solid #dc143c;
2667 border-radius: 4px;
2668 padding: 2px;
2669 }
2670 """
2671 if actuator_state == 1:
2672 widget["on_btn"].setStyleSheet(active_open_style)
2673 widget["off_btn"].setStyleSheet(inactive_style)
2674 else:
2675 widget["on_btn"].setStyleSheet(inactive_style)
2676 widget["off_btn"].setStyleSheet(active_closed_style)
2677
2678 def set_actuator_state(self, actuator_id: int, gui_state: int, force: bool = False):
2679 """
2680 Set actuator state and send command packet.
2681 gui_state: 0 = CLOSED, 1 = OPEN (from GUI button)
2682 force: If True, bypass manual control lock (used by state machine)
2683 Converts to hardware command based on NC/NO type:
2684 - NC: OPEN (1) -> hardware ON (1), CLOSED (0) -> hardware OFF (0)
2685 - NO: OPEN (1) -> hardware OFF (0), CLOSED (0) -> hardware ON (1)
2686 """
2687 # If manual control is locked and this is a manual click (not forced), do nothing
2688 if not self.manual_control_enabled and not force:
2689 return
2690
2691 array_idx = actuator_id - 1
2692 old_state = self.actuator_states[array_idx]
2693 self.actuator_states[array_idx] = gui_state
2694
2695 # Convert GUI state to hardware command based on NC/NO type
2696 actuator_type = self.actuator_types.get(actuator_id, "NC")
2697 if actuator_type == "NO":
2698 # NO: OPEN (1) -> hardware OFF (0), CLOSED (0) -> hardware ON (1)
2699 hardware_command = 0 if gui_state == 1 else 1
2700 else:
2701 # NC: OPEN (1) -> hardware ON (1), CLOSED (0) -> hardware OFF (0)
2702 hardware_command = gui_state
2703
2704 self.update_button_highlight(array_idx, gui_state)
2705 self.send_actuator_command(actuator_id, hardware_command)
2706
2707 # Log actuator state change event
2708 if self.main_window_ref and old_state != gui_state:
2709 label = self.actuator_labels.get(actuator_id, "")
2710 if label:
2711 details = f"Actuator {actuator_id} ({label}): {'OPEN' if gui_state else 'CLOSED'}"
2712 else:
2713 details = f"Actuator {actuator_id}: {'OPEN' if gui_state else 'CLOSED'}"
2714 self.main_window_ref.log_event("Actuator State Change", details)
2715
2716 def send_actuator_command(self, actuator_id: int, hardware_command: int):
2717 """
2718 Send an actuator command packet to the device.
2719 hardware_command: 0 = OFF, 1 = ON (hardware level)
2720 """
2721 try:
2722 commands = [(actuator_id, hardware_command)]
2723 packet = create_actuator_command_packet(commands)
2724 if len(packet) > 0:
2725 self.command_sock.sendto(packet, (self.device_ip, self.device_port))
2726 # Get GUI state for logging
2727 array_idx = actuator_id - 1
2728 gui_state = self.actuator_states[array_idx]
2729 print(
2730 f"Sent command: Actuator {actuator_id} -> {'OPEN' if gui_state else 'CLOSED'} (hardware: {'ON' if hardware_command else 'OFF'})"
2731 )
2732 else:
2733 print(f"Error: Failed to create packet for actuator {actuator_id}")
2734 except OSError as e:
2735 err = e.errno
2736 if err == 65:
2737 msg = f"No route to host — check device IP ({self.device_ip})"
2738 elif err == 64:
2739 msg = f"Network unreachable — check WiFi/Ethernet"
2740 else:
2741 msg = f"Network error: [{err}] {e}"
2742 print(f"Error sending command: {e}")
2743 except Exception as e:
2744 print(f"Error sending command: {e}")
2745
2747 """Update the voltage reading display for all actuators"""
2748 for i in range(get_num_actuators()):
2749 voltage = self.voltage_readings[i]
2750 widget = self.actuator_widgets[i]
2751 widget["voltage_label"].setText(f"{voltage:.3f} V")
2752
2753 def close_socket(self):
2754 """Close the command socket"""
2755 if self.command_sock:
2756 try:
2757 self.command_sock.close()
2758 except:
2759 pass
2760
2761
2762# ---------------------- PWM Control Window ----------------------
2763class PWMControlWindow(QtWidgets.QWidget):
2764 """
2765 Dedicated window for detailed actuator control (PWM).
2766 Allows setting frequency, duty cycle, duration, and arming commands for the next state transition.
2767 """
2768
2769 def __init__(self, parent_window):
2770 super().__init__()
2771 self.setWindowTitle("Actuator PWM Control")
2772 self.resize(900, 700)
2773 self.parent_window = parent_window # Reference to CombinedMainWindow
2774
2775 # Track armed commands: actuator_id -> (duration_ms, duty, freq)
2776 self.active_arms = {}
2777
2778 self.init_ui()
2779
2780 def init_ui(self):
2781 main_layout = QtWidgets.QVBoxLayout(self)
2782
2783 # Header
2784 header = QtWidgets.QLabel("Actuator PWM Configuration")
2785 header.setStyleSheet("font-size: 16pt; font-weight: bold; margin-bottom: 10px;")
2786 main_layout.addWidget(header)
2787
2788 # Scroll area for actuator list
2789 scroll = QtWidgets.QScrollArea()
2790 scroll.setWidgetResizable(True)
2791 scroll_content = QtWidgets.QWidget()
2792 self.scroll_layout = QtWidgets.QVBoxLayout(scroll_content)
2793
2794 self.actuator_rows = {} # id -> widgets dict
2795
2796 # Create a row for each actuator
2797 for i in range(get_num_actuators()):
2798 actuator_id = i + 1
2799 row_widget = QtWidgets.QGroupBox(f"Actuator {actuator_id}")
2800 row_layout = QtWidgets.QHBoxLayout(row_widget)
2801
2802 # Label (Role)
2803 role = CONFIG.get_actuator_label(actuator_id)
2804 label = QtWidgets.QLabel(f"{role}" if role else "Unassigned")
2805 label.setMinimumWidth(120)
2806 label.setStyleSheet("font-weight: bold;")
2807 row_layout.addWidget(label)
2808
2809 # Parameters
2810 # Duty Cycle
2811 row_layout.addWidget(QtWidgets.QLabel("Duty %:"))
2812 duty_spin = QtWidgets.QDoubleSpinBox()
2813 duty_spin.setRange(0, 100)
2814 duty_spin.setValue(50.0)
2815 duty_spin.setSuffix("%")
2816 row_layout.addWidget(duty_spin)
2817
2818 # Rate/Freq
2819 row_layout.addWidget(QtWidgets.QLabel("Freq (Hz):"))
2820 freq_spin = QtWidgets.QDoubleSpinBox()
2821 freq_spin.setRange(
2822 0.1, 1000000.0
2823 ) # Match extended range from previous task
2824 freq_spin.setValue(10.0)
2825 freq_spin.setDecimals(1)
2826 row_layout.addWidget(freq_spin)
2827
2828 # Duration
2829 row_layout.addWidget(QtWidgets.QLabel("Dur (s):"))
2830 dur_spin = QtWidgets.QDoubleSpinBox()
2831 dur_spin.setRange(0.0, 3600.0)
2832 dur_spin.setValue(1.0)
2833 dur_spin.setDecimals(2)
2834 row_layout.addWidget(dur_spin)
2835
2836 # Actions
2837 btn_exec = QtWidgets.QPushButton("Execute Now")
2838 btn_exec.setStyleSheet(
2839 "background-color: #2ecc71; color: white; font-weight: bold;"
2840 )
2841 btn_exec.clicked.connect(
2842 lambda checked=False, aid=actuator_id: self.execute_now(aid)
2843 )
2844 row_layout.addWidget(btn_exec)
2845
2846 # Arm Checkbox
2847 chk_arm = QtWidgets.QCheckBox("Arm for Next State")
2848 chk_arm.setStyleSheet("margin-left: 10px;")
2849 chk_arm.toggled.connect(
2850 lambda checked, aid=actuator_id: self.update_arm_status(aid, checked)
2851 )
2852 row_layout.addWidget(chk_arm)
2853
2854 self.scroll_layout.addWidget(row_widget)
2855
2856 self.actuator_rows[actuator_id] = {
2857 "duty": duty_spin,
2858 "freq": freq_spin,
2859 "dur": dur_spin,
2860 "arm_chk": chk_arm,
2861 }
2862
2863 self.scroll_layout.addStretch()
2864 scroll.setWidget(scroll_content)
2865 main_layout.addWidget(scroll)
2866
2867 # Global Controls
2868 bottom_bar = QtWidgets.QHBoxLayout()
2869
2870 self.lbl_status = QtWidgets.QLabel("Ready")
2871 bottom_bar.addWidget(self.lbl_status)
2872
2873 bottom_bar.addStretch()
2874
2875 btn_disarm_all = QtWidgets.QPushButton("Disarm All")
2876 btn_disarm_all.setStyleSheet(
2877 "background-color: #e74c3c; color: white; padding: 5px 15px;"
2878 )
2879 btn_disarm_all.clicked.connect(self.disarm_all)
2880 bottom_bar.addWidget(btn_disarm_all)
2881
2882 btn_close = QtWidgets.QPushButton("Close")
2883 btn_close.clicked.connect(self.hide)
2884 bottom_bar.addWidget(btn_close)
2885
2886 main_layout.addLayout(bottom_bar)
2887
2888 def execute_now(self, actuator_id):
2889 """Send PWM command immediately."""
2890 widgets = self.actuator_rows[actuator_id]
2891 duty = widgets["duty"].value() / 100.0
2892 freq = widgets["freq"].value()
2893 dur_s = widgets["dur"].value()
2894 dur_ms = int(dur_s * 1000)
2895
2896 # Commands: list of (id, dur_ms, duty, freq)
2897 commands = [(actuator_id, dur_ms, duty, freq)]
2898 packet = create_pwm_actuator_command_packet(commands)
2899
2900 if self.parent_window and self.parent_window.actuator_widget:
2901 try:
2902 sock = self.parent_window.actuator_widget.command_sock
2903 if sock:
2904 ip = self.parent_window.actuator_widget.device_ip
2905 port = self.parent_window.actuator_widget.device_port
2906 sock.sendto(packet, (ip, port))
2907 self.lbl_status.setText(f"Executed PWM on Actuator {actuator_id}")
2908 print(
2909 f"Manual PWM: ID={actuator_id} F={freq}Hz D={duty*100}% T={dur_s}s"
2910 )
2911 except Exception as e:
2912 self.lbl_status.setText(f"Error: {e}")
2913
2914 def update_arm_status(self, actuator_id, checked):
2915 """Update the armed status dict based on checkbox."""
2916 if checked:
2917 widgets = self.actuator_rows[actuator_id]
2918 duty = widgets["duty"].value() / 100.0
2919 freq = widgets["freq"].value()
2920 dur_s = widgets["dur"].value()
2921 dur_ms = int(dur_s * 1000)
2922
2923 self.active_arms[actuator_id] = (dur_ms, duty, freq)
2924 self.lbl_status.setText(f"Armed Actuator {actuator_id} for next state")
2925 else:
2926 if actuator_id in self.active_arms:
2927 del self.active_arms[actuator_id]
2928 self.lbl_status.setText(f"Disarmed Actuator {actuator_id}")
2929
2930 def disarm_all(self):
2931 """Uncheck all arm checkboxes."""
2932 for aid, widgets in self.actuator_rows.items():
2933 widgets["arm_chk"].setChecked(
2934 False
2935 ) # This triggers update_arm_status via signal
2936 self.active_arms.clear()
2937 self.lbl_status.setText("All actuators disarmed")
2938
2939 def get_armed_commands(self) -> List[Tuple[int, int, float, float]]:
2940 """Return list of armed commands to be sent on state transition.
2941 Also clears the arms (consumes them) if that's the desired behavior.
2942 Re-reading user requirement: 'wait till the next state begins'.
2943 Usually one-shot. Let's consume them (uncheck boxes) after retrieving."""
2944 commands = []
2945 for aid, (dur, duty, freq) in self.active_arms.items():
2946 commands.append((aid, dur, duty, freq))
2947 return commands
2948
2949 def consume_arms(self):
2950 """Clear all arms after execution (update UI)."""
2951 # Block signals to prevent recursion loops or unnecessary updates if we want to keep them checks?
2952 # Usually 'arm for next state' implies one-shot. Let's uncheck them.
2953 for aid in list(self.active_arms.keys()):
2954 # We used list(keys) because toggling checkbox modifies the dict
2955 self.actuator_rows[aid]["arm_chk"].setChecked(False)
2956
2957
2958class PressureBarWidget(QtWidgets.QWidget):
2959 """
2960 Vertical bar gauge: four dashed lines at 20%, 40%, 60%, 80% of bar height (global Y from parent).
2961 Labels:
2962 y80=POP, y60=MEOP, y40=NOP, y20=THRESH. Bar outline and fill are full height; fill is linear in pressure.
2963 """
2964
2965 TOP_MARGIN = 20
2966 BOTTOM_GAP = 8 # space between bar and value box
2967 VALUE_BOX_HEIGHT = 26
2968 BOTTOM_CLEARANCE = 4
2969 BOTTOM_MARGIN = (
2970 BOTTOM_GAP + VALUE_BOX_HEIGHT + BOTTOM_CLEARANCE
2971 ) # total bottom region (parent uses for bar extent)
2972
2974 self,
2975 title: str,
2976 nop: float = 500.0,
2977 meop: float = 700.0,
2978 pop: float = 1000.0,
2979 thresh: float = None,
2980 fixed_color: QtGui.QColor = None,
2981 parent=None,
2982 ):
2983 super().__init__(parent)
2984 self.title = title
2985 self.nop = nop
2986 self.meop = meop
2987 self.pop = pop
2988 self.thresh = thresh if thresh is not None else (nop * 0.5)
2989 self.scale_max = 1.25 * pop # shared across bars
2990 self.fixed_color = fixed_color
2991 self.current_value = 0.0
2992 # Global line Y positions in parent coordinates (set by TopBarWidget); None until set
2997 self.setMinimumWidth(79)
2998 self.setMaximumWidth(83)
2999 self.setMinimumHeight(100)
3000 self.setSizePolicy(
3001 QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding
3002 )
3003
3004 def set_global_line_ys(self, y20: float, y40: float, y60: float, y80: float):
3005 """Set the four dashed-line Y positions in parent coordinates (same for all three bars)."""
3006 self._line_y20_parent = y20
3007 self._line_y40_parent = y40
3008 self._line_y60_parent = y60
3009 self._line_y80_parent = y80
3010 self.update()
3011
3013 self,
3014 nop: float,
3015 meop: float,
3016 pop: float,
3017 thresh: float = None,
3018 scale_max: float = None,
3019 ):
3020 """Update NOP, MEOP, POP, THRESH and optional shared scale_max; repaint."""
3021 self.nop = nop
3022 self.meop = meop
3023 self.pop = pop
3024 if thresh is not None:
3025 self.thresh = thresh
3026 if scale_max is not None:
3027 self.scale_max = scale_max
3028 self.update()
3029
3030 def set_value(self, value: float):
3031 self.current_value = value
3032 self.update() # Trigger repaint
3033
3034 def paintEvent(self, event):
3035 painter = QtGui.QPainter(self)
3036 painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
3037
3038 # Geometry: bar on the left, label column on the right
3039 w = self.width()
3040 h = self.height()
3041 top_margin = self.TOP_MARGIN
3042 bottom_margin = self.BOTTOM_MARGIN
3043 left_margin = 5
3044 label_width = 32
3045 gap = 2
3046 bar_w = w - left_margin - gap - label_width
3047 bar_w = max(40, bar_w)
3048 draw_w = max(
3049 28, int(bar_w * 0.85)
3050 ) # bar width (slightly thinner than full slot)
3051 bar_x = left_margin + (bar_w - draw_w) // 2 # center the bar
3052 bar_h = h - top_margin - bottom_margin
3053 bar_y = top_margin
3054 bar_bottom_y = bar_y + bar_h
3055 label_x = bar_x + draw_w + 4 # just to the right of the bar
3056
3057 # Draw Title centered above the bar
3058 painter.setPen(QtCore.Qt.GlobalColor.white)
3059 painter.drawText(
3060 QtCore.QRect(bar_x, 0, draw_w, top_margin),
3061 QtCore.Qt.AlignmentFlag.AlignCenter,
3062 self.title,
3063 )
3064
3065 # Bar outline: full height
3066 scale_max = max(self.scale_max, 1.0)
3067 painter.setPen(QtCore.Qt.GlobalColor.gray)
3068 painter.setBrush(QtGui.QColor(50, 50, 50))
3069 painter.drawRect(bar_x, bar_y, draw_w, bar_h)
3070
3071 # Fill height: piecewise mapping so fill aligns with reference lines (y20=THRESH, y40=NOP, y60=MEOP, y80=POP)
3072 # 0..THRESH -> 0..20%, THRESH..NOP -> 20..40%, NOP..MEOP -> 40..60%, MEOP..POP -> 60..80%, >=POP -> 80%
3073 v = max(0.0, self.current_value)
3074 thresh, nop, meop, pop = self.thresh, self.nop, self.meop, self.pop
3075 if v <= thresh:
3076 segment = (v / thresh) * 0.20 if thresh > 0 else 0.0
3077 elif v <= nop:
3078 segment = (
3079 0.20 + (v - thresh) / (nop - thresh) * 0.20 if nop > thresh else 0.20
3080 )
3081 elif v <= meop:
3082 segment = 0.40 + (v - nop) / (meop - nop) * 0.20 if meop > nop else 0.40
3083 elif v <= pop:
3084 segment = 0.60 + (v - meop) / (pop - meop) * 0.20 if pop > meop else 0.60
3085 else:
3086 segment = 0.80 # cap at 80% (top reference line)
3087 fill_ratio = min(max(segment, 0.0), 1.0)
3088 fill_h = int(fill_ratio * bar_h)
3089 fill_y = bar_bottom_y - fill_h
3090
3091 # Color based on value (vibrant pastels when no fixed_color)
3092 if self.fixed_color:
3093 painter.setBrush(self.fixed_color)
3094 else:
3095 if self.current_value > self.meop:
3096 painter.setBrush(QtGui.QColor("#ff7675")) # vibrant pastel red
3097 elif self.current_value > self.nop:
3098 painter.setBrush(QtGui.QColor("#fdcb6e")) # vibrant pastel amber
3099 else:
3100 painter.setBrush(
3101 QtGui.QColor("#27ae60")
3102 ) # jungle green (darker for white text)
3103
3104 painter.setPen(QtCore.Qt.PenStyle.NoPen)
3105 painter.drawRect(bar_x, fill_y, draw_w, fill_h)
3106
3107 # Global line Y positions: convert from parent coords to local
3108 my_y_in_parent = self.geometry().y()
3109
3110 def to_local(parent_y):
3111 return int(parent_y - my_y_in_parent) if parent_y is not None else None
3112
3113 ly20 = to_local(self._line_y20_parent)
3114 ly40 = to_local(self._line_y40_parent)
3115 ly60 = to_local(self._line_y60_parent)
3116 ly80 = to_local(self._line_y80_parent)
3117
3118 # Fallback if parent has not set global lines yet: compute from this bar's rect
3119 if ly20 is None:
3120 bar_height = bar_bottom_y - bar_y
3121 ly20 = bar_bottom_y - int(0.20 * bar_height)
3122 ly40 = bar_bottom_y - int(0.40 * bar_height)
3123 ly60 = bar_bottom_y - int(0.60 * bar_height)
3124 ly80 = bar_bottom_y - int(0.80 * bar_height)
3125
3126 # Draw four dashed lines at 20%, 40%, 60%, 80% (same pixel y). THRESH/NOP white, MEOP orange, POP red.
3127 white_dash = QtGui.QPen(
3128 QtCore.Qt.GlobalColor.white, 1, QtCore.Qt.PenStyle.DashLine
3129 )
3130 orange_dash = QtGui.QPen(
3131 QtGui.QColor("#ff9f43"), 1, QtCore.Qt.PenStyle.DashLine
3132 )
3133 red_dash = QtGui.QPen(QtGui.QColor("#ff4444"), 1, QtCore.Qt.PenStyle.DashLine)
3134 for ly in (ly20, ly40):
3135 painter.setPen(white_dash)
3136 painter.drawLine(bar_x, ly, bar_x + draw_w, ly)
3137 painter.setPen(orange_dash)
3138 painter.drawLine(bar_x, ly60, bar_x + draw_w, ly60)
3139 painter.setPen(red_dash)
3140 painter.drawLine(bar_x, ly80, bar_x + draw_w, ly80)
3141
3142 # Labels: y80=POP, y60=MEOP, y40=NOP, y20=THRESH (PSI values, positions fixed by line)
3143 label_font = painter.font()
3144 label_font.setPointSize(7)
3145 painter.setFont(label_font)
3146 painter.setPen(QtCore.Qt.GlobalColor.white)
3147 line_height = 12
3148 for ly, limit_val in [
3149 (ly80, self.pop),
3150 (ly60, self.meop),
3151 (ly40, self.nop),
3152 (ly20, self.thresh),
3153 ]:
3154 label_y = ly - line_height // 2
3155 label_rect = QtCore.QRect(label_x, label_y, label_width, line_height)
3156 painter.drawText(
3157 label_rect,
3158 QtCore.Qt.AlignmentFlag.AlignLeft
3159 | QtCore.Qt.AlignmentFlag.AlignVCenter,
3160 f"{int(limit_val)}",
3161 )
3162
3163 # Value box under the bar (with gap above): filled rounded rect in bar color, then text
3164 value_box_y = bar_bottom_y + self.BOTTOM_GAP
3165 val_rect = QtCore.QRect(bar_x, value_box_y, draw_w, self.VALUE_BOX_HEIGHT)
3166 box_color = (
3167 self.fixed_color
3168 if self.fixed_color
3169 else (
3170 QtGui.QColor("#ff7675")
3171 if self.current_value > self.meop
3172 else (
3173 QtGui.QColor("#fdcb6e")
3174 if self.current_value > self.nop
3175 else QtGui.QColor("#27ae60")
3176 )
3177 )
3178 )
3179 painter.setPen(QtGui.QPen(box_color, 2))
3180 painter.setBrush(box_color)
3181 radius = 4
3182 painter.drawRoundedRect(val_rect, radius, radius)
3183 # Draw value text on top (larger, bold, white)
3184 val_font = painter.font()
3185 val_font.setPointSize(val_font.pointSize() + 2)
3186 val_font.setWeight(QtGui.QFont.Weight.Bold)
3187 painter.setFont(val_font)
3188 painter.setPen(QtCore.Qt.GlobalColor.white)
3189 val_str = f"{self.current_value:.0f}"
3190 painter.drawText(val_rect, QtCore.Qt.AlignmentFlag.AlignCenter, val_str)
3191
3192
3193class TopBarWidget(QtWidgets.QWidget):
3194 navigation_requested = QtCore.pyqtSignal(str) # "dashboard" or "settings"
3195 mode_changed = QtCore.pyqtSignal(str) # Mode name string
3196 debug_toggled = QtCore.pyqtSignal(bool) # DEBUG mode enabled/disabled
3197
3198 # Countdown target: Friday March 6, 2026 12:00:00 PST (UTC-8)
3199 _COUNTDOWN_TARGET_UTC = QtCore.QDateTime(
3200 QtCore.QDate(2026, 3, 6), QtCore.QTime(20, 0, 0), QtCore.Qt.TimeSpec.UTC
3201 )
3202
3203 def __init__(self, parent=None):
3204 super().__init__(parent)
3205 self.setFixedHeight(150)
3206 layout = QtWidgets.QHBoxLayout(self)
3207 layout.setContentsMargins(10, 5, 10, 5)
3208 layout.setSpacing(0)
3209
3210 # ── Clock & Countdown (top-left, before pressure bars) ──────────────
3211 clock_col = QtWidgets.QVBoxLayout()
3212 clock_col.setSpacing(2)
3213 clock_col.setAlignment(QtCore.Qt.AlignmentFlag.AlignVCenter)
3214
3215 self.lbl_time = QtWidgets.QLabel()
3216 self.lbl_time.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
3217 self.lbl_time.setStyleSheet(
3218 "font-size: 14pt; font-weight: bold; color: #e0e0e0; font-family: monospace;"
3219 )
3220
3221 self.lbl_countdown = QtWidgets.QLabel()
3222 self.lbl_countdown.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
3223 self.lbl_countdown.setStyleSheet(
3224 "font-size: 11pt; font-weight: bold; color: #ffd700; font-family: monospace;"
3225 )
3226
3227 lbl_countdown_title = QtWidgets.QLabel("T- LAUNCH")
3228 lbl_countdown_title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
3229 lbl_countdown_title.setStyleSheet(
3230 "font-size: 7pt; color: #aaaaaa; letter-spacing: 1px;"
3231 )
3232
3233 clock_col.addStretch()
3234 clock_col.addWidget(self.lbl_time)
3235 clock_col.addWidget(lbl_countdown_title)
3236 clock_col.addWidget(self.lbl_countdown)
3237 clock_col.addStretch()
3238
3239 layout.addLayout(clock_col)
3240 layout.addSpacing(8)
3241
3242 # Tick immediately, then every second
3243 self._update_clock()
3244 self._clock_timer = QtCore.QTimer(self)
3245 self._clock_timer.setInterval(1000)
3246 self._clock_timer.timeout.connect(self._update_clock)
3247 self._clock_timer.start()
3248 # ────────────────────────────────────────────────────────────────────
3249
3250 # Pressure Bars: bottom=0, shared scale_max so THRESH/NOP/MEOP/POP align across bars
3251 pl = CONFIG.config.get("pressure_limits", {})
3252
3253 def limits(fluid: str):
3254 d = pl.get(fluid, {})
3255 return (
3256 float(d.get("NOP", 600)),
3257 float(d.get("MEOP", 650)),
3258 float(d.get("POP", 750)),
3259 float(d.get("THRESH", 400)),
3260 )
3261
3262 nop_gn2, meop_gn2, pop_gn2, thresh_gn2 = limits("GN2")
3263 nop_eth, meop_eth, pop_eth, thresh_eth = limits("ETH")
3264 nop_lox, meop_lox, pop_lox, thresh_lox = limits("LOX")
3265 scale_max = max(1.25 * pop_gn2, 1.25 * pop_eth, 1.25 * pop_lox, 1.0)
3266 # Vibrant pastel bar colors (match app's stylized pastel palette)
3268 "GN2",
3269 nop_gn2,
3270 meop_gn2,
3271 pop_gn2,
3272 thresh=thresh_gn2,
3273 fixed_color=QtGui.QColor("#27ae60"),
3274 ) # jungle green (darker for white text)
3276 "ETH",
3277 nop_eth,
3278 meop_eth,
3279 pop_eth,
3280 thresh=thresh_eth,
3281 fixed_color=QtGui.QColor("#ff9f43"),
3282 ) # vibrant pastel orange (fuel)
3284 "LOX",
3285 nop_lox,
3286 meop_lox,
3287 pop_lox,
3288 thresh=thresh_lox,
3289 fixed_color=QtGui.QColor("#74b9ff"),
3290 ) # vibrant pastel blue
3291 for bar in (self.bar_gn2, self.bar_eth, self.bar_lox):
3292 bar.scale_max = scale_max
3293
3294 layout.addWidget(self.bar_gn2, 0)
3295 layout.addWidget(self.bar_eth, 0)
3296 layout.addWidget(self.bar_lox, 0)
3297
3298 # Compute global dashed-line Y positions once from first bar's rect (same pixel y for all three)
3300
3301 # Add stretch before mode buttons to center them
3302 layout.addStretch()
3303
3304 # Mode Selection Buttons (radio button group) - 2 rows
3305 mode_button_container = QtWidgets.QVBoxLayout()
3306 mode_button_container.setSpacing(5)
3307
3308 # Create two horizontal rows
3309 row1_layout = QtWidgets.QHBoxLayout()
3310 row1_layout.setSpacing(5)
3311 row2_layout = QtWidgets.QHBoxLayout()
3312 row2_layout.setSpacing(5)
3313
3314 # Button group to ensure only one button is selected at a time
3315 self.mode_button_group = QtWidgets.QButtonGroup(self)
3316 self.mode_button_group.setExclusive(True)
3317
3318 # List of button labels (same order as CSV header, minus Abort and Debug)
3319 # CSV: Idle, Armed, Fuel Fill, Ox Fill, Quick Fire, GN2 Press, Fuel Press, Fuel Vent, Ox Press, Ox Vent, High Press, GN2 Vent, Fire, Vent, Abort
3320 # Abort is handled by the dedicated yellow ABORT button on the right.
3321 # Debug is handled by the DEBUG button (unlocks states and manual control)
3322 mode_labels = [
3323 "Idle",
3324 "Armed",
3325 "Fuel Fill",
3326 "Ox Fill",
3327 "Quick Fire",
3328 "GN2 Press",
3329 "Fuel Press",
3330 "Fuel Vent",
3331 "Ox Press",
3332 "Ox Vent",
3333 "High Press",
3334 "GN2 Vent",
3335 "Fire",
3336 "Vent",
3337 ]
3339
3340 # Load state transitions
3342 CONFIG.get_state_transitions_csv_path()
3343 )
3344 self.current_state = None # Track current state for transition validation (None = no state selected)
3346 False # Track whether state transitions are unlocked (via DEBUG button)
3347 )
3348
3349 # Quick Fire → GN2 Press: after 3s in Quick Fire, auto-transition to GN2 Press
3350 self.quick_fire_to_gn2_timer = QtCore.QTimer(self)
3351 self.quick_fire_to_gn2_timer.setSingleShot(True)
3353 # Abort → Vent, then after 5s auto-transition to Abort
3354 self.abort_vent_to_abort_timer = QtCore.QTimer(self)
3355 self.abort_vent_to_abort_timer.setSingleShot(True)
3357
3358 # Helper function to get button color based on state name
3359 def get_state_color(state_name: str) -> str:
3360 """Return pastel background color for state button"""
3361 state_lower = state_name.lower()
3362 if "vent" in state_lower:
3363 return "#c5b0d8" # Pastel purple
3364 elif "fire" in state_lower or state_lower == "quick fire":
3365 return "#ffc0c5" # Pastel red
3366 elif "fill" in state_lower:
3367 return "#ffe5cc" # Pastel orange
3368 elif "press" in state_lower:
3369 return "#c5e0ff" # Pastel blue
3370 elif state_lower == "armed":
3371 return "#ffffcc" # Pastel yellow
3372 else: # Idle - keep default gray
3373 return "#e0e0e0" # Light gray
3374
3375 self.get_state_color = get_state_color
3376
3377 # Vivid/saturated colors for SELECTED state (super obvious which is current)
3378 def get_state_selected_color(state_name: str) -> str:
3379 """Return vivid, saturated color for selected state button."""
3380 state_lower = state_name.lower()
3381 if "vent" in state_lower:
3382 return "#9932cc" # Vivid purple
3383 elif "fire" in state_lower or state_lower == "quick fire":
3384 return "#dc143c" # Vivid red (crimson)
3385 elif "fill" in state_lower:
3386 return "#ff8c00" # Vivid orange
3387 elif "press" in state_lower:
3388 return "#0066cc" # Vivid blue
3389 elif state_lower == "armed":
3390 return "#ffd700" # Vivid yellow/gold
3391 else: # Idle
3392 return "#708090" # Vivid gray (slate)
3393
3394 self.get_state_selected_color = get_state_selected_color
3395
3396 def text_color_for_bg(hex_bg: str) -> str:
3397 """Return #000000 or #ffffff for readable text on the given background."""
3398 hex_bg = hex_bg.lstrip("#")
3399 r = int(hex_bg[0:2], 16)
3400 g = int(hex_bg[2:4], 16)
3401 b = int(hex_bg[4:6], 16)
3402 # Relative luminance (perceived brightness)
3403 lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
3404 return "#ffffff" if lum < 0.6 else "#000000"
3405
3406 self.text_color_for_bg = text_color_for_bg
3407
3408 # Create buttons and distribute them across 2 rows
3409 # Note: Initial styling will be set by update_button_states() after buttons are created
3410 for i, label in enumerate(mode_labels):
3411 btn = QtWidgets.QPushButton(label)
3412 btn.setCheckable(True)
3413 # Use a lambda with default argument to capture the correct index
3414 btn.clicked.connect(
3415 lambda checked=False, idx=i: self.on_mode_button_clicked(idx)
3416 )
3417 self.mode_button_group.addButton(btn, i)
3418
3419 # Distribute buttons across 2 rows (7 in first row, 7 in second row for 14 buttons)
3420 if i < 7:
3421 row1_layout.addWidget(btn)
3422 else:
3423 row2_layout.addWidget(btn)
3424
3425 self.mode_buttons.append(btn)
3426
3427 # Add rows to container
3428 mode_button_container.addLayout(row1_layout)
3429 mode_button_container.addLayout(row2_layout)
3430
3431 # On startup: no state selected, only Idle is enabled
3432 # Update button states for no state (None) - only Idle will be enabled
3433 self.update_button_states(None)
3434
3435 layout.addLayout(mode_button_container)
3436 layout.addStretch()
3437
3438 # Buttons Layout
3439 btn_layout = QtWidgets.QVBoxLayout()
3440
3441 # Abort Buttons Row — solid colors, no gradients; match state-button look
3442 abort_row = QtWidgets.QHBoxLayout()
3443 self.btn_abort = QtWidgets.QPushButton("ABORT")
3444 self.btn_abort.setMinimumSize(100, 32)
3445 self.btn_abort.setStyleSheet(
3446 """
3447 QPushButton {
3448 font-size: 9pt;
3449 font-weight: bold;
3450 background-color: #e67e22;
3451 color: #ffffff;
3452 border: 2px solid #b85c0e;
3453 border-radius: 4px;
3454 padding: 4px 8px;
3455 }
3456 QPushButton:hover {
3457 background-color: #eb9842;
3458 border-color: #d35400;
3459 }
3460 QPushButton:pressed {
3461 background-color: #d35400;
3462 border-color: #b85c0e;
3463 }
3464 """
3465 )
3466 self.btn_abort.clicked.connect(lambda: self.abort("ABORT"))
3467
3468 self.btn_emergency = QtWidgets.QPushButton("EMERGENCY ABORT")
3469 self.btn_emergency.setMinimumSize(100, 32)
3470 self.btn_emergency.setStyleSheet(
3471 """
3472 QPushButton {
3473 font-size: 9pt;
3474 font-weight: bold;
3475 background-color: #c0392b;
3476 color: #ffffff;
3477 border: 2px solid #922b21;
3478 border-radius: 4px;
3479 padding: 4px 8px;
3480 }
3481 QPushButton:hover {
3482 background-color: #e74c3c;
3483 border-color: #c0392b;
3484 }
3485 QPushButton:pressed {
3486 background-color: #a93226;
3487 border-color: #922b21;
3488 }
3489 """
3490 )
3491 self.btn_emergency.clicked.connect(lambda: self.abort("EMERGENCY ABORT"))
3492
3493 abort_row.addWidget(self.btn_abort)
3494 abort_row.addWidget(self.btn_emergency)
3495 btn_layout.addLayout(abort_row)
3496
3497 # SETTINGS — solid neutral button, same shape as other controls
3498 self.nav_btn = QtWidgets.QPushButton("SETTINGS")
3499 self.nav_btn.setMinimumHeight(26)
3500 self.nav_btn.setStyleSheet(
3501 """
3502 QPushButton {
3503 font-size: 8pt;
3504 font-weight: normal;
3505 background-color: #505050;
3506 color: #ffffff;
3507 border: 2px solid #404040;
3508 border-radius: 4px;
3509 padding: 4px 8px;
3510 }
3511 QPushButton:hover {
3512 background-color: #606060;
3513 border-color: #505050;
3514 }
3515 QPushButton:pressed {
3516 background-color: #404040;
3517 border-color: #303030;
3518 }
3519 """
3520 )
3521 self.nav_btn.clicked.connect(self.toggle_view)
3522
3523 # Add buttons to layout
3524 bottom_row = QtWidgets.QHBoxLayout()
3525 bottom_row.addWidget(self.nav_btn)
3526
3527 btn_layout.addLayout(bottom_row)
3528
3529 # DEBUG — toggle; unchecked = neutral, checked = solid green (no gradients)
3530 self.debug_btn = QtWidgets.QPushButton("DEBUG")
3531 self.debug_btn.setMinimumHeight(26)
3532 self.debug_btn.setCheckable(True)
3533 self.debug_btn.setChecked(False)
3534 self.debug_btn.clicked.connect(self.on_debug_clicked)
3535 self.debug_btn.setStyleSheet(
3536 """
3537 QPushButton {
3538 font-size: 8pt;
3539 font-weight: normal;
3540 background-color: #505050;
3541 color: #ffffff;
3542 border: 2px solid #404040;
3543 border-radius: 4px;
3544 padding: 4px 8px;
3545 }
3546 QPushButton:hover {
3547 background-color: #606060;
3548 border-color: #505050;
3549 }
3550 QPushButton:pressed {
3551 background-color: #404040;
3552 border-color: #353535;
3553 }
3554 QPushButton:checked {
3555 background-color: #27ae60;
3556 color: #000000;
3557 border: 2px solid #1e8449;
3558 }
3559 QPushButton:checked:hover {
3560 background-color: #2ecc71;
3561 border-color: #27ae60;
3562 }
3563 QPushButton:checked:pressed {
3564 background-color: #1e8449;
3565 border-color: #196f3d;
3566 }
3567 """
3568 )
3569 btn_layout.addWidget(self.debug_btn)
3570
3571 # Current state display (under Settings/Debug)
3572 self.current_state_label = QtWidgets.QLabel("CURRENT STATE: —")
3573 self.current_state_label.setStyleSheet("font-weight: bold; font-size: 10pt;")
3574 btn_layout.addWidget(self.current_state_label)
3576
3577 layout.addLayout(btn_layout)
3578
3579 def _update_clock(self):
3580 """Update the current-time label and the countdown-to-launch label."""
3581 now_utc = QtCore.QDateTime.currentDateTimeUtc()
3582 # Current time display in local time
3583 now_local = QtCore.QDateTime.currentDateTime()
3584 self.lbl_time.setText(now_local.toString("HH:mm:ss"))
3585 # Countdown
3586 remaining_ms = now_utc.msecsTo(self._COUNTDOWN_TARGET_UTC)
3587 if remaining_ms <= 0:
3588 self.lbl_countdown.setText("00:00:00")
3589 self.lbl_countdown.setStyleSheet(
3590 "font-size: 11pt; font-weight: bold; color: #ff4444; font-family: monospace;"
3591 )
3592 else:
3593 total_secs = remaining_ms // 1000
3594 hours = total_secs // 3600
3595 minutes = (total_secs % 3600) // 60
3596 seconds = total_secs % 60
3597 self.lbl_countdown.setText(f"{hours:03d}:{minutes:02d}:{seconds:02d}")
3598
3600 """Compute bar rect once from first bar; set same y20,y40,y60,y80 (parent coords) on all three bars."""
3601 ref = self.bar_gn2
3602 top_margin = PressureBarWidget.TOP_MARGIN
3603 bottom_margin = PressureBarWidget.BOTTOM_MARGIN
3604 bar_top_y = ref.geometry().y() + top_margin
3605 bar_bottom_y = ref.geometry().y() + ref.geometry().height() - bottom_margin
3606 bar_height = bar_bottom_y - bar_top_y
3607 y20 = bar_bottom_y - 0.20 * bar_height
3608 y40 = bar_bottom_y - 0.40 * bar_height
3609 y60 = bar_bottom_y - 0.60 * bar_height
3610 y80 = bar_bottom_y - 0.80 * bar_height
3611 for bar in (self.bar_gn2, self.bar_eth, self.bar_lox):
3612 bar.set_global_line_ys(y20, y40, y60, y80)
3613
3614 def resizeEvent(self, event):
3615 super().resizeEvent(event)
3617
3618 def showEvent(self, event):
3619 super().showEvent(event)
3621
3623 """Update the CURRENT STATE label to reflect self.current_state."""
3624 state_text = self.current_state if self.current_state is not None else "—"
3625 self.current_state_label.setText(f"CURRENT STATE: {state_text}")
3626
3628 """Handle DEBUG button click (toggle) - unlocks states and manual actuator control"""
3629 checked = self.debug_btn.isChecked()
3630 self.unlock_states = checked
3631
3632 # Emit signal to notify main window to update actuator control
3633 self.debug_toggled.emit(checked)
3634
3635 # Update button states when debug state changes
3637
3638 def update_button_states(self, current_state: str):
3639 """Update button enabled/disabled states and styles based on allowed transitions"""
3640 mode_labels = [
3641 "Idle",
3642 "Armed",
3643 "Fuel Fill",
3644 "Ox Fill",
3645 "Quick Fire",
3646 "GN2 Press",
3647 "Fuel Press",
3648 "Fuel Vent",
3649 "Ox Press",
3650 "Ox Vent",
3651 "High Press",
3652 "GN2 Vent",
3653 "Fire",
3654 "Vent",
3655 ]
3656
3657 # Get allowed transitions for current state
3658 if current_state is None:
3659 # No state selected: only Idle is allowed
3660 allowed_transitions = {"Idle": True}
3661 for state in mode_labels:
3662 if state != "Idle":
3663 allowed_transitions[state] = False
3664 else:
3665 allowed_transitions = self.state_transitions.get(current_state, {})
3666
3667 # If DEBUG/unlock is enabled, allow all transitions
3668 if self.unlock_states:
3669 allowed_transitions = {state: True for state in mode_labels}
3670
3671 # Update each button based on whether transition is allowed
3672 for i, btn in enumerate(self.mode_buttons):
3673 if i < len(mode_labels):
3674 next_state = mode_labels[i]
3675 is_allowed = allowed_transitions.get(next_state, False)
3676
3677 # Get color for this state
3678 bg_color = self.get_state_color(next_state)
3679
3680 # Calculate hover color (slightly lighter)
3681 hover_color = self._lighten_color(bg_color, 20)
3682
3683 # Enable/disable button
3684 btn.setEnabled(is_allowed)
3685
3686 # Create style based on button state
3687 if btn.isChecked():
3688 # Selected: vivid, saturated color so current state is super obvious
3689 selected_bg = self.get_state_selected_color(next_state)
3690 selected_text = self.text_color_for_bg(selected_bg)
3691 style = f"""
3692 QPushButton {{
3693 font-size: 9pt;
3694 font-weight: bold;
3695 background-color: {selected_bg};
3696 color: {selected_text};
3697 border: 3px solid #000000;
3698 border-radius: 5px;
3699 padding: 6px 8px;
3700 min-width: 80px;
3701 }}
3702 """
3703 elif is_allowed:
3704 # Unselected: colored background with dark text for readability on pastel colors
3705 style = f"""
3706 QPushButton {{
3707 font-size: 9pt;
3708 font-weight: bold;
3709 background-color: {bg_color};
3710 color: #000000;
3711 border: 2px solid #888888;
3712 border-radius: 5px;
3713 padding: 6px 8px;
3714 min-width: 80px;
3715 }}
3716 QPushButton:hover {{
3717 background-color: {hover_color};
3718 border-color: #666666;
3719 }}
3720 """
3721 else:
3722 # Disabled: actual grey color
3723 style = """
3724 QPushButton {
3725 font-size: 9pt;
3726 font-weight: bold;
3727 background-color: #808080;
3728 color: #666666;
3729 border: 2px solid #666666;
3730 border-radius: 5px;
3731 padding: 6px 8px;
3732 min-width: 80px;
3733 }
3734 QPushButton:hover {
3735 background-color: #808080;
3736 border-color: #666666;
3737 }
3738 """
3739
3740 btn.setStyleSheet(style)
3741
3742 def _lighten_color(self, hex_color: str, amount: int) -> str:
3743 """Lighten a hex color by the given amount"""
3744 hex_color = hex_color.lstrip("#")
3745 r = min(255, int(hex_color[0:2], 16) + amount)
3746 g = min(255, int(hex_color[2:4], 16) + amount)
3747 b = min(255, int(hex_color[4:6], 16) + amount)
3748 return f"#{r:02x}{g:02x}{b:02x}"
3749
3750 def _darken_color(self, hex_color: str, amount: int) -> str:
3751 """Darken a hex color by the given amount"""
3752 hex_color = hex_color.lstrip("#")
3753 r = max(0, int(hex_color[0:2], 16) - amount)
3754 g = max(0, int(hex_color[2:4], 16) - amount)
3755 b = max(0, int(hex_color[4:6], 16) - amount)
3756 return f"#{r:02x}{g:02x}{b:02x}"
3757
3759 """Handle mode button click - update highlighting"""
3760 # Ensure the clicked button stays checked (exclusive group handles this, but be explicit)
3761 if 0 <= idx < len(self.mode_buttons):
3762 # Check if button is enabled (transition allowed) - unless DEBUG/unlock is enabled
3763 if not self.unlock_states and not self.mode_buttons[idx].isEnabled():
3764 return # Don't allow clicking disabled buttons unless DEBUG is enabled
3765
3766 self.mode_buttons[idx].setChecked(True)
3767 # Emit signal with mode name (keep in sync with mode_labels above)
3768 mode_labels = [
3769 "Idle",
3770 "Armed",
3771 "Fuel Fill",
3772 "Ox Fill",
3773 "Quick Fire",
3774 "GN2 Press",
3775 "Fuel Press",
3776 "Fuel Vent",
3777 "Ox Press",
3778 "Ox Vent",
3779 "High Press",
3780 "GN2 Vent",
3781 "Fire",
3782 "Vent",
3783 ]
3784 new_state = mode_labels[idx]
3785
3786 # Stop any active timers if state is changing
3787 if self.current_state == "Quick Fire":
3789 elif self.current_state == "Abort" and new_state == "Vent":
3790 # Starting release sequence from abort
3792
3793 self.current_state = new_state
3795 self.update_button_states(new_state)
3796 self.mode_changed.emit(new_state)
3797
3798 # Quick Fire: start 3s timer to auto-transition to GN2 Press; otherwise cancel timer
3799 if new_state == "Quick Fire":
3801 else:
3804
3805 # Button styles are now handled by update_button_states, which is called above
3806
3808 """After 3s in Quick Fire, auto-transition to GN2 Press."""
3809 if self.current_state == "Quick Fire":
3810 self.request_state("GN2 Press")
3811
3813 """After 5s in Vent (from ABORT button), auto-transition to Abort."""
3814 self._apply_abort_state()
3815
3817 """Clear selection of all mode buttons so none appear selected (e.g. for Abort/Vent-from-abort)."""
3818 self.mode_button_group.setExclusive(False)
3819 for btn in self.mode_buttons:
3820 btn.setChecked(False)
3821 self.mode_button_group.setExclusive(True)
3822
3824 """Set GUI and emitted state to Abort (used by EMERGENCY ABORT and by 5s Vent→Abort timer)."""
3827 self.current_state = "Abort"
3830 self.update_button_states("Abort")
3831 self.mode_changed.emit("Abort")
3832
3833 def request_state(self, mode_name: str):
3834 """Programmatically switch to a state (e.g. for automatic pressure-based transitions). Bypasses transition check."""
3835 mode_labels = [
3836 "Idle",
3837 "Armed",
3838 "Fuel Fill",
3839 "Ox Fill",
3840 "Quick Fire",
3841 "GN2 Press",
3842 "Fuel Press",
3843 "Fuel Vent",
3844 "Ox Press",
3845 "Ox Vent",
3846 "High Press",
3847 "GN2 Vent",
3848 "Fire",
3849 "Vent",
3850 ]
3851 if mode_name not in mode_labels:
3852 return
3853 idx = mode_labels.index(mode_name)
3854 self.mode_buttons[idx].setChecked(True)
3855 self.current_state = mode_name
3857 self.update_button_states(mode_name)
3858 self.mode_changed.emit(mode_name)
3859 # Quick Fire: start 3s timer to auto-transition to GN2 Press; otherwise cancel timer
3860 if mode_name == "Quick Fire":
3863 else:
3866
3867 def abort(self, abort_type: str = "ABORT"):
3868 """Trigger Abort state (CSV) and log the event.
3869 ABORT: go to Vent first, then after 5s automatically go to Abort.
3870 EMERGENCY ABORT: go to Abort immediately, no matter what state or mode (including DEBUG).
3871 """
3872 print(f"{abort_type} TRIGGERED")
3873 parent = self.parent()
3874 if abort_type == "EMERGENCY ABORT":
3875 # Always go to Abort immediately, regardless of state/mode/DEBUG
3876 self._apply_abort_state()
3877 if parent and hasattr(parent, "log_event"):
3878 parent.log_event("Abort", f"{abort_type} button pressed")
3879 return
3880 if abort_type == "ABORT":
3881 # Go to Vent first; after 5s _on_abort_vent_5s_elapsed will transition to Abort
3884 self.current_state = "Vent"
3887 self.update_button_states("Vent")
3888 self.mode_changed.emit("Vent")
3890 if parent and hasattr(parent, "log_event"):
3891 parent.log_event("Abort", f"{abort_type} button pressed")
3892
3893 def toggle_view(self):
3894 text = self.nav_btn.text()
3895 if text == "SETTINGS":
3896 self.navigation_requested.emit("settings")
3897 self.nav_btn.setText("DASHBOARD")
3898 else:
3899 self.navigation_requested.emit("dashboard")
3900 self.nav_btn.setText("SETTINGS")
3901
3902
3903# ---------------------- Settings Widget ----------------------
3904class SettingsWidget(QtWidgets.QWidget):
3905 """
3906 Centralized settings configuration widget.
3907 """
3908
3909 mapping_changed = QtCore.pyqtSignal(str, int) # gauge_name (GN2/ETH/LOX), pt_id
3910
3911 def __init__(self, sensor_widget, actuator_widget, parent=None):
3912 super().__init__(parent)
3913 self.sensor_widget = sensor_widget
3914 self.actuator_widget = actuator_widget
3915
3916 # Default mappings
3917 self.gauge_mappings = {"GN2": 0, "ETH": 0, "LOX": 0}
3918
3919 self.init_ui()
3920 self.load_values()
3921
3922 def init_ui(self):
3923 main_layout = QtWidgets.QHBoxLayout(self)
3924
3925 # Left Column: Sensor & General Settings
3926 left_col_widget = QtWidgets.QWidget()
3927 left_col = QtWidgets.QVBoxLayout(left_col_widget)
3928
3929 # ... Sensor Settings ...
3930 sensor_group = QtWidgets.QGroupBox("Sensor & General View Settings")
3931 sensor_layout = QtWidgets.QFormLayout()
3932
3933 self.demo_chk = QtWidgets.QCheckBox("Demo Mode")
3934 self.demo_chk.toggled.connect(self.sensor_widget.on_demo_mode_toggled)
3935 sensor_layout.addRow(self.demo_chk)
3936
3937 self.sim_pressure_chk = QtWidgets.QCheckBox("Simulate Chamber Pressure")
3938 self.sim_pressure_chk.setToolTip(
3939 "If enabled: In FIRE state, transitions to ARMED if LOX or Fuel Upstream < 350 psi"
3940 )
3942 sensor_layout.addRow(self.sim_pressure_chk)
3943
3944 self.time_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
3945 self.time_slider.setRange(1, 60)
3946 self.time_lbl = QtWidgets.QLabel("10s")
3947 self.time_slider.valueChanged.connect(self.on_time_changed)
3948 sensor_layout.addRow("Time Window:", self.time_slider)
3949 sensor_layout.addRow("", self.time_lbl)
3950
3951 self.y_min_spin = QtWidgets.QDoubleSpinBox()
3952 self.y_min_spin.setRange(-1000, 10000)
3953 self.y_min_spin.valueChanged.connect(self.on_y_min_changed)
3954 sensor_layout.addRow("Y Min:", self.y_min_spin)
3955
3956 self.y_max_spin = QtWidgets.QDoubleSpinBox()
3957 self.y_max_spin.setRange(-1000, 10000)
3958 self.y_max_spin.valueChanged.connect(self.on_y_max_changed)
3959 sensor_layout.addRow("Y Max:", self.y_max_spin)
3960
3961 self.ip_filter = QtWidgets.QLineEdit()
3962 self.ip_filter.textChanged.connect(self.on_ip_filter_changed)
3963 sensor_layout.addRow("Sensor IP Filter:", self.ip_filter)
3964
3965 sensor_group.setLayout(sensor_layout)
3966 left_col.addWidget(sensor_group)
3967
3968 # Moving average (graph and display)
3969 ma_group = QtWidgets.QGroupBox("Moving Average")
3970 ma_layout = QtWidgets.QFormLayout()
3971 self.graph_ma_spin = QtWidgets.QSpinBox()
3972 self.graph_ma_spin.setRange(1, 100)
3973 self.graph_ma_spin.setSuffix(" samples")
3974 self.graph_ma_spin.valueChanged.connect(self.on_graph_ma_changed)
3975 ma_layout.addRow("Graph:", self.graph_ma_spin)
3976 self.display_ma_spin = QtWidgets.QSpinBox()
3977 self.display_ma_spin.setRange(1, 100)
3978 self.display_ma_spin.setSuffix(" samples")
3979 self.display_ma_spin.valueChanged.connect(self.on_display_ma_changed)
3980 ma_layout.addRow("Display:", self.display_ma_spin)
3981 ma_group.setLayout(ma_layout)
3982 left_col.addWidget(ma_group)
3983
3984 # Plot visibility: which sensors to show on the graph (horizontal row) + Debug button
3985 visibility_group = QtWidgets.QGroupBox("Plot visibility & Debug")
3986 visibility_layout = QtWidgets.QVBoxLayout()
3987 title_lbl = QtWidgets.QLabel("Sensors to show on plot")
3988 title_lbl.setStyleSheet("font-weight: bold;")
3989 visibility_layout.addWidget(title_lbl)
3990 # Row of number labels, then row of checkboxes (horizontal)
3991 numbers_row = QtWidgets.QHBoxLayout()
3992 numbers_row.setSpacing(4)
3993 checkboxes_row = QtWidgets.QHBoxLayout()
3994 checkboxes_row.setSpacing(4)
3995 small_font = QtGui.QFont()
3996 small_font.setPointSize(8)
3997 self.plot_visibility_cbs: Dict[int, QtWidgets.QCheckBox] = {}
3998 for i in range(1, NUM_CONNECTORS + 1):
3999 num_lbl = QtWidgets.QLabel(str(i))
4000 num_lbl.setFont(small_font)
4001 num_lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
4002 num_lbl.setFixedWidth(24)
4003 numbers_row.addWidget(num_lbl)
4004 cb = QtWidgets.QCheckBox()
4005 cb.setChecked(True)
4006 cb.stateChanged.connect(
4007 lambda s, cid=i: self.sensor_widget._on_plot_toggle(cid, s)
4008 )
4009 cb.setFixedWidth(24)
4010 checkboxes_row.addWidget(cb)
4011 self.plot_visibility_cbs[i] = cb
4012 visibility_layout.addLayout(numbers_row)
4013 visibility_layout.addLayout(checkboxes_row)
4014 self.only_show_pt_with_roles_chk = QtWidgets.QCheckBox(
4015 "Only show PTs with roles"
4016 )
4017 self.only_show_pt_with_roles_chk.toggled.connect(
4019 )
4020 visibility_layout.addWidget(self.only_show_pt_with_roles_chk)
4021 self.debug_btn = QtWidgets.QPushButton("Open debug panel…")
4022 self.debug_btn.clicked.connect(self.sensor_widget.open_debug_menu)
4023 visibility_layout.addWidget(self.debug_btn)
4024
4025 # PT Zeroing buttons
4026 zero_row = QtWidgets.QHBoxLayout()
4027 self.zero_pts_btn = QtWidgets.QPushButton("0 PTs")
4028 self.zero_pts_btn.setToolTip(
4029 "Set current PSI readings as zero reference (tare all PTs)"
4030 )
4031 self.zero_pts_btn.setStyleSheet(
4032 "QPushButton { background-color: #e67e22; color: white; font-weight: bold; padding: 4px 8px; border-radius: 4px; }"
4033 "QPushButton:hover { background-color: #d35400; }"
4034 )
4035 self.zero_pts_btn.clicked.connect(self.on_zero_pts)
4036 self.clear_zeros_btn = QtWidgets.QPushButton("Clear Zeros")
4037 self.clear_zeros_btn.setToolTip(
4038 "Remove zero offset — restore raw calibration values"
4039 )
4040 self.clear_zeros_btn.setStyleSheet(
4041 "QPushButton { background-color: #555; color: white; padding: 4px 8px; border-radius: 4px; }"
4042 "QPushButton:hover { background-color: #333; }"
4043 )
4044 self.clear_zeros_btn.clicked.connect(self.on_clear_pt_zeros)
4045 zero_row.addWidget(self.zero_pts_btn)
4046 zero_row.addWidget(self.clear_zeros_btn)
4047 visibility_layout.addLayout(zero_row)
4048
4049 visibility_group.setLayout(visibility_layout)
4050
4051 left_col.addWidget(visibility_group)
4052
4053 # ADC Settings
4054 adc_group = QtWidgets.QGroupBox("ADC Configuration")
4055 adc_layout = QtWidgets.QFormLayout()
4056 self.adc_bits_spin = QtWidgets.QSpinBox()
4057 self.adc_bits_spin.setRange(8, 32)
4058 self.adc_bits_spin.valueChanged.connect(self.on_adc_bits_changed)
4059 adc_layout.addRow("ADC Bits:", self.adc_bits_spin)
4060
4061 self.ref_volt_spin = QtWidgets.QDoubleSpinBox()
4062 self.ref_volt_spin.setRange(0.1, 10.0)
4063 self.ref_volt_spin.setValue(2.5)
4064 self.ref_volt_spin.valueChanged.connect(self.on_ref_volt_changed)
4065 adc_layout.addRow("Ref Voltage (V):", self.ref_volt_spin)
4066 adc_group.setLayout(adc_layout)
4067 left_col.addWidget(adc_group)
4068
4069 # Reset button — clears all stored sensor data, history, and event log
4070 reset_btn = QtWidgets.QPushButton("⟳ Reset All Data")
4071 reset_btn.setStyleSheet(
4072 "QPushButton { background-color: #8B0000; color: white; font-weight: bold; padding: 8px; border-radius: 4px; }"
4073 "QPushButton:hover { background-color: #A00000; }"
4074 )
4075 reset_btn.setToolTip("Clear all plot data, history (CSV buffer), and event log")
4076 reset_btn.clicked.connect(self._on_reset_all_data)
4077 left_col.addWidget(reset_btn)
4078
4079 left_col.addStretch()
4080
4081 # Wrap left column in scroll area
4082 left_scroll = QtWidgets.QScrollArea()
4083 left_scroll.setWidget(left_col_widget)
4084 left_scroll.setWidgetResizable(True)
4085 main_layout.addWidget(left_scroll, 1)
4086
4087 # Middle Column: Actuator Configuration
4088 mid_col_widget = QtWidgets.QWidget()
4089 mid_col = QtWidgets.QVBoxLayout(mid_col_widget)
4090 act_group = QtWidgets.QGroupBox("Actuator Configuration")
4091 act_layout = QtWidgets.QVBoxLayout()
4092
4093 form = QtWidgets.QFormLayout()
4094 self.act_ip = QtWidgets.QLineEdit()
4095 self.act_ip.textChanged.connect(self.on_act_ip_changed)
4096 form.addRow("Device IP:", self.act_ip)
4097
4098 self.act_port = QtWidgets.QSpinBox()
4099 self.act_port.setRange(1, 65535)
4100 self.act_port.valueChanged.connect(self.on_act_port_changed)
4101 form.addRow("Device Port:", self.act_port)
4102 act_layout.addLayout(form)
4103
4104 self.only_show_actuators_with_roles_chk = QtWidgets.QCheckBox(
4105 "Only show actuators with roles"
4106 )
4107 self.only_show_actuators_with_roles_chk.toggled.connect(
4109 )
4110 act_layout.addWidget(self.only_show_actuators_with_roles_chk)
4111
4112 act_layout.addWidget(QtWidgets.QLabel("Actuator role → slot:"))
4113 self.act_scroll = QtWidgets.QScrollArea()
4114 self.act_scroll_widget = QtWidgets.QWidget()
4115 self.act_form = QtWidgets.QFormLayout(self.act_scroll_widget)
4116 self.act_inputs = {} # role_name -> QComboBox (actuator number 0=None, 1-10)
4117 for role_name in CONFIG.get_actuator_role_names():
4118 combo = QtWidgets.QComboBox()
4119 combo.addItem("—", 0)
4120 for act_num in range(1, get_num_actuators() + 1):
4121 combo.addItem(str(act_num), act_num)
4122 combo.currentIndexChanged.connect(
4123 lambda idx, rn=role_name, c=combo: self.on_actuator_role_changed(rn, c)
4124 )
4125 self.act_form.addRow(role_name + ":", combo)
4126 self.act_inputs[role_name] = combo
4127 self.act_scroll.setWidget(self.act_scroll_widget)
4128 self.act_scroll.setWidgetResizable(True)
4129 act_layout.addWidget(self.act_scroll)
4130
4131 act_group.setLayout(act_layout)
4132 mid_col.addWidget(act_group)
4133 mid_col.addStretch()
4134
4135 # Wrap middle column in scroll area
4136 mid_scroll = QtWidgets.QScrollArea()
4137 mid_scroll.setWidget(mid_col_widget)
4138 mid_scroll.setWidgetResizable(True)
4139 main_layout.addWidget(mid_scroll, 1)
4140
4141 # Right Column: PT Configuration & Gauge Mapping
4142 right_col_widget = QtWidgets.QWidget()
4143 right_col = QtWidgets.QVBoxLayout(right_col_widget)
4144 pt_group = QtWidgets.QGroupBox("Pressure Transducers (PT) & Mapping")
4145 pt_layout = QtWidgets.QVBoxLayout()
4146
4147 # PT calibration CSV paths (from config; empty = use built-in default)
4148 pt_csv_group = QtWidgets.QGroupBox("PT Calibration CSVs")
4149 pt_csv_layout = QtWidgets.QVBoxLayout()
4150
4151 self.pt_calibration_csv_list = QtWidgets.QListWidget()
4152 self.pt_calibration_csv_list.setMaximumHeight(120)
4153 pt_csv_layout.addWidget(self.pt_calibration_csv_list)
4154
4155 pt_csv_buttons = QtWidgets.QHBoxLayout()
4156 pt_csv_add = QtWidgets.QPushButton("Add CSV…")
4157 pt_csv_add.clicked.connect(self.on_pt_calibration_csv_add)
4158 pt_csv_remove = QtWidgets.QPushButton("Remove Selected")
4159 pt_csv_remove.clicked.connect(self.on_pt_calibration_csv_remove)
4160 pt_csv_buttons.addWidget(pt_csv_add)
4161 pt_csv_buttons.addWidget(pt_csv_remove)
4162 pt_csv_buttons.addStretch()
4163 pt_csv_layout.addLayout(pt_csv_buttons)
4164
4165 self.pt_calibration_error_label = QtWidgets.QLabel()
4166 self.pt_calibration_error_label.setStyleSheet("color: red;")
4167 self.pt_calibration_error_label.setWordWrap(True)
4168 self.pt_calibration_error_label.setVisible(False)
4169 pt_csv_layout.addWidget(self.pt_calibration_error_label)
4170
4171 pt_csv_group.setLayout(pt_csv_layout)
4172 pt_layout.addWidget(pt_csv_group)
4173
4174 # Gauge Mapping (Moved to top as requested)
4175 mapping_group = QtWidgets.QGroupBox("Top Bar Gauge Mapping")
4176 mapping_form = QtWidgets.QFormLayout()
4177
4178 self.combo_gn2 = QtWidgets.QComboBox()
4179 self.combo_eth = QtWidgets.QComboBox()
4180 self.combo_lox = QtWidgets.QComboBox()
4181
4182 self.combos = {
4183 "GN2": self.combo_gn2,
4184 "ETH": self.combo_eth,
4185 "LOX": self.combo_lox,
4186 }
4187
4188 for name, combo in self.combos.items():
4189 combo.addItem("None", 0)
4190 for pt_id in sorted(self.sensor_widget.pt_calibration.keys()):
4191 combo.addItem(f"PT {pt_id}", pt_id)
4192 # Reverted to currentIndexChanged now that we block signals in load_values
4193 combo.currentIndexChanged.connect(
4194 lambda idx, n=name, c=combo: self.on_mapping_changed(n, c)
4195 )
4196 mapping_form.addRow(f"{name} Source:", combo)
4197
4198 mapping_group.setLayout(mapping_form)
4199 pt_layout.addWidget(mapping_group)
4200
4201 # State transition thresholds (auto transitions: POP = over → vent, THRESH = under → press)
4202 thresh_group = QtWidgets.QGroupBox("State Transition Thresholds (psi)")
4203 thresh_form = QtWidgets.QFormLayout()
4204 self.gn2_pop_spin = QtWidgets.QDoubleSpinBox()
4205 self.gn2_pop_spin.setRange(0, 10000)
4206 self.gn2_pop_spin.setDecimals(1)
4207 self.gn2_pop_spin.setSuffix(" psi")
4208 self.gn2_pop_spin.valueChanged.connect(
4209 lambda v: self._on_threshold_changed("gn2_pop_psi", v)
4210 )
4211 thresh_form.addRow("GN2 POP (→ GN2 Vent):", self.gn2_pop_spin)
4212 self.fuel_pop_spin = QtWidgets.QDoubleSpinBox()
4213 self.fuel_pop_spin.setRange(0, 10000)
4214 self.fuel_pop_spin.setDecimals(1)
4215 self.fuel_pop_spin.setSuffix(" psi")
4216 self.fuel_pop_spin.valueChanged.connect(
4217 lambda v: self._on_threshold_changed("fuel_pop_psi", v)
4218 )
4219 thresh_form.addRow("Fuel POP (→ Fuel Vent):", self.fuel_pop_spin)
4220 self.ox_pop_spin = QtWidgets.QDoubleSpinBox()
4221 self.ox_pop_spin.setRange(0, 10000)
4222 self.ox_pop_spin.setDecimals(1)
4223 self.ox_pop_spin.setSuffix(" psi")
4224 self.ox_pop_spin.valueChanged.connect(
4225 lambda v: self._on_threshold_changed("ox_pop_psi", v)
4226 )
4227 thresh_form.addRow("Ox POP (→ Ox Vent):", self.ox_pop_spin)
4228 self.gn2_thresh_spin = QtWidgets.QDoubleSpinBox()
4229 self.gn2_thresh_spin.setRange(0, 10000)
4230 self.gn2_thresh_spin.setDecimals(1)
4231 self.gn2_thresh_spin.setSuffix(" psi")
4232 self.gn2_thresh_spin.valueChanged.connect(
4233 lambda v: self._on_threshold_changed("gn2_thresh_psi", v)
4234 )
4235 thresh_form.addRow("GN2 THRESH (→ GN2 Press):", self.gn2_thresh_spin)
4236 self.fuel_thresh_spin = QtWidgets.QDoubleSpinBox()
4237 self.fuel_thresh_spin.setRange(0, 10000)
4238 self.fuel_thresh_spin.setDecimals(1)
4239 self.fuel_thresh_spin.setSuffix(" psi")
4240 self.fuel_thresh_spin.valueChanged.connect(
4241 lambda v: self._on_threshold_changed("fuel_thresh_psi", v)
4242 )
4243 thresh_form.addRow("Fuel THRESH (→ Fuel Press):", self.fuel_thresh_spin)
4244 self.ox_thresh_spin = QtWidgets.QDoubleSpinBox()
4245 self.ox_thresh_spin.setRange(0, 10000)
4246 self.ox_thresh_spin.setDecimals(1)
4247 self.ox_thresh_spin.setSuffix(" psi")
4248 self.ox_thresh_spin.valueChanged.connect(
4249 lambda v: self._on_threshold_changed("ox_thresh_psi", v)
4250 )
4251 thresh_form.addRow("Ox THRESH (→ Ox Press):", self.ox_thresh_spin)
4252 thresh_group.setLayout(thresh_form)
4253 pt_layout.addWidget(thresh_group)
4254
4255 # PT role → connector (same pattern as actuators)
4256 pt_layout.addWidget(QtWidgets.QLabel("PT role → connector:"))
4257 self.pt_form_widget = QtWidgets.QWidget()
4258 self.pt_form = QtWidgets.QFormLayout(self.pt_form_widget)
4259 self.pt_inputs = {} # role_name -> QComboBox (pt_id 0=None, else PT 1-10)
4260 for role_name in CONFIG.get_sensor_role_names():
4261 combo = QtWidgets.QComboBox()
4262 combo.addItem("—", 0)
4263 for pt_id in range(1, NUM_CONNECTORS + 1):
4264 combo.addItem(f"PT {pt_id}", pt_id)
4265 combo.currentIndexChanged.connect(
4266 lambda idx, rn=role_name, c=combo: self.on_pt_role_changed(rn, c)
4267 )
4268 self.pt_form.addRow(role_name + ":", combo)
4269 self.pt_inputs[role_name] = combo
4270 pt_layout.addWidget(self.pt_form_widget)
4271
4272 pt_group.setLayout(pt_layout)
4273 right_col.addWidget(pt_group)
4274 right_col.addStretch()
4275
4276 # Wrap right column in scroll area
4277 right_scroll = QtWidgets.QScrollArea()
4278 right_scroll.setWidget(right_col_widget)
4279 right_scroll.setWidgetResizable(True)
4280 main_layout.addWidget(right_scroll, 1)
4281
4282 def load_values(self):
4283 # Sensor
4284 with QtCore.QSignalBlocker(self.demo_chk):
4285 self.demo_chk.setChecked(self.sensor_widget.demo_mode)
4286
4287 with QtCore.QSignalBlocker(self.sim_pressure_chk):
4288 self.sim_pressure_chk.setChecked(
4289 CONFIG.config.get("simulate_chamber_pressure", False)
4290 )
4291
4292 with QtCore.QSignalBlocker(self.time_slider):
4293 self.time_slider.setValue(int(self.sensor_widget.window_seconds))
4294
4295 with QtCore.QSignalBlocker(self.y_min_spin):
4296 self.y_min_spin.setValue(self.sensor_widget.y_axis_min)
4297
4298 with QtCore.QSignalBlocker(self.y_max_spin):
4299 self.y_max_spin.setValue(self.sensor_widget.y_axis_max)
4300
4301 with QtCore.QSignalBlocker(self.ip_filter):
4302 self.ip_filter.setText(self.sensor_widget.filter_source_ip)
4303
4304 # Load PT calibration CSV paths (support both list and single string for backward compatibility)
4305 p = (CONFIG.config.get("paths") or {}).get("pt_calibration_csv", "")
4306 csv_paths = []
4307 if isinstance(p, list):
4308 csv_paths = [str(path).strip() for path in p if path and str(path).strip()]
4309 elif isinstance(p, str) and p.strip():
4310 csv_paths = [p.strip()]
4311
4312 with QtCore.QSignalBlocker(self.pt_calibration_csv_list):
4313 self.pt_calibration_csv_list.clear()
4314 for path in csv_paths:
4315 self.pt_calibration_csv_list.addItem(path)
4316
4317 # Update error label if there's an error
4318 if (
4319 hasattr(self.sensor_widget, "pt_calibration_error")
4320 and self.sensor_widget.pt_calibration_error
4321 ):
4322 self.pt_calibration_error_label.setText(
4323 self.sensor_widget.pt_calibration_error
4324 )
4325 self.pt_calibration_error_label.setVisible(True)
4326 else:
4327 self.pt_calibration_error_label.setVisible(False)
4328
4329 with QtCore.QSignalBlocker(self.only_show_pt_with_roles_chk):
4330 self.only_show_pt_with_roles_chk.setChecked(
4331 self.sensor_widget.only_show_pt_with_roles
4332 )
4333
4334 with QtCore.QSignalBlocker(self.adc_bits_spin):
4335 self.adc_bits_spin.setValue(self.sensor_widget.adc_bits)
4336
4337 with QtCore.QSignalBlocker(self.ref_volt_spin):
4338 self.ref_volt_spin.setValue(self.sensor_widget.reference_voltage)
4339
4340 with QtCore.QSignalBlocker(self.graph_ma_spin):
4341 self.graph_ma_spin.setValue(self.sensor_widget.graph_moving_avg_samples)
4342 with QtCore.QSignalBlocker(self.display_ma_spin):
4343 self.display_ma_spin.setValue(self.sensor_widget.display_moving_avg_samples)
4344
4345 for i in range(1, NUM_CONNECTORS + 1):
4346 if i in self.plot_visibility_cbs:
4347 with QtCore.QSignalBlocker(self.plot_visibility_cbs[i]):
4348 self.plot_visibility_cbs[i].setChecked(
4349 self.sensor_widget.plot_enabled.get(i, True)
4350 )
4351
4352 # Actuator
4353 with QtCore.QSignalBlocker(self.act_ip):
4354 self.act_ip.setText(self.actuator_widget.device_ip)
4355
4356 with QtCore.QSignalBlocker(self.act_port):
4357 self.act_port.setValue(self.actuator_widget.device_port)
4358
4359 with QtCore.QSignalBlocker(self.only_show_actuators_with_roles_chk):
4360 self.only_show_actuators_with_roles_chk.setChecked(
4361 self.actuator_widget.only_show_actuators_with_roles
4362 )
4363
4364 # Actuator roles: load from config (role → actuator id)
4365 for role_name, combo in self.act_inputs.items():
4366 with QtCore.QSignalBlocker(combo):
4367 actuator_id = CONFIG.get_actuator_role(role_name)
4368 idx = combo.findData(actuator_id)
4369 if idx >= 0:
4370 combo.setCurrentIndex(idx)
4371 else:
4372 combo.setCurrentIndex(0)
4373
4374 # PT roles: load from config (role → connector id)
4375 for role_name, combo in self.pt_inputs.items():
4376 with QtCore.QSignalBlocker(combo):
4377 pt_id = CONFIG.get_sensor_role(role_name)
4378 idx = combo.findData(pt_id)
4379 if idx >= 0:
4380 combo.setCurrentIndex(idx)
4381 else:
4382 combo.setCurrentIndex(0)
4383
4384 # Mappings (refresh list from current pt_calibration then restore selection)
4386
4387 # Pressure limits (THRESH and POP from pressure_limits)
4388 pl = CONFIG.config.get("pressure_limits", {})
4389 key_to_fluid_limit = [
4390 ("gn2_pop_psi", "GN2", "POP"),
4391 ("fuel_pop_psi", "ETH", "POP"),
4392 ("ox_pop_psi", "LOX", "POP"),
4393 ("gn2_thresh_psi", "GN2", "THRESH"),
4394 ("fuel_thresh_psi", "ETH", "THRESH"),
4395 ("ox_thresh_psi", "LOX", "THRESH"),
4396 ]
4397 for key, fluid, limit in key_to_fluid_limit:
4398 spin = getattr(self, key.replace("_psi", "_spin"))
4399 default = 100.0 if limit == "POP" else 10.0
4400 val = pl.get(fluid, {}).get(limit, default)
4401 with QtCore.QSignalBlocker(spin):
4402 spin.setValue(float(val))
4403
4405 """Repopulate GN2/ETH/LOX source combos from current pt_calibration and restore selection from config."""
4406 for name, combo in self.combos.items():
4407 with QtCore.QSignalBlocker(combo):
4408 combo.clear()
4409 combo.addItem("None", 0)
4410 for pt_id in sorted(self.sensor_widget.pt_calibration.keys()):
4411 combo.addItem(f"PT {pt_id}", pt_id)
4412 pt_id = CONFIG.config["mappings"].get(name, 0)
4413 idx = combo.findData(pt_id)
4414 combo.setCurrentIndex(idx if idx >= 0 else 0)
4415
4417 """Update config with current list of CSV paths."""
4418 paths = []
4419 for i in range(self.pt_calibration_csv_list.count()):
4420 item = self.pt_calibration_csv_list.item(i)
4421 if item and item.text().strip():
4422 paths.append(item.text().strip())
4423 CONFIG.config.setdefault("paths", {})["pt_calibration_csv"] = (
4424 paths if paths else []
4425 )
4426 CONFIG.save()
4427 self.sensor_widget.reload_pt_calibration()
4429
4430 # Update error label
4431 if (
4432 hasattr(self.sensor_widget, "pt_calibration_error")
4433 and self.sensor_widget.pt_calibration_error
4434 ):
4435 self.pt_calibration_error_label.setText(
4436 self.sensor_widget.pt_calibration_error
4437 )
4438 self.pt_calibration_error_label.setVisible(True)
4439 else:
4440 self.pt_calibration_error_label.setVisible(False)
4441
4443 """Add a new CSV file to the list."""
4444 # Get last directory from list or use home directory
4445 last_path = ""
4446 if self.pt_calibration_csv_list.count() > 0:
4447 last_item = self.pt_calibration_csv_list.item(
4449 )
4450 if last_item:
4451 last_path = (
4452 os.path.dirname(last_item.text())
4453 if os.path.dirname(last_item.text())
4454 else os.path.expanduser("~")
4455 )
4456 else:
4457 last_path = os.path.expanduser("~")
4458
4459 path, _ = QtWidgets.QFileDialog.getOpenFileName(
4460 self,
4461 "Select PT calibration CSV",
4462 last_path,
4463 "CSV files (*.csv);;All files (*)",
4464 )
4465 if path:
4466 self.pt_calibration_csv_list.addItem(path)
4468
4470 """Remove selected CSV file from the list."""
4471 current_item = self.pt_calibration_csv_list.currentItem()
4472 if current_item:
4473 row = self.pt_calibration_csv_list.row(current_item)
4474 self.pt_calibration_csv_list.takeItem(row)
4476
4477 def _on_threshold_changed(self, key: str, value: float):
4478 key_to_fluid_limit = {
4479 "gn2_pop_psi": ("GN2", "POP"),
4480 "fuel_pop_psi": ("ETH", "POP"),
4481 "ox_pop_psi": ("LOX", "POP"),
4482 "gn2_thresh_psi": ("GN2", "THRESH"),
4483 "fuel_thresh_psi": ("ETH", "THRESH"),
4484 "ox_thresh_psi": ("LOX", "THRESH"),
4485 }
4486 fluid, limit = key_to_fluid_limit.get(key, (None, None))
4487 if fluid is not None:
4488 CONFIG.config.setdefault("pressure_limits", {}).setdefault(fluid, {})[
4489 limit
4490 ] = value
4491 CONFIG.save()
4492
4493 def on_sim_pressure_toggled(self, checked):
4494 CONFIG.config["simulate_chamber_pressure"] = checked
4495 CONFIG.save()
4496 # Direct update to main window state (via parent linkage or signal)
4497 if hasattr(self.parent(), "parent") and hasattr(
4498 self.parent().parent(), "update_sim_pressure_state"
4499 ):
4500 # stack -> central_widget -> CombinedMainWindow
4501 # But self.parent() is the QStackedWidget. self.parent().parent() is usually central widget or window.
4502 # Easier: SettingsWidget constructed with main_window ref? No.
4503 # We can use the fact that CombinedMainWindow holds a ref to settings_widget.
4504 # Better pattern: SettingsWidget emits signal, connected in MainWindow.
4505 pass
4506 # Actually, let's just use CONFIG in main window loop or add a specific update method call if we can resolve main window.
4507 # CombinedMainWindow passes 'self' to settings widget constructor?
4508 # __init__(self, sensor_widget, actuator_widget, parent=None) -> parent is stack.
4509 # Let's rely on config being source of truth for the loop check, or add a callback.
4510 # For immediate update, we can assume the loop checks CONFIG or a member variable updated by timer/loop.
4511 # The loop _check_auto_state_transitions runs on timer, so reading CONFIG.config there is fine/safe enough
4512 # or we update a variable in CombinedMainWindow if we really want to.
4513 # Let's update the Config object which is shared.
4514 pass
4515
4517 """Clear all stored sensor data, plot buffers, CSV history, and event log."""
4518 reply = QtWidgets.QMessageBox.question(
4519 self,
4520 "Reset All Data",
4521 "This will clear all plot data, CSV history, and the event log.\n\nAre you sure?",
4522 QtWidgets.QMessageBox.StandardButton.Yes
4523 | QtWidgets.QMessageBox.StandardButton.No,
4524 QtWidgets.QMessageBox.StandardButton.No,
4525 )
4526 if reply != QtWidgets.QMessageBox.StandardButton.Yes:
4527 return
4528
4529 # Clear sensor data
4530 sw = self.sensor_widget
4531 for k in list(sw.sensor_data.keys()):
4532 sw.sensor_data[k] = deque(maxlen=MAX_POINTS)
4533 sw.sensor_adc_codes[k] = deque(maxlen=MAX_POINTS)
4534 for k in list(sw.sensor_psi_plot_t.keys()):
4535 sw.sensor_psi_plot_t[k] = deque(maxlen=sw.plot_buffer_size)
4536 sw.sensor_psi_plot_v[k] = deque(maxlen=sw.plot_buffer_size)
4537 for k in list(sw.sensor_psi_history.keys()):
4538 sw.sensor_psi_history[k] = []
4539
4540 # Reset sample counters and timing
4541 sw.total_samples_received = 0
4542 sw.last_sps_sample_count = 0
4543 sw.last_sps_time = time.time()
4544 sw.stats_start_time = time.time()
4545
4546 # Clear event log on the main window
4547 main_win = (
4548 sw.main_window_ref
4549 if hasattr(sw, "main_window_ref") and sw.main_window_ref
4550 else None
4551 )
4552 if main_win and hasattr(main_win, "event_log"):
4553 main_win.event_log.clear()
4554
4555 # Clear plots visually
4556 for plot in sw.sensor_plots.values():
4557 plot.setData([], [])
4558
4559 sw.update_statistics()
4560 print("All data reset.")
4561
4562 def on_time_changed(self, val):
4563 self.time_lbl.setText(f"{val}s")
4564 self.sensor_widget.window_seconds = float(val)
4565 self.sensor_widget.update_buffer_size() # Update buffer size dynamically
4566 CONFIG.config["display"]["window_seconds"] = float(val)
4567 CONFIG.save()
4568
4569 def on_y_min_changed(self, val):
4570 self.sensor_widget.y_axis_min = val
4571 CONFIG.config["display"]["y_axis_min"] = val
4572 CONFIG.save()
4573
4574 def on_y_max_changed(self, val):
4575 self.sensor_widget.y_axis_max = val
4576 CONFIG.config["display"]["y_axis_max"] = val
4577 CONFIG.save()
4578
4579 def on_ip_filter_changed(self, text):
4580 self.sensor_widget.filter_source_ip = text
4581 CONFIG.config["network"]["sensor_ip_filter"] = text
4582 CONFIG.save()
4583
4585 self.sensor_widget.only_show_pt_with_roles = checked
4586 CONFIG.config["display"]["only_show_pt_with_roles"] = checked
4587 CONFIG.save()
4588 # Trigger immediate update of plots and under-graph display
4589 self.sensor_widget.update_plots()
4590 self.sensor_widget.update_statistics()
4591
4592 def on_zero_pts(self):
4593 """Capture current PSI readings as zero offsets for all PTs."""
4594 self.sensor_widget.zero_pts()
4595
4597 """Clear all PT zero offsets."""
4598 self.sensor_widget.clear_pt_zeros()
4599
4600 def on_adc_bits_changed(self, val):
4601 self.sensor_widget.adc_bits = val
4602 CONFIG.config["display"]["adc_bits"] = val
4603 CONFIG.save()
4604
4605 def on_ref_volt_changed(self, val):
4606 self.sensor_widget.reference_voltage = val
4607 CONFIG.config["display"]["ref_voltage"] = val
4608 CONFIG.save()
4609
4610 def on_graph_ma_changed(self, val):
4611 self.sensor_widget.on_graph_moving_avg_changed(val)
4612 CONFIG.config["display"]["graph_ma_samples"] = val
4613 CONFIG.save()
4614
4616 self.sensor_widget.on_display_moving_avg_changed(val)
4617 CONFIG.config["display"]["display_ma_samples"] = val
4618 CONFIG.save()
4619
4620 def on_act_ip_changed(self, text):
4621 self.actuator_widget.device_ip = text
4622 CONFIG.config["network"]["actuator_ip"] = text
4623 CONFIG.save()
4624
4625 def on_act_port_changed(self, val):
4626 self.actuator_widget.device_port = val
4627 CONFIG.config["network"]["actuator_port"] = val
4628 CONFIG.save()
4629
4631 self.actuator_widget.only_show_actuators_with_roles = checked
4632 CONFIG.config["display"]["only_show_actuators_with_roles"] = checked
4633 CONFIG.save()
4634 # Trigger immediate update of actuator visibility
4635 self.actuator_widget.update_actuator_visibility()
4636
4637 def on_actuator_role_changed(self, role_name, combo):
4638 actuator_id = combo.currentData()
4639 if actuator_id is None:
4640 actuator_id = 0
4641 CONFIG.set_actuator_role(role_name, actuator_id)
4642 # Refresh widget labels from config
4643 for aid in range(1, get_num_actuators() + 1):
4644 label = CONFIG.get_actuator_label(aid)
4645 self.actuator_widget.actuator_labels[aid] = label
4646 self.actuator_widget.update_label_display(aid, label)
4647 # Update visibility if filtering is enabled
4648 if self.actuator_widget.only_show_actuators_with_roles:
4649 self.actuator_widget.update_actuator_visibility()
4650
4651 def on_pt_role_changed(self, role_name, combo):
4652 pt_id = combo.currentData()
4653 if pt_id is None:
4654 pt_id = 0
4655 CONFIG.set_sensor_role(role_name, pt_id)
4656 # Refresh widget labels from config
4657 for cid in range(1, NUM_CONNECTORS + 1):
4658 label = CONFIG.get_sensor_label(cid)
4659 self.sensor_widget.sensor_labels[cid] = label
4660 if cid in self.sensor_widget.sensor_plots:
4661 self.sensor_widget.update_plot_legend(cid)
4662
4663 def on_mapping_changed(self, gauge_name, combo):
4664 pt_id = combo.currentData()
4665 self.mapping_changed.emit(gauge_name, pt_id)
4666 CONFIG.config["mappings"][gauge_name] = pt_id
4667 CONFIG.save()
4668
4669
4670# ---------------------- Actuator Control Window (standalone) ----------------------
4671class ActuatorControlWindow(QtWidgets.QMainWindow):
4672 """Standalone window that embeds ActuatorControlWidget."""
4673
4675 self,
4676 receiver,
4677 device_ip: str = DEFAULT_ACTUATOR_IP,
4678 device_port: int = DEFAULT_DEVICE_PORT,
4679 ):
4680 super().__init__()
4681 self.setWindowTitle(f"Actuator Control - {device_ip}:{device_port}")
4682 self.setGeometry(1350, 100, 1000, 500)
4683 self.setCentralWidget(
4684 ActuatorControlWidget(receiver, device_ip, device_port, self)
4685 )
4686
4687 def closeEvent(self, event):
4688 """Handle window close event"""
4689 w = self.centralWidget()
4690 if w and hasattr(w, "save_labels"):
4691 w.save_labels()
4692 if w and hasattr(w, "close_socket"):
4693 w.close_socket()
4694 event.accept()
4695
4696
4697# ---------------------- Calibration Window (Separate) ----------------------
4698class CalibrationWindow(QtWidgets.QMainWindow):
4699 """Separate window for PT calibration - doesn't interfere with main GUI"""
4700
4701 def __init__(self, sensor_widget, parent=None):
4702 super().__init__(parent)
4703 self.sensor_widget = sensor_widget
4704 self.calibration_points: List[Dict] = []
4705 self.calibration_polynomials: Dict[int, Tuple[float, float, float, float]] = {}
4706 self.ref_pressure_inputs: Dict[int, QtWidgets.QDoubleSpinBox] = {}
4707
4708 # Phase 1 & Phase 2: Robust calibration frameworks for each sensor
4709 self.robust_frameworks: Dict[int, RobustCalibrationFramework] = {}
4710 self.phase2_active = False
4711 self.phase2_timer = None
4712 if ROBUST_CALIBRATION_AVAILABLE:
4713 for sensor_id in range(1, NUM_CONNECTORS + 1):
4715 sensor_id
4716 )
4717
4718 self.setWindowTitle("PT Sensor Calibration - Phase 1 & Phase 2")
4719 self.setGeometry(100, 100, 1100, 900)
4720
4721 # Zero-point calibration flag
4723
4724 central = QtWidgets.QWidget()
4725 self.setCentralWidget(central)
4726 self.init_ui(central)
4728
4729 # Prompt for zero-point calibration on startup
4730 QtCore.QTimer.singleShot(1000, self.prompt_zero_point_calibration)
4731
4732 def init_ui(self, parent):
4733 """Initialize the calibration UI with proper spacing"""
4734 main_layout = QtWidgets.QVBoxLayout(parent)
4735 main_layout.setContentsMargins(15, 15, 15, 15)
4736 main_layout.setSpacing(12)
4737
4738 # Instructions
4739 instructions = QtWidgets.QLabel(
4740 "1. Enter reference pressures (PSI) for sensors\n"
4741 "2. Click 'Record Point' to capture current ADC readings\n"
4742 "3. Repeat for multiple points (minimum 3)\n"
4743 "4. Click 'Fit Calibration' to calculate coefficients\n"
4744 "5. Click 'Save Calibration' to save"
4745 )
4746 instructions.setStyleSheet(
4747 "color: #555; font-size: 10pt; padding: 10px; background: #f0f0f0; border-radius: 5px;"
4748 )
4749 main_layout.addWidget(instructions)
4750
4751 # Control buttons
4752 control_frame = QtWidgets.QFrame()
4753 control_layout = QtWidgets.QHBoxLayout(control_frame)
4754 control_layout.setSpacing(10)
4755 control_layout.setContentsMargins(0, 0, 0, 0)
4756
4757 self.record_btn = QtWidgets.QPushButton("Record Point")
4758 self.record_btn.setMinimumHeight(35)
4759 self.record_btn.clicked.connect(self.record_calibration_point)
4760 control_layout.addWidget(self.record_btn)
4761
4762 self.fit_btn = QtWidgets.QPushButton("Fit Calibration")
4763 self.fit_btn.setEnabled(False)
4764 self.fit_btn.setMinimumHeight(35)
4765 self.fit_btn.clicked.connect(self.fit_calibration)
4766 control_layout.addWidget(self.fit_btn)
4767
4768 self.save_btn = QtWidgets.QPushButton("Save Calibration")
4769 self.save_btn.setEnabled(False)
4770 self.save_btn.setMinimumHeight(35)
4771 self.save_btn.clicked.connect(self.save_calibration)
4772 control_layout.addWidget(self.save_btn)
4773
4774 self.load_btn = QtWidgets.QPushButton("Load Calibration")
4775 self.load_btn.setMinimumHeight(35)
4776 self.load_btn.clicked.connect(self.load_calibration)
4777 control_layout.addWidget(self.load_btn)
4778
4779 # Phase 2 controls
4780 if ROBUST_CALIBRATION_AVAILABLE:
4781 self.phase2_btn = QtWidgets.QPushButton("Enable Phase 2 (Autonomous)")
4782 self.phase2_btn.setMinimumHeight(35)
4783 self.phase2_btn.setCheckable(True)
4784 self.phase2_btn.clicked.connect(self.toggle_phase2)
4785 control_layout.addWidget(self.phase2_btn)
4786
4787 main_layout.addWidget(control_frame)
4788
4789 # Status
4790 self.status_label = QtWidgets.QLabel("Ready - Waiting for sensor data...")
4791 self.status_label.setStyleSheet("font-size: 10pt; padding: 5px;")
4792 main_layout.addWidget(self.status_label)
4793
4794 # Sensor inputs with header
4795 sensor_group = QtWidgets.QGroupBox("Sensor Data")
4796 sensor_layout = QtWidgets.QVBoxLayout(sensor_group)
4797 sensor_layout.setSpacing(5)
4798
4799 # Header row
4800 header_frame = QtWidgets.QFrame()
4801 header_layout = QtWidgets.QHBoxLayout(header_frame)
4802 header_layout.setContentsMargins(10, 5, 10, 5)
4803 header_layout.setSpacing(10)
4804
4805 for header_text, width in [
4806 ("Sensor", 80),
4807 ("Current ADC", 150),
4808 ("Calibrated (PSI)", 150),
4809 ("Reference (PSI)", 200),
4810 ("Status", 200),
4811 ]:
4812 label = QtWidgets.QLabel(header_text)
4813 label.setStyleSheet("font-weight: bold; font-size: 10pt;")
4814 label.setMinimumWidth(width)
4815 header_layout.addWidget(label)
4816
4817 sensor_layout.addWidget(header_frame)
4818
4819 # Sensor rows (sensors 1-10, skipping inactive sensor_id 0 from packet)
4820 for sensor_id in range(1, NUM_CONNECTORS + 1):
4821 frame = QtWidgets.QFrame()
4822 frame.setFrameShape(QtWidgets.QFrame.Shape.Box)
4823 frame.setStyleSheet(
4824 "QFrame { border: 1px solid #ddd; border-radius: 3px; padding: 3px; }"
4825 )
4826 layout = QtWidgets.QHBoxLayout(frame)
4827 layout.setContentsMargins(10, 5, 10, 5)
4828 layout.setSpacing(10)
4829
4830 # Sensor label
4831 sensor_label = QtWidgets.QLabel(f"PT {sensor_id}")
4832 sensor_label.setMinimumWidth(80)
4833 sensor_label.setStyleSheet("font-weight: bold; font-size: 10pt;")
4834 layout.addWidget(sensor_label)
4835
4836 # ADC display
4837 adc_label = QtWidgets.QLabel("--")
4838 adc_label.setObjectName(f"adc_{sensor_id}")
4839 adc_label.setMinimumWidth(150)
4840 adc_label.setStyleSheet(
4841 "font-family: monospace; font-size: 10pt; color: #2c3e50;"
4842 )
4843 layout.addWidget(adc_label)
4844
4845 # Calibrated pressure display (real-time)
4846 calibrated_label = QtWidgets.QLabel("--")
4847 calibrated_label.setObjectName(f"calibrated_{sensor_id}")
4848 calibrated_label.setMinimumWidth(150)
4849 calibrated_label.setStyleSheet(
4850 "font-family: monospace; font-size: 10pt; font-weight: bold; color: #27ae60;"
4851 )
4852 layout.addWidget(calibrated_label)
4853
4854 # Reference pressure input
4855 ref_input = QtWidgets.QDoubleSpinBox()
4856 ref_input.setRange(-1000.0, 10000.0)
4857 ref_input.setDecimals(2)
4858 ref_input.setSuffix(" PSI")
4859 ref_input.setMinimumWidth(200)
4860 ref_input.setStyleSheet("font-size: 10pt; padding: 3px;")
4861 self.ref_pressure_inputs[sensor_id] = ref_input
4862 layout.addWidget(ref_input)
4863
4864 # Calibration status
4865 calib_status = QtWidgets.QLabel("Not Calibrated")
4866 calib_status.setObjectName(f"calib_status_{sensor_id}")
4867 calib_status.setStyleSheet("color: #e74c3c; font-size: 10pt;")
4868 calib_status.setMinimumWidth(200)
4869 layout.addWidget(calib_status)
4870
4871 sensor_layout.addWidget(frame)
4872
4873 main_layout.addWidget(sensor_group)
4874
4875 # Points table
4876 points_label = QtWidgets.QLabel("Calibration Points Recorded:")
4877 points_label.setStyleSheet(
4878 "font-weight: bold; font-size: 11pt; margin-top: 5px;"
4879 )
4880 main_layout.addWidget(points_label)
4881
4882 self.points_table = QtWidgets.QTableWidget()
4883 self.points_table.setColumnCount(3)
4884 self.points_table.setHorizontalHeaderLabels(
4885 ["Point #", "Sensors", "Pressures (PSI)"]
4886 )
4887 self.points_table.horizontalHeader().setStretchLastSection(True)
4888 main_layout.addWidget(self.points_table)
4889
4890 # Update timer
4891 self.update_timer = QtCore.QTimer(self)
4892 self.update_timer.timeout.connect(self.update_adc_displays)
4893 self.update_timer.start(100)
4894
4896 """Update ADC displays - sensor_id is 1-based (1-10) matching init_connector_plots"""
4897 if not hasattr(self.sensor_widget, "sensor_adc_codes"):
4898 return
4899
4900 # Debug: print available keys (only once, after a short delay to let data arrive)
4901 if not hasattr(self, "_debug_keys_printed"):
4902 QtCore.QTimer.singleShot(2000, lambda: self._print_debug_keys())
4904
4905 for sensor_id in range(1, NUM_CONNECTORS + 1):
4906 # sensor_id in GUI is 1-10 (1-based, matching init_connector_plots)
4907 adc_label = self.findChild(QtWidgets.QLabel, f"adc_{sensor_id}")
4908
4909 if adc_label:
4910 adc_value = None
4911 # sensor_id is 1-10 - matches data storage (packet 1-9 → GUI 1-9, skipping packet 0)
4912 if sensor_id in self.sensor_widget.sensor_adc_codes:
4913 adc_deque = self.sensor_widget.sensor_adc_codes[sensor_id]
4914 if len(adc_deque) > 0:
4915 adc_value = adc_deque[-1][1] # (time, adc_code)
4916
4917 # Display value or "--"
4918 if adc_value is not None:
4919 adc_label.setText(f"{adc_value:,}")
4920 adc_label.setStyleSheet(
4921 "font-family: 'Courier New', monospace; font-size: 11pt; font-weight: bold; color: #2c3e50;"
4922 )
4923
4924 # Calculate and display calibrated pressure in real-time
4925 calibrated_label = self.findChild(
4926 QtWidgets.QLabel, f"calibrated_{sensor_id}"
4927 )
4928 if calibrated_label:
4929 calibrated_pressure = self.calculate_calibrated_pressure(
4930 sensor_id, adc_value
4931 )
4932 if calibrated_pressure is not None:
4933 calibrated_label.setText(f"{calibrated_pressure:.2f}")
4934 calibrated_label.setStyleSheet(
4935 "font-family: 'Courier New', monospace; font-size: 11pt; font-weight: bold; color: #27ae60;"
4936 )
4937 else:
4938 calibrated_label.setText("--")
4939 calibrated_label.setStyleSheet(
4940 "font-family: 'Courier New', monospace; font-size: 11pt; color: #95a5a6;"
4941 )
4942 else:
4943 adc_label.setText("--")
4944 adc_label.setStyleSheet(
4945 "font-family: 'Courier New', monospace; font-size: 11pt; color: #95a5a6;"
4946 )
4947
4948 calibrated_label = self.findChild(
4949 QtWidgets.QLabel, f"calibrated_{sensor_id}"
4950 )
4951 if calibrated_label:
4952 calibrated_label.setText("--")
4953 calibrated_label.setStyleSheet(
4954 "font-family: 'Courier New', monospace; font-size: 11pt; color: #95a5a6;"
4955 )
4956
4958 """Print debug info about sensor_adc_codes keys"""
4959 if hasattr(self.sensor_widget, "sensor_adc_codes"):
4960 available_keys = sorted(self.sensor_widget.sensor_adc_codes.keys())
4961 print(f"🔍 Calibration GUI: sensor_adc_codes has keys: {available_keys}")
4962 if available_keys:
4963 sample_data = [
4964 (
4965 k,
4966 len(self.sensor_widget.sensor_adc_codes[k]),
4967 (
4968 self.sensor_widget.sensor_adc_codes[k][-1][1]
4969 if len(self.sensor_widget.sensor_adc_codes[k]) > 0
4970 else None
4971 ),
4972 )
4973 for k in available_keys[:5]
4974 ]
4975 print(f" Sample: {sample_data}")
4976
4978 """Record calibration point - sensor_id is 1-10 (1-based, matching init_connector_plots)"""
4979 adc_codes = {}
4980 for sensor_id in range(1, NUM_CONNECTORS + 1):
4981 # sensor_id is 1-10 - matches data storage (packet 1-9 → GUI 1-9, skipping packet 0)
4982 if sensor_id in self.sensor_widget.sensor_adc_codes:
4983 adc_deque = self.sensor_widget.sensor_adc_codes[sensor_id]
4984 adc_codes[sensor_id] = adc_deque[-1][1] if len(adc_deque) > 0 else 0
4985 else:
4986 adc_codes[sensor_id] = 0
4987
4988 reference_pressures = {}
4989 for sensor_id, ref_input in self.ref_pressure_inputs.items():
4990 ref_pressure = ref_input.value()
4991 # Accept 0.0 as a valid reference value (zero-point calibration)
4992 # Only skip if the input field is truly empty/unset (but QDoubleSpinBox always has a value)
4993 reference_pressures[sensor_id] = ref_pressure
4994
4995 # Check if at least one sensor has a non-zero reference OR if zero-point calibration is being performed
4996 has_non_zero = any(p != 0.0 for p in reference_pressures.values())
4997 has_zero = any(p == 0.0 for p in reference_pressures.values())
4998
4999 if not reference_pressures or (not has_non_zero and not has_zero):
5000 QtWidgets.QMessageBox.warning(
5001 self,
5002 "No Reference Pressures",
5003 "Please enter reference pressures for sensors (0.0 is valid for zero-point calibration).",
5004 )
5005 return
5006
5007 # Legacy format for backward compatibility
5008 self.calibration_points.append(
5009 {
5010 "adc_codes": adc_codes,
5011 "reference_pressures": reference_pressures,
5012 "timestamp": time.time(),
5013 }
5014 )
5015
5016 # Phase 1: Add to RobustCalibrationFramework if available
5017 if ROBUST_CALIBRATION_AVAILABLE:
5018 env_state = EnvironmentalState() # Default environmental state
5019 for sensor_id, ref_pressure in reference_pressures.items():
5020 if sensor_id in adc_codes and sensor_id in self.robust_frameworks:
5021 adc_code = adc_codes[sensor_id]
5022 # Accept all ADC codes (including 0) - 0 is valid for zero-point calibration
5023 cal_point = CalibrationPoint(
5024 adc_code=float(adc_code),
5025 pressure=float(
5026 ref_pressure
5027 ), # 0.0 is valid for zero-point calibration
5028 timestamp=time.time(),
5029 environmental_state=env_state,
5030 uncertainty=0.01, # 1% default uncertainty
5031 )
5032 self.robust_frameworks[sensor_id].calibration_points.append(
5033 cal_point
5034 )
5035
5036 # Immediately update calibration if we have at least 1 point (for real-time feedback)
5037 # This allows calibrated pressure to update right after recording, even before "Fit Calibration"
5038 framework = self.robust_frameworks[sensor_id]
5039 if len(framework.calibration_points) >= 1:
5040 # For single point: use simple linear calibration (offset only)
5041 # For multiple points: use TLS + Bayesian if available
5042 if len(framework.calibration_points) == 1:
5043 # Single point: set offset (theta[0]) to reference pressure
5044 # This gives immediate feedback for zero-point calibration
5045 framework.theta_mean[0] = float(ref_pressure)
5046 elif len(framework.calibration_points) >= 2:
5047 # Multiple points: try to fit immediately for real-time feedback
5048 try:
5049 theta_tls, cov_tls = (
5050 framework.total_least_squares_calibration(
5051 framework.calibration_points
5052 )
5053 )
5054 theta_post, cov_post = framework.bayesian_update(
5055 framework.calibration_points
5056 )
5057 framework.theta_mean = theta_post
5058 framework.theta_cov = cov_post
5059
5060 # Also update polynomial coefficients for backward compatibility
5061 adc_list = [
5062 cp.adc_code for cp in framework.calibration_points
5063 ]
5064 pressure_list = [
5065 cp.pressure for cp in framework.calibration_points
5066 ]
5067 if len(adc_list) >= 2:
5068 poly_coeffs = np.polyfit(
5069 adc_list,
5070 pressure_list,
5071 min(3, len(adc_list) - 1),
5072 )
5073 self.calibration_polynomials[sensor_id] = tuple(
5074 poly_coeffs
5075 )
5076 except Exception as e:
5077 print(
5078 f"Warning: Could not fit calibration immediately for PT {sensor_id}: {e}"
5079 )
5080
5081 self.update_points_table()
5082 if len(self.calibration_points) >= 3:
5083 self.fit_btn.setEnabled(True)
5084
5085 self.status_label.setText(
5086 f"Recorded point #{len(self.calibration_points)} - Calibrated pressure will update immediately"
5087 )
5088
5090 """Update points table"""
5091 self.points_table.setRowCount(len(self.calibration_points))
5092 for i, point in enumerate(self.calibration_points):
5093 self.points_table.setItem(i, 0, QtWidgets.QTableWidgetItem(str(i + 1)))
5094 sensors_str = ", ".join(
5095 [f"PT{sid}" for sid in sorted(point["adc_codes"].keys())]
5096 )
5097 self.points_table.setItem(i, 1, QtWidgets.QTableWidgetItem(sensors_str))
5098 pressures_str = ", ".join(
5099 [
5100 f"PT{sid}: {p:.2f}"
5101 for sid, p in sorted(point["reference_pressures"].items())
5102 ]
5103 )
5104 self.points_table.setItem(i, 2, QtWidgets.QTableWidgetItem(pressures_str))
5105
5107 """Phase 1: Fit calibration using RobustCalibrationFramework (TLS + Bayesian)"""
5108 if len(self.calibration_points) < 3:
5109 QtWidgets.QMessageBox.warning(
5110 self, "Insufficient Data", "Need at least 3 points."
5111 )
5112 return
5113
5114 fitted_count = 0
5115
5116 if ROBUST_CALIBRATION_AVAILABLE:
5117 # Use RobustCalibrationFramework for Phase 1 (TLS + Bayesian)
5118 for sensor_id in range(1, NUM_CONNECTORS + 1):
5119 if sensor_id not in self.robust_frameworks:
5120 continue
5121
5122 framework = self.robust_frameworks[sensor_id]
5123 cal_points = framework.calibration_points
5124
5125 if len(cal_points) >= 3:
5126 try:
5127 # Phase 1: TLS + Bayesian calibration
5128 theta_tls, cov_tls = framework.total_least_squares_calibration(
5129 cal_points
5130 )
5131 theta_post, cov_post = framework.bayesian_update(cal_points)
5132
5133 # Update framework with posterior
5134 framework.theta_mean = theta_post
5135 framework.theta_cov = cov_post
5136
5137 # Extract polynomial coefficients for backward compatibility
5138 # theta_mean[0:4] corresponds to polynomial coefficients
5139 # For cubic: pressure = theta[0] + theta[1]*adc + theta[2]*adc^2 + theta[3]*adc^3
5140 # But we need to account for normalization in basis functions
5141 # For now, use simple polynomial fit for display, but store robust parameters
5142 adc_codes = [cp.adc_code for cp in cal_points]
5143 pressures = [cp.pressure for cp in cal_points]
5144 poly_coeffs = np.polyfit(adc_codes, pressures, 3)
5145 self.calibration_polynomials[sensor_id] = tuple(poly_coeffs)
5146
5147 # Calculate R² for display
5148 y_pred = np.polyval(poly_coeffs, adc_codes)
5149 ss_res = np.sum((np.array(pressures) - y_pred) ** 2)
5150 ss_tot = np.sum((np.array(pressures) - np.mean(pressures)) ** 2)
5151 r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
5152
5153 # Get confidence level from framework
5154 conf_level = framework.get_confidence_level()
5155
5156 calib_status = self.findChild(
5157 QtWidgets.QLabel, f"calib_status_{sensor_id}"
5158 )
5159 if calib_status:
5160 calib_status.setText(
5161 f"Calibrated (R²={r_squared:.3f}, {conf_level})"
5162 )
5163 calib_status.setStyleSheet("color: green;")
5164
5165 fitted_count += 1
5166 except Exception as e:
5167 print(
5168 f"Failed to fit PT {sensor_id} with robust framework: {e}"
5169 )
5170 else:
5171 # Fallback to simple polynomial fit if robust framework not available
5172 for sensor_id in range(1, NUM_CONNECTORS + 1):
5173 adc_codes = []
5174 pressures = []
5175
5176 for point in self.calibration_points:
5177 if (
5178 sensor_id in point["adc_codes"]
5179 and sensor_id in point["reference_pressures"]
5180 ):
5181 adc_codes.append(point["adc_codes"][sensor_id])
5182 pressures.append(point["reference_pressures"][sensor_id])
5183
5184 if len(adc_codes) >= 3 and len(set(adc_codes)) > 1:
5185 try:
5186 poly_coeffs = np.polyfit(adc_codes, pressures, 3)
5187 self.calibration_polynomials[sensor_id] = tuple(poly_coeffs)
5188
5189 y_pred = np.polyval(poly_coeffs, adc_codes)
5190 ss_res = np.sum((np.array(pressures) - y_pred) ** 2)
5191 ss_tot = np.sum((np.array(pressures) - np.mean(pressures)) ** 2)
5192 r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
5193
5194 calib_status = self.findChild(
5195 QtWidgets.QLabel, f"calib_status_{sensor_id}"
5196 )
5197 if calib_status:
5198 calib_status.setText(f"Calibrated (R²={r_squared:.3f})")
5199 calib_status.setStyleSheet("color: green;")
5200
5201 fitted_count += 1
5202 except Exception as e:
5203 print(f"Failed to fit PT {sensor_id}: {e}")
5204
5205 self.save_btn.setEnabled(True)
5206 phase_text = (
5207 " (Phase 1: TLS + Bayesian)" if ROBUST_CALIBRATION_AVAILABLE else ""
5208 )
5209 self.status_label.setText(f"Fitted {fitted_count} sensor(s){phase_text}")
5210 QtWidgets.QMessageBox.information(
5211 self, "Complete", f"Fitted {fitted_count} sensor(s)!"
5212 )
5213
5215 """Save calibration"""
5216 calib_dir = (
5217 Path(__file__).parent.parent.parent
5218 / "scripts"
5219 / "calibration"
5220 / "calibrations"
5221 )
5222 calib_dir.mkdir(parents=True, exist_ok=True)
5223
5224 filename, _ = QtWidgets.QFileDialog.getSaveFileName(
5225 self,
5226 "Save Calibration",
5227 str(calib_dir / f"calibration_{time.strftime('%Y%m%d_%H%M%S')}.json"),
5228 "JSON files (*.json);;All files (*.*)",
5229 )
5230
5231 if filename:
5232 calibration_data = {
5233 "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
5234 "calibration_points": self.calibration_points,
5235 "calibration_polynomials": {
5236 str(sensor_id): list(coeffs)
5237 for sensor_id, coeffs in self.calibration_polynomials.items()
5238 },
5239 }
5240
5241 # Save RobustCalibrationFramework state if available (Phase 1 & Phase 2 compatible)
5242 if ROBUST_CALIBRATION_AVAILABLE:
5243 robust_state = {}
5244 for sensor_id, framework in self.robust_frameworks.items():
5245 if len(framework.calibration_points) >= 3:
5246 robust_state[str(sensor_id)] = {
5247 "theta_mean": framework.theta_mean.tolist(),
5248 "theta_cov": framework.theta_cov.tolist(),
5249 "calibration_points": [
5250 {
5251 "adc_code": cp.adc_code,
5252 "pressure": cp.pressure,
5253 "timestamp": cp.timestamp,
5254 "uncertainty": cp.uncertainty,
5255 }
5256 for cp in framework.calibration_points
5257 ],
5258 "confidence_level": framework.get_confidence_level(),
5259 }
5260 calibration_data["robust_frameworks"] = robust_state
5261
5262 with open(filename, "w") as f:
5263 json.dump(calibration_data, f, indent=2)
5264
5265 QtWidgets.QMessageBox.information(self, "Saved", f"Saved to:\n{filename}")
5267
5269 """Load calibration"""
5270 filename, _ = QtWidgets.QFileDialog.getOpenFileName(
5271 self, "Load Calibration", "", "JSON files (*.json);;All files (*.*)"
5272 )
5273
5274 if filename:
5275 try:
5276 with open(filename, "r") as f:
5277 calibration_data = json.load(f)
5278
5279 self.calibration_points = calibration_data.get("calibration_points", [])
5281 int(sensor_id): tuple(coeffs)
5282 for sensor_id, coeffs in calibration_data.get(
5283 "calibration_polynomials", {}
5284 ).items()
5285 }
5286
5287 # Load RobustCalibrationFramework state if available
5288 if (
5289 ROBUST_CALIBRATION_AVAILABLE
5290 and "robust_frameworks" in calibration_data
5291 ):
5292 robust_state = calibration_data["robust_frameworks"]
5293 for sensor_id_str, state in robust_state.items():
5294 sensor_id = int(sensor_id_str)
5295 if sensor_id in self.robust_frameworks:
5296 framework = self.robust_frameworks[sensor_id]
5297 framework.theta_mean = np.array(state["theta_mean"])
5298 framework.theta_cov = np.array(state["theta_cov"])
5299 # Restore calibration points
5300 framework.calibration_points = []
5301 env_state = EnvironmentalState()
5302 for cp_data in state.get("calibration_points", []):
5303 cal_point = CalibrationPoint(
5304 adc_code=cp_data["adc_code"],
5305 pressure=cp_data["pressure"],
5306 timestamp=cp_data.get("timestamp", time.time()),
5307 environmental_state=env_state,
5308 uncertainty=cp_data.get("uncertainty", 0.01),
5309 )
5310 framework.calibration_points.append(cal_point)
5311
5312 self.update_points_table()
5315
5316 if len(self.calibration_points) >= 3:
5317 self.fit_btn.setEnabled(True)
5319 self.save_btn.setEnabled(True)
5320
5321 QtWidgets.QMessageBox.information(
5322 self, "Loaded", f"Loaded from:\n{filename}"
5323 )
5324 except Exception as e:
5325 QtWidgets.QMessageBox.critical(self, "Error", f"Failed to load:\n{e}")
5326
5328 """Load existing calibration"""
5329 calib_dir = (
5330 Path(__file__).parent.parent.parent
5331 / "scripts"
5332 / "calibration"
5333 / "calibrations"
5334 )
5335 if calib_dir.exists():
5336 json_files = sorted(
5337 calib_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True
5338 )
5339 if json_files:
5340 try:
5341 with open(json_files[0], "r") as f:
5342 calibration_data = json.load(f)
5343
5345 int(sensor_id): tuple(coeffs)
5346 for sensor_id, coeffs in calibration_data.get(
5347 "calibration_polynomials", {}
5348 ).items()
5349 }
5350
5353 except Exception as e:
5354 print(f"Failed to load existing calibration: {e}")
5355
5357 """Update status labels"""
5358 for sensor_id in range(1, NUM_CONNECTORS + 1):
5359 calib_status = self.findChild(QtWidgets.QLabel, f"calib_status_{sensor_id}")
5360 if calib_status:
5361 if sensor_id in self.calibration_polynomials:
5362 calib_status.setText("Calibrated")
5363 calib_status.setStyleSheet("color: green;")
5364 else:
5365 calib_status.setText("Not Calibrated")
5366 calib_status.setStyleSheet("color: red;")
5367
5368 def calculate_calibrated_pressure(self, sensor_id: int, adc_code: int):
5369 """Calculate calibrated pressure from ADC code - human input is always source of truth"""
5370 # Priority 1: Use robust framework if available (human input is source of truth)
5371 if ROBUST_CALIBRATION_AVAILABLE and sensor_id in self.robust_frameworks:
5372 framework = self.robust_frameworks[sensor_id]
5373 if len(framework.calibration_points) >= 1: # At least one human input
5374 env_state = EnvironmentalState()
5375 phi = framework.environmental_robust_basis_functions(
5376 float(adc_code), env_state
5377 )
5378 pressure = phi @ framework.theta_mean
5379 # Ensure we return a valid number (not NaN or inf)
5380 if np.isfinite(pressure):
5381 return float(pressure)
5382
5383 # Priority 2: Use polynomial calibration (from human input)
5384 if sensor_id in self.calibration_polynomials:
5385 coeffs = self.calibration_polynomials[sensor_id]
5386 # Handle different polynomial degrees
5387 if len(coeffs) == 4:
5388 # Cubic polynomial: pressure = A*adc^3 + B*adc^2 + C*adc + D
5389 pressure = (
5390 coeffs[0] * (adc_code**3)
5391 + coeffs[1] * (adc_code**2)
5392 + coeffs[2] * adc_code
5393 + coeffs[3]
5394 )
5395 elif len(coeffs) == 3:
5396 # Quadratic: pressure = A*adc^2 + B*adc + C
5397 pressure = coeffs[0] * (adc_code**2) + coeffs[1] * adc_code + coeffs[2]
5398 elif len(coeffs) == 2:
5399 # Linear: pressure = A*adc + B
5400 pressure = coeffs[0] * adc_code + coeffs[1]
5401 else:
5402 return None
5403 if np.isfinite(pressure):
5404 return float(pressure)
5405
5406 # No calibration available
5407 return None
5408
5410 """Prompt for zero-point calibration on startup (singular zero point calibration)"""
5411 reply = QtWidgets.QMessageBox.question(
5412 self,
5413 "Zero-Point Calibration",
5414 "Perform zero-point calibration?\n\n"
5415 "This will calibrate all sensors at 0 PSI (atmospheric pressure).\n"
5416 "This ensures parity across all sensors on stack bring-up.",
5417 QtWidgets.QMessageBox.StandardButton.Yes
5418 | QtWidgets.QMessageBox.StandardButton.No,
5419 QtWidgets.QMessageBox.StandardButton.Yes,
5420 )
5421
5422 if reply == QtWidgets.QMessageBox.StandardButton.Yes:
5424
5426 """Perform zero-point calibration: all sensors at 0 PSI (human input is source of truth)"""
5427 if not hasattr(self.sensor_widget, "sensor_adc_codes"):
5428 QtWidgets.QMessageBox.warning(
5429 self, "No Data", "No sensor data available yet."
5430 )
5431 return
5432
5433 # Collect ADC codes for all sensors
5434 adc_codes = {}
5435 for sensor_id in range(1, NUM_CONNECTORS + 1):
5436 if sensor_id in self.sensor_widget.sensor_adc_codes:
5437 adc_deque = self.sensor_widget.sensor_adc_codes[sensor_id]
5438 if len(adc_deque) > 0:
5439 adc_codes[sensor_id] = adc_deque[-1][1] # Latest ADC code
5440
5441 if not adc_codes:
5442 QtWidgets.QMessageBox.warning(
5443 self, "No Data", "No sensor data available yet."
5444 )
5445 return
5446
5447 # Zero-point calibration: reference pressure is 0.0 PSI for all sensors
5448 reference_pressures = {sid: 0.0 for sid in adc_codes.keys()}
5449
5450 # Add to calibration points (human input is source of truth)
5451 self.calibration_points.append(
5452 {
5453 "adc_codes": adc_codes,
5454 "reference_pressures": reference_pressures,
5455 "timestamp": time.time(),
5456 "is_zero_point": True,
5457 }
5458 )
5459
5460 # Add to RobustCalibrationFramework (human input is source of truth)
5461 if ROBUST_CALIBRATION_AVAILABLE:
5462 env_state = EnvironmentalState()
5463 for sensor_id, adc_code in adc_codes.items():
5464 if sensor_id in self.robust_frameworks:
5465 cal_point = CalibrationPoint(
5466 adc_code=float(adc_code),
5467 pressure=0.0, # Zero-point: 0 PSI
5468 timestamp=time.time(),
5469 environmental_state=env_state,
5470 uncertainty=0.01, # 1% uncertainty
5471 )
5472 self.robust_frameworks[sensor_id].calibration_points.append(
5473 cal_point
5474 )
5475
5476 self.zero_point_calibrated = True
5477 self.update_points_table()
5478 self.status_label.setText(
5479 "Zero-point calibration complete (0 PSI for all sensors)"
5480 )
5481 QtWidgets.QMessageBox.information(
5482 self,
5483 "Zero-Point Calibration Complete",
5484 f"Calibrated {len(adc_codes)} sensor(s) at 0 PSI.\n"
5485 "This ensures parity across all sensors on stack bring-up.",
5486 )
5487
5489 """Consensus-based autonomous recalibration: sensors agree with each other"""
5490 if not ROBUST_CALIBRATION_AVAILABLE:
5491 return
5492
5493 # Find sensors with good calibration (at least 3 points)
5494 calibrated_sensors = []
5495 for sensor_id in range(1, NUM_CONNECTORS + 1):
5496 if sensor_id in self.robust_frameworks:
5497 framework = self.robust_frameworks[sensor_id]
5498 if len(framework.calibration_points) >= 3:
5499 calibrated_sensors.append(sensor_id)
5500
5501 if len(calibrated_sensors) < 2:
5502 return # Need at least 2 calibrated sensors for consensus
5503
5504 # Get current ADC codes for all sensors
5505 if not hasattr(self.sensor_widget, "sensor_adc_codes"):
5506 return
5507
5508 current_adcs = {}
5509 for sensor_id in range(1, NUM_CONNECTORS + 1):
5510 if sensor_id in self.sensor_widget.sensor_adc_codes:
5511 adc_deque = self.sensor_widget.sensor_adc_codes[sensor_id]
5512 if len(adc_deque) > 0:
5513 current_adcs[sensor_id] = adc_deque[-1][1]
5514
5515 if len(current_adcs) < 2:
5516 return
5517
5518 # Calculate predicted pressures for all calibrated sensors
5519 predicted_pressures = {}
5520 env_state = EnvironmentalState()
5521
5522 for sensor_id in calibrated_sensors:
5523 if sensor_id in current_adcs:
5524 framework = self.robust_frameworks[sensor_id]
5525 phi = framework.environmental_robust_basis_functions(
5526 float(current_adcs[sensor_id]), env_state
5527 )
5528 predicted_pressures[sensor_id] = phi @ framework.theta_mean
5529
5530 if len(predicted_pressures) < 2:
5531 return
5532
5533 # Consensus: if all calibrated sensors agree within 5% (and human input hasn't been provided recently)
5534 pressures = list(predicted_pressures.values())
5535 mean_pressure = np.mean(pressures)
5536 std_pressure = np.std(pressures)
5537
5538 if std_pressure < 0.05 * abs(mean_pressure): # Within 5% agreement
5539 # Sensors are in consensus - use this as autonomous calibration update
5540 # But ONLY if human input hasn't been provided recently (human is source of truth)
5541 # For now, we just log consensus - human input always takes precedence
5542 pass # Consensus detected, but human input is source of truth
5543
5544 def apply_calibration_to_sensor_widget(self):
5545 """Apply calibration to sensor widget"""
5546 if hasattr(self.sensor_widget, "pt_calibration"):
5547 for sensor_id, coeffs in self.calibration_polynomials.items():
5548 sensor_idx = sensor_id - 1
5549 self.sensor_widget.pt_calibration[sensor_idx] = coeffs
5550
5551 def toggle_phase2(self, checked):
5552 """Toggle Phase 2 autonomous monitoring (RLS + GLR drift detection)"""
5553 if not ROBUST_CALIBRATION_AVAILABLE:
5554 QtWidgets.QMessageBox.warning(
5555 self, "Not Available", "Robust calibration framework not available."
5556 )
5557 self.phase2_btn.setChecked(False)
5558 return
5559
5560 self.phase2_active = checked
5561
5562 if checked:
5563 # Start Phase 2 monitoring
5564 self.phase2_timer = QtCore.QTimer(self)
5565 self.phase2_timer.timeout.connect(self.phase2_update)
5566 self.phase2_timer.start(100) # Update every 100ms
5567 self.phase2_btn.setText("Disable Phase 2")
5568 self.status_label.setText("Phase 2 Active: RLS + GLR drift detection")
5569 else:
5570 # Stop Phase 2 monitoring
5571 if self.phase2_timer:
5572 self.phase2_timer.stop()
5573 self.phase2_timer = None
5574 self.phase2_btn.setText("Enable Phase 2 (Autonomous)")
5575 self.status_label.setText("Phase 2 Disabled")
5576
5577 def phase2_update(self):
5578 """Phase 2: RLS updates and GLR drift detection on real-time sensor data"""
5579 if not self.phase2_active or not ROBUST_CALIBRATION_AVAILABLE:
5580 return
5581
5582 # Consensus-based recalibration check (sensors agree with each other)
5583 self.consensus_based_recalibration()
5584
5585 # Update each sensor with RLS if we have calibration data
5586 # NOTE: Human input is always source of truth - RLS only updates when no human input conflicts
5587 for sensor_id in range(1, NUM_CONNECTORS + 1):
5588 if sensor_id not in self.robust_frameworks:
5589 continue
5590
5591 framework = self.robust_frameworks[sensor_id]
5592
5593 # Need at least 3 calibration points for Phase 2
5594 if len(framework.calibration_points) < 3:
5595 continue
5596
5597 # Get latest ADC code from sensor widget
5598 if (
5599 hasattr(self.sensor_widget, "sensor_adc_codes")
5600 and sensor_id in self.sensor_widget.sensor_adc_codes
5601 ):
5602 adc_deque = self.sensor_widget.sensor_adc_codes[sensor_id]
5603 if len(adc_deque) > 0:
5604 latest_adc = adc_deque[-1][1] # (time, adc_code)
5605
5606 # Create calibration point for RLS update (without reference pressure)
5607 # In Phase 2, we use predicted pressure from current calibration
5608 # BUT: Human input is always source of truth - RLS only refines, never overrides
5609 env_state = EnvironmentalState()
5610
5611 # Predict pressure using current calibration
5612 phi = framework.environmental_robust_basis_functions(
5613 latest_adc, env_state
5614 )
5615 predicted_pressure = phi @ framework.theta_mean
5616
5617 # RLS update (this will adapt calibration parameters online)
5618 # NOTE: This is autonomous refinement - human input always takes precedence
5619 # Note: In true Phase 2, we'd have reference pressure from consensus sensors
5620 # For now, we use predicted pressure (autonomous mode)
5621 cal_point = CalibrationPoint(
5622 adc_code=float(latest_adc),
5623 pressure=float(predicted_pressure), # Predicted, not measured
5624 timestamp=time.time(),
5625 environmental_state=env_state,
5626 uncertainty=0.05, # Higher uncertainty for predicted values
5627 )
5628
5629 # RLS update
5630 framework.recursive_least_squares_update(cal_point)
5631
5632 # GLR drift detection
5633 glr_stat, drift_detected = (
5634 framework.generalized_likelihood_ratio_test([cal_point])
5635 )
5636
5637 if drift_detected:
5638 # Drift detected - trigger recalibration alert
5639 calib_status = self.findChild(
5640 QtWidgets.QLabel, f"calib_status_{sensor_id}"
5641 )
5642 if calib_status:
5643 calib_status.setText(
5644 "⚠️ DRIFT DETECTED - Recalibration needed"
5645 )
5646 calib_status.setStyleSheet(
5647 "color: orange; font-weight: bold;"
5648 )
5649
5650
5651# ---------------------- Combined Main Window ----------------------
5652class CombinedMainWindow(QtWidgets.QMainWindow):
5653 """Single window with sensor plot (left 66%) and actuator control (right 33%)."""
5654
5655 def __init__(
5656 self,
5657 receiver,
5658 device_ip: str = DEFAULT_ACTUATOR_IP,
5659 device_port: int = DEFAULT_DEVICE_PORT,
5660 bind_address: str = "0.0.0.0",
5661 ):
5662 super().__init__()
5663 self.setWindowTitle("Diablo Avionics – Sensor & Actuator")
5664 # Event log storage: list of (time, type, details)
5665 self.event_log: List[Tuple[float, str, str]] = []
5666
5667 # Load state machine CSV
5668 self.state_machine, _ = load_state_machine_csv(
5669 CONFIG.get_state_machine_csv_path()
5670 )
5671
5672 # Map mode button names to CSV state names (keep order in sync with CSV header and TopBarWidget)
5673 self.mode_to_state_map = {
5674 "Idle": "Idle",
5675 "Armed": "Armed",
5676 "Fuel Fill": "Fuel Fill",
5677 "Ox Fill": "Ox Fill",
5678 "Quick Fire": "Quick Fire",
5679 "GN2 Press": "GN2 Press",
5680 "Fuel Press": "Fuel Press",
5681 "Fuel Vent": "Fuel Vent",
5682 "Ox Press": "Ox Press",
5683 "Ox Vent": "Ox Vent",
5684 "High Press": "High Press",
5685 "GN2 Vent": "GN2 Vent",
5686 "Fire": "Fire",
5687 "Vent": "Vent",
5688 "Abort": "Abort",
5689 }
5690
5691 # Central widget container
5692 central_widget = QtWidgets.QWidget()
5693 self.setCentralWidget(central_widget)
5694
5695 # Main vertical layout
5696 main_layout = QtWidgets.QVBoxLayout(central_widget)
5697 main_layout.setContentsMargins(0, 0, 0, 0)
5698 main_layout.setSpacing(0)
5699
5700 # Top Bar
5701 self.top_bar_widget = TopBarWidget(self)
5702 self.top_bar_widget.navigation_requested.connect(self.on_navigation_requested)
5703 self.top_bar_widget.mode_changed.connect(self.on_mode_changed)
5704 self.top_bar_widget.debug_toggled.connect(self.on_debug_toggled)
5705 main_layout.addWidget(self.top_bar_widget)
5706
5707 # Stacked Widget for Dashboard / Settings
5708 self.stack = QtWidgets.QStackedWidget()
5709
5710 # --- Page 1: Dashboard ---
5711 dashboard_widget = QtWidgets.QWidget()
5712 dashboard_layout = QtWidgets.QHBoxLayout(dashboard_widget)
5713 dashboard_layout.setContentsMargins(0, 0, 0, 0)
5714
5715 self.sensor_widget = SensorPlotWidget(receiver, bind_address, self)
5716 self.actuator_widget = ActuatorControlWidget(
5717 receiver, device_ip, device_port, self
5718 )
5719 self.sensor_widget.main_window_ref = self
5720 self.actuator_widget.main_window_ref = self
5721
5722 splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal)
5723 splitter.addWidget(self.sensor_widget)
5724 splitter.addWidget(self.actuator_widget)
5725 splitter.setStretchFactor(0, 2) # Left 66%
5726 splitter.setStretchFactor(1, 1) # Right 33%
5727
5728 dashboard_layout.addWidget(splitter)
5729 self.stack.addWidget(dashboard_widget)
5730
5731 # --- Page 2: Settings ---
5732 self.settings_widget = SettingsWidget(self.sensor_widget, self.actuator_widget)
5733 self.settings_widget.mapping_changed.connect(self.on_mapping_changed)
5734 self.stack.addWidget(self.settings_widget)
5735
5736 main_layout.addWidget(self.stack, 1)
5737
5738 # Calibration window (separate, opened via menu)
5739 self.calibration_window = None
5740
5741 # Connect sensor data for Top Bar updates
5742 # leveraging the existing timer update in SensorPlotWidget is tricky without a signal
5743 # simpler to just shadow the data
5744 self.gauge_map = CONFIG.config["mappings"]
5745
5746 self.update_timer = QtCore.QTimer(self)
5747 self.update_timer.timeout.connect(self.update_top_bar)
5748 self.update_timer.start(100)
5749
5750 self._apply_bar_limits()
5751
5752 # PWM control window (lazily created)
5753 self.pwm_window = None
5754
5755 def _apply_bar_limits(self):
5756 """Apply NOP, MEOP, POP, THRESH and shared scale_max to top bar gauges (bottom=0, same scale)."""
5757 pl = CONFIG.config.get("pressure_limits", {})
5758 pops = [float(pl.get(f, {}).get("POP", 750)) for f in ("GN2", "ETH", "LOX")]
5759 scale_max = max(1.25 * p for p in pops) if pops else 1000.0
5760 scale_max = max(scale_max, 1.0)
5761 for fluid, bar in [
5762 ("GN2", self.top_bar_widget.bar_gn2),
5763 ("ETH", self.top_bar_widget.bar_eth),
5764 ("LOX", self.top_bar_widget.bar_lox),
5765 ]:
5766 d = pl.get(fluid, {})
5767 nop = float(d.get("NOP", 600))
5768 meop = float(d.get("MEOP", 650))
5769 pop = float(d.get("POP", 750))
5770 thresh = float(d.get("THRESH", 400))
5771 bar.set_limits(nop, meop, pop, thresh, scale_max=scale_max)
5772
5773 def on_navigation_requested(self, view_name):
5774 if view_name == "settings":
5775 self.settings_widget.load_values() # Refresh values on enter
5776 self.stack.setCurrentWidget(self.settings_widget)
5777 else:
5778 self._apply_bar_limits()
5779 self.stack.setCurrentIndex(0) # Dashboard
5780
5781 def on_debug_toggled(self, enabled: bool):
5782 """Handle DEBUG button toggle - enable/disable manual actuator control"""
5783 if hasattr(self, "actuator_widget") and self.actuator_widget:
5784 self.actuator_widget.manual_control_enabled = enabled
5785 print(f"DEBUG mode: Manual control {'enabled' if enabled else 'disabled'}")
5786 else:
5787 print(f"Warning: actuator_widget not available when DEBUG toggled")
5788
5789 def open_pwm_control(self):
5790 """Open the PWM control window, creating it lazily."""
5791 if self.pwm_window is None:
5792 self.pwm_window = PWMControlWindow(self)
5793 self.pwm_window.show()
5794 self.pwm_window.raise_()
5795 self.pwm_window.activateWindow()
5796
5797 def open_calibration(self):
5798 """Open the calibration window, creating it lazily."""
5799 if self.calibration_window is None:
5800 self.calibration_window = CalibrationWindow(self.sensor_widget, self)
5801 self.calibration_window.show()
5802 self.calibration_window.raise_()
5803 self.calibration_window.activateWindow()
5804
5805 def on_mode_changed(self, mode_name: str):
5806 """Handle mode button change - apply state from CSV and log event"""
5807 relative_time = time.time() - self.sensor_widget.stats_start_time
5808 self.log_event("Mode Changed", mode_name)
5809
5810 # Manual control is now controlled by DEBUG button, not by state selection
5811 # (DEBUG button handler sets manual_control_enabled)
5812
5813 # Map mode name to CSV state name
5814 csv_state_name = self.mode_to_state_map.get(mode_name, mode_name)
5815
5816 # Apply actuator states from CSV
5817 if self.state_machine:
5818 apply_state_from_csv(
5819 self.actuator_widget, csv_state_name, self.state_machine
5820 )
5821 else:
5822 print(
5823 f"Warning: State machine not loaded, cannot apply state for '{mode_name}'"
5824 )
5825
5826 # Fire any armed PWM commands on state transition
5827 if self.pwm_window is not None:
5828 armed = self.pwm_window.get_armed_commands()
5829 if armed:
5830 packet = create_pwm_actuator_command_packet(armed)
5831 try:
5832 sock = self.actuator_widget.command_sock
5833 ip = self.actuator_widget.device_ip
5834 port = self.actuator_widget.device_port
5835 sock.sendto(packet, (ip, port))
5836 self.log_event(
5837 "PWM Armed Execute", f"{len(armed)} commands on '{mode_name}'"
5838 )
5839 print(
5840 f"Sent {len(armed)} armed PWM commands on state '{mode_name}'"
5841 )
5842 except Exception as e:
5843 print(f"Error sending armed PWM commands: {e}")
5844 self.pwm_window.consume_arms()
5845
5846 def log_event(self, event_type: str, details: str):
5847 """Log an event with current relative time"""
5848 relative_time = time.time() - self.sensor_widget.stats_start_time
5849 self.event_log.append((relative_time, event_type, details))
5850
5851 def get_event_log(self):
5852 """Get the event log, filtered by settings"""
5853 filtered_events = []
5854 for event_time, event_type, details in self.event_log:
5855 # Filter actuator events if only_show_actuators_with_roles is enabled
5856 if (
5857 event_type == "Actuator State Change"
5858 and self.actuator_widget.only_show_actuators_with_roles
5859 ):
5860 # Extract actuator ID from details (format: "Actuator X: ..." or "Actuator X (label): ...")
5861 import re
5862
5863 match = re.match(r"Actuator (\d+)", details)
5864 if match:
5865 actuator_id = int(match.group(1))
5866 # Check if this actuator has a role (non-empty label)
5867 label = self.actuator_widget.actuator_labels.get(actuator_id, "")
5868 if not label or label.strip() == "":
5869 continue # Skip this event
5870 filtered_events.append((event_time, event_type, details))
5871 return filtered_events
5872
5873 def on_mapping_changed(self, gauge_name, pt_id):
5874 self.gauge_map[gauge_name] = pt_id # Update local map for internal timer use
5875 # CONFIG save is handled in settings_widget
5876
5877 def _get_gauge_pressure(self, gauge_name: str) -> float:
5878 """Return latest pressure (psi) for the given gauge (GN2, ETH, LOX), or 0.0 if no data."""
5879 pt_id = self.gauge_map.get(gauge_name, 0)
5880 if pt_id <= 0 or pt_id not in self.sensor_widget.sensor_psi_plot_v:
5881 return 0.0
5882 deque_data = self.sensor_widget.sensor_psi_plot_v[pt_id]
5883 if len(deque_data) == 0:
5884 return 0.0
5885 return deque_data[-1]
5886
5887 def _check_auto_state_transitions(self):
5888 """If current state and pressures meet threshold rules, automatically transition state."""
5889 if self.top_bar_widget.unlock_states:
5890 return # In DEBUG mode, no automatic transitions
5891 current = self.top_bar_widget.current_state
5892 if current is None:
5893 return
5894 pl = CONFIG.config.get("pressure_limits", {})
5895 gn2_psi = self._get_gauge_pressure("GN2")
5896 fuel_psi = self._get_gauge_pressure("ETH")
5897 ox_psi = self._get_gauge_pressure("LOX")
5898 gn2_pop = float(pl.get("GN2", {}).get("POP", 100.0))
5899 fuel_pop = float(pl.get("ETH", {}).get("POP", 100.0))
5900 ox_pop = float(pl.get("LOX", {}).get("POP", 100.0))
5901 gn2_thresh = float(pl.get("GN2", {}).get("THRESH", 10.0))
5902 fuel_thresh = float(pl.get("ETH", {}).get("THRESH", 10.0))
5903 ox_thresh = float(pl.get("LOX", {}).get("THRESH", 10.0))
5904
5905 # Simulate Chamber Pressure Logic (Safety/Test)
5906 # If enabled: In FIRE state, if LOX < 350 or Fuel < 350, go to ARMED
5907 if current == "Fire" and CONFIG.config.get("simulate_chamber_pressure", False):
5908 # Using Upstream sensors (from config mapping or hardcoded roles?)
5909 # User said "LOX upstream OR Fuel Upstream".
5910 # We need to get pressure from the sensors assigned to these roles.
5911 # config.json: "sensor_roles": { "Fuel Upstream": 1, "Data": ..., "Ox Upstream": 5 }
5912 # Let's resolve the PT IDs for these roles.
5913 fuel_upstream_pt = CONFIG.get_sensor_role("Fuel Upstream")
5914 ox_upstream_pt = CONFIG.get_sensor_role(
5915 "Ox Upstream"
5916 ) # "Ox Upstream" in config vs "LOX Upstream" in code?
5917 # Looking at config.json provided earlier:
5918 # "Fuel Upstream": 1, "Ox Upstream": 5.
5919
5920 # Helper to get PSI
5921 def get_psi(pt_id):
5922 if pt_id > 0 and pt_id in self.sensor_widget.sensor_psi_plot_v:
5923 d = self.sensor_widget.sensor_psi_plot_v[pt_id]
5924 if d:
5925 return d[-1]
5926 return 0.0
5927
5928 p_fuel = get_psi(fuel_upstream_pt)
5929 p_lox = get_psi(ox_upstream_pt)
5930
5931 # Threshold is 350 psi
5932 if p_fuel < 350.0 or p_lox < 350.0:
5933 self.top_bar_widget.request_state("Armed")
5934 self.log_event(
5935 "Auto Transition",
5936 f"Simulate Chamber Pressure triggered: Fuel={p_fuel:.1f}, LOX={p_lox:.1f} (<350)",
5937 )
5938 return
5939
5940 # Press → Vent when pressure above POP
5941 if current == "GN2 Press" and gn2_psi >= gn2_pop:
5942 self.top_bar_widget.request_state("GN2 Vent")
5943 return
5944 if current == "Fuel Press" and fuel_psi >= fuel_pop:
5945 self.top_bar_widget.request_state("Fuel Vent")
5946 return
5947 if current == "Ox Press" and ox_psi >= ox_pop:
5948 self.top_bar_widget.request_state("Ox Vent")
5949 return
5950 if current == "High Press" and gn2_psi >= gn2_pop:
5951 self.top_bar_widget.request_state("GN2 Vent")
5952 return
5953 # Vent → Press when pressure below THRESH
5954 if current == "GN2 Vent" and gn2_psi <= gn2_thresh:
5955 self.top_bar_widget.request_state("GN2 Press")
5956 return
5957 if current == "Fuel Vent" and fuel_psi <= fuel_thresh:
5958 self.top_bar_widget.request_state("Fuel Press")
5959 return
5960 if current == "Ox Vent" and ox_psi <= ox_thresh:
5961 self.top_bar_widget.request_state("Ox Press")
5962
5963 def update_top_bar(self):
5964 # Poll sensor widget for latest PSI values
5965 # Accessing private data sensor_psi_data directly for simplicity given the code structure
5966 for gauge, pt_id in self.gauge_map.items():
5967 val = 0.0
5968 if pt_id > 0 and pt_id in self.sensor_widget.sensor_psi_plot_v:
5969 deque_data = self.sensor_widget.sensor_psi_plot_v[pt_id]
5970 if len(deque_data) > 0:
5971 val = deque_data[-1] # just the psi value (no tuple)
5972
5973 if gauge == "GN2":
5974 self.top_bar_widget.bar_gn2.set_value(val)
5975 elif gauge == "ETH":
5976 self.top_bar_widget.bar_eth.set_value(val)
5977 elif gauge == "LOX":
5978 self.top_bar_widget.bar_lox.set_value(val)
5979 self._check_auto_state_transitions()
5980
5981 def closeEvent(self, event):
5982 """Handle window close: save labels and close actuator socket."""
5983 # ConfigManager handles saving on change, so explicit save here might be redundant
5984 # but safe to keep close actions for sockets
5985 if hasattr(self, "actuator_widget") and self.actuator_widget:
5986 if hasattr(self.actuator_widget, "close_socket"):
5987 self.actuator_widget.close_socket()
5988 event.accept()
5989
5990
5991# ---------------------- Settings Dialogs ----------------------
5992
5993
5994# ---------------------- Main Application ----------------------
5995def main():
5996 """Main entry point"""
5997 import argparse
5998
5999 parser = argparse.ArgumentParser(
6000 description="Combined Sensor & Actuator Control GUI (single full-screen window)",
6001 formatter_class=argparse.RawDescriptionHelpFormatter,
6002 epilog="""
6003Opens one full-screen window: sensor plot on the left, actuator control on the right.
6004
6005Examples:
6006 %(prog)s # Use default settings
6007 %(prog)s -i 192.168.2.100 # Specify actuator device IP
6008 %(prog)s -p 5006 # Specify receive port
6009 """,
6010 )
6011 parser.add_argument(
6012 "-i",
6013 "--ip",
6014 type=str,
6015 default=CONFIG.config["network"]["actuator_ip"],
6016 help=f'Actuator board IP address (default from config: {CONFIG.config["network"]["actuator_ip"]})',
6017 )
6018 parser.add_argument(
6019 "-p",
6020 "--port",
6021 type=int,
6022 default=CONFIG.config["network"]["receive_port"],
6023 help=f'UDP port to receive sensor data on (default from config: {CONFIG.config["network"]["receive_port"]})',
6024 )
6025 parser.add_argument(
6026 "-d",
6027 "--device-port",
6028 type=int,
6029 default=CONFIG.config["network"]["actuator_port"],
6030 help=f'Device UDP port for actuator commands (default from config: {CONFIG.config["network"]["actuator_port"]})',
6031 )
6032 parser.add_argument(
6033 "-a",
6034 "--address",
6035 type=str,
6036 default="0.0.0.0",
6037 help="IP address to bind receiver to (default: 0.0.0.0 for all interfaces)",
6038 )
6039
6040 args = parser.parse_args()
6041
6042 app = QtWidgets.QApplication(sys.argv)
6043
6044 # Force Fusion style and dark palette (WSL doesn't always pick up system theme)
6045 app.setStyle("Fusion")
6046 palette = QtGui.QPalette()
6047 palette.setColor(
6048 QtGui.QPalette.ColorGroup.All,
6049 QtGui.QPalette.ColorRole.Window,
6050 QtGui.QColor(53, 53, 53),
6051 )
6052 palette.setColor(
6053 QtGui.QPalette.ColorGroup.All,
6054 QtGui.QPalette.ColorRole.WindowText,
6055 QtCore.Qt.GlobalColor.white,
6056 )
6057 palette.setColor(
6058 QtGui.QPalette.ColorGroup.All,
6059 QtGui.QPalette.ColorRole.Base,
6060 QtGui.QColor(25, 25, 25),
6061 )
6062 palette.setColor(
6063 QtGui.QPalette.ColorGroup.All,
6064 QtGui.QPalette.ColorRole.AlternateBase,
6065 QtGui.QColor(53, 53, 53),
6066 )
6067 palette.setColor(
6068 QtGui.QPalette.ColorGroup.All,
6069 QtGui.QPalette.ColorRole.ToolTipBase,
6070 QtCore.Qt.GlobalColor.white,
6071 )
6072 palette.setColor(
6073 QtGui.QPalette.ColorGroup.All,
6074 QtGui.QPalette.ColorRole.ToolTipText,
6075 QtCore.Qt.GlobalColor.white,
6076 )
6077 palette.setColor(
6078 QtGui.QPalette.ColorGroup.All,
6079 QtGui.QPalette.ColorRole.Text,
6080 QtCore.Qt.GlobalColor.white,
6081 )
6082 palette.setColor(
6083 QtGui.QPalette.ColorGroup.All,
6084 QtGui.QPalette.ColorRole.Button,
6085 QtGui.QColor(53, 53, 53),
6086 )
6087 palette.setColor(
6088 QtGui.QPalette.ColorGroup.All,
6089 QtGui.QPalette.ColorRole.ButtonText,
6090 QtCore.Qt.GlobalColor.white,
6091 )
6092 palette.setColor(
6093 QtGui.QPalette.ColorGroup.All,
6094 QtGui.QPalette.ColorRole.BrightText,
6095 QtCore.Qt.GlobalColor.red,
6096 )
6097 palette.setColor(
6098 QtGui.QPalette.ColorGroup.All,
6099 QtGui.QPalette.ColorRole.Link,
6100 QtGui.QColor(42, 130, 218),
6101 )
6102 palette.setColor(
6103 QtGui.QPalette.ColorGroup.All,
6104 QtGui.QPalette.ColorRole.Highlight,
6105 QtGui.QColor(42, 130, 218),
6106 )
6107 palette.setColor(
6108 QtGui.QPalette.ColorGroup.All,
6109 QtGui.QPalette.ColorRole.HighlightedText,
6110 QtCore.Qt.GlobalColor.black,
6111 )
6112 app.setPalette(palette)
6113
6114 # Create shared UDP receiver
6115 receiver = UDPReceiver(port=args.port, bind_address=args.address)
6116 receiver.start()
6117
6118 # Create single combined window (sensor plot left, actuator control right)
6119 window = CombinedMainWindow(
6120 receiver,
6121 device_ip=args.ip,
6122 device_port=args.device_port,
6123 bind_address=args.address,
6124 )
6125
6126 # Add Calibration menu item
6127 menu_bar = window.menuBar()
6128 if menu_bar:
6129 calib_action = menu_bar.addAction("Calibration...")
6130 calib_action.setShortcut("Ctrl+C")
6131 calib_action.triggered.connect(window.open_calibration)
6132
6133 # Start maximized as requested
6134 window.showMaximized()
6135
6136 # Handle cleanup when app exits
6137 def cleanup():
6138 receiver.stop()
6139 receiver.wait(2000)
6140
6141 app.aboutToQuit.connect(cleanup)
6142
6143 sys.exit(app.exec())
6144
6145
6146if __name__ == "__main__":
6147 main()
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
__init__(self, receiver, str device_ip=None, int device_port=None, parent=None)
send_actuator_command(self, int actuator_id, int hardware_command)
update_label_display(self, actuator_id, text)
set_actuator_state(self, int actuator_id, int gui_state, bool force=False)
on_label_changed(self, int actuator_id, str text)
update_button_highlight(self, int array_idx, int actuator_state)
__init__(self, receiver, str device_ip=DEFAULT_ACTUATOR_IP, int device_port=DEFAULT_DEVICE_PORT)
__init__(self, sensor_widget, parent=None)
calculate_calibrated_pressure(self, int sensor_id, int adc_code)
get_actuator_type(self, role_name)
get_sensor_role(self, role_name)
get_actuator_type_by_id(self, actuator_id)
set_actuator_role(self, role_name, actuator_id)
set_sensor_role(self, role_name, connector_id)
get_actuator_role(self, role_name)
_update_dict(self, target, source)
update_arm_status(self, actuator_id, checked)
execute_now(self, actuator_id)
List[Tuple[int, int, float, float]] get_armed_commands(self)
__init__(self, parent_window)
set_limits(self, float nop, float meop, float pop, float thresh=None, float scale_max=None)
__init__(self, str title, float nop=500.0, float meop=700.0, float pop=1000.0, float thresh=None, QtGui.QColor fixed_color=None, parent=None)
set_global_line_ys(self, float y20, float y40, float y60, float y80)
__init__(self, "SensorPlotWidget" parent)
_set_cell(self, int row, int col, str text)
update_plot_legend(self, int connector_id)
_on_plot_toggle(self, int connector_id, state)
str format_label_two_rows(self, str text)
on_display_moving_avg_changed(self, value)
_apply_demo_mode(self, bool enabled)
Optional[socket.socket] demo_send_socket
float code_to_voltage(self, int code_uint32)
on_navigation_requested(self, str target)
Optional[QtCore.QTimer] demo_send_timer
on_sensor_label_changed(self, int connector_id, str text)
add_sensor_plot(self, int sensor_id)
__init__(self, receiver, str bind_address="0.0.0.0", parent=None)
__init__(self, receiver, str bind_address="0.0.0.0")
__init__(self, sensor_widget, actuator_widget, parent=None)
on_only_show_actuators_with_roles_changed(self, checked)
on_actuator_role_changed(self, role_name, combo)
on_mapping_changed(self, gauge_name, combo)
on_only_show_pt_with_roles_changed(self, checked)
_on_threshold_changed(self, str key, float value)
on_pt_role_changed(self, role_name, combo)
request_state(self, str mode_name)
str _lighten_color(self, str hex_color, int amount)
update_button_states(self, str current_state)
abort(self, str abort_type="ABORT")
__init__(self, parent=None)
str _darken_color(self, str hex_color, int amount)
__init__(self, int port=DEFAULT_RECEIVE_PORT, str bind_address="0.0.0.0")
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
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens
Definition json.hpp:14471
size_type count(const key_type &key) const
Definition json.hpp:19264
*brief remove last reference token *sa JSON pointer has no parent
Definition json.hpp:13968
int get_actuator_id_from_role(str role_name)
bytes create_actuator_command_packet(List[Tuple[int, int]] commands)
bytes create_pwm_actuator_command_packet(List[Tuple[int, int, float, float]] commands)
Dict[str, Dict[str, bool]] load_state_transitions_csv(Path csv_path)
Tuple[Dict[int, Tuple[float, float, float, float]], Optional[str]] load_pt_calibration(csv_paths)
tuple[Dict[str, Dict[str, str]], int] load_state_machine_csv(Path csv_path)
Optional[Tuple[dict, List[dict]]] parse_sensor_data_packet(bytes data)
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)
float actuator_counts_to_volts(int data_u32)
apply_state_from_csv(actuator_widget, str state_name, Dict[str, Dict[str, str]] state_machine)
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