fix(omny): parking overwrite guard, active-slot leica targets, shuttle-scoped print; add measured-status+account encoding and P-touch label printing
CI for csaxs_bec / test (push) Successful in 2m20s
CI for csaxs_bec / test (push) Successful in 2m20s
- Widget: reject assigning a shuttle to a parking slot already held by a
different shuttle ("Slot in use", clear it first) instead of silently
overwriting.
- Widget: PDF report now shows only the 3 shuttles + their samples (title
"OMNY Shuttle Samples") -- stage/gripper/fixed-O positions can't
physically leave the vacuum chamber, so a printed report has nothing
useful to say about them. The on-screen widget still shows/manages all
of it.
- Widget: fixed the active-parking-slot badge never showing for a
loadlock-driven park. Root cause: otransfer_park_loadlock_slot() drives
oparkz to a different target-position table (base -6, "leica") than the
regular otransfer_park_slot() (base -0.9); the widget only recognized
the regular one. Now checks both.
- OMNYSampleStorage: measured status now packs status *and* the beamtime
account into one VAL -- 0 = not measured, negative 5-digit = started,
positive 5-digit = completed, falling back to FALLBACK_ACCOUNT (11111)
for anything that doesn't parse as exactly 5 digits. No IOC schema
change needed (same ai record). New get_sample_account()(+gripper/
samplestage variants); set_sample_status() now takes an account param;
show_all()/help() updated. otransfer_* CLI wrappers updated to match.
- Widget: new P-touch label-print offer, but only when clearing a
*shuttle* slot (not fixed-O/stage/gripper -- those are bookkeeping
corrections, not physical sample removal, since only shuttle samples
leave the vacuum chamber). Uses a separate PTouchLabelPrinter instance
at template 3 (not yet bench-tested against a real printer, unlike
FlOMNI's confirmed-working template 1), same field names as FlOMNI.
- AI_docs/OPEN_ISSUES.md: noted that the measured-status+account encoding
should be ported to FlOMNI at some point (separate task, not this
branch's scope -- FlOMNI's own status tracking is untouched, still the
redis-based _MeasuredSampleLog).
- New tests: encode/decode round-trips + fallback behavior for the
status+account VAL scheme, plus oparkz leica-target coverage.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Mupip8x7Hean6E3FFvpbj
This commit is contained in:
1 parent
bdb0d25f90
commit
3ffcdf5128
6 files changed
+379
-101
No files matched your search
@@ -794,6 +794,26 @@ where OMNY's version was ported from, so it inherited the bug faithfully. **Not
|
||||
(`update-omny`); flagging for a separate, dedicated look rather than bundling an unreviewed
|
||||
flomni-affecting change into this branch.
|
||||
|
||||
## Measured-status + account tracking now real EPICS for OMNY -- port to flomni later
|
||||
|
||||
OMNY sample storage gained a real EPICS channel for measured status and the beamtime account
|
||||
(`OMNYSampleStorage`'s new `sample_status`/`sample_shuttle_{A,B,C}_status` signals, one new `ai`
|
||||
record per sample position -- see `omny_samples_status.template` in `OMNY/OMNY.git` and
|
||||
`OMNY/ioc-samples-and-xrayeye`). Encoding: VAL packs *both* status and account into one number --
|
||||
`0` means not measured; otherwise VAL's magnitude is the 5-digit account and its sign is the
|
||||
status (negative = "started", positive = "completed"), falling back to `FALLBACK_ACCOUNT` (11111)
|
||||
for anything that doesn't parse as exactly 5 digits. See `OMNYSampleStorage.STATUS_NONE`'s class
|
||||
comment and `_status_to_val()`/`_val_to_status()`/`_val_to_account()`/`_normalize_account()` in
|
||||
`csaxs_bec/devices/omny/omny_sample_storage.py`.
|
||||
|
||||
This deliberately mirrors, but does not replace, FlOMNI's own measured-status tracking, which
|
||||
still lives entirely in a BEC redis global var (`_MeasuredSampleLog`/`flomni_measured_samples` in
|
||||
`sample_storage.py`/`flomni.py`), keyed by `pack_desc(name, owner)` rather than a fixed physical
|
||||
position -- FlOMNI wasn't touched this branch (explicitly out of scope, see the sample-storage
|
||||
work above). **Mirko's intent: port the same real-EPICS status+account channel to FlOMNI at some
|
||||
point** -- a separate, dedicated task (its own `.template` record + `FlomniSampleStorage` changes
|
||||
+ retiring/reconciling `_MeasuredSampleLog`), not bundled into this branch.
|
||||
|
||||
## Environment note (not code, but will bite again if forgotten)
|
||||
|
||||
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
|
||||
|
||||
@@ -1170,30 +1170,39 @@ class OMNYSampleTransferMixin:
|
||||
def otransfer_get_sample_name_and_owner(self, container: str, slot_nr: int) -> tuple:
|
||||
return dev.omny_samples.get_sample_name_and_owner(container, slot_nr)
|
||||
|
||||
def otransfer_set_sample_status(self, container: str, slot_nr: int, status: str) -> bool:
|
||||
return dev.omny_samples.set_sample_status(container, slot_nr, status)
|
||||
def otransfer_set_sample_status(self, container: str, slot_nr: int, status: str, account=None) -> bool:
|
||||
return dev.omny_samples.set_sample_status(container, slot_nr, status, account)
|
||||
|
||||
def otransfer_get_sample_status(self, container: str, slot_nr: int) -> str:
|
||||
return dev.omny_samples.get_sample_status(container, slot_nr)
|
||||
|
||||
def otransfer_get_sample_account(self, container: str, slot_nr: int):
|
||||
return dev.omny_samples.get_sample_account(container, slot_nr)
|
||||
|
||||
def otransfer_get_sample_owner_in_gripper(self) -> str:
|
||||
return dev.omny_samples.get_sample_owner_in_gripper()
|
||||
|
||||
def otransfer_get_sample_owner_in_samplestage(self) -> str:
|
||||
return dev.omny_samples.get_sample_owner_in_samplestage()
|
||||
|
||||
def otransfer_set_sample_status_in_gripper(self, status: str) -> bool:
|
||||
return dev.omny_samples.set_sample_status_in_gripper(status)
|
||||
def otransfer_set_sample_status_in_gripper(self, status: str, account=None) -> bool:
|
||||
return dev.omny_samples.set_sample_status_in_gripper(status, account)
|
||||
|
||||
def otransfer_get_sample_status_in_gripper(self) -> str:
|
||||
return dev.omny_samples.get_sample_status_in_gripper()
|
||||
|
||||
def otransfer_set_sample_status_in_samplestage(self, status: str) -> bool:
|
||||
return dev.omny_samples.set_sample_status_in_samplestage(status)
|
||||
def otransfer_get_sample_account_in_gripper(self):
|
||||
return dev.omny_samples.get_sample_account_in_gripper()
|
||||
|
||||
def otransfer_set_sample_status_in_samplestage(self, status: str, account=None) -> bool:
|
||||
return dev.omny_samples.set_sample_status_in_samplestage(status, account)
|
||||
|
||||
def otransfer_get_sample_status_in_samplestage(self) -> str:
|
||||
return dev.omny_samples.get_sample_status_in_samplestage()
|
||||
|
||||
def otransfer_get_sample_account_in_samplestage(self):
|
||||
return dev.omny_samples.get_sample_account_in_samplestage()
|
||||
|
||||
# def _otransfer_modify_sample(_ocontainer, _oposition, _ostatus, _oname):
|
||||
# #_ocontainer is A, B, C for the pin containers
|
||||
# #it can also be FZP, OSA and omny
|
||||
|
||||
@@ -24,11 +24,11 @@ OMNY (``dev.omny_samples``)
|
||||
recording which shuttle letter is docked at each physical parking
|
||||
position (parking slots are a shuttle-letter assignment, not a sample,
|
||||
so they carry no name/owner/status). Unlike FlOMNI, OMNY has a
|
||||
dedicated owner channel (and a measured-status channel -- see
|
||||
``OMNY_SampleStorage.STATUS_NONE``/``STATUS_STARTED``/
|
||||
``STATUS_COMPLETED``) next to each sample slot's existing placed/name
|
||||
channels, so no packing is needed there. Whichever shuttle's parking
|
||||
slot matches the ``oparkz`` motor's current readback is "active", i.e.
|
||||
dedicated owner channel and a measured-status channel (packing status
|
||||
*and* account into one VAL -- see ``OMNY_SampleStorage.STATUS_NONE``)
|
||||
next to each sample slot's existing placed/name channels, so no
|
||||
packing is needed there. Whichever shuttle's parking slot matches the
|
||||
``oparkz`` motor's current readback is "active", i.e.
|
||||
at the transfer-ready position (see ``oparkz_active_parking_slot()``).
|
||||
|
||||
A LamNI session has neither device; the widget shows a "no known setup
|
||||
@@ -157,10 +157,13 @@ COLOR_SLOT_BORDER = "#c0c0c0"
|
||||
# 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.
|
||||
# FlOMNI-only: OMNY has no measured-sample-log integration (see module
|
||||
# docstring), so it never drives this printer.
|
||||
# Same printer, same field names, but a *different* template number is
|
||||
# pre-loaded on the printer for the OMNY label -- not yet bench-tested
|
||||
# against a real printer the way FlOMNI's template 1 has been, so the
|
||||
# field names are an assumption for now, not confirmed working.
|
||||
PTOUCH_PRINTER_HOST = "BRN94DDF8AAB8EC.psi.ch"
|
||||
PTOUCH_TEMPLATE_NUMBER = 1
|
||||
PTOUCH_TEMPLATE_NUMBER_OMNY = 3
|
||||
PTOUCH_FIELD_ACCOUNT = "ACCOUNT"
|
||||
PTOUCH_FIELD_DATE = "DATE"
|
||||
PTOUCH_FIELD_SAMPLENAME = "SAMPLENAME"
|
||||
@@ -197,20 +200,38 @@ _OPARKZ_TARGET_POSITIONS: dict[int, float] = {
|
||||
5: -0.9 - 29,
|
||||
6: -0.9,
|
||||
}
|
||||
# otransfer_park_loadlock_slot()'s targets (_oparkz_leica_slot() in the same
|
||||
# mixin) -- the *same* slot numbers, reached via the loadlock/leica transfer
|
||||
# path instead of the regular gripper path, at a different oparkz offset
|
||||
# (base -6, not -0.9). Both genuinely mean "the parking stage is at that
|
||||
# slot" -- "active" must recognize either, or a loadlock-driven park (like
|
||||
# otransfer_park_loadlock_slot()) never shows as active in the GUI even
|
||||
# though it physically is.
|
||||
_OPARKZ_LEICA_TARGET_POSITIONS: dict[int, float] = {
|
||||
2: -6 - 29 * 3 - 42 - 0.05,
|
||||
3: -6 - 29 * 3,
|
||||
4: -6 - 29 * 2,
|
||||
5: -6 - 29,
|
||||
6: -6,
|
||||
}
|
||||
_OPARKZ_TOLERANCE = 0.05
|
||||
|
||||
|
||||
def oparkz_active_parking_slot(readback: float, tol: float = _OPARKZ_TOLERANCE) -> Optional[int]:
|
||||
"""Return which parking slot (2-6) the oparkz motor is currently sitting
|
||||
at, i.e. ready for a sample transfer, or None if it's between positions
|
||||
(or at slot 1, which has no drivable transfer target). Pure function --
|
||||
see the module-level docstring above ``_OPARKZ_TARGET_POSITIONS`` for
|
||||
why this duplicates rather than imports omny_sample_transfer_mixin.py's
|
||||
``_oparkz_slot_check()``.
|
||||
(or at slot 1, which has no drivable transfer target). Checks both the
|
||||
regular-gripper and loadlock/leica target tables (see
|
||||
``_OPARKZ_LEICA_TARGET_POSITIONS``) since either represents the parking
|
||||
stage genuinely being at that slot. Pure function -- see the
|
||||
module-level docstring above ``_OPARKZ_TARGET_POSITIONS`` for why this
|
||||
duplicates rather than imports omny_sample_transfer_mixin.py's
|
||||
``_oparkz_slot_check()``/``_oparkz_leica_slot()``.
|
||||
"""
|
||||
for slot, target in _OPARKZ_TARGET_POSITIONS.items():
|
||||
if abs(readback - target) < tol:
|
||||
return slot
|
||||
for targets in (_OPARKZ_TARGET_POSITIONS, _OPARKZ_LEICA_TARGET_POSITIONS):
|
||||
for slot, target in targets.items():
|
||||
if abs(readback - target) < tol:
|
||||
return slot
|
||||
return None
|
||||
|
||||
|
||||
@@ -504,6 +525,9 @@ class OMNY_SampleStorage(BECWidget, QWidget):
|
||||
self.label_printer = PTouchLabelPrinter(
|
||||
host=PTOUCH_PRINTER_HOST, template_number=PTOUCH_TEMPLATE_NUMBER
|
||||
)
|
||||
self.label_printer_omny = PTouchLabelPrinter(
|
||||
host=PTOUCH_PRINTER_HOST, template_number=PTOUCH_TEMPLATE_NUMBER_OMNY
|
||||
)
|
||||
self._setup = self._detect_setup()
|
||||
self._build_ui()
|
||||
self._poll_timer = QTimer(self)
|
||||
@@ -634,9 +658,9 @@ class OMNY_SampleStorage(BECWidget, QWidget):
|
||||
sample-kind slot -- stage, gripper, the 10 fixed O positions, and
|
||||
the 18 shuttle slots. Owner and measured status each have their own
|
||||
dedicated OMNYSampleStorage signal (added alongside the existing
|
||||
placed/name signals -- see that class's ``STATUS_NONE``/
|
||||
``STATUS_STARTED``/``STATUS_COMPLETED``), unlike FlOMNI which packs
|
||||
name+owner into one field via ``sample_desc_codec``.
|
||||
placed/name signals -- see that class's status+account VAL
|
||||
encoding, ``STATUS_NONE``), unlike FlOMNI which packs name+owner
|
||||
into one field via ``sample_desc_codec``.
|
||||
"""
|
||||
|
||||
def _val(key, default=None):
|
||||
@@ -646,13 +670,19 @@ class OMNY_SampleStorage(BECWidget, QWidget):
|
||||
return default
|
||||
|
||||
def _status_str(raw) -> Optional[str]:
|
||||
# mirrors OMNYSampleStorage._status_val_to_str(), without a live
|
||||
# mirrors OMNYSampleStorage._val_to_status(), without a live
|
||||
# device round-trip: this widget only has the bulk-read dict.
|
||||
# VAL packs status+account (see that class's status+account VAL
|
||||
# encoding comment): 0 -> no badge, negative -> "started",
|
||||
# positive -> "completed" (the magnitude is the account, not
|
||||
# needed here -- see _status_account() for that).
|
||||
try:
|
||||
val = int(round(float(raw)))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return {1: "started", 2: "completed"}.get(val) # 0 ("none") -> None: no badge
|
||||
if val == 0:
|
||||
return None
|
||||
return "started" if val < 0 else "completed"
|
||||
|
||||
state: dict[tuple, tuple[bool, str, str, Optional[str]]] = {}
|
||||
|
||||
@@ -788,6 +818,74 @@ class OMNY_SampleStorage(BECWidget, QWidget):
|
||||
if reply != QMessageBox.StandardButton.Retry:
|
||||
break
|
||||
|
||||
def _offer_print_label_for_previous_occupant_omny(self, key: tuple) -> None:
|
||||
"""If the shuttle slot about to be cleared has a measured status
|
||||
(started or completed), offer to print an account/date/name label
|
||||
for it -- OMNY's counterpart to
|
||||
``_offer_print_label_for_previous_occupant()``, but only ever
|
||||
called for shuttle-slot clears (see ``_write_slot_omny``): unlike
|
||||
the fixed-O/stage/gripper positions, a shuttle's samples are the
|
||||
only ones that can physically leave the vacuum chamber, so
|
||||
printing a label only makes sense there.
|
||||
|
||||
Uses ``self.label_printer_omny`` (``PTOUCH_TEMPLATE_NUMBER_OMNY``),
|
||||
a different template number than FlOMNI's -- same field names
|
||||
(ACCOUNT/DATE/SAMPLENAME) by assumption, not yet bench-tested
|
||||
against a real printer the way FlOMNI's template 1 is.
|
||||
|
||||
OMNY has no FlOMNI-style measured-sample log to pop a record from
|
||||
-- status and account already live directly on the slot itself
|
||||
(see ``OMNYSampleStorage``'s status+account VAL encoding), so this
|
||||
reads them fresh from the device right before the clear, instead.
|
||||
There's also no stored measurement date (OMNY doesn't track one),
|
||||
so the label's DATE field is simply today's date, at print time.
|
||||
"""
|
||||
assert key[0] == "shuttle"
|
||||
_, letter, n = key
|
||||
try:
|
||||
was_used = self._samples.is_sample_slot_used(letter, n)
|
||||
prev_name = self._samples.get_sample_name(letter, n)
|
||||
status = self._samples.get_sample_status(letter, n)
|
||||
account = self._samples.get_sample_account(letter, n)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"OMNY_SampleStorage: could not read previous state of slot {key} for the"
|
||||
f" measured-label check: {exc}"
|
||||
)
|
||||
return
|
||||
if not was_used or status == "none":
|
||||
return
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Print label?",
|
||||
f"Sample '{prev_name}' was measured ({status}), account {account}. Print a label?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
fields = {
|
||||
PTOUCH_FIELD_ACCOUNT: str(account),
|
||||
PTOUCH_FIELD_DATE: datetime.date.today().strftime("%Y-%m-%d"),
|
||||
PTOUCH_FIELD_SAMPLENAME: prev_name,
|
||||
}
|
||||
# Retry loop, same reasoning as _offer_print_label_for_previous_occupant():
|
||||
# the slot is about to be cleared regardless of whether printing
|
||||
# succeeds, so give the operator a real chance to fix the printer
|
||||
# and retry rather than losing the label silently.
|
||||
while not self.label_printer_omny.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 FlOMNI storage slot 0–20 or the
|
||||
@@ -826,13 +924,21 @@ class OMNY_SampleStorage(BECWidget, QWidget):
|
||||
no packing needed. Measured status is reset to "none" on every
|
||||
write here, mirroring ``OMNYSampleStorage.set_sample_slot()``'s/
|
||||
``unset_sample_slot()``'s own behavior -- a fresh name (even a
|
||||
rename) or a cleared slot isn't mid-measurement. No P-touch
|
||||
label-printing offer for the previous occupant (FlOMNI-only, see
|
||||
module docstring).
|
||||
rename) or a cleared slot isn't mid-measurement.
|
||||
|
||||
Offers a P-touch label print for the previous occupant only when
|
||||
*clearing* a *shuttle* slot (see
|
||||
``_offer_print_label_for_previous_occupant_omny``) -- shuttles are
|
||||
the only OMNY samples that can physically leave the vacuum
|
||||
chamber, so that's the only "sample remove" this widget can
|
||||
meaningfully print a label for; every other clear (fixed-O/stage/
|
||||
gripper) is a bookkeeping correction, not a physical removal.
|
||||
"""
|
||||
kind = key[0]
|
||||
if used == 0 and kind == "shuttle":
|
||||
self._offer_print_label_for_previous_occupant_omny(key)
|
||||
try:
|
||||
status_none = OMNY_STATUS_NONE
|
||||
kind = key[0]
|
||||
if kind == "stage":
|
||||
self._samples.sample_in_samplestage.set(used)
|
||||
self._samples.sample_in_samplestage_name.set(name)
|
||||
@@ -1224,11 +1330,18 @@ class OMNY_SampleStorage(BECWidget, QWidget):
|
||||
|
||||
def action_set_parking(self, letter: str, current_slot: Optional[int]) -> None:
|
||||
"""Assign OMNY shuttle `letter` to a parking slot (1-6), via
|
||||
``_ShuttleStatusLabel``'s context menu. Overwrites that slot's
|
||||
previous occupant, if any, without clearing wherever `letter` was
|
||||
previously parked -- mirrors ``set_shuttle_slot()``'s own
|
||||
semantics exactly; keeping the two in sync is operator discipline,
|
||||
same as everywhere else in this widget (see module docstring).
|
||||
``_ShuttleStatusLabel``'s context menu. Rejects the assignment if
|
||||
that slot is already assigned to a *different* shuttle -- unlike
|
||||
``set_shuttle_slot()`` itself, which would silently overwrite it
|
||||
(see module docstring on why writes elsewhere in this widget don't
|
||||
add interlocks: parking is the one exception, since two different
|
||||
shuttles both being "in" the same slot is a bookkeeping
|
||||
impossibility, not a legitimate concurrent-edit race). Re-assigning
|
||||
the *same* shuttle to a slot it's already in is a no-op-safe
|
||||
confirm, not an error.
|
||||
|
||||
Doesn't clear wherever `letter` was previously parked, if anywhere
|
||||
else -- still the operator's job, same as everywhere else here.
|
||||
"""
|
||||
slot_nr, ok = QInputDialog.getInt(
|
||||
self,
|
||||
@@ -1241,6 +1354,19 @@ class OMNY_SampleStorage(BECWidget, QWidget):
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
data = self._bulk_read_omny()
|
||||
parking_assignment = self._parse_parking_assignment_omny(data) if data is not None else {}
|
||||
occupant = parking_assignment.get(slot_nr)
|
||||
if occupant is not None and occupant != letter:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Slot in use",
|
||||
f"Parking slot {slot_nr} is already assigned to shuttle {occupant}.\n\n"
|
||||
f"Clear shuttle {occupant}'s parking assignment first (right-click its "
|
||||
f"status line → Clear parking assignment), then assign shuttle "
|
||||
f"{letter} to slot {slot_nr}.",
|
||||
)
|
||||
return
|
||||
if self._write_parking_omny(slot_nr, letter):
|
||||
self.refresh()
|
||||
|
||||
@@ -1358,10 +1484,16 @@ class OMNY_SampleStorage(BECWidget, QWidget):
|
||||
return path
|
||||
|
||||
def _build_report_pdf_omny(self) -> str:
|
||||
"""Render a page mirroring the on-screen OMNY layout (stage +
|
||||
gripper header, fixed-O grid, 3 shuttle sections with their
|
||||
installed/active status text) from a fresh device read, and return
|
||||
the path of the temp PDF file it was written to.
|
||||
"""Render a page with just the 3 shuttles and their samples (status
|
||||
line + slot grid) from a fresh device read, and return the path of
|
||||
the temp PDF file it was written to.
|
||||
|
||||
Deliberately excludes the stage/gripper/fixed-O positions that the
|
||||
on-screen widget also shows -- unlike the shuttles, none of those
|
||||
samples can physically leave the vacuum chamber, so a printed
|
||||
report (meant to travel with a shuttle's physical samples) has
|
||||
nothing useful to say about them. The widget itself still displays
|
||||
and manages all of it; only this printed report is scoped down.
|
||||
"""
|
||||
from fpdf import XPos, YPos
|
||||
|
||||
@@ -1375,7 +1507,7 @@ class OMNY_SampleStorage(BECWidget, QWidget):
|
||||
with PDFWriter(path) as file:
|
||||
pdf = file._pdf
|
||||
pdf.set_font("Helvetica", "B", 16)
|
||||
pdf.cell(0, 10, "OMNY Sample Storage", new_x=XPos.LMARGIN, new_y=YPos.NEXT, align="C")
|
||||
pdf.cell(0, 10, "OMNY Shuttle Samples", new_x=XPos.LMARGIN, new_y=YPos.NEXT, align="C")
|
||||
pdf.set_font("Helvetica", "", 10)
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
pdf.cell(0, 6, timestamp, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align="C")
|
||||
@@ -1383,27 +1515,10 @@ class OMNY_SampleStorage(BECWidget, QWidget):
|
||||
|
||||
margin = pdf.l_margin
|
||||
usable_w = pdf.w - 2 * margin
|
||||
cell_w = usable_w / 5
|
||||
cell_h = 28
|
||||
|
||||
# header: stage + gripper, same widths as two fixed-O cells
|
||||
y0 = pdf.get_y()
|
||||
self._draw_report_cell(pdf, margin, y0, cell_w, cell_h, ("stage",), state.get(("stage",)))
|
||||
self._draw_report_cell(
|
||||
pdf, margin + cell_w, y0, cell_w, cell_h, ("gripper",), state.get(("gripper",))
|
||||
)
|
||||
|
||||
# fixed O positions, same 5-wide grid as the on-screen layout
|
||||
fixed_y0 = y0 + cell_h + 4
|
||||
for idx, n in enumerate(OMNY_FIXED_POSITIONS):
|
||||
r, c = divmod(idx, 5)
|
||||
x = margin + c * cell_w
|
||||
y = fixed_y0 + r * cell_h
|
||||
key = ("O", n)
|
||||
self._draw_report_cell(pdf, x, y, cell_w, cell_h, key, state.get(key))
|
||||
|
||||
# shuttles A/B/C: status line + 3-wide slot grid, side by side
|
||||
shuttle_y0 = fixed_y0 + 2 * cell_h + 8
|
||||
shuttle_y0 = pdf.get_y()
|
||||
shuttle_w = usable_w / 3
|
||||
for si, letter in enumerate(OMNY_SHUTTLE_LETTERS):
|
||||
x0 = margin + si * shuttle_w
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from ophyd import Component as Cpt
|
||||
from ophyd import Device
|
||||
@@ -27,13 +28,16 @@ class OMNYSampleStorage(Device):
|
||||
"get_sample_owner_in_gripper",
|
||||
"set_sample_status_in_samplestage",
|
||||
"get_sample_status_in_samplestage",
|
||||
"get_sample_account_in_samplestage",
|
||||
"set_sample_status_in_gripper",
|
||||
"get_sample_status_in_gripper",
|
||||
"get_sample_account_in_gripper",
|
||||
"get_sample_name",
|
||||
"get_sample_owner",
|
||||
"get_sample_name_and_owner",
|
||||
"set_sample_status",
|
||||
"get_sample_status",
|
||||
"get_sample_account",
|
||||
"is_sample_in_samplestage",
|
||||
"set_shuttle_slot",
|
||||
"unset_shuttle_slot",
|
||||
@@ -46,17 +50,26 @@ class OMNYSampleStorage(Device):
|
||||
SUB_VALUE = "value"
|
||||
_default_sub = SUB_VALUE
|
||||
|
||||
# Measured status, one of these three -- an "ai" record's VAL (see
|
||||
# omny_samples_status.template in the OMNY/OMNY.git IOC repo), not a
|
||||
# proper EPICS enum: this IOC's sample DB uses plain "ai" records
|
||||
# uniformly even for boolean-ish fields (see the placed-flag records),
|
||||
# so the new STATUS record follows the same convention rather than
|
||||
# introducing an mbbo/mbbi record type used nowhere else here. The
|
||||
# int<->str mapping lives entirely in this class.
|
||||
# Measured status *and* account, packed into one number -- an "ai"
|
||||
# record's VAL (see omny_samples_status.template in the OMNY/OMNY.git
|
||||
# IOC repo), not a proper EPICS enum: this IOC's sample DB uses plain
|
||||
# "ai" records uniformly even for boolean-ish fields (see the
|
||||
# placed-flag records), so the STATUS record follows the same
|
||||
# convention rather than introducing a new record type used nowhere
|
||||
# else here.
|
||||
#
|
||||
# Encoding: VAL == 0 means "not measured"; otherwise VAL's magnitude is
|
||||
# the 5-digit beamtime account and its sign is the status -- negative
|
||||
# while a measurement is in progress ("started"), positive once it's
|
||||
# done ("completed"). This packs status+account into the one VAL this
|
||||
# IOC's "ai"-record convention already gives us, with no new EPICS
|
||||
# record needed. An account that isn't exactly 5 digits (e.g. a
|
||||
# non-numeric test account) falls back to FALLBACK_ACCOUNT (11111) --
|
||||
# safe because real account numbers are already above 20000, so this
|
||||
# sentinel can never collide with a real one, and only ever shows up
|
||||
# for test accounts.
|
||||
STATUS_NONE = 0
|
||||
STATUS_STARTED = 1
|
||||
STATUS_COMPLETED = 2
|
||||
_STATUS_LABELS = {STATUS_NONE: "none", STATUS_STARTED: "started", STATUS_COMPLETED: "completed"}
|
||||
FALLBACK_ACCOUNT = 11111
|
||||
|
||||
sample_shuttle_A_placed = {
|
||||
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_A:{i}", {"auto_monitor": True}) for i in range(1, 7)
|
||||
@@ -119,11 +132,11 @@ class OMNYSampleStorage(Device):
|
||||
}
|
||||
sample_names = Dcpt(sample_names)
|
||||
|
||||
# Measured status (VAL) + owner (DESC) -- one new record per sample
|
||||
# position, added alongside the existing placed/name records (see
|
||||
# STATUS_NONE/STARTED/COMPLETED above and omny_samples_status.template
|
||||
# in OMNY/OMNY.git). Parking slots have no equivalent -- they hold a
|
||||
# shuttle letter, not a sample.
|
||||
# Measured status+account (VAL) + owner (DESC) -- one new record per
|
||||
# sample position, added alongside the existing placed/name records
|
||||
# (see the status+account VAL encoding described above and
|
||||
# omny_samples_status.template in OMNY/OMNY.git). Parking slots have no
|
||||
# equivalent -- they hold a shuttle letter, not a sample.
|
||||
sample_shuttle_A_status = {
|
||||
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_A:{i}:STATUS", {"auto_monitor": True})
|
||||
for i in range(1, 7)
|
||||
@@ -242,27 +255,53 @@ class OMNYSampleStorage(Device):
|
||||
self._run_subs(sub_type=self.SUB_VALUE, timestamp=timestamp, obj=self)
|
||||
|
||||
@classmethod
|
||||
def _status_str_to_val(cls, status: str) -> int:
|
||||
status = (status or "none").strip().lower()
|
||||
for val, label in cls._STATUS_LABELS.items():
|
||||
if label == status:
|
||||
def _normalize_account(cls, account) -> int:
|
||||
"""A positive 5-digit account number for the status+account VAL
|
||||
encoding (see the class docstring comment above), or
|
||||
FALLBACK_ACCOUNT if `account` doesn't parse as exactly 5 digits.
|
||||
"""
|
||||
digits = "".join(ch for ch in str(account) if ch.isdigit()) if account is not None else ""
|
||||
if len(digits) == 5:
|
||||
val = int(digits)
|
||||
if 10000 <= val <= 99999:
|
||||
return val
|
||||
raise OMNYSampleStorageError(
|
||||
f"Invalid status {status!r}; expected one of {sorted(cls._STATUS_LABELS.values())}."
|
||||
)
|
||||
return cls.FALLBACK_ACCOUNT
|
||||
|
||||
@classmethod
|
||||
def _status_val_to_str(cls, val) -> str:
|
||||
def _status_to_val(cls, status: str, account=None) -> int:
|
||||
status = (status or "none").strip().lower()
|
||||
if status == "none":
|
||||
return 0
|
||||
if status not in ("started", "completed"):
|
||||
raise OMNYSampleStorageError(
|
||||
f"Invalid status {status!r}; expected one of ['none', 'started', 'completed']."
|
||||
)
|
||||
acc = cls._normalize_account(account)
|
||||
return -acc if status == "started" else acc
|
||||
|
||||
@classmethod
|
||||
def _val_to_status(cls, val) -> str:
|
||||
try:
|
||||
return cls._STATUS_LABELS.get(int(round(float(val))), "none")
|
||||
v = int(round(float(val)))
|
||||
except (TypeError, ValueError):
|
||||
return "none"
|
||||
if v == 0:
|
||||
return "none"
|
||||
return "started" if v < 0 else "completed"
|
||||
|
||||
@classmethod
|
||||
def _val_to_account(cls, val) -> Optional[int]:
|
||||
try:
|
||||
v = int(round(float(val)))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return abs(v) if v != 0 else None
|
||||
|
||||
def set_sample_slot(self, container: str, slot_nr: int, name: str, owner: str = "") -> bool:
|
||||
if slot_nr > 20:
|
||||
raise OMNYSampleStorageError(f"Invalid slot number {slot_nr}.")
|
||||
|
||||
status = self._status_str_to_val("none")
|
||||
status = self._status_to_val("none")
|
||||
if container == "A":
|
||||
getattr(self.sample_shuttle_A_placed, f"sample{slot_nr}").set(1)
|
||||
getattr(self.sample_shuttle_A_names, f"sample{slot_nr}").set(name)
|
||||
@@ -288,7 +327,7 @@ class OMNYSampleStorage(Device):
|
||||
if slot_nr > 20:
|
||||
raise OMNYSampleStorageError(f"Invalid slot number {slot_nr}.")
|
||||
|
||||
status = self._status_str_to_val("none")
|
||||
status = self._status_to_val("none")
|
||||
if container == "A":
|
||||
getattr(self.sample_shuttle_A_placed, f"sample{slot_nr}").set(0)
|
||||
getattr(self.sample_shuttle_A_names, f"sample{slot_nr}").set("-")
|
||||
@@ -310,8 +349,8 @@ class OMNYSampleStorage(Device):
|
||||
getattr(self.sample_owners, f"sample{slot_nr}").set("")
|
||||
getattr(self.sample_status, f"sample{slot_nr}").set(status)
|
||||
|
||||
def set_sample_status(self, container: str, slot_nr: int, status: str) -> bool:
|
||||
val = self._status_str_to_val(status)
|
||||
def set_sample_status(self, container: str, slot_nr: int, status: str, account=None) -> bool:
|
||||
val = self._status_to_val(status, account)
|
||||
if container == "A":
|
||||
getattr(self.sample_shuttle_A_status, f"sample{slot_nr}").set(val)
|
||||
elif container == "B":
|
||||
@@ -332,7 +371,24 @@ class OMNYSampleStorage(Device):
|
||||
val = getattr(self.sample_status, f"sample{slot_nr}").get()
|
||||
else:
|
||||
return "none"
|
||||
return self._status_val_to_str(val)
|
||||
return self._val_to_status(val)
|
||||
|
||||
def get_sample_account(self, container: str, slot_nr: int) -> Optional[int]:
|
||||
"""The account packed into this slot's status VAL (see the
|
||||
status+account encoding described above ``STATUS_NONE``), or None
|
||||
if the slot isn't measured ("none" status).
|
||||
"""
|
||||
if container == "A":
|
||||
val = getattr(self.sample_shuttle_A_status, f"sample{slot_nr}").get()
|
||||
elif container == "B":
|
||||
val = getattr(self.sample_shuttle_B_status, f"sample{slot_nr}").get()
|
||||
elif container == "C":
|
||||
val = getattr(self.sample_shuttle_C_status, f"sample{slot_nr}").get()
|
||||
elif container == "O":
|
||||
val = getattr(self.sample_status, f"sample{slot_nr}").get()
|
||||
else:
|
||||
return None
|
||||
return self._val_to_account(val)
|
||||
|
||||
def get_sample_owner(self, container: str, slot_nr: int) -> str:
|
||||
if container == "A":
|
||||
@@ -366,25 +422,25 @@ class OMNYSampleStorage(Device):
|
||||
self.sample_in_gripper.set(1)
|
||||
self.sample_in_gripper_name.set(name)
|
||||
self.sample_in_gripper_owner.set(owner)
|
||||
self.sample_in_gripper_status.set(self._status_str_to_val("none"))
|
||||
self.sample_in_gripper_status.set(self._status_to_val("none"))
|
||||
|
||||
def unset_sample_in_gripper(self) -> bool:
|
||||
self.sample_in_gripper.set(0)
|
||||
self.sample_in_gripper_name.set("-")
|
||||
self.sample_in_gripper_owner.set("")
|
||||
self.sample_in_gripper_status.set(self._status_str_to_val("none"))
|
||||
self.sample_in_gripper_status.set(self._status_to_val("none"))
|
||||
|
||||
def set_sample_in_samplestage(self, name: str, owner: str = "") -> bool:
|
||||
self.sample_in_samplestage.set(1)
|
||||
self.sample_in_samplestage_name.set(name)
|
||||
self.sample_in_samplestage_owner.set(owner)
|
||||
self.sample_in_samplestage_status.set(self._status_str_to_val("none"))
|
||||
self.sample_in_samplestage_status.set(self._status_to_val("none"))
|
||||
|
||||
def unset_sample_in_samplestage(self) -> bool:
|
||||
self.sample_in_samplestage.set(0)
|
||||
self.sample_in_samplestage_name.set("-")
|
||||
self.sample_in_samplestage_owner.set("")
|
||||
self.sample_in_samplestage_status.set(self._status_str_to_val("none"))
|
||||
self.sample_in_samplestage_status.set(self._status_to_val("none"))
|
||||
|
||||
def get_sample_owner_in_gripper(self) -> str:
|
||||
val = self.sample_in_gripper_owner.get()
|
||||
@@ -394,17 +450,23 @@ class OMNYSampleStorage(Device):
|
||||
val = self.sample_in_samplestage_owner.get()
|
||||
return str(val) if val else ""
|
||||
|
||||
def set_sample_status_in_gripper(self, status: str) -> bool:
|
||||
self.sample_in_gripper_status.set(self._status_str_to_val(status))
|
||||
def set_sample_status_in_gripper(self, status: str, account=None) -> bool:
|
||||
self.sample_in_gripper_status.set(self._status_to_val(status, account))
|
||||
|
||||
def get_sample_status_in_gripper(self) -> str:
|
||||
return self._status_val_to_str(self.sample_in_gripper_status.get())
|
||||
return self._val_to_status(self.sample_in_gripper_status.get())
|
||||
|
||||
def set_sample_status_in_samplestage(self, status: str) -> bool:
|
||||
self.sample_in_samplestage_status.set(self._status_str_to_val(status))
|
||||
def get_sample_account_in_gripper(self) -> Optional[int]:
|
||||
return self._val_to_account(self.sample_in_gripper_status.get())
|
||||
|
||||
def set_sample_status_in_samplestage(self, status: str, account=None) -> bool:
|
||||
self.sample_in_samplestage_status.set(self._status_to_val(status, account))
|
||||
|
||||
def get_sample_status_in_samplestage(self) -> str:
|
||||
return self._status_val_to_str(self.sample_in_samplestage_status.get())
|
||||
return self._val_to_status(self.sample_in_samplestage_status.get())
|
||||
|
||||
def get_sample_account_in_samplestage(self) -> Optional[int]:
|
||||
return self._val_to_account(self.sample_in_samplestage_status.get())
|
||||
|
||||
def is_sample_slot_used(self, container, slot_nr: int) -> bool:
|
||||
if container == "A":
|
||||
@@ -473,9 +535,11 @@ class OMNYSampleStorage(Device):
|
||||
status = self.get_sample_status(container, slot_nr)
|
||||
suffix = f" ({owner})" if owner else ""
|
||||
if status == "started":
|
||||
suffix += " ◐ started"
|
||||
account = self.get_sample_account(container, slot_nr)
|
||||
suffix += f" ◐ started (acc {account})"
|
||||
elif status == "completed":
|
||||
suffix += " ✓ completed"
|
||||
account = self.get_sample_account(container, slot_nr)
|
||||
suffix += f" ✓ completed (acc {account})"
|
||||
return name + suffix
|
||||
|
||||
def show_all(self):
|
||||
@@ -508,9 +572,9 @@ class OMNYSampleStorage(Device):
|
||||
status = self.get_sample_status_in_samplestage()
|
||||
suffix = f" ({owner})" if owner else ""
|
||||
if status == "started":
|
||||
suffix += " ◐ started"
|
||||
suffix += f" ◐ started (acc {self.get_sample_account_in_samplestage()})"
|
||||
elif status == "completed":
|
||||
suffix += " ✓ completed"
|
||||
suffix += f" ✓ completed (acc {self.get_sample_account_in_samplestage()})"
|
||||
print(f"\n\n Sample stage: {name}{suffix}")
|
||||
else:
|
||||
print(f"\n\n Sample stage: no sample")
|
||||
@@ -544,9 +608,12 @@ class OMNYSampleStorage(Device):
|
||||
print(" dev.omny_samples.set_sample_slot('system',position,'name','owner')")
|
||||
print(" system can be A, B, C, O")
|
||||
print(" dev.omny_samples.get_sample_owner('system',position)")
|
||||
print(" dev.omny_samples.set_sample_status('system',position,'status')")
|
||||
print(" dev.omny_samples.set_sample_status('system',position,'status',account)")
|
||||
print(" status is one of 'none', 'started', 'completed'")
|
||||
print(" account is required for 'started'/'completed' (a 5-digit account number,")
|
||||
print(" falls back to 11111 if not exactly 5 digits)")
|
||||
print(" dev.omny_samples.get_sample_status('system',position)")
|
||||
print(" dev.omny_samples.get_sample_account('system',position)")
|
||||
print(" dev.omny_samples.set_sample_in_gripper('name','owner') / unset_sample_in_gripper()")
|
||||
print(" dev.omny_samples.set_sample_in_samplestage('name','owner'), unset_sample_in_samplestage()")
|
||||
print(" dev.omny_samples.set_shuttle_slot(container, slot_nr) / unset_shuttle_slot(slot_nr)")
|
||||
|
||||
@@ -31,6 +31,26 @@ def test_oparkz_active_parking_slot_matches_each_target(readback, expected_slot)
|
||||
assert oparkz_active_parking_slot(readback) == expected_slot
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"readback,expected_slot",
|
||||
[
|
||||
(-6, 6),
|
||||
(-6 - 29, 5),
|
||||
(-6 - 29 * 2, 4),
|
||||
(-6 - 29 * 3, 3),
|
||||
(-6 - 29 * 3 - 42 - 0.05, 2),
|
||||
],
|
||||
)
|
||||
def test_oparkz_active_parking_slot_matches_each_leica_target(readback, expected_slot):
|
||||
"""otransfer_park_loadlock_slot()'s targets (_oparkz_leica_slot(), a
|
||||
different oparkz offset than the regular otransfer_park_slot() table)
|
||||
must be recognized as "active" too -- a real bug: the GUI never showed
|
||||
the active badge after a loadlock-driven park, since only the regular
|
||||
target table was checked.
|
||||
"""
|
||||
assert oparkz_active_parking_slot(readback) == expected_slot
|
||||
|
||||
|
||||
def test_oparkz_active_parking_slot_none_when_far_from_any_target():
|
||||
assert oparkz_active_parking_slot(0.0) is None
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import pytest
|
||||
|
||||
from csaxs_bec.devices.omny.omny_sample_storage import OMNYSampleStorage, OMNYSampleStorageError
|
||||
|
||||
|
||||
def test_status_none_encodes_to_zero():
|
||||
assert OMNYSampleStorage._status_to_val("none") == 0
|
||||
assert OMNYSampleStorage._val_to_status(0) == "none"
|
||||
assert OMNYSampleStorage._val_to_account(0) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("account", [20123, "20123", "e20123"])
|
||||
def test_started_encodes_negative_account(account):
|
||||
val = OMNYSampleStorage._status_to_val("started", account)
|
||||
assert val == -20123
|
||||
assert OMNYSampleStorage._val_to_status(val) == "started"
|
||||
assert OMNYSampleStorage._val_to_account(val) == 20123
|
||||
|
||||
|
||||
@pytest.mark.parametrize("account", [20123, "20123", "e20123"])
|
||||
def test_completed_encodes_positive_account(account):
|
||||
val = OMNYSampleStorage._status_to_val("completed", account)
|
||||
assert val == 20123
|
||||
assert OMNYSampleStorage._val_to_status(val) == "completed"
|
||||
assert OMNYSampleStorage._val_to_account(val) == 20123
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_account", [None, "", "not-a-number", "123", "123456", "e-test"])
|
||||
def test_unparseable_account_falls_back_to_sentinel(bad_account):
|
||||
val = OMNYSampleStorage._status_to_val("started", bad_account)
|
||||
assert OMNYSampleStorage._val_to_account(val) == OMNYSampleStorage.FALLBACK_ACCOUNT
|
||||
assert OMNYSampleStorage.FALLBACK_ACCOUNT == 11111
|
||||
|
||||
|
||||
def test_invalid_status_raises():
|
||||
with pytest.raises(OMNYSampleStorageError):
|
||||
OMNYSampleStorage._status_to_val("bogus")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["not-a-number", None, object()])
|
||||
def test_val_to_status_defaults_to_none_on_bad_input(raw):
|
||||
assert OMNYSampleStorage._val_to_status(raw) == "none"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["not-a-number", None, object()])
|
||||
def test_val_to_account_defaults_to_none_on_bad_input(raw):
|
||||
assert OMNYSampleStorage._val_to_account(raw) is None
|
||||
Reference in new issue
Block a user