From 94ac7238b10d91fc46a738c211ff55cf16f64373 Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 7 Jul 2026 10:07:37 +0200 Subject: [PATCH] perf(bec_widgets): fix SampleStorageWidget poll lag 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. --- .../widgets/sample_storage/sample_storage.py | 79 +++++++++++++++---- 1 file changed, 63 insertions(+), 16 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py index 44a26f1..13bc78b 100644 --- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py +++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py @@ -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 0–20 or the gripper.""" + def _read_all_slots(self) -> dict[int, tuple[bool, str]]: + """Return {slot: (occupied, name)} for every slot 0–20 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() \ No newline at end of file