diff options
Diffstat (limited to 'core')
| -rw-r--r-- | core/__init__.py | 4 | ||||
| -rw-r--r-- | core/acquisition.py | 60 | ||||
| -rw-r--r-- | core/profile.py | 166 | ||||
| -rw-r--r-- | core/reference_registry.py | 74 | ||||
| -rw-r--r-- | core/signal_processor.py | 60 |
5 files changed, 240 insertions, 124 deletions
diff --git a/core/__init__.py b/core/__init__.py index 06fabd5..ba8c2b8 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # core/__init__.py -from core.acquisition import AcquisitionEngine, ChannelBuffer -__all__ = ["AcquisitionEngine", "ChannelBuffer"] +from core.acquisition import AcquisitionEngine, SignalBuffer +__all__ = ["AcquisitionEngine", "SignalBuffer"] diff --git a/core/acquisition.py b/core/acquisition.py index f54f265..eeeb4c8 100644 --- a/core/acquisition.py +++ b/core/acquisition.py @@ -21,7 +21,7 @@ from devices.base_device import BaseDevice, DeviceStatus MAX_BUFFER = 20_000 -class ChannelBuffer: +class SignalBuffer: """Circular time-series buffer for one channel.""" def __init__(self, maxlen: int = MAX_BUFFER): @@ -70,10 +70,10 @@ class AcquisitionEngine(QObject): Signals ------- - new_data(timestamp, readings) # readings: list[(device_id, channel_id, value)] — + new_data(timestamp, readings) # readings: list[(device_id, signal_id, value)] — # one batched emit per poll tick, not one per channel device_status_changed(device_id, status_str) - channel_unit_changed(device_id, channel_id, new_unit) # e.g. Mark-10 gauge + channel_unit_changed(device_id, signal_id, new_unit) # e.g. Mark-10 gauge # switched lb/kgF/N/ozF on its own front panel log_started(filepath) log_stopped(filepath) @@ -89,7 +89,7 @@ class AcquisitionEngine(QObject): super().__init__() self._interval = poll_interval_ms / 1000.0 self._devices: List[BaseDevice] = [] - self._buffers: Dict[str, Dict[str, ChannelBuffer]] = {} + self._buffers: Dict[str, Dict[str, SignalBuffer]] = {} self._last_units: Dict[Tuple[str, str], str] = {} self._last_status: Dict[str, str] = {} self._running = False @@ -106,11 +106,11 @@ class AcquisitionEngine(QObject): def add_device(self, dev: BaseDevice): self._devices.append(dev) self._buffers[dev.info.device_id] = { - ch.channel_id: ChannelBuffer() - for ch in dev.info.channels + ch.signal_id: SignalBuffer() + for ch in dev.info.signals } - for ch in dev.info.channels: - self._last_units[(dev.info.device_id, ch.channel_id)] = ch.unit + for ch in dev.info.signals: + self._last_units[(dev.info.device_id, ch.signal_id)] = ch.unit def on_device_reconfigured(self, device_id: str): """Reconcile buffers with a device's current channel list — reconfigure() @@ -120,13 +120,13 @@ class AcquisitionEngine(QObject): if dev is None: return bufs = self._buffers.setdefault(device_id, {}) - valid_ids = {ch.channel_id for ch in dev.info.channels} + valid_ids = {ch.signal_id for ch in dev.info.signals} for stale_id in [cid for cid in bufs if cid not in valid_ids]: bufs.pop(stale_id, None) - for ch in dev.info.channels: - if ch.channel_id not in bufs: - bufs[ch.channel_id] = ChannelBuffer() - self._last_units[(device_id, ch.channel_id)] = ch.unit + for ch in dev.info.signals: + if ch.signal_id not in bufs: + bufs[ch.signal_id] = SignalBuffer() + self._last_units[(device_id, ch.signal_id)] = ch.unit for key in [k for k in self._last_units if k[0] == device_id and k[1] not in valid_ids]: self._last_units.pop(key, None) @@ -137,8 +137,22 @@ class AcquisitionEngine(QObject): for key in [k for k in self._last_units if k[0] == device_id]: self._last_units.pop(key, None) - def get_buffer(self, device_id: str, channel_id: str) -> Optional[ChannelBuffer]: - return self._buffers.get(device_id, {}).get(channel_id) + def get_signal_buffer(self, device_id: str, signal_id: str) -> Optional[SignalBuffer]: + return self._buffers.get(device_id, {}).get(signal_id) + + def rename_channel(self, device_id: str, old_id: str, new_id: str) -> None: + """Move a channel's buffer/unit-cache entry to a new id after a rename + — mirrors on_device_reconfigured's bookkeeping but for a single + in-place rename rather than a full channel-list diff, so history is + carried over instead of dropped.""" + if old_id == new_id: + return + bufs = self._buffers.get(device_id) + if bufs and old_id in bufs: + bufs[new_id] = bufs.pop(old_id) + key = (device_id, old_id) + if key in self._last_units: + self._last_units[(device_id, new_id)] = self._last_units.pop(key) def clear_history(self): for ch_map in self._buffers.values(): @@ -181,8 +195,8 @@ class AcquisitionEngine(QObject): self._csv_file = open(filepath, "w", newline="") headers = ["elapsed_s"] for dev in self._devices: - for ch in dev.info.channels: - headers.append(f"{dev.info.device_id}/{ch.channel_id}[{ch.unit}]") + for ch in dev.info.signals: + headers.append(f"{dev.info.device_id}/{ch.signal_id}[{ch.unit}]") self._csv_writer = csv.writer(self._csv_file) self._csv_writer.writerow(headers) self._logging = True @@ -225,21 +239,21 @@ class AcquisitionEngine(QObject): print(f"[Acq] {dev.info.device_id} read error: {e}") readings = {} - for ch in dev.info.channels: - key = (dev.info.device_id, ch.channel_id) + for ch in dev.info.signals: + key = (dev.info.device_id, ch.signal_id) if self._last_units.get(key) != ch.unit: self._last_units[key] = ch.unit - self.channel_unit_changed.emit(dev.info.device_id, ch.channel_id, ch.unit) + self.channel_unit_changed.emit(dev.info.device_id, ch.signal_id, ch.unit) - val = readings.get(ch.channel_id) + val = readings.get(ch.signal_id) if val is None or not ch.enabled: log_row.append("") continue fval = float(val) - buf = self._buffers.get(dev.info.device_id, {}).get(ch.channel_id) + buf = self._buffers.get(dev.info.device_id, {}).get(ch.signal_id) if buf is not None: buf.append(elapsed, fval) - batch.append((dev.info.device_id, ch.channel_id, fval)) + batch.append((dev.info.device_id, ch.signal_id, fval)) log_row.append(f"{fval:.5f}") if batch: diff --git a/core/profile.py b/core/profile.py index c0ae5f1..c77e646 100644 --- a/core/profile.py +++ b/core/profile.py @@ -3,20 +3,28 @@ core/profile.py Lab profile — saves and loads the complete operator configuration: - • Controls — all control widgets (type, title, device, channel, params) - • Channels — per-channel visibility, name overrides - • Signal pipelines — filter stacks per channel - • Derived channels — all virtual/computed channels - • Plot layout — pane arrangement, traces, axes, time window + • Controls — all control widgets (type, title, device, signal, params) + • Signals — per-signal visibility, name overrides (physical device channels) + • Signal pipelines — filter stacks per signal + • Channels — all derived/virtual channels (Signals + math -> a new stream) + • Plot layout — pane arrangement, traces, axes, time window Profiles are stored as human-readable JSON files (.labui). + +Schema v2 renamed two sections from v1 to match the Signal/Channel naming +split (devices/base_device.py, core/signal_processor.py): the old "channels" +section (physical overrides) is now "signals", and the old "derived" section +(virtual channels) is now "channels". `_migrate_legacy_profile()` upgrades a +v1 file transparently on load, including remapping Arduino analog Signal ids +that used to be the raw pin name (e.g. "A0") to the stable positional id +("ai0") introduced alongside this schema — see devices/arduino_device.py. """ from __future__ import annotations import json import os from dataclasses import dataclass, field, asdict -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -26,54 +34,80 @@ def _safe(d: dict, key: str, default=None): # ══════════════════════════════════════════════════════════════════════════════ -# Profile data model +# Legacy (pre-2.0) schema migration # ══════════════════════════════════════════════════════════════════════════════ -@dataclass -class ProfileChannelOverride: - """Per-channel settings that survive device reconnects.""" - device_id: str - channel_id: str - name: str = "" - unit: str = "" - enabled: bool = True - color: str = "" - - -@dataclass -class ProfilePipeline: - """Serialised filter pipeline for one channel.""" - device_id: str - channel_id: str - enabled: bool = True - filters: List[Dict[str, Any]] = field(default_factory=list) - # filters = [{"type": "low_pass", "alpha": 0.1}, ...] - +def _migrate_legacy_profile(raw: dict) -> dict: + """Upgrade a pre-2.0 profile dict to the current schema. Returns a new + dict; does not mutate the input. Two independent changes bundled + together since both only matter for old files: + + 1. Section rename: "channels" (physical overrides) -> "signals", with + its "channel_id" keys -> "signal_id"; "derived" (virtual channel + defs) -> "channels". The "channels" (ex-"derived") section's own + "channel_id" field is untouched — a Channel's own id field was + never renamed, only what it's called at the top level. + + 2. Arduino analog Signal ids used to BE the pin name ("A0") instead of + a stable positional id ("ai0") that survives a pin remap (see + devices/arduino_device.py::_build_channels). Every reference to an + old pin-name id is rewritten to the new positional id, using each + Arduino device's saved `analog_pins` order to know which pin became + which "ai<N>". + """ + raw = dict(raw) + + derived = raw.pop("derived", []) + signals = raw.pop("channels", []) + for s in signals: + if "channel_id" in s: + s["signal_id"] = s.pop("channel_id") + raw["signals"] = signals + raw["channels"] = derived + + pin_to_id: Dict[Tuple[str, str], str] = {} # (device_id, old_pin) -> new positional id + for dev_cfg in raw.get("devices", []): + if dev_cfg.get("device_type") != "arduino": + continue + dev_id = dev_cfg.get("device_id", "") + for i, pin in enumerate(dev_cfg.get("analog_pins", [])): + pin_to_id[(dev_id, pin)] = f"ai{i}" + + if pin_to_id: + def _remap(dev_id: str, ch_id: str) -> str: + return pin_to_id.get((dev_id, ch_id), ch_id) + + for s in raw["signals"]: + s["signal_id"] = _remap(s.get("device_id", ""), s.get("signal_id", "")) + for pd in raw.get("pipelines", []): + pd["channel_id"] = _remap(pd.get("device_id", ""), pd.get("channel_id", "")) + for dc in raw["channels"]: + dc["sources"] = [[d, _remap(d, c)] for d, c in dc.get("sources", [])] + for c in raw.get("controls", []): + c["channel_id"] = _remap(c.get("device_id", ""), c.get("channel_id", "")) + plot = raw.get("plot") + if plot: + for pane in plot.get("panes", []): + for tr in pane.get("traces", []): + tr["channel_id"] = _remap(tr.get("device_id", ""), tr.get("channel_id", "")) + + raw["version"] = "2.0" + return raw -@dataclass -class ProfileDerived: - """Serialised derived/virtual channel.""" - channel_id: str - name: str - unit: str = "" - color: str = "#f72585" - kind: str = "expression" - sources: List = field(default_factory=list) # [(dev_id, ch_id), ...] - expression: str = "" - script: str = "" - params: Dict = field(default_factory=dict) - enabled: bool = True +# ══════════════════════════════════════════════════════════════════════════════ +# Profile data model +# ══════════════════════════════════════════════════════════════════════════════ @dataclass class Profile: name: str = "Untitled Profile" - version: str = "1.0" + version: str = "2.0" devices: List[Dict] = field(default_factory=list) controls: List[Dict] = field(default_factory=list) - channels: List[Dict] = field(default_factory=list) + signals: List[Dict] = field(default_factory=list) pipelines: List[Dict] = field(default_factory=list) - derived: List[Dict] = field(default_factory=list) + channels: List[Dict] = field(default_factory=list) plot: Optional[Dict] = None # LayoutConfig.to_json() settings: Dict[str, Any] = field(default_factory=dict) plugin_state: Dict[str, Any] = field(default_factory=dict) @@ -91,14 +125,16 @@ class Profile: @staticmethod def from_json(s: str) -> "Profile": d = json.loads(s) + if not str(d.get("version", "1.0")).startswith("2."): + d = _migrate_legacy_profile(d) return Profile( name = d.get("name", ""), - version = d.get("version", "1.0"), + version = d.get("version", "2.0"), devices = d.get("devices", []), controls = d.get("controls", []), - channels = d.get("channels", []), + signals = d.get("signals", []), pipelines = d.get("pipelines", []), - derived = d.get("derived", []), + channels = d.get("channels", []), plot = d.get("plot"), settings = d.get("settings", {}), plugin_state = d.get("plugin_state", {}), @@ -171,12 +207,12 @@ class ProfileManager: # Controls p.controls = [spec.to_dict() for spec in control_specs] - # Channel overrides + # Signal overrides for dev in registry.all_instances(): - for ch in dev.info.channels: - p.channels.append({ + for ch in dev.info.signals: + p.signals.append({ "device_id": dev.info.device_id, - "channel_id": ch.channel_id, + "signal_id": ch.signal_id, "name": ch.name, "unit": ch.unit, "enabled": ch.enabled, @@ -192,9 +228,9 @@ class ProfileManager: "filters": [f.to_dict() for f in pipeline.filters], }) - # Derived channels + # Channels (derived) for dc in processor.get_derived(): - p.derived.append({ + p.channels.append({ "channel_id": dc.channel_id, "name": dc.name, "unit": dc.unit, @@ -263,7 +299,7 @@ class ProfileManager: """Apply a loaded profile to the live application state.""" from ui.control_editor import ControlSpec from core.signal_processor import ( - ChannelPipeline, DerivedChannel, + ChannelPipeline, Channel, filter_from_dict, ) @@ -284,18 +320,6 @@ class ProfileManager: _DEVICE_FACTORIES["serial"] = SerialDevice except Exception: pass - # Backward compatibility with old profiles - try: - from devices.analog_input import AnalogInputDevice - _DEVICE_FACTORIES["analog_input"] = AnalogInputDevice - except Exception: - pass - try: - from devices.digital_io import DigitalIODevice - _DEVICE_FACTORIES["digital_io"] = DigitalIODevice - except Exception: - pass - # Clear all existing devices — profile defines the complete device set for dev in list(registry.all_instances()): try: @@ -362,12 +386,12 @@ class ProfileManager: except Exception as e: print(f"[Profile] Could not restore device {dev_id}: {e}") - # ── Channel overrides ──────────────────────────────────────────── - for ch_data in profile.channels: + # ── Signal overrides ───────────────────────────────────────────── + for ch_data in profile.signals: dev = registry.get_instance(ch_data["device_id"]) if not dev: continue - ch = dev.get_channel(ch_data["channel_id"]) + ch = dev.get_signal(ch_data["signal_id"]) if not ch: continue ch.name = ch_data.get("name", ch.name) @@ -387,10 +411,10 @@ class ProfileManager: ) processor.set_pipeline(pipeline) - # ── Derived channels ───────────────────────────────────────────── - for dd in profile.derived: + # ── Channels (derived) ──────────────────────────────────────────── + for dd in profile.channels: sources = [tuple(s) for s in dd.get("sources", [])] - dc = DerivedChannel( + dc = Channel( channel_id=dd["channel_id"], name=dd.get("name", dd["channel_id"]), unit=dd.get("unit", ""), diff --git a/core/reference_registry.py b/core/reference_registry.py new file mode 100644 index 0000000..b6fcd0f --- /dev/null +++ b/core/reference_registry.py @@ -0,0 +1,74 @@ +""" +core/reference_registry.py + +Generic (device_id, channel_id) reference-rename propagation. + +A Signal (physical device channel) or a Channel (derived, device_id == +"derived") can be renamed by the user. Both kinds are referenced the same +way everywhere downstream — plot traces, control targets, filter +pipelines, other derived channels' sources — as a plain +(device_id, channel_id) pair. Renaming one without rewriting every +reference to it leaves those references dangling: they silently stop +receiving data (this is exactly the bug that motivated this module — an +Arduino analog pin remap changed a Signal's id and orphaned its plot +trace). + +propagate_rename() is the single place that walks every subsystem able to +hold such a reference and rewrites it. It expects a MainWindow instance +(or anything exposing the same .engine/.processor/._chart/._ctrl/ +._win_config attributes) since that's the one place all of them are +reachable from. +""" + +from typing import Tuple + +Ref = Tuple[str, str] + + +def propagate_rename(app, old_ref: Ref, new_ref: Ref) -> None: + if old_ref == new_ref: + return + + # Physical signals live in the acquisition engine's buffers; derived + # Channels don't (their buffers live in SignalProcessor._derived_bufs). + if old_ref[0] != "derived": + app.engine.rename_channel(old_ref[0], old_ref[1], new_ref[1]) + + # Filter pipelines, other derived channels' sources, latest-value cache, + # and (if old_ref is itself a derived Channel) its ring buffer. + app.processor.rename_reference(old_ref, new_ref) + + # Live plot — trace specs in the strip chart's current layout. + chart = getattr(app, "_chart", None) + changed = False + if chart is not None and chart._cfg is not None: + for pane in chart._cfg.panes: + for tr in pane.traces: + if (tr.device_id, tr.channel_id) == old_ref: + tr.device_id, tr.channel_id = new_ref + changed = True + if changed: + chart.refresh() + + # Control panel — saved specs (profile persistence) + live bound widgets + # (actual I/O routing — ControlWidget reads its own .device_id/.channel_id + # directly in _write()/_check_stale(), not from the spec). + ctrl = getattr(app, "_ctrl", None) + if ctrl is not None: + for spec in ctrl.get_specs(): + if (spec.device_id, spec.channel_id) == old_ref: + spec.device_id, spec.channel_id = new_ref + for widget in getattr(ctrl, "_widgets", []): + if (widget.device_id, widget.channel_id) == old_ref: + widget.device_id, widget.channel_id = new_ref + + # Config window tabs, if currently open — refresh so pickers/tables + # don't show the stale id until next full refresh. + win = getattr(app, "_win_config", None) + if win is not None: + if chart is not None: + win.refresh_plot(chart._cfg) + if hasattr(win, "refresh_derived"): + win.refresh_derived() + if hasattr(win, "refresh_devices"): + win.refresh_devices() diff --git a/core/signal_processor.py b/core/signal_processor.py index 4781e91..da615b6 100644 --- a/core/signal_processor.py +++ b/core/signal_processor.py @@ -230,7 +230,7 @@ def _make_src_names(sources: List[Tuple[str, str]], # ══════════════════════════════════════════════════════════════════════════════ @dataclass -class DerivedChannel: +class Channel: """ A virtual channel computed from one or more physical channels. @@ -393,7 +393,7 @@ class SignalProcessor(QObject): def __init__(self): super().__init__() self._pipelines: Dict[Tuple[str, str], ChannelPipeline] = {} - self._derived: List[DerivedChannel] = [] + self._derived: List[Channel] = [] self._lock = threading.Lock() # Latest raw values cache for derived evaluation @@ -421,7 +421,7 @@ class SignalProcessor(QObject): # ── Derived channel management ──────────────────────────────────────── - def add_derived(self, dc: DerivedChannel) -> Optional[str]: + def add_derived(self, dc: Channel) -> Optional[str]: """Add a derived channel. Returns compile error string or None.""" err = dc.compile() if err: @@ -437,36 +437,40 @@ class SignalProcessor(QObject): self._derived = [d for d in self._derived if d.channel_id != channel_id] self._derived_bufs.pop(channel_id, None) - def rename_derived(self, old_id: str, new_id: str): - """Propagate a Channel ID rename to every other derived channel's - sources — otherwise anything chained from it keeps a dangling - ("derived", old_id) reference (SignalProcessor._evaluate_derived - looks sources up by exact match) and silently stops updating. - Also carries the ring buffer and latest-value cache over under the - new key so the rename doesn't reset plotted history or blank the - Value column for a poll cycle. Does NOT touch source_names or - expression/script text — those are display labels / user-authored - code, out of scope for an automatic rewrite.""" - if old_id == new_id: + def rename_reference(self, old_ref: Tuple[str, str], new_ref: Tuple[str, str]): + """Propagate a (device_id, channel_id) reference rename to everything + this processor holds one of — otherwise anything reading it keeps a + dangling reference (_evaluate_derived/_process_one look sources and + pipelines up by exact match) and silently stops updating. Covers + both a physical Signal rename and a derived Channel rename with the + same code path — the renamed item's own identity (Channel. + channel_id, or a device's SignalConfig) is the caller's job; this + only fixes up *references to* old_ref held elsewhere. + Also carries the latest-value cache, and — when old_ref is itself a + derived Channel (device_id == "derived") — its ring buffer, over + under the new key so the rename doesn't reset plotted history or + blank the Value column for a poll cycle. Does NOT touch + source_names or expression/script text — those are display labels / + user-authored code, out of scope for an automatic rewrite.""" + if old_ref == new_ref: return with self._lock: + if old_ref in self._pipelines: + self._pipelines[new_ref] = self._pipelines.pop(old_ref) + for dc in self._derived: - if dc.channel_id == new_id: - continue - new_sources = [ - ("derived", new_id) if d == "derived" and c == old_id else (d, c) - for d, c in dc.sources - ] + new_sources = [new_ref if s == old_ref else s for s in dc.sources] if new_sources != dc.sources: dc.sources = new_sources - buf = self._derived_bufs.pop(old_id, None) - if buf is not None: - self._derived_bufs[new_id] = buf - - entry = self._latest.pop(("derived", old_id), None) + entry = self._latest.pop(old_ref, None) if entry is not None: - self._latest[("derived", new_id)] = entry + self._latest[new_ref] = entry + + if old_ref[0] == "derived": + buf = self._derived_bufs.pop(old_ref[1], None) + if buf is not None: + self._derived_bufs[new_ref[1]] = buf def reorder_derived(self, src_idx: int, dst_idx: int): """Move the derived channel at src_idx to dst_idx — list order is @@ -478,7 +482,7 @@ class SignalProcessor(QObject): dc = self._derived.pop(src_idx) self._derived.insert(dst_idx, dc) - def get_derived(self) -> List[DerivedChannel]: + def get_derived(self) -> List[Channel]: return list(self._derived) def get_derived_buffer(self, channel_id: str): @@ -575,7 +579,7 @@ class SignalProcessor(QObject): self.derived_error.emit(dc.channel_id, traceback.format_exc(limit=3)) - def _compute_derived(self, dc: DerivedChannel, inputs: List[float], + def _compute_derived(self, dc: Channel, inputs: List[float], t: float, latest: dict) -> Optional[float]: if dc.kind in ("expression", "function", "custom_script"): if dc._fn is None: |
