feat(flomni): track measured samples and offer a P-touch label print on clear/replace #304
@@ -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
|
||||
|
||||
@@ -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) ─────────────────
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user