OMNY sample storage on the OMNY soft IOC records (OMNY-SAMPLE:<slot>:...)
CI for csaxs_bec / test (push) Failing after 4m40s
CI for csaxs_bec / test (pull_request) Failing after 4m35s

OMNYSampleStorage follows FlomniSampleStorage: one record per slot
(USED/NAME/OWNER/STATUS/MEAS_DATE; slots A1..C6, P10..P101, STAGE, GRIPPER,
PARK1..6), served by OMNY's caproto IOC (OMNY repo preemt_rt,
OMNYtools/softioc/ioc_omny.py). Replaces the XOMNY-SAMPLE_DB_* records and the
status x account VAL packing.

- device: slot_name(), transfer_sample() (whole record moves),
  set_sample_measured(), get_sample_record()/get_sample_meas_date(); the
  (container, slot_nr) API is kept; the 0-20 slot check that rejected 32-34/
  100/101 is gone.
- transfer mixin: picks/puts use transfer_sample(); otransfer_* wrappers for
  the new methods, the account wrappers removed.
- omny.py tomo_scan(): started/completed + month on the STAGE record (as flOMNI).
- sample_storage widget: bulk-read parser and writes on the new records; label
  prints owner as account and the stored date (as flOMNI).
- tomo_params: sample name from sample_names.STAGE.
- SimOMNYSampleStorage, tests, user docs.

Checked against a local ioc_omny.py (real EPICS, localhost): long names,
status enum, transfers, parking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNtKEk4jAyjebMDQFwRHJQ
This commit is contained in:
Mirko HollerandClaude Opus 5.5 committed 2026-10-18 19:28:39 +02:00
1 parent f53a5ffb13
commit 89ba28550c
14 files changed
+508 -767

No files matched your search

@@ -816,6 +816,12 @@ work above). **Mirko's intent: port the same real-EPICS status+account channel t
point** -- a separate, dedicated task (its own `.template` record + `FlomniSampleStorage` changes
+ retiring/reconciling `_MeasuredSampleLog`), not bundled into this branch.
**Superseded 2026-10-18 (branch `feat/omny-sample-ioc`):** both instruments now keep one
soft-IOC record per slot (`FLOMNI-SAMPLE:<slot>:...` since 2026-10-17, `OMNY-SAMPLE:<slot>:USED/
NAME/OWNER/STATUS/MEAS_DATE` on OMNY's caproto IOC `ioc_omny.py`); the status is an enum plus a
date, the status x account packing above and `_MeasuredSampleLog` are gone, `transfer_sample()`
moves the whole record, and `tomo_scan()` sets started/completed on the STAGE record (as flOMNI).
## Reference images now saved at every manual confirm gate in otransfer_get_sample/put_sample -- flomni has no equivalent gate to save at
`_otransfer_save_reference_images()` used to be called only from inside `_otransfer_confirm()`'s
@@ -1365,6 +1365,14 @@ class OMNY(
self.write_pdf_report()
else:
self.tomo_id = 0
# measurement status on the sample record (sample stage) in the IOC
dev.omny_samples.set_sample_measured(
"STAGE",
0,
"started",
datetime.date.today().strftime("%Y-%m"),
owner_if_empty=bec.active_account or "",
)
self.progress["tomo_start_time"] = datetime.datetime.now().isoformat()
# reset stale estimates from any previous scan, otherwise the GUI
# would keep showing a leftover ETA from before this scan has
@@ -1551,6 +1559,14 @@ class OMNY(
self.progress["subtomo_projection"] = self.progress["subtomo_total_projections"]
self._print_progress()
self.OMNYTools.printgreenbold("Tomoscan finished")
# measurement status on the sample record (sample stage) in the IOC
dev.omny_samples.set_sample_measured(
"STAGE",
0,
"completed",
datetime.date.today().strftime("%Y-%m"),
owner_if_empty=bec.active_account or "",
)
idle_s = self.progress.get("accumulated_idle_time", 0.0)
start_str = self.progress.get("tomo_start_time")
@@ -1233,14 +1233,22 @@ 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, account=None) -> bool:
return dev.omny_samples.set_sample_status(container, slot_nr, status, account)
def otransfer_set_sample_measured(
self, container: str, slot_nr: int, status: str, meas_date: str, owner_if_empty: str = ""
):
return dev.omny_samples.set_sample_measured(container, slot_nr, status, meas_date, owner_if_empty)
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_meas_date(self, container: str, slot_nr: int) -> str:
return dev.omny_samples.get_sample_meas_date(container, slot_nr)
def otransfer_get_sample_record(self, container: str, slot_nr: int) -> dict:
return dev.omny_samples.get_sample_record(container, slot_nr)
def otransfer_transfer_sample(self, from_slot, to_slot):
return dev.omny_samples.transfer_sample(from_slot, to_slot)
def otransfer_get_sample_owner_in_gripper(self) -> str:
return dev.omny_samples.get_sample_owner_in_gripper()
@@ -1248,24 +1256,12 @@ class OMNYSampleTransferMixin:
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, 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_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
@@ -1331,23 +1327,11 @@ class OMNYSampleTransferMixin:
# is there a sample available?
if dev.omny_samples.is_sample_slot_used(installedshuttle, _osampleposition):
if _picked_it == 1:
samplename = dev.omny_samples.get_sample_name(
installedshuttle, _osampleposition
)
owner = dev.omny_samples.get_sample_owner(
installedshuttle, _osampleposition
)
status = dev.omny_samples.get_sample_status(
installedshuttle, _osampleposition
)
account = dev.omny_samples.get_sample_account(
installedshuttle, _osampleposition
)
# remove sample from slot and put to gripper -- owner/status/account
# remove sample from slot and put to gripper -- name/owner/status/date
# carry over with it, the same physical sample
dev.omny_samples.unset_sample_slot(installedshuttle, _osampleposition)
dev.omny_samples.set_sample_in_gripper(samplename, owner)
dev.omny_samples.set_sample_status_in_gripper(status, account)
dev.omny_samples.transfer_sample(
(installedshuttle, _osampleposition), "GRIPPER"
)
return True
else:
print("There is no sample availabe at that position.")
@@ -1435,13 +1419,7 @@ class OMNYSampleTransferMixin:
):
if dev.omny_samples.is_sample_slot_used("O", _osampleposition):
if _picked_it:
samplename = dev.omny_samples.get_sample_name("O", _osampleposition)
owner = dev.omny_samples.get_sample_owner("O", _osampleposition)
status = dev.omny_samples.get_sample_status("O", _osampleposition)
account = dev.omny_samples.get_sample_account("O", _osampleposition)
dev.omny_samples.unset_sample_slot("O", _osampleposition)
dev.omny_samples.set_sample_in_gripper(samplename, owner)
dev.omny_samples.set_sample_status_in_gripper(status, account)
dev.omny_samples.transfer_sample(("O", _osampleposition), "GRIPPER")
# FZP was picked
# if (_osampleposition == 10 || _osampleposition == 11 || _osampleposition == 100)
# _otransfer_modify_sample("omny",110,2,samplename)
@@ -1460,13 +1438,7 @@ class OMNYSampleTransferMixin:
elif _osampleposition == 0:
if dev.omny_samples.is_sample_in_samplestage():
if _picked_it:
samplename = dev.omny_samples.get_sample_name_in_samplestage()
owner = dev.omny_samples.get_sample_owner_in_samplestage()
status = dev.omny_samples.get_sample_status_in_samplestage()
account = dev.omny_samples.get_sample_account_in_samplestage()
dev.omny_samples.unset_sample_in_samplestage()
dev.omny_samples.set_sample_in_gripper(samplename, owner)
dev.omny_samples.set_sample_status_in_gripper(status, account)
dev.omny_samples.transfer_sample("STAGE", "GRIPPER")
# FZP was picked
# if (_osampleposition == 10 || _osampleposition == 11 || _osampleposition == 100)
# _otransfer_modify_sample("omny",110,2,samplename)
@@ -1538,16 +1510,8 @@ class OMNYSampleTransferMixin:
# is there free position available?
if not dev.omny_samples.is_sample_slot_used(installedshuttle, _osampleposition):
if _put_it:
samplename = dev.omny_samples.get_sample_name_in_gripper()
owner = dev.omny_samples.get_sample_owner_in_gripper()
status = dev.omny_samples.get_sample_status_in_gripper()
account = dev.omny_samples.get_sample_account_in_gripper()
dev.omny_samples.unset_sample_in_gripper()
dev.omny_samples.set_sample_slot(
installedshuttle, _osampleposition, samplename, owner
)
dev.omny_samples.set_sample_status(
installedshuttle, _osampleposition, status, account
dev.omny_samples.transfer_sample(
"GRIPPER", (installedshuttle, _osampleposition)
)
return True
else:
@@ -1636,13 +1600,7 @@ class OMNYSampleTransferMixin:
# is there a free slot available?
if not dev.omny_samples.is_sample_slot_used("O", _osampleposition):
if _put_it:
samplename = dev.omny_samples.get_sample_name_in_gripper()
owner = dev.omny_samples.get_sample_owner_in_gripper()
status = dev.omny_samples.get_sample_status_in_gripper()
account = dev.omny_samples.get_sample_account_in_gripper()
dev.omny_samples.set_sample_slot("O", _osampleposition, samplename, owner)
dev.omny_samples.set_sample_status("O", _osampleposition, status, account)
dev.omny_samples.unset_sample_in_gripper()
dev.omny_samples.transfer_sample("GRIPPER", ("O", _osampleposition))
return True
else:
print("There is no free space at this position.")
@@ -1652,13 +1610,7 @@ class OMNYSampleTransferMixin:
elif _osampleposition == 0:
if not dev.omny_samples.is_sample_in_samplestage():
if _put_it:
samplename = dev.omny_samples.get_sample_name_in_gripper()
owner = dev.omny_samples.get_sample_owner_in_gripper()
status = dev.omny_samples.get_sample_status_in_gripper()
account = dev.omny_samples.get_sample_account_in_gripper()
dev.omny_samples.set_sample_in_samplestage(samplename, owner)
dev.omny_samples.set_sample_status_in_samplestage(status, account)
dev.omny_samples.unset_sample_in_gripper()
dev.omny_samples.transfer_sample("GRIPPER", "STAGE")
return True
else:
print("There is no sample in the sample stage.")
@@ -9,13 +9,13 @@ different setups, detected at construction time (see ``SETUP_PROFILES``,
``_detect_setup()``) — a BEC session is always exactly one setup, never
both at once:
Both setups require every sample slot to carry a name *and* an owner, but
via different backend mechanisms:
Both setups require every sample slot to carry a name *and* an owner; both
keep one soft-IOC record per slot (USED/NAME/OWNER/STATUS/MEAS_DATE):
FlOMNI (``dev.flomni_samples``)
A flat magazine: slots 1-20, the sample stage (slot 0) and the gripper
(slot 100). Name and owner are packed into one EPICS DESC field (see
``sample_desc_codec``) since this backend has no separate owner PV.
(slot 100), FLOMNI-SAMPLE:<slot>:... since 2026-10-17 (before: name and
owner packed into one EPICS DESC field, ``sample_desc_codec``).
OMNY (``dev.omny_samples``)
A richer structure: three shuttles (A/B/C) of 6 slots each, a sparse
@@ -23,11 +23,10 @@ OMNY (``dev.omny_samples``)
separate sample stage and gripper, and a 6-slot "parking" carousel
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 (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
so they carry no name/owner/status). Since 2026-10-18 every OMNY slot is
one record on the OMNY soft IOC, like FlOMNI's
(OMNY-SAMPLE:<slot>:USED/NAME/OWNER/STATUS/MEAS_DATE, see
``OMNYSampleStorage``). 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()``).
@@ -66,9 +65,11 @@ FlOMNI
Since 2026-10-17 every slot is one record on the flOMNI soft IOC
(FLOMNI-SAMPLE:<slot>:USED/NAME/OWNER/STATUS/MEAS_DATE); the measurement
status travels with the sample and is no longer kept in a BEC global var.
OMNY, stage/gripper/fixed-O/shuttle slots -- see ``_write_slot_omny()``.
OMNY, parking : parking_placed.parking{N}.set(used)
parking_names.parking{N}.set(letter or "none")
OMNY, stage/gripper/fixed-O/shuttle slots: set_sample_slot(container, N,
name, owner) / unset_sample_slot(container, N) -- see
``_write_slot_omny()``; the same one-record-per-slot layout
(OMNY-SAMPLE:<slot>:..., 2026-10-18).
OMNY, parking : set_shuttle_slot(letter, N) / unset_shuttle_slot(N)
Polling
-------
@@ -135,14 +136,6 @@ OMNY_PARKING_SLOTS = tuple(range(1, 7))
# slots (which use EMPTY_NAME = "-"), parking slots are cleared to "none".
PARKING_EMPTY_SENTINEL = "none"
# Mirrors OMNYSampleStorage.STATUS_NONE, duplicated locally rather than read
# off the device -- self._samples is the BEC *client-side* device proxy,
# which only exposes USER_ACCESS methods/signals, not plain class
# attributes like STATUS_NONE (reaching for self._samples.STATUS_NONE
# raised "'Device' object has no attribute 'STATUS_NONE'" here, even though
# it works when testing directly against the real device class).
OMNY_STATUS_NONE = 0
# colours
# An occupied slot's name label intentionally has no fixed foreground colour
# here (see _SlotCell.set_state()) -- it was previously hardcoded to
@@ -337,10 +330,9 @@ class _SlotCell(QFrame):
"""Update the cell's displayed state (called by the poll/refresh).
`measured_status` is "started"/"completed" (FLOMNI-SAMPLE:<slot>:STATUS)
or None if this slot's current occupant has no measured record.
OMNY cells always pass measured_status=None (no measured-sample log
for OMNY -- see module docstring), which the measured label below
already renders as blank.
(OMNY: OMNY-SAMPLE:<slot>:STATUS) or None if this slot's current
occupant has no measured record, which the measured label below
renders as blank.
"""
self._occupied = occupied
self._name = name if name else EMPTY_NAME
@@ -599,11 +591,11 @@ class OMNY_SampleStorage(BECWidget, QWidget):
"""Parse a dev.omny_samples.read(cached=True) dict into
{key: (occupied, name, owner, measured_status)} for every
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+account VAL
encoding, ``STATUS_NONE``), unlike FlOMNI which packs name+owner
into one field via ``sample_desc_codec``.
the 18 shuttle slots. One record per slot on the OMNY soft IOC
(OMNY-SAMPLE:<slot>:USED/NAME/OWNER/STATUS/MEAS_DATE, see
``OMNYSampleStorage``); the flat keys are
``omny_samples_sample_<field>_<slot>`` with <slot> the IOC slot name
(A1..C6, P10..P101, STAGE, GRIPPER).
"""
def _val(key, default=None):
@@ -612,50 +604,22 @@ class OMNY_SampleStorage(BECWidget, QWidget):
return entry.get("value", default)
return default
def _status_str(raw) -> Optional[str]:
# 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
if val == 0:
return None
return "started" if val < 0 else "completed"
def _record(slot: str) -> tuple[bool, str, str, Optional[str]]:
used = bool(_val(f"omny_samples_sample_used_{slot}", 0))
name = _val(f"omny_samples_sample_names_{slot}", EMPTY_NAME) or EMPTY_NAME
owner = _val(f"omny_samples_sample_owners_{slot}", "") or ""
raw = _val(f"omny_samples_sample_status_{slot}")
status = str(raw) if raw is not None else ""
return (used, str(name), str(owner), status if used and status in ("started", "completed") else None)
state: dict[tuple, tuple[bool, str, str, Optional[str]]] = {}
used = bool(_val("omny_samples_sample_in_samplestage", 0))
name = _val("omny_samples_sample_in_samplestage_name", EMPTY_NAME) or EMPTY_NAME
owner = _val("omny_samples_sample_in_samplestage_owner", "") or ""
status = _status_str(_val("omny_samples_sample_in_samplestage_status", 0))
state[("stage",)] = (used, name, owner, status)
used = bool(_val("omny_samples_sample_in_gripper", 0))
name = _val("omny_samples_sample_in_gripper_name", EMPTY_NAME) or EMPTY_NAME
owner = _val("omny_samples_sample_in_gripper_owner", "") or ""
status = _status_str(_val("omny_samples_sample_in_gripper_status", 0))
state[("gripper",)] = (used, name, owner, status)
state[("stage",)] = _record("STAGE")
state[("gripper",)] = _record("GRIPPER")
for n in OMNY_FIXED_POSITIONS:
used = bool(_val(f"omny_samples_sample_placed_sample{n}", 0))
name = _val(f"omny_samples_sample_names_sample{n}", EMPTY_NAME) or EMPTY_NAME
owner = _val(f"omny_samples_sample_owners_sample{n}", "") or ""
status = _status_str(_val(f"omny_samples_sample_status_sample{n}", 0))
state[("O", n)] = (used, name, owner, status)
state[("O", n)] = _record(f"P{n}")
for letter in OMNY_SHUTTLE_LETTERS:
for n in OMNY_SHUTTLE_SLOTS:
used = bool(_val(f"omny_samples_sample_shuttle_{letter}_placed_sample{n}", 0))
name = _val(f"omny_samples_sample_shuttle_{letter}_names_sample{n}", EMPTY_NAME) or EMPTY_NAME
owner = _val(f"omny_samples_sample_shuttle_{letter}_owners_sample{n}", "") or ""
status = _status_str(_val(f"omny_samples_sample_shuttle_{letter}_status_sample{n}", 0))
state[("shuttle", letter, n)] = (used, name, owner, status)
state[("shuttle", letter, n)] = _record(f"{letter}{n}")
return state
def _parse_parking_assignment_omny(self, data: dict) -> dict[int, str]:
@@ -769,39 +733,35 @@ class OMNY_SampleStorage(BECWidget, QWidget):
(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.
Status, owner and measurement date live on the slot record itself
(OMNY-SAMPLE:<slot>:..., as FlOMNI), read fresh from the device right
before the clear; the owner is printed as account, as on FlOMNI.
"""
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)
record = self._samples.get_sample_record(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":
if not record["used"] or record["status"] not in ("started", "completed"):
return
prev_name = record["name"]
reply = QMessageBox.question(
self,
"Print label?",
f"Sample '{prev_name}' was measured ({status}), account {account}. Print a label?",
f"Sample '{prev_name}' was measured ({record['status']}) on"
f" {record['meas_date']}. 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_ACCOUNT: record["owner"],
PTOUCH_FIELD_DATE: record["meas_date"],
PTOUCH_FIELD_SAMPLENAME: prev_name,
}
# Retry loop, same reasoning as _offer_print_label_for_previous_occupant():
@@ -852,14 +812,11 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def _write_slot_omny(self, key: tuple, used: int, name: str, owner: str = "") -> bool:
"""Write (used, name, owner) to an OMNY sample-kind slot -- stage,
gripper, a fixed O position, or a shuttle slot -- mirroring the
direct-signal style of ``_write_slot()``. Unlike FlOMNI, OMNY has a
dedicated owner signal (added alongside placed/name -- see
``OMNYSampleStorage``), so name and owner are written separately,
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.
gripper, a fixed O position, or a shuttle slot -- through the device
methods, like ``_write_slot()``. Measured status is reset to "none"
on every write here (``OMNYSampleStorage.set_sample_slot()``/
``unset_sample_slot()`` write the whole record) -- a fresh name
(even a 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
@@ -873,31 +830,20 @@ class OMNY_SampleStorage(BECWidget, QWidget):
if used == 0 and kind == "shuttle":
self._offer_print_label_for_previous_occupant_omny(key)
try:
status_none = OMNY_STATUS_NONE
if kind == "stage":
self._samples.sample_in_samplestage.set(used)
self._samples.sample_in_samplestage_name.set(name)
self._samples.sample_in_samplestage_owner.set(owner)
self._samples.sample_in_samplestage_status.set(status_none)
container, n = "STAGE", 0
elif kind == "gripper":
self._samples.sample_in_gripper.set(used)
self._samples.sample_in_gripper_name.set(name)
self._samples.sample_in_gripper_owner.set(owner)
self._samples.sample_in_gripper_status.set(status_none)
container, n = "GRIPPER", 0
elif kind == "O":
_, n = key
getattr(self._samples.sample_placed, f"sample{n}").set(used)
getattr(self._samples.sample_names, f"sample{n}").set(name)
getattr(self._samples.sample_owners, f"sample{n}").set(owner)
getattr(self._samples.sample_status, f"sample{n}").set(status_none)
container, n = "O", key[1]
elif kind == "shuttle":
_, letter, n = key
getattr(getattr(self._samples, f"sample_shuttle_{letter}_placed"), f"sample{n}").set(used)
getattr(getattr(self._samples, f"sample_shuttle_{letter}_names"), f"sample{n}").set(name)
getattr(getattr(self._samples, f"sample_shuttle_{letter}_owners"), f"sample{n}").set(owner)
getattr(getattr(self._samples, f"sample_shuttle_{letter}_status"), f"sample{n}").set(status_none)
container, n = key[1], key[2]
else:
raise ValueError(f"unknown OMNY slot key {key!r}")
if used:
self._samples.set_sample_slot(container, n, name, owner)
else:
self._samples.unset_sample_slot(container, n)
self._last_state.pop(key, None)
return True
except Exception as exc:
@@ -921,10 +867,10 @@ class OMNY_SampleStorage(BECWidget, QWidget):
in-progress transfer.
"""
try:
used = 1 if letter else 0
value = letter if letter else PARKING_EMPTY_SENTINEL
getattr(self._samples.parking_placed, f"parking{slot_nr}").set(used)
getattr(self._samples.parking_names, f"parking{slot_nr}").set(value)
if letter:
self._samples.set_shuttle_slot(letter, slot_nr)
else:
self._samples.unset_shuttle_slot(slot_nr)
return True
except Exception as exc:
logger.warning(f"OMNY_SampleStorage: write of parking slot {slot_nr} failed: {exc}")
@@ -1146,9 +1092,10 @@ class OMNY_SampleStorage(BECWidget, QWidget):
"""Return the slot key that already holds `name`, if any OTHER
occupied slot of the active setup currently has this exact name, or
None. Used by action_change_sample()/action_new_sample() to enforce
that sample names are unique -- otherwise the measured-sample log
(FlOMNI, keyed by name+owner) and operators reading the storage
overview could no longer tell two same-named samples apart.
that sample names are unique -- otherwise operators reading the
storage overview (and the measurement records, scilog entries and
labels keyed by sample name) could no longer tell two same-named
samples apart.
Reads from ``self._last_state``, the same poll-refreshed snapshot
the cells themselves are painted from, rather than issuing a fresh
@@ -1168,7 +1115,7 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def action_change_sample(self, slot, current_name: str, current_owner: str = "") -> None:
"""Replace an occupied slot's sample identity (name + owner), stays
occupied. Named "change sample" rather than "rename" because
_write_sample() resets the slot's measured status/account back to
_write_sample() resets the slot's measured status/date back to
"none" -- appropriate since a new name+owner means a different
physical sample, not a correction to the existing one's record.
Shared by both setups (see ``_SlotCell``) -- both require a name and
@@ -1484,9 +1431,8 @@ class OMNY_SampleStorage(BECWidget, QWidget):
def _draw_report_cell(self, pdf, x, y, w, h, slot, entry) -> None:
"""Draw one slot's box, mirroring what ``_SlotCell.set_state()``
shows on screen: slot caption, name (or "empty"), owner and measured
marker. Shared by both setups' report builders -- OMNY entries
always carry owner="" and measured_status=None, which render as
blank without any OMNY-specific branch here.
marker. Shared by both setups' report builders (same entry tuple
for both, no setup-specific branch here).
"""
pdf.rect(x, y, w, h)
occupied, name, owner, measured_status = entry if entry else (False, EMPTY_NAME, "", None)
@@ -3050,7 +3050,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
# get_sample_name_in_samplestage device_rpc entries stuck PENDING
# after an abort left the queue paused, plus a GUI freeze while a
# scan was running -- the wrapper method itself is just
# `return str(self.sample_in_samplestage_name.get())`
# the NAME of the STAGE record, OMNY-SAMPLE:STAGE:NAME
# (omny_sample_storage.py), so reading the signal directly is
# equivalent and free of both problems. cached=True goes one step
# further: without it, DeviceBase.get() still issues a live
@@ -3058,9 +3058,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
# cached=True reads BEC's already-locally-updated status cache
# instead, no round-trip at all -- standard advice for GUI/widget
# code (see e.g. sample_storage.py's oparkz.readback.get(cached=True)).
"sample_name_getter": lambda w: w.dev.omny_samples.sample_in_samplestage_name.get(
cached=True
),
"sample_name_getter": lambda w: w.dev.omny_samples.sample_names.STAGE.get(cached=True),
"cli_hint_name": "omny",
# OMNY has three "equally spaced sub-tomograms" flavors (2/4/8
# sub-tomograms), unlike flomni's/lamni's single always-8 flavor --
+261 -477
View File
@@ -1,5 +1,33 @@
"""OMNY sample storage on the OMNY soft IOC (``OMNYtools/softioc/ioc_omny.py`` in the OMNY repo).
One record per slot, all fields autosaved by the IOC (same layout as flOMNI's
``FLOMNI-SAMPLE:``, see ``flomni_sample_storage.py``)::
OMNY-SAMPLE:<slot>:USED 0 = free, 1 = sample present
OMNY-SAMPLE:<slot>:NAME sample name (long string, up to 256 chars)
OMNY-SAMPLE:<slot>:OWNER e-account of the owner
OMNY-SAMPLE:<slot>:STATUS none / started / completed (measurement status)
OMNY-SAMPLE:<slot>:MEAS_DATE date of the measurement
<slot> (container, slot_nr of this device in brackets):
A1..A6, B1..B6, C1..C6 shuttles ("A".."C", 1-6)
P10..P14, P32..P34, P100, P101 fixed positions ("O", 10-14, 32-34, 100, 101)
STAGE sample stage (set/unset_sample_in_samplestage)
GRIPPER gripper (set/unset_sample_in_gripper)
PARK1..PARK6 parking slots: USED + NAME = shuttle letter (set_shuttle_slot)
FZP0, OSA0, OSA1 exist in the IOC, not used by the transfer yet
Replaces (2026-10-18) the classic softIoc records ``XOMNY-SAMPLE_DB_<container>:<pos>``
(VAL = placed, DESC = name) and ``...:<pos>:STATUS`` (VAL = status sign x account,
DESC = owner). The status is now a plain enum plus a date, as on flOMNI, and travels
with the sample: ``transfer_sample()`` moves all fields between slots, the gripper and
the stage.
An empty string cannot be written to the char-array NAME via Channel Access, so a
free slot carries the name ``EMPTY_NAME`` ("-").
"""
import time
from typing import Optional
from ophyd import Component as Cpt
from ophyd import Device
@@ -7,11 +35,68 @@ from ophyd import DynamicDeviceComponent as Dcpt
from ophyd import EpicsSignal
from prettytable import FRAME, PrettyTable
PV_PREFIX = "OMNY-SAMPLE:"
SHUTTLES = ("A", "B", "C")
SHUTTLE_SLOTS = range(1, 7)
FIXED_SLOTS = (10, 11, 12, 13, 14, 32, 33, 34, 100, 101)
PARKING_SLOTS = range(1, 7)
STAGE = "STAGE"
GRIPPER = "GRIPPER"
EMPTY_NAME = "-"
NO_SHUTTLE = "none"
STATUS_NONE = "none"
STATUS_STARTED = "started"
STATUS_COMPLETED = "completed"
STATUS_VALUES = (STATUS_NONE, STATUS_STARTED, STATUS_COMPLETED)
FIELDS = ("used", "name", "owner", "status", "meas_date")
# every sample slot of the IOC that this device handles, by PV slot name
SAMPLE_SLOTS = (
[f"{s}{i}" for s in SHUTTLES for i in SHUTTLE_SLOTS] + [f"P{n}" for n in FIXED_SLOTS] + [STAGE, GRIPPER]
)
class OMNYSampleStorageError(Exception):
pass
def slot_name(container: str, slot_nr: int = 0) -> str:
"""("A", 3) -> "A3", ("O", 10) -> "P10"; "STAGE"/"GRIPPER" (and a slot name
such as "A3" or "P10") are passed through."""
container = str(container)
if container.upper() in (STAGE, GRIPPER):
return container.upper()
if container in SAMPLE_SLOTS:
return container
slot_nr = int(slot_nr)
if container in SHUTTLES and slot_nr in SHUTTLE_SLOTS:
return f"{container}{slot_nr}"
if container == "O" and slot_nr in FIXED_SLOTS:
return f"P{slot_nr}"
raise OMNYSampleStorageError(
f"Invalid sample slot ({container!r}, {slot_nr}): container A/B/C with slot 1-6, "
f"O with {FIXED_SLOTS}, or STAGE/GRIPPER."
)
def _field_pv(slot: str, field: str) -> str:
return f"{PV_PREFIX}{slot}:{field.upper()}"
def _sample_signals(field: str) -> dict:
kwargs = {"auto_monitor": True}
if field != "used":
kwargs["string"] = True
return {slot: (EpicsSignal, _field_pv(slot, field), dict(kwargs)) for slot in SAMPLE_SLOTS}
def _parking_signals(field: str) -> dict:
kwargs = {"auto_monitor": True}
if field != "used":
kwargs["string"] = True
return {f"parking{i}": (EpicsSignal, _field_pv(f"PARK{i}", field), dict(kwargs)) for i in PARKING_SLOTS}
class OMNYSampleStorage(Device):
USER_ACCESS = [
"is_sample_slot_used",
@@ -26,18 +111,16 @@ class OMNYSampleStorage(Device):
"get_sample_name_in_gripper",
"get_sample_owner_in_samplestage",
"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",
"get_sample_meas_date",
"get_sample_record",
"set_sample_measured",
"transfer_sample",
"is_sample_in_samplestage",
"set_shuttle_slot",
"unset_shuttle_slot",
@@ -50,482 +133,197 @@ class OMNYSampleStorage(Device):
SUB_VALUE = "value"
_default_sub = SUB_VALUE
# 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
FALLBACK_ACCOUNT = 11111
# sample slots, attribute = PV slot name (A1 ... C6, P10 ... P101, STAGE, GRIPPER)
sample_used = Dcpt(_sample_signals("used"))
sample_names = Dcpt(_sample_signals("name"))
sample_owners = Dcpt(_sample_signals("owner"))
sample_status = Dcpt(_sample_signals("status"))
sample_meas_date = Dcpt(_sample_signals("meas_date"))
sample_shuttle_A_placed = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_A:{i}", {"auto_monitor": True}) for i in range(1, 7)
}
sample_shuttle_A_placed = Dcpt(sample_shuttle_A_placed)
sample_shuttle_B_placed = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_B:{i}", {"auto_monitor": True}) for i in range(1, 7)
}
sample_shuttle_B_placed = Dcpt(sample_shuttle_B_placed)
sample_shuttle_C_placed = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_C:{i}", {"auto_monitor": True}) for i in range(1, 7)
}
sample_shuttle_C_placed = Dcpt(sample_shuttle_C_placed)
sample_shuttle_C_placed = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_C:{i}", {"auto_monitor": True}) for i in range(1, 7)
}
sample_shuttle_C_placed = Dcpt(sample_shuttle_C_placed)
parking_placed = {
f"parking{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_parking:{i}", {"auto_monitor": True}) for i in range(1, 7)
}
parking_placed = Dcpt(parking_placed)
sample_placed = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_omny:{i}", {"auto_monitor": True})
for i in [10, 11, 12, 13, 14, 32, 33, 34, 100, 101]
}
sample_placed = Dcpt(sample_placed)
sample_shuttle_A_names = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_A:{i}.DESC", {"string": True, "auto_monitor": True})
for i in range(1, 7)
}
sample_shuttle_A_names = Dcpt(sample_shuttle_A_names)
sample_shuttle_B_names = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_B:{i}.DESC", {"string": True, "auto_monitor": True})
for i in range(1, 7)
}
sample_shuttle_B_names = Dcpt(sample_shuttle_B_names)
sample_shuttle_C_names = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_C:{i}.DESC", {"string": True, "auto_monitor": True})
for i in range(1, 7)
}
sample_shuttle_C_names = Dcpt(sample_shuttle_C_names)
parking_names = {
f"parking{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_parking:{i}.DESC", {"string": True, "auto_monitor": True})
for i in range(1, 7)
}
parking_names = Dcpt(parking_names)
sample_names = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_omny:{i}.DESC", {"string": True, "auto_monitor": True})
for i in [10, 11, 12, 13, 14, 32, 33, 34, 100, 101]
}
sample_names = Dcpt(sample_names)
# 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)
}
sample_shuttle_A_status = Dcpt(sample_shuttle_A_status)
sample_shuttle_B_status = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_B:{i}:STATUS", {"auto_monitor": True})
for i in range(1, 7)
}
sample_shuttle_B_status = Dcpt(sample_shuttle_B_status)
sample_shuttle_C_status = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_shuttle_C:{i}:STATUS", {"auto_monitor": True})
for i in range(1, 7)
}
sample_shuttle_C_status = Dcpt(sample_shuttle_C_status)
sample_status = {
f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_omny:{i}:STATUS", {"auto_monitor": True})
for i in [10, 11, 12, 13, 14, 32, 33, 34, 100, 101]
}
sample_status = Dcpt(sample_status)
sample_shuttle_A_owners = {
f"sample{i}": (
EpicsSignal,
f"XOMNY-SAMPLE_DB_shuttle_A:{i}:STATUS.DESC",
{"string": True, "auto_monitor": True},
)
for i in range(1, 7)
}
sample_shuttle_A_owners = Dcpt(sample_shuttle_A_owners)
sample_shuttle_B_owners = {
f"sample{i}": (
EpicsSignal,
f"XOMNY-SAMPLE_DB_shuttle_B:{i}:STATUS.DESC",
{"string": True, "auto_monitor": True},
)
for i in range(1, 7)
}
sample_shuttle_B_owners = Dcpt(sample_shuttle_B_owners)
sample_shuttle_C_owners = {
f"sample{i}": (
EpicsSignal,
f"XOMNY-SAMPLE_DB_shuttle_C:{i}:STATUS.DESC",
{"string": True, "auto_monitor": True},
)
for i in range(1, 7)
}
sample_shuttle_C_owners = Dcpt(sample_shuttle_C_owners)
sample_owners = {
f"sample{i}": (
EpicsSignal,
f"XOMNY-SAMPLE_DB_omny:{i}:STATUS.DESC",
{"string": True, "auto_monitor": True},
)
for i in [10, 11, 12, 13, 14, 32, 33, 34, 100, 101]
}
sample_owners = Dcpt(sample_owners)
sample_in_gripper = Cpt(
EpicsSignal, name="sample_in_gripper", read_pv="XOMNY-SAMPLE_DB_omny:110.VAL", auto_monitor=True
)
sample_in_gripper_name = Cpt(
EpicsSignal,
name="sample_in_gripper_name",
read_pv="XOMNY-SAMPLE_DB_omny:110.DESC",
string=True,
auto_monitor=True
)
sample_in_samplestage = Cpt(
EpicsSignal, name="sample_in_samplestage", read_pv="XOMNY-SAMPLE_DB_omny:0.VAL", auto_monitor=True
)
sample_in_samplestage_name = Cpt(
EpicsSignal,
name="sample_in_samplestage_name",
read_pv="XOMNY-SAMPLE_DB_omny:0.DESC",
string=True,
auto_monitor=True
)
sample_in_gripper_status = Cpt(
EpicsSignal, name="sample_in_gripper_status", read_pv="XOMNY-SAMPLE_DB_omny:110:STATUS", auto_monitor=True
)
sample_in_gripper_owner = Cpt(
EpicsSignal,
name="sample_in_gripper_owner",
read_pv="XOMNY-SAMPLE_DB_omny:110:STATUS.DESC",
string=True,
auto_monitor=True,
)
sample_in_samplestage_status = Cpt(
EpicsSignal, name="sample_in_samplestage_status", read_pv="XOMNY-SAMPLE_DB_omny:0:STATUS", auto_monitor=True
)
sample_in_samplestage_owner = Cpt(
EpicsSignal,
name="sample_in_samplestage_owner",
read_pv="XOMNY-SAMPLE_DB_omny:0:STATUS.DESC",
string=True,
auto_monitor=True,
)
# parking slots 1-6: which shuttle (letter in NAME) is docked
parking_placed = Dcpt(_parking_signals("used"))
parking_names = Dcpt(_parking_signals("name"))
def __init__(self, prefix="", *, name, **kwargs):
super().__init__(prefix, name=name, **kwargs)
self.sample_shuttle_A_placed.sample1.subscribe(self._emit_value)
self.sample_used.A1.subscribe(self._emit_value)
def _emit_value(self, **kwargs):
timestamp = kwargs.pop("timestamp", time.time())
self.wait_for_connection()
self._run_subs(sub_type=self.SUB_VALUE, timestamp=timestamp, obj=self)
@classmethod
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.
# ── slot addressing ──────────────────────────────────────────────────────
def _sig(self, field: str, slot: str) -> EpicsSignal:
group = {
"used": self.sample_used,
"name": self.sample_names,
"owner": self.sample_owners,
"status": self.sample_status,
"meas_date": self.sample_meas_date,
}[field]
return getattr(group, slot)
@staticmethod
def _check_parking(slot_nr: int) -> int:
slot_nr = int(slot_nr)
if slot_nr not in PARKING_SLOTS:
raise OMNYSampleStorageError(f"Invalid parking slot {slot_nr} (1-6).")
return slot_nr
@staticmethod
def _str(val) -> str:
return "" if val is None else str(val)
# ── write ────────────────────────────────────────────────────────────────
def _write(self, slot: str, used: int, name: str, owner: str, status: str, meas_date: str):
if status not in STATUS_VALUES:
raise OMNYSampleStorageError(f"Invalid status {status!r}, expected one of {STATUS_VALUES}.")
name = name if name else EMPTY_NAME
# name/owner/status/date first, USED last: a client watching USED sees a complete record
self._sig("name", slot).set(name).wait(timeout=5)
self._sig("owner", slot).set(owner or "").wait(timeout=5)
self._sig("status", slot).set(status).wait(timeout=5)
self._sig("meas_date", slot).set(meas_date or "").wait(timeout=5)
self._sig("used", slot).set(int(used)).wait(timeout=5)
def set_sample_slot(
self,
container: str,
slot_nr: int,
name: str,
owner: str = "",
status: str = STATUS_NONE,
meas_date: str = "",
):
"""Place a sample record in (container, slot_nr): A/B/C 1-6, O 10-14/32-34/100/101."""
self._write(slot_name(container, slot_nr), 1, name, owner, status, meas_date)
def unset_sample_slot(self, container: str, slot_nr: int):
"""Clear (container, slot_nr)."""
self._write(slot_name(container, slot_nr), 0, EMPTY_NAME, "", STATUS_NONE, "")
def set_sample_in_gripper(self, name: str, owner: str = "", status: str = STATUS_NONE, meas_date: str = ""):
self.set_sample_slot(GRIPPER, 0, name, owner, status, meas_date)
def unset_sample_in_gripper(self):
self.unset_sample_slot(GRIPPER, 0)
def set_sample_in_samplestage(
self, name: str, owner: str = "", status: str = STATUS_NONE, meas_date: str = ""
):
self.set_sample_slot(STAGE, 0, name, owner, status, meas_date)
def unset_sample_in_samplestage(self):
self.unset_sample_slot(STAGE, 0)
def set_sample_measured(self, container: str, slot_nr: int, status: str, meas_date: str, owner_if_empty: str = ""):
"""Record the measurement status/date of the sample in (container, slot_nr)
(container "STAGE" for the sample stage).
If the sample has no owner yet, `owner_if_empty` (e.g. the active
e-account) is stored as owner -- the label printed when a measured
sample is cleared uses the owner as account.
"""
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
return cls.FALLBACK_ACCOUNT
if status not in STATUS_VALUES:
raise OMNYSampleStorageError(f"Invalid status {status!r}, expected one of {STATUS_VALUES}.")
slot = slot_name(container, slot_nr)
if owner_if_empty and not self._str(self._sig("owner", slot).get()):
self._sig("owner", slot).set(owner_if_empty).wait(timeout=5)
self._sig("status", slot).set(status).wait(timeout=5)
self._sig("meas_date", slot).set(meas_date or "").wait(timeout=5)
@classmethod
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
def transfer_sample(self, from_slot, to_slot):
"""Move the complete record (name, owner, status, date) from one slot to
another and clear the source. Slots as (container, slot_nr) tuples or slot
names: e.g. transfer_sample(("A", 3), "GRIPPER"), transfer_sample("GRIPPER", "STAGE")."""
src = slot_name(*from_slot) if isinstance(from_slot, tuple) else slot_name(from_slot)
dst = slot_name(*to_slot) if isinstance(to_slot, tuple) else slot_name(to_slot)
rec = self.get_sample_record(src)
self._write(dst, 1, rec["name"], rec["owner"], rec["status"], rec["meas_date"])
self._write(src, 0, EMPTY_NAME, "", STATUS_NONE, "")
@classmethod
def _val_to_status(cls, val) -> str:
try:
v = int(round(float(val)))
except (TypeError, ValueError):
return "none"
if v == 0:
return "none"
return "started" if v < 0 else "completed"
def set_shuttle_slot(self, container: str, slot_nr: int):
"""Shuttle `container` (A/B/C) is docked at parking slot `slot_nr` (1-6)."""
slot_nr = self._check_parking(slot_nr)
getattr(self.parking_names, f"parking{slot_nr}").set(container).wait(timeout=5)
getattr(self.parking_placed, f"parking{slot_nr}").set(1).wait(timeout=5)
@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 unset_shuttle_slot(self, slot_nr: int):
slot_nr = self._check_parking(slot_nr)
getattr(self.parking_names, f"parking{slot_nr}").set(NO_SHUTTLE).wait(timeout=5)
getattr(self.parking_placed, f"parking{slot_nr}").set(0).wait(timeout=5)
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_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)
getattr(self.sample_shuttle_A_owners, f"sample{slot_nr}").set(owner)
getattr(self.sample_shuttle_A_status, f"sample{slot_nr}").set(status)
elif container == "B":
getattr(self.sample_shuttle_B_placed, f"sample{slot_nr}").set(1)
getattr(self.sample_shuttle_B_names, f"sample{slot_nr}").set(name)
getattr(self.sample_shuttle_B_owners, f"sample{slot_nr}").set(owner)
getattr(self.sample_shuttle_B_status, f"sample{slot_nr}").set(status)
elif container == "C":
getattr(self.sample_shuttle_C_placed, f"sample{slot_nr}").set(1)
getattr(self.sample_shuttle_C_names, f"sample{slot_nr}").set(name)
getattr(self.sample_shuttle_C_owners, f"sample{slot_nr}").set(owner)
getattr(self.sample_shuttle_C_status, f"sample{slot_nr}").set(status)
elif container == "O":
getattr(self.sample_placed, f"sample{slot_nr}").set(1)
getattr(self.sample_names, f"sample{slot_nr}").set(name)
getattr(self.sample_owners, f"sample{slot_nr}").set(owner)
getattr(self.sample_status, f"sample{slot_nr}").set(status)
def unset_sample_slot(self, container: str, slot_nr: int) -> bool:
if slot_nr > 20:
raise OMNYSampleStorageError(f"Invalid slot number {slot_nr}.")
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("-")
getattr(self.sample_shuttle_A_owners, f"sample{slot_nr}").set("")
getattr(self.sample_shuttle_A_status, f"sample{slot_nr}").set(status)
elif container == "B":
getattr(self.sample_shuttle_B_placed, f"sample{slot_nr}").set(0)
getattr(self.sample_shuttle_B_names, f"sample{slot_nr}").set("-")
getattr(self.sample_shuttle_B_owners, f"sample{slot_nr}").set("")
getattr(self.sample_shuttle_B_status, f"sample{slot_nr}").set(status)
elif container == "C":
getattr(self.sample_shuttle_C_placed, f"sample{slot_nr}").set(0)
getattr(self.sample_shuttle_C_names, f"sample{slot_nr}").set("-")
getattr(self.sample_shuttle_C_owners, f"sample{slot_nr}").set("")
getattr(self.sample_shuttle_C_status, f"sample{slot_nr}").set(status)
elif container == "O":
getattr(self.sample_placed, f"sample{slot_nr}").set(0)
getattr(self.sample_names, f"sample{slot_nr}").set("-")
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, 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":
getattr(self.sample_shuttle_B_status, f"sample{slot_nr}").set(val)
elif container == "C":
getattr(self.sample_shuttle_C_status, f"sample{slot_nr}").set(val)
elif container == "O":
getattr(self.sample_status, f"sample{slot_nr}").set(val)
def get_sample_status(self, container: str, slot_nr: int) -> str:
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_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":
val = getattr(self.sample_shuttle_A_owners, f"sample{slot_nr}").get()
elif container == "B":
val = getattr(self.sample_shuttle_B_owners, f"sample{slot_nr}").get()
elif container == "C":
val = getattr(self.sample_shuttle_C_owners, f"sample{slot_nr}").get()
elif container == "O":
val = getattr(self.sample_owners, f"sample{slot_nr}").get()
else:
return ""
return str(val) if val else ""
def get_sample_name_and_owner(self, container: str, slot_nr: int) -> tuple:
return self.get_sample_name(container, slot_nr), self.get_sample_owner(container, slot_nr)
def set_shuttle_slot(self, container: str, slot_nr: int) -> bool:
if slot_nr > 6:
raise OMNYSampleStorageError(f"Invalid slot number {slot_nr}.")
getattr(self.parking_placed, f"parking{slot_nr}").set(1)
getattr(self.parking_names, f"parking{slot_nr}").set(container)
def unset_shuttle_slot(self, slot_nr: int) -> bool:
if slot_nr > 6:
raise OMNYSampleStorageError(f"Invalid slot number {slot_nr}.")
getattr(self.parking_placed, f"parking{slot_nr}").set(0)
getattr(self.parking_names, f"parking{slot_nr}").set("none")
def set_sample_in_gripper(self, name: str, owner: str = "") -> bool:
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_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_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_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_to_val("none"))
def get_sample_owner_in_gripper(self) -> str:
val = self.sample_in_gripper_owner.get()
return str(val) if val else ""
def get_sample_owner_in_samplestage(self) -> str:
val = self.sample_in_samplestage_owner.get()
return str(val) if val else ""
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._val_to_status(self.sample_in_gripper_status.get())
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._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":
val = getattr(self.sample_shuttle_A_placed, f"sample{slot_nr}").get()
if container == "B":
val = getattr(self.sample_shuttle_B_placed, f"sample{slot_nr}").get()
if container == "C":
val = getattr(self.sample_shuttle_C_placed, f"sample{slot_nr}").get()
elif container == "O":
val = getattr(self.sample_placed, f"sample{slot_nr}").get()
return bool(val)
def is_shuttle_slot_used(self, slot_nr: int) -> bool:
val = getattr(self.parking_placed, f"parking{slot_nr}").get()
return bool(val)
# ── read ─────────────────────────────────────────────────────────────────
def is_sample_slot_used(self, container: str, slot_nr: int = 0) -> bool:
return bool(self._sig("used", slot_name(container, slot_nr)).get())
def is_sample_in_gripper(self) -> bool:
val = self.sample_in_gripper.get()
return bool(val)
return self.is_sample_slot_used(GRIPPER)
def is_sample_in_samplestage(self) -> bool:
val = self.sample_in_samplestage.get()
return bool(val)
return self.is_sample_slot_used(STAGE)
def get_sample_name(self, container, slot_nr) -> str:
if container == "A":
val = getattr(self.sample_shuttle_A_names, f"sample{slot_nr}").get()
elif container == "B":
val = getattr(self.sample_shuttle_B_names, f"sample{slot_nr}").get()
elif container == "C":
val = getattr(self.sample_shuttle_C_names, f"sample{slot_nr}").get()
elif container == "O":
val = getattr(self.sample_names, f"sample{slot_nr}").get()
else:
val = "unknown container"
return str(val)
def get_sample_name(self, container: str, slot_nr: int = 0) -> str:
name = self._str(self._sig("name", slot_name(container, slot_nr)).get())
return name if name else EMPTY_NAME
def get_shuttle_name_slot(self, slot_nr: int) -> str:
val = getattr(self.parking_names, f"parking{slot_nr}").get()
return str(val)
def get_sample_owner(self, container: str, slot_nr: int = 0) -> str:
return self._str(self._sig("owner", slot_name(container, slot_nr)).get())
def get_sample_name_and_owner(self, container: str, slot_nr: int = 0) -> tuple:
return self.get_sample_name(container, slot_nr), self.get_sample_owner(container, slot_nr)
def get_sample_status(self, container: str, slot_nr: int = 0) -> str:
status = self._str(self._sig("status", slot_name(container, slot_nr)).get())
return status if status in STATUS_VALUES else STATUS_NONE
def get_sample_meas_date(self, container: str, slot_nr: int = 0) -> str:
return self._str(self._sig("meas_date", slot_name(container, slot_nr)).get())
def get_sample_record(self, container: str, slot_nr: int = 0) -> dict:
return {
"used": self.is_sample_slot_used(container, slot_nr),
"name": self.get_sample_name(container, slot_nr),
"owner": self.get_sample_owner(container, slot_nr),
"status": self.get_sample_status(container, slot_nr),
"meas_date": self.get_sample_meas_date(container, slot_nr),
}
def get_sample_name_in_gripper(self) -> str:
val = self.sample_in_gripper_name.get()
return str(val)
return self.get_sample_name(GRIPPER)
def get_sample_name_in_samplestage(self) -> str:
val = self.sample_in_samplestage_name.get()
return str(val)
return self.get_sample_name(STAGE)
def get_sample_owner_in_gripper(self) -> str:
return self.get_sample_owner(GRIPPER)
def get_sample_owner_in_samplestage(self) -> str:
return self.get_sample_owner(STAGE)
def get_sample_status_in_gripper(self) -> str:
return self.get_sample_status(GRIPPER)
def get_sample_status_in_samplestage(self) -> str:
return self.get_sample_status(STAGE)
def is_shuttle_slot_used(self, slot_nr: int) -> bool:
return bool(getattr(self.parking_placed, f"parking{self._check_parking(slot_nr)}").get())
def get_shuttle_name_slot(self, slot_nr: int) -> str:
return self._str(getattr(self.parking_names, f"parking{self._check_parking(slot_nr)}").get())
def search_shuttle_in_slot(self, shuttle: str) -> int:
returnvalue = 0
for i in range(1, 7):
for i in PARKING_SLOTS:
if self.get_shuttle_name_slot(i) == shuttle:
returnvalue = i
return returnvalue
def _describe_slot(self, container: str, slot_nr: int) -> str:
def _describe_slot(self, container: str, slot_nr: int = 0) -> str:
"""Sample name, plus owner/measured-status suffix when occupied --
e.g. "sample1 (bob) ◐ started". Used by show_all() so the
e.g. "sample1 (e12345) ◐ started 2026-10". Used by show_all() so the
overview surfaces owner+status without changing get_sample_name()'s
own plain-name contract (still used elsewhere, e.g. transfers)."""
name = self.get_sample_name(container, slot_nr)
@@ -533,13 +331,12 @@ class OMNYSampleStorage(Device):
return name
owner = self.get_sample_owner(container, slot_nr)
status = self.get_sample_status(container, slot_nr)
date = self.get_sample_meas_date(container, slot_nr)
suffix = f" ({owner})" if owner else ""
if status == "started":
account = self.get_sample_account(container, slot_nr)
suffix += f" ◐ started (acc {account})"
elif status == "completed":
account = self.get_sample_account(container, slot_nr)
suffix += f" ✓ completed (acc {account})"
if status == STATUS_STARTED:
suffix += " ◐ started" + (f" {date}" if date else "")
elif status == STATUS_COMPLETED:
suffix += " ✓ completed" + (f" {date}" if date else "")
return name + suffix
def show_all(self):
@@ -547,14 +344,13 @@ class OMNYSampleStorage(Device):
red = "\x1b[91m"
green = "\x1b[92m"
white = "\x1b[0m"
for ch in ["A", "B", "C"]:
for ch in SHUTTLES:
t.clear()
shuttle_in_slot = self.search_shuttle_in_slot(ch)
if shuttle_in_slot > 0:
t.title = green + "Shuttle " + ch + " in OMNY slot " + str(shuttle_in_slot) + white
else:
t.title = red + "Shuttle " + ch + white
field_names = [""]
for ax in [1, 3, 5]:
row = []
row.extend([self._describe_slot(ch, ax)])
@@ -567,28 +363,18 @@ class OMNYSampleStorage(Device):
print(t)
if self.is_sample_in_samplestage():
name = self.get_sample_name_in_samplestage()
owner = self.get_sample_owner_in_samplestage()
status = self.get_sample_status_in_samplestage()
suffix = f" ({owner})" if owner else ""
if status == "started":
suffix += f" ◐ started (acc {self.get_sample_account_in_samplestage()})"
elif status == "completed":
suffix += f" ✓ completed (acc {self.get_sample_account_in_samplestage()})"
print(f"\n\n Sample stage: {name}{suffix}")
print(f"\n\n Sample stage: {self._describe_slot(STAGE)}")
else:
print(f"\n\n Sample stage: no sample")
if self.is_sample_in_gripper():
name = self.get_sample_name_in_gripper()
owner = self.get_sample_owner_in_gripper()
print(f"\n Gripper: {name}" + (f" ({owner})" if owner else "") + "\n")
print(f"\n Gripper: {self._describe_slot(GRIPPER)}\n")
else:
print(f"\n Gripper: no sample\n")
t.clear()
t.title = "Fixed positions in OMNY"
for i in [10, 11, 12, 13, 14, 32, 33, 34, 100, 101]:
for i in FIXED_SLOTS:
row = []
row.extend([f"Position {i:3d}"])
if self.is_sample_slot_used("O", i):
@@ -608,12 +394,10 @@ 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',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.get_sample_record('system',position)")
print(" dev.omny_samples.set_sample_measured('system',position,'status','YYYY-MM')")
print(" status is one of 'none', 'started', 'completed'; system 'STAGE' for the sample stage")
print(" dev.omny_samples.transfer_sample(('A',3), 'GRIPPER') (name, owner, status move along)")
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)")
+10 -48
View File
@@ -28,7 +28,11 @@ from ophyd_devices.tests.utils import patched_device
from csaxs_bec.devices.omny.galil.ogalil_ophyd import OMNYGalilMotor
from csaxs_bec.devices.omny.omny_dewar import OMNYDewar
from csaxs_bec.devices.omny.omny_sample_storage import OMNYSampleStorage
from csaxs_bec.devices.omny.omny_sample_storage import (
PARKING_SLOTS,
SAMPLE_SLOTS,
OMNYSampleStorage,
)
from csaxs_bec.devices.omny.omny_temperatures import OMNYTemperatures
from csaxs_bec.devices.omny.rt.rt_omny_ophyd import RtOMNYFlyer, RtOMNYMotor
from csaxs_bec.devices.sim.sim_galil import SimGalilSocket, SimGalilState, _sim_state_kwargs
@@ -262,53 +266,11 @@ class SimOMNYSampleStorage(OMNYSampleStorage):
# real backend's own empty sentinels ("-" for sample slots, "none"
# for parking) -- explicitly unset every slot first, the same way
# SimFlomniSampleStorage does, so untouched slots read back as
# genuinely empty instead of a stray "0" name/shuttle-letter.
#
# Writes go directly to the Dcpt signals rather than through
# unset_sample_slot()/set_sample_slot() -- those reject any
# slot_nr > 20 (a pre-existing bug: it's copied from Flomni's
# 0-20-only range check, but OMNY's own "O" fixed positions
# legitimately include 32-34/100/101), which would raise here
# for exactly the positions this needs to clear.
# Same clearing for the owner/status signals added alongside
# placed/names -- untouched positions should read owner="" and
# status="none", not the mock-PV "0" default (an invalid status
# string, would fail _status_val_to_str's lookup silently back
# to "none" anyway, but owner would show a stray "0").
for shuttle_placed, shuttle_names, shuttle_owners, shuttle_status in (
(
storage.sample_shuttle_A_placed,
storage.sample_shuttle_A_names,
storage.sample_shuttle_A_owners,
storage.sample_shuttle_A_status,
),
(
storage.sample_shuttle_B_placed,
storage.sample_shuttle_B_names,
storage.sample_shuttle_B_owners,
storage.sample_shuttle_B_status,
),
(
storage.sample_shuttle_C_placed,
storage.sample_shuttle_C_names,
storage.sample_shuttle_C_owners,
storage.sample_shuttle_C_status,
),
):
for slot_nr in shuttle_placed.component_names:
getattr(shuttle_placed, slot_nr).set(0)
getattr(shuttle_names, slot_nr).set("-")
getattr(shuttle_owners, slot_nr).set("")
getattr(shuttle_status, slot_nr).set(OMNYSampleStorage.STATUS_NONE)
for slot_nr in storage.sample_placed.component_names:
getattr(storage.sample_placed, slot_nr).set(0)
getattr(storage.sample_names, slot_nr).set("-")
getattr(storage.sample_owners, slot_nr).set("")
getattr(storage.sample_status, slot_nr).set(OMNYSampleStorage.STATUS_NONE)
for slot_nr in storage.parking_placed.component_names:
getattr(storage.parking_placed, slot_nr).set(0)
getattr(storage.parking_names, slot_nr).set("none")
storage.unset_sample_in_gripper()
# genuinely empty (owner "", status "none") instead of a stray "0".
for slot in SAMPLE_SLOTS:
storage.unset_sample_slot(slot, 0)
for slot_nr in PARKING_SLOTS:
storage.unset_shuttle_slot(slot_nr)
storage.set_sample_in_samplestage("sim_stage_sample")
for slot_nr, name in samples.items():
storage.set_sample_slot("A", int(slot_nr), str(name))
+5 -4
View File
@@ -89,7 +89,7 @@ Day to day, sample storage is viewed and adjusted through the __OMNY Sample Stor
The widget shows every position (shuttle pins A/B/C, fixed "O" positions, the gripper, and the sample stage) with its occupied state, sample name, owner, and measured status (unmeasured / ◐ started / ✓ completed). The parking-carousel view also shows which shuttle is in which parking slot and marks the currently __ACTIVE__ one (oparkz is physically sitting there, ready for transfer).
Right-click a slot for:
- __Change sample…__ — set a new name and owner for an occupied slot (replaces the sample record, including resetting its measured status back to unmeasured — use this when a physically different sample is now there, not to correct a typo in the current one's account).
- __Change sample…__ — set a new name and owner for an occupied slot (replaces the sample record, including resetting its measured status back to unmeasured — use this when a physically different sample is now there, not to correct a typo in the current one's record).
- __Clear (set empty)__ — mark a slot empty after physically removing the sample. Only offered for shuttle slots, since only samples in a shuttle can leave vacuum: this also offers to print a P-touch label for the sample that was just removed.
- __New sample…__ — occupy an empty slot with a name and owner.
- __Set parking slot…__ / __Clear parking assignment__ (shuttle labels) — record which parking slot a shuttle occupies; a slot already held by a different shuttle is rejected, clear it there first.
@@ -145,10 +145,11 @@ The shuttles are mounted in the parking station. Typically oparkz slot 3 is used
`dev.omny_samples.set_shuttle_slot(container, slot_nr)`
Here is an _example_: dev.omny_samples.set_shuttle_slot('A',2)
Measured status/account (normally set automatically during a tomographic scan) can be read and, if really needed, corrected manually via the `omny.otransfer_*` wrappers, e.g.:
Measured status and date (set automatically when a tomographic scan starts and finishes: "started"/"completed" with the month) travel with the sample on every transfer. They can be read and, if really needed, corrected manually:
`dev.omny_samples.get_sample_record('system', position)` returns used/name/owner/status/meas_date (`'STAGE'`/`'GRIPPER'` as system for the sample stage and the gripper)
`dev.omny_samples.set_sample_measured('system', position, status, 'YYYY-MM')` (`status` is one of `"none"`/`"started"`/`"completed"`; also as `omny.otransfer_set_sample_measured`)
`omny.otransfer_get_sample_status('system', position)` / `otransfer_get_sample_status_in_gripper()` / `otransfer_get_sample_status_in_samplestage()`
`omny.otransfer_set_sample_status('system', position, status, account)` (`status` is one of `"none"`/`"started"`/`"completed"`) and the matching `_in_gripper`/`_in_samplestage` variants
`omny.otransfer_get_sample_account(...)` and its `_in_gripper`/`_in_samplestage` variants read back the account tied to a "started"/"completed" status.
Each position is one record on the OMNY soft IOC, `OMNY-SAMPLE:<slot>:USED/NAME/OWNER/STATUS/MEAS_DATE` (slots `A1`…`C6`, `P10`…`P101`, `STAGE`, `GRIPPER`, `PARK1`…`PARK6`).
### Status of OMNY
@@ -301,7 +301,10 @@ def test_tomo_parameters_forces_type_1_for_non_superuser(monkeypatch, fake_bec):
omny.tomo_angle_stepsize = 10.0
omny.tomo_type = 1
fake_dev = types.SimpleNamespace(
omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
omny_samples=types.SimpleNamespace(
get_sample_name_in_samplestage=lambda: "test_sample",
set_sample_measured=lambda *a, **k: None,
)
)
monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
omny.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: False)
@@ -335,7 +338,10 @@ def test_tomo_parameters_allows_superuser_to_unlock_8_subtomo_mode(monkeypatch,
omny.tomo_angle_stepsize = 10.0
omny.tomo_type = 1
fake_dev = types.SimpleNamespace(
omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
omny_samples=types.SimpleNamespace(
get_sample_name_in_samplestage=lambda: "test_sample",
set_sample_measured=lambda *a, **k: None,
)
)
monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
omny.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: False)
@@ -371,7 +377,10 @@ def test_tomo_parameters_clamps_type_3_step_size_to_max_2_5_degrees(monkeypatch,
omny.tomo_angle_stepsize = 10.0
omny.tomo_type = 1
fake_dev = types.SimpleNamespace(
omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
omny_samples=types.SimpleNamespace(
get_sample_name_in_samplestage=lambda: "test_sample",
set_sample_measured=lambda *a, **k: None,
)
)
monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
omny.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: False)
@@ -413,7 +422,10 @@ def test_tomo_parameters_warns_when_fermat_positions_below_minimum(monkeypatch,
omny.tomo_angle_stepsize = 10.0
omny.tomo_type = 1
fake_dev = types.SimpleNamespace(
omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
omny_samples=types.SimpleNamespace(
get_sample_name_in_samplestage=lambda: "test_sample",
set_sample_measured=lambda *a, **k: None,
)
)
monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
omny.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: True) # accept as-is, no edits
@@ -440,7 +452,10 @@ def test_tomo_parameters_no_warning_when_fermat_positions_sufficient(monkeypatch
omny.tomo_angle_stepsize = 10.0
omny.tomo_type = 1
fake_dev = types.SimpleNamespace(
omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
omny_samples=types.SimpleNamespace(
get_sample_name_in_samplestage=lambda: "test_sample",
set_sample_measured=lambda *a, **k: None,
)
)
monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
omny.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: True)
@@ -9,6 +9,8 @@ grid: OMNY has three "equally spaced sub-tomograms" flavors (tomo_type
N*n_subtomos total, where n_subtomos = {1: 2, 4: 4, 5: 8}[tomo_type].
"""
import types
import pytest
from csaxs_bec.bec_ipython_client.plugins.omny.omny import OMNY
@@ -245,7 +247,7 @@ def test_omny_sample_name_getter_reads_signal_not_rpc_method():
class _FakeOmnySamples:
def __init__(self):
self.sample_in_samplestage_name = _FakeSignal("my_sample")
self.sample_names = types.SimpleNamespace(STAGE=_FakeSignal("my_sample"))
def get_sample_name_in_samplestage(self):
raise AssertionError(
@@ -259,7 +261,7 @@ def test_omny_sample_name_getter_reads_signal_not_rpc_method():
class _FakeWidget:
dev = _FakeDev()
signal = _FakeDev.omny_samples.sample_in_samplestage_name
signal = _FakeDev.omny_samples.sample_names.STAGE
result = SETUP_PROFILES["omny"]["sample_name_getter"](_FakeWidget())
assert result == "my_sample"
@@ -45,7 +45,10 @@ def make_omny(fake_client=None):
# real BEC session only) -- stand in a fake so tomo_queue_execute()'s
# confirmation prompt (which reads self.sample_name) doesn't NameError.
omny_module.dev = types.SimpleNamespace(
omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
omny_samples=types.SimpleNamespace(
get_sample_name_in_samplestage=lambda: "test_sample",
set_sample_measured=lambda *a, **k: None,
)
)
obj = object.__new__(OMNY)
@@ -57,7 +57,10 @@ def make_omny_for_tomo_scan(monkeypatch, tomo_angle_stepsize: float, active_acco
# real BEC session only) -- stand in a fake so tomo_scan()'s sample-
# database registration (which reads self.sample_name) doesn't NameError.
omny_module.dev = types.SimpleNamespace(
omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample")
omny_samples=types.SimpleNamespace(
get_sample_name_in_samplestage=lambda: "test_sample",
set_sample_measured=lambda *a, **k: None,
)
)
obj = object.__new__(OMNY)
@@ -525,7 +528,10 @@ def test_write_tomo_scan_number_creates_missing_directory(monkeypatch, tmp_path)
omny.tomo_id = 1
fake_dev = types.SimpleNamespace(
osamroy=types.SimpleNamespace(read=lambda: {"osamroy": {"value": 12.5}}),
omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample"),
omny_samples=types.SimpleNamespace(
get_sample_name_in_samplestage=lambda: "test_sample",
set_sample_measured=lambda *a, **k: None,
),
)
monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
+77 -34
View File
@@ -1,47 +1,90 @@
"""OMNYSampleStorage on the OMNY soft IOC records (OMNY-SAMPLE:<slot>:USED/NAME/OWNER/STATUS/MEAS_DATE)."""
import pytest
from csaxs_bec.devices.omny.omny_sample_storage import OMNYSampleStorage, OMNYSampleStorageError
from csaxs_bec.devices.omny.omny_sample_storage import (
SAMPLE_SLOTS,
OMNYSampleStorage,
OMNYSampleStorageError,
slot_name,
)
from csaxs_bec.devices.sim.sim_omny import SimOMNYSampleStorage
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.fixture
def storage():
dev = SimOMNYSampleStorage(name="omny_samples", sim_samples={})
try:
yield dev
finally:
dev.destroy()
@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(
"container, slot_nr, expected",
[("A", 1, "A1"), ("C", 6, "C6"), ("O", 10, "P10"), ("O", 101, "P101"), ("STAGE", 0, "STAGE"),
("gripper", 0, "GRIPPER"), ("B4", 0, "B4"), ("P32", 0, "P32")],
)
def test_slot_name(container, slot_nr, expected):
assert slot_name(container, slot_nr) == expected
@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():
@pytest.mark.parametrize("container, slot_nr", [("A", 0), ("A", 7), ("O", 15), ("O", 1), ("D", 1)])
def test_slot_name_rejects_unknown_slots(container, slot_nr):
with pytest.raises(OMNYSampleStorageError):
OMNYSampleStorage._status_to_val("bogus")
slot_name(container, slot_nr)
@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"
def test_pv_names_match_the_ioc():
assert OMNYSampleStorage.sample_used.defn["A1"][1] == "OMNY-SAMPLE:A1:USED"
assert OMNYSampleStorage.sample_names.defn["P100"][1] == "OMNY-SAMPLE:P100:NAME"
assert OMNYSampleStorage.sample_status.defn["STAGE"][1] == "OMNY-SAMPLE:STAGE:STATUS"
assert OMNYSampleStorage.sample_meas_date.defn["GRIPPER"][1] == "OMNY-SAMPLE:GRIPPER:MEAS_DATE"
assert OMNYSampleStorage.parking_names.defn["parking3"][1] == "OMNY-SAMPLE:PARK3:NAME"
assert len(SAMPLE_SLOTS) == 18 + 10 + 2
@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
def test_set_and_unset_slot(storage):
storage.set_sample_slot("O", 32, "pin1", "e12345")
assert storage.get_sample_record("O", 32) == {
"used": True, "name": "pin1", "owner": "e12345", "status": "none", "meas_date": ""}
storage.unset_sample_slot("O", 32)
rec = storage.get_sample_record("O", 32)
assert rec["used"] is False and rec["name"] == "-" and rec["owner"] == ""
def test_status_travels_with_the_sample(storage):
storage.set_sample_slot("B", 2, "pin2", "e11111")
storage.set_sample_measured("B", 2, "completed", "2026-10")
storage.transfer_sample(("B", 2), "GRIPPER")
storage.transfer_sample("GRIPPER", "STAGE")
assert not storage.is_sample_slot_used("B", 2)
assert not storage.is_sample_in_gripper()
assert storage.get_sample_record("STAGE") == {
"used": True, "name": "pin2", "owner": "e11111", "status": "completed", "meas_date": "2026-10"}
def test_set_sample_measured_owner_if_empty(storage):
storage.set_sample_in_samplestage("pin3")
storage.set_sample_measured("STAGE", 0, "started", "2026-10", owner_if_empty="e22222")
assert storage.get_sample_owner_in_samplestage() == "e22222"
assert storage.get_sample_status_in_samplestage() == "started"
# an existing owner is kept
storage.set_sample_measured("STAGE", 0, "completed", "2026-10", owner_if_empty="e33333")
assert storage.get_sample_owner_in_samplestage() == "e22222"
def test_invalid_status_raises(storage):
storage.set_sample_slot("A", 1, "pin4")
with pytest.raises(OMNYSampleStorageError):
storage.set_sample_measured("A", 1, "bogus", "")
def test_parking(storage):
storage.set_shuttle_slot("C", 2)
assert storage.is_shuttle_slot_used(2) and storage.get_shuttle_name_slot(2) == "C"
assert storage.search_shuttle_in_slot("C") == 2
storage.unset_shuttle_slot(2)
assert not storage.is_shuttle_slot_used(2) and storage.get_shuttle_name_slot(2) == "none"
with pytest.raises(OMNYSampleStorageError):
storage.set_shuttle_slot("A", 7)
@@ -29,3 +29,10 @@ def test_default_samples_still_seeded_in_shuttle_a(storage):
assert storage.get_sample_name("A", 1) == "sim_sample_1"
assert storage.get_sample_name("A", 2) == "sim_sample_2"
assert storage.get_sample_name("A", 3) == "sim_sample_3"
def test_untouched_slots_read_empty(storage):
rec = storage.get_sample_record("O", 100)
assert rec == {"used": False, "name": "-", "owner": "", "status": "none", "meas_date": ""}
assert storage.get_sample_name_in_samplestage() == "sim_stage_sample"
assert not storage.is_sample_in_gripper()