perf(bec_widgets): fix SampleStorageWidget poll lag
CI for csaxs_bec / test (push) Successful in 1m31s
CI for csaxs_bec / test (pull_request) Successful in 1m32s

Replace ~44 per-slot blocking device reads per poll with a single bulk
flomni_samples.read(), and add change-detection so cells only repaint
when their state actually changes. The old per-slot accessors stalled
the GUI event loop every 2s; steady-state polling now does no UI work.
Writes invalidate the changed slot's cache so local edits still repaint
immediately.
This commit is contained in:
x12sa
2026-07-07 10:07:37 +02:00
parent c9d45b69ff
commit ce4d88a526
@@ -198,6 +198,8 @@ class SampleStorageWidget(BECWidget, QWidget):
super().__init__(parent=parent, **kwargs)
self.get_bec_shortcuts()
self._cells: dict[int, _SlotCell] = {}
# last-seen (occupied, name) per slot, for change-detection in refresh()
self._last_state: dict[int, tuple[bool, str]] = {}
self._flomni_available = self._check_flomni_available()
self._build_ui()
self._poll_timer = QTimer(self)
@@ -227,19 +229,45 @@ class SampleStorageWidget(BECWidget, QWidget):
"""The flomni_samples device (single source of truth)."""
return self.dev.flomni_samples
def _read_slot(self, slot: int) -> tuple[bool, str]:
"""Return (occupied, name) for a storage slot 020 or the gripper."""
def _read_all_slots(self) -> dict[int, tuple[bool, str]]:
"""Return {slot: (occupied, name)} for every slot 020 and the gripper
from a SINGLE bulk ``dev.flomni_samples.read()`` round-trip.
The per-slot accessors (``is_sample_slot_used`` +
``sample_names.sample{N}.get()``) would be ~44 blocking device
round-trips per poll, which stalled the GUI event loop. ``read()``
returns the whole device's signals at once; we index into that dict
using the flat signal-key convention BEC uses for this device
(``flomni_samples_sample_placed_sample{N}`` /
``flomni_samples_sample_names_sample{N}`` /
``flomni_samples_sample_in_gripper`` /
``flomni_samples_sample_in_gripper_name``), the same keys the CLI
iterated over in ftransfer_sample_change.
"""
result: dict[int, tuple[bool, str]] = {}
try:
if slot == GRIPPER_SLOT:
occupied = bool(self._samples.is_sample_in_gripper())
name = self._samples.sample_in_gripper_name.get()
else:
occupied = bool(self._samples.is_sample_slot_used(slot))
name = getattr(self._samples.sample_names, f"sample{slot}").get()
return occupied, (name if name else EMPTY_NAME)
data = self._samples.read()
except Exception as exc:
logger.warning(f"SampleStorageWidget: read of slot {slot} failed: {exc}")
return False, EMPTY_NAME
logger.warning(f"SampleStorageWidget: bulk read failed: {exc}")
# keep whatever is currently displayed rather than blanking out
return {}
def _val(key, default=None):
entry = data.get(key)
if isinstance(entry, dict):
return entry.get("value", default)
return default
for slot in (STAGE_SLOT, *STORAGE_SLOTS):
used = _val(f"flomni_samples_sample_placed_sample{slot}", 0)
name = _val(f"flomni_samples_sample_names_sample{slot}", EMPTY_NAME)
result[slot] = (bool(used), name if name else EMPTY_NAME)
g_used = _val("flomni_samples_sample_in_gripper", 0)
g_name = _val("flomni_samples_sample_in_gripper_name", EMPTY_NAME)
result[GRIPPER_SLOT] = (bool(g_used), g_name if g_name else EMPTY_NAME)
return result
def _write_slot(self, slot: int, used: int, name: str) -> bool:
"""
@@ -253,6 +281,10 @@ class SampleStorageWidget(BECWidget, QWidget):
else:
getattr(self._samples.sample_placed, f"sample{slot}").set(used)
getattr(self._samples.sample_names, f"sample{slot}").set(name)
# drop the cached state for this slot so the refresh() right after
# a write always repaints it, without waiting for the value to
# differ from a possibly-stale cache entry
self._last_state.pop(slot, None)
return True
except Exception as exc:
logger.warning(f"SampleStorageWidget: write of slot {slot} failed: {exc}")
@@ -325,12 +357,27 @@ class SampleStorageWidget(BECWidget, QWidget):
@SafeSlot()
def refresh(self) -> None:
"""Re-read every slot from the device and update the cells."""
"""Re-read all slots (one bulk device round-trip) and update only the
cells whose state actually changed.
Repainting every cell on every 2 s poll — even when nothing changed,
which is almost always the case for a sample magazine — was needless
work on the GUI thread. We diff against the last-seen state and only
call set_state() on cells that differ, so a steady-state poll does no
UI work at all.
"""
if not self._flomni_available:
return
for slot, cell in self._cells.items():
occupied, name = self._read_slot(slot)
cell.set_state(occupied, name)
state = self._read_all_slots()
if not state:
return # bulk read failed; leave the current display untouched
for slot, (occupied, name) in state.items():
if self._last_state.get(slot) == (occupied, name):
continue
cell = self._cells.get(slot)
if cell is not None:
cell.set_state(occupied, name)
self._last_state[slot] = (occupied, name)
# ── mutation actions (called from _SlotCell context menu) ─────────────────
@@ -389,4 +436,4 @@ class SampleStorageWidget(BECWidget, QWidget):
def cleanup(self) -> None:
self._poll_timer.stop()
super().cleanup()
super().cleanup()