summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-06-09 22:14:20 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-06-09 22:14:20 -0600
commit7aec470acd69e9c1810b40aa5eea9b96b9eb73ad (patch)
tree9146091077ec64ed1437621ebf2725edf987801f /ui
parentb138e72d6f81cf58f27c6c1e4ce65cfebbb58624 (diff)
Scripting: named signal vars, shared vars dict, control scripts, code templates
- Named variables in expressions: source channel IDs injected as Python identifiers alongside x[] (e.g. write `A0 * 2` instead of `x[0] * 2`) - Shared `vars` dict on SignalProcessor accessible from all expressions, functions, and control scripts; persisted in .labdaq profiles - Per-derived-channel `state` dict for persistent computation state in custom scripts - Control widgets gain optional on_action_script (Python snippet with value, vars, channels, math in scope); syntax-checked in editor dialog - Built-in derived kinds (derivative, rms, power, etc.) now show their Python implementation as read-only code previews; "Edit as Custom Script" converts to custom_script kind with template pre-filled Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'ui')
-rw-r--r--ui/code_templates.py70
-rw-r--r--ui/control_editor.py58
-rw-r--r--ui/control_panel.py53
-rw-r--r--ui/main_window.py2
-rw-r--r--ui/windows/channels_window.py63
5 files changed, 229 insertions, 17 deletions
diff --git a/ui/code_templates.py b/ui/code_templates.py
new file mode 100644
index 0000000..0863e62
--- /dev/null
+++ b/ui/code_templates.py
@@ -0,0 +1,70 @@
+"""
+ui/code_templates.py
+
+Python code templates for built-in derived channel kinds.
+Shown as read-only previews in the UI; clicking "Edit as Custom Script"
+converts the channel to custom_script kind with the template pre-filled.
+
+Templates use the enhanced expression namespace:
+ x — list of source values in order
+ <name> — each source's channel_id as a named variable
+ t — elapsed time (seconds)
+ math — Python math module
+ vars — shared SignalProcessor._script_vars dict (read/write)
+ state — per-channel persistent dict (survives between evaluations)
+ result — assign the computed value here (expression-style exec)
+"""
+
+BUILTIN_TEMPLATES: dict = {
+
+ "derivative": """\
+# dy/dt — backward finite difference
+prev_v = state.get("v", x[0])
+prev_t = state.get("t", t)
+state["v"] = x[0]
+state["t"] = t
+result = (x[0] - prev_v) / (t - prev_t) if t != prev_t else 0.0
+""",
+
+ "second_derivative": """\
+# d²y/dt² — two-pass finite difference
+prev_v = state.get("v", x[0])
+prev_t = state.get("t", t)
+prev_vel = state.get("vel", 0.0)
+
+cur_vel = (x[0] - prev_v) / (t - prev_t) if t != prev_t else 0.0
+result = (cur_vel - prev_vel) / (t - prev_t) if t != prev_t else 0.0
+
+state["v"] = x[0]
+state["t"] = t
+state["vel"] = cur_vel
+""",
+
+ "rms": """\
+# Rolling RMS over a sliding window
+import math as _math
+buf = state.setdefault("buf", [])
+buf.append(x[0])
+window = 20 # adjust window size (samples) here
+if len(buf) > window:
+ buf.pop(0)
+result = _math.sqrt(sum(v * v for v in buf) / len(buf))
+""",
+
+ "power": """\
+# Electrical power P = V × I
+# source 0 = voltage, source 1 = current
+result = x[0] * x[1]
+""",
+
+ "difference": """\
+# Difference A − B
+# source 0 = A, source 1 = B
+result = x[0] - x[1]
+""",
+
+ "sum": """\
+# Sum of all sources
+result = sum(x)
+""",
+}
diff --git a/ui/control_editor.py b/ui/control_editor.py
index c582e6b..48de21d 100644
--- a/ui/control_editor.py
+++ b/ui/control_editor.py
@@ -18,8 +18,10 @@ from PyQt6.QtWidgets import (
QLabel, QLineEdit, QComboBox, QDoubleSpinBox,
QSpinBox, QPushButton, QGroupBox, QWidget,
QStackedWidget, QFrame, QCheckBox, QSizePolicy,
+ QTextEdit, QScrollArea,
)
from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QFont, QFontMetrics
class _DynStack(QStackedWidget):
@@ -68,6 +70,8 @@ class ControlSpec:
max_val: float = 100.0
step: float = 1.0
max_rpm: int = 3000
+ # Optional Python snippet executed on every value change
+ on_action_script: str = ""
def to_dict(self) -> dict:
return asdict(self)
@@ -177,8 +181,8 @@ class ControlEditorDialog(QDialog):
self.result_spec: Optional[ControlSpec] = None
self.setWindowTitle("Edit Control" if spec else "Add Control")
- self.setMinimumSize(440, 460)
- self.resize(460, 500)
+ self.setMinimumSize(440, 520)
+ self.resize(460, 580)
self._build()
self._load_spec()
@@ -244,6 +248,40 @@ class ControlEditorDialog(QDialog):
self._stack.addWidget(panel)
params_lay.addWidget(self._stack)
root.addWidget(self._params_grp)
+
+ # ── On-action script ───────────────────────────────────────────
+ script_grp = QGroupBox("On Action Script (optional)")
+ script_lay = QVBoxLayout(script_grp); script_lay.setContentsMargins(10, 12, 10, 10)
+
+ _help = QLabel(
+ "Runs every time this control changes value.\n"
+ "Available: value, channel_id, device_id, vars (shared dict), "
+ "channels (latest values by channel_id), math"
+ )
+ _help.setObjectName("traceSource"); _help.setWordWrap(True)
+ script_lay.addWidget(_help)
+
+ _mono = QFont("IBM Plex Mono, Consolas, Monospace")
+ _mono.setStyleHint(QFont.StyleHint.Monospace)
+ self._script_edit = QTextEdit()
+ self._script_edit.setFont(_mono)
+ self._script_edit.setTabStopDistance(QFontMetrics(_mono).horizontalAdvance(" ") * 4)
+ self._script_edit.setMinimumHeight(80); self._script_edit.setMaximumHeight(160)
+ self._script_edit.setPlaceholderText(
+ "# Example: zero a position offset when button is pressed\n"
+ "# vars['zero_pos'] = channels.get('encoder_0', 0)"
+ )
+ script_lay.addWidget(self._script_edit)
+
+ self._script_status = QLabel("")
+ self._script_status.setObjectName("traceSource")
+ check_btn = QPushButton("Check Syntax")
+ check_btn.clicked.connect(self._check_script)
+ btn_row = QHBoxLayout()
+ btn_row.addWidget(self._script_status, 1)
+ btn_row.addWidget(check_btn)
+ script_lay.addLayout(btn_row)
+ root.addWidget(script_grp)
root.addStretch()
# ── Buttons ────────────────────────────────────────────────────
@@ -258,6 +296,18 @@ class ControlEditorDialog(QDialog):
btn_row.addWidget(cancel); btn_row.addWidget(ok)
root.addLayout(btn_row)
+ def _check_script(self):
+ src = self._script_edit.toPlainText().strip()
+ if not src:
+ self._script_status.setText(""); return
+ try:
+ compile(src, "<script>", "exec")
+ self._script_status.setText("✓ OK")
+ self._script_status.setStyleSheet("color:#22c55e;")
+ except SyntaxError as e:
+ self._script_status.setText(f"⚠ {e}")
+ self._script_status.setStyleSheet("color:#ef4444;")
+
def _load_spec(self):
"""Populate fields from self.spec."""
# Type
@@ -287,6 +337,9 @@ class ControlEditorDialog(QDialog):
for name, panel in self._param_panels.items():
panel.load(self.spec)
+ # Script
+ self._script_edit.setPlainText(self.spec.on_action_script)
+
def _on_type_changed(self, type_name: str):
idx = list(_PARAM_PANELS.keys()).index(type_name)
self._stack.setCurrentIndex(idx)
@@ -340,6 +393,7 @@ class ControlEditorDialog(QDialog):
device_id=self._dev_cb.currentData() or "",
channel_id=channel_id,
active_low=self._active_low_chk.isChecked(),
+ on_action_script=self._script_edit.toPlainText().strip(),
)
# Save type-specific params
panel = self._param_panels.get(ctype)
diff --git a/ui/control_panel.py b/ui/control_panel.py
index 7a3e416..6e78e89 100644
--- a/ui/control_panel.py
+++ b/ui/control_panel.py
@@ -20,6 +20,9 @@ an instance into ControlPanel.add_widget().
from __future__ import annotations
from typing import Optional
+import math as _math
+from typing import Any
+
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QSlider, QDoubleSpinBox, QComboBox,
@@ -44,7 +47,9 @@ class ControlWidget(QFrame):
def __init__(self, title: str, icon: str = "⚙",
device_id: str = "", channel_id: str = "",
registry: Optional[DeviceRegistry] = None,
- active_low: bool = False):
+ active_low: bool = False,
+ processor=None,
+ on_action_script: str = ""):
super().__init__()
self.title = title
self.icon = icon
@@ -52,9 +57,20 @@ class ControlWidget(QFrame):
self.channel_id = channel_id
self.registry = registry
self.active_low = active_low
+ self._processor = processor
+ self._on_action_fn = None
+ if on_action_script.strip():
+ self._compile_action(on_action_script)
self.setObjectName("controlWidget")
self._build_frame()
+ def _compile_action(self, script: str):
+ try:
+ code = compile(script, "<control_script>", "exec")
+ self._on_action_fn = code
+ except SyntaxError as e:
+ print(f"[Control '{self.title}'] script syntax error: {e}")
+
def _logic_level(self, enabled: bool) -> float:
"""Map logical ON/OFF to electrical level, respecting active-low outputs."""
if self.active_low:
@@ -112,6 +128,27 @@ class ControlWidget(QFrame):
f"check device type and channel ID")
self.value_changed.emit(self.channel_id, value)
+ if self._on_action_fn is not None:
+ try:
+ sv: dict = {}
+ channels: dict = {}
+ if self._processor is not None:
+ with self._processor._lock:
+ sv = self._processor._script_vars
+ channels = {ch_id: v
+ for (_, ch_id), (_, v) in self._processor._latest.items()}
+ ns: dict = {
+ "value": value,
+ "channel_id": self.channel_id,
+ "device_id": self.device_id,
+ "vars": sv,
+ "channels": channels,
+ "math": _math,
+ }
+ exec(self._on_action_fn, ns)
+ except Exception as e:
+ print(f"[Control '{self.title}'] script error: {e}")
+
# ══════════════════════════════════════════════════════════════════════════════
# On/Off Switch
@@ -480,9 +517,10 @@ class ControlPanel(QWidget):
controls_changed = pyqtSignal() # emitted whenever widgets are added/edited/removed
- def __init__(self, registry: DeviceRegistry):
+ def __init__(self, registry: DeviceRegistry, processor=None):
super().__init__()
- self.registry = registry
+ self.registry = registry
+ self.processor = processor
self._widgets: list[ControlWidget] = []
self._specs: list = [] # parallel list of ControlSpec
self._build()
@@ -560,7 +598,7 @@ class ControlPanel(QWidget):
def _add_widget_from_spec(self, spec):
"""Instantiate a ControlWidget from a ControlSpec and add to panel."""
from ui.control_editor import ControlSpec as CS
- widget = _build_widget_from_spec(spec, self.registry)
+ widget = _build_widget_from_spec(spec, self.registry, self.processor)
if widget is None:
return
wrapper = self._make_wrapper(widget, spec)
@@ -641,7 +679,7 @@ class ControlPanel(QWidget):
self._widgets.pop(idx); self._specs.pop(idx)
# Insert new one at same position
- new_widget = _build_widget_from_spec(new_spec, self.registry)
+ new_widget = _build_widget_from_spec(new_spec, self.registry, self.processor)
if new_widget is None:
return
new_wrapper = self._make_wrapper(new_widget, new_spec)
@@ -674,7 +712,8 @@ class ControlPanel(QWidget):
# ── Factory — build a ControlWidget from a ControlSpec ─────────────────────────
-def _build_widget_from_spec(spec, registry: DeviceRegistry) -> Optional[ControlWidget]:
+def _build_widget_from_spec(spec, registry: DeviceRegistry,
+ processor=None) -> Optional[ControlWidget]:
"""Instantiate the right ControlWidget subclass from a ControlSpec."""
# Base kwargs — icon is NOT included here; it's passed explicitly below
# so subclasses never get a double-value collision.
@@ -683,6 +722,8 @@ def _build_widget_from_spec(spec, registry: DeviceRegistry) -> Optional[ControlW
channel_id=spec.channel_id,
registry=registry,
active_low=getattr(spec, "active_low", False),
+ processor=processor,
+ on_action_script=getattr(spec, "on_action_script", ""),
)
t = spec.control_type
ttl = spec.title
diff --git a/ui/main_window.py b/ui/main_window.py
index ebc7463..d0e0ac4 100644
--- a/ui/main_window.py
+++ b/ui/main_window.py
@@ -178,7 +178,7 @@ class MainWindow(QMainWindow):
hsplit = QSplitter(Qt.Orientation.Horizontal); hsplit.setHandleWidth(3)
- self._ctrl = ControlPanel(self.registry)
+ self._ctrl = ControlPanel(self.registry, processor=self.processor)
self._ctrl.setMinimumWidth(200); self._ctrl.setMaximumWidth(340)
self._ctrl._add_demo_widgets()
hsplit.addWidget(self._ctrl)
diff --git a/ui/windows/channels_window.py b/ui/windows/channels_window.py
index f0ea34e..ecc7cbc 100644
--- a/ui/windows/channels_window.py
+++ b/ui/windows/channels_window.py
@@ -606,9 +606,21 @@ _KIND_HELP = {
"rms": "Rolling RMS from 1 source (set window below)",
"difference": "source[0] − source[1] from 2 sources",
"sum": "Σ all source values",
- "expression": "x = list of source values, t = elapsed time (s)\nExample: x[0] * 3.14159 / 180",
- "function": "def compute(x, t):\n # x = source values list\n return x[0] * 2.0",
- "custom_script":"Full Python. Must define: def compute(x, t): ...",
+ "expression": (
+ "Single-line Python expression.\n"
+ "Available: x[0]… or use signal names directly (e.g. A0, encoder_0)\n"
+ " t = elapsed time (s), math, vars, state\n"
+ "Example: A0 * 3.14159 / 180"
+ ),
+ "function": (
+ "Multi-line function body — auto-wrapped as def compute(x, t):\n"
+ "Available globals: x, signal names, t, math, vars, state\n"
+ "Example:\n offset = vars.get('zero', 0)\n return x[0] - offset"
+ ),
+ "custom_script": (
+ "Full Python. Must define: def compute(x, t): ...\n"
+ "Available globals: x, signal names, t, math, vars, state"
+ ),
}
@@ -728,17 +740,28 @@ class DerivedBlock(QFrame):
wrt_lay.addWidget(self._wrt_x_row)
bl.addWidget(self._wrt_grp)
- # Code editor
+ # Code editor (shown for all kinds — read-only for built-ins)
self._code_grp = QGroupBox("Code")
code_lay = QVBoxLayout(self._code_grp); code_lay.setContentsMargins(6, 4, 6, 4)
+ _mono = QFont("IBM Plex Mono, Consolas, Monospace"); _mono.setStyleHint(QFont.StyleHint.Monospace)
self._code = QTextEdit(); self._code.setObjectName("codeEditor")
self._code.setMinimumHeight(90); self._code.setMaximumHeight(180)
- _mono = QFont("IBM Plex Mono, Consolas, Monospace"); _mono.setStyleHint(QFont.StyleHint.Monospace)
self._code.setFont(_mono)
self._code.setTabStopDistance(QFontMetrics(_mono).horizontalAdvance(" ") * 4)
- txt = self.dc.expression if self.dc.kind == "expression" else self.dc.script
+ if self.dc.kind in _BUILTIN:
+ from ui.code_templates import BUILTIN_TEMPLATES
+ txt = BUILTIN_TEMPLATES.get(self.dc.kind, "")
+ elif self.dc.kind == "expression":
+ txt = self.dc.expression
+ else:
+ txt = self.dc.script
self._code.setPlainText(txt)
code_lay.addWidget(self._code)
+ # "Edit as Custom Script" button — only visible for built-in kinds
+ self._edit_as_script_btn = QPushButton("✎ Edit as Custom Script")
+ self._edit_as_script_btn.setObjectName("addTraceBtn")
+ self._edit_as_script_btn.clicked.connect(self._convert_to_custom_script)
+ code_lay.addWidget(self._edit_as_script_btn)
bl.addWidget(self._code_grp)
# Status + apply
@@ -783,19 +806,43 @@ class DerivedBlock(QFrame):
if it.widget(): it.widget().deleteLater()
def _on_kind(self, idx: int):
+ from ui.code_templates import BUILTIN_TEMPLATES
k = _ALL_KINDS[idx]
self.dc.kind = k
self._help.setText(_KIND_HELP.get(k, ""))
if k in ("derivative", "second_derivative") and not self._src_combos:
self._add_src()
+ if k in _BUILTIN:
+ # Show the template as a read-only preview
+ self._code.setPlainText(BUILTIN_TEMPLATES.get(k, ""))
self._update_visibility()
def _on_wrt_changed(self, idx: int):
self._wrt_x_row.setVisible(idx == 1)
+ def _convert_to_custom_script(self):
+ """Switch kind to custom_script, pre-fill with built-in template, make editable."""
+ from ui.code_templates import BUILTIN_TEMPLATES
+ template = BUILTIN_TEMPLATES.get(self.dc.kind, "")
+ # Switch kind combo to custom_script
+ idx = _ALL_KINDS.index("custom_script")
+ self._kind_cb.blockSignals(True)
+ self._kind_cb.setCurrentIndex(idx)
+ self._kind_cb.blockSignals(False)
+ self.dc.kind = "custom_script"
+ self._help.setText(_KIND_HELP.get("custom_script", ""))
+ self.dc.script = template
+ self._code.setPlainText(template)
+ self._update_visibility()
+
def _update_visibility(self):
- is_deriv = self.dc.kind in ("derivative", "second_derivative")
- self._code_grp.setVisible(self.dc.kind in _SCRIPT)
+ is_deriv = self.dc.kind in ("derivative", "second_derivative")
+ is_builtin = self.dc.kind in _BUILTIN
+ is_script = self.dc.kind in _SCRIPT
+ # Code group shown for all kinds; read-only for built-ins
+ self._code_grp.setVisible(True)
+ self._code.setReadOnly(is_builtin)
+ self._edit_as_script_btn.setVisible(is_builtin)
self._win_widget.setVisible(self.dc.kind == "rms")
self._wrt_grp.setVisible(is_deriv)
self._add_src_btn.setVisible(not is_deriv)