diff options
| -rw-r--r-- | core/signal_processor.py | 53 | ||||
| -rw-r--r-- | ui/windows/signals_window.py | 156 |
2 files changed, 148 insertions, 61 deletions
diff --git a/core/signal_processor.py b/core/signal_processor.py index 723de66..df80dbb 100644 --- a/core/signal_processor.py +++ b/core/signal_processor.py @@ -442,28 +442,45 @@ class SignalProcessor(QObject): return dc._fn(inputs, t) elif dc.kind == "derivative": - # derivative of source[0] - src = dc.sources[0] if dc.sources else None - if src is None: return None + if not dc.sources: return None state = dc.params.setdefault("_state", {}) - prev_t = state.get("t"); prev_v = state.get("v") - state["t"] = t; state["v"] = inputs[0] - if prev_t is None or t == prev_t: return 0.0 - return (inputs[0] - prev_v) / (t - prev_t) + prev_v = state.get("v") + state["v"] = inputs[0] + if dc.params.get("deriv_wrt") == "channel": + if len(inputs) < 2: return None + prev_x = state.get("x") + state["x"] = inputs[1] + if prev_x is None or inputs[1] == prev_x: return 0.0 + return (inputs[0] - prev_v) / (inputs[1] - prev_x) + else: + prev_t = state.get("t") + state["t"] = t + if prev_t is None or t == prev_t: return 0.0 + return (inputs[0] - prev_v) / (t - prev_t) elif dc.kind == "second_derivative": - # second derivative — derivative of derivative - src = dc.sources[0] if dc.sources else None - if src is None: return None + if not dc.sources: return None state = dc.params.setdefault("_state", {}) - prev_t = state.get("t"); prev_v = state.get("v") - cur_vel = 0.0 - if prev_t is not None and t != prev_t: - cur_vel = (inputs[0] - prev_v) / (t - prev_t) - prev_vel = state.get("vel", 0.0) - state["t"] = t; state["v"] = inputs[0]; state["vel"] = cur_vel - if prev_t is None or t == prev_t: return 0.0 - return (cur_vel - prev_vel) / (t - prev_t) + prev_v = state.get("v") + if dc.params.get("deriv_wrt") == "channel": + if len(inputs) < 2: return None + prev_x = state.get("x") + cur_vel = 0.0 + if prev_x is not None and inputs[1] != prev_x: + cur_vel = (inputs[0] - prev_v) / (inputs[1] - prev_x) + prev_vel = state.get("vel", 0.0) + state["x"] = inputs[1]; state["v"] = inputs[0]; state["vel"] = cur_vel + if prev_x is None or inputs[1] == prev_x: return 0.0 + return (cur_vel - prev_vel) / (inputs[1] - prev_x) + else: + prev_t = state.get("t") + cur_vel = 0.0 + if prev_t is not None and t != prev_t: + cur_vel = (inputs[0] - prev_v) / (t - prev_t) + prev_vel = state.get("vel", 0.0) + state["t"] = t; state["v"] = inputs[0]; state["vel"] = cur_vel + if prev_t is None or t == prev_t: return 0.0 + return (cur_vel - prev_vel) / (t - prev_t) elif dc.kind == "power": # V * I diff --git a/ui/windows/signals_window.py b/ui/windows/signals_window.py index eb146f9..fbe0ec4 100644 --- a/ui/windows/signals_window.py +++ b/ui/windows/signals_window.py @@ -16,7 +16,7 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QFrame, QTabWidget, QComboBox, QLineEdit, QDoubleSpinBox, QSpinBox, QCheckBox, QTextEdit, QGroupBox, - QToolButton, + QToolButton, QSplitter, ) from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QCloseEvent @@ -281,43 +281,72 @@ class PipelineTab(QWidget): self._build() def _build(self): - lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.setSpacing(0) + root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) - # Top bar: add physical channel + add virtual channel - add_bar = QWidget(); add_bar.setObjectName("cfgGlobalBar") - ab_lay = QHBoxLayout(add_bar); ab_lay.setContentsMargins(10,7,10,7); ab_lay.setSpacing(6) - ab_lay.addWidget(QLabel("Add:")) - self._avail_cb = QComboBox(); self._avail_cb.setObjectName("channelPickerCb") - ab_lay.addWidget(self._avail_cb, 1) - add_btn = QPushButton("+ Add Channel"); add_btn.setObjectName("addTraceBtn") - add_btn.clicked.connect(self._on_add_channel) - ab_lay.addWidget(add_btn) + splitter = QSplitter(Qt.Orientation.Vertical) + splitter.setObjectName("channelSplitter") + root.addWidget(splitter) - sep = QFrame(); sep.setFrameShape(QFrame.Shape.VLine) - sep.setObjectName("toolbarSep"); sep.setFixedWidth(6) - ab_lay.addWidget(sep) + # ── Physical Channels pane ────────────────────────────────────────────── + phys_w = QWidget() + phys_l = QVBoxLayout(phys_w); phys_l.setContentsMargins(0,0,0,0); phys_l.setSpacing(0) - ab_lay.addWidget(QLabel("Virtual:")) + phys_bar = QWidget(); phys_bar.setObjectName("cfgGlobalBar") + pb_lay = QHBoxLayout(phys_bar); pb_lay.setContentsMargins(10,7,10,7); pb_lay.setSpacing(6) + pb_lbl = QLabel("Physical Channels"); pb_lbl.setObjectName("devWindowTitle") + pb_lay.addWidget(pb_lbl, 1) + self._avail_cb = QComboBox(); self._avail_cb.setObjectName("channelPickerCb") + pb_lay.addWidget(self._avail_cb) + add_btn = QPushButton("+ Add"); add_btn.setObjectName("addTraceBtn") + add_btn.clicked.connect(self._on_add_channel) + pb_lay.addWidget(add_btn) + phys_l.addWidget(phys_bar) + + div1 = QFrame(); div1.setFrameShape(QFrame.Shape.HLine) + div1.setObjectName("devWindowDivider"); phys_l.addWidget(div1) + + phys_scroll = QScrollArea(); phys_scroll.setWidgetResizable(True) + phys_scroll.setObjectName("deviceScroll") + phys_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._phys_cont = QWidget() + self._phys_inner = QVBoxLayout(self._phys_cont) + self._phys_inner.setContentsMargins(10,10,10,10); self._phys_inner.setSpacing(6) + self._phys_inner.addStretch() + phys_scroll.setWidget(self._phys_cont) + phys_l.addWidget(phys_scroll, 1) + splitter.addWidget(phys_w) + + # ── Virtual Channels pane ─────────────────────────────────────────────── + virt_w = QWidget() + virt_l = QVBoxLayout(virt_w); virt_l.setContentsMargins(0,0,0,0); virt_l.setSpacing(0) + + virt_bar = QWidget(); virt_bar.setObjectName("cfgGlobalBar") + vb_lay = QHBoxLayout(virt_bar); vb_lay.setContentsMargins(10,7,10,7); vb_lay.setSpacing(6) + vb_lbl = QLabel("Virtual Channels"); vb_lbl.setObjectName("devWindowTitle") + vb_lay.addWidget(vb_lbl, 1) self._new_kind = QComboBox(); self._new_kind.setObjectName("channelPickerCb") self._new_kind.addItems([_KIND_DISPLAY[k] for k in _ALL_KINDS]) - ab_lay.addWidget(self._new_kind) + vb_lay.addWidget(self._new_kind) add_virt = QPushButton("+ Add Virtual"); add_virt.setObjectName("addTraceBtn") add_virt.clicked.connect(self._add_virtual) - ab_lay.addWidget(add_virt) - lay.addWidget(add_bar) + vb_lay.addWidget(add_virt) + virt_l.addWidget(virt_bar) - div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) - div.setObjectName("devWindowDivider"); lay.addWidget(div) + div2 = QFrame(); div2.setFrameShape(QFrame.Shape.HLine) + div2.setObjectName("devWindowDivider"); virt_l.addWidget(div2) - scroll = QScrollArea(); scroll.setWidgetResizable(True) - scroll.setObjectName("deviceScroll") - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self._cont = QWidget() - self._inner = QVBoxLayout(self._cont) - self._inner.setContentsMargins(10,10,10,10); self._inner.setSpacing(6) - self._inner.addStretch() - scroll.setWidget(self._cont) - lay.addWidget(scroll, 1) + virt_scroll = QScrollArea(); virt_scroll.setWidgetResizable(True) + virt_scroll.setObjectName("deviceScroll") + virt_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._virt_cont = QWidget() + self._virt_inner = QVBoxLayout(self._virt_cont) + self._virt_inner.setContentsMargins(10,10,10,10); self._virt_inner.setSpacing(6) + self._virt_inner.addStretch() + virt_scroll.setWidget(self._virt_cont) + virt_l.addWidget(virt_scroll, 1) + splitter.addWidget(virt_w) + + splitter.setSizes([300, 300]) for dev in self.registry.all_instances(): for ch in dev.info.channels: @@ -336,9 +365,7 @@ class PipelineTab(QWidget): block.changed.connect(self.changed) block.removed.connect(self._on_remove_block) self._blocks[(dev_id, ch_id)] = block - # Insert before derived blocks and trailing stretch - insert_at = self._inner.count() - 1 - len(self._derived_blocks) - self._inner.insertWidget(insert_at, block) + self._phys_inner.insertWidget(self._phys_inner.count() - 1, block) def on_channel_enabled_changed(self, dev_id: str, ch_id: str, enabled: bool): key = (dev_id, ch_id) @@ -419,7 +446,7 @@ class PipelineTab(QWidget): key = (block.dev_id, block.ch_id) self._shown.discard(key) self._blocks.pop(key, None) - self._inner.removeWidget(block) + self._phys_inner.removeWidget(block) block.deleteLater() self._refresh_avail_cb() @@ -446,7 +473,7 @@ class PipelineTab(QWidget): dc = DerivedChannel(channel_id=f"sig_{n}", name=f"Signal {n}", kind=kind, color=color) blk = self._make_derived_block(dc) - self._inner.insertWidget(self._inner.count() - 1, blk) + self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk) def _make_derived_block(self, dc: DerivedChannel) -> DerivedBlock: blk = DerivedBlock(dc, self.registry, self.processor) @@ -458,19 +485,19 @@ class PipelineTab(QWidget): def _rm_virtual(self, blk: DerivedBlock): self.processor.remove_derived(blk.dc.channel_id) self._derived_blocks.remove(blk) - self._inner.removeWidget(blk); blk.deleteLater() + self._virt_inner.removeWidget(blk); blk.deleteLater() self.derived_changed.emit() def refresh_derived(self): """Rebuild DerivedBlock widgets from processor state (called after profile load).""" for blk in self._derived_blocks: - self._inner.removeWidget(blk); blk.deleteLater() + self._virt_inner.removeWidget(blk); blk.deleteLater() self._derived_blocks.clear() self._color_idx = 0 for dc in self.processor.get_derived(): blk = self._make_derived_block(dc) - self._inner.insertWidget(self._inner.count() - 1, blk) + self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk) self._color_idx = len(self._derived_blocks) @@ -575,13 +602,41 @@ class DerivedBlock(QFrame): src_lay = QVBoxLayout(src_grp); src_lay.setContentsMargins(6,4,6,4); src_lay.setSpacing(3) self._src_cont = QWidget(); self._src_vlay = QVBoxLayout(self._src_cont) self._src_vlay.setContentsMargins(0,0,0,0); self._src_vlay.setSpacing(3) - for s in self.dc.sources: + _is_deriv = self.dc.kind in ("derivative", "second_derivative") + src_init = [self.dc.sources[0]] if (_is_deriv and self.dc.sources) else self.dc.sources + for s in src_init: self._add_src(s) - add_s = QPushButton("+ Add Source"); add_s.setObjectName("addTraceBtn") - add_s.clicked.connect(lambda: self._add_src()) - src_lay.addWidget(self._src_cont); src_lay.addWidget(add_s) + self._add_src_btn = QPushButton("+ Add Source"); self._add_src_btn.setObjectName("addTraceBtn") + self._add_src_btn.clicked.connect(lambda: self._add_src()) + src_lay.addWidget(self._src_cont); src_lay.addWidget(self._add_src_btn) bl.addWidget(src_grp) + # W.r.t. — only for derivative/second_derivative + self._wrt_grp = QGroupBox("With Respect To"); self._wrt_grp.setObjectName("cfgGlobalBar") + wrt_lay = QVBoxLayout(self._wrt_grp); wrt_lay.setContentsMargins(6,6,6,6); wrt_lay.setSpacing(4) + wrt_top = QHBoxLayout(); wrt_top.addWidget(QLabel("Variable:")) + self._wrt_cb = QComboBox(); self._wrt_cb.setObjectName("channelPickerCb") + self._wrt_cb.addItems(["Time", "Channel"]) + _cur_wrt = self.dc.params.get("deriv_wrt", "time") + self._wrt_cb.setCurrentIndex(1 if _cur_wrt == "channel" else 0) + self._wrt_cb.currentIndexChanged.connect(self._on_wrt_changed) + wrt_top.addWidget(self._wrt_cb); wrt_top.addStretch() + wrt_lay.addLayout(wrt_top) + self._wrt_x_row = QWidget() + wx_lay = QHBoxLayout(self._wrt_x_row); wx_lay.setContentsMargins(0,0,0,0); wx_lay.setSpacing(6) + wx_lay.addWidget(QLabel("Channel (x):")) + self._wrt_x_cb = _channel_combo(self.registry, self.processor, + include_derived=True, show_unit=True) + self._wrt_x_cb.setObjectName("channelPickerCb") + if _cur_wrt == "channel" and len(self.dc.sources) > 1: + x_src = self.dc.sources[1] + for i in range(self._wrt_x_cb.count()): + if self._wrt_x_cb.itemData(i) == x_src: + self._wrt_x_cb.setCurrentIndex(i); break + wx_lay.addWidget(self._wrt_x_cb, 1) + wrt_lay.addWidget(self._wrt_x_row) + bl.addWidget(self._wrt_grp) + # Code editor self._code_grp = QGroupBox("Code") code_lay = QVBoxLayout(self._code_grp); code_lay.setContentsMargins(6,4,6,4) @@ -633,13 +688,28 @@ class DerivedBlock(QFrame): self._help.setText(_KIND_HELP.get(k,"")) self._update_visibility() + def _on_wrt_changed(self, idx: int): + self._wrt_x_row.setVisible(idx == 1) + def _update_visibility(self): + is_deriv = self.dc.kind in ("derivative", "second_derivative") self._code_grp.setVisible(self.dc.kind in _SCRIPT) self._win_widget.setVisible(self.dc.kind == "rms") + self._wrt_grp.setVisible(is_deriv) + self._add_src_btn.setVisible(not is_deriv) + if is_deriv: + self._wrt_x_row.setVisible(self._wrt_cb.currentIndex() == 1) def _apply(self): - self.dc.sources = [cb.currentData() for cb in self._src_combos - if cb.currentData()] + sources = [cb.currentData() for cb in self._src_combos if cb.currentData()] + if self.dc.kind in ("derivative", "second_derivative"): + wrt = "channel" if self._wrt_cb.currentIndex() == 1 else "time" + self.dc.params["deriv_wrt"] = wrt + if wrt == "channel": + x_src = self._wrt_x_cb.currentData() + if x_src: + sources.append(x_src) + self.dc.sources = sources if self.dc.kind == "expression": self.dc.expression = self._code.toPlainText().strip() elif self.dc.kind in ("function","custom_script"): |
