summaryrefslogtreecommitdiff
path: root/core/profile.py
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-04-21 13:43:52 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-04-21 13:57:46 -0600
commit2a634dca0c7962b90004f75c4cdac6225201bb5e (patch)
tree3e1f0cd4ff165e77d17ebec5278c9bed885bb1da /core/profile.py
parentb1a61fd29e2282110bc4f4bc4616c55ed9d88dbb (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.py266
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