diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-04-21 13:43:52 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-04-21 13:57:46 -0600 |
| commit | 2a634dca0c7962b90004f75c4cdac6225201bb5e (patch) | |
| tree | 3e1f0cd4ff165e77d17ebec5278c9bed885bb1da /core/profile.py | |
| parent | b1a61fd29e2282110bc4f4bc4616c55ed9d88dbb (diff) | |
V13
This version contains the profile feature. Allowins users to save the
channels, signals and plots for specific labs.
Diffstat (limited to 'core/profile.py')
| -rw-r--r-- | core/profile.py | 266 |
1 files changed, 266 insertions, 0 deletions
diff --git a/core/profile.py b/core/profile.py new file mode 100644 index 0000000..b2d25b4 --- /dev/null +++ b/core/profile.py @@ -0,0 +1,266 @@ +""" +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 + +Profiles are stored as human-readable JSON files (.labdaq). +""" + +from __future__ import annotations +import json +import os +from dataclasses import dataclass, field, asdict +from typing import Any, Dict, List, Optional + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _safe(d: dict, key: str, default=None): + return d.get(key, default) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Profile data model +# ══════════════════════════════════════════════════════════════════════════════ + +@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}, ...] + + +@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 + + +@dataclass +class Profile: + name: str = "Untitled Profile" + version: str = "1.0" + controls: List[Dict] = field(default_factory=list) + channels: List[Dict] = field(default_factory=list) + pipelines: List[Dict] = field(default_factory=list) + derived: List[Dict] = field(default_factory=list) + plot: Optional[Dict] = None # LayoutConfig.to_json() + settings: Dict[str, Any] = field(default_factory=dict) + + def to_json(self) -> str: + return json.dumps(asdict(self), indent=2) + + @staticmethod + def from_json(s: str) -> "Profile": + d = json.loads(s) + return Profile( + name = d.get("name", ""), + version = d.get("version", "1.0"), + controls = d.get("controls", []), + channels = d.get("channels", []), + pipelines = d.get("pipelines", []), + derived = d.get("derived", []), + plot = d.get("plot"), + settings = d.get("settings", {}), + ) + + def save(self, path: str): + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + with open(path, "w") as f: + f.write(self.to_json()) + + @staticmethod + def load(path: str) -> "Profile": + with open(path) as f: + return Profile.from_json(f.read()) + + +# ══════════════════════════════════════════════════════════════════════════════ +# ProfileManager — serialises / deserialises the live app state +# ══════════════════════════════════════════════════════════════════════════════ + +class ProfileManager: + """ + Converts between a Profile dict and the live application objects. + + Usage: + # Save + profile = ProfileManager.capture(registry, processor, chart_cfg, + control_specs, settings) + profile.save("my_lab.labdaq") + + # Load + profile = Profile.load("my_lab.labdaq") + ProfileManager.apply(profile, registry, processor, ...) + """ + + # ── Capture ────────────────────────────────────────────────────────────── + + @staticmethod + def capture( + registry, + processor, + plot_cfg, + control_specs: List, + settings: dict, + profile_name: str = "Profile", + ) -> Profile: + p = Profile(name=profile_name) + + # Controls + p.controls = [spec.to_dict() for spec in control_specs] + + # Channel overrides + for dev in registry.all_instances(): + for ch in dev.info.channels: + p.channels.append({ + "device_id": dev.info.device_id, + "channel_id": ch.channel_id, + "name": ch.name, + "unit": ch.unit, + "enabled": ch.enabled, + "color": ch.color, + }) + + # Signal pipelines + for key, pipeline in processor._pipelines.items(): + p.pipelines.append({ + "device_id": pipeline.device_id, + "channel_id": pipeline.channel_id, + "enabled": pipeline.enabled, + "filters": [f.to_dict() for f in pipeline.filters], + }) + + # Derived channels + for dc in processor.get_derived(): + p.derived.append({ + "channel_id": dc.channel_id, + "name": dc.name, + "unit": dc.unit, + "color": dc.color, + "kind": dc.kind, + "sources": [list(s) for s in dc.sources], + "expression": dc.expression, + "script": dc.script, + "params": {k: v for k, v in dc.params.items() + if not k.startswith("_")}, + "enabled": dc.enabled, + }) + + # Plot layout + if plot_cfg is not None: + try: + from ui.windows.plot_window import LayoutConfig + if hasattr(plot_cfg, "to_json"): + p.plot = json.loads(plot_cfg.to_json()) + except Exception: + pass + + p.settings = dict(settings) + return p + + # ── Apply ───────────────────────────────────────────────────────────────── + + @staticmethod + def apply( + profile: Profile, + registry, + processor, + control_panel, + settings_ref: dict, + ): + """Apply a loaded profile to the live application state.""" + from ui.control_editor import ControlSpec + from core.signal_processor import ( + ChannelPipeline, DerivedChannel, + filter_from_dict, + ) + + # ── Channel overrides ──────────────────────────────────────────── + for ch_data in profile.channels: + dev = registry.get_instance(ch_data["device_id"]) + if not dev: + continue + ch = dev.get_channel(ch_data["channel_id"]) + if not ch: + continue + ch.name = ch_data.get("name", ch.name) + ch.unit = ch_data.get("unit", ch.unit) + ch.enabled = ch_data.get("enabled", ch.enabled) + if ch_data.get("color"): + ch.color = ch_data["color"] + + # ── Signal pipelines ───────────────────────────────────────────── + for pd in profile.pipelines: + filters = [filter_from_dict(fd) for fd in pd.get("filters", [])] + pipeline = ChannelPipeline( + device_id=pd["device_id"], + channel_id=pd["channel_id"], + filters=filters, + enabled=pd.get("enabled", True), + ) + processor.set_pipeline(pipeline) + + # ── Derived channels ───────────────────────────────────────────── + for dd in profile.derived: + sources = [tuple(s) for s in dd.get("sources", [])] + dc = DerivedChannel( + channel_id=dd["channel_id"], + name=dd.get("name", dd["channel_id"]), + unit=dd.get("unit", ""), + color=dd.get("color", "#f72585"), + kind=dd.get("kind", "expression"), + sources=sources, + expression=dd.get("expression", ""), + script=dd.get("script", ""), + params=dd.get("params", {}), + enabled=dd.get("enabled", True), + ) + processor.add_derived(dc) + + # ── Controls ───────────────────────────────────────────────────── + specs = [ControlSpec.from_dict(d) for d in profile.controls] + control_panel.load_specs(specs) + + # ── Settings ───────────────────────────────────────────────────── + settings_ref.update(profile.settings) + + # ── Plot layout ─────────────────────────────────────────────────── + plot_cfg = None + if profile.plot: + try: + from ui.windows.plot_window import LayoutConfig + plot_cfg = LayoutConfig.from_json(json.dumps(profile.plot)) + except Exception as e: + print(f"[Profile] Could not restore plot layout: {e}") + + return plot_cfg # caller applies this to the chart |
