Helios panel: refresh about every 0.65 s, and yield the port to the operator

Three things were making the panel feel slow, all of them waiting rather
than working.

The trailing quiet window is waited out once per query, so it set the pace
of the whole sweep: at 50 ms that was 400 ms of a 590 ms poll spent
listening to silence. A reply streams at the baud rate — ~1 ms between
bytes, no measurable gap between its lines — and the deadline restarts on
every line read, so 20 ms outlasts the gap it exists for twenty times over.
A tail that still arrives late is caught by _discard_input(), which is what
actually protects the next query. Replayed against the rig transcript, a
sweep goes from 590 ms to 356 ms.

The poll interval was 1 s on top of that, so a value could be 1.6 s stale.
At 0.3 s the panel comes round about every 0.65 s.

And a poll held the port for its whole sweep, so a button pressed during
one waited for all eight queries. _work_pending() on the worker base lets a
poll drop what is left as soon as the operator queues something: a click
now waits ~135 ms for the register read in progress instead of the full
sweep, and the rest is picked up next time round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-09-08 08:36:57 -05:00
parent 76ed7828cc
commit b473aac6aa
4 changed files with 86 additions and 4 deletions
+12
View File
@@ -44,6 +44,18 @@ class QueueWorker(QObject):
def stop_worker(self): def stop_worker(self):
self._cmd_q.put(_STOP) self._cmd_q.put(_STOP)
# ── Worker-side helpers ───────────────────────────────────────────────────
def _work_pending(self) -> bool:
"""True if the operator is waiting on something.
A poll is one queue item that can hold the port for hundreds of
milliseconds; a button pressed during one should not have to wait
for the whole sweep to finish. A poll that checks this between
reads gives the port up and picks the rest up next time round.
"""
return not self._cmd_q.empty()
# ── Worker loop ─────────────────────────────────────────────────────────── # ── Worker loop ───────────────────────────────────────────────────────────
@pyqtSlot() @pyqtSlot()
+10 -2
View File
@@ -107,7 +107,15 @@ class HeliosLaser:
# as a "Bit 15..0" string, and the reads around it timing out on a # as a "Bit 15..0" string, and the reads around it timing out on a
# leading blank line. So: match a reply to the command that asked for # leading blank line. So: match a reply to the command that asked for
# it, and read off the rest of it before the next command goes out. # it, and read off the rest of it before the next command goes out.
TRAILING_QUIET_S = 0.05 # the line counts as idle after this long # How long the line has to stay silent before a reply counts as over.
# It is waited out once per query, so it sets the pace of the whole
# status poll: at 50 ms that was 400 ms of a 590 ms poll spent listening
# to nothing. A reply streams at the baud rate — ~1 ms between bytes,
# no measurable gap between its lines — and the deadline restarts on
# every line, so 20 ms is twenty times the gap it has to outlast. A
# tail that still arrives late is caught by _discard_input() rather
# than by waiting longer here.
TRAILING_QUIET_S = 0.02
MAX_REPLY_LINES = 8 MAX_REPLY_LINES = 8
def _discard_input(self): def _discard_input(self):
@@ -269,7 +277,7 @@ class HeliosLaser:
# it worked, and the panel's next status poll reads back the old value — # it worked, and the panel's next status poll reads back the old value —
# which looks exactly like the GUI refusing the operator's number. # which looks exactly like the GUI refusing the operator's number.
SET_RETRIES = 3 SET_RETRIES = 3
SET_SETTLE_S = 0.05 # let the controller store it before reading SET_SETTLE_S = 0.02 # let the controller store it before reading
def _write_verified(self, mnemonic: str, value: int) -> bool: def _write_verified(self, mnemonic: str, value: int) -> bool:
"""Write `value` to `mnemonic`, and confirm the controller took it. """Write `value` to `mnemonic`, and confirm the controller took it.
+15 -2
View File
@@ -343,7 +343,9 @@ class HeliosWorker(PollingQueueWorker):
qswitch_temp_updated = pyqtSignal(float) qswitch_temp_updated = pyqtSignal(float)
status_registers_updated = pyqtSignal(object, object, object) # LER, LCE, CCE (int|None) status_registers_updated = pyqtSignal(object, object, object) # LER, LCE, CCE (int|None)
POLL_INTERVAL_S = 1.0 # A full sweep is eight queries, ~360 ms of port time. The interval is
# the gap between sweeps, so the panel refreshes about every 0.65 s.
POLL_INTERVAL_S = 0.3
def __init__(self): def __init__(self):
super().__init__(poll_interval_s=self.POLL_INTERVAL_S) super().__init__(poll_interval_s=self.POLL_INTERVAL_S)
@@ -416,17 +418,26 @@ class HeliosWorker(PollingQueueWorker):
def _poll_once(self): def _poll_once(self):
"""Read the full status set. Individual reads are allowed to fail """Read the full status set. Individual reads are allowed to fail
(a timed-out register shouldn't suppress the rest of the panel).""" (a timed-out register shouldn't suppress the rest of the panel).
Abandoned as soon as the operator queues something: the rest of the
sweep is worth less than a button that responds now, and the next
poll will pick it up.
"""
if not self._laser or not self._laser.is_connected: if not self._laser or not self._laser.is_connected:
return return
try: try:
self.status_registers_updated.emit(*self._laser.get_status_registers()) self.status_registers_updated.emit(*self._laser.get_status_registers())
except Exception: except Exception:
pass pass
if self._work_pending():
return
try: try:
self.enabled_updated.emit(self._laser.is_laser_enabled()) self.enabled_updated.emit(self._laser.is_laser_enabled())
except Exception: except Exception:
pass pass
if self._work_pending():
return
# A failed LDS read must not leave the last good value on screen # A failed LDS read must not leave the last good value on screen
# looking live: that is indistinguishable from a setpoint that # looking live: that is indistinguishable from a setpoint that
# refuses to move, which is the failure this panel exists to show. # refuses to move, which is the failure this panel exists to show.
@@ -444,6 +455,8 @@ class HeliosWorker(PollingQueueWorker):
(self._laser.get_power_stage_temp_c, self.pstage_temp_updated), (self._laser.get_power_stage_temp_c, self.pstage_temp_updated),
(self._laser.get_qswitch_temp_c, self.qswitch_temp_updated), (self._laser.get_qswitch_temp_c, self.qswitch_temp_updated),
): ):
if self._work_pending():
return
try: try:
value = getter() value = getter()
if value is not None: if value is not None:
+49
View File
@@ -137,6 +137,55 @@ def test_polling_never_overlaps_or_backs_up(qapp):
assert queued <= 1, f"{queued} stale polls queued up" assert queued <= 1, f"{queued} stale polls queued up"
class _YieldingPoller(PollingQueueWorker):
"""A poll made of several reads that gives up as soon as work arrives."""
def __init__(self):
super().__init__(poll_interval_s=0.02)
self.reads = 0
self.handled = []
self.is_connected = True
self._handlers["click"] = self._click
def _click(self, value):
self.handled.append(value)
def _poll_once(self):
for _ in range(6):
if self._work_pending():
return
time.sleep(0.02)
self.reads += 1
def test_a_queued_command_interrupts_a_poll(qapp):
"""A button pressed mid-poll should not wait out the whole sweep.
The Helios sweep is eight serial queries; before this, a command queued
behind one waited for every last read to finish.
"""
w = _YieldingPoller()
t = threading.Thread(target=w.run, daemon=True)
t.start()
w.start_polling()
time.sleep(0.03) # a poll is now in progress
pressed = time.monotonic()
w._enqueue("click", value="set current")
deadline = pressed + 2.0
while not w.handled and time.monotonic() < deadline:
time.sleep(0.002)
waited = time.monotonic() - pressed
w.stop_polling()
w.stop_worker()
t.join(timeout=5)
assert w.handled == ["set current"]
# A full sweep is 6 x 20 ms; the command must not have waited for it.
assert waited < 0.08, f"command waited {waited * 1000:.0f} ms for the poll"
def test_stop_polling_halts_the_cycle(qapp): def test_stop_polling_halts_the_cycle(qapp):
w = _Poller() w = _Poller()
t = threading.Thread(target=w.run, daemon=True) t = threading.Thread(target=w.run, daemon=True)