diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md index ad400426..651527d7 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md @@ -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::...` since 2026-10-17, `OMNY-SAMPLE::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 diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py index a2664d3b..16b67727 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py @@ -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") diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py index 4e6bbdd0..369397e6 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py @@ -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.") diff --git a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py index 90e8517f..99f86b7a 100644 --- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py +++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py @@ -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::... 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::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::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::..., 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::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::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::USED/NAME/OWNER/STATUS/MEAS_DATE, see + ``OMNYSampleStorage``); the flat keys are + ``omny_samples_sample__`` with 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::..., 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) diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index 7bc355c2..05796e04 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -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 -- diff --git a/csaxs_bec/devices/omny/omny_sample_storage.py b/csaxs_bec/devices/omny/omny_sample_storage.py index 362fc537..8cf31d0a 100644 --- a/csaxs_bec/devices/omny/omny_sample_storage.py +++ b/csaxs_bec/devices/omny/omny_sample_storage.py @@ -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::USED 0 = free, 1 = sample present + OMNY-SAMPLE::NAME sample name (long string, up to 256 chars) + OMNY-SAMPLE::OWNER e-account of the owner + OMNY-SAMPLE::STATUS none / started / completed (measurement status) + OMNY-SAMPLE::MEAS_DATE date of the measurement + + (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_:`` +(VAL = placed, DESC = name) and ``...::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)") diff --git a/csaxs_bec/devices/sim/sim_omny.py b/csaxs_bec/devices/sim/sim_omny.py index 4f91b56d..458d3f17 100644 --- a/csaxs_bec/devices/sim/sim_omny.py +++ b/csaxs_bec/devices/sim/sim_omny.py @@ -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)) diff --git a/docs/user/ptychography/omny.md b/docs/user/ptychography/omny.md index 95ac8743..dcb17b4a 100644 --- a/docs/user/ptychography/omny.md +++ b/docs/user/ptychography/omny.md @@ -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::USED/NAME/OWNER/STATUS/MEAS_DATE` (slots `A1`…`C6`, `P10`…`P101`, `STAGE`, `GRIPPER`, `PARK1`…`PARK6`). ### Status of OMNY diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_angles.py b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py index a3e1a411..d295853e 100644 --- a/tests/tests_bec_ipython_client/test_omny_tomo_angles.py +++ b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py @@ -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) diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py index f5b2796e..e6cd8a25 100644 --- a/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py +++ b/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py @@ -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" diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_queue.py b/tests/tests_bec_ipython_client/test_omny_tomo_queue.py index 736a54b2..11284244 100644 --- a/tests/tests_bec_ipython_client/test_omny_tomo_queue.py +++ b/tests/tests_bec_ipython_client/test_omny_tomo_queue.py @@ -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) diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py index 4be38cca..15213c57 100644 --- a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py +++ b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py @@ -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) diff --git a/tests/tests_devices/test_omny_sample_storage.py b/tests/tests_devices/test_omny_sample_storage.py index 71a730e2..1b3ec4c9 100644 --- a/tests/tests_devices/test_omny_sample_storage.py +++ b/tests/tests_devices/test_omny_sample_storage.py @@ -1,47 +1,90 @@ +"""OMNYSampleStorage on the OMNY soft IOC records (OMNY-SAMPLE::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) diff --git a/tests/tests_devices/test_sim_omny_sample_storage_defaults.py b/tests/tests_devices/test_sim_omny_sample_storage_defaults.py index 9aac632f..66c0f910 100644 --- a/tests/tests_devices/test_sim_omny_sample_storage_defaults.py +++ b/tests/tests_devices/test_sim_omny_sample_storage_defaults.py @@ -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()