From d5acb04b88373d33b038bb59945fb5ab8b4f543b Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Mon, 20 Apr 2026 16:55:57 -0600 Subject: V8 --- api_layers/__pycache__/__init__.cpython-314.pyc | Bin 324 -> 327 bytes .../__pycache__/arduino_layer.cpython-314.pyc | Bin 15070 -> 15422 bytes .../__pycache__/nidaqmx_layer.cpython-314.pyc | Bin 10251 -> 10254 bytes api_layers/arduino_layer.py | 337 +++++------ core/__pycache__/__init__.cpython-314.pyc | Bin 277 -> 280 bytes core/__pycache__/acquisition.cpython-314.pyc | Bin 14572 -> 13184 bytes core/__pycache__/signal_processor.cpython-314.pyc | Bin 0 -> 29240 bytes core/acquisition.py | 83 +-- core/signal_processor.py | 464 +++++++++++++++ devices/__pycache__/__init__.cpython-314.pyc | Bin 398 -> 401 bytes devices/__pycache__/analog_input.cpython-314.pyc | Bin 16238 -> 24525 bytes devices/__pycache__/base_device.cpython-314.pyc | Bin 7731 -> 7588 bytes .../__pycache__/device_registry.cpython-314.pyc | Bin 5927 -> 5930 bytes devices/__pycache__/digital_io.cpython-314.pyc | Bin 19275 -> 19331 bytes devices/__pycache__/serial_device.cpython-314.pyc | Bin 11824 -> 16431 bytes devices/analog_input.py | 369 ++++++++---- devices/base_device.py | 2 - devices/digital_io.py | 3 +- devices/serial_device.py | 92 ++- main.py | 16 +- plot.png | Bin 0 -> 111109 bytes ui/__pycache__/__init__.cpython-314.pyc | Bin 153 -> 156 bytes ui/__pycache__/config_dialog.cpython-314.pyc | Bin 7331 -> 7036 bytes ui/__pycache__/control_panel.cpython-314.pyc | Bin 0 -> 34961 bytes ui/__pycache__/main_window.cpython-314.pyc | Bin 15798 -> 24564 bytes ui/__pycache__/strip_chart.cpython-314.pyc | Bin 11052 -> 16639 bytes ui/add_device_dialog.py | 371 ++++++++++-- ui/config_dialog.py | 35 +- ui/control_panel.py | 559 +++++++++++++++++ ui/devices_window.py | 224 +++++++ ui/main_window.py | 383 +++++++----- ui/plot_builder.py | 506 ++++++++++++++++ ui/plot_config.py | 629 ++++++++++++++++++++ ui/signal_builder.py | 605 +++++++++++++++++++ ui/strip_chart.py | 329 +++++----- ui/style.qss | 545 +++++++++++++++++ ui/style_dark.qss | 468 +++++++++++++++ ui/style_light.qss | 69 +++ ui/windows/__init__.py | 0 ui/windows/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 164 bytes .../__pycache__/devices_window.cpython-314.pyc | Bin 0 -> 23245 bytes ui/windows/__pycache__/plot_window.cpython-314.pyc | Bin 0 -> 46125 bytes .../__pycache__/settings_window.cpython-314.pyc | Bin 0 -> 16406 bytes .../__pycache__/signals_window.cpython-314.pyc | Bin 0 -> 53434 bytes ui/windows/devices_window.py | 319 ++++++++++ ui/windows/plot_window.py | 486 +++++++++++++++ ui/windows/settings_window.py | 231 ++++++++ ui/windows/signals_window.py | 659 +++++++++++++++++++++ 48 files changed, 7043 insertions(+), 741 deletions(-) create mode 100644 core/__pycache__/signal_processor.cpython-314.pyc create mode 100644 core/signal_processor.py create mode 100644 plot.png create mode 100644 ui/__pycache__/control_panel.cpython-314.pyc create mode 100644 ui/control_panel.py create mode 100644 ui/devices_window.py create mode 100644 ui/plot_builder.py create mode 100644 ui/plot_config.py create mode 100644 ui/signal_builder.py create mode 100644 ui/style_dark.qss create mode 100644 ui/style_light.qss create mode 100644 ui/windows/__init__.py create mode 100644 ui/windows/__pycache__/__init__.cpython-314.pyc create mode 100644 ui/windows/__pycache__/devices_window.cpython-314.pyc create mode 100644 ui/windows/__pycache__/plot_window.cpython-314.pyc create mode 100644 ui/windows/__pycache__/settings_window.cpython-314.pyc create mode 100644 ui/windows/__pycache__/signals_window.cpython-314.pyc create mode 100644 ui/windows/devices_window.py create mode 100644 ui/windows/plot_window.py create mode 100644 ui/windows/settings_window.py create mode 100644 ui/windows/signals_window.py diff --git a/api_layers/__pycache__/__init__.cpython-314.pyc b/api_layers/__pycache__/__init__.cpython-314.pyc index 88ad51b..fe19862 100644 Binary files a/api_layers/__pycache__/__init__.cpython-314.pyc and b/api_layers/__pycache__/__init__.cpython-314.pyc differ diff --git a/api_layers/__pycache__/arduino_layer.cpython-314.pyc b/api_layers/__pycache__/arduino_layer.cpython-314.pyc index 186b224..f01edd3 100644 Binary files a/api_layers/__pycache__/arduino_layer.cpython-314.pyc and b/api_layers/__pycache__/arduino_layer.cpython-314.pyc differ diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc index 92f0ff3..084fe36 100644 Binary files a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc and b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc differ diff --git a/api_layers/arduino_layer.py b/api_layers/arduino_layer.py index dea2783..f2b8241 100644 --- a/api_layers/arduino_layer.py +++ b/api_layers/arduino_layer.py @@ -3,81 +3,62 @@ api_layers/arduino_layer.py Arduino serial API layer. -Protocol (default): Arduino sends newline-terminated CSV strings: - "A0:1.23,A1:4.56,A2:0.12\n" (analog) - "D2:1,D3:0,D4:1\n" (digital) - -The Arduino firmware sketch is provided at the bottom of this file -as a multi-line string for reference / deployment. - -Swap for a different protocol by subclassing ArduinoLayer and -overriding `_parse_line()` and `_build_write_cmd()`. - -Usage: - from api_layers.arduino_layer import ArduinoLayer - layer = ArduinoLayer(port="COM3", baud=115200, simulate=False) - if layer.connect(): - data = layer.read() # {"A0": 3.14, "A1": 1.07, ...} - layer.write("D13", 1) - layer.disconnect() +Protocol: Arduino sends newline-terminated lines, e.g.: + "A0:1.23,A1:4.56\n" key:value pairs (default) + "1.23,4.56\n" plain CSV + {"A0":1.23,"A1":4.56} JSON + +connect() is non-blocking — serial open is done, then a background +thread reads continuously. The 2-second Arduino-reset wait is done +inside the thread so the UI never freezes. + +Swap protocol by subclassing and overriding _parse_line(). """ import math import random -import re import threading import time from typing import Dict, List, Optional, Tuple -# ── Try pyserial ───────────────────────────────────────────────────────────── try: - import serial # type: ignore - import serial.tools.list_ports # type: ignore + import serial # type: ignore + import serial.tools.list_ports # type: ignore _SERIAL_AVAILABLE = True except ImportError: _SERIAL_AVAILABLE = False class ArduinoLayer: - """ - Serial communication layer for Arduino-based DAQ nodes. - - Supports: - - Auto-detect available serial ports - - Configurable baud rate / timeout - - Background read thread with latest-value cache - - Digital output writes - - Full simulation mode (no hardware required) - """ DEFAULT_ANALOG_PINS = ["A0", "A1", "A2", "A3", "A4", "A5"] DEFAULT_DIGITAL_PINS = ["D2", "D3", "D4", "D5", "D6", "D7"] def __init__( self, - port: str = "COM3", - baud: int = 115200, - timeout: float = 0.5, - analog_pins: List[str] = None, - digital_pins: List[str] = None, - simulate: bool = True, + port: str = "COM3", + baud: int = 115200, + timeout: float = 1.0, + analog_pins: List[str] = None, + digital_pins: List[str] = None, + simulate: bool = True, ): - self.port = port - self.baud = baud - self.timeout = timeout - self.analog_pins = analog_pins or self.DEFAULT_ANALOG_PINS - self.digital_pins = digital_pins or [] - self.simulate = simulate or not _SERIAL_AVAILABLE - - self._ser: Optional[object] = None - self._cache: Dict[str, float] = {} - self._lock = threading.Lock() - self._running = False - self._thread: Optional[threading.Thread] = None - self._t0 = 0.0 + self.port = port + self.baud = baud + self.timeout = timeout + self.analog_pins = analog_pins or self.DEFAULT_ANALOG_PINS + self.digital_pins = digital_pins or [] + self.simulate = simulate # stored exactly as given — no override + + self._ser: Optional[object] = None + self._cache: Dict[str, float] = {} + self._lock = threading.Lock() + self._running = False + self._thread: Optional[threading.Thread] = None + self._t0 = 0.0 + self._last_error: str = "" # surfaced to UI for diagnosis - # Sim waveform params self._sim_params = { pin: { "freq": 0.1 + i * 0.13, @@ -89,82 +70,141 @@ class ArduinoLayer: for i, pin in enumerate(self.analog_pins) } - # ── Lifecycle ──────────────────────────────────────────────────────── + # ── Lifecycle ───────────────────────────────────────────────────────── def connect(self) -> bool: + """ + Open the connection. + Simulation: starts waveform thread immediately → returns True. + Hardware: opens serial port synchronously (fast), then starts + read thread which handles the Arduino reset wait. + Returns True on success, False on failure. + Check self.last_error for the reason on failure. + """ self._t0 = time.time() + self._last_error = "" + if self.simulate: self._running = True - self._thread = threading.Thread(target=self._sim_loop, daemon=True) + self._thread = threading.Thread( + target=self._sim_loop, daemon=True, name="ArduinoSim" + ) self._thread.start() return True + if not _SERIAL_AVAILABLE: - print("[ArduinoLayer] pyserial not installed.") + self._last_error = "pyserial not installed — run: pip install pyserial" + print(f"[ArduinoLayer] {self._last_error}") return False + try: - self._ser = serial.Serial( - port=self.port, baudrate=self.baud, timeout=self.timeout - ) - time.sleep(2.0) # Allow Arduino reset - self._ser.reset_input_buffer() - self._running = True - self._thread = threading.Thread(target=self._read_loop, daemon=True) - self._thread.start() - return True + self._ser = serial.Serial() + self._ser.port = self.port + self._ser.baudrate = self.baud + self._ser.timeout = self.timeout + self._ser.open() # raises SerialException on failure except Exception as e: - print(f"[ArduinoLayer] connect() failed: {e}") + self._last_error = str(e) + print(f"[ArduinoLayer] connect() failed on {self.port}: {e}") + self._ser = None return False + self._running = True + self._thread = threading.Thread( + target=self._read_loop, daemon=True, name=f"Arduino-{self.port}" + ) + self._thread.start() + return True + def disconnect(self) -> None: self._running = False if self._thread: self._thread.join(timeout=2.0) + self._thread = None if self._ser: try: self._ser.close() except Exception: pass - self._ser = None + self._ser = None + with self._lock: + self._cache.clear() - # ── Read / Write ──────────────────────────────────────────────────── + @property + def is_connected(self) -> bool: + if self.simulate: + return self._running + return self._ser is not None and self._ser.is_open + + @property + def last_error(self) -> str: + return self._last_error + + # ── Read / Write ────────────────────────────────────────────────────── def read(self) -> Dict[str, float]: - """Return cached latest values for all pins.""" with self._lock: return dict(self._cache) def write(self, pin: str, value: int) -> bool: - """ - Send digital write command to Arduino. - Format sent: "W:D13:1\n" - """ if self.simulate: return True if self._ser and self._ser.is_open: try: - cmd = f"W:{pin}:{int(bool(value))}\n" - self._ser.write(cmd.encode()) + self._ser.write(f"W:{pin}:{int(bool(value))}\n".encode()) return True except Exception as e: print(f"[ArduinoLayer] write() failed: {e}") return False - # ── Background threads ─────────────────────────────────────────────── + # ── Background threads ──────────────────────────────────────────────── def _read_loop(self): - """Background thread: reads lines from serial port.""" - while self._running and self._ser and self._ser.is_open: + """ + Hardware read thread. + Waits 2 s for Arduino reset, then reads lines continuously. + All errors are caught so the thread never crashes silently. + """ + # Wait for Arduino to reset after serial open + deadline = time.time() + 2.5 + while time.time() < deadline and self._running: + time.sleep(0.05) + + if not self._running: + return + + # Flush any garbage from reset + try: + self._ser.reset_input_buffer() + except Exception: + pass + + consecutive_errors = 0 + while self._running: try: - line = self._ser.readline().decode("utf-8", errors="replace").strip() - if line: - parsed = self._parse_line(line) + if not self._ser or not self._ser.is_open: + break + raw = self._ser.readline() + if not raw: + continue + line = raw.decode("utf-8", errors="replace").strip() + if not line: + continue + parsed = self._parse_line(line) + if parsed: with self._lock: self._cache.update(parsed) - except Exception: - time.sleep(0.05) + consecutive_errors = 0 + except Exception as e: + consecutive_errors += 1 + if consecutive_errors <= 3: + print(f"[ArduinoLayer] read error: {e}") + if consecutive_errors > 20: + print(f"[ArduinoLayer] too many errors, stopping read loop") + break + time.sleep(0.1) def _sim_loop(self): - """Background thread: generates simulated waveforms.""" while self._running: t = time.time() - self._t0 update = {} @@ -172,124 +212,75 @@ class ArduinoLayer: val = p["amp"] * math.sin(2 * math.pi * p["freq"] * t + p["phase"]) val += p["offset"] val += random.gauss(0, p["noise"] * p["amp"]) - # Clamp to 0-5V (Arduino ADC range) update[pin] = round(max(0.0, min(5.0, val)), 4) with self._lock: self._cache.update(update) time.sleep(0.05) - # ── Protocol helpers ───────────────────────────────────────────────── + # ── Protocol ───────────────────────────────────────────────────────── def _parse_line(self, line: str) -> Dict[str, float]: """ - Parse "A0:1.23,A1:4.56,D2:1" → {"A0": 1.23, "A1": 4.56, "D2": 1.0} - Also handles plain CSV "1.23,4.56,0.12" mapped to analog_pins in order. + Parse common Arduino output formats: + "A0:1.23,A1:4.56" → key:value pairs + "1.23,4.56" → positional CSV mapped to analog_pins + {"A0":1.23} → JSON """ + line = line.strip() result: Dict[str, float] = {} - # Key:value pairs + + # JSON + if line.startswith("{"): + try: + import json + d = json.loads(line) + return {k: float(v) for k, v in d.items()} + except Exception: + return {} + + # Key:value CSV for token in line.split(","): token = token.strip() + if not token: + continue if ":" in token: parts = token.split(":", 1) try: result[parts[0].strip()] = float(parts[1].strip()) - except ValueError: + except (ValueError, IndexError): pass else: - # plain CSV fallback - try: - idx = len(result) - if idx < len(self.analog_pins): + idx = len(result) + if idx < len(self.analog_pins): + try: result[self.analog_pins[idx]] = float(token) - except ValueError: - pass + except ValueError: + pass return result - def _build_write_cmd(self, pin: str, value: int) -> str: - return f"W:{pin}:{int(bool(value))}\n" - - # ── Utilities ──────────────────────────────────────────────────────── + # ── Utilities ───────────────────────────────────────────────────────── @staticmethod - def list_ports() -> List[str]: - """Return available serial port names.""" + def list_ports() -> List[Tuple[str, str]]: + """ + Return list of (device, description) for all detected serial ports. + Returns [] if pyserial is not installed. + """ if not _SERIAL_AVAILABLE: return [] - return [p.device for p in serial.tools.list_ports.comports()] + try: + return [ + (p.device, p.description or "") + for p in serial.tools.list_ports.comports() + ] + except Exception as e: + print(f"[ArduinoLayer] list_ports() error: {e}") + return [] - @property - def is_simulated(self) -> bool: - return self.simulate + @staticmethod + def is_pyserial_available() -> bool: + return _SERIAL_AVAILABLE def __repr__(self): mode = "SIM" if self.simulate else f"HW:{self.port}@{self.baud}" return f"" - - -# ════════════════════════════════════════════════════════════════════════════ -# Arduino Firmware Reference Sketch -# ════════════════════════════════════════════════════════════════════════════ -ARDUINO_SKETCH = """ -/* - * LabDAQ Arduino Firmware - * Upload this to your Arduino to communicate with the Python DAQ system. - * - * Protocol: - * SEND (Arduino → PC): "A0:3.14,A1:2.71,A2:1.41,D2:1,D3:0\\n" - * RECV (PC → Arduino): "W:D13:1\\n" to set digital outputs - * - * Analog values are converted from 10-bit ADC (0-1023) to 0.0-5.0 V. - */ - -const int ANALOG_PINS[] = {A0, A1, A2, A3, A4, A5}; -const int DIGITAL_IN[] = {2, 3, 4}; -const int DIGITAL_OUT[] = {5, 6, 7, 13}; -const int N_ANALOG = 6; -const int N_DIG_IN = 3; -const int N_DIG_OUT = 4; -const int SEND_INTERVAL = 50; // ms between transmissions - -unsigned long lastSend = 0; - -void setup() { - Serial.begin(115200); - for (int i = 0; i < N_DIG_IN; i++) pinMode(DIGITAL_IN[i], INPUT_PULLUP); - for (int i = 0; i < N_DIG_OUT; i++) pinMode(DIGITAL_OUT[i], OUTPUT); -} - -void loop() { - // ── Handle incoming commands ──────────────────────────── - if (Serial.available()) { - String cmd = Serial.readStringUntil('\\n'); - cmd.trim(); - if (cmd.startsWith("W:")) { - // W:D13:1 → set pin 13 HIGH - int colon1 = cmd.indexOf(':', 2); - int colon2 = cmd.indexOf(':', colon1 + 1); - if (colon1 > 0 && colon2 > 0) { - String pinStr = cmd.substring(colon1 + 1, colon2); - int val = cmd.substring(colon2 + 1).toInt(); - int pin = pinStr.substring(1).toInt(); // strip 'D' - digitalWrite(pin, val ? HIGH : LOW); - } - } - } - - // ── Transmit data ──────────────────────────────────────── - unsigned long now = millis(); - if (now - lastSend >= SEND_INTERVAL) { - lastSend = now; - String out = ""; - for (int i = 0; i < N_ANALOG; i++) { - float v = analogRead(ANALOG_PINS[i]) * (5.0 / 1023.0); - out += "A" + String(i) + ":" + String(v, 3); - if (i < N_ANALOG - 1) out += ","; - } - for (int i = 0; i < N_DIG_IN; i++) { - out += ",D" + String(DIGITAL_IN[i]) + ":" + String(!digitalRead(DIGITAL_IN[i])); - } - Serial.println(out); - } -} -*/ -""" diff --git a/core/__pycache__/__init__.cpython-314.pyc b/core/__pycache__/__init__.cpython-314.pyc index 2a7cb7e..e90d2a9 100644 Binary files a/core/__pycache__/__init__.cpython-314.pyc and b/core/__pycache__/__init__.cpython-314.pyc differ diff --git a/core/__pycache__/acquisition.cpython-314.pyc b/core/__pycache__/acquisition.cpython-314.pyc index d84b66b..c41e1a5 100644 Binary files a/core/__pycache__/acquisition.cpython-314.pyc and b/core/__pycache__/acquisition.cpython-314.pyc differ diff --git a/core/__pycache__/signal_processor.cpython-314.pyc b/core/__pycache__/signal_processor.cpython-314.pyc new file mode 100644 index 0000000..dc61203 Binary files /dev/null and b/core/__pycache__/signal_processor.cpython-314.pyc differ diff --git a/core/acquisition.py b/core/acquisition.py index 640e6d5..afccb56 100644 --- a/core/acquisition.py +++ b/core/acquisition.py @@ -3,7 +3,7 @@ core/acquisition.py Background acquisition engine. Polls all connected devices, buffers data, fires Qt signals, -writes CSV logs, and checks alarm thresholds. +and writes CSV logs. """ import csv @@ -18,7 +18,7 @@ from PyQt6.QtCore import QObject, pyqtSignal from devices.base_device import BaseDevice, DeviceStatus -MAX_BUFFER = 20_000 # samples per channel +MAX_BUFFER = 20_000 class ChannelBuffer: @@ -33,7 +33,6 @@ class ChannelBuffer: self.values.append(v) def window(self, seconds: float) -> Tuple[List[float], List[float]]: - """Return the last `seconds` worth of data.""" if not self.times: return [], [] cutoff = self.times[-1] - seconds @@ -63,35 +62,29 @@ class AcquisitionEngine(QObject): Signals ------- new_data(device_id, channel_id, timestamp, value) - alarm_triggered(device_id, channel_id, value, kind) kind: "low"|"high" device_status_changed(device_id, status_str) log_started(filepath) log_stopped(filepath) """ - new_data = pyqtSignal(str, str, float, float) - alarm_triggered = pyqtSignal(str, str, float, str) - device_status_changed = pyqtSignal(str, str) - log_started = pyqtSignal(str) - log_stopped = pyqtSignal(str) + new_data = pyqtSignal(str, str, float, float) + device_status_changed = pyqtSignal(str, str) + log_started = pyqtSignal(str) + log_stopped = pyqtSignal(str) def __init__(self, poll_interval_ms: int = 100): super().__init__() self._interval = poll_interval_ms / 1000.0 - self._devices: List[BaseDevice] = [] - self._buffers: Dict[str, Dict[str, ChannelBuffer]] = {} - self._running = False - self._thread: Optional[threading.Thread] = None - self._t0 = 0.0 - - # Logging - self._logging = False - self._log_path = "" - self._csv_file = None - self._csv_writer = None - - # Alarm dedup - self._alarm_state: Dict[str, bool] = {} + self._devices: List[BaseDevice] = [] + self._buffers: Dict[str, Dict[str, ChannelBuffer]] = {} + self._running = False + self._thread: Optional[threading.Thread] = None + self._t0 = 0.0 + + self._logging = False + self._log_path = "" + self._csv_file = None + self._csv_writer = None # ── Device management ──────────────────────────────────────────────── @@ -128,10 +121,11 @@ class AcquisitionEngine(QObject): # ── Logging ────────────────────────────────────────────────────────── def start_logging(self, filepath: str = "") -> str: - if not filepath: - os.makedirs("logs", exist_ok=True) - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - filepath = f"logs/daq_{ts}.csv" + if not filepath or filepath.endswith(os.sep) or filepath.endswith("/"): + dir_ = filepath if filepath else "logs" + os.makedirs(dir_, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + filepath = os.path.join(dir_, f"daq_{ts}.csv") self._log_path = filepath self._csv_file = open(filepath, "w", newline="") headers = ["elapsed_s"] @@ -163,9 +157,9 @@ class AcquisitionEngine(QObject): def _loop(self): while self._running: - t_start = time.time() - elapsed = t_start - self._t0 - log_row = [f"{elapsed:.4f}"] + t_start = time.time() + elapsed = t_start - self._t0 + log_row = [f"{elapsed:.4f}"] for dev in list(self._devices): active = dev.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED) @@ -180,15 +174,11 @@ class AcquisitionEngine(QObject): if val is None: log_row.append("") continue - # Buffer buf = self._buffers.get(dev.info.device_id, {}).get(ch.channel_id) if buf is not None: buf.append(elapsed, float(val)) - # Signal self.new_data.emit(dev.info.device_id, ch.channel_id, elapsed, float(val)) log_row.append(f"{val:.5f}") - # Alarms - self._check_alarm(dev.info.device_id, ch, float(val)) if self._logging and self._csv_writer: try: @@ -196,30 +186,7 @@ class AcquisitionEngine(QObject): except Exception: pass - # Sleep remainder of interval - dt = time.time() - t_start + dt = time.time() - t_start sleep = self._interval - dt if sleep > 0: time.sleep(sleep) - - # ── Alarm logic ─────────────────────────────────────────────────────── - - def _check_alarm(self, device_id: str, ch, val: float): - lo_key = f"{device_id}.{ch.channel_id}.lo" - hi_key = f"{device_id}.{ch.channel_id}.hi" - - if ch.alarm_low is not None: - if val < ch.alarm_low: - if not self._alarm_state.get(lo_key): - self._alarm_state[lo_key] = True - self.alarm_triggered.emit(device_id, ch.channel_id, val, "low") - else: - self._alarm_state[lo_key] = False - - if ch.alarm_high is not None: - if val > ch.alarm_high: - if not self._alarm_state.get(hi_key): - self._alarm_state[hi_key] = True - self.alarm_triggered.emit(device_id, ch.channel_id, val, "high") - else: - self._alarm_state[hi_key] = False diff --git a/core/signal_processor.py b/core/signal_processor.py new file mode 100644 index 0000000..4161d4d --- /dev/null +++ b/core/signal_processor.py @@ -0,0 +1,464 @@ +""" +core/signal_processor.py + +Signal processing pipeline engine. + +Provides two capabilities: + +1. FILTERS — applied to a raw channel buffer before plotting: + LowPass, HighPass, MovingAverage, Median, Derivative, Integral, Scale+Offset + +2. DERIVED CHANNELS — virtual channels computed from one or more physical + channels. Each derived channel runs a user-defined function every time + new data arrives. Built-ins: velocity/acceleration from displacement, + power from voltage+current, RMS, etc. Custom: arbitrary Python snippet. + +Architecture +------------ + SignalProcessor sits between AcquisitionEngine and StripChartWidget. + engine.new_data → SignalProcessor.process(dev, ch, t, val) + → emits processed_data(virtual_or_real_id, ch_id, t, val) + +The processor maintains its own ring buffers for derived channels so the +strip chart can query history just like physical channels. +""" + +from __future__ import annotations + +import math +import threading +import traceback +from collections import deque +from dataclasses import dataclass, field +from typing import Callable, Dict, List, Optional, Tuple, Any + +from PyQt6.QtCore import QObject, pyqtSignal + +# ── Constants ────────────────────────────────────────────────────────────── + +MAX_BUF = 20_000 + + +# ══════════════════════════════════════════════════════════════════════════════ +# Filter definitions +# ══════════════════════════════════════════════════════════════════════════════ + +class FilterBase: + """All filters implement __call__(value: float) -> float.""" + name: str = "identity" + params: dict = {} + + def __call__(self, value: float) -> float: + return value + + def reset(self): pass + + def to_dict(self) -> dict: + return {"type": self.name, **self.params} + + +class MovingAverageFilter(FilterBase): + name = "moving_average" + def __init__(self, window: int = 10): + self.params = {"window": window} + self._buf = deque(maxlen=window) + + def __call__(self, v: float) -> float: + self._buf.append(v) + return sum(self._buf) / len(self._buf) + + def reset(self): self._buf.clear() + + +class MedianFilter(FilterBase): + name = "median" + def __init__(self, window: int = 5): + self.params = {"window": window} + self._buf = deque(maxlen=window) + + def __call__(self, v: float) -> float: + self._buf.append(v) + s = sorted(self._buf) + n = len(s) + return s[n // 2] if n % 2 else (s[n//2 - 1] + s[n//2]) / 2 + + def reset(self): self._buf.clear() + + +class LowPassFilter(FilterBase): + """Exponential moving average (single-pole IIR low-pass).""" + name = "low_pass" + def __init__(self, alpha: float = 0.1): + """alpha=0.0 → no change, 1.0 → unfiltered.""" + self.params = {"alpha": alpha} + self._prev = None + + def __call__(self, v: float) -> float: + if self._prev is None: + self._prev = v + self._prev = self._prev + self.params["alpha"] * (v - self._prev) + return self._prev + + def reset(self): self._prev = None + + +class HighPassFilter(FilterBase): + """Simple single-pole IIR high-pass (compliment of low-pass).""" + name = "high_pass" + def __init__(self, alpha: float = 0.9): + self.params = {"alpha": alpha} + self._prev_v = None + self._prev_y = 0.0 + + def __call__(self, v: float) -> float: + if self._prev_v is None: + self._prev_v = v + y = self.params["alpha"] * (self._prev_y + v - self._prev_v) + self._prev_y = y + self._prev_v = v + return y + + def reset(self): self._prev_v = None; self._prev_y = 0.0 + + +class ScaleOffsetFilter(FilterBase): + """y = scale * x + offset (unit conversion, calibration).""" + name = "scale_offset" + def __init__(self, scale: float = 1.0, offset: float = 0.0): + self.params = {"scale": scale, "offset": offset} + + def __call__(self, v: float) -> float: + return self.params["scale"] * v + self.params["offset"] + + +class DerivativeFilter(FilterBase): + """Numerical first derivative dy/dt.""" + name = "derivative" + def __init__(self): self.params = {}; self._prev_v = None; self._prev_t = None + + def process_with_t(self, v: float, t: float) -> float: + if self._prev_t is None or t == self._prev_t: + self._prev_v = v; self._prev_t = t; return 0.0 + dy = (v - self._prev_v) / (t - self._prev_t) + self._prev_v = v; self._prev_t = t + return dy + + def __call__(self, v: float) -> float: + return 0.0 # use process_with_t for real output + + def reset(self): self._prev_v = None; self._prev_t = None + + +class IntegralFilter(FilterBase): + """Numerical integration (trapezoidal rule).""" + name = "integral" + def __init__(self): self.params = {}; self._sum = 0.0; self._prev_v = None; self._prev_t = None + + def process_with_t(self, v: float, t: float) -> float: + if self._prev_t is not None and t != self._prev_t: + self._sum += 0.5 * (v + self._prev_v) * (t - self._prev_t) + self._prev_v = v; self._prev_t = t + return self._sum + + def __call__(self, v: float) -> float: + return self._sum + + def reset(self): self._sum = 0.0; self._prev_v = None; self._prev_t = None + + +FILTER_CLASSES = { + "moving_average": MovingAverageFilter, + "median": MedianFilter, + "low_pass": LowPassFilter, + "high_pass": HighPassFilter, + "scale_offset": ScaleOffsetFilter, + "derivative": DerivativeFilter, + "integral": IntegralFilter, +} + + +def filter_from_dict(d: dict) -> FilterBase: + cls = FILTER_CLASSES.get(d.get("type", "")) + if cls is None: + return FilterBase() + params = {k: v for k, v in d.items() if k != "type"} + return cls(**params) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Derived channel definitions +# ══════════════════════════════════════════════════════════════════════════════ + +@dataclass +class DerivedChannel: + """ + A virtual channel computed from one or more physical channels. + + kind options: + "velocity" — derivative of a displacement source + "acceleration" — second derivative of a displacement source + "power" — voltage_source * current_source + "rms" — rolling RMS of a source (window samples) + "expression" — arbitrary Python expression string + "function" — multi-line Python function body (def compute(...)) + "custom_script" — full Python script, must define compute(inputs, t) + """ + channel_id: str # virtual ID, e.g. "vel_0" + name: str # display name + unit: str = "" + color: str = "#f72585" + kind: str = "expression" # see above + # Source channel references [("dev_id", "ch_id"), ...] + sources: List[Tuple[str, str]] = field(default_factory=list) + # For built-in kinds + params: Dict[str, Any] = field(default_factory=dict) + # For expression / function / custom_script + expression: str = "" # single-line: "x[0] * 2" + script: str = "" # multi-line function body + enabled: bool = True + # Runtime: compiled callable (not serialised) + _fn: Optional[Callable] = field(default=None, repr=False, compare=False) + + def compile(self) -> Optional[str]: + """ + Compile expression/script into self._fn. + Returns None on success, or error string on failure. + """ + try: + if self.kind == "expression": + # Single-line: inputs are x (list of latest values), t (time) + code = compile(f"__result__ = {self.expression}", "", "exec") + def _expr_fn(inputs, t, _code=code): + ns = {"x": inputs, "t": t, "math": math} + exec(_code, ns) + return float(ns["__result__"]) + self._fn = _expr_fn + + elif self.kind in ("function", "custom_script"): + # User provides a def compute(x, t): ... body + # We wrap it in a module namespace + src = self.script + if not src.strip().startswith("def compute"): + src = "def compute(x, t):\n" + "\n".join( + " " + ln for ln in src.splitlines() + ) + ns: dict = {"math": math} + exec(compile(src, "