From a31fe481a7b5bd2ccaaf040c7c52ece2674cada8 Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 19 Aug 2026 15:28:16 +0200 Subject: [PATCH 1/2] feat(flomni): track measured samples and offer a P-touch label print on clear/replace Records whether a tomo scan was started/completed for the sample currently on the stage (a new _MeasuredSampleLog, redis-backed via BEC global vars, keyed by the packed name|owner identity so it survives ftransfer_get_sample/put_sample moves between slots). When a tray slot's occupant is cleared or replaced via ftransfer_modify_storage() (CLI) or the sample-storage widget's Clear/Change-name actions (GUI), and that sample was measured, offer to print an account/date/samplename label via PTouchLabelPrinter. Also surfaces measured status in ftransfer_show_all() and the widget's slot cells, including the stage line. GUI failures retry via QMessageBox Retry/Ignore (a modal dialog runs its own Qt event loop, so this can't hang the process the way the CLI's input()-based ensure_ready() would) instead of losing the label on the first failed attempt. Co-Authored-By: Claude Sonnet 5 --- .../plugins/flomni/flomni.py | 158 ++++++++++++- .../widgets/sample_storage/sample_storage.py | 208 ++++++++++++++++-- .../devices/omny/flomni_sample_storage.py | 33 ++- 3 files changed, 380 insertions(+), 19 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 3c1fe4dc..91a603ba 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -28,6 +28,7 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import PtychoReconstructor, TomoIDManager, ) +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.ptouch_printer import PTouchLabelPrinter from csaxs_bec.devices.omny.galil.galil_ophyd import GalilError from csaxs_bec.devices.omny.sample_desc_codec import pack_desc, unpack_desc @@ -356,6 +357,17 @@ class FlomniInitStagesMixin: class FlomniSampleTransferMixin: + # P-touch Template object names for the flomni label (see + # OMNY_shared/ptouch_printer.py) -- these are the names bench-tested + # against the real printer's already-transferred template (confirmed + # working). OMNY_shared/flomni.lbx, a template *source* file, happens + # to show generic P-touch-Editor object names (Text1/Text2/Text6) for + # its own fields, but that's not what's loaded on the physical + # printer -- use the literal ACCOUNT/DATE/SAMPLENAME names instead. + _PTOUCH_FIELD_ACCOUNT = "ACCOUNT" + _PTOUCH_FIELD_DATE = "DATE" + _PTOUCH_FIELD_SAMPLENAME = "SAMPLENAME" + def ensure_osa_back(self): dev.fosaz.limits = [-12.6, -12.4] umv(dev.fosaz, -12.5) @@ -790,7 +802,7 @@ class FlomniSampleTransferMixin: self.flomni_modify_storage_non_interactive(position, 0, "-") def ftransfer_show_all(self): - dev.flomni_samples.show_all() + dev.flomni_samples.show_all(measured=self.measured_log.snapshot()) def ftransfer_put_sample(self, position: int): self.check_position_is_valid(position) @@ -858,6 +870,16 @@ class FlomniSampleTransferMixin: signal_name = getattr(dev.flomni_samples.sample_names, f"sample{position}") return unpack_desc(signal_name.get())[0] + def sample_get_measured_log_key(self, position: int = 0) -> str: + """Packed `name | owner` identity key for the sample at `position`, + for use with `self.measured_log` -- NOT the same as `sample_name` + (a `sample_get_name()` alias), which is name-only and would silently + under-key the measured log against a different sample of the same + name but a different owner. + """ + name, owner = dev.flomni_samples.get_sample_name_and_owner(position) + return pack_desc(name, owner) + def ftransfer_sample_change(self, new_sample_position: int): self.check_tray_in() # sample_in_gripper = dev.flomni_samples.sample_in_gripper.get() @@ -972,8 +994,51 @@ class FlomniSampleTransferMixin: else: name = "-" owner = "" + if 1 <= position <= 20: + self._offer_print_label_for_previous_occupant(position) self.flomni_modify_storage_non_interactive(position, used, name, owner=owner) + def _offer_print_label_for_previous_occupant(self, position: int) -> None: + """If tray slot `position` currently holds a measured sample, offer + to print an account/date/name label for it before its bookkeeping + record gets overwritten by the caller (a clear via + ftransfer_modify_storage(N, 0), or a rename via + ftransfer_modify_storage(N, 1) on an already-occupied slot). + + Deliberately called only from this interactive wrapper, not from + flomni_modify_storage_non_interactive() itself -- that lower-level + setter is also invoked internally by the automated + ftransfer_get_sample()/ftransfer_put_sample() transfer sequence + (e.g. moving a sample from a tray slot into the gripper), where a + print offer would incorrectly fire on every ordinary transfer step + instead of only on a genuine manual clear/replace. + """ + if not dev.flomni_samples.is_sample_slot_used(position): + return + prev_name, prev_owner = dev.flomni_samples.get_sample_name_and_owner(position) + prev_packed = pack_desc(prev_name, prev_owner) + record = self.measured_log.pop(prev_packed) + if record is None: + return + if self.OMNYTools.yesno( + f"Sample '{prev_name}' was measured ({record['status']}) on" + f" {record['measured_date']}. Print a label?", + "y", + ): + self.label_printer.ensure_ready() + printed = self.label_printer.print_label( + { + self._PTOUCH_FIELD_ACCOUNT: record["account"], + self._PTOUCH_FIELD_DATE: record["measured_date"], + self._PTOUCH_FIELD_SAMPLENAME: prev_name, + } + ) + if not printed: + print( + f"Could not print label for '{prev_name}' -- printer unreachable." + " The slot will be updated regardless." + ) + def flomni_modify_storage_non_interactive( self, position: int, used: int, name: str, owner: str = "" ): @@ -1605,6 +1670,83 @@ class _ProgressProxy: return self._load() +class _MeasuredSampleLog: + """Tracks which flomni samples have been measured, for the P-touch + label-printing feature (print an account/date/name label when a + measured sample is cleared or replaced in tray storage). + + Keyed by the packed ``name | owner`` string (see pack_desc/unpack_desc + in sample_desc_codec.py) rather than by slot number, since that packed + string is the identity that already travels with a sample across + ftransfer_get_sample/ftransfer_put_sample transfers between tray + slots, the gripper, and the stage -- tracking by slot number would + require threading a flag through every one of those transfer calls. + Persisted as a BEC global var (same pattern as _ProgressProxy above) + so it's visible to any client session -- CLI or GUI widget -- and + survives a kernel restart. + + Two statuses: "started" (a fresh tomogram began for this sample) and + "completed" (that tomogram's scan loop ran to completion without + error). Both count as "measured" for is_measured()/the print offer -- + "started" without "completed" just means the tomogram may be + incomplete, which callers surface in the offer wording rather than + suppressing the offer entirely. + """ + + _GLOBAL_VAR_KEY = "flomni_measured_samples" + + def __init__(self, client): + self._client = client + + def _load(self) -> dict: + val = self._client.get_global_var(self._GLOBAL_VAR_KEY) + return val if val is not None else {} + + def _save(self, data: dict) -> None: + self._client.set_global_var(self._GLOBAL_VAR_KEY, data) + + def mark_measured(self, sample_key: str, account: str, date: str, status: str) -> None: + """Record `sample_key` as measured, overwriting any existing record. + + Overwriting (rather than merging) means re-measuring a sample from + scratch correctly resets a stale "completed" back to "started". + """ + data = self._load() + data[sample_key] = {"account": account, "measured_date": date, "status": status} + self._save(data) + + def is_measured(self, sample_key: str) -> bool: + return sample_key in self._load() + + def get(self, sample_key: str) -> dict | None: + return self._load().get(sample_key) + + def snapshot(self) -> dict: + """One bulk fetch of the whole log, as a plain dict. + + Used instead of `get` when the caller needs to hand the data to + `dev.flomni_samples.show_all()`, which runs via BEC's + device-server RPC mechanism -- every argument gets msgpack- + serialized, so a bound method (`self.measured_log.get`) can't be + passed directly; a plain dict can. + """ + return self._load() + + def pop(self, sample_key: str) -> dict | None: + """Remove and return the record for `sample_key`, if any. + + Called once the print offer for that sample has been resolved + (printed or declined), so the log stays bounded to + currently-measured-but-not-yet-cleared samples instead of growing + unbounded over a beamtime. + """ + data = self._load() + record = data.pop(sample_key, None) + if record is not None: + self._save(data) + return record + + class Flomni( TomoQueueMixin, FlomniInitStagesMixin, @@ -1651,6 +1793,8 @@ class Flomni( self.reconstructor = PtychoReconstructor(self.ptycho_reconstruct_foldername) self.tomo_id_manager = TomoIDManager() self.align = XrayEyeAlign(self.client, self) + self.measured_log = _MeasuredSampleLog(self.client) + self.label_printer = PTouchLabelPrinter(host="BRN94DDF8AAB8EC.psi.ch", template_number=1) self.set_client(client) self._maybe_reset_params_on_account_change() @@ -2536,6 +2680,12 @@ class Flomni( "test additional info", "BEC", ) + self.measured_log.mark_measured( + self.sample_get_measured_log_key(0), + bec.active_account or "", + datetime.date.today().strftime("%Y-%m"), + status="started", + ) self.write_pdf_report() self.progress["tomo_start_time"] = datetime.datetime.now().isoformat() # reset stale estimates from any previous scan, otherwise the GUI @@ -2712,6 +2862,12 @@ class Flomni( self._print_progress() self._log_tomogram_timing() self.OMNYTools.printgreenbold("Tomoscan finished") + self.measured_log.mark_measured( + self.sample_get_measured_log_key(0), + bec.active_account or "", + datetime.date.today().strftime("%Y-%m"), + status="completed", + ) idle_s = self.progress.get("accumulated_idle_time", 0.0) start_str = self.progress.get("tomo_start_time") elapsed_s = None 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 480c9576..b47b4fa2 100644 --- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py +++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py @@ -62,6 +62,7 @@ from qtpy.QtWidgets import ( QWidget, ) +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.ptouch_printer import PTouchLabelPrinter from csaxs_bec.devices.omny.sample_desc_codec import pack_desc, unpack_desc logger = bec_logger.logger @@ -90,6 +91,66 @@ COLOR_STAGE_BORDER = "#2196F3" COLOR_GRIPPER_BORDER = "#FF9800" COLOR_SLOT_BORDER = "#c0c0c0" +# P-touch printer / template config -- kept in sync with the CLI's copies +# in flomni.py's Flomni.__init__ / FlomniSampleTransferMixin. Field names +# are the literal object names bench-tested against the real printer's +# already-transferred template (confirmed working) -- NOT the generic +# Text1/Text2/Text6 names that OMNY_shared/flomni.lbx's own objectName +# attributes happen to use for its (different) template source file. +PTOUCH_PRINTER_HOST = "BRN94DDF8AAB8EC.psi.ch" +PTOUCH_TEMPLATE_NUMBER = 1 +PTOUCH_FIELD_ACCOUNT = "ACCOUNT" +PTOUCH_FIELD_DATE = "DATE" +PTOUCH_FIELD_SAMPLENAME = "SAMPLENAME" + + +# ── measured-sample log ────────────────────────────────────────────────────── + + +class _MeasuredSampleLog: + """Tracks which flomni samples have been measured, for the P-touch + label-printing feature. + + Deliberately duplicated here rather than imported from + ``flomni.py``'s ``_MeasuredSampleLog`` -- pulling in that whole plugin + module (CLI-only dependencies, module-level ``bec``/``dev`` builtins + access) into this widget's gui-server process is a heavier and more + fragile coupling than re-declaring this ~15-line global-var wrapper, + and this widget already duplicates ``flomni_modify_storage_non_interactive()`` + the same way (see module docstring). Both copies read/write the same + ``flomni_measured_samples`` global-var key, so they stay consistent in + effect despite being independent code, exactly like the existing + ``_write_slot``/``flomni_modify_storage_non_interactive`` duplication. + """ + + _GLOBAL_VAR_KEY = "flomni_measured_samples" + + def __init__(self, client): + self._client = client + + def _load(self) -> dict: + val = self._client.get_global_var(self._GLOBAL_VAR_KEY) + return val if val is not None else {} + + def _save(self, data: dict) -> None: + self._client.set_global_var(self._GLOBAL_VAR_KEY, data) + + def get(self, sample_key: str) -> Optional[dict]: + return self._load().get(sample_key) + + def snapshot(self) -> dict: + """One bulk fetch of the whole log, for callers (like the poll + loop below) that need to look up many sample keys at once without + one redis round-trip per key.""" + return self._load() + + def pop(self, sample_key: str) -> Optional[dict]: + data = self._load() + record = data.pop(sample_key, None) + if record is not None: + self._save(data) + return record + # ── slot cell widget ───────────────────────────────────────────────────────── @@ -134,9 +195,14 @@ class _SlotCell(QFrame): self._lbl_owner.setStyleSheet("color: #888888; font-size: 10px;") self._lbl_owner.setWordWrap(True) + self._lbl_measured = QLabel("") + self._lbl_measured.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._lbl_measured.setStyleSheet("font-size: 10px;") + layout.addWidget(self._lbl_num) layout.addWidget(self._lbl_name, stretch=1) layout.addWidget(self._lbl_owner) + layout.addWidget(self._lbl_measured) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self._show_menu) @@ -150,8 +216,14 @@ class _SlotCell(QFrame): return "Gripper (100)" return str(self._slot) - def set_state(self, occupied: bool, name: str, owner: str = "") -> None: - """Update the cell's displayed state (called by the poll/refresh).""" + def set_state( + self, occupied: bool, name: str, owner: str = "", measured_status: Optional[str] = None + ) -> None: + """Update the cell's displayed state (called by the poll/refresh). + + `measured_status` is "started"/"completed" (from `_MeasuredSampleLog`) + or None if this slot's current occupant has no measured record. + """ self._occupied = occupied self._name = name if name else EMPTY_NAME self._sample_owner = owner @@ -159,10 +231,23 @@ class _SlotCell(QFrame): self._lbl_name.setText(self._name) self._lbl_name.setStyleSheet(f"color: {COLOR_OCCUPIED};") self._lbl_owner.setText(f"owner: {owner}" if owner else "") + if measured_status == "completed": + self._lbl_measured.setText("✓ measured") + self._lbl_measured.setStyleSheet("font-size: 10px; color: #2e7d32;") + elif measured_status == "started": + # "started" means a tomogram began for this sample at some + # point -- not necessarily that one is running right now + # (it may be long finished, or have crashed/aborted); avoid + # wording that implies live progress. + self._lbl_measured.setText("◐ started") + self._lbl_measured.setStyleSheet("font-size: 10px; color: #ef6c00;") + else: + self._lbl_measured.setText("") else: self._lbl_name.setText(EMPTY_LABEL) self._lbl_name.setStyleSheet(f"color: {COLOR_EMPTY}; font-style: italic;") self._lbl_owner.setText("") + self._lbl_measured.setText("") # ── context menu ────────────────────────────────────────────────────────── @@ -210,8 +295,13 @@ class OMNY_SampleStorage(BECWidget, QWidget): super().__init__(parent=parent, **kwargs) self.get_bec_shortcuts() self._cells: dict[int, _SlotCell] = {} - # last-seen (occupied, name, owner) per slot, for change-detection in refresh() - self._last_state: dict[int, tuple[bool, str, str]] = {} + # last-seen (occupied, name, owner, measured_status) per slot, for + # change-detection in refresh() + self._last_state: dict[int, tuple[bool, str, str, Optional[str]]] = {} + self.measured_log = _MeasuredSampleLog(self.client) + self.label_printer = PTouchLabelPrinter( + host=PTOUCH_PRINTER_HOST, template_number=PTOUCH_TEMPLATE_NUMBER + ) self._flomni_available = self._check_flomni_available() self._build_ui() self._poll_timer = QTimer(self) @@ -241,9 +331,12 @@ class OMNY_SampleStorage(BECWidget, QWidget): """The flomni_samples device (single source of truth).""" return self.dev.flomni_samples - def _read_all_slots(self) -> dict[int, tuple[bool, str, str]]: - """Return {slot: (occupied, name, owner)} for every slot 0–20 and the - gripper from a SINGLE bulk ``dev.flomni_samples.read()`` round-trip. + def _read_all_slots(self) -> dict[int, tuple[bool, str, str, Optional[str]]]: + """Return {slot: (occupied, name, owner, measured_status)} for every + slot 0–20 and the gripper from a SINGLE bulk + ``dev.flomni_samples.read()`` round-trip plus a single bulk + ``_MeasuredSampleLog.snapshot()`` (one redis round-trip, not one + per occupied slot). The per-slot accessors (``is_sample_slot_used`` + ``sample_names.sample{N}.get()``) would be ~44 blocking device @@ -263,7 +356,7 @@ class OMNY_SampleStorage(BECWidget, QWidget): so the poll returns those monitored values instead of issuing a live EPICS round-trip for all ~23 signals every 2 s. """ - result: dict[int, tuple[bool, str, str]] = {} + result: dict[int, tuple[bool, str, str, Optional[str]]] = {} try: data = self._samples.read(cached=True) except Exception as exc: @@ -271,30 +364,117 @@ class OMNY_SampleStorage(BECWidget, QWidget): # keep whatever is currently displayed rather than blanking out return {} + try: + measured = self.measured_log.snapshot() + except Exception as exc: + logger.warning(f"OMNY_SampleStorage: measured-log read failed: {exc}") + measured = {} + def _val(key, default=None): entry = data.get(key) if isinstance(entry, dict): return entry.get("value", default) return default + def _measured_status(used: bool, name: str, owner: str) -> Optional[str]: + if not used: + return None + record = measured.get(pack_desc(name, owner)) + return record["status"] if record else None + for slot in (STAGE_SLOT, *STORAGE_SLOTS): used = _val(f"flomni_samples_sample_placed_sample{slot}", 0) raw_name = _val(f"flomni_samples_sample_names_sample{slot}", EMPTY_NAME) name, owner = unpack_desc(raw_name if raw_name else EMPTY_NAME) - result[slot] = (bool(used), name if name else EMPTY_NAME, owner) + name = name if name else EMPTY_NAME + result[slot] = (bool(used), name, owner, _measured_status(bool(used), name, owner)) g_used = _val("flomni_samples_sample_in_gripper", 0) g_raw_name = _val("flomni_samples_sample_in_gripper_name", EMPTY_NAME) g_name, g_owner = unpack_desc(g_raw_name if g_raw_name else EMPTY_NAME) - result[GRIPPER_SLOT] = (bool(g_used), g_name if g_name else EMPTY_NAME, g_owner) + g_name = g_name if g_name else EMPTY_NAME + result[GRIPPER_SLOT] = ( + bool(g_used), + g_name, + g_owner, + _measured_status(bool(g_used), g_name, g_owner), + ) return result + def _offer_print_label_for_previous_occupant(self, slot: int) -> None: + """If `slot` currently holds a measured sample, offer to print an + account/date/name label for it before its record gets overwritten. + + Unlike flomni.py's equivalent check, this can run unconditionally + for every slot ``_write_slot()`` touches (stage, gripper, and + tray) -- this widget is UI-only and never invoked by any automated + transfer sequence (see module docstring), so there's no "internal + transfer call" case to exclude the way there is in + ``Flomni.flomni_modify_storage_non_interactive()``. + """ + try: + if slot == GRIPPER_SLOT: + was_used = bool(self._samples.sample_in_gripper.get()) + prev_name, prev_owner = unpack_desc(str(self._samples.sample_in_gripper_name.get())) + else: + was_used = self._samples.is_sample_slot_used(slot) + prev_name, prev_owner = self._samples.get_sample_name_and_owner(slot) + except Exception as exc: + logger.warning( + f"OMNY_SampleStorage: could not read previous state of slot {slot} for the" + f" measured-label check: {exc}" + ) + return + if not was_used: + return + record = self.measured_log.pop(pack_desc(prev_name, prev_owner)) + if record is None: + return + reply = QMessageBox.question( + self, + "Print label?", + f"Sample '{prev_name}' was measured ({record['status']}) on" + f" {record['measured_date']}. Print a label?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + fields = { + PTOUCH_FIELD_ACCOUNT: record["account"], + PTOUCH_FIELD_DATE: record["measured_date"], + PTOUCH_FIELD_SAMPLENAME: prev_name, + } + # Retry loop instead of a single attempt: the record was already + # popped from measured_log above, so if we gave up after one + # failure the label's account/date/name would be gone for good + # even though the operator could just switch the printer on and + # try again a moment later. Mirrors the CLI's ensure_ready() retry + # prompt, but via QMessageBox.Retry/Ignore instead of input() -- + # a modal QMessageBox runs its own nested Qt event loop while + # waiting for a click, so (unlike ensure_ready()'s blocking + # input()) this never freezes the GUI or hangs on a process with + # no real stdin. + while not self.label_printer.print_label(fields): + reply = QMessageBox.question( + self, + "Print failed", + f"Could not print label for '{prev_name}' — printer unreachable.\n\n" + "Power it on / check it has tape loaded, then Retry, or Ignore to" + " give up on printing this label.\n\nThe slot will be updated" + " regardless, once you close this dialog.", + QMessageBox.StandardButton.Retry | QMessageBox.StandardButton.Ignore, + QMessageBox.StandardButton.Retry, + ) + if reply != QMessageBox.StandardButton.Retry: + break + def _write_slot(self, slot: int, used: int, name: str, owner: str = "") -> bool: """ Write (used, name, owner) to a storage slot 0–20 or the gripper, mirroring ``Flomni.flomni_modify_storage_non_interactive()`` exactly. """ + self._offer_print_label_for_previous_occupant(slot) try: packed_name = pack_desc(name, owner) if slot == GRIPPER_SLOT: @@ -396,13 +576,13 @@ class OMNY_SampleStorage(BECWidget, QWidget): state = self._read_all_slots() if not state: return # bulk read failed; leave the current display untouched - for slot, (occupied, name, owner) in state.items(): - if self._last_state.get(slot) == (occupied, name, owner): + for slot, (occupied, name, owner, measured_status) in state.items(): + if self._last_state.get(slot) == (occupied, name, owner, measured_status): continue cell = self._cells.get(slot) if cell is not None: - cell.set_state(occupied, name, owner) - self._last_state[slot] = (occupied, name, owner) + cell.set_state(occupied, name, owner, measured_status) + self._last_state[slot] = (occupied, name, owner, measured_status) # ── mutation actions (called from _SlotCell context menu) ───────────────── diff --git a/csaxs_bec/devices/omny/flomni_sample_storage.py b/csaxs_bec/devices/omny/flomni_sample_storage.py index b6b8fb76..380d8d1a 100644 --- a/csaxs_bec/devices/omny/flomni_sample_storage.py +++ b/csaxs_bec/devices/omny/flomni_sample_storage.py @@ -1,4 +1,5 @@ import time +from typing import Optional from ophyd import Component as Cpt from ophyd import Device @@ -101,7 +102,31 @@ class FlomniSampleStorage(Device): val = getattr(self.sample_names, f"sample{slot_nr}").get() return unpack_desc(str(val)) - def show_all(self): + def show_all(self, measured: Optional[dict] = None): + """Print the tray grid plus loaded tray/gripper/stage samples. + + `measured`, if given, is a plain dict (as returned by + `_MeasuredSampleLog.snapshot()`) mapping packed + `pack_desc(name, owner)` strings to {"status": ..., ...} records -- + this device class stays decoupled from the BEC-client-backed + measured-sample log that produces it; callers (e.g. + `Flomni.ftransfer_show_all()`) inject the snapshot instead. + + Must be a plain, msgpack-serializable dict, not a callable: this + method runs via BEC's device-server RPC mechanism, which + serializes every argument before sending it, so a bound method + (an earlier version of this passed `self.measured_log.get`) fails + with "can not serialize 'method' object". + """ + + def _measured_suffix(name: str, owner: str) -> str: + if not measured: + return "" + record = measured.get(pack_desc(name, owner)) + if record is None: + return "" + return f" [measured: {record['status']}]" + t = PrettyTable() t.title = "flOMNI sample storage" field_names = [""] @@ -120,17 +145,17 @@ class FlomniSampleStorage(Device): if self.is_sample_slot_used(ct): name, owner = self.get_sample_name_and_owner(ct) owner_suffix = f" (owner: {owner})" if owner else "" - print(f" Position {ct:2.0f}: {name}{owner_suffix}") + print(f" Position {ct:2.0f}: {name}{owner_suffix}{_measured_suffix(name, owner)}") if self.sample_in_gripper.get(): name, owner = unpack_desc(str(self.sample_in_gripper_name.get())) owner_suffix = f" (owner: {owner})" if owner else "" - print(f"\n Gripper: {name}{owner_suffix}\n") + print(f"\n Gripper: {name}{owner_suffix}{_measured_suffix(name, owner)}\n") else: print(f"\n Gripper: no sample\n") if self.is_sample_slot_used(0): name, owner = self.get_sample_name_and_owner(0) owner_suffix = f" (owner: {owner})" if owner else "" - print(f" flOMNI stage: {name}{owner_suffix}\n") + print(f" flOMNI stage: {name}{owner_suffix}{_measured_suffix(name, owner)}\n") else: print(f" flOMNI stage: no sample\n") -- 2.54.0 From a20be612513107053f827e5456a296d48c96bf2a Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 19 Aug 2026 15:34:58 +0200 Subject: [PATCH 2/2] fix(tests): stub measured_log in flomni tomo-angle test helper make_flomni_for_golden_resume() builds a bare Flomni via object.__new__, bypassing __init__, so it never got measured_log or a working sample_get_measured_log_key -- both added by the measured-sample-tracking commit for the P-touch label printer. Co-Authored-By: Claude Sonnet 5 --- tests/tests_bec_ipython_client/test_flomni_tomo_angles.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py b/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py index b8b83400..7f0a1edb 100644 --- a/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py +++ b/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py @@ -194,6 +194,8 @@ def make_flomni_for_golden_resume(monkeypatch, tomo_type: int) -> Flomni: obj.golden_max_number_of_projections = 21 obj.tomo_id = -1 obj.sample_get_name = lambda position: "test" # backs the read-only sample_name property + obj.sample_get_measured_log_key = lambda position=0: "test|" + obj.measured_log = types.SimpleNamespace(mark_measured=lambda *a, **k: None) obj.special_angles = [] obj._golden = _fake_golden obj._golden_equally_spaced = _fake_golden -- 2.54.0