From 47010828f5a9856ff24c0317f2b8e489c7b2056c Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Thu, 1 Oct 2026 11:42:58 -0400 Subject: [PATCH] feat(trigger): add TrainCount + trigger_status for polled progress Lets the operator interface poll sonication progress against ``TxDevice.get_trigger()`` instead of subscribing to the async ``signal_data_received`` STATUS-frame OWSignal. Companion to openlifu-operator-interface#78 (PROJ-08 Task 1: Running-state ProgressBar + firmware-driven Running->Finished transition). The walking-skeleton OI deliberately dropped async telemetry / status-frame subscriptions in openlifu-operator-interface#92; reintroducing one for sonication progress would undo that work. A polled model fits the sync-probe idiom the OI state machine already uses for connect state and firmware compat. ## Changes ### Simulated TxDevice * ``SimulatedTxDevice.__init__`` adds ``self._train_curr = 0`` so a polled host can read progress without subscribing to the OWSignal. Written by the engine on each tick. * ``SimulatedTxDevice.get_trigger_json()`` adds ``"TrainCount": self._train_curr`` to its returned dict. * ``SimulatedTxDevice.get_trigger()`` is new -- mirrors the shape of the real ``LIFUTXDevice.TxDevice.get_trigger()`` so OI code calling ``interface.txdevice.get_trigger()`` gets the same keys against sim and real hardware. Adds two walking-skeleton polling fields: ``train_count`` (current running counter) and ``trigger_status`` (``"RUNNING"`` / ``"STOPPED"``). ### Sim engine * ``_SimulatedRunEngine.start()`` resets ``self._tx._train_curr = 0`` so a polled host sees progress starting from 0 on every fresh sonication (previously the counter carried over from a prior run if the host polled before the first tick). * ``_SimulatedRunEngine._on_train_tick`` mirrors ``self._train_curr`` back to ``self._tx._train_curr`` after each increment -- the field the polled host reads. * ``_SimulatedRunEngine._on_train_tick`` natural-completion branch now calls ``self._tx.stop_trigger()`` (previously only an explicit ``stop_sonication`` flipped ``_trigger_running`` to False, so a polled host saw ``TriggerStatus=RUNNING`` after the sim finished naturally). The emitted STATUS frame already carried ``status=STOPPED`` so callers that subscribed to the OWSignal weren't affected by the bug; polled callers were. ### Real TxDevice * ``LIFUTXDevice.get_trigger()`` extends its return dict with the same two fields: * ``"train_count": trigger_json.get("TrainCount", 0)`` * ``"trigger_status": trigger_json.get("TriggerStatus", "UNKNOWN")`` Both use ``.get(default)`` so pre-PROJ-04 firmware (which doesn't yet populate ``TrainCount``) returns 0 cleanly. The firmware-side ``OW_CTRL_GET_SWTRIG`` response-payload extension to actually populate the field is tracked separately as a PROJ-04 companion on openlifu-transmitter-fw -- not blocking the OI walking skeleton since OI runs against the sim pre-PROJ-04. ## Tests New: ``unit-test/test_simulated_trigger_progress.py`` -- 7 tests exercising the sim-side changes end-to-end: * ``get_trigger_json`` / ``get_trigger`` initial state (both ``TrainCount`` / ``train_count`` default to 0, ``TriggerStatus`` / ``trigger_status`` default to ``STOPPED``). * ``get_trigger`` snake-case shape check (every expected key present). * ``train_count`` advances monotonically during a short synthetic sequence (5 pulse trains * 20 ms), reaches ``pulse_train_count`` on natural completion. * Natural completion flips ``trigger_status`` to ``STOPPED`` -- regression test for the pre-2026-10-01 bug where ``_trigger_running`` wasn't flipped on natural end. * Operator-initiated ``stop_sonication`` midway: ``trigger_status`` flips to ``STOPPED``, ``train_count`` retained at partial progress (not reset to 0, not advanced to total). * Restart resets ``train_count`` to 0 at engine ``start()``. Full suite (excluding pre-existing ``test_tx_device.py`` collection failure on an unrelated ``HW_ID_DATA_LENGTH`` import): 107 passed. Refs OpenwaterHealth/openlifu-operator-interface#78 Signed-off-by: Peter Hollender --- src/openlifu_sdk/io/LIFUTXDevice.py | 2 + src/openlifu_sdk/ui/simulated_interface.py | 59 +++++ unit-test/test_simulated_trigger_progress.py | 216 +++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 unit-test/test_simulated_trigger_progress.py diff --git a/src/openlifu_sdk/io/LIFUTXDevice.py b/src/openlifu_sdk/io/LIFUTXDevice.py index 22588a1..62f24e1 100644 --- a/src/openlifu_sdk/io/LIFUTXDevice.py +++ b/src/openlifu_sdk/io/LIFUTXDevice.py @@ -459,6 +459,8 @@ def get_trigger(self) -> dict: "mode": mode, "profile_index": trigger_json["ProfileIndex"], "profile_increment": bool(trigger_json["ProfileIncrement"]), + "train_count": int(trigger_json.get("TrainCount", 0)), + "trigger_status": str(trigger_json.get("TriggerStatus", "UNKNOWN")), } def set_pattern_profile(self, profile: int, module: int | None = None) -> bool: diff --git a/src/openlifu_sdk/ui/simulated_interface.py b/src/openlifu_sdk/ui/simulated_interface.py index 0369a58..7da5c6d 100644 --- a/src/openlifu_sdk/ui/simulated_interface.py +++ b/src/openlifu_sdk/ui/simulated_interface.py @@ -162,6 +162,16 @@ def __init__(self, num_modules: int = 1, } self._pulse = {"frequency": 400_000.0, "duration": 100e-6, "amplitude": 1.0} self._trigger_running = False + # Current pulse-train counter during an active sonication. + # Written by the engine on each tick so a polled host (via + # :meth:`get_trigger` / :meth:`get_trigger_json`) can read + # progress without subscribing to the status-frame + # OWSignal. Reset to 0 at engine start; left at its final + # value after natural completion (so a post-run poll shows + # train_count == pulse_train_count); unchanged by an + # operator-initiated stop (so the host sees how far the + # sonication got). + self._train_curr = 0 # Simulated flash-baked machine_config table (SR-002 / SR-003 # host<->firmware preset-integrity handshake, per @@ -388,9 +398,43 @@ def get_trigger_json(self) -> dict: return { "TriggerStatus": "RUNNING" if self._trigger_running else "STOPPED", "TriggerMode": "SEQUENCE", + "TrainCount": self._train_curr, **self._sequence, } + def get_trigger(self) -> dict: + """Return the current trigger state as a snake_case dict. + + Mirrors the shape of + :meth:`openlifu_sdk.io.LIFUTXDevice.TxDevice.get_trigger` + so operator-interface code calling ``interface.txdevice.get_trigger()`` + gets the same keys against the sim and real hardware. + + Includes two fields the walking-skeleton polling loop needs: + + * ``train_count`` -- current pulse-train counter, 0 before + an engine starts, incremented each train tick, equal to + ``pulse_train_count`` on natural completion. + * ``trigger_status`` -- ``"RUNNING"`` or ``"STOPPED"``; + flips to ``"STOPPED"`` on both operator stop and natural + completion so a polled host sees a single stop edge + either way. + """ + seq = self._sequence + pulse_interval = seq.get("pulse_interval", 0.1) + return { + "pulse_interval": pulse_interval, + "pulse_count": seq.get("pulse_count", 1), + "pulse_width": self._pulse.get("duration", 100e-6) * 1e6, + "pulse_train_interval": seq.get("pulse_train_interval", 0.0), + "pulse_train_count": seq.get("pulse_train_count", 1), + "mode": "sequence", + "profile_index": 1, + "profile_increment": True, + "train_count": self._train_curr, + "trigger_status": "RUNNING" if self._trigger_running else "STOPPED", + } + def set_trigger_json(self, data) -> dict: if isinstance(data, dict): for k in ("pulse_interval", "pulse_count", @@ -806,6 +850,9 @@ def __init__(self, txdevice: SimulatedTxDevice, hvcontroller: SimulatedHVControl def start(self): self._tx.start_trigger() + # Reset the TxDevice's running counter so a polled host sees + # progress starting from 0 at engine start. + self._tx._train_curr = 0 # Apply heating for the very first train period as it elapses; # speed-clamp to avoid pegging the GUI on tiny periods. period_ms = max(20, int(round(self._train_period_s * 1000))) @@ -863,6 +910,10 @@ def _on_train_tick(self): if not self.alive: return self._train_curr += 1 + # Mirror the running counter back to the TxDevice so a + # polled host (:meth:`SimulatedTxDevice.get_trigger`) sees + # progress without subscribing to the status-frame OWSignal. + self._tx._train_curr = self._train_curr # Apply heating for this train period. for m in self._tx._modules: m.heat_step(self._voltage, self._duty, self._train_period_s) @@ -885,6 +936,14 @@ def _on_train_tick(self): self.alive = False self._train_timer.stop() self._heartbeat.stop() + # Flip the TxDevice's trigger_running flag so a polled + # host's :meth:`get_trigger` sees ``trigger_status == + # "STOPPED"`` on the natural-completion edge, matching + # the operator-initiated stop edge. (Pre-2026-10-01 the + # sim left trigger_running=True after natural end -- + # that only mattered for callers that subscribed to the + # STOPPED status frame, not for a polled host.) + self._tx.stop_trigger() # Final STOPPED frame so the connector flips trigger state / # transitions back to READY. self._tx.emit_status_frame( diff --git a/unit-test/test_simulated_trigger_progress.py b/unit-test/test_simulated_trigger_progress.py new file mode 100644 index 0000000..2e0182c --- /dev/null +++ b/unit-test/test_simulated_trigger_progress.py @@ -0,0 +1,216 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Unit tests for the polled sonication-progress fields on +:class:`SimulatedTxDevice.get_trigger` / +:meth:`SimulatedTxDevice.get_trigger_json`. + +Added 2026-10-01 for the openlifu-operator-interface walking +skeleton's Running-state progress poll (PROJ-08 Task 1, OI #78). +Rather than subscribing to the ``signal_data_received`` STATUS +frames (which carry the same progress but via an async-emission +pipeline the walking skeleton deliberately doesn't consume), the +host reads ``train_count`` and ``trigger_status`` off a polled +:meth:`get_trigger` call. + +Also exercises a pre-existing sim bug fix: on natural completion +of a sequence, the engine now flips the TxDevice's +``_trigger_running`` to False (previously only an explicit +``stop_trigger()`` did so, which only mattered for callers that +subscribed to STATUS frames rather than polling). +""" + +from __future__ import annotations + +import time + +import pytest + +pytest.importorskip("PyQt6") + +from PyQt6.QtCore import QCoreApplication # noqa: E402 + +from openlifu_sdk.ui.simulated_interface import ( # noqa: E402 + SimulatedLIFUInterface, + SimulatedTxDevice, +) + + +@pytest.fixture(scope="module") +def qt_app(): + return QCoreApplication.instance() or QCoreApplication([]) + + +# ------------------------------------------------------------------ +# get_trigger / get_trigger_json: static state before an engine runs +# ------------------------------------------------------------------ + + +def test_get_trigger_json_initial_state_has_train_count_zero(): + tx = SimulatedTxDevice() + j = tx.get_trigger_json() + assert j["TriggerStatus"] == "STOPPED" + assert j["TrainCount"] == 0 + + +def test_get_trigger_initial_state_mirrors_get_trigger_json(): + tx = SimulatedTxDevice() + t = tx.get_trigger() + assert t["trigger_status"] == "STOPPED" + assert t["train_count"] == 0 + # pulse_train_count defaults to 1 per __init__. + assert t["pulse_train_count"] == 1 + + +def test_get_trigger_returns_snake_case_shape(): + """Snake-case keys matching the real ``LIFUTXDevice.get_trigger`` + shape plus the two new progress fields.""" + tx = SimulatedTxDevice() + t = tx.get_trigger() + for key in ( + "pulse_interval", "pulse_count", "pulse_width", + "pulse_train_interval", "pulse_train_count", + "mode", "profile_index", "profile_increment", + "train_count", "trigger_status", + ): + assert key in t, f"missing key {key!r} in get_trigger() dict" + + +# ------------------------------------------------------------------ +# Running sequence: train_count advances, trigger_status flips +# ------------------------------------------------------------------ + + +def _short_sequence(iface: SimulatedLIFUInterface, train_count: int = 5): + """Configure a very short sequence so the full sonication + completes within a few hundred milliseconds.""" + iface.txdevice.set_trigger( + pulse_interval=0.001, + pulse_count=1, + pulse_train_interval=0.02, # 20 ms per train (clamped to 20ms min) + pulse_train_count=train_count, + ) + + +def test_train_count_advances_during_running_sequence(qt_app): + iface = SimulatedLIFUInterface() + try: + _short_sequence(iface, train_count=5) + iface.start_sonication(turn_hv_on=False, wait_for_settle=False) + + # Poll trigger state until the sim reports STOPPED. Record + # successive train_count values to confirm monotonic advance. + values: list[int] = [] + deadline = time.monotonic() + 1.0 + while time.monotonic() < deadline: + qt_app.processEvents() + t = iface.txdevice.get_trigger() + values.append(t["train_count"]) + if t["trigger_status"] == "STOPPED": + break + time.sleep(0.015) + + assert values[-1] == 5, ( + f"train_count should reach pulse_train_count on natural " + f"completion; final value was {values[-1]}, full trace: {values}" + ) + assert values == sorted(values), ( + f"train_count must be monotonically non-decreasing: {values}" + ) + assert iface.txdevice.get_trigger()["trigger_status"] == "STOPPED" + finally: + iface.close() + + +def test_natural_completion_flips_trigger_status_to_stopped(qt_app): + """Pre-2026-10-01 bug: natural completion of a sequence did + not call ``_tx.stop_trigger()``, so a polled host would see + ``TriggerStatus=RUNNING`` even after the sim finished. Fixed: + the engine now flips the flag on natural completion.""" + iface = SimulatedLIFUInterface() + try: + _short_sequence(iface, train_count=3) + iface.start_sonication(turn_hv_on=False, wait_for_settle=False) + assert iface.txdevice.get_trigger()["trigger_status"] == "RUNNING" + + # Drive the sim engine to completion. + deadline = time.monotonic() + 1.0 + while time.monotonic() < deadline: + qt_app.processEvents() + if iface.txdevice.get_trigger()["trigger_status"] == "STOPPED": + break + time.sleep(0.02) + + assert iface.txdevice.get_trigger()["trigger_status"] == "STOPPED" + assert iface.txdevice.get_trigger()["train_count"] == 3 + finally: + iface.close() + + +def test_operator_stop_flips_trigger_status_to_stopped(qt_app): + """Explicit ``stop_sonication`` also flips trigger_status to + STOPPED, matching the natural-completion edge so a polled + host sees a single stop edge either way. ``train_count`` is + left at whatever value the engine reached (so the host can + display partial progress on a stopped-early treatment).""" + iface = SimulatedLIFUInterface() + try: + # Long sequence so we can stop midway. + iface.txdevice.set_trigger( + pulse_interval=0.001, + pulse_count=1, + pulse_train_interval=0.05, + pulse_train_count=100, + ) + iface.start_sonication(turn_hv_on=False, wait_for_settle=False) + + # Let the engine run for a few ticks. + deadline = time.monotonic() + 0.3 + while time.monotonic() < deadline: + qt_app.processEvents() + if iface.txdevice.get_trigger()["train_count"] >= 2: + break + time.sleep(0.02) + + mid_progress = iface.txdevice.get_trigger()["train_count"] + assert 1 <= mid_progress < 100 + + # Operator-initiated stop. + iface.stop_sonication(turn_hv_off=False) + qt_app.processEvents() + + final = iface.txdevice.get_trigger() + assert final["trigger_status"] == "STOPPED" + # train_count retained from where the engine got to -- not + # reset to 0 and not advanced to pulse_train_count. + assert mid_progress <= final["train_count"] < 100 + finally: + iface.close() + + +# ------------------------------------------------------------------ +# Fresh start resets train_count +# ------------------------------------------------------------------ + + +def test_restart_resets_train_count(qt_app): + """Running two sequences back-to-back: the second one starts + counting from 0 again, not from where the first ended.""" + iface = SimulatedLIFUInterface() + try: + _short_sequence(iface, train_count=3) + iface.start_sonication(turn_hv_on=False, wait_for_settle=False) + deadline = time.monotonic() + 1.0 + while time.monotonic() < deadline: + qt_app.processEvents() + if iface.txdevice.get_trigger()["trigger_status"] == "STOPPED": + break + time.sleep(0.02) + assert iface.txdevice.get_trigger()["train_count"] == 3 + + # Second sonication. + iface.start_sonication(turn_hv_on=False, wait_for_settle=False) + qt_app.processEvents() + # Immediately after start, counter is reset. + assert iface.txdevice.get_trigger()["train_count"] == 0 + assert iface.txdevice.get_trigger()["trigger_status"] == "RUNNING" + finally: + iface.close()