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
8Requirements: pip install pyqt6 pyqtgraph numpy
19from pathlib
import Path
20from typing
import Optional, Tuple, List, Dict
21from collections
import deque
24_SCRIPT_DIR = Path(__file__).resolve().parent
27PT_CALIBRATION_CSV = str(
28 _SCRIPT_DIR.parent.parent
31 /
"PT Calibration Attempt 2026-02-04_test2.csv"
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):
39 "/opt/homebrew/share/qt/plugins/platforms",
40 "/opt/homebrew/Cellar/qtbase/6.10.1/share/qt/plugins/platforms",
42 if os.path.isdir(
"/opt/homebrew/Cellar/qtbase"):
44 os.listdir(
"/opt/homebrew/Cellar/qtbase"), reverse=
True
47 "/opt/homebrew/Cellar/qtbase",
55 _candidates.insert(1, _p)
57 for _p
in _candidates:
58 if os.path.isdir(_p)
and any(
59 _f.startswith(
"libqcocoa")
for _f
in os.listdir(_p)
61 os.environ[
"QT_QPA_PLATFORM_PLUGIN_PATH"] = _p
64from PyQt6
import QtCore, QtGui, QtWidgets
68pg.setConfigOptions(useOpenGL=
True, antialias=
False)
77_file_dir = os.path.dirname(
78 os.path.abspath(__file__)
80_level1 = os.path.dirname(_file_dir)
81_level2 = os.path.dirname(_level1)
82_project_root = os.path.dirname(_level2)
83_calib_path = os.path.join(_project_root,
"scripts",
"calibration")
84if _calib_path
not in sys.path:
85 sys.path.insert(0, _calib_path)
87 from robust_calibration
import (
88 RobustCalibrationFramework,
93 ROBUST_CALIBRATION_AVAILABLE =
True
94except ImportError
as e:
95 ROBUST_CALIBRATION_AVAILABLE =
False
97 f
"⚠️ Robust calibration framework not available: {e}. Using simple polynomial fit."
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]}")
105DIABLO_COMMS_VERSION = 0
120 PWM_ACTUATOR_COMMAND = 10
121 NO_CONNECTION_ABORT = 11
125DEFAULT_SENSOR_IP =
"192.168.2.101"
126DEFAULT_ACTUATOR_IP =
"192.168.2.201"
127DEFAULT_DEVICE_PORT = 5005
128DEFAULT_RECEIVE_PORT = 5006
131PACKET_HEADER_FORMAT =
"<BBI"
132PACKET_HEADER_SIZE = 6
134ACTUATOR_COMMAND_PACKET_FORMAT =
"<B"
135ACTUATOR_COMMAND_PACKET_SIZE = 1
137ACTUATOR_COMMAND_FORMAT =
"<BB"
138ACTUATOR_COMMAND_SIZE = 2
140PWM_ACTUATOR_COMMAND_PACKET_FORMAT =
"<B"
141PWM_ACTUATOR_COMMAND_PACKET_SIZE = 1
143PWM_ACTUATOR_COMMAND_FORMAT =
"<BIff"
144PWM_ACTUATOR_COMMAND_SIZE = 13
146SENSOR_DATA_PACKET_FORMAT =
"<BB"
147SENSOR_DATA_PACKET_SIZE = 2
149SENSOR_DATA_CHUNK_FORMAT =
"<I"
150SENSOR_DATA_CHUNK_SIZE = 4
152SENSOR_DATAPOINT_FORMAT =
"<BI"
153SENSOR_DATAPOINT_SIZE = 5
161 """Actuator current-sense datapoint -> volts.
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.
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]
173DEFAULT_WINDOW_SECONDS = 10.0
175UPDATE_INTERVAL_MS = 100
179DEFAULT_ACTUATOR_ROLE_NAMES = [
187DEFAULT_SENSOR_ROLE_NAMES = [
193DEFAULT_ACTUATOR_ABBREV_TO_ROLE = {
202ACTUATOR_DISPLAY_ORDER_WHEN_ROLES = [
211ACTUATOR_LABELS_FILE = _SCRIPT_DIR /
"actuator_labels.json"
212SENSOR_LABELS_FILE = _SCRIPT_DIR /
"sensor_labels.json"
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"
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.
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),
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,
256 "window_seconds": DEFAULT_WINDOW_SECONDS,
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,
265 "mappings": {
"GN2": 0,
"ETH": 0,
"LOX": 0},
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},
271 "num_connectors": 10,
274 "pt_calibration_csv": [],
275 "state_machine_csv":
"",
276 "state_transitions_csv":
"",
278 "simulate_chamber_pressure":
False,
283 if CONFIG_FILE.exists():
285 with open(CONFIG_FILE,
"r")
as f:
286 loaded = json.load(f)
289 if "sensor_roles" in loaded
and isinstance(
290 loaded[
"sensor_roles"], dict
292 self.
config[
"sensor_roles"] = dict(loaded[
"sensor_roles"])
294 except Exception
as e:
295 print(f
"Error loading config: {e}")
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")
304 names = list(roles.keys())
if roles
else list(DEFAULT_ACTUATOR_ROLE_NAMES)
305 self.
config[
"actuator_role_names"] = names
308 if name
not in names:
311 if name
not in roles:
314 roles = self.
config.setdefault(
"sensor_roles", {})
315 legacy_names = self.
config.pop(
"sensor_role_names",
None)
319 for name
in legacy_names:
320 if name
not in roles:
326 self.
config[
"sensor_roles"] = {k: roles[k]
for k
in ordered}
328 if "actuator_abbrev_to_role" not in self.
config:
329 self.
config[
"actuator_abbrev_to_role"] = dict(
330 DEFAULT_ACTUATOR_ABBREV_TO_ROLE
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:
338 "pt_calibration_csv": [],
339 "state_machine_csv":
"",
340 "state_transitions_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 []
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}")
358 for k, v
in source.items():
359 if isinstance(v, dict)
and k
in target
and isinstance(target[k], dict):
365 """Ordered list of actuator role names (from config)."""
367 self.
config.get(
"actuator_role_names")
368 or list(self.
config.get(
"actuator_roles", {}).keys())
369 or list(DEFAULT_ACTUATOR_ROLE_NAMES)
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())
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
383 return int(self.
config.get(
"num_connectors", 10))
386 """Get number of actuators, dynamically determined from CSV if available."""
389 if csv_path.exists():
392 if num_actuators > 0:
394 if self.
config.get(
"num_actuators", 10) != num_actuators:
395 self.
config[
"num_actuators"] = num_actuators
398 except Exception
as e:
399 print(f
"Warning: Could not determine actuator count from CSV: {e}")
401 return int(self.
config.get(
"num_actuators", 10))
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 []
416 str(_SCRIPT_DIR / path)
if not os.path.isabs(path)
else path
for path
in raw
420 p = (self.
config.get(
"paths")
or {}).get(
"state_machine_csv",
"").strip()
421 return Path(p)
if p
else _DEFAULT_STATE_MACHINE_CSV
424 p = (self.
config.get(
"paths")
or {}).get(
"state_transitions_csv",
"").strip()
425 return Path(p)
if p
else _DEFAULT_STATE_TRANSITIONS_CSV
429 roles = self.
config.get(
"actuator_roles", {})
430 for role, aid
in roles.items():
432 actuator_id = aid[1]
if isinstance(aid, list)
and len(aid) == 2
else aid
433 if actuator_id == idx:
441 roles = self.
config.setdefault(
442 "actuator_roles", {name: 0
for name
in role_names}
445 for r, aid
in list(roles.items()):
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:
451 existing_value = roles.get(role_name, 0)
452 if isinstance(existing_value, list)
and len(existing_value) == 2:
456 int(actuator_id)
if actuator_id
else 0,
461 roles[role_name] = [
"NC", int(actuator_id)]
467 roles = self.
config.get(
"sensor_roles", {})
468 for role, cid
in roles.items():
474 roles = self.
config.setdefault(
"sensor_roles", {})
475 if role_name
not in roles:
477 for r, cid
in list(roles.items()):
478 if cid == connector_id
and r != role_name:
480 roles[role_name] = int(connector_id)
if connector_id
else 0
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)
487 if isinstance(aid, list)
and len(aid) == 2:
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)
495 if isinstance(aid, list)
and len(aid) == 2:
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():
504 if isinstance(aid, list)
and len(aid) == 2:
505 if aid[1] == actuator_id:
507 elif aid == actuator_id:
512 return self.
config.get(
"sensor_roles", {}).get(role_name, 0)
517NUM_CONNECTORS = CONFIG.get_num_connectors()
522 """Get number of actuators dynamically from CSV/config."""
523 return CONFIG.get_num_actuators()
526pg.setConfigOptions(antialias=
False)
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
540 if not csv_path.exists():
541 print(f
"Warning: State machine CSV not found at {csv_path}")
542 return state_machine, num_actuators
545 with open(csv_path,
"r", newline=
"", encoding=
"utf-8")
as f:
546 reader = csv.reader(f)
550 print(f
"Warning: State machine CSV has insufficient rows")
551 return state_machine, num_actuators
556 col.strip()
for col
in header[1:]
if col.strip()
563 actuator_abbrev = row[0].strip()
564 if not actuator_abbrev:
570 for i, state
in enumerate(states):
572 value = row[i + 1].strip().upper()
574 if state.strip().lower() ==
"debug":
576 if state
not in state_machine:
577 state_machine[state] = {}
579 if value
in [
"OPEN",
"CLOSE",
"CLOSED"]:
580 state_machine[state][actuator_abbrev] = (
581 "OPEN" if value ==
"OPEN" else "CLOSE"
584 except Exception
as e:
585 print(f
"Error loading state machine CSV: {e}")
588 traceback.print_exc()
590 return state_machine, num_actuators
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
600 if not csv_path.exists():
601 print(f
"Warning: State transitions CSV not found at {csv_path}")
605 with open(csv_path,
"r", newline=
"", encoding=
"utf-8")
as f:
606 reader = csv.reader(f)
610 print(f
"Warning: State transitions CSV has insufficient rows")
616 col.strip()
for col
in header[1:]
if col.strip()
623 current_state = row[0].strip()
624 if not current_state:
627 transitions[current_state] = {}
629 for i, next_state
in enumerate(next_states):
631 value = row[i + 1].strip()
633 transitions[current_state][next_state] = value ==
"1"
634 except Exception
as e:
635 print(f
"Error loading state transitions CSV: {e}")
638 traceback.print_exc()
644 """Get actuator ID from role name. Returns 0 if not found."""
645 return CONFIG.get_actuator_role(role_name)
649 actuator_widget, state_name: str, state_machine: Dict[str, Dict[str, str]]
652 Apply actuator states from CSV for the given state name.
662 if state_name
not in state_machine:
663 print(f
"Warning: State '{state_name}' not found in CSV")
667 abbrev_to_role = CONFIG.get_actuator_abbrev_to_role()
668 state_config = state_machine[state_name]
673 desired_by_actuator_id: Dict[int, int] = {
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")
683 role_name = abbrev_to_role[abbrev]
688 f
"Warning: Actuator role '{role_name}' ({abbrev}) not assigned to any actuator"
693 gui_state = 1
if action ==
"OPEN" else 0
696 desired_by_actuator_id[actuator_id] = gui_state
701 desired = desired_by_actuator_id.get(actuator_id, 0)
702 array_idx = actuator_id - 1
704 actuator_widget.actuator_states[array_idx]
705 if 0 <= array_idx < len(actuator_widget.actuator_states)
708 if current
is None or current != desired:
709 actuator_widget.set_actuator_state(actuator_id, desired, force=
True)
714 adc_code: float, PT_A: float, PT_B: float, PT_C: float, PT_D: 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
722) -> Tuple[Dict[int, Tuple[float, float, float, float]], Optional[str]]:
724 Load PT calibration coefficients from one or more CSV files.
726 csv_paths: Single CSV path (str) or list of CSV paths. Empty string/list uses default.
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.
742 if isinstance(csv_paths, str):
743 csv_paths = [csv_paths]
if csv_paths.strip()
else []
744 elif not isinstance(csv_paths, list):
749 csv_paths = [PT_CALIBRATION_CSV]
755 for file_path
in csv_paths:
756 if not file_path
or not file_path.strip():
758 file_path = file_path.strip()
761 if not os.path.isabs(file_path):
762 file_path = os.path.join(
763 os.path.dirname(os.path.abspath(__file__)), file_path
766 if not os.path.isfile(file_path):
770 if file_path.lower().endswith(
".json"):
771 with open(file_path,
"r", encoding=
"utf-8")
as f:
773 polys = data.get(
"calibration_polynomials", {})
774 for pt_id_str, coeffs
in polys.items():
775 pt_num = int(pt_id_str)
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)
785 duplicates[pt_num].append(os.path.basename(file_path))
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)
794 with open(file_path, newline=
"", encoding=
"utf-8")
as f:
795 reader = csv.DictReader(f)
797 fieldnames = reader.fieldnames
or []
803 for col
in fieldnames:
804 m = re.match(
r"PT(\d+)\s+Coefficient\s+0", col, re.IGNORECASE)
806 pt_nums.add(int(m.group(1)))
808 csv_pt_map[file_path] = pt_nums
812 for pt_num
in sorted(pt_nums):
815 if pt_num
not in duplicates:
816 duplicates[pt_num] = []
818 for prev_path, prev_pt_nums
in csv_pt_map.items():
819 if prev_path == file_path:
821 if pt_num
in prev_pt_nums:
822 duplicates[pt_num].append(
823 os.path.basename(prev_path)
825 duplicates[pt_num].append(os.path.basename(file_path))
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)}"
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)
847from demo_sensor_sender
import build_demo_packet
852 """Parse the packet header. Returns: (packet_type, version, timestamp) or None"""
853 if len(data) < PACKET_HEADER_SIZE:
856 packet_type, version, timestamp = struct.unpack(
857 PACKET_HEADER_FORMAT, data[:PACKET_HEADER_SIZE]
859 return (packet_type, version, timestamp)
865 """Parse a sensor data packet. Returns: (header_dict, chunks_list) or None"""
866 if len(data) < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE:
870 if header
is None or header[0] != PacketType.SENSOR_DATA:
873 packet_type, version, timestamp = header
875 offset = PACKET_HEADER_SIZE
877 num_chunks, num_sensors = struct.unpack(
878 SENSOR_DATA_PACKET_FORMAT, data[offset : offset + SENSOR_DATA_PACKET_SIZE]
883 offset += SENSOR_DATA_PACKET_SIZE
885 per_chunk_size = SENSOR_DATA_CHUNK_SIZE + (num_sensors * SENSOR_DATAPOINT_SIZE)
887 PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE + (num_chunks * per_chunk_size)
890 if len(data) < expected_size:
894 for chunk_idx
in range(num_chunks):
896 (chunk_timestamp,) = struct.unpack(
897 SENSOR_DATA_CHUNK_FORMAT, data[offset : offset + SENSOR_DATA_CHUNK_SIZE]
902 offset += SENSOR_DATA_CHUNK_SIZE
905 for sensor_idx
in range(num_sensors):
907 sensor_id, sensor_data = struct.unpack(
908 SENSOR_DATAPOINT_FORMAT,
909 data[offset : offset + SENSOR_DATAPOINT_SIZE],
911 datapoints.append({
"sensor_id": sensor_id,
"data": sensor_data})
912 offset += SENSOR_DATAPOINT_SIZE
916 chunks.append({
"timestamp": chunk_timestamp,
"datapoints": datapoints})
919 "packet_type": packet_type,
921 "timestamp": timestamp,
924 return (header_dict, chunks)
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
934 if len(commands) == 0
or len(commands) > 255:
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
943 if total_size > MAX_PACKET_SIZE:
947 packet = bytearray(total_size)
951 packet_type = PacketType.ACTUATOR_COMMAND
952 version = DIABLO_COMMS_VERSION
953 timestamp = int(
time.time() * 1000) & 0xFFFFFFFF
956 PACKET_HEADER_FORMAT, packet, offset, packet_type, version, timestamp
958 offset += PACKET_HEADER_SIZE
961 num_commands = len(commands)
962 struct.pack_into(ACTUATOR_COMMAND_PACKET_FORMAT, packet, offset, num_commands)
963 offset += ACTUATOR_COMMAND_PACKET_SIZE
966 for actuator_id, actuator_state
in commands:
968 ACTUATOR_COMMAND_FORMAT, packet, offset, actuator_id, actuator_state
970 offset += ACTUATOR_COMMAND_SIZE
976 commands: List[Tuple[int, int, float, float]],
979 Creates a PWM Actuator Command packet.
980 Packet layout: PacketHeader + PWMActuatorCommandPacket + N PWMActuatorCommand.
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)
985 if len(commands) == 0
or len(commands) > 255:
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
993 if total_size > MAX_PACKET_SIZE:
996 packet = bytearray(total_size)
1001 PACKET_HEADER_FORMAT,
1004 PacketType.PWM_ACTUATOR_COMMAND,
1005 DIABLO_COMMS_VERSION,
1008 offset += PACKET_HEADER_SIZE
1011 struct.pack_into(PWM_ACTUATOR_COMMAND_PACKET_FORMAT, packet, offset, len(commands))
1012 offset += PWM_ACTUATOR_COMMAND_PACKET_SIZE
1015 for actuator_id, duration_ms, duty_cycle, frequency
in commands:
1017 PWM_ACTUATOR_COMMAND_FORMAT,
1025 offset += PWM_ACTUATOR_COMMAND_SIZE
1027 return bytes(packet)
1032 """Thread that receives UDP packets and emits decoded sensor data"""
1034 sensor_data_received = QtCore.pyqtSignal(
1037 status_update = QtCore.pyqtSignal(str)
1038 packet_received = QtCore.pyqtSignal(int, int)
1040 def __init__(self, port: int = DEFAULT_RECEIVE_PORT, bind_address: str =
"0.0.0.0"):
1051 """Stop the receiver thread"""
1060 """Get current statistics"""
1065 "packets_per_sec": 0.0,
1066 "bytes_per_sec": 0.0,
1080 "packets_per_sec": pps,
1081 "bytes_per_sec": bps,
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)
1095 except OSError
as e:
1099 while not self.
_stop:
1101 data, addr = self.
sock.recvfrom(MAX_PACKET_SIZE)
1109 packet_type, version, timestamp = header
1112 if packet_type == PacketType.SENSOR_DATA:
1115 header_dict, chunks = result
1119 except socket.timeout:
1121 except Exception
as e:
1136 """Popup showing network stats, C1, MA, and psi for each connector."""
1141 self.setWindowTitle(
"Sensor debug")
1142 self.setMinimumWidth(320)
1143 layout = QtWidgets.QVBoxLayout(self)
1145 net_group = QtWidgets.QGroupBox(
"Network")
1146 net_layout = QtWidgets.QVBoxLayout()
1148 self.
pps_lbl = QtWidgets.QLabel(
"Packets/sec: 0.0")
1150 self.
bps_lbl = QtWidgets.QLabel(
"Bytes/sec: 0.0 B/s")
1152 net_layout.addWidget(w)
1153 net_group.setLayout(net_layout)
1154 layout.addWidget(net_group)
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")
1163 layout.addWidget(close_btn)
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']}")
1179 self.
table.setRowCount(NUM_CONNECTORS)
1180 for i
in range(1, NUM_CONNECTORS + 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}")
1186 c = dv.get(
"current")
1187 ma = dv.get(
"moving_avg")
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 "---")
1194 item = self.
table.item(row, col)
1196 item = QtWidgets.QTableWidgetItem(text)
1197 self.
table.setItem(row, col, item)
1208 """Reusable sensor/PT plotting panel. Used in SensorPlotWindow and CombinedMainWindow."""
1210 def __init__(self, receiver, bind_address: str =
"0.0.0.0", parent=
None):
1245 i:
True for i
in range(1, NUM_CONNECTORS + 1)
1252 CONFIG.get_pt_calibration_csv_paths()
1255 print(f
"PT Calibration Error: {self.pt_calibration_error}")
1264 i: CONFIG.get_sensor_label(i)
for i
in range(1, NUM_CONNECTORS + 1)
1282 disp = CONFIG.config[
"display"]
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."""
1331 words = text.strip().
split()
1334 elif len(words) == 1:
1338 parts = re.split(
r"(\d+)", word)
1339 parts = [p
for p
in parts
if p]
1341 return f
"{parts[0]}\n{''.join(parts[1:])}"
1345 elif len(words) >= 2:
1347 return f
"{words[0]}\n{words[1]}"
1351 """Update local sensor label and plot legend (config is written via set_sensor_role in settings)."""
1356 display_name = text
if text
else f
"PT {connector_id}"
1370 """Update the legend for a specific sensor plot"""
1377 new_name = f
"PT {connector_id}: {label} (psi)"
1379 new_name = f
"PT {connector_id} (psi)"
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)
1390 """Initialize the user interface"""
1391 layout = QtWidgets.QVBoxLayout(self)
1392 layout.setContentsMargins(0, 0, 0, 0)
1395 top_panel = QtWidgets.QHBoxLayout()
1399 self.
status_label.setStyleSheet(
"font-weight: bold; padding: 5px;")
1405 top_panel.addStretch()
1415 btn_save_pressures = QtWidgets.QPushButton(
"Save Pressures CSV")
1417 btn_save_pressures.setStyleSheet(
"padding: 5px;")
1418 top_panel.addWidget(btn_save_pressures)
1420 btn_save_events = QtWidgets.QPushButton(
"Save Events CSV")
1422 btn_save_events.setStyleSheet(
"padding: 5px;")
1423 top_panel.addWidget(btn_save_events)
1425 layout.addLayout(top_panel)
1428 plot_stats_layout = QtWidgets.QHBoxLayout()
1430 left_column = QtWidgets.QWidget()
1431 left_column_layout = QtWidgets.QVBoxLayout(left_column)
1432 left_column_layout.setContentsMargins(0, 0, 0, 0)
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)
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)
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)
1483 under_graph_layout.addStretch()
1484 left_column_layout.addWidget(under_graph_widget)
1486 plot_stats_layout.addWidget(left_column, 1)
1487 layout.addLayout(plot_stats_layout, 1)
1491 title=
"Pressure Data Over Time (Calibrated Sensors)"
1496 "Pressure Data Over Time (Calibrated Sensors)", color=
"w", size=
"14pt"
1500 self.
plot_item.setLabel(
"left",
"Pressure (psi)", color=
"w")
1501 self.
plot_item.setLabel(
"bottom",
"Time (seconds)", color=
"w")
1506 self.
plot_item.showGrid(x=
True, y=
True, alpha=0.5)
1509 self.
plot_item.getViewBox().setBackgroundColor(
"k")
1512 font = QtGui.QFont()
1513 font.setPointSize(12)
1516 left_axis = self.
plot_item.getAxis(
"left")
1517 bottom_axis = self.
plot_item.getAxis(
"bottom")
1520 left_axis.setStyle(tickFont=font)
1521 bottom_axis.setStyle(tickFont=font)
1525 left_axis.label.setFont(font)
1526 bottom_axis.label.setFont(font)
1527 except AttributeError:
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")
1542 text=
"SPS: 0", color=(200, 200, 200), anchor=(0, 1)
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)
1566 """Reload PT calibration from config paths (e.g. after user changes CSV in settings)."""
1568 CONFIG.get_pt_calibration_csv_paths()
1571 print(f
"PT Calibration Error: {self.pt_calibration_error}")
1574 QtWidgets.QMessageBox.warning(
1576 "PT Calibration Error",
1579 for connector_id
in range(1, NUM_CONNECTORS + 1):
1596 """Zero all PTs: capture current PSI reading for each calibrated sensor and store as offset."""
1599 if not adc_d
or len(adc_d) == 0:
1602 adc_tail = [adc_d[-1 - j][1]
for j
in range(n)]
1603 moving_avg_adc = sum(adc_tail) / n
1609 """Clear all PT zero offsets (restore raw calibration output)."""
1618 """Open the debug popup showing C1, MA, psi per connector."""
1623 """Handle auto-scale Y-axis toggle"""
1634 """Clear all sensor data deques so plot/stats show only new data."""
1645 """Handle Demo mode checkbox: toggle demo_mode, start/stop UDP sender, clear deques.
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).
1654 """Apply demo mode state change (called via QTimer to avoid Wayland crashes)."""
1659 self.
status_label.setText(
"Demo mode – synthetic data (UDP)")
1683 """Update plot buffer size based on current window_seconds."""
1701 """Handle graph moving average window size change"""
1705 """Handle display moving average window size change"""
1710 Convert raw ADC code to voltage.
1711 code_uint32: uint32_t representation of the ADC code
1712 Returns: voltage in Volts
1715 if code_uint32 >= 0x80000000:
1716 code_int32 = code_uint32 - 0x100000000
1718 code_int32 = code_uint32
1722 max_code = 2 ** (self.
adc_bits - 1)
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()
1736 """Handle status updates from receiver thread"""
1741 """Handle packet received notification"""
1745 """Handle received sensor data"""
1747 if source_ip !=
"127.0.0.1":
1757 for chunk
in chunks:
1758 chunk_timestamp_ms = chunk[
"timestamp"]
1762 for dp
in chunk[
"datapoints"]:
1763 sensor_id_packet = dp[
"sensor_id"]
1768 if sensor_id_packet == 0:
1772 sensor_id = sensor_id_packet
1775 if sensor_id < 1
or sensor_id > NUM_CONNECTORS:
1778 code_uint32 = dp[
"data"]
1785 self.
sensor_data[sensor_id] = deque(maxlen=MAX_POINTS)
1799 self.
sensor_data[sensor_id].append((relative_time, voltage))
1812 (relative_time, raw_psi)
1816 """Build and send one demo UDP packet to localhost (called by demo timer)."""
1823 packet = build_demo_packet(
1827 packet_type=PacketType.SENSOR_DATA,
1828 version=DIABLO_COMMS_VERSION,
1829 max_packet_size=MAX_PACKET_SIZE,
1840 """Add a new sensor plot (for calibrated sensors only)"""
1843 color_idx = sensor_id % len(SENSOR_COLORS)
1844 color = SENSOR_COLORS[color_idx]
1849 plot_name = f
"PT {sensor_id}: {label} (psi)"
1851 plot_name = f
"PT {sensor_id} (psi)"
1853 pen = pg.mkPen(color=color, width=2)
1854 plot = self.
plot_item.plot([], [], pen=pen, name=plot_name)
1859 for item
in self.
legend.items:
1860 if len(item) >= 2
and item[0] == plot:
1861 label_item = item[1]
1863 label_item.setColor(
"w")
1868 """Update all sensor plots (PSI data for calibrated sensors only)"""
1883 if not pt_label
or pt_label.strip() ==
"":
1897 times_all = np.array(t_deque)
1898 psi_all = np.array(v_deque)
1901 start_time = current_time - time_window
1902 start_idx = np.searchsorted(times_all, start_time)
1904 times_array = times_all[start_idx:]
1905 psi_array = psi_all[start_idx:]
1907 if len(times_array) == 0:
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
1918 times_array = times_array[indices]
1919 psi_array = psi_array[indices]
1930 smoothed_psi = np.convolve(psi_array, kernel, mode=
"valid")
1932 self.
sensor_plots[sensor_id].setData(smoothed_times, smoothed_psi)
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}")
1940 if current_time > time_window:
1942 current_time - time_window, current_time, padding=0
1945 self.
plot_item.setXRange(0, time_window, padding=0)
1950 self.
plot_item.disableAutoRange(axis=
"y")
1952 y_lo, y_hi = float(
"inf"), float(
"-inf")
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()))
1972 pad =
max((y_hi - y_lo) * 0.05, 1.0)
1973 target_lo = y_lo - pad
1974 target_hi = y_hi + pad
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)
1983 self.
plot_item.disableAutoRange(axis=
"y")
1987 view_range = view_box.viewRange()
1988 x_min = view_range[0][0]
1989 y_min = view_range[1][0]
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))
1996 """Update statistics display"""
2002 self.
network_stats[
"pps"] = f
"{stats['packets_per_sec']:.2f}"
2003 bytes_val = stats[
"bytes"]
2004 if bytes_val < 1024:
2006 elif bytes_val < 1024 * 1024:
2009 self.
network_stats[
"bytes"] = f
"{bytes_val / (1024 * 1024):.2f} MB"
2010 bps = stats[
"bytes_per_sec"]
2013 elif bps < 1024 * 1024:
2016 self.
network_stats[
"bps"] = f
"{bps / (1024 * 1024):.2f} MB/s"
2022 if time_diff >= 0.5:
2024 sps = sample_diff / time_diff
2032 sps = getattr(self,
"current_sps_display", 0.0)
2037 for connector_id
in range(1, NUM_CONNECTORS + 1):
2038 display_text =
"---"
2045 if d
and len(d) > 0:
2049 tail_values = [d[-1 - j][1]
for j
in range(n_samples)]
2051 moving_avg = sum(tail_values) / n_samples
2053 self.
debug_values[connector_id][
"moving_avg"] = moving_avg
2056 if connector_id
in self.
pt_calibration and adc_d
and len(adc_d) > 0:
2058 adc_tail = [adc_d[-1 - j][1]
for j
in range(n_adc)]
2059 moving_avg_adc = sum(adc_tail) / n_adc
2062 moving_avg_adc, a, b, c, dd
2065 display_text = f
"<span style='font-size:16pt; font-weight:600'>{psi_val:.2f}</span> <span style='font-size:8pt'>psi</span>"
2067 display_text = f
"<span style='font-size:16pt; font-weight:600'>{moving_avg:.4f}</span> <span style='font-size:8pt'>V</span>"
2073 if not pt_label
or pt_label.strip() ==
"":
2081 """Save pressure data to CSV file, respecting only_show_pt_with_roles setting."""
2084 QtWidgets.QMessageBox.warning(
2085 self,
"No Data",
"No pressure data available to export."
2090 sensors_to_save = []
2095 if label
and label.strip():
2096 sensors_to_save.append(sensor_id)
2099 sensors_to_save.append(sensor_id)
2101 if not sensors_to_save:
2102 QtWidgets.QMessageBox.warning(
2103 self,
"No Data",
"No sensors with roles available to export."
2108 timestamp = time.strftime(
"%Y%m%d_%H%M%S")
2109 default_filename = f
"pressures_{timestamp}.csv"
2112 filename, _ = QtWidgets.QFileDialog.getSaveFileName(
2114 "Save Pressures CSV File",
2116 "CSV Files (*.csv);;All Files (*)",
2123 with open(filename,
"w", newline=
"", encoding=
"utf-8")
as csvfile:
2124 writer = csv.writer(csvfile)
2127 header = [
"Time (s)"]
2128 for sensor_id
in sensors_to_save:
2131 header.append(f
"PT{sensor_id}: {label} (psi)")
2133 header.append(f
"PT{sensor_id} (psi)")
2134 writer.writerow(header)
2141 for sensor_id
in sensors_to_save:
2143 if t
not in data_map:
2145 data_map[t][sensor_id] = psi
2149 QtWidgets.QMessageBox.warning(
2150 self,
"No Data",
"No timestamp data available."
2155 for t
in sorted(all_times):
2157 row_data = data_map[t]
2158 for sensor_id
in sensors_to_save:
2160 val = row_data.get(sensor_id,
"")
2162 row.append(f
"{val:.6f}")
2165 writer.writerow(row)
2167 QtWidgets.QMessageBox.information(
2168 self,
"Success", f
"Saved pressures CSV to {filename}"
2173 zeroed_filename = filename.replace(
".csv",
"_zeroed.csv")
2174 if zeroed_filename == filename:
2175 zeroed_filename = filename +
"_zeroed.csv"
2177 with open(zeroed_filename,
"w", newline=
"", encoding=
"utf-8")
as zf:
2178 zwriter = csv.writer(zf)
2180 zheader = [
"Time (s)"]
2181 for sensor_id
in sensors_to_save:
2184 f
"PT{sensor_id}: {label} (psi, zeroed)"
2186 else f
"PT{sensor_id} (psi, zeroed)"
2189 zwriter.writerow(zheader)
2191 for t
in sorted(all_times):
2193 row_data = data_map[t]
2194 for sensor_id
in sensors_to_save:
2195 val = row_data.get(sensor_id,
"")
2200 row.append(f
"{zeroed_val:.6f}")
2203 zwriter.writerow(row)
2204 QtWidgets.QMessageBox.information(
2207 f
"Also saved zeroed pressures to:\n{zeroed_filename}",
2209 except Exception
as ze:
2210 QtWidgets.QMessageBox.warning(
2211 self,
"Zeroed CSV Error", f
"Could not save zeroed CSV: {ze}"
2213 except Exception
as e:
2214 QtWidgets.QMessageBox.critical(self,
"Error", f
"CSV save error: {e}")
2217 """Save events (mode button clicks and actuator state changes) to CSV file."""
2219 QtWidgets.QMessageBox.warning(
2220 self,
"Error",
"Main window reference not available."
2226 QtWidgets.QMessageBox.warning(
2227 self,
"No Data",
"No events available to export."
2232 timestamp = time.strftime(
"%Y%m%d_%H%M%S")
2233 default_filename = f
"events_{timestamp}.csv"
2236 filename, _ = QtWidgets.QFileDialog.getSaveFileName(
2238 "Save Events CSV File",
2240 "CSV Files (*.csv);;All Files (*)",
2247 with open(filename,
"w", newline=
"", encoding=
"utf-8")
as csvfile:
2248 writer = csv.writer(csvfile)
2251 writer.writerow([
"Time (s)",
"Event Type",
"Details"])
2254 for event_time, event_type, details
in events:
2255 writer.writerow([f
"{event_time:.6f}", event_type, details])
2257 QtWidgets.QMessageBox.information(
2258 self,
"Success", f
"Saved events CSV to {filename}"
2260 except Exception
as e:
2261 QtWidgets.QMessageBox.critical(self,
"Error", f
"CSV save error: {e}")
2266 """Standalone window that embeds SensorPlotWidget."""
2268 def __init__(self, receiver, bind_address: str =
"0.0.0.0"):
2270 self.setWindowTitle(f
"Sensor Data Receiver - Port {receiver.port}")
2271 self.setGeometry(100, 100, 1200, 800)
2275 """Handle window close event"""
2276 w = self.centralWidget()
2277 if w
and hasattr(w,
"save_sensor_labels"):
2278 w.save_sensor_labels()
2284 """Reusable actuator control panel. Used in ActuatorControlWindow and CombinedMainWindow."""
2287 self, receiver, device_ip: str =
None, device_port: int =
None, parent=
None
2302 i: CONFIG.get_actuator_type_by_id(i)
2325 disp = CONFIG.config[
"display"]
2327 "only_show_actuators_with_roles",
False
2345 """Initialize the user interface"""
2346 layout = QtWidgets.QVBoxLayout(self)
2347 layout.setContentsMargins(0, 0, 0, 0)
2358 if num_actuators <= 8:
2360 elif num_actuators <= 16:
2367 for i
in range(num_actuators):
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;"
2382 actuator_layout = QtWidgets.QVBoxLayout(actuator_frame)
2383 actuator_layout.setContentsMargins(0, 0, 0, 0)
2384 actuator_layout.setSpacing(1)
2389 self.
actuator_labels.get(actuator_id,
"")
or f
"Actuator {actuator_id}"
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;"
2397 actuator_layout.addWidget(id_label)
2400 button_container = QtWidgets.QHBoxLayout()
2401 button_container.setSpacing(3)
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(
2412 background-color: {bg_color};
2414 border: 2px solid {bg_color};
2420 on_btn.clicked.connect(
2425 off_btn = QtWidgets.QPushButton(
"CLOSED")
2426 off_btn.setMinimumHeight(30)
2427 off_btn.setStyleSheet(
2432 background-color: {bg_color};
2434 border: 2px solid {bg_color};
2440 off_btn.clicked.connect(
2444 button_container.addWidget(on_btn)
2445 button_container.addWidget(off_btn)
2446 actuator_layout.addLayout(button_container)
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;"
2456 actuator_layout.addWidget(voltage_label)
2459 self.
grid_layout.addWidget(actuator_frame, row, col)
2464 "frame": actuator_frame,
2467 "voltage_label": voltage_label,
2468 "id_label": id_label,
2475 self.
pwm_btn = QtWidgets.QPushButton(
"PWM CONTROL")
2476 self.
pwm_btn.setMinimumHeight(40)
2482 background-color: #505050;
2484 border: 2px solid #404040;
2490 background-color: #606060;
2491 border-color: #505050;
2493 QPushButton:pressed {
2494 background-color: #404040;
2495 border-color: #303030;
2500 layout.addWidget(self.
pwm_btn)
2508 array_idx = actuator_id - 1
2509 if actuator_type ==
"NO":
2524 """Open the PWM control window, creating it lazily."""
2538 """External method to update label display when changed in settings"""
2539 idx = actuator_id - 1
2541 new_text = text
or f
"Actuator {actuator_id}"
2545 """Update local actuator label and display (config is written via set_actuator_role in settings)."""
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.
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
2570 visible_widgets.append(widget)
2571 widget[
"frame"].setVisible(
True)
2575 if widget
not in visible_widgets:
2576 widget[
"frame"].setVisible(
False)
2579 for idx, widget
in enumerate(visible_widgets):
2582 self.
grid_layout.addWidget(widget[
"frame"], row, col)
2588 widget[
"frame"].setVisible(
True)
2596 self.
grid_layout.addWidget(widget[
"frame"], row, col)
2599 """Handle status updates from receiver thread"""
2603 """Handle received sensor data (actuator current-sense voltage readings)."""
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))
2618 actuator_id = sensor_id_packet
2620 actuator_id = sensor_id_packet + 1
2624 array_idx = actuator_id - 1
2631 """Update button highlighting based on actuator state. Selected OPEN = saturated green, selected CLOSED = saturated red."""
2634 widget[
"frame"].palette().color(QtGui.QPalette.ColorRole.Window).name()
2637 inactive_style = f
"""
2641 background-color: {bg_color};
2643 border: 2px solid {bg_color};
2649 active_open_style =
"""
2653 background-color: #008800;
2655 border: 2px solid #008800;
2660 active_closed_style =
"""
2664 background-color: #dc143c;
2666 border: 2px solid #dc143c;
2671 if actuator_state == 1:
2672 widget[
"on_btn"].setStyleSheet(active_open_style)
2673 widget[
"off_btn"].setStyleSheet(inactive_style)
2675 widget[
"on_btn"].setStyleSheet(inactive_style)
2676 widget[
"off_btn"].setStyleSheet(active_closed_style)
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)
2691 array_idx = actuator_id - 1
2697 if actuator_type ==
"NO":
2699 hardware_command = 0
if gui_state == 1
else 1
2702 hardware_command = gui_state
2711 details = f
"Actuator {actuator_id} ({label}): {'OPEN' if gui_state else 'CLOSED'}"
2713 details = f
"Actuator {actuator_id}: {'OPEN' if gui_state else 'CLOSED'}"
2718 Send an actuator command packet to the device.
2719 hardware_command: 0 = OFF, 1 = ON (hardware level)
2722 commands = [(actuator_id, hardware_command)]
2727 array_idx = actuator_id - 1
2730 f
"Sent command: Actuator {actuator_id} -> {'OPEN' if gui_state else 'CLOSED'} (hardware: {'ON' if hardware_command else 'OFF'})"
2733 print(f
"Error: Failed to create packet for actuator {actuator_id}")
2734 except OSError
as e:
2737 msg = f
"No route to host — check device IP ({self.device_ip})"
2739 msg = f
"Network unreachable — check WiFi/Ethernet"
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}")
2747 """Update the voltage reading display for all actuators"""
2751 widget[
"voltage_label"].setText(f
"{voltage:.3f} V")
2754 """Close the command socket"""
2765 Dedicated window for detailed actuator control (PWM).
2766 Allows setting frequency, duty cycle, duration, and arming commands for the next state transition.
2771 self.setWindowTitle(
"Actuator PWM Control")
2772 self.resize(900, 700)
2781 main_layout = QtWidgets.QVBoxLayout(self)
2784 header = QtWidgets.QLabel(
"Actuator PWM Configuration")
2785 header.setStyleSheet(
"font-size: 16pt; font-weight: bold; margin-bottom: 10px;")
2786 main_layout.addWidget(header)
2789 scroll = QtWidgets.QScrollArea()
2790 scroll.setWidgetResizable(
True)
2791 scroll_content = QtWidgets.QWidget()
2799 row_widget = QtWidgets.QGroupBox(f
"Actuator {actuator_id}")
2800 row_layout = QtWidgets.QHBoxLayout(row_widget)
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)
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)
2819 row_layout.addWidget(QtWidgets.QLabel(
"Freq (Hz):"))
2820 freq_spin = QtWidgets.QDoubleSpinBox()
2824 freq_spin.setValue(10.0)
2825 freq_spin.setDecimals(1)
2826 row_layout.addWidget(freq_spin)
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)
2837 btn_exec = QtWidgets.QPushButton(
"Execute Now")
2838 btn_exec.setStyleSheet(
2839 "background-color: #2ecc71; color: white; font-weight: bold;"
2841 btn_exec.clicked.connect(
2842 lambda checked=
False, aid=actuator_id: self.
execute_now(aid)
2844 row_layout.addWidget(btn_exec)
2847 chk_arm = QtWidgets.QCheckBox(
"Arm for Next State")
2848 chk_arm.setStyleSheet(
"margin-left: 10px;")
2849 chk_arm.toggled.connect(
2852 row_layout.addWidget(chk_arm)
2864 scroll.setWidget(scroll_content)
2865 main_layout.addWidget(scroll)
2868 bottom_bar = QtWidgets.QHBoxLayout()
2873 bottom_bar.addStretch()
2875 btn_disarm_all = QtWidgets.QPushButton(
"Disarm All")
2876 btn_disarm_all.setStyleSheet(
2877 "background-color: #e74c3c; color: white; padding: 5px 15px;"
2880 bottom_bar.addWidget(btn_disarm_all)
2882 btn_close = QtWidgets.QPushButton(
"Close")
2883 btn_close.clicked.connect(self.
hide)
2884 bottom_bar.addWidget(btn_close)
2886 main_layout.addLayout(bottom_bar)
2889 """Send PWM command immediately."""
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)
2897 commands = [(actuator_id, dur_ms, duty, freq)]
2906 sock.sendto(packet, (ip, port))
2907 self.
lbl_status.setText(f
"Executed PWM on Actuator {actuator_id}")
2909 f
"Manual PWM: ID={actuator_id} F={freq}Hz D={duty*100}% T={dur_s}s"
2911 except Exception
as e:
2915 """Update the armed status dict based on checkbox."""
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)
2923 self.
active_arms[actuator_id] = (dur_ms, duty, freq)
2924 self.
lbl_status.setText(f
"Armed Actuator {actuator_id} for next state")
2928 self.
lbl_status.setText(f
"Disarmed Actuator {actuator_id}")
2931 """Uncheck all arm checkboxes."""
2933 widgets[
"arm_chk"].setChecked(
2937 self.
lbl_status.setText(
"All actuators disarmed")
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."""
2945 for aid, (dur, duty, freq)
in self.
active_arms.items():
2946 commands.append((aid, dur, duty, freq))
2950 """Clear all arms after execution (update UI)."""
2960 Vertical bar gauge: four dashed lines at 20%, 40%, 60%, 80% of bar height (global Y from parent).
2962 y80=POP, y60=MEOP, y40=NOP, y20=THRESH. Bar outline and fill are full height; fill is linear in pressure.
2967 VALUE_BOX_HEIGHT = 26
2968 BOTTOM_CLEARANCE = 4
2970 BOTTOM_GAP + VALUE_BOX_HEIGHT + BOTTOM_CLEARANCE
2977 meop: float = 700.0,
2978 pop: float = 1000.0,
2979 thresh: float =
None,
2980 fixed_color: QtGui.QColor =
None,
2988 self.
thresh = thresh
if thresh
is not None else (nop * 0.5)
2997 self.setMinimumWidth(79)
2998 self.setMaximumWidth(83)
2999 self.setMinimumHeight(100)
3001 QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding
3005 """Set the four dashed-line Y positions in parent coordinates (same for all three bars)."""
3017 thresh: float =
None,
3018 scale_max: float =
None,
3020 """Update NOP, MEOP, POP, THRESH and optional shared scale_max; repaint."""
3024 if thresh
is not None:
3026 if scale_max
is not None:
3035 painter = QtGui.QPainter(self)
3036 painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
3046 bar_w = w - left_margin - gap - label_width
3047 bar_w =
max(40, bar_w)
3049 28, int(bar_w * 0.85)
3051 bar_x = left_margin + (bar_w - draw_w) // 2
3052 bar_h = h - top_margin - bottom_margin
3054 bar_bottom_y = bar_y + bar_h
3055 label_x = bar_x + draw_w + 4
3058 painter.setPen(QtCore.Qt.GlobalColor.white)
3060 QtCore.QRect(bar_x, 0, draw_w, top_margin),
3061 QtCore.Qt.AlignmentFlag.AlignCenter,
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)
3076 segment = (v / thresh) * 0.20
if thresh > 0
else 0.0
3079 0.20 + (v - thresh) / (nop - thresh) * 0.20
if nop > thresh
else 0.20
3082 segment = 0.40 + (v - nop) / (meop - nop) * 0.20
if meop > nop
else 0.40
3084 segment = 0.60 + (v - meop) / (pop - meop) * 0.20
if pop > meop
else 0.60
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
3096 painter.setBrush(QtGui.QColor(
"#ff7675"))
3098 painter.setBrush(QtGui.QColor(
"#fdcb6e"))
3101 QtGui.QColor(
"#27ae60")
3104 painter.setPen(QtCore.Qt.PenStyle.NoPen)
3105 painter.drawRect(bar_x, fill_y, draw_w, fill_h)
3108 my_y_in_parent = self.geometry().y()
3110 def to_local(parent_y):
3111 return int(parent_y - my_y_in_parent)
if parent_y
is not None else 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)
3127 white_dash = QtGui.QPen(
3128 QtCore.Qt.GlobalColor.white, 1, QtCore.Qt.PenStyle.DashLine
3130 orange_dash = QtGui.QPen(
3131 QtGui.QColor(
"#ff9f43"), 1, QtCore.Qt.PenStyle.DashLine
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)
3143 label_font = painter.font()
3144 label_font.setPointSize(7)
3145 painter.setFont(label_font)
3146 painter.setPen(QtCore.Qt.GlobalColor.white)
3148 for ly, limit_val
in [
3154 label_y = ly - line_height // 2
3155 label_rect = QtCore.QRect(label_x, label_y, label_width, line_height)
3158 QtCore.Qt.AlignmentFlag.AlignLeft
3159 | QtCore.Qt.AlignmentFlag.AlignVCenter,
3160 f
"{int(limit_val)}",
3170 QtGui.QColor(
"#ff7675")
3173 QtGui.QColor(
"#fdcb6e")
3175 else QtGui.QColor(
"#27ae60")
3179 painter.setPen(QtGui.QPen(box_color, 2))
3180 painter.setBrush(box_color)
3182 painter.drawRoundedRect(val_rect, radius, radius)
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)
3194 navigation_requested = QtCore.pyqtSignal(str)
3195 mode_changed = QtCore.pyqtSignal(str)
3196 debug_toggled = QtCore.pyqtSignal(bool)
3199 _COUNTDOWN_TARGET_UTC = QtCore.QDateTime(
3200 QtCore.QDate(2026, 3, 6), QtCore.QTime(20, 0, 0), QtCore.Qt.TimeSpec.UTC
3205 self.setFixedHeight(150)
3206 layout = QtWidgets.QHBoxLayout(self)
3207 layout.setContentsMargins(10, 5, 10, 5)
3208 layout.setSpacing(0)
3211 clock_col = QtWidgets.QVBoxLayout()
3212 clock_col.setSpacing(2)
3213 clock_col.setAlignment(QtCore.Qt.AlignmentFlag.AlignVCenter)
3216 self.
lbl_time.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
3218 "font-size: 14pt; font-weight: bold; color: #e0e0e0; font-family: monospace;"
3222 self.
lbl_countdown.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
3224 "font-size: 11pt; font-weight: bold; color: #ffd700; font-family: monospace;"
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;"
3233 clock_col.addStretch()
3235 clock_col.addWidget(lbl_countdown_title)
3237 clock_col.addStretch()
3239 layout.addLayout(clock_col)
3240 layout.addSpacing(8)
3251 pl = CONFIG.config.get(
"pressure_limits", {})
3253 def limits(fluid: str):
3254 d = pl.get(fluid, {})
3256 float(d.get(
"NOP", 600)),
3257 float(d.get(
"MEOP", 650)),
3258 float(d.get(
"POP", 750)),
3259 float(d.get(
"THRESH", 400)),
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)
3273 fixed_color=QtGui.QColor(
"#27ae60"),
3281 fixed_color=QtGui.QColor(
"#ff9f43"),
3289 fixed_color=QtGui.QColor(
"#74b9ff"),
3292 bar.scale_max = scale_max
3294 layout.addWidget(self.
bar_gn2, 0)
3295 layout.addWidget(self.
bar_eth, 0)
3296 layout.addWidget(self.
bar_lox, 0)
3305 mode_button_container = QtWidgets.QVBoxLayout()
3306 mode_button_container.setSpacing(5)
3309 row1_layout = QtWidgets.QHBoxLayout()
3310 row1_layout.setSpacing(5)
3311 row2_layout = QtWidgets.QHBoxLayout()
3312 row2_layout.setSpacing(5)
3342 CONFIG.get_state_transitions_csv_path()
3360 """Return pastel background color for state button"""
3361 state_lower = state_name.lower()
3362 if "vent" in state_lower:
3364 elif "fire" in state_lower
or state_lower ==
"quick fire":
3366 elif "fill" in state_lower:
3368 elif "press" in state_lower:
3370 elif state_lower ==
"armed":
3379 """Return vivid, saturated color for selected state button."""
3380 state_lower = state_name.lower()
3381 if "vent" in state_lower:
3383 elif "fire" in state_lower
or state_lower ==
"quick fire":
3385 elif "fill" in state_lower:
3387 elif "press" in state_lower:
3389 elif state_lower ==
"armed":
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)
3403 lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
3404 return "#ffffff" if lum < 0.6
else "#000000"
3410 for i, label
in enumerate(mode_labels):
3411 btn = QtWidgets.QPushButton(label)
3412 btn.setCheckable(
True)
3414 btn.clicked.connect(
3421 row1_layout.addWidget(btn)
3423 row2_layout.addWidget(btn)
3428 mode_button_container.addLayout(row1_layout)
3429 mode_button_container.addLayout(row2_layout)
3435 layout.addLayout(mode_button_container)
3439 btn_layout = QtWidgets.QVBoxLayout()
3442 abort_row = QtWidgets.QHBoxLayout()
3450 background-color: #e67e22;
3452 border: 2px solid #b85c0e;
3457 background-color: #eb9842;
3458 border-color: #d35400;
3460 QPushButton:pressed {
3461 background-color: #d35400;
3462 border-color: #b85c0e;
3475 background-color: #c0392b;
3477 border: 2px solid #922b21;
3482 background-color: #e74c3c;
3483 border-color: #c0392b;
3485 QPushButton:pressed {
3486 background-color: #a93226;
3487 border-color: #922b21;
3495 btn_layout.addLayout(abort_row)
3499 self.
nav_btn.setMinimumHeight(26)
3504 font-weight: normal;
3505 background-color: #505050;
3507 border: 2px solid #404040;
3512 background-color: #606060;
3513 border-color: #505050;
3515 QPushButton:pressed {
3516 background-color: #404040;
3517 border-color: #303030;
3524 bottom_row = QtWidgets.QHBoxLayout()
3525 bottom_row.addWidget(self.
nav_btn)
3527 btn_layout.addLayout(bottom_row)
3539 font-weight: normal;
3540 background-color: #505050;
3542 border: 2px solid #404040;
3547 background-color: #606060;
3548 border-color: #505050;
3550 QPushButton:pressed {
3551 background-color: #404040;
3552 border-color: #353535;
3554 QPushButton:checked {
3555 background-color: #27ae60;
3557 border: 2px solid #1e8449;
3559 QPushButton:checked:hover {
3560 background-color: #2ecc71;
3561 border-color: #27ae60;
3563 QPushButton:checked:pressed {
3564 background-color: #1e8449;
3565 border-color: #196f3d;
3577 layout.addLayout(btn_layout)
3580 """Update the current-time label and the countdown-to-launch label."""
3581 now_utc = QtCore.QDateTime.currentDateTimeUtc()
3583 now_local = QtCore.QDateTime.currentDateTime()
3584 self.
lbl_time.setText(now_local.toString(
"HH:mm:ss"))
3587 if remaining_ms <= 0:
3590 "font-size: 11pt; font-weight: bold; color: #ff4444; font-family: monospace;"
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}")
3600 """Compute bar rect once from first bar; set same y20,y40,y60,y80 (parent coords) on all three bars."""
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
3612 bar.set_global_line_ys(y20, y40, y60, y80)
3623 """Update the CURRENT STATE label to reflect self.current_state."""
3628 """Handle DEBUG button click (toggle) - unlocks states and manual actuator control"""
3639 """Update button enabled/disabled states and styles based on allowed transitions"""
3658 if current_state
is None:
3660 allowed_transitions = {
"Idle":
True}
3661 for state
in mode_labels:
3663 allowed_transitions[state] =
False
3669 allowed_transitions = {state:
True for state
in mode_labels}
3673 if i < len(mode_labels):
3674 next_state = mode_labels[i]
3675 is_allowed = allowed_transitions.get(next_state,
False)
3684 btn.setEnabled(is_allowed)
3695 background-color: {selected_bg};
3696 color: {selected_text};
3697 border: 3px solid #000000;
3709 background-color: {bg_color};
3711 border: 2px solid #888888;
3716 QPushButton:hover {{
3717 background-color: {hover_color};
3718 border-color: #666666;
3727 background-color: #808080;
3729 border: 2px solid #666666;
3735 background-color: #808080;
3736 border-color: #666666;
3740 btn.setStyleSheet(style)
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}"
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}"
3759 """Handle mode button click - update highlighting"""
3784 new_state = mode_labels[idx]
3799 if new_state ==
"Quick Fire":
3808 """After 3s in Quick Fire, auto-transition to GN2 Press."""
3813 """After 5s in Vent (from ABORT button), auto-transition to Abort."""
3817 """Clear selection of all mode buttons so none appear selected (e.g. for Abort/Vent-from-abort)."""
3820 btn.setChecked(
False)
3824 """Set GUI and emitted state to Abort (used by EMERGENCY ABORT and by 5s Vent→Abort timer)."""
3834 """Programmatically switch to a state (e.g. for automatic pressure-based transitions). Bypasses transition check."""
3851 if mode_name
not in mode_labels:
3853 idx = mode_labels.index(mode_name)
3860 if mode_name ==
"Quick Fire":
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).
3872 print(f
"{abort_type} TRIGGERED")
3873 parent = self.parent()
3874 if abort_type ==
"EMERGENCY ABORT":
3877 if parent
and hasattr(parent,
"log_event"):
3878 parent.log_event(
"Abort", f
"{abort_type} button pressed")
3880 if abort_type ==
"ABORT":
3890 if parent
and hasattr(parent,
"log_event"):
3891 parent.log_event(
"Abort", f
"{abort_type} button pressed")
3895 if text ==
"SETTINGS":
3897 self.
nav_btn.setText(
"DASHBOARD")
3900 self.
nav_btn.setText(
"SETTINGS")
3906 Centralized settings configuration widget.
3909 mapping_changed = QtCore.pyqtSignal(str, int)
3911 def __init__(self, sensor_widget, actuator_widget, parent=None):
3923 main_layout = QtWidgets.QHBoxLayout(self)
3926 left_col_widget = QtWidgets.QWidget()
3927 left_col = QtWidgets.QVBoxLayout(left_col_widget)
3930 sensor_group = QtWidgets.QGroupBox(
"Sensor & General View Settings")
3931 sensor_layout = QtWidgets.QFormLayout()
3935 sensor_layout.addRow(self.
demo_chk)
3939 "If enabled: In FIRE state, transitions to ARMED if LOX or Fuel Upstream < 350 psi"
3944 self.
time_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
3948 sensor_layout.addRow(
"Time Window:", self.
time_slider)
3949 sensor_layout.addRow(
"", self.
time_lbl)
3954 sensor_layout.addRow(
"Y Min:", self.
y_min_spin)
3959 sensor_layout.addRow(
"Y Max:", self.
y_max_spin)
3963 sensor_layout.addRow(
"Sensor IP Filter:", self.
ip_filter)
3965 sensor_group.setLayout(sensor_layout)
3966 left_col.addWidget(sensor_group)
3969 ma_group = QtWidgets.QGroupBox(
"Moving Average")
3970 ma_layout = QtWidgets.QFormLayout()
3981 ma_group.setLayout(ma_layout)
3982 left_col.addWidget(ma_group)
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)
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)
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()
4006 cb.stateChanged.connect(
4009 cb.setFixedWidth(24)
4010 checkboxes_row.addWidget(cb)
4012 visibility_layout.addLayout(numbers_row)
4013 visibility_layout.addLayout(checkboxes_row)
4015 "Only show PTs with roles"
4023 visibility_layout.addWidget(self.
debug_btn)
4026 zero_row = QtWidgets.QHBoxLayout()
4029 "Set current PSI readings as zero reference (tare all PTs)"
4032 "QPushButton { background-color: #e67e22; color: white; font-weight: bold; padding: 4px 8px; border-radius: 4px; }"
4033 "QPushButton:hover { background-color: #d35400; }"
4038 "Remove zero offset — restore raw calibration values"
4041 "QPushButton { background-color: #555; color: white; padding: 4px 8px; border-radius: 4px; }"
4042 "QPushButton:hover { background-color: #333; }"
4047 visibility_layout.addLayout(zero_row)
4049 visibility_group.setLayout(visibility_layout)
4051 left_col.addWidget(visibility_group)
4054 adc_group = QtWidgets.QGroupBox(
"ADC Configuration")
4055 adc_layout = QtWidgets.QFormLayout()
4066 adc_group.setLayout(adc_layout)
4067 left_col.addWidget(adc_group)
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; }"
4075 reset_btn.setToolTip(
"Clear all plot data, history (CSV buffer), and event log")
4077 left_col.addWidget(reset_btn)
4079 left_col.addStretch()
4082 left_scroll = QtWidgets.QScrollArea()
4083 left_scroll.setWidget(left_col_widget)
4084 left_scroll.setWidgetResizable(
True)
4085 main_layout.addWidget(left_scroll, 1)
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()
4093 form = QtWidgets.QFormLayout()
4096 form.addRow(
"Device IP:", self.
act_ip)
4101 form.addRow(
"Device Port:", self.
act_port)
4102 act_layout.addLayout(form)
4105 "Only show actuators with roles"
4112 act_layout.addWidget(QtWidgets.QLabel(
"Actuator role → slot:"))
4117 for role_name
in CONFIG.get_actuator_role_names():
4118 combo = QtWidgets.QComboBox()
4119 combo.addItem(
"—", 0)
4121 combo.addItem(str(act_num), act_num)
4122 combo.currentIndexChanged.connect(
4125 self.
act_form.addRow(role_name +
":", combo)
4131 act_group.setLayout(act_layout)
4132 mid_col.addWidget(act_group)
4133 mid_col.addStretch()
4136 mid_scroll = QtWidgets.QScrollArea()
4137 mid_scroll.setWidget(mid_col_widget)
4138 mid_scroll.setWidgetResizable(
True)
4139 main_layout.addWidget(mid_scroll, 1)
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()
4148 pt_csv_group = QtWidgets.QGroupBox(
"PT Calibration CSVs")
4149 pt_csv_layout = QtWidgets.QVBoxLayout()
4155 pt_csv_buttons = QtWidgets.QHBoxLayout()
4156 pt_csv_add = QtWidgets.QPushButton(
"Add CSV…")
4158 pt_csv_remove = QtWidgets.QPushButton(
"Remove Selected")
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)
4171 pt_csv_group.setLayout(pt_csv_layout)
4172 pt_layout.addWidget(pt_csv_group)
4175 mapping_group = QtWidgets.QGroupBox(
"Top Bar Gauge Mapping")
4176 mapping_form = QtWidgets.QFormLayout()
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)
4193 combo.currentIndexChanged.connect(
4196 mapping_form.addRow(f
"{name} Source:", combo)
4198 mapping_group.setLayout(mapping_form)
4199 pt_layout.addWidget(mapping_group)
4202 thresh_group = QtWidgets.QGroupBox(
"State Transition Thresholds (psi)")
4203 thresh_form = QtWidgets.QFormLayout()
4211 thresh_form.addRow(
"GN2 POP (→ GN2 Vent):", self.
gn2_pop_spin)
4219 thresh_form.addRow(
"Fuel POP (→ Fuel Vent):", self.
fuel_pop_spin)
4227 thresh_form.addRow(
"Ox POP (→ Ox Vent):", self.
ox_pop_spin)
4251 thresh_form.addRow(
"Ox THRESH (→ Ox Press):", self.
ox_thresh_spin)
4252 thresh_group.setLayout(thresh_form)
4253 pt_layout.addWidget(thresh_group)
4256 pt_layout.addWidget(QtWidgets.QLabel(
"PT role → connector:"))
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(
4268 self.
pt_form.addRow(role_name +
":", combo)
4272 pt_group.setLayout(pt_layout)
4273 right_col.addWidget(pt_group)
4274 right_col.addStretch()
4277 right_scroll = QtWidgets.QScrollArea()
4278 right_scroll.setWidget(right_col_widget)
4279 right_scroll.setWidgetResizable(
True)
4280 main_layout.addWidget(right_scroll, 1)
4284 with QtCore.QSignalBlocker(self.
demo_chk):
4289 CONFIG.config.get(
"simulate_chamber_pressure",
False)
4301 with QtCore.QSignalBlocker(self.
ip_filter):
4305 p = (CONFIG.config.get(
"paths")
or {}).get(
"pt_calibration_csv",
"")
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()]
4314 for path
in csv_paths:
4345 for i
in range(1, NUM_CONNECTORS + 1):
4353 with QtCore.QSignalBlocker(self.
act_ip):
4356 with QtCore.QSignalBlocker(self.
act_port):
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)
4370 combo.setCurrentIndex(idx)
4372 combo.setCurrentIndex(0)
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)
4380 combo.setCurrentIndex(idx)
4382 combo.setCurrentIndex(0)
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"),
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))
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):
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)
4417 """Update config with current list of CSV paths."""
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 []
4443 """Add a new CSV file to the list."""
4452 os.path.dirname(last_item.text())
4453 if os.path.dirname(last_item.text())
4454 else os.path.expanduser(
"~")
4457 last_path = os.path.expanduser(
"~")
4459 path, _ = QtWidgets.QFileDialog.getOpenFileName(
4461 "Select PT calibration CSV",
4463 "CSV files (*.csv);;All files (*)",
4470 """Remove selected CSV file from the list."""
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"),
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, {})[
4494 CONFIG.config[
"simulate_chamber_pressure"] = checked
4497 if hasattr(self.parent(),
"parent")
and hasattr(
4498 self.parent().
parent(),
"update_sim_pressure_state"
4517 """Clear all stored sensor data, plot buffers, CSV history, and event log."""
4518 reply = QtWidgets.QMessageBox.question(
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,
4526 if reply != QtWidgets.QMessageBox.StandardButton.Yes:
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] = []
4541 sw.total_samples_received = 0
4542 sw.last_sps_sample_count = 0
4549 if hasattr(sw,
"main_window_ref")
and sw.main_window_ref
4552 if main_win
and hasattr(main_win,
"event_log"):
4553 main_win.event_log.clear()
4556 for plot
in sw.sensor_plots.values():
4557 plot.setData([], [])
4559 sw.update_statistics()
4560 print(
"All data reset.")
4566 CONFIG.config[
"display"][
"window_seconds"] = float(val)
4571 CONFIG.config[
"display"][
"y_axis_min"] = val
4576 CONFIG.config[
"display"][
"y_axis_max"] = val
4581 CONFIG.config[
"network"][
"sensor_ip_filter"] = text
4586 CONFIG.config[
"display"][
"only_show_pt_with_roles"] = checked
4593 """Capture current PSI readings as zero offsets for all PTs."""
4597 """Clear all PT zero offsets."""
4602 CONFIG.config[
"display"][
"adc_bits"] = val
4607 CONFIG.config[
"display"][
"ref_voltage"] = val
4612 CONFIG.config[
"display"][
"graph_ma_samples"] = val
4617 CONFIG.config[
"display"][
"display_ma_samples"] = val
4622 CONFIG.config[
"network"][
"actuator_ip"] = text
4627 CONFIG.config[
"network"][
"actuator_port"] = val
4632 CONFIG.config[
"display"][
"only_show_actuators_with_roles"] = checked
4638 actuator_id = combo.currentData()
4639 if actuator_id
is None:
4641 CONFIG.set_actuator_role(role_name, actuator_id)
4644 label = CONFIG.get_actuator_label(aid)
4652 pt_id = combo.currentData()
4655 CONFIG.set_sensor_role(role_name, pt_id)
4657 for cid
in range(1, NUM_CONNECTORS + 1):
4658 label = CONFIG.get_sensor_label(cid)
4664 pt_id = combo.currentData()
4666 CONFIG.config[
"mappings"][gauge_name] = pt_id
4672 """Standalone window that embeds ActuatorControlWidget."""
4677 device_ip: str = DEFAULT_ACTUATOR_IP,
4678 device_port: int = DEFAULT_DEVICE_PORT,
4681 self.setWindowTitle(f
"Actuator Control - {device_ip}:{device_port}")
4682 self.setGeometry(1350, 100, 1000, 500)
4683 self.setCentralWidget(
4688 """Handle window close event"""
4689 w = self.centralWidget()
4690 if w
and hasattr(w,
"save_labels"):
4692 if w
and hasattr(w,
"close_socket"):
4699 """Separate window for PT calibration - doesn't interfere with main GUI"""
4712 if ROBUST_CALIBRATION_AVAILABLE:
4713 for sensor_id
in range(1, NUM_CONNECTORS + 1):
4718 self.setWindowTitle(
"PT Sensor Calibration - Phase 1 & Phase 2")
4719 self.setGeometry(100, 100, 1100, 900)
4724 central = QtWidgets.QWidget()
4725 self.setCentralWidget(central)
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)
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"
4746 instructions.setStyleSheet(
4747 "color: #555; font-size: 10pt; padding: 10px; background: #f0f0f0; border-radius: 5px;"
4749 main_layout.addWidget(instructions)
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)
4762 self.
fit_btn = QtWidgets.QPushButton(
"Fit Calibration")
4763 self.
fit_btn.setEnabled(
False)
4764 self.
fit_btn.setMinimumHeight(35)
4766 control_layout.addWidget(self.
fit_btn)
4768 self.
save_btn = QtWidgets.QPushButton(
"Save Calibration")
4772 control_layout.addWidget(self.
save_btn)
4774 self.
load_btn = QtWidgets.QPushButton(
"Load Calibration")
4777 control_layout.addWidget(self.
load_btn)
4780 if ROBUST_CALIBRATION_AVAILABLE:
4781 self.
phase2_btn = QtWidgets.QPushButton(
"Enable Phase 2 (Autonomous)")
4787 main_layout.addWidget(control_frame)
4791 self.
status_label.setStyleSheet(
"font-size: 10pt; padding: 5px;")
4795 sensor_group = QtWidgets.QGroupBox(
"Sensor Data")
4796 sensor_layout = QtWidgets.QVBoxLayout(sensor_group)
4797 sensor_layout.setSpacing(5)
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)
4805 for header_text, width
in [
4807 (
"Current ADC", 150),
4808 (
"Calibrated (PSI)", 150),
4809 (
"Reference (PSI)", 200),
4812 label = QtWidgets.QLabel(header_text)
4813 label.setStyleSheet(
"font-weight: bold; font-size: 10pt;")
4814 label.setMinimumWidth(width)
4815 header_layout.addWidget(label)
4817 sensor_layout.addWidget(header_frame)
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; }"
4826 layout = QtWidgets.QHBoxLayout(frame)
4827 layout.setContentsMargins(10, 5, 10, 5)
4828 layout.setSpacing(10)
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)
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;"
4843 layout.addWidget(adc_label)
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;"
4852 layout.addWidget(calibrated_label)
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;")
4862 layout.addWidget(ref_input)
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)
4871 sensor_layout.addWidget(frame)
4873 main_layout.addWidget(sensor_group)
4876 points_label = QtWidgets.QLabel(
"Calibration Points Recorded:")
4877 points_label.setStyleSheet(
4878 "font-weight: bold; font-size: 11pt; margin-top: 5px;"
4880 main_layout.addWidget(points_label)
4885 [
"Point #",
"Sensors",
"Pressures (PSI)"]
4887 self.
points_table.horizontalHeader().setStretchLastSection(
True)
4896 """Update ADC displays - sensor_id is 1-based (1-10) matching init_connector_plots"""
4901 if not hasattr(self,
"_debug_keys_printed"):
4905 for sensor_id
in range(1, NUM_CONNECTORS + 1):
4907 adc_label = self.findChild(QtWidgets.QLabel, f
"adc_{sensor_id}")
4914 if len(adc_deque) > 0:
4915 adc_value = adc_deque[-1][1]
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;"
4925 calibrated_label = self.findChild(
4926 QtWidgets.QLabel, f
"calibrated_{sensor_id}"
4928 if calibrated_label:
4930 sensor_id, adc_value
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;"
4938 calibrated_label.setText(
"--")
4939 calibrated_label.setStyleSheet(
4940 "font-family: 'Courier New', monospace; font-size: 11pt; color: #95a5a6;"
4943 adc_label.setText(
"--")
4944 adc_label.setStyleSheet(
4945 "font-family: 'Courier New', monospace; font-size: 11pt; color: #95a5a6;"
4948 calibrated_label = self.findChild(
4949 QtWidgets.QLabel, f
"calibrated_{sensor_id}"
4951 if calibrated_label:
4952 calibrated_label.setText(
"--")
4953 calibrated_label.setStyleSheet(
4954 "font-family: 'Courier New', monospace; font-size: 11pt; color: #95a5a6;"
4958 """Print debug info about sensor_adc_codes keys"""
4960 available_keys = sorted(self.
sensor_widget.sensor_adc_codes.keys())
4961 print(f
"🔍 Calibration GUI: sensor_adc_codes has keys: {available_keys}")
4973 for k
in available_keys[:5]
4975 print(f
" Sample: {sample_data}")
4978 """Record calibration point - sensor_id is 1-10 (1-based, matching init_connector_plots)"""
4980 for sensor_id
in range(1, NUM_CONNECTORS + 1):
4984 adc_codes[sensor_id] = adc_deque[-1][1]
if len(adc_deque) > 0
else 0
4986 adc_codes[sensor_id] = 0
4988 reference_pressures = {}
4990 ref_pressure = ref_input.value()
4993 reference_pressures[sensor_id] = ref_pressure
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())
4999 if not reference_pressures
or (
not has_non_zero
and not has_zero):
5000 QtWidgets.QMessageBox.warning(
5002 "No Reference Pressures",
5003 "Please enter reference pressures for sensors (0.0 is valid for zero-point calibration).",
5010 "adc_codes": adc_codes,
5011 "reference_pressures": reference_pressures,
5017 if ROBUST_CALIBRATION_AVAILABLE:
5019 for sensor_id, ref_pressure
in reference_pressures.items():
5021 adc_code = adc_codes[sensor_id]
5024 adc_code=float(adc_code),
5029 environmental_state=env_state,
5039 if len(framework.calibration_points) >= 1:
5042 if len(framework.calibration_points) == 1:
5045 framework.theta_mean[0] = float(ref_pressure)
5046 elif len(framework.calibration_points) >= 2:
5049 theta_tls, cov_tls = (
5050 framework.total_least_squares_calibration(
5051 framework.calibration_points
5054 theta_post, cov_post = framework.bayesian_update(
5055 framework.calibration_points
5057 framework.theta_mean = theta_post
5058 framework.theta_cov = cov_post
5062 cp.adc_code
for cp
in framework.calibration_points
5065 cp.pressure
for cp
in framework.calibration_points
5067 if len(adc_list) >= 2:
5068 poly_coeffs = np.polyfit(
5071 min(3, len(adc_list) - 1),
5076 except Exception
as e:
5078 f
"Warning: Could not fit calibration immediately for PT {sensor_id}: {e}"
5086 f
"Recorded point #{len(self.calibration_points)} - Calibrated pressure will update immediately"
5090 """Update points table"""
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())]
5097 self.
points_table.setItem(i, 1, QtWidgets.QTableWidgetItem(sensors_str))
5098 pressures_str =
", ".join(
5101 for sid, p
in sorted(point[
"reference_pressures"].items())
5104 self.
points_table.setItem(i, 2, QtWidgets.QTableWidgetItem(pressures_str))
5107 """Phase 1: Fit calibration using RobustCalibrationFramework (TLS + Bayesian)"""
5109 QtWidgets.QMessageBox.warning(
5110 self,
"Insufficient Data",
"Need at least 3 points."
5116 if ROBUST_CALIBRATION_AVAILABLE:
5118 for sensor_id
in range(1, NUM_CONNECTORS + 1):
5123 cal_points = framework.calibration_points
5125 if len(cal_points) >= 3:
5128 theta_tls, cov_tls = framework.total_least_squares_calibration(
5131 theta_post, cov_post = framework.bayesian_update(cal_points)
5134 framework.theta_mean = theta_post
5135 framework.theta_cov = cov_post
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)
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
5154 conf_level = framework.get_confidence_level()
5156 calib_status = self.findChild(
5157 QtWidgets.QLabel, f
"calib_status_{sensor_id}"
5160 calib_status.setText(
5161 f
"Calibrated (R²={r_squared:.3f}, {conf_level})"
5163 calib_status.setStyleSheet(
"color: green;")
5166 except Exception
as e:
5168 f
"Failed to fit PT {sensor_id} with robust framework: {e}"
5172 for sensor_id
in range(1, NUM_CONNECTORS + 1):
5178 sensor_id
in point[
"adc_codes"]
5179 and sensor_id
in point[
"reference_pressures"]
5181 adc_codes.append(point[
"adc_codes"][sensor_id])
5182 pressures.append(point[
"reference_pressures"][sensor_id])
5184 if len(adc_codes) >= 3
and len(set(adc_codes)) > 1:
5186 poly_coeffs = np.polyfit(adc_codes, pressures, 3)
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
5194 calib_status = self.findChild(
5195 QtWidgets.QLabel, f
"calib_status_{sensor_id}"
5198 calib_status.setText(f
"Calibrated (R²={r_squared:.3f})")
5199 calib_status.setStyleSheet(
"color: green;")
5202 except Exception
as e:
5203 print(f
"Failed to fit PT {sensor_id}: {e}")
5207 " (Phase 1: TLS + Bayesian)" if ROBUST_CALIBRATION_AVAILABLE
else ""
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)!"
5215 """Save calibration"""
5217 Path(__file__).parent.parent.parent
5222 calib_dir.mkdir(parents=
True, exist_ok=
True)
5224 filename, _ = QtWidgets.QFileDialog.getSaveFileName(
5227 str(calib_dir / f
"calibration_{time.strftime('%Y%m%d_%H%M%S')}.json"),
5228 "JSON files (*.json);;All files (*.*)",
5232 calibration_data = {
5233 "timestamp": time.strftime(
"%Y-%m-%d %H:%M:%S"),
5235 "calibration_polynomials": {
5236 str(sensor_id): list(coeffs)
5242 if ROBUST_CALIBRATION_AVAILABLE:
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": [
5251 "adc_code": cp.adc_code,
5252 "pressure": cp.pressure,
5253 "timestamp": cp.timestamp,
5254 "uncertainty": cp.uncertainty,
5256 for cp
in framework.calibration_points
5258 "confidence_level": framework.get_confidence_level(),
5260 calibration_data[
"robust_frameworks"] = robust_state
5262 with open(filename,
"w")
as f:
5263 json.dump(calibration_data, f, indent=2)
5265 QtWidgets.QMessageBox.information(self,
"Saved", f
"Saved to:\n{filename}")
5269 """Load calibration"""
5270 filename, _ = QtWidgets.QFileDialog.getOpenFileName(
5271 self,
"Load Calibration",
"",
"JSON files (*.json);;All files (*.*)"
5276 with open(filename,
"r")
as f:
5277 calibration_data = json.load(f)
5281 int(sensor_id): tuple(coeffs)
5282 for sensor_id, coeffs
in calibration_data.get(
5283 "calibration_polynomials", {}
5289 ROBUST_CALIBRATION_AVAILABLE
5290 and "robust_frameworks" in calibration_data
5292 robust_state = calibration_data[
"robust_frameworks"]
5293 for sensor_id_str, state
in robust_state.items():
5294 sensor_id = int(sensor_id_str)
5297 framework.theta_mean = np.array(state[
"theta_mean"])
5298 framework.theta_cov = np.array(state[
"theta_cov"])
5300 framework.calibration_points = []
5302 for cp_data
in state.get(
"calibration_points", []):
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),
5310 framework.calibration_points.append(cal_point)
5321 QtWidgets.QMessageBox.information(
5322 self,
"Loaded", f
"Loaded from:\n{filename}"
5324 except Exception
as e:
5325 QtWidgets.QMessageBox.critical(self,
"Error", f
"Failed to load:\n{e}")
5328 """Load existing calibration"""
5330 Path(__file__).parent.parent.parent
5335 if calib_dir.exists():
5336 json_files = sorted(
5337 calib_dir.glob(
"*.json"), key=
lambda p: p.stat().st_mtime, reverse=
True
5341 with open(json_files[0],
"r")
as f:
5342 calibration_data = json.load(f)
5345 int(sensor_id): tuple(coeffs)
5346 for sensor_id, coeffs
in calibration_data.get(
5347 "calibration_polynomials", {}
5353 except Exception
as e:
5354 print(f
"Failed to load existing calibration: {e}")
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}")
5362 calib_status.setText(
"Calibrated")
5363 calib_status.setStyleSheet(
"color: green;")
5365 calib_status.setText(
"Not Calibrated")
5366 calib_status.setStyleSheet(
"color: red;")
5369 """Calculate calibrated pressure from ADC code - human input is always source of truth"""
5373 if len(framework.calibration_points) >= 1:
5375 phi = framework.environmental_robust_basis_functions(
5376 float(adc_code), env_state
5378 pressure = phi @ framework.theta_mean
5380 if np.isfinite(pressure):
5381 return float(pressure)
5387 if len(coeffs) == 4:
5390 coeffs[0] * (adc_code**3)
5391 + coeffs[1] * (adc_code**2)
5392 + coeffs[2] * adc_code
5395 elif len(coeffs) == 3:
5397 pressure = coeffs[0] * (adc_code**2) + coeffs[1] * adc_code + coeffs[2]
5398 elif len(coeffs) == 2:
5400 pressure = coeffs[0] * adc_code + coeffs[1]
5403 if np.isfinite(pressure):
5404 return float(pressure)
5410 """Prompt for zero-point calibration on startup (singular zero point calibration)"""
5411 reply = QtWidgets.QMessageBox.question(
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,
5422 if reply == QtWidgets.QMessageBox.StandardButton.Yes:
5426 """Perform zero-point calibration: all sensors at 0 PSI (human input is source of truth)"""
5428 QtWidgets.QMessageBox.warning(
5429 self,
"No Data",
"No sensor data available yet."
5435 for sensor_id
in range(1, NUM_CONNECTORS + 1):
5438 if len(adc_deque) > 0:
5439 adc_codes[sensor_id] = adc_deque[-1][1]
5442 QtWidgets.QMessageBox.warning(
5443 self,
"No Data",
"No sensor data available yet."
5448 reference_pressures = {sid: 0.0
for sid
in adc_codes.keys()}
5453 "adc_codes": adc_codes,
5454 "reference_pressures": reference_pressures,
5456 "is_zero_point":
True,
5461 if ROBUST_CALIBRATION_AVAILABLE:
5463 for sensor_id, adc_code
in adc_codes.items():
5466 adc_code=float(adc_code),
5469 environmental_state=env_state,
5479 "Zero-point calibration complete (0 PSI for all sensors)"
5481 QtWidgets.QMessageBox.information(
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.",
5489 """Consensus-based autonomous recalibration: sensors agree with each other"""
5490 if not ROBUST_CALIBRATION_AVAILABLE:
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)
5501 if len(calibrated_sensors) < 2:
5502 return # Need at least 2 calibrated sensors for consensus
5504 # Get current ADC codes for all sensors
5505 if not hasattr(self.sensor_widget, "sensor_adc_codes"):
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]
5515 if len(current_adcs) < 2:
5518 # Calculate predicted pressures for all calibrated sensors
5519 predicted_pressures = {}
5520 env_state = EnvironmentalState()
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
5528 predicted_pressures[sensor_id] = phi @ framework.theta_mean
5530 if len(predicted_pressures) < 2:
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)
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
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
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."
5557 self.phase2_btn.setChecked(False)
5560 self.phase2_active = 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")
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")
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:
5582 # Consensus-based recalibration check (sensors agree with each other)
5583 self.consensus_based_recalibration()
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:
5591 framework = self.robust_frameworks[sensor_id]
5593 # Need at least 3 calibration points for Phase 2
5594 if len(framework.calibration_points) < 3:
5597 # Get latest ADC code from sensor widget
5599 hasattr(self.sensor_widget, "sensor_adc_codes")
5600 and sensor_id in self.sensor_widget.sensor_adc_codes
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)
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()
5611 # Predict pressure using current calibration
5612 phi = framework.environmental_robust_basis_functions(
5613 latest_adc, env_state
5615 predicted_pressure = phi @ framework.theta_mean
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
5630 framework.recursive_least_squares_update(cal_point)
5632 # GLR drift detection
5633 glr_stat, drift_detected = (
5634 framework.generalized_likelihood_ratio_test([cal_point])
5638 # Drift detected - trigger recalibration alert
5639 calib_status = self.findChild(
5640 QtWidgets.QLabel, f"calib_status_{sensor_id}"
5643 calib_status.setText(
5644 "⚠️ DRIFT DETECTED - Recalibration needed"
5646 calib_status.setStyleSheet(
5647 "color: orange; font-weight: bold;"
5651# ---------------------- Combined Main Window ----------------------
5652class CombinedMainWindow(QtWidgets.QMainWindow):
5653 """Single window with sensor plot (left 66%) and actuator control (right 33%)."""
5658 device_ip: str = DEFAULT_ACTUATOR_IP,
5659 device_port: int = DEFAULT_DEVICE_PORT,
5660 bind_address: str = "0.0.0.0",
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]] = []
5667 # Load state machine CSV
5668 self.state_machine, _ = load_state_machine_csv(
5669 CONFIG.get_state_machine_csv_path()
5672 # Map mode button names to CSV state names (keep order in sync with CSV header and TopBarWidget)
5673 self.mode_to_state_map = {
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",
5691 # Central widget container
5692 central_widget = QtWidgets.QWidget()
5693 self.setCentralWidget(central_widget)
5695 # Main vertical layout
5696 main_layout = QtWidgets.QVBoxLayout(central_widget)
5697 main_layout.setContentsMargins(0, 0, 0, 0)
5698 main_layout.setSpacing(0)
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)
5707 # Stacked Widget for Dashboard / Settings
5708 self.stack = QtWidgets.QStackedWidget()
5710 # --- Page 1: Dashboard ---
5711 dashboard_widget = QtWidgets.QWidget()
5712 dashboard_layout = QtWidgets.QHBoxLayout(dashboard_widget)
5713 dashboard_layout.setContentsMargins(0, 0, 0, 0)
5715 self.sensor_widget = SensorPlotWidget(receiver, bind_address, self)
5716 self.actuator_widget = ActuatorControlWidget(
5717 receiver, device_ip, device_port, self
5719 self.sensor_widget.main_window_ref = self
5720 self.actuator_widget.main_window_ref = self
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%
5728 dashboard_layout.addWidget(splitter)
5729 self.stack.addWidget(dashboard_widget)
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)
5736 main_layout.addWidget(self.stack, 1)
5738 # Calibration window (separate, opened via menu)
5739 self.calibration_window = None
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"]
5746 self.update_timer = QtCore.QTimer(self)
5747 self.update_timer.timeout.connect(self.update_top_bar)
5748 self.update_timer.start(100)
5750 self._apply_bar_limits()
5752 # PWM control window (lazily created)
5753 self.pwm_window = None
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)
5762 ("GN2", self.top_bar_widget.bar_gn2),
5763 ("ETH", self.top_bar_widget.bar_eth),
5764 ("LOX", self.top_bar_widget.bar_lox),
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)
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)
5778 self._apply_bar_limits()
5779 self.stack.setCurrentIndex(0) # Dashboard
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'}")
5787 print(f"Warning: actuator_widget not available when DEBUG toggled")
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()
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()
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)
5810 # Manual control is now controlled by DEBUG button, not by state selection
5811 # (DEBUG button handler sets manual_control_enabled)
5813 # Map mode name to CSV state name
5814 csv_state_name = self.mode_to_state_map.get(mode_name, mode_name)
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
5823 f"Warning: State machine not loaded, cannot apply state for '{mode_name}'"
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()
5830 packet = create_pwm_actuator_command_packet(armed)
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))
5837 "PWM Armed Execute", f"{len(armed)} commands on '{mode_name}'"
5840 f"Sent {len(armed)} armed PWM commands on state '{mode_name}'"
5842 except Exception as e:
5843 print(f"Error sending armed PWM commands: {e}")
5844 self.pwm_window.consume_arms()
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))
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
5857 event_type == "Actuator State Change"
5858 and self.actuator_widget.only_show_actuators_with_roles
5860 # Extract actuator ID from details (format: "Actuator X: ..." or "Actuator X (label): ...")
5863 match = re.match(r"Actuator (\d+)", details)
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
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
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:
5882 deque_data = self.sensor_widget.sensor_psi_plot_v[pt_id]
5883 if len(deque_data) == 0:
5885 return deque_data[-1]
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
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))
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(
5916 ) # "Ox Upstream" in config vs "LOX Upstream" in code?
5917 # Looking at config.json provided earlier:
5918 # "Fuel Upstream": 1, "Ox Upstream": 5.
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]
5928 p_fuel = get_psi(fuel_upstream_pt)
5929 p_lox = get_psi(ox_upstream_pt)
5931 # Threshold is 350 psi
5932 if p_fuel < 350.0 or p_lox < 350.0:
5933 self.top_bar_widget.request_state("Armed")
5936 f"Simulate Chamber Pressure triggered: Fuel={p_fuel:.1f}, LOX={p_lox:.1f} (<350)",
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")
5944 if current == "Fuel Press" and fuel_psi >= fuel_pop:
5945 self.top_bar_widget.request_state("Fuel Vent")
5947 if current == "Ox Press" and ox_psi >= ox_pop:
5948 self.top_bar_widget.request_state("Ox Vent")
5950 if current == "High Press" and gn2_psi >= gn2_pop:
5951 self.top_bar_widget.request_state("GN2 Vent")
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")
5957 if current == "Fuel Vent" and fuel_psi <= fuel_thresh:
5958 self.top_bar_widget.request_state("Fuel Press")
5960 if current == "Ox Vent" and ox_psi <= ox_thresh:
5961 self.top_bar_widget.request_state("Ox Press")
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():
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)
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()
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()
5991# ---------------------- Settings Dialogs ----------------------
5994# ---------------------- Main Application ----------------------
5996 """Main entry point"""
5999 parser = argparse.ArgumentParser(
6000 description="Combined Sensor & Actuator Control GUI (single full-screen window)",
6001 formatter_class=argparse.RawDescriptionHelpFormatter,
6003Opens one full-screen window: sensor plot on the left, actuator control on the right.
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
6011 parser.add_argument(
6015 default=CONFIG.config["network"]["actuator_ip"],
6016 help=f'Actuator board IP address (default from config: {CONFIG.config["network"]["actuator_ip"]})',
6018 parser.add_argument(
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"]})',
6025 parser.add_argument(
6029 default=CONFIG.config["network"]["actuator_port"],
6030 help=f'Device UDP port for actuator commands (default from config: {CONFIG.config["network"]["actuator_port"]})',
6032 parser.add_argument(
6037 help="IP address to bind receiver to (default: 0.0.0.0 for all interfaces)",
6040 args = parser.parse_args()
6042 app = QtWidgets.QApplication(sys.argv)
6044 # Force Fusion style and dark palette (WSL doesn't always pick up system theme)
6045 app.setStyle("Fusion")
6046 palette = QtGui.QPalette()
6048 QtGui.QPalette.ColorGroup.All,
6049 QtGui.QPalette.ColorRole.Window,
6050 QtGui.QColor(53, 53, 53),
6053 QtGui.QPalette.ColorGroup.All,
6054 QtGui.QPalette.ColorRole.WindowText,
6055 QtCore.Qt.GlobalColor.white,
6058 QtGui.QPalette.ColorGroup.All,
6059 QtGui.QPalette.ColorRole.Base,
6060 QtGui.QColor(25, 25, 25),
6063 QtGui.QPalette.ColorGroup.All,
6064 QtGui.QPalette.ColorRole.AlternateBase,
6065 QtGui.QColor(53, 53, 53),
6068 QtGui.QPalette.ColorGroup.All,
6069 QtGui.QPalette.ColorRole.ToolTipBase,
6070 QtCore.Qt.GlobalColor.white,
6073 QtGui.QPalette.ColorGroup.All,
6074 QtGui.QPalette.ColorRole.ToolTipText,
6075 QtCore.Qt.GlobalColor.white,
6078 QtGui.QPalette.ColorGroup.All,
6079 QtGui.QPalette.ColorRole.Text,
6080 QtCore.Qt.GlobalColor.white,
6083 QtGui.QPalette.ColorGroup.All,
6084 QtGui.QPalette.ColorRole.Button,
6085 QtGui.QColor(53, 53, 53),
6088 QtGui.QPalette.ColorGroup.All,
6089 QtGui.QPalette.ColorRole.ButtonText,
6090 QtCore.Qt.GlobalColor.white,
6093 QtGui.QPalette.ColorGroup.All,
6094 QtGui.QPalette.ColorRole.BrightText,
6095 QtCore.Qt.GlobalColor.red,
6098 QtGui.QPalette.ColorGroup.All,
6099 QtGui.QPalette.ColorRole.Link,
6100 QtGui.QColor(42, 130, 218),
6103 QtGui.QPalette.ColorGroup.All,
6104 QtGui.QPalette.ColorRole.Highlight,
6105 QtGui.QColor(42, 130, 218),
6108 QtGui.QPalette.ColorGroup.All,
6109 QtGui.QPalette.ColorRole.HighlightedText,
6110 QtCore.Qt.GlobalColor.black,
6112 app.setPalette(palette)
6114 # Create shared UDP receiver
6115 receiver = UDPReceiver(port=args.port, bind_address=args.address)
6118 # Create single combined window (sensor plot left, actuator control right)
6119 window = CombinedMainWindow(
6122 device_port=args.device_port,
6123 bind_address=args.address,
6126 # Add Calibration menu item
6127 menu_bar = window.menuBar()
6129 calib_action = menu_bar.addAction("Calibration...")
6130 calib_action.setShortcut("Ctrl+C")
6131 calib_action.triggered.connect(window.open_calibration)
6133 # Start maximized as requested
6134 window.showMaximized()
6136 # Handle cleanup when app exits
6141 app.aboutToQuit.connect(cleanup)
6143 sys.exit(app.exec())
6146if __name__ == "__main__":
__init__(self, receiver, str device_ip=DEFAULT_ACTUATOR_IP, int device_port=DEFAULT_DEVICE_PORT)
consensus_based_recalibration(self)
update_points_table(self)
load_existing_calibration(self)
update_calibration_status(self)
dict calibration_polynomials
bool zero_point_calibrated
perform_zero_point_calibration(self)
__init__(self, sensor_widget, parent=None)
apply_calibration_to_sensor_widget(self)
prompt_zero_point_calibration
calculate_calibrated_pressure(self, int sensor_id, int adc_code)
_backfill_role_names(self)
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)
get_sensor_role_names(self)
get_sensor_label(self, idx)
set_sensor_role(self, role_name, connector_id)
get_actuator_abbrev_to_role(self)
get_actuator_role_names(self)
get_state_transitions_csv_path(self)
get_actuator_label(self, idx)
get_pt_calibration_csv_paths(self)
get_actuator_role(self, role_name)
get_state_machine_csv_path(self)
_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)
__init__(self, "SensorPlotWidget" parent)
_set_cell(self, int row, int col, str text)
__init__(self, receiver, str bind_address="0.0.0.0")
__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)
static void start(gnss_frame_parser_t *p, uint8_t byte)
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens
size_type count(const key_type &key) const
*brief remove last reference token *sa JSON pointer has no parent
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)
static void emit(sensors_core_t *c, uint8_t type, const void *data, uint8_t len)
TOML_NODISCARD_CTOR time() noexcept=default