From 8184f9e6799800c5de21bd243fed08c14fa435ae Mon Sep 17 00:00:00 2001 From: gbradham Date: Thu, 20 Aug 2026 13:45:04 -0500 Subject: [PATCH] feat(gui): make the Behavior Input node placeable and configurable The node was registered with the flow engine and had no presence in the app: no palette entry, no port configuration, no properties. So the closed loop worked when a graph was built in Python and could not be built at all in the Builder, which is where experiments are actually authored. The port gap was the sharp one. setup_node_ports falls back to one generic input and one generic output for an unlisted type, but this node has no inputs and four outputs, so the fallback rendered the wrong shape and left On Enter -- output index 2 -- unreachable. A connection drawn on the canvas would have been wired to Active instead. Maimu gets an entry in the same table, which only relabels its ports from in/out to exec/exec. The behavior is chosen from a dropdown of the loaded model's own vocabulary rather than typed. A mistyped label is silently inert: the node compares the emitted string against the configured one and simply never matches, so the stimulus never fires and nothing anywhere says why. The combo stays editable so a graph can still be authored before any model is loaded, and a label saved from a model that is not currently loaded is preserved rather than dropped. The vocabulary reaches the editor over LiveSignalBus, which is already the boundary between vision and the flow. The camera panel publishes the model's classes when inference goes live, the same place it already hands them to the preview overlay. The alternative -- the properties panel reaching into the camera panel for a worker thread's attribute -- would couple the flow editor to the vision UI's internals. Confirmation window is exposed as a spin box floored at 1, with the latency its docstring documents stated in the panel: at 30 fps, 5 frames is about 167 ms on top of inference. --- src/glider/core/live_signals.py | 18 ++ src/glider/gui/panels/camera_panel.py | 5 + .../gui/panels/node_editor_controller.py | 65 ++++- src/glider/gui/panels/node_library_panel.py | 9 + src/glider/gui/styles/colors.py | 1 + tests/unit/gui/test_behavior_input_editor.py | 251 ++++++++++++++++++ 6 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 tests/unit/gui/test_behavior_input_editor.py diff --git a/src/glider/core/live_signals.py b/src/glider/core/live_signals.py index bf62130..f5792a3 100644 --- a/src/glider/core/live_signals.py +++ b/src/glider/core/live_signals.py @@ -59,6 +59,7 @@ class LiveSignalBus: def __init__(self) -> None: self._lock = threading.RLock() self._behavior_subs: list[Callable[[BehaviorEvent], None]] = [] + self._behaviors: list[str] = [] # -- behavior --------------------------------------------------------- def subscribe_behavior(self, callback: Callable[[BehaviorEvent], None]) -> None: @@ -79,6 +80,23 @@ def unsubscribe_behavior(self, callback: Callable[[BehaviorEvent], None]) -> Non "live bus: behavior subscriber removed (%d left)", len(self._behavior_subs) ) + @property + def behaviors(self) -> list[str]: + """The loaded model's ordered class vocabulary, or empty if none. + + The bus is already the boundary between vision and the flow, and the + label vocabulary is the one piece of vision metadata the flow *editor* + needs: a Behavior Input node has to offer the behaviors the model can + actually emit. Routing it here keeps the properties panel from reaching + into the camera panel to find a worker thread's attribute. + """ + return list(self._behaviors) + + def set_behaviors(self, names: list[str] | None) -> None: + """Publish the vocabulary of whatever model is now loaded.""" + self._behaviors = [str(n) for n in (names or [])] + logger.debug("LiveSignalBus: behavior vocabulary set to %s", self._behaviors) + def publish_behavior(self, event: BehaviorEvent) -> int: """Deliver *event* to every subscriber. Returns how many were called. diff --git a/src/glider/gui/panels/camera_panel.py b/src/glider/gui/panels/camera_panel.py index 042adc6..a4790d3 100644 --- a/src/glider/gui/panels/camera_panel.py +++ b/src/glider/gui/panels/camera_panel.py @@ -1602,6 +1602,11 @@ def _on_behavior_ready(self) -> None: if worker is None: return self._preview.set_behavior_vocab(worker.classes) + # Also hand the vocabulary to the flow side, so a Behavior Input node's + # properties can offer the behaviors this model actually emits instead + # of a free-text box where a typo means "never fires". + if self._live_signals is not None: + self._live_signals.set_behaviors(worker.classes) self._behavior_running = True self._live_behavior_btn.setText("Stop") self._live_behavior_btn.setEnabled(True) diff --git a/src/glider/gui/panels/node_editor_controller.py b/src/glider/gui/panels/node_editor_controller.py index 8094abc..d0b1934 100644 --- a/src/glider/gui/panels/node_editor_controller.py +++ b/src/glider/gui/panels/node_editor_controller.py @@ -58,7 +58,7 @@ def node_category_for_type(node_type: str) -> str: "StartFunction", "EndFunction", }, - "interface": {"Loop", "WaitForInput", "ZoneInput"}, + "interface": {"Loop", "WaitForInput", "ZoneInput", "BehaviorInput"}, "hardware": {"Output", "Input", "MotorGovernor", "Maimu"}, } @@ -161,6 +161,8 @@ def setup_node_ports(self, node_item, node_type: str) -> None: "EndFunction": ([">exec"], []), "FunctionCall": ([">exec"], [">next"]), "ZoneInput": ([], ["Occupied", "Object Count", ">On Enter", ">On Exit"]), + "BehaviorInput": ([], ["Active", "Behavior", ">On Enter", ">On Exit"]), + "Maimu": ([">exec"], [">exec"]), } inputs, outputs = port_configs.get(nt, ([">in"], [">out"])) @@ -585,6 +587,53 @@ def _sync_pulse_fields(_=None, combo=mode_combo): note.setWordWrap(True) props_layout.addRow(note) + elif node_type == "BehaviorInput": + self._add_section_header(props_layout, "BEHAVIOR") + saved_state = (node_config.state if node_config else None) or {} + + # Editable so a graph can be authored before any model is loaded, + # but populated from the loaded model's vocabulary when there is + # one -- a mistyped label silently never fires, which is the worst + # failure mode available to a closed-loop stimulus. + behavior_combo = QComboBox() + behavior_combo.setEditable(True) + known = self._known_behaviors() + if known: + behavior_combo.addItems(known) + behavior_combo.lineEdit().setPlaceholderText( + "behavior label" if known else "start live behavior to list the model's labels" + ) + saved_behavior = saved_state.get("target_behavior", "") + behavior_combo.setCurrentText(saved_behavior) + behavior_combo.currentTextChanged.connect( + lambda txt, nid=node_id: self._on_node_property_changed( + nid, "target_behavior", txt.strip() + ) + ) + props_layout.addRow("Behavior:", behavior_combo) + + frames_spin = QSpinBox() + frames_spin.setRange(1, 300) + frames_spin.setSuffix(" frames") + frames_spin.setValue(int(saved_state.get("min_frames", 5))) + frames_spin.setToolTip( + "Consecutive frames carrying the behavior before it counts as " + "entered, and the same number without it before it counts as left." + ) + frames_spin.valueChanged.connect( + lambda val, nid=node_id: self._on_node_property_changed(nid, "min_frames", val) + ) + props_layout.addRow("Confirm over:", frames_spin) + + note = QLabel( + "Per-frame classification is noisy, so a single stray frame must " + "not fire hardware. The confirmation delays the trigger: at 30 fps, " + "5 frames is about 167 ms on top of inference." + ) + note.setProperty("textRole", "muted") + note.setWordWrap(True) + props_layout.addRow(note) + elif node_type == "Delay": self._add_section_header(props_layout, "CONFIGURATION") @@ -1313,6 +1362,20 @@ def _on_node_device_changed(self, node_id: str, device_id: str) -> None: self._update_properties_panel(node_id) + def _known_behaviors(self) -> list[str]: + """Labels the currently loaded behavior model can emit, if any. + + Empty before a model is loaded, which is a normal authoring state and + not an error: the combo stays editable so the graph can still be built. + """ + core = getattr(self, "_core", None) + bus = getattr(core, "live_signals", None) if core is not None else None + try: + return list(bus.behaviors) if bus is not None else [] + except Exception: # noqa: BLE001 - a missing vocabulary must not break the panel + logger.debug("Could not read the behavior vocabulary", exc_info=True) + return [] + def _on_node_property_changed(self, node_id: str, prop_name: str, value) -> None: """Handle property change for a node.""" session = self._session diff --git a/src/glider/gui/panels/node_library_panel.py b/src/glider/gui/panels/node_library_panel.py index 3845b4b..8761761 100644 --- a/src/glider/gui/panels/node_library_panel.py +++ b/src/glider/gui/panels/node_library_panel.py @@ -173,6 +173,14 @@ def _setup_ui(self): ("Input", "Input", "Read from a device (digital or analog)"), ("Maimu", "Maimu", "Drive a Maimu BLE stimulator: on, off, or a timed pulse"), ], + "Behavior": [ + ( + "BehaviorInput", + "Behavior Input", + "Trigger on live behavior classification - fires On Enter / On " + "Exit as the animal starts and stops a behavior", + ), + ], "Audio": [ ("AudioPlayback", "Audio Playback", "Play an audio file (WAV/MP3)"), ], @@ -186,6 +194,7 @@ def _setup_ui(self): "Functions": colors.LIB_FUNCTIONS, "Control": colors.LIB_CONTROL, "I/O": colors.LIB_IO, + "Behavior": colors.LIB_BEHAVIOR, "Audio": colors.LIB_AUDIO, "Video": colors.LIB_VIDEO, "default": colors.BORDER, diff --git a/src/glider/gui/styles/colors.py b/src/glider/gui/styles/colors.py index 9271bc1..364c986 100644 --- a/src/glider/gui/styles/colors.py +++ b/src/glider/gui/styles/colors.py @@ -118,6 +118,7 @@ def qcolor_with_alpha(color: QColor, alpha: float) -> QColor: LIB_AUDIO = "#3d1a5f" LIB_VIDEO = "#1e3a5f" LIB_ZONES = "#4a3a1a" +LIB_BEHAVIOR = "#5f1a3a" # === Behavior State Colors (CV-specific, not theme colors) === BEHAVIOR_FREEZE = "#0000FF" diff --git a/tests/unit/gui/test_behavior_input_editor.py b/tests/unit/gui/test_behavior_input_editor.py new file mode 100644 index 0000000..6686206 --- /dev/null +++ b/tests/unit/gui/test_behavior_input_editor.py @@ -0,0 +1,251 @@ +"""The Behavior Input node has to be reachable from the app, not just the engine. + +Before this, the node was registered with the flow engine and had no GUI +presence at all: no palette entry, no port configuration, no properties. The +port fallback in ``setup_node_ports`` gave it one generic input and one generic +output, so its real shape -- no inputs, four outputs -- was wrong on the canvas +and ``On Enter`` at index 2 could never be wired to anything. +""" + +from types import SimpleNamespace + +import pytest +from PyQt6.QtWidgets import QComboBox, QSpinBox + +from glider.core.live_signals import LiveSignalBus +from glider.gui.node_graph.port_item import PortType +from glider.gui.panels.node_editor_controller import ( + NodeEditorController, + node_category_for_type, +) + +pytestmark = pytest.mark.usefixtures("qtbot") + + +def _controller(state, behaviors=None): + node_config = SimpleNamespace(device_id=None, state=state) + ctrl = NodeEditorController.__new__(NodeEditorController) # skip heavy __init__ + ctrl._graph_view = SimpleNamespace( + nodes={"b1": SimpleNamespace(node_type="BehaviorInput", _actual_node_type=None)} + ) + saved: list = [] + ctrl._session_fn = lambda: SimpleNamespace( + get_node=lambda nid: node_config, + update_node_state=lambda nid, patch: saved.append((nid, patch)), + ) + ctrl._hardware_manager = SimpleNamespace(devices={}, get_device=lambda i: None) + bus = LiveSignalBus() + if behaviors is not None: + bus.set_behaviors(behaviors) + ctrl._core = SimpleNamespace(live_signals=bus) + ctrl._zone_config = None + captured: dict = {} + ctrl._properties_dock = SimpleNamespace(setWidget=lambda w: captured.__setitem__("w", w)) + return ctrl, captured, saved + + +def _widgets(captured): + panel = captured["w"] + combo = next(c for c in panel.findChildren(QComboBox)) + spin = next(s for s in panel.findChildren(QSpinBox)) + return combo, spin + + +# --- placement ---------------------------------------------------------------- + + +def test_the_library_offers_a_behavior_input_button(qtbot): + """Registered but unplaceable is not a feature.""" + from glider.gui.panels.node_library_panel import DraggableNodeButton, NodeLibraryPanel + + panel = NodeLibraryPanel(lambda: None, SimpleNamespace()) + qtbot.addWidget(panel) + + types = {b._node_type for b in panel.findChildren(DraggableNodeButton)} + assert "BehaviorInput" in types + + +def test_it_styles_as_an_interface_node(qtbot): + assert node_category_for_type("BehaviorInput") == "interface" + + +# --- ports -------------------------------------------------------------------- + + +class _NodeItem: + def __init__(self): + self.inputs: list[tuple] = [] + self.outputs: list[tuple] = [] + + def add_input_port(self, name, port_type): + self.inputs.append((name, port_type)) + + def add_output_port(self, name, port_type): + self.outputs.append((name, port_type)) + + +def _ports(node_type): + ctrl = NodeEditorController.__new__(NodeEditorController) + item = _NodeItem() + ctrl.setup_node_ports(item, node_type) + return item + + +def test_behavior_input_draws_its_real_ports(qtbot): + """The generic fallback gave it one input and one output. It has none and + four -- and On Enter has to land at index 2 to match the node definition, + or a connection drawn on the canvas would fire the wrong output.""" + item = _ports("BehaviorInput") + + assert item.inputs == [] + assert [name for name, _ in item.outputs] == ["Active", "Behavior", "On Enter", "On Exit"] + assert item.outputs[2] == ("On Enter", PortType.EXEC) + assert item.outputs[3] == ("On Exit", PortType.EXEC) + assert item.outputs[0][1] == PortType.DATA + + +def test_maimu_draws_exec_ports_not_the_generic_fallback(qtbot): + item = _ports("Maimu") + assert item.inputs == [("exec", PortType.EXEC)] + assert item.outputs == [("exec", PortType.EXEC)] + + +# --- properties --------------------------------------------------------------- + + +def test_the_behavior_list_comes_from_the_loaded_model(qtbot): + """A free-text box would let a typo mean 'never fires', which is the worst + failure available to a closed-loop stimulus.""" + ctrl, captured, _ = _controller({}, behaviors=["darting", "freezing", "grooming"]) + + ctrl._update_properties_panel("b1") + + combo, _spin = _widgets(captured) + assert [combo.itemText(i) for i in range(combo.count())] == [ + "darting", + "freezing", + "grooming", + ] + + +def test_the_saved_behavior_is_shown(qtbot): + ctrl, captured, _ = _controller( + {"target_behavior": "darting", "min_frames": 8}, behaviors=["darting", "freezing"] + ) + + ctrl._update_properties_panel("b1") + + combo, spin = _widgets(captured) + assert combo.currentText() == "darting" + assert spin.value() == 8 + + +def test_a_behavior_saved_before_the_model_loaded_survives(qtbot): + """Authoring a graph without a model loaded is normal; the panel must not + drop a label just because it cannot currently offer it.""" + ctrl, captured, _ = _controller({"target_behavior": "head dips"}, behaviors=[]) + + ctrl._update_properties_panel("b1") + + combo, _spin = _widgets(captured) + assert combo.isEditable() + assert combo.currentText() == "head dips" + + +def test_choosing_a_behavior_persists(qtbot): + ctrl, captured, saved = _controller({}, behaviors=["darting", "freezing"]) + ctrl._update_properties_panel("b1") + combo, _spin = _widgets(captured) + + combo.setCurrentText("freezing") + + assert ("b1", {"target_behavior": "freezing"}) in saved + + +def test_editing_the_confirmation_window_persists(qtbot): + ctrl, captured, saved = _controller({"min_frames": 5}, behaviors=["darting"]) + ctrl._update_properties_panel("b1") + _combo, spin = _widgets(captured) + + spin.setValue(12) + + assert ("b1", {"min_frames": 12}) in saved + + +def test_confirmation_cannot_be_set_below_one(qtbot): + """One frame is no confirmation at all -- the configuration the node exists + to prevent.""" + ctrl, captured, _ = _controller({}, behaviors=["darting"]) + ctrl._update_properties_panel("b1") + _combo, spin = _widgets(captured) + + assert spin.minimum() == 1 + + +def test_a_missing_bus_does_not_break_the_panel(qtbot): + """The properties panel must open whether or not vision is running.""" + ctrl, captured, _ = _controller({"target_behavior": "darting"}) + ctrl._core = SimpleNamespace() # no live_signals at all + + ctrl._update_properties_panel("b1") + + combo, _spin = _widgets(captured) + assert combo.currentText() == "darting" + + +# --- the vocabulary reaches the bus ------------------------------------------- + + +def test_the_bus_carries_the_model_vocabulary(): + bus = LiveSignalBus() + assert bus.behaviors == [] + + bus.set_behaviors(["darting", "freezing"]) + assert bus.behaviors == ["darting", "freezing"] + + bus.set_behaviors(None) + assert bus.behaviors == [] + + +def test_the_published_vocabulary_is_a_copy(): + """A caller mutating what it got back must not edit the bus's state.""" + bus = LiveSignalBus() + bus.set_behaviors(["darting"]) + + bus.behaviors.append("nonsense") + + assert bus.behaviors == ["darting"] + + +def test_loading_a_model_publishes_its_vocabulary_to_the_bus(qtbot): + """The link that makes the dropdown non-empty: the camera panel knows the + model's classes, and the flow editor needs them.""" + from glider.gui.panels.camera_panel import CameraPanel + + panel = CameraPanel.__new__(CameraPanel) # skip the heavy __init__ + bus = LiveSignalBus() + panel._live_signals = bus + panel._behavior_worker = SimpleNamespace(classes=["darting", "freezing", "grooming"]) + panel._preview = SimpleNamespace(set_behavior_vocab=lambda names: None) + panel._behavior_running = False + panel._live_behavior_btn = SimpleNamespace(setText=lambda t: None, setEnabled=lambda e: None) + + panel._on_behavior_ready() + + assert bus.behaviors == ["darting", "freezing", "grooming"] + + +def test_a_panel_with_no_bus_still_goes_live(qtbot): + """Vision must not depend on a flow being attached.""" + from glider.gui.panels.camera_panel import CameraPanel + + panel = CameraPanel.__new__(CameraPanel) + panel._live_signals = None + panel._behavior_worker = SimpleNamespace(classes=["darting"]) + panel._preview = SimpleNamespace(set_behavior_vocab=lambda names: None) + panel._behavior_running = False + panel._live_behavior_btn = SimpleNamespace(setText=lambda t: None, setEnabled=lambda e: None) + + panel._on_behavior_ready() + + assert panel._behavior_running is True