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