From 7aec470acd69e9c1810b40aa5eea9b96b9eb73ad Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Tue, 9 Jun 2026 22:14:20 -0600 Subject: 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 --- ui/code_templates.py | 70 +++++++++++++++++++++++++++++++++++++++++++ ui/control_editor.py | 58 +++++++++++++++++++++++++++++++++-- ui/control_panel.py | 53 ++++++++++++++++++++++++++++---- ui/main_window.py | 2 +- ui/windows/channels_window.py | 63 +++++++++++++++++++++++++++++++++----- 5 files changed, 229 insertions(+), 17 deletions(-) create mode 100644 ui/code_templates.py (limited to 'ui') 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 + — 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, "