diff --git a/.gitignore b/.gitignore index f4c73aad..35329d5d 100644 --- a/.gitignore +++ b/.gitignore @@ -155,7 +155,7 @@ venv.bak/ # Rope project settings .ropeproject -# mkdocs documentation +# Zensical documentation /site # mypy @@ -178,3 +178,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Local documentation tooling +.venv-docs/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 76640b7e..aeb70c3b 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,27 +1,14 @@ -# .readthedocs.yaml -# Read the Docs configuration file -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details - version: 2 -# Set the version of Python and other tools you might need build: os: ubuntu-24.04 tools: python: "3.12" jobs: - pre_install: - - pip install . - -# Build documentation in the docs/ directory with Sphinx -sphinx: - configuration: docs/conf.py - -# If using Sphinx, optionally build your docs in additional formats such as PDF -# formats: -# - pdf - -# Optionally declare the Python requirements required to build your docs -python: - install: - - requirements: docs/requirements.txt + install: + - python -m pip install -e '.[docs]' + build: + html: + - python -m zensical build --clean + - mkdir -p "$READTHEDOCS_OUTPUT/html" + - cp -a site/. "$READTHEDOCS_OUTPUT/html/" diff --git a/AGENTS_mirko.md b/AGENTS_mirko.md new file mode 100644 index 00000000..36ad2608 --- /dev/null +++ b/AGENTS_mirko.md @@ -0,0 +1,62 @@ +# Personal notes — mirko + +Personal working notes for Claude Code sessions in this repo, for my own use across machines. +Not project-wide guidance — nothing here applies to other contributors, and nothing else in +this repo references or enforces it. + +## Repo ownership boundaries + +Root causes traced into BEC core (`bec_lib`/`bec_server`/`bec_ipython_client`, the `bec` repo) +or into core `bec_widgets` are not mine to fix here — those are owned by their respective core +teams. When an investigation in `csaxs_bec` turns up a bug whose fix belongs in one of those +repos, write it up (a short report or plan) instead of implementing/committing it there. + +Fixes that land in `csaxs_bec` itself (beamline plugin code, widgets, device configs, scans) +are mine to implement, commit, and push directly. + +## Where notes go + +Never write plans, TODOs or notes to Claude's local memory / local plan files (they are +machine-local and invisible on other machines). Put them in this file, or in a markdown file in +the repo when I ask for one. + +## TODO + +- 2026-09-21: port "always define `tomo_alignment_fit`" from Flomni to OMNY. Flomni is done + (commit `5b688df`): `reset_tomo_alignment_fit()` writes zeros instead of deleting the global + var, and `ensure_tomo_alignment_fit()` (called from `Flomni.__init__`) creates the zero fit + only if missing, so manual tweaks like `fit[0][2] += 5.0` work. OMNY + (`omny/omny_alignment_mixin.py`) still deletes the var and never defines it at startup; nothing + in `omny/` calls the reset yet, so decide where OMNY should reset it. LamNI does not use it. + Details and steps: `csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_alignment_fit_always_defined.md`. + +- 2026-09-20: `flomni_fermat_scan` hung on `pre_scan_all_devices()` after positions were + already sent to the rt controller (scans 11998, 12000 in `/var/log/bec/ScanServer.log` / + `DeviceServer.log`). Positions/PID-correction step (`prepare_setup_part_2()`) completed + fine; `kickoff()`/`start_scan()` (the actual rt-side trigger, logged as `Read N out of 106`) + was never reached because the joined `pre_scan_all_devices()` wait never returned. No error, + no timeout — DeviceServer just went silent until manually aborted. + Most likely culprit: `omny_panda_continuous` (PandaBox) — `on_pre_scan()` in + `ophyd_devices/devices/panda_box/panda_box.py:696` arms the PCAP block and returns a status + that only resolves on a `PandaState.READY`/`FRAME`/`END` event, with **no timeout**. Logs + show `Cannot emit async signal omny_panda_continuous_data ... with status aborted` spamming + throughout the hang window, consistent with the box being left in a stuck/unarmed state after + the preceding rapid interlock-triggered abort/retry cycle (11991–12001, ~11 attempts in 5 + minutes). + This traces into `ophyd_devices`, not `csaxs_bec` — per the ownership note above, not mine to + fix directly here. Follow-ups to consider: (a) file/flag this with whoever owns + `ophyd_devices` (add a timeout + explicit failure path to `PandaBox.on_pre_scan`'s status + callback so a stuck arm surfaces as an error instead of hanging forever), (b) check whether + `on_unstage`/`_reset_panda()` is reliably called on scan abort so the box doesn't carry a + stale state into the next attempt. + + **Status 2026-09-20 (csaxs_bec side done):** `PandaBoxOMNY.on_pre_scan` + (`csaxs_bec/devices/panda_box/panda_box_omny.py`) no longer waits for READY forever. Each + arming attempt waits `pre_scan_timeout` (default 5 s); if READY does not arrive, the box is + force-reset (`_reset_panda()`), the readout thread restarted and arming retried once (retry is + printed and logged as a warning with diagnostics). Only if the second attempt fails as well + does the status fail with a `RuntimeError`, so the scan aborts instead of hanging. + Still open for the ophyd_devices owner: the base-class race where the readout loop's + `finally: _reset_panda()` can clear `data_thread_run_event` after a fresh `on_stage()` set it + (likely root cause of the missing READY after rapid abort/retry); the new diagnostics line in + the DeviceServer log (`data_thread_run_event=False`) would confirm it. diff --git a/README.md b/README.md index 757f5be0..1b65ca3a 100644 --- a/README.md +++ b/README.md @@ -134,3 +134,29 @@ scans.line_scan(dev.samx, -1, 1, dev.samy, -1, 1, steps=20, exp_time=0.5, readou scans.sgalil_grid(start_y = , end_y = , interval_y = , start_x=, end_x=, interval_x =, exp_time=0.5, readout_time=3e-3, relative=True) ``` + +## Documentation + +The beamline documentation is built with Zensical. See the +[editing guide](docs/developer/editing_docs.md) for authoring conventions. +From the repository root, create a documentation environment and start the preview: + +```bash +python -m venv .venv-docs +source .venv-docs/bin/activate +python -m pip install -e '.[docs]' +zensical serve +``` + +Open the local URL printed by Zensical. Zensical reloads the preview when you edit +pages. For later sessions, activate the environment and run the same `zensical serve` +command. Stop the preview with Ctrl+C. + +To build the static site in `site/`, run: + +```bash +zensical build --clean +``` + +The `docs` extra in `pyproject.toml` adds Zensical and PyMdown Extensions to the +repository's regular dependencies. diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md index 17c91123..63fba981 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md @@ -1,5 +1,53 @@ # flomni → lamni feature gaps still open (2026-07 sweep) +> **Status update (2026-09-25 go-live audit).** The sections below are the +> original July findings; several are done since. Current state: +> +> - **Done**: §1 timing log + `scilog_last_ptycho_scans()`, §2 account-change +> reset, §4 `lfzp_in()` skip-if-in-position, §6 `tomo_alignment_scan()` (12 +> angles over 360°, `write_alignment_scan_numbers()`). §7 (OSA clearance in +> `lfzp_info()`) is implemented but **inactive** until `loptz`'s +> `collision_offset` userParameter is commissioned in `ptycho_lamni.yaml`. +> - **Still open**: §3 `collect_empty_frames()` (needs a decision whether +> lamni's reconstruction uses flat fields), §5 hard-stop button in the GUI +> (see the caveat in the audit notes below), §9 status-page rows (zero-degree +> reference toggle is now shown; golden damage-check shots already were). +> - **Fixed by the September audit** (LamNI compared against the running +> flomni; tests in `tests/tests_bec_ipython_client/test_lamni_golive_fixes.py` +> and siblings): projections silently dropped on a TimeoutError alarm +> (also in `tomo_alignment_scan`); `leye_out()` opened the shutter instead of +> closing it; endless golden-ratio scan crashed on its first projection and +> any GUI failure aborted the scan; scilog writes used the removed +> `bec.logbook` API; `raise_window()` could hide the GUI; tomo_scan_projection +> now reconstructs itself like flomni (custom hooks were never reconstructed); +> PDF report ignored the tomo type; CLI range guards; special-angle +> `UnboundLocalError` (also fixed in flomni); tomo-type-3 sub-tomogram +> boundary float truncation; `lfzp_info()`/queue "move Energy" used the +> legacy `mokev` device (now `ccm_energy`); webpage showed half the +> type-1 projection count; GUI manual-shift unit label (mm, not µm) and a +> bunch-size floor of 20; x-ray-eye `align()` cleanup on any failure; +> `xrayeye_update_frame()` beam-state warning. +> - **Open questions found by the audit** (need a hardware/design decision, not +> changed): default `lopty.limits = [3.0, 3.6]` excludes the FZP-in position +> 3.724 from `ptycho_lamni.yaml`; no OSA collision guard in `losa_in()` (needs +> LamNI's own geometry constant); the hard-stop button must not blindly reuse +> flomni's `hard_abort_and_restore_positioning_mode()` (it runs `XQ#POSMODE`, +> a transfer routine LamNI's Galil program may not have; LamNI has one Galil, +> a SmarAct controller for the OSA and the RT controller); +> `_write_tomo_scan_number()` writes the literal `'lamni'` (flomni: sanitized +> sample name, 0cadd85) and `.3f` angles (flomni `.5f`); `LamniFermatScan` +> lacks flomni's `randomize_offset` switch and `MAX_CORRIDOR_SIZE` cap +> (19cfee3, 53f246a); `tomo_alignment_scan()` always runs the full eye-out +> sequence (flomni skips it when already out). +> - **Behaviour to be aware of**: flomni's `tomo_alignment_scan()` queues its +> projections for reconstruction (it goes through `tomo_scan_projection`); +> LamNI's deliberately does not (`_reconstruct=False`). +> - **Same latent issues remain in flomni** (running in production, not +> touched here): tomo-type-3 sub-tomogram boundary float truncation +> (`flomni.py` `_golden_equally_spaced`), and the queue "move Energy" device +> name `mokev` (`_TOMO_QUEUE_MOVE_DEVICES`) which is only defined in +> `device_configs/legacy/`. + Follow-up to `FLOMNI_TO_LAMNI_COMPARISON.md` (flomni's `AI_docs/`) and this folder's `TOMO_QUEUE_PORT.md`/`TOMO_PARAMS_GUI_PORT.md` — those covered the tomo-queue backend, the GUI, the webpage generator, and the angle-distribution @@ -82,12 +130,13 @@ implementation plan — nothing here has been started. ## 5. No hard-stop button wired into lamni's GUI -- flomni: `flomnigui_show_cameras()` (`flomni/gui_tools.py:143-150`) wires up - a `ConsoleButtonsWidget` (`hard_stop_device_name="ftransy"`, - `extra_hard_stop_device_name="foptx"`) calling the shared, generic - `GalilController.hard_abort_and_restore_positioning_mode()` — added - specifically to replace an older blind stop-all-devices broadcast that - could crash the scan worker thread. +- flomni: `flomnigui_show_cameras()` (`flomni/gui_tools.py`) wires up + a `ConsoleButtonsWidget` (`hard_stop_device_names=["ftransy", "foptx", + "fsamroy"]`, one device per Galil controller, transfer controller first) + that sends `stop_all_axes()` to each controller and then calls the shared, + generic `GalilController.hard_abort_and_restore_positioning_mode()` on the + first — added specifically to replace an older blind stop-all-devices + broadcast that could crash the scan worker thread. - lamni: `LamNI/gui_tools.py` has no `ConsoleButtonsWidget`/hard-stop wiring at all. - The original commit message explicitly notes this was "scoped to flomni diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py index ead6fe36..5ad2ac99 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py @@ -46,6 +46,8 @@ class LamniWebpageGenerator(WebpageGeneratorBase): sample name, and measurement settings. """ + CLIENT_NAME = "lamni" + # LamNI's tomo-queue jobs (added via TomoQueueMixin, see # OMNY_shared/tomo_queue_mixin.py) are persisted under the same # "tomo_queue" global var key flomni uses, so the shared "Tomo queue" @@ -59,7 +61,11 @@ class LamniWebpageGenerator(WebpageGeneratorBase): # FlomniWebpageGenerator's TOMO_TYPES -- confirmed against lamni.py's # actual tomo_scan()/sub_tomo_scan() implementation, not a guess. TOMO_TYPES = { - 1: {"kind": "equally_spaced_grid", "n_subtomos": 8}, + # base_angle 360: each lamni sub-tomogram sweeps the full circle + # (N = int(360 / step), LamNI._tomo_type1_actual_grid()), unlike + # flomni's 180-degree sub-tomograms -- without it the page showed + # half the real projection count (144 instead of 288 at 10 deg). + 1: {"kind": "equally_spaced_grid", "n_subtomos": 8, "base_angle": 360}, 2: {"kind": "golden_capped"}, 3: {"kind": "golden_capped"}, } @@ -72,6 +78,12 @@ class LamniWebpageGenerator(WebpageGeneratorBase): ("tomo_type", "Type"), ("tomo_countingtime", "Exposure (s)"), ("tomo_circfov", "Circular FOV (µm)"), + # the piezo range is the FOV of each ptycho tile actually scanned + ("lamni_piezo_range_x", "Piezo range x (µm)"), + ("lamni_piezo_range_y", "Piezo range y (µm)"), + ("manual_shift_x", "Manual shift x (mm)"), + ("manual_shift_y", "Manual shift y (mm)"), + ("tomo_angle_file", "Angle file"), ("tomo_angle_stepsize", "Angle step (°)"), ("tomo_shellstep", "Shell step (µm)"), ("lamni_stitch_x", "Stitch x"), @@ -82,6 +94,7 @@ class LamniWebpageGenerator(WebpageGeneratorBase): ("golden_ratio_bunch_size", "Golden bunch size"), ("golden_max_number_of_projections", "Golden max projections"), ("golden_projections_at_0_deg_for_damage_estimation", "0° damage-check shots"), + ("zero_deg_reference_at_each_subtomo", "0° reference per sub-tomo"), ("ptycho_reconstruct_foldername", "Recon folder"), ] diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/docs/FZP_30nm_Tomas_Abe_2026.pdf b/csaxs_bec/bec_ipython_client/plugins/LamNI/docs/FZP 30nm Tomas Abe 2026.pdf similarity index 100% rename from csaxs_bec/bec_ipython_client/plugins/flomni/docs/FZP_30nm_Tomas_Abe_2026.pdf rename to csaxs_bec/bec_ipython_client/plugins/LamNI/docs/FZP 30nm Tomas Abe 2026.pdf diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/docs/central_stops.pdf b/csaxs_bec/bec_ipython_client/plugins/LamNI/docs/central_stops.pdf new file mode 100644 index 00000000..e033747e Binary files /dev/null and b/csaxs_bec/bec_ipython_client/plugins/LamNI/docs/central_stops.pdf differ diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py index 1b07dfa1..4ba826f7 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py @@ -18,6 +18,11 @@ import numpy as np from bec_lib import bec_logger from bec_lib.alarm_handler import AlarmBase +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.filter_check import ( + filters_out_of_beam, + warn_and_confirm, +) + from .lamni import LamNI logger = bec_logger.logger @@ -56,8 +61,8 @@ class MagLamNI(LamNI): lamni_at_each_angle(self, angle) return - self.tomo_scan_projection(angle) - self.tomo_reconstruct() + # tomo_scan_projection() adds the projection to the reconstruction queue + self.tomo_scan_projection(angle, _internal=True) class DataDrivenLamNI(LamNI): @@ -87,6 +92,14 @@ class DataDrivenLamNI(LamNI): """ bec = builtins.__dict__.get("bec") scans = builtins.__dict__.get("scans") + dev = builtins.__dict__.get("dev") + + all_out, offending = filters_out_of_beam(dev) + if not all_out: + warning = f"Not all filters are out of the beam: {', '.join(offending)}." + if not warn_and_confirm(self, warning, interactive=True, force=False): + print("Aborting tomo scan.") + return bec.builtin_actors.scan_interlock.trigger_setting = "restart_scan" bec.builtin_actors.scan_interlock.enabled = True @@ -97,9 +110,7 @@ class DataDrivenLamNI(LamNI): content = f"Loading tomo parameters from {fname}." logger.warning(content) - msg = bec.logbook.LogbookMessage() - msg.add_text(content).add_tag(["Data_driven_file", "BEC"]) - self.client.logbook.send_logbook_message(msg) + self.write_to_scilog(content, ["Data_driven_file"]) self._update_tomo_data_from_file(fname) self._current_special_angles = self.special_angles.copy() @@ -163,6 +174,10 @@ class DataDrivenLamNI(LamNI): error_caught = False if 0 <= angle < 360.05: print(f"Starting LamNI scan for angle {angle}") + # default for angles that aren't a special angle; without it + # num_repeats is unbound whenever special_angles is set but + # this angle isn't near the next one in the list + num_repeats = 1 while not successful: self._start_beam_check() if not self.special_angles: diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py index 52d612e1..8e2113e5 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py @@ -4,6 +4,10 @@ import builtins import datetime import time +from bec_lib import bec_logger + +logger = bec_logger.logger + if builtins.__dict__.get("bec") is not None: bec = builtins.__dict__.get("bec") dev = builtins.__dict__.get("dev") @@ -41,13 +45,19 @@ class LamniGuiTools: def lamnigui_show_gui(self): if "lamni" in self.gui.windows: self.lamni_window = self.gui.windows["lamni"] - self.gui.lamni.raise_window() + # raise_window() is currently buggy (bec_widgets RPCServer "raise" handling can + # detach/hide the window instead of bringing it to front) - disabled so that + # calling a lamnigui_show_* macro again on an already-open GUI is a safe no-op + # instead of risking hiding the window. Same workaround as flomni's + # flomnigui_show_gui(). + # self.gui.lamni.raise_window() else: self.lamni_window = self.gui.new("lamni", timeout=self.GUI_RPC_TIMEOUT) time.sleep(1) def lamnigui_raise(self): - self.gui.lamni.raise_window() + # raise_window() disabled, see lamnigui_show_gui(). + pass def lamnigui_stop_gui(self): self.gui.lamni.hide() @@ -198,20 +208,27 @@ class LamniGuiTools: # ------------------------------------------------------------------ def lamnigui_show_progress(self): - self.lamnigui_show_gui() - if self._lamnigui_is_missing("progressbar"): - self.lamnigui_remove_all_docks() - self.progressbar = self.gui.lamni.new( - "RingProgressBar", timeout=self.GUI_RPC_TIMEOUT - ) - # Outer ring: overall tomo progress (manual update) - self.progressbar.add_ring().set_update("manual") - # Middle ring: current sub-tomo progress (manual update) - self.progressbar.add_ring().set_update("manual") - # Inner ring: current scan progress (driven by BEC scan events) - self.progressbar.add_ring().set_update("scan") + # The progress GUI is a display only: it must never be able to abort + # a tomogram (a closed GUI / RPC timeout raising out of here would + # propagate through @scan_repeat and fail the projection). Same + # safeguard as flomni's flomnigui_show_progress() (dea43ed). + try: + self.lamnigui_show_gui() + if self._lamnigui_is_missing("progressbar"): + self.lamnigui_remove_all_docks() + self.progressbar = self.gui.lamni.new( + "RingProgressBar", timeout=self.GUI_RPC_TIMEOUT + ) + # Outer ring: overall tomo progress (manual update) + self.progressbar.add_ring().set_update("manual") + # Middle ring: current sub-tomo progress (manual update) + self.progressbar.add_ring().set_update("manual") + # Inner ring: current scan progress (driven by BEC scan events) + self.progressbar.add_ring().set_update("scan") - self._lamnigui_update_progress() + self._lamnigui_update_progress() + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Error in lamnigui_show_progress: {exc}") def _lamnigui_update_progress(self): """Update the progress ring bar and centre label from the current progress state. @@ -220,18 +237,35 @@ class LamniGuiTools: so this reflects the live state accessible from any BEC client session via:: client.get_global_var("tomo_progress") + + Never raises: called from _print_progress() inside every projection, so a + GUI failure must not fail (and, via @scan_repeat, retry) the scan itself. """ + try: + self._lamnigui_update_progress_impl() + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Error updating the LamNI progress GUI: {exc}") + + @staticmethod + def _percent(numerator, denominator) -> float: + """numerator/denominator in percent; 0 when the denominator is not + positive -- e.g. the total of an "endless" golden-ratio scan + (golden_max_number_of_projections == 0), which has no meaningful + percentage.""" + if not denominator or denominator <= 0: + return 0.0 + return numerator / denominator * 100 + + def _lamnigui_update_progress_impl(self): if self.progressbar is None: return main_progress_ring = self.progressbar.rings[0] subtomo_progress_ring = self.progressbar.rings[1] - progress = self.progress["projection"] / self.progress["total_projections"] * 100 - subtomo_progress = ( - self.progress["subtomo_projection"] - / self.progress["subtomo_total_projections"] - * 100 + progress = self._percent(self.progress["projection"], self.progress["total_projections"]) + subtomo_progress = self._percent( + self.progress["subtomo_projection"], self.progress["subtomo_total_projections"] ) main_progress_ring.set_value(progress) subtomo_progress_ring.set_value(subtomo_progress) @@ -269,11 +303,14 @@ class LamniGuiTools: else: finish_display = "N/A" + total = self.progress["total_projections"] + total_display = f"{total:.0f}" if total and total > 0 else "endless" + text = ( f"Progress report:\n" f" Tomo type: {self.progress['tomo_type']}\n" f" Projection: {self.progress['projection']:.0f}\n" - f" Total projections expected: {self.progress['total_projections']:.1f}\n" + f" Total projections expected: {total_display}\n" f" Angle: {self.progress['angle']:.1f}\n" f" Current subtomo: {self.progress['subtomo']}\n" f" Current projection within subtomo: {self.progress['subtomo_projection']}\n" diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index 7a88df3e..0e9d86a8 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -14,11 +14,23 @@ from typeguard import typechecked from csaxs_bec.bec_ipython_client.plugins.LamNI.gui_tools import LamniGuiTools from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_alignment_mixin import LamNIAlignmentMixin +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.filter_check import ( + filters_out_of_beam, + warn_and_confirm, +) from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import ( OMNYTools, PtychoReconstructor, TomoIDManager, ) +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.angle_file import ( + AngleFileError, + read_angle_file, + summarize_angles, +) +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_angle_file_mixin import ( + TomoAngleFileMixin, +) from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import TomoQueueMixin from .lamni_optics_mixin import LaMNIInitStages, LamNIOpticsMixin @@ -125,7 +137,17 @@ class LamNIError(Exception): precondition, ...) that should never be retried by @scan_repeat.""" -class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools): +class LamNI( + TomoQueueMixin, TomoAngleFileMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools +): + _TOMO_PARAMS_WINDOW_NAME = "lamni_tomo_params" + _TOMO_PARAMS_ERROR = LamNIError + # tomo_type 4: laminography -- 180-deg-opposite projections are not + # redundant, so do not warn about them. No legacy at-each-angle builtin + # is consulted by _at_each_angle() (only at_each_angle_hook). + _ANGLE_FILE_OPPOSITE_WARNING = False + _AT_EACH_ANGLE_BUILTIN = None + # Ordered set of all global-var-backed tomo scan parameters snapshotted # by tomo_queue_add()/restored by tomo_queue_execute(). Lamni's own # names for overlapping flomni concepts (tomo_circfov instead of @@ -146,6 +168,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools "lamni_piezo_range_y", "frames_per_trigger", "tomo_type", + "tomo_angle_file", "tomo_angle_stepsize", "golden_ratio_bunch_size", "golden_max_number_of_projections", @@ -156,17 +179,23 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools ) # Curated allow-list for the "move" queue action -- same beamline-wide - # devices as flomni's (mokev, idgap). Lamni has no equivalent of + # devices as flomni's (energy, idgap). Lamni has no equivalent of # flomni's ftray (sample-transfer tray) sim-testable bonus target. + # The energy device is "ccm_energy" (bl_optics_hutch.yaml; also what + # write_pdf_report()/lfzp_info() read) -- the legacy "mokev" only exists + # in device_configs/legacy/, so a queued "move Energy" job would have + # failed with a KeyError in dev["mokev"] and paused the queue. _TOMO_QUEUE_MOVE_DEVICES = { - "mokev": {"label": "Energy", "unit": "keV"}, + "ccm_energy": {"label": "Energy", "unit": "keV"}, "idgap": {"label": "Undulator gap", "unit": "mm"}, } # Named-action registry for tomo-queue command jobs -- identical to - # flomni's, since both actions (move, optimize_idgap) are beamline-wide, - # not flomni-specific hardware. See TomoQueueMixin._queue_action_move()/ - # optimize_idgap() for the implementations. + # flomni's plain "move" action, since it's beamline-wide, not + # flomni-specific hardware. flomni also has a "move_foptz_with_shift" + # action for its own FZP optics-Z stage; lamni has no equivalent + # hardware, so it isn't mirrored here. See + # TomoQueueMixin._queue_action_move() for the implementation. _TOMO_QUEUE_ACTIONS = { "move": { "func": "_queue_action_move", @@ -174,21 +203,6 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools "help": "Move device(s) to absolute position(s).", "params": {"positions": {"type": "device_positions"}}, }, - "optimize_idgap": { - "func": "optimize_idgap", - "idempotent": True, - "help": "Scan idgap over a search range and move to the peak. STUB: no-op.", - "params": { - "search_range": { - "type": "float", - "default": 0.5, - "min": 0.0, - "max": 2.0, - "unit": "mm", - "label": "Search range", - } - }, - }, } def _describe_tomo_job_line(self, job: dict) -> str: @@ -201,14 +215,18 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools missing key. """ p = job["params"] + angles_str = "" + if p.get("tomo_type") == 4: + angles_str = f" angles={os.path.basename(p.get('tomo_angle_file') or '') or '?'}" return ( - f"type={p.get('tomo_type')} circfov={p.get('tomo_circfov')}um " + f"type={p.get('tomo_type')}{angles_str} circfov={p.get('tomo_circfov')}um " f"step={p.get('tomo_shellstep')}um ctime={p.get('tomo_countingtime')}s" ) - def __init__(self, client): + def __init__(self, client, csaxs=None): super().__init__() self.client = client + self.csaxs = csaxs self.set_client(client) self.device_manager = client.device_manager self.init = LaMNIInitStages(client) @@ -224,7 +242,6 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self.tomo_id_manager = TomoIDManager() self.OMNYTools = OMNYTools(self.client) - self.tomo_id = -1 self.special_angles = [] self.special_angle_repeats = 20 self.special_angle_tolerance = 20 @@ -270,15 +287,20 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self._webpage_gen.start(force=True) def _maybe_reset_params_on_account_change(self) -> None: - """Offer a tomo-parameter reset when the active account has changed. + """Offer a tomo-parameter/queue/progress reset when the active + account has changed. Called at BEC session start. The account for which defaults were last applied is stored in the global var ``defaults_applied_for_account`` so it survives client restarts. Only a genuine change of account triggers the (interactive) reset; the same account is a silent no-op, so restarting a client within the same experiment never disturbs tuned - parameters. Mirrors Flomni._maybe_reset_params_on_account_change() - exactly. + parameters, the tomo queue, or in-progress acquisition state. Mirrors + Flomni._maybe_reset_params_on_account_change() exactly. + + On a confirmed reset, this also clears the tomo queue and resets tomo + acquisition progress (via tomo_queue_clear()/tomo_progress_reset()), + since both are leftover state from the previous account/experiment. """ bec = builtins.__dict__.get("bec") try: @@ -295,11 +317,13 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools if self.OMNYTools.yesno( f"New account '{account}' detected (previous: '{last_account}').\n" - "Reset tomo parameters to defaults for the new experiment?", + "Reset tomo parameters to defaults, clear the tomo queue, and " + "reset tomo acquisition progress for the new experiment?", "y", ): self._set_default_tomo_params() - print(f"Tomo parameters reset to defaults for account '{account}'.") + self.tomo_queue_clear() + self.tomo_progress_reset() self.client.set_global_var("defaults_applied_for_account", account) def _set_default_tomo_params(self) -> None: @@ -320,6 +344,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self.manual_shift_y = 0.0 self.tomo_circfov = 0.0 self.tomo_type = 1 + self.tomo_angle_file = "" self.corridor_size = -1 self.lamni_stitch_x = 0 self.lamni_stitch_y = 0 @@ -405,6 +430,71 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self._progress_proxy.reset() print("Tomo progress reset.") + def _build_timing_lines(self, header: str = "Tomoscan finished.") -> list: + """Build the timing-report lines (elapsed / idle / excl.-idle + breakdown) from the current ``self.progress`` state. + + Shared by the end-of-scan report in ``tomo_scan()`` and the + on-demand ``tomo_print_timing_report()`` / ``_and_scilog()`` + commands, so a scan interrupted mid-flight can still get this + breakdown -- ``self.progress`` is a BEC global var, not a local, + so it survives a KeyboardInterrupt raised inside ``tomo_scan()``. + """ + idle_s = self.progress.get("accumulated_idle_time", 0.0) + start_str = self.progress.get("tomo_start_time") + elapsed_s = None + if start_str is not None: + try: + elapsed_s = ( + datetime.datetime.now() - datetime.datetime.fromisoformat(start_str) + ).total_seconds() + except (ValueError, TypeError): + elapsed_s = None + timing_lines = [ + header, + f"Measurement ID: {self.tomo_id}", + f"Sample: {self.sample_name}", + ] + if self.at_each_angle_hook: + timing_lines.append(f"At-each-angle hook: {self._describe_active_hook()}") + if elapsed_s is not None: + timing_lines.append(f"Total measurement time: {self._format_duration(elapsed_s)}") + timing_lines.append( + f"Total measurement time excluding detected gaps: {self._format_duration(elapsed_s - idle_s)}" + ) + timing_lines.append( + f"Total measurement time lost to detected gaps: {self._format_duration(idle_s)}" + ) + return timing_lines + + def tomo_print_timing_report(self) -> None: + """Print the current tomo timing breakdown (elapsed / idle / excl. + idle) for whatever scan is or was last in progress. + + Callable at any time, including right after interrupting a scan + with Ctrl-C, since the underlying data (self.progress) survives + the interrupt. Console only -- does not write to scilog. + """ + timing_lines = self._build_timing_lines(header="Tomo timing report (current progress).") + for line in timing_lines: + print(line) + + def tomo_print_timing_report_and_scilog(self) -> None: + """Same as tomo_print_timing_report(), but also writes the report + to scilog -- useful for documenting the timing of a scan that was + interrupted rather than left undocumented. + """ + timing_lines = self._build_timing_lines(header="Tomo timing report (current progress).") + for line in timing_lines: + print(line) + timing_content = "\n".join(timing_lines) + hook_source = self._active_hook_source() + if hook_source: + timing_content += ( + f"\n\nAt-each-angle hook source ('{self.at_each_angle_hook}'):\n{hook_source}" + ) + self.write_to_scilog(timing_content, ["tomoscan"]) + @property def alignment_scan_progress(self) -> _AlignmentScanProgressProxy: """Proxy dict backed by the BEC global variable ``alignment_scan_progress``. @@ -446,8 +536,9 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools Args: keep_shutter_open: If True the shutter is left open between angle steps so the sample remains visible in live view. - force: skip the rotation-center-calibration check below without - prompting. + force: skip the rotation-center-calibration check and the + "this will erase the existing alignment" warning below + without prompting. """ if self.client.get_global_var("lamni_center_found_at") is None: if not self._confirm_sequence_override( @@ -459,6 +550,14 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools ): print("Aborting X-ray eye alignment.") return + existing = self._existing_alignment() + if existing and not self._confirm_sequence_override( + f"Starting a new X-ray eye alignment will erase the existing " + f"{' and '.join(existing)} for this sample.", + force, + ): + print("Aborting X-ray eye alignment.") + return aligner = XrayEyeAlignGUI(self.client, self) try: aligner.align(keep_shutter_open=keep_shutter_open) @@ -497,9 +596,48 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools Args: keep_shutter_open: If True the shutter is left open after the frame. """ + all_valid, bad_states = self._check_beamline_states_valid() + if not all_valid: + print( + "Attention: not all beamline states are valid:\n " + + "\n ".join(bad_states) + + "\nThe beam may be down, so the frame could come back empty." + ) + if not self.OMNYTools.yesno("Take a frame anyway?", "n"): + print("Stopping.") + return aligner = XrayEyeAlignGUI(self.client, self) aligner.update_frame(keep_shutter_open=keep_shutter_open) + def _check_beamline_states_valid(self): + """Return (all_valid, invalid_labels). + + Reads every configured beamline state (bec.beamline_states) and + collects the ones whose status is not "valid" (i.e. "invalid", + "warning" or "unknown"). Used to warn before taking a manual X-ray + eye frame: if e.g. the beam is down, the frame would come back empty + and the user is left confused about why they see nothing. Ported from + Flomni._check_beamline_states_valid() (4fe8d02). + + Returns all_valid=True (with an empty list) if the beamline-state + machinery isn't available at all, so this never blocks on a session + that doesn't use beamline states. + """ + bec = builtins.__dict__.get("bec") + manager = getattr(bec, "beamline_states", None) if bec is not None else None + if manager is None: + return True, [] + + bad = [] + for name in list(getattr(manager, "_states", {})): + try: + status = manager.get_status_by_name(name) + except Exception: # pylint: disable=broad-except + continue + if status is not None and status != "valid": + bad.append(f"{name}: {status}") + return (len(bad) == 0), bad + def xrayeye_rotation_center_calibration_isolated(self, keep_shutter_open: bool = False): """Determine new lsamx/lsamy rotation-center values for a sparse/isolated particle, via the X-ray eye GUI. @@ -565,10 +703,14 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools raise exc def xrayeye_rotation_center_calibration_smear_experimental( - self, keep_shutter_open: bool = False, sweep_deg: float = 360.0 + self, + keep_shutter_open: bool = False, + sweep_deg: float = 360.0, + projection: str = "min", + opaque_level: float = 0.25, ): """EXPERIMENTAL: extended-sample rotation-center calibration aided by - a continuous-rotation max-projection ("star-trail") composite image, + a continuous-rotation min- or max-projection ("star-trail") composite image, instead of a single instant of live rotation. Off-axis features smear into circular arcs while lsamrot rotates @@ -584,6 +726,15 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools sweep_deg: angular range of the continuous smear sweep. Defaults to a full 360 deg (complete arcs), but a smaller range may already show enough curvature to locate the center. + projection: "min" (default) for absorption contrast, i.e. dark + features on a bright background; "max" for bright features + on a dark background. + opaque_level: with projection="min", pixels darker than this + fraction of a frame's bright level are treated as opaque + (sample holder) and ignored, so the holder rotating through + the view does not black out the field of view. Raise it if + the holder still blocks too much, lower it if faint sample + features disappear; 0 = plain minimum. Returns: tuple: (new_lsamx_center, new_lsamy_center) in mm. @@ -591,7 +742,10 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools aligner = XrayEyeAlignGUI(self.client, self) try: return aligner.find_rotation_center_smear_experimental( - keep_shutter_open=keep_shutter_open, sweep_deg=sweep_deg + keep_shutter_open=keep_shutter_open, + sweep_deg=sweep_deg, + projection=projection, + opaque_level=opaque_level, ) except KeyboardInterrupt as exc: print("Smear rotation-center calibration interrupted by user.") @@ -649,7 +803,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self.device_manager.devices.rtx.controller.show_feedback_status() def show_interferometer_positions(self): - self.device_manager.devices.rtx.controller.show_feedback_status() + return self.device_manager.devices.rtx.controller.show_interferometer_positions() def show_signal_strength(self): self.device_manager.devices.rtx.controller.show_signal_strength_interferometer() @@ -658,10 +812,25 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools return self.device_manager.devices.rtx.controller.show_analog_signals() def lights_off(self): - self.device_manager.devices.lsamx.controller.lights_off() + self.device_manager.devices.lsamx.controller.lamni_lights_off() def lights_on(self): - self.device_manager.devices.lsamx.controller.lights_on() + self.device_manager.devices.lsamx.controller.lamni_lights_on() + + def lamni_move_to_scan_center(self, shift_x: float, shift_y: float, angle: float): + """ + LamNI scan to move the interferometer to the computed scan center based on the provided shift and angle. + + Args: + shift_x (float): Shift x. + shift_y (float): Shift y. + angle (float): Angle. + + Returns: + ScanReport + """ + scans = builtins.__dict__.get("scans") + return scans.lamni_move_to_scan_center(shift_x=shift_x, shift_y=shift_y, angle=angle) # ------------------------------------------------------------------ # Global parameters (backed by BEC global vars) @@ -698,8 +867,8 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools @tomo_type.setter def tomo_type(self, val: int): - if val not in (1, 2, 3): - raise ValueError("Unknown tomo_type. Must be 1, 2 or 3.") + if val not in (1, 2, 3, 4): + raise ValueError("Unknown tomo_type. Must be 1, 2, 3 or 4.") self.client.set_global_var("tomo_type", val) @property @@ -922,17 +1091,38 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools # Logging helpers # ------------------------------------------------------------------ - def write_to_scilog(self, content, tags: list = None): + def _get_scilog(self): + """The enabled scilog messaging service, or None (with a warning) if + scilog is unavailable/disabled -- e.g. a dev/sim session.""" + scilog = getattr(getattr(bec, "messaging", None), "scilog", None) + if scilog is None or not getattr(scilog, "_enabled", False): + logger.warning("scilog is not enabled; skipping scilog entry.") + return None + return scilog + + def write_to_scilog(self, content, tags: list = None) -> bool: + """Write a text entry to scilog, tolerating scilog being disabled or + unreachable. This is documentation of a scan, not part of the scan + itself -- it must never fail a caller (in particular a tomo_queue + job, which would otherwise be marked "incomplete" and pause the whole + queue over a logging side effect). Uses the ``bec.messaging.scilog`` + service like Flomni._scilog_write(); the old ``bec.logbook`` API this + used to call no longer exists in current bec_lib, so every entry was + silently dropped. + + Returns True if the entry was sent, False if skipped/failed. + """ + scilog = self._get_scilog() + if scilog is None: + return False try: - if tags is not None: - tags.append("BEC") - else: - tags = ["BEC"] - msg = bec.logbook.LogbookMessage() - msg.add_text(content).add_tag(tags) - self.client.logbook.send_logbook_message(msg) - except Exception: - logger.warning("Failed to write to scilog.") + tags = list(tags) if tags is not None else [] + tags.append("BEC") + scilog.new().add_text(content.replace("\n", "
")).add_tags(tags).send() + return True + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Failed to write to scilog: {exc}") + return False _TIMING_LOG_DIR = "~/data/raw/logs/timing_statistics" _TIMING_SETUP = "lamni" @@ -1131,12 +1321,14 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools fovx = rec.get("lamni_piezo_range_x") fovy = rec.get("lamni_piezo_range_y") fov_str = f"{fovx} x {fovy} um" if fovx is not None and fovy is not None else "?" + step = rec.get("tomo_shellstep") + step_str = f"{step} um" if step is not None else "?" exposure = rec.get("tomo_countingtime") exp_str = f"{exposure} s" if exposure is not None else "?" duration = rec.get("duration_s") dur_str = self._format_duration(duration) if duration is not None else "?" lines.append( - f"scan {scan_str}: FOV {fov_str}, exposure {exp_str}, duration {dur_str}" + f"scan {scan_str}: FOV {fov_str}, step {step_str}, exposure {exp_str}, duration {dur_str}" ) content = "\n".join(lines) @@ -1159,8 +1351,61 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools # Scan projection # ------------------------------------------------------------------ - def tomo_scan_projection(self, angle: float): + def tomo_scan_projection( + self, + angle: float, + _internal: bool = False, + _reconstruct: bool = True, + step: float | None = None, + exp_time: float | None = None, + frames_per_trigger: int | None = None, + ): + """Acquire one fermat-scan projection at `angle` and add it to the + reconstruction queue (tomo_reconstruct()), like + Flomni.tomo_scan_projection(). + + Reconstruction lives here, not in _at_each_angle(), so that custom + at_each_angle hooks -- which call ``setup.tomo_scan_projection(angle, + _internal=True)`` (see tomo_queue_mixin's hook example) -- and direct + calls also get their projection reconstructed; it used to be + skipped for them. + + _internal=True is passed by callers that already sit inside an + already-checked flow (tomo_scan()'s/tomo_alignment_scan()'s own + per-angle loop, via _at_each_angle) so the filter-out-of-beam check + below only runs once per tomogram/alignment run instead of once per + projection. Direct/standalone calls (the default) are checked every + time, mirroring Flomni.tomo_scan_projection()'s _internal convention. + + step/exp_time/frames_per_trigger (tomo_type 4 per-line overrides) + replace tomo_shellstep/tomo_countingtime/frames_per_trigger for this + projection only; None falls back to the current global values. + + _reconstruct=False skips the reconstruction-queue entry; used by + tomo_alignment_scan(), whose quick projections were never queued for + reconstruction on lamni. + """ scans = builtins.__dict__.get("scans") + step = self.tomo_shellstep if step is None else step + exp_time = self.tomo_countingtime if exp_time is None else exp_time + frames_per_trigger = ( + self.frames_per_trigger if frames_per_trigger is None else frames_per_trigger + ) + + if not _internal: + dev = builtins.__dict__.get("dev") + all_out, offending = filters_out_of_beam(dev) + if not all_out: + warning = f"Not all filters are out of the beam: {', '.join(offending)}." + if not warn_and_confirm(self, warning, interactive=True, force=False): + # Raise rather than return: this can run inside a + # per-angle loop (directly, or via a custom + # at_each_angle hook), and a bare return would leave the + # projection silently missing / the job stuck running. + raise LamNIError( + "tomo_scan_projection: declined to proceed with filter(s) " + f"in the beam ({', '.join(offending)})." + ) additional_correction = self.compute_additional_correction(angle) additional_correction_2 = self.compute_additional_correction_2(angle) @@ -1169,6 +1414,8 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self._current_scan_list = [] + start_scan_number = bec.queue.next_scan_number + projection_start = time.perf_counter() for stitch_x in range(-self.lamni_stitch_x, self.lamni_stitch_x + 1): for stitch_y in range(-self.lamni_stitch_y, self.lamni_stitch_y + 1): self._current_scan_list.append(bec.queue.next_scan_number) @@ -1180,7 +1427,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools scans.lamni_fermat_scan( fovx=self.lamni_piezo_range_x, fovy=self.lamni_piezo_range_y, - step=self.tomo_shellstep, + step=step, stitch_x=stitch_x, stitch_y=stitch_y, stitch_overlap=self.tomo_stitch_overlap, @@ -1200,14 +1447,40 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools ), fov_circular=self.tomo_circfov, angle=angle, - exp_time=self.tomo_countingtime, - frames_per_trigger=self.frames_per_trigger, + exp_time=exp_time, + frames_per_trigger=frames_per_trigger, corridor_size=corridor_size, ) - def write_alignment_scan_numbers(self, first_scan: int) -> None: + projection_duration = time.perf_counter() - projection_start + end_scan_number = bec.queue.next_scan_number + # Internal callers (_at_each_angle, tomo_alignment_scan) run inside a + # flow that already logs projection timing in _tomo_scan_at_angle, so + # only log here for direct CLI calls (e.g. probe/test scans) to avoid + # a duplicate record per projection -- and so that + # scilog_last_ptycho_scans() finds them. Same as Flomni. + if not _internal: + self._log_projection_timing( + angle=angle, + subtomo_number=0, + duration_s=projection_duration, + start_scan_number=start_scan_number, + end_scan_number=end_scan_number, + ) + + if _reconstruct: + self.tomo_reconstruct() + + def write_alignment_scan_numbers( + self, first_scan: int, scans: list[int] | None = None + ) -> None: """Write the scan-number/angle/offset log consumed by BEC_ptycho_align. + ``scans`` (optional): the actual scan number of each alignment point, + used to rewrite the file after the scan when a retried angle consumed + extra scan numbers. Defaults to the contiguous ``first_scan + k`` + prediction written before the scan starts. + Mirrors Flomni.write_alignment_scan_numbers() exactly (same 4-line format), adapted to lamni's own alignment-scan angle set (12 points across the full 360 degrees, see tomo_alignment_scan()) and its own @@ -1217,7 +1490,8 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools version makes. """ angles = list(np.linspace(0, 360, num=12, endpoint=False)) - scans = [first_scan + k for k in range(len(angles))] + if scans is None: + scans = [first_scan + k for k in range(len(angles))] x_vals = [] for angle in angles: @@ -1262,6 +1536,13 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools bec = builtins.__dict__.get("bec") dev = builtins.__dict__.get("dev") + all_out, offending = filters_out_of_beam(dev) + if not all_out: + warning = f"Not all filters are out of the beam: {', '.join(offending)}." + if not warn_and_confirm(self, warning, interactive=True, force=force): + print("Aborting alignment scan.") + return + self.leye_out() self.write_alignment_scan_numbers(bec.queue.next_scan_number) @@ -1275,29 +1556,38 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools ) self.lamnigui_show_alignment_progress() + first_scan_per_angle = [] + max_attempts = 10 for idx, angle in enumerate(angles): - successful = False print(f"Starting LamNI scan for angle {angle}") - while not successful: + for attempt in range(1, max_attempts + 1): + start_scan_number = bec.queue.next_scan_number try: - start_scan_number = bec.queue.next_scan_number - self.tomo_scan_projection(angle) + self.tomo_scan_projection(angle, _internal=True, _reconstruct=False) + break except AlarmBase as exc: - if exc.alarm_type == "TimeoutError": - bec.queue.request_queue_reset() - time.sleep(2) - else: - raise exc + if exc.alarm_type != "TimeoutError" or attempt == max_attempts: + raise + # Re-acquire this angle after clearing the queue. This used + # to swallow the alarm and carry on, silently losing the + # angle while still logging the failed attempt's scan + # numbers as if it had succeeded. + print(f"Alignment angle {angle}: scan timed out, retrying ({attempt}).") + bec.queue.request_queue_reset() + time.sleep(2) - end_scan_number = bec.queue.next_scan_number - for scan_nr in range(start_scan_number, end_scan_number): - alignment_scan_numbers.append(scan_nr) - - successful = True + end_scan_number = bec.queue.next_scan_number + first_scan_per_angle.append(start_scan_number) + alignment_scan_numbers.extend(range(start_scan_number, end_scan_number)) self.alignment_scan_progress.update(angle_index=idx + 1, angle=angle) self._lamnigui_update_alignment_progress() + # The file written up front assumes first_scan + k per angle; a retried + # angle consumes extra scan numbers, so rewrite it with what was + # actually acquired. + self.write_alignment_scan_numbers(first_scan_per_angle[0], scans=first_scan_per_angle) + umv(dev.lsamrot, 0) self.OMNYTools.printgreenbold( "\n\nAlignment scan finished. Please run BEC_ptycho_align and load the new fit" @@ -1318,23 +1608,53 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self.write_to_scilog(scilog_content, ["alignmentscan"]) def tomo_reconstruct(self, base_path="~/data/raw/logs/reconstruction_queue"): - """write the tomo reconstruct file for the reconstruction queue""" + """Write the tomo reconstruct file for the reconstruction queue. + + Normally called automatically at the end of tomo_scan_projection(), + which keeps self._current_scan_list up to date with the scan + number(s) of the projection just acquired (possibly several, when + stitching). When called directly -- e.g. from the command line after + a plain scans.lamni_fermat_scan(), without going through + tomo_scan_projection() -- that cached list is either stale (left over + from an earlier tomo scan) or not set at all, so fall back to just + the most recently completed scan number. + """ bec = builtins.__dict__.get("bec") + next_scan_number = bec.queue.next_scan_number + last_scan_number = next_scan_number - 1 + scan_list = getattr(self, "_current_scan_list", None) + if not scan_list or scan_list[-1] != last_scan_number: + scan_list = [last_scan_number] + self.reconstructor.folder_name = self.ptycho_reconstruct_foldername self.reconstructor.write( - scan_list=self._current_scan_list, - next_scan_number=bec.queue.next_scan_number, - base_path=base_path, + scan_list=scan_list, next_scan_number=next_scan_number, base_path=base_path ) - def _at_each_angle(self, angle: float) -> None: + def _at_each_angle( + self, + angle: float, + *, + step: float | None = None, + exp_time: float | None = None, + frames_per_trigger: int | None = None, + ) -> None: hook_name = self.at_each_angle_hook if hook_name: + # Hooks own the whole acquisition (fixed 2-arg contract) and never + # see the tomo_type 4 per-line overrides. hook = self._resolve_at_each_angle_hook(hook_name, LamNIError) hook(self, angle) return - self.tomo_scan_projection(angle) - self.tomo_reconstruct() + # tomo_scan_projection() itself adds the projection to the + # reconstruction queue -- do not call tomo_reconstruct() again here. + self.tomo_scan_projection( + angle, + _internal=True, + step=step, + exp_time=exp_time, + frames_per_trigger=frames_per_trigger, + ) # ------------------------------------------------------------------ # Progress reporting @@ -1375,6 +1695,62 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools print(f"Estimated finish time: ........... {finish_str}\x1b[0m") self._lamnigui_update_progress() + def _reset_progress_for_new_scan(self) -> None: + """Reset ``tomo_progress`` (and refresh the progress GUI) for a brand-new + tomogram, in one global-var round-trip. + + Every field must be reset, not just the timing/ETA ones: a finished + previous scan leaves projection == total_projections (100 %), its last + subtomo and angle behind, and the new scan's very first + _print_progress() -- fired by the 0-degree reference shot, before the + grid loop has written a single field -- would otherwise show those + stale values (rings at 100 %, old subtomo/angle, and an ETA of "0 s" + computed from the old projection count against the NEW start time). + + subtomo is set to 1 (not 0) so a scan interrupted during the 0-degree + shot is still resumable by tomo_scan_resume(). + """ + if self.tomo_type == 1: + tomo_type_name = "Equally spaced sub-tomograms" + subtomo_total, _, total = self._tomo_type1_actual_grid() + elif self.tomo_type == 2: + tomo_type_name = "Golden ratio tomography" + subtomo_total = self.golden_ratio_bunch_size + total = self.golden_max_number_of_projections + elif self.tomo_type == 3: + tomo_type_name = "Equally spaced, golden ratio starting angles" + subtomo_total = 360 / self.tomo_angle_stepsize + total = self.golden_max_number_of_projections + elif self.tomo_type == 4: + # Real totals come from the angle file once _run_angle_file_loop() + # has parsed it (and only if the file is marked complete). + tomo_type_name = "Angles from file" + subtomo_total = 1 + total = 1 + else: + raise ValueError(f"Unknown tomo_type: {self.tomo_type}.") + + self.progress.update( + { + "tomo_type": tomo_type_name, + "subtomo": 1, + "subtomo_projection": 0, + # totals are used as divisors by the GUI rings -- never 0 + "subtomo_total_projections": subtomo_total or 1, + "projection": 0, + "total_projections": total if total and total > 0 else 1, + "angle": 0, + "tomo_start_time": datetime.datetime.now().isoformat(), + # no leftover ETA from a previous scan until this one has + # accumulated enough projections to compute a fresh one + "estimated_remaining_time": None, + "estimated_finish_time": None, + "heartbeat": None, + "accumulated_idle_time": 0.0, + } + ) + self._lamnigui_update_progress() + # ------------------------------------------------------------------ # Tomo scan orchestration # ------------------------------------------------------------------ @@ -1408,35 +1784,64 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools (no tomo_angle_range/forward-reverse split -- lamni always scans the full 360 degrees). + Args: + start_angle (float, optional): resume point -- the literal angle + (on this sub-tomogram's own grid) to continue from, NOT the + origin of a new sweep. The returned angles are then the + remaining tail of the normal sweep, beginning at (and + including) this angle. Raises ValueError if it isn't on + the grid. Mirrors Flomni._subtomo_angle_plan(). + Returns: - angles (np.ndarray): the N angles (degrees) for this sub-tomogram. + angles (np.ndarray): the N angles (degrees) for this sub-tomogram + (or, when resuming, the remaining ones from start_angle on). + subtomo_offset (int): index of angles[0] within the full + sub-tomogram sweep (0 unless resuming), for progress numbering. N (int): number of projections in this (and every) sub-tomogram. """ N = int(360.0 / tomo_angle_stepsize) achievable_step = 360.0 / N - if start_angle is None: - # Phase offset must be a fraction of the ACHIEVABLE step (after - # int() truncation), not the raw configured tomo_angle_stepsize -- - # they only coincide when 360/tomo_angle_stepsize happens to - # already be a whole number. Using the raw value here breaks the - # interlacing scheme: sub-tomograms combined in pairs/quads/all-8 - # land on unevenly-spaced angles instead of the intended - # progressively finer, evenly-spaced grid at every combination - # level. Mirrors Flomni._subtomo_angle_plan() exactly. - offsets = {1: 0, 2: 4, 3: 2, 4: 6, 5: 1, 6: 5, 7: 3, 8: 7} - start_angle = achievable_step / 8.0 * offsets[subtomo_number] + # Phase offset must be a fraction of the ACHIEVABLE step (after + # int() truncation), not the raw configured tomo_angle_stepsize -- + # they only coincide when 360/tomo_angle_stepsize happens to + # already be a whole number. Using the raw value here breaks the + # interlacing scheme: sub-tomograms combined in pairs/quads/all-8 + # land on unevenly-spaced angles instead of the intended + # progressively finer, evenly-spaced grid at every combination + # level. Mirrors Flomni._subtomo_angle_plan() exactly. + offsets = {1: 0, 2: 4, 3: 2, 4: 6, 5: 1, 6: 5, 7: 3, 8: 7} + sweep_start = achievable_step / 8.0 * offsets[subtomo_number] - angle_end = start_angle + 360 # endpoint=False: a full 360-degree sweep must NOT re-measure both - # start_angle and start_angle+360 -- they're the same physical angle, + # sweep_start and sweep_start+360 -- they're the same physical angle, # unlike a [0, 180) span's two distinct endpoints. - angles = np.linspace(start_angle, angle_end, num=N, endpoint=False) + angles = np.linspace(sweep_start, sweep_start + 360, num=N, endpoint=False) if not (subtomo_number % 2): angles = np.flip(angles) - return angles, N + if start_angle is None: + return angles, 0, N + + # Resuming: locate start_angle on this sub-tomogram's own grid (with + # 360-degree wrap-around) and continue from there in the same + # direction. start_angle must NOT be used as the sweep origin -- that + # would generate angles past 360 (silently skipped by + # _tomo_scan_at_angle's range gate) and never reach the remaining + # part of the sweep. + diff = np.abs((angles - start_angle + 180.0) % 360.0 - 180.0) + subtomo_offset = int(np.argmin(diff)) + # start_angle normally comes straight from progress["angle"] (taken + # from this same grid), so only float noise is tolerated here -- the + # nearest grid point is always within step/2, so a looser check + # would never catch a genuinely off-grid angle. + if diff[subtomo_offset] > 0.01: + raise ValueError( + f"start_angle {start_angle} is not on the angle grid of sub-tomogram " + f"{subtomo_number} (tomo_angle_stepsize={tomo_angle_stepsize})." + ) + return angles[subtomo_offset:], subtomo_offset, N def _resolve_type1_projection(self, projection_number: int) -> tuple[int, float]: """Map a flat 0-indexed tomo_type-1 projection number to @@ -1448,7 +1853,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools tomography_scannumbers.txt log resolves to the same angle sub_tomo_scan() would have produced at that position. """ - _, N = self._subtomo_angle_plan(1, self.tomo_angle_stepsize) + _, _, N = self._subtomo_angle_plan(1, self.tomo_angle_stepsize) total = N * 8 if not 0 <= projection_number < total: raise ValueError( @@ -1456,20 +1861,20 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools f"(tomo_angle_stepsize={self.tomo_angle_stepsize})." ) subtomo_number = projection_number // N + 1 - angles, _ = self._subtomo_angle_plan(subtomo_number, self.tomo_angle_stepsize) + angles, _, _ = self._subtomo_angle_plan(subtomo_number, self.tomo_angle_stepsize) return subtomo_number, float(angles[projection_number % N]) def sub_tomo_scan(self, subtomo_number, start_angle=None): """Perform one sub-tomogram (tomo_type 1 only).""" - angles, N = self._subtomo_angle_plan( + angles, subtomo_offset, N = self._subtomo_angle_plan( subtomo_number, self.tomo_angle_stepsize, start_angle=start_angle ) total_projections = N * 8 - for angle in angles: + for i, angle in enumerate(angles): self.progress["tomo_type"] = "Equally spaced sub-tomograms" self.progress["subtomo"] = subtomo_number - self.progress["subtomo_projection"] = np.where(angles == angle)[0][0] + self.progress["subtomo_projection"] = subtomo_offset + i self.progress["subtomo_total_projections"] = N self.progress["projection"] = (subtomo_number - 1) * N + self.progress[ "subtomo_projection" @@ -1506,10 +1911,10 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools return not isinstance(exc, LamNIError) @scan_repeat(max_repeats=10, default=True, exc_handler=_retry_unless_lamni_error) - def _tomo_scan_at_angle(self, angle, subtomo_number): - successful = False - error_caught = False - if 0 <= angle < 360.05: + def _tomo_scan_at_angle( + self, angle, subtomo_number, *, step=None, exp_time=None, frames_per_trigger=None + ): + if self._min_scan_angle() <= angle < self._max_scan_angle() + 0.05: now = datetime.datetime.now() prev_heartbeat_str = self.progress.get("heartbeat") if prev_heartbeat_str is not None: @@ -1525,7 +1930,11 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools # tune if it over/under-triggers in practice. n_tiles = (2 * self.lamni_stitch_x + 1) * (2 * self.lamni_stitch_y + 1) normal_cadence = max( - 60.0, 5 * self.tomo_countingtime * self.frames_per_trigger * n_tiles + 60.0, + 5 + * (self.tomo_countingtime if exp_time is None else exp_time) + * (self.frames_per_trigger if frames_per_trigger is None else frames_per_trigger) + * n_tiles, ) if gap > normal_cadence: idle = gap - normal_cadence @@ -1540,49 +1949,55 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools print(f"Starting LamNI scan for angle {angle} in subtomo {subtomo_number}") self._print_progress() - while not successful: - if not self.special_angles: - self._current_special_angles = [] - if self._current_special_angles: - next_special_angle = self._current_special_angles[0] - if np.isclose(angle, next_special_angle, atol=0.5): - self._current_special_angles.pop(0) - num_repeats = self.special_angle_repeats - else: - num_repeats = 1 - try: - start_scan_number = bec.queue.next_scan_number - projection_start = time.perf_counter() - for i in range(num_repeats): - self._at_each_angle(angle) - projection_duration = time.perf_counter() - projection_start - error_caught = False - except AlarmBase as exc: - if exc.alarm_type == "TimeoutError": - bec.queue.request_queue_reset() - time.sleep(2) - error_caught = True - else: - raise exc - end_scan_number = bec.queue.next_scan_number - for scan_nr in range(start_scan_number, end_scan_number): - self._write_tomo_scan_number(scan_nr, angle, subtomo_number) + if not self.special_angles: + self._current_special_angles = [] + # num_repeats is decided without consuming the special-angle list, + # which is only popped once the projection succeeded: this method + # runs under @scan_repeat, so a failed attempt is retried and must + # see the same special angle (and repeat count) again. + num_repeats = 1 + is_special_angle = bool( + self._current_special_angles + and np.isclose(angle, self._current_special_angles[0], atol=0.5) + ) + if is_special_angle: + num_repeats = self.special_angle_repeats - if not error_caught: - # Only reached for a clean completion -- an - # AlarmBase/TimeoutError attempt above has no reliable - # duration measurement, so it's excluded from the - # timing log rather than polluting it. - self._log_projection_timing( - angle=angle, - subtomo_number=subtomo_number, - duration_s=projection_duration, - start_scan_number=start_scan_number, - end_scan_number=end_scan_number, - ) + start_scan_number = bec.queue.next_scan_number + projection_start = time.perf_counter() + overrides = self._angle_overrides_kwargs(step, exp_time, frames_per_trigger) + try: + for _ in range(num_repeats): + self._at_each_angle(angle, **overrides) + except AlarmBase as exc: + if exc.alarm_type == "TimeoutError": + # Clear the stuck queue, then re-raise so @scan_repeat + # re-acquires this projection. Swallowing the alarm here + # (as this used to) silently dropped the projection: its + # failed scan numbers were logged as if it had succeeded + # and it never reached the reconstruction queue. + bec.queue.request_queue_reset() + time.sleep(2) + raise + projection_duration = time.perf_counter() - projection_start - successful = True + if is_special_angle: + self._current_special_angles.pop(0) + + end_scan_number = bec.queue.next_scan_number + for scan_nr in range(start_scan_number, end_scan_number): + self._write_tomo_scan_number(scan_nr, angle, subtomo_number) + + # Only reached when the projection completed without @scan_repeat + # re-raising, so this is the final successful attempt's duration. + self._log_projection_timing( + angle=angle, + subtomo_number=subtomo_number, + duration_s=projection_duration, + start_scan_number=start_scan_number, + end_scan_number=end_scan_number, + ) def _golden(self, ii, howmany_sorted, maxangle=360, reverse=False): """Return the ii-th golden ratio angle within sorted bunches and its subtomo number.""" @@ -1604,7 +2019,13 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools ): """Return angles for equally spaced tomography with golden ratio sub-tomogram starting angles.""" angular_step = maxangle / number_of_projections_per_subtomo - subtomo_number = int((ii * angular_step) / maxangle) + 1 + # Exact integer division. The equivalent float expression + # int((ii * angular_step) / maxangle) + 1 truncates just below an + # integer for ii == k * n for some n (e.g. n = 19, 33, 38, 39, 66, + # ...): the first projection of a sub-tomogram was then labelled with + # the previous sub-tomogram number and acquired as a duplicate of its + # closing angle, and the next sub-tomogram skipped its first angle. + subtomo_number = int(ii // number_of_projections_per_subtomo) + 1 start_angle = self._golden(subtomo_number - 1, 1, angular_step)[0] projection_number_of_subtomo = ii - (subtomo_number - 1) * number_of_projections_per_subtomo @@ -1651,6 +2072,26 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools check instead prints a bold warning, waits 10s, and always proceeds -- it never aborts or raises. """ + is_new_scan = ( + (self.tomo_type == 1 and subtomo_start == 1 and start_angle is None) + or (self.tomo_type == 2 and projection_number == None) + or (self.tomo_type == 3 and projection_number == None) + or (self.tomo_type == 4 and projection_number is None) + ) + if self.tomo_type == 4 and is_new_scan: + # Clear stale progress from a previous scan immediately, before + # the angle-file check and the gates below -- otherwise a + # declined/raising gate leaves the GUI showing the last scan's + # projection/angle/start time as if it were still running. + self._reset_progress_for_new_scan() + + # tomo_type 4: (re-)read the angle file before anything else, so a + # missing/malformed file fails immediately (see + # TomoAngleFileMixin._start_angle_file_scan). + file_angles = None + if self.tomo_type == 4: + file_angles = self._start_angle_file_scan(projection_number) + if not self._check_eye_out_and_optics_in(): warning = ( "The setup is not in measurement configuration -- the X-ray " @@ -1667,6 +2108,14 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools ) time.sleep(10) + dev = builtins.__dict__.get("dev") + all_out, offending = filters_out_of_beam(dev) + if not all_out: + warning = f"Not all filters are out of the beam: {', '.join(offending)}." + if not warn_and_confirm(self, warning, interactive=interactive, force=force): + print("Aborting tomo scan.") + return + self.lamnigui_show_progress() bec = builtins.__dict__.get("bec") @@ -1677,11 +2126,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self._current_special_angles = self.special_angles.copy() - if ( - (self.tomo_type == 1 and subtomo_start == 1 and start_angle is None) - or (self.tomo_type == 2 and projection_number is None) - or (self.tomo_type == 3 and projection_number is None) - ): + if is_new_scan: if not self.corr_pos_x and not force: warning = ( "No fine (ptycho) alignment correction is loaded -- the " @@ -1717,14 +2162,21 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools "BEC", ) self.write_pdf_report() - 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 - # accumulated enough projections to compute a fresh one - self.progress["estimated_remaining_time"] = None - self.progress["estimated_finish_time"] = None - self.progress["accumulated_idle_time"] = 0.0 - self.progress["heartbeat"] = None + self._reset_progress_for_new_scan() + if file_angles is not None: + self._init_angle_file_progress(file_angles, projection_number) + # Unconditional single 0-degree reference projection at the very + # start of a new tomogram, independent of + # zero_deg_reference_at_each_subtomo / + # golden_projections_at_0_deg_for_damage_estimation -- gives an + # immediate radiation-damage baseline even when those flags are + # off. Real data (uses _tomo_scan_at_angle like any grid + # projection), included in reconstruction like any other + # projection. Logged under subtomo_number=1. Mirrors Flomni's + # equivalent addition -- no flat-field/empty-frame step here + # (unlike Flomni's collect_empty_frames()), since LamNI has no + # way to retract the sample out of the beam. + self._tomo_scan_at_angle(0, 1) try: with scans.dataset_id_on_hold: @@ -1844,6 +2296,8 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools f" {self.golden_max_number_of_projections} projections." ) break + elif self.tomo_type == 4: + self._run_angle_file_loop(file_angles, projection_number) else: raise ValueError(f"Unknown tomo_type: {self.tomo_type}.") finally: @@ -1862,37 +2316,18 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self._log_tomogram_timing() self.OMNYTools.printgreenbold("Tomoscan finished") - idle_s = self.progress.get("accumulated_idle_time", 0.0) - start_str = self.progress.get("tomo_start_time") - elapsed_s = None - if start_str is not None: - try: - elapsed_s = ( - datetime.datetime.now() - datetime.datetime.fromisoformat(start_str) - ).total_seconds() - except (ValueError, TypeError): - elapsed_s = None - timing_lines = [ - "Tomoscan finished.", - f"Measurement ID: {self.tomo_id}", - f"Sample: {self.sample_name}", - ] - if self.at_each_angle_hook: - timing_lines.append(f"At-each-angle hook: {self._describe_active_hook()}") - if elapsed_s is not None: - timing_lines.append(f"Total measurement time: {self._format_duration(elapsed_s)}") - timing_lines.append( - f"Total measurement time excluding detected gaps: {self._format_duration(elapsed_s - idle_s)}" - ) - timing_lines.append( - f"Total measurement time lost to detected gaps: {self._format_duration(idle_s)}" - ) + timing_lines = self._build_timing_lines() for line in timing_lines[3:]: print(line) timing_content = "\n".join(timing_lines) + hook_source = self._active_hook_source() + if hook_source: + timing_content += ( + f"\n\nAt-each-angle hook source ('{self.at_each_angle_hook}'):\n{hook_source}" + ) self.write_to_scilog(timing_content, ["tomoscan"]) - def tomo_scan_resume(self) -> None: + def tomo_scan_resume(self, from_queue: bool = False) -> None: """Resume a tomo_scan() that crashed or was interrupted, picking up automatically from wherever ``progress`` last reported -- no need to read the last subtomo/angle/projection off the progress printout by @@ -1909,11 +2344,20 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools internally when resuming a job that previously failed partway through, so a failed queue job picks up mid-scan rather than restarting from subtomo 1 / angle 0. + + If the tomo queue holds an unfinished ("incomplete"/"running") job, + a manual call warns and asks for confirmation, since resuming by + hand bypasses the queue (use tomo_queue_resume() instead). + ``from_queue`` is set by tomo_queue_execute() to skip that prompt. """ if self.progress.get("tomo_start_time") is None: print("No tomo scan in progress to resume -- nothing to do.") return + if not from_queue and not self._confirm_resume_outside_queue(): + print("Aborted.") + return + if self.tomo_type == 1: subtomo_start = self.progress["subtomo"] start_angle = self.progress["angle"] @@ -1922,9 +2366,14 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools return print(f"Resuming tomo scan at subtomo {subtomo_start}, angle {start_angle:.3f} deg.") self.tomo_scan(subtomo_start=subtomo_start, start_angle=start_angle) - elif self.tomo_type in (2, 3): + elif self.tomo_type in (2, 3, 4): projection_number = self.progress["projection"] print(f"Resuming tomo scan at projection {projection_number}.") + if self.tomo_type == 4: + print( + f"Note: the angle file {self.tomo_angle_file} is re-read now -- its " + "already-acquired lines must be unchanged." + ) self.tomo_scan(projection_number=projection_number) else: raise ValueError(f"Unknown tomo_type: {self.tomo_type}.") @@ -1950,6 +2399,12 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools the real per-projection count can vary a bit with angle/stitch tile, since the circular FOV crop (tomo_circfov) is checked against the rotated stage position. + + The real scan also draws a fresh random sub-step offset on every + call (see get_lamni_fermat_spiral_pos()'s `seed` argument), adding a + further small amount of per-projection count variation on top of + the angle/stitch dependence above. A fixed seed is used here only so + that this estimate itself is reproducible from one call to the next. """ from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan @@ -1968,6 +2423,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools fovx=self.lamni_piezo_range_x, fovy=self.lamni_piezo_range_y, fov_circular=self.tomo_circfov, + seed=0, ) return len(positions) @@ -1979,10 +2435,11 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools return LamniFermatScan.MIN_POSITIONS - def tomo_parameters(self): - """Print and interactively update the tomo parameters.""" + def tomo_parameters_cli(self): + """Print and interactively update the tomo parameters on the command line. + tomo_parameters() opens the GUI version.""" print("Current settings:") - print(f"Counting time = {self.tomo_countingtime} s") + print(f"Counting time per frame = {self.tomo_countingtime} s") print(f"Stepsize microns = {self.tomo_shellstep}") print( f"Piezo range (max 80) = {self.lamni_piezo_range_x}," @@ -2041,39 +2498,62 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools print("Ending by manual interruption.") if self.golden_projections_at_0_deg_for_damage_estimation == 1: print("Repeating projections at 0 deg at start of every second subtomogram.") + elif self.tomo_type == 4: + print("\x1b[1mTomo type 4:\x1b[0m Angles read from a file") + self._print_angle_file_summary() print(f"\nSample name: {self.sample_name}\n") if self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"): print("OK. continue.") return - self.tomo_countingtime = self._get_val(" s", self.tomo_countingtime, float) - self.tomo_shellstep = self._get_val(" um", self.tomo_shellstep, float) + self.tomo_countingtime = self._get_val( + " s", self.tomo_countingtime, float, min_=0.001, max_=100.0 + ) + self.tomo_shellstep = self._get_val( + " um", self.tomo_shellstep, float, min_=0.025, max_=20.0 + ) self.lamni_piezo_range_x = self._get_val( - " um", self.lamni_piezo_range_x, float + " um", self.lamni_piezo_range_x, float, min_=0.1, max_=79.9 ) self.lamni_piezo_range_y = self._get_val( - " um", self.lamni_piezo_range_y, float + " um", self.lamni_piezo_range_y, float, min_=0.1, max_=79.9 + ) + # min 0: a negative stitch makes range(-n, n + 1) empty, so a + # projection would silently run zero scans. max 50 = the GUI's range. + self.lamni_stitch_x = self._get_val( + "", self.lamni_stitch_x, int, min_=0, max_=50 + ) + self.lamni_stitch_y = self._get_val( + "", self.lamni_stitch_y, int, min_=0, max_=50 + ) + self.tomo_stitch_overlap = self._get_val( + " um", self.tomo_stitch_overlap, float, min_=0.0, max_=10.0 + ) + self.tomo_circfov = self._get_val( + " um", self.tomo_circfov, float, min_=0.0, max_=500.0 ) - self.lamni_stitch_x = self._get_val("", self.lamni_stitch_x, int) - self.lamni_stitch_y = self._get_val("", self.lamni_stitch_y, int) - self.tomo_circfov = self._get_val(" um", self.tomo_circfov, float) self.ptycho_reconstruct_foldername = self._get_val( "Reconstruction queue", self.ptycho_reconstruct_foldername, str ) self.frames_per_trigger = self._get_val( - "Frames per trigger (burst)", self.frames_per_trigger, int + "Frames per trigger (burst)", self.frames_per_trigger, int, min_=1, max_=100 ) print("Tomography type:") print(" 1: 8 equally spaced sub-tomograms (360 deg)") print(" 2: Golden ratio tomography") print(" 3: Equally spaced tomography, golden ratio starting angle") - self.tomo_type = self._get_val("Tomography type", self.tomo_type, int) + print(" 4: Angles (and sub-tomogram numbers) read from a file") + self.tomo_type = self._get_val("Tomography type", self.tomo_type, int, min_=1, max_=4) if self.tomo_type == 1: _, _, current_total = self._tomo_type1_actual_grid() - tomo_numberofprojections = self._get_val("Number of projections", current_total, int) + # min 8: fewer than one projection per sub-tomogram makes + # N = int(360 / step) zero and divides by zero later. + tomo_numberofprojections = self._get_val( + "Number of projections", current_total, int, min_=8, max_=10000 + ) self.tomo_angle_stepsize = 360 / tomo_numberofprojections * 8 # Report what was ACTUALLY achieved, via the same helper # sub_tomo_scan() itself uses -- not the raw value just typed in, @@ -2099,20 +2579,19 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools ) elif self.tomo_type == 2: - while True: - bunch_size = self._get_val( - "Number of projections sorted per bunch (minimum 20)", - self.golden_ratio_bunch_size, - int, - ) - if bunch_size >= 20: - self.golden_ratio_bunch_size = bunch_size - break - print("Bunch size must be at least 20. Please try again.") + self.golden_ratio_bunch_size = self._get_val( + "Number of projections sorted per bunch (minimum 20)", + self.golden_ratio_bunch_size, + int, + min_=20, + max_=100, + ) self.golden_max_number_of_projections = self._get_val( "Stop after number of projections (0 for endless)", self.golden_max_number_of_projections, int, + min_=0, + max_=1_000_000, ) self.golden_projections_at_0_deg_for_damage_estimation = self._get_val( "Repeat projections at 0 deg every second subtomo 1/0?", @@ -2122,13 +2601,19 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools elif self.tomo_type == 3: numprj = self._get_val( - "Number of projections per sub-tomogram", int(360 / self.tomo_angle_stepsize), int + "Number of projections per sub-tomogram", + int(360 / self.tomo_angle_stepsize), + int, + min_=1, + max_=10000, ) self.tomo_angle_stepsize = 360 / numprj self.golden_max_number_of_projections = self._get_val( "Stop after number of projections (0 for endless)", self.golden_max_number_of_projections, int, + min_=0, + max_=1_000_000, ) self.golden_projections_at_0_deg_for_damage_estimation = self._get_val( "Repeat projections at 0 deg every second subtomo 1/0?", @@ -2136,9 +2621,32 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools int, ) + elif self.tomo_type == 4: + print( + "Angle file: one angle (0 to 360 deg) per line, optional 2nd column =" + " sub-tomogram number, '#' comments. Read at scan start, and again" + " while the scan runs -- without a final 'END' line the scan waits" + " for more angles to be appended rather than stopping." + ) + self._prompt_angle_file() + @staticmethod - def _get_val(msg: str, default_value, data_type): - return data_type(input(f"{msg} ({default_value}): ") or default_value) + def _get_val(msg: str, default_value, data_type, min_=None, max_=None): + """Prompt for a value, casting to data_type. When min_/max_ are + given, re-prompts (rather than silently clamping) until the typed + value is in range, mirroring the Tomo Parameters GUI's spinbox + bounds (tomo_params.py) so tomo_parameters() enforces the same + limits instead of accepting anything of the right type. Mirrors + Flomni._get_val() exactly.""" + while True: + val = data_type(input(f"{msg} ({default_value}): ") or default_value) + if min_ is not None and val < min_: + print(f"Value must be >= {min_}. Please try again.") + continue + if max_ is not None and val > max_: + print(f"Value must be <= {max_}. Please try again.") + continue + return val def _confirm_sequence_override(self, warning: str, force: bool) -> bool: """Print *warning* and ask whether to proceed anyway. @@ -2182,7 +2690,66 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools piezo_range = f"{self.lamni_piezo_range_x:.2f}/{self.lamni_piezo_range_y:.2f}" stitching = f"{self.lamni_stitch_x:.2f}/{self.lamni_stitch_y:.2f}" dataset_id = str(self.client.queue.next_dataset_number) - _, _, report_total_projections = self._tomo_type1_actual_grid() + # bec.active_account, not client.username: the e-account the data is + # stored under (same as flomni, c1ad2fc). + account = getattr(builtins.__dict__.get("bec"), "active_account", None) or "N/A" + # The correct total-projections / sub-tomogram-count / angular-step + # values depend on which tomography type is actually configured -- + # mirrors tomo_parameters()'s tomo_type 1/2/3 branching and flomni's + # write_pdf_report() (5a22651). This used to always call + # _tomo_type1_actual_grid(), which is only meaningful for type 1: a + # golden-ratio scan (type 2/3) reported a stale, unrelated type-1 + # total left over in the persistent tomo_angle_stepsize global var + # instead of golden_max_number_of_projections, plus a hardcoded "8 + # sub-tomograms". + angle_step_label = "Angular step within sub-tomogram:" + if self.tomo_type == 1: + _, achievable_step, total_projections = self._tomo_type1_actual_grid() + subtomo_count_label = "Number of individual sub-tomograms:" + subtomo_count_value = "8" + angle_step_value = f"{achievable_step:.2f}" + elif self.tomo_type == 3: + total_projections = ( + self.golden_max_number_of_projections + if self.golden_max_number_of_projections > 0 + else 0 + ) + subtomo_count_label = "Number of projections per sub-tomogram:" + subtomo_count_value = f"{360 / self.tomo_angle_stepsize:.1f}" + angle_step_value = f"{self.tomo_angle_stepsize:.2f}" + elif self.tomo_type == 4: + try: + file_angles = read_angle_file(self.tomo_angle_file) + total_projections = len(file_angles.angles) + subtomo_count_value = f"{summarize_angles(file_angles.angles)['n_subtomos']}" + if not file_angles.is_complete: + subtomo_count_value += " (file incomplete, no 'END' marker yet)" + except AngleFileError: + total_projections = 0 + subtomo_count_value = "N/A (angle file not readable)" + subtomo_count_label = "Number of individual sub-tomograms:" + angle_step_label = "Angle file:" + angle_step_value = self.tomo_angle_file or "N/A" + else: + # tomo_type == 2: golden ratio tomography -- there is no fixed set + # of sub-tomograms or a fixed angular step, so report the bunch + # size instead (matches tomo_parameters()'s printout). + total_projections = ( + self.golden_max_number_of_projections + if self.golden_max_number_of_projections > 0 + else 0 + ) + subtomo_count_label = "Sorted in bunches of:" + subtomo_count_value = f"{self.golden_ratio_bunch_size}" + angle_step_value = "N/A (golden ratio)" + total_projections_str = ( + f"{total_projections:.0f}" if total_projections else "N/A (manual stop)" + ) + last_scan_number_str = ( + f"{self.client.queue.next_scan_number + total_projections + 10:.0f}" + if total_projections + else "N/A" + ) # The energy readback device is "ccm_energy", not "mokev" -- and may # not be configured/available in every session (e.g. some simulated # configs). Read it defensively so a missing/misbehaving device @@ -2216,15 +2783,24 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools except Exception: focal_distance_str = "N/A" # FZP focus-to-sample distance: same live z-stage-based calculation - # lfzp_info() already uses -- not a stored value. + # lfzp_info() already uses -- not a stored value. Depends on the + # mounted FZP type (30 nm sits in an extra holder further downstream). try: loptz_val = dev.loptz.read()["loptz"]["value"] - fzp_sample_distance_str = f"{-loptz_val + 85.6 + 52:.1f}" + fzp_sample_distance_str = ( + f"{self._fzp_sample_distance(loptz_val, fzp_zone_width_nm):.1f}" + ) except Exception: fzp_sample_distance_str = "N/A" # Sample-to-detector distance: userParameter on loptx, defaults to # -1 (unknown) until someone measures and sets it. - detector_distance = self._get_user_param_safe("loptx", "detector_distance") + # Read defensively like the other device values above: a missing + # parameter must not crash the report after add_sample_database() + # already registered this tomogram. + try: + detector_distance = self._get_user_param_safe("loptx", "detector_distance") + except Exception: + detector_distance = None detector_distance_str = ( f"{detector_distance:.1f}" if detector_distance and detector_distance > 0 else "N/A" ) @@ -2233,19 +2809,18 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools f"{'Measurement ID:':<{padding}}{self.tomo_id}\n", f"{'Dataset ID:':<{padding}}{dataset_id}\n", f"{'Sample Info:':<{padding}}Sample Info\n", - f"{'e-account:':<{padding}}{self.client.username}\n", - f"{'Number of projections:':<{padding}}{report_total_projections}\n", + f"{'e-account:':<{padding}}{account}\n", + f"{'Number of projections:':<{padding}}{total_projections_str}\n", f"{'First scan number:':<{padding}}{self.client.queue.next_scan_number}\n", - f"{'Last scan number approx.:':<{padding}}" - f"{self.client.queue.next_scan_number + report_total_projections + 10}\n", + f"{'Last scan number approx.:':<{padding}}{last_scan_number_str}\n", f"{'Current photon energy:':<{padding}}{energy_str}\n", f"{'Exposure time:':<{padding}}{self.tomo_countingtime:.2f}\n", f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:.2f}\n", f"{'Piezo range (FOV sample plane):':<{padding}}{piezo_range}\n", f"{'Restriction to circular FOV:':<{padding}}{self.tomo_circfov:.2f}\n", f"{'Stitching:':<{padding}}{stitching}\n", - f"{'Number of individual sub-tomograms:':<{padding}}8\n", - f"{'Angular step within sub-tomogram:':<{padding}}{self.tomo_angle_stepsize:.2f}\n", + f"{subtomo_count_label:<{padding}}{subtomo_count_value}\n", + f"{angle_step_label:<{padding}}{angle_step_value}\n", f"{'Tomo type:':<{padding}}{self.tomo_type}\n", f"{'FZP diameter:':<{padding}}{fzp_diameter_um} microns\n", f"{'FZP outermost zone width:':<{padding}}{fzp_zone_width_nm} nm\n", @@ -2262,6 +2837,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools user_target = os.path.expanduser( f"~/data/raw/documentation/tomo_scan_ID_{self.tomo_id}.pdf" ) + os.makedirs(os.path.dirname(user_target), exist_ok=True) with PDFWriter(user_target) as file: self._add_psi_footer(file) # PDFWriter (bec_lib) has no public image API -- reach into its @@ -2279,22 +2855,25 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools # Replaces the old upload_last_pon.sh script (broken, never rewritten -- # see git history) with a direct HTTP upload to the samples web folder. self._upload_pdf_report_to_samples(user_target) - # Same tolerance as write_to_scilog(): a session without scilog/logbook + # Same tolerance as write_to_scilog(): a session without scilog # configured (e.g. a dev/sim session) must not crash report generation - # over the logbook upload -- the PDF itself is already written above. + # over the scilog upload -- the PDF itself is already written above. + scilog = self._get_scilog() + if scilog is None: + return try: scilog_text = content if hook_source: scilog_text += ( f"\n\nAt-each-angle hook source ('{self.at_each_angle_hook}'):\n{hook_source}" ) - msg = bec.logbook.LogbookMessage() - msg.add_file(logo_path).add_text(scilog_text.replace("\n", "

")).add_tag( + scilog.new().add_attachment(logo_path, width=200).add_text( + scilog_text.replace("\n", "
") + ).add_tags( ["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "LamNI", self.sample_name] - ) - self.client.logbook.send_logbook_message(msg) - except Exception: - logger.warning("Failed to write PDF report to scilog.") + ).send() + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Failed to write PDF report to scilog: {exc}") def get_calibration_of_capstops_left_and_right(self): import time diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py index e404710a..bfdd8e91 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py @@ -17,6 +17,7 @@ from __future__ import annotations import builtins import os +import h5py import numpy as np from bec_lib import bec_logger from typeguard import typechecked @@ -25,6 +26,27 @@ logger = bec_logger.logger dev = builtins.__dict__.get("dev") +# Raw per-angle offsets of the most recent X-ray eye alignment, as written by +# XrayEyeAlign.write_output(): [angles_deg, x_offsets_um, y_offsets_um]. +XEYE_FIT_DATA_VAR = "lamni_xeye_fit_data" + + +def fit_xray_eye_sine(angles_deg, values): + """Least-squares fit of ``A * sin(radians(angle) + B) + C`` to *values*. + + Same model as the XRayEye GUI's DAP fit (SineModel with the frequency + fixed to 1 cycle/360 deg, plus a constant) and the same convention as + ``tomo_fit_xray_eye``. Written as ``a*sin + b*cos + C`` the model is + linear, so the fit is exact and needs no start values. + + Returns: + tuple: ``(A, B, C)`` with ``A >= 0`` and ``B`` in (-pi, pi]. + """ + theta = np.radians(np.asarray(angles_deg, dtype=float)) + design = np.column_stack([np.sin(theta), np.cos(theta), np.ones_like(theta)]) + (a, b, c), *_ = np.linalg.lstsq(design, np.asarray(values, dtype=float), rcond=None) + return float(np.hypot(a, b)), float(np.arctan2(b, a)), float(c) + class LamNIAlignmentMixin: """Correction infrastructure for LamNI laminography. @@ -54,8 +76,21 @@ class LamNIAlignmentMixin: self.corr_angle_2 = [] def reset_xray_eye_correction(self): - """Delete the X-ray eye sinusoidal fit from the BEC global variable store.""" + """Delete the X-ray eye sinusoidal fit, and the alignment points it was + fitted to, from the BEC global variable store.""" self.client.delete_global_var("tomo_fit_xray_eye") + self.client.delete_global_var(XEYE_FIT_DATA_VAR) + + def _existing_alignment(self) -> list[str]: + """Names of the alignment results currently stored, i.e. what a new + X-ray eye alignment or an applied rotation-center calibration would + erase. Empty if there is nothing to lose.""" + existing = [] + if self.client.get_global_var("tomo_fit_xray_eye") is not None: + existing.append("X-ray eye alignment fit") + if self.corr_pos_x: + existing.append("fine-alignment correction") + return existing # ------------------------------------------------------------------ # Additional lookup-table correction properties -- global-var-backed so @@ -202,8 +237,10 @@ class LamNIAlignmentMixin: .. important:: This method reads from the live GUI widget. If the XRayEye GUI window has been closed since the alignment was performed the call - will fail. In that case use :meth:`read_xray_eye_correction` to - reload from the archived text files. + will fail. (The former file-based fallback + ``read_xray_eye_correction()`` is disabled -- see the commented-out + code above -- so re-open the X-ray eye GUI and let the fit converge + again, or re-run the alignment.) """ _dev = builtins.__dict__.get("dev") tomo_fit_xray_eye = np.zeros((2, 3)) @@ -223,9 +260,8 @@ class LamNIAlignmentMixin: f"{type(params).__name__ if not isinstance(params, dict) else 'a dict missing keys'}). " "This means the fit hasn't converged / DAP forwarding hasn't produced " "a result yet, not that the correction data is missing. Wait for the " - "fit to converge in the XRayEye GUI's Fit tab, or use " - "read_xray_eye_correction() to reload from the archived text files " - "instead." + "fit to converge in the XRayEye GUI's Fit tab (keep the XRayEye " + "window open), or re-run the alignment." ) tomo_fit_xray_eye[0][0] = params_x["SineModel_0_amplitude"] @@ -240,6 +276,125 @@ class LamNIAlignmentMixin: print("New alignment parameters loaded from XRayEye GUI fit:") self._print_xeye_fit(tomo_fit_xray_eye) + def xrayeye_refit(self, exclude_angles=None, file: str | None = None, confirm: bool = True): + """Redo the X-ray eye sinusoidal fit, optionally leaving out angles. + + Use this after ``xrayeye_alignment_start()`` when one click is clearly + off the curve (sample barely visible at that angle, misclick, ...). + The clicks of the last alignment are refitted without the excluded + angles, a table of all points with their deviation from the new fit + is printed, and after confirmation the result replaces + ``tomo_fit_xray_eye``. Nothing is moved and no new frame is taken. + + Call it without arguments first to see the deviations of all points + from the full fit and spot the outlier. Calling it again with an + empty exclusion list restores the fit over all points. + + Args: + exclude_angles: angle or list of angles in deg (of 0, 45, ..., + 315) whose point is left out of the fit in both x and y. + file: refit an archived alignment instead of the last one, e.g. + ~/data/raw/logs/xrayeye_alignmentvalues/xrayeye_alignmentvalues_.h5 + confirm: ask before replacing the stored fit. + + Example: + lamni.xrayeye_refit() # inspect all points + lamni.xrayeye_refit([135]) # drop the 135 deg click + lamni.xrayeye_refit([135, 270]) + """ + data = self._load_xeye_fit_data(file) + angles = np.asarray(data[0], dtype=float) + offsets = {"x": np.asarray(data[1], dtype=float), "y": np.asarray(data[2], dtype=float)} + + if exclude_angles is None: + exclude_angles = [] + elif np.isscalar(exclude_angles): + exclude_angles = [exclude_angles] + excluded = np.zeros(len(angles), dtype=bool) + for angle in exclude_angles: + match = np.isclose(angles, float(angle) % 360) + if not match.any(): + raise ValueError( + f"No alignment point at {angle} deg. " + f"Available angles: {', '.join(f'{a:g}' for a in angles)}." + ) + excluded |= match + kept = ~excluded + # 3 free parameters per axis; with only 3 points any set of clicks + # fits exactly, so a bad click could no longer be noticed. + if kept.sum() < 4: + raise ValueError( + f"Only {kept.sum()} of {len(angles)} points left -- at least 4 are " + "needed for a meaningful fit." + ) + + fit = np.array([fit_xray_eye_sine(angles[kept], offsets[ax][kept]) for ax in ("x", "y")]) + residuals = {} + for i, ax in enumerate(("x", "y")): + amp, shift, const = fit[i] + residuals[ax] = offsets[ax] - (amp * np.sin(np.radians(angles) + shift) + const) + + print("X-ray eye points (offsets and deviation from the new fit, in um):") + print(" angle x_off x_dev y_off y_dev") + for j, angle in enumerate(angles): + flag = " excluded" if excluded[j] else "" + print( + f" {angle:5.0f} {offsets['x'][j]:9.2f} {residuals['x'][j]:8.2f}" + f" {offsets['y'][j]:9.2f} {residuals['y'][j]:8.2f}{flag}" + ) + for ax in ("x", "y"): + rms = np.sqrt(np.mean(residuals[ax][kept] ** 2)) + print(f" rms deviation of the fitted points, {ax}: {rms:.2f} um") + + print("New fit:") + self._print_xeye_fit(fit) + old_fit = self.client.get_global_var("tomo_fit_xray_eye") + if old_fit is not None: + print("Currently stored fit:") + self._print_xeye_fit(old_fit) + + self._show_xeye_refit_in_gui(angles[kept], offsets["x"][kept], offsets["y"][kept]) + + if confirm and not self.OMNYTools.yesno("Replace the stored X-ray eye fit?", "y"): + print("Stored fit left unchanged.") + return + self.client.set_global_var("tomo_fit_xray_eye", fit.tolist()) + if excluded.any(): + print( + "New X-ray eye fit stored (excluded: " + + ", ".join(f"{a:g}" for a in angles[excluded]) + + " deg)." + ) + else: + print("New X-ray eye fit stored (all points).") + + def _load_xeye_fit_data(self, file: str | None = None): + """Return the ``[angles, x_offsets, y_offsets]`` of the last alignment, + or of the archived alignment in *file*.""" + if file is not None: + with h5py.File(os.path.expanduser(file), "r") as f: + return f["alignment_fit"][()].tolist() + data = self.client.get_global_var(XEYE_FIT_DATA_VAR) + if data is None: + raise RuntimeError( + "No X-ray eye alignment points stored -- run lamni.xrayeye_alignment_start(), " + "or pass the archived alignment as file=... (see " + "~/data/raw/logs/xrayeye_alignmentvalues/)." + ) + return data + + def _show_xeye_refit_in_gui(self, angles, x_offsets, y_offsets): + """Show the points used in the refit on the XRayEye Fit tab. + + Display only: DAP parameter forwarding stays off, so the GUI's own fit + of these points does not touch the stored correction. + """ + try: + self.lamnigui_show_xeyealign_fittab() + self.xeyegui.submit_fit_array(np.array([angles, x_offsets, y_offsets])) + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Could not show the refit points in the XRayEye GUI: {exc}") + @staticmethod def _print_xeye_fit(fit): """Pretty-print the 2×3 X-ray eye fit array.""" @@ -314,7 +469,6 @@ class LamNIAlignmentMixin: with open(correction_file, "r") as f: num_elements = f.readline() int_num_elements = int(num_elements.split(" ")[2]) - print(int_num_elements) corr_pos_x = [] corr_pos_y = [] corr_angle = [] diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py index c04a4d24..1c4a8ca7 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py @@ -214,23 +214,135 @@ class LamNIOpticsMixin: return None return param.get(var) + # slits opened by _EYE_SLIT_OPENING (relative) while the xray eye is in + _EYE_SLITS = ("sl1", "sl6") + _EYE_SLIT_OPENING = 1.0 # mm + + @staticmethod + def _slit_size(name): + # sl1xs/sl1ys are EpicsMotors (user_readback), sl6xs/sl6ys are + # VirtualSlitWidth pseudo motors (readback) + device = dev[name] + if hasattr(device, "user_readback"): + return device.user_readback.get() + return device.readback.get() + + def _slit_before_eye(self, slit): + return ( + self.client.get_global_var(f"lamni_{slit}xs_before_eye"), + self.client.get_global_var(f"lamni_{slit}ys_before_eye"), + ) + + def _set_slit_before_eye(self, slit, xs, ys): + self.client.set_global_var(f"lamni_{slit}xs_before_eye", xs) + self.client.set_global_var(f"lamni_{slit}ys_before_eye", ys) + + def _open_slits_for_eye(self): + for slit in self._EYE_SLITS: + xs, ys = f"{slit}xs", f"{slit}ys" + if xs not in dev or ys not in dev: + continue + self._set_slit_before_eye(slit, self._slit_size(xs), self._slit_size(ys)) + scans.umv( + dev[xs], self._EYE_SLIT_OPENING, dev[ys], self._EYE_SLIT_OPENING, relative=True + ) + + def _restore_slits_after_eye(self): + for slit in self._EYE_SLITS: + xs, ys = f"{slit}xs", f"{slit}ys" + xs_before, ys_before = self._slit_before_eye(slit) + if xs_before is None or ys_before is None: + continue + if xs not in dev or ys not in dev: + self._set_slit_before_eye(slit, None, None) + continue + # if the slit was manually re-adjusted while the eye was in, + # offer to keep the current size instead of restoring the + # pre-eye value + tol = 0.01 # mm (10 microns) + manually_adjusted = not np.isclose( + self._slit_size(xs), xs_before + self._EYE_SLIT_OPENING, atol=tol + ) or not np.isclose(self._slit_size(ys), ys_before + self._EYE_SLIT_OPENING, atol=tol) + + if manually_adjusted and self.OMNYTools.yesno( + f"{xs}/{ys} appear to have been adjusted manually while the " + "eye was in. Keep the current slit size instead of " + "restoring the pre-eye value?" + ): + print(f"Keeping current {slit} size, not restoring pre-eye value.") + else: + umv(dev[xs], xs_before, dev[ys], ys_before) + self._set_slit_before_eye(slit, None, None) + def leye_out(self): self.loptics_in() - dev.fsh.fshopen() + # Close (not open) the shutter as the eye leaves the beam, like + # flomni's feye_out(). Was fshclose() originally; commit 4967474 + # ("removed fsh commands") turned it into fshopen() by mistake. + dev.fsh.fshclose() + + if self.csaxs is not None: + self.csaxs.fil_trans(1, print_only=False) + + self._restore_slits_after_eye() + leyey_out = self._get_user_param_safe("leyey", "out") umv(dev.leyey, leyey_out) umv(dev.lsamrot, 0) - #umv(dev.dttrz, 5854, dev.fttrz, 2395) + + # flight tube (fttrz) and detector table (dttrz) move together + if "fttrz" not in dev or "dttrz" not in dev: + if not self.OMNYTools.yesno( + "Device 'fttrz' or 'dttrz' does not exist in the current config, " + "so the flight tube and detector table can't be moved into position. " + "Continue anyway? WARNING: this could cause collisions when " + "operating on the real beamline." + ): + print("Aborting leye_out(): fttrz/dttrz not moved.") + return + else: + fttrz_in = self._get_user_param_safe("leyex", "fttrz_in") + dttrz_in = self._get_user_param_safe("leyex", "dttrz_in") + umv(dev.fttrz, fttrz_in, dev.dttrz, dttrz_in) def leye_in(self): bec.queue.next_dataset_number += 1 umv(dev.lsamrot, 0) - #umv(dev.dttrz, 6419.677, dev.fttrz, 2959.979) + + # flight tube (fttrz) and detector table (dttrz) move together + if "fttrz" not in dev or "dttrz" not in dev: + if not self.OMNYTools.yesno( + "Device 'fttrz' or 'dttrz' does not exist in the current config, " + "so the flight tube and detector table can't be moved out of the way. " + "Continue anyway? WARNING: this could cause collisions when " + "operating on the real beamline." + ): + print("Aborting leye_in(): fttrz/dttrz not moved.") + return + else: + fttrz_out = self._get_user_param_safe("leyex", "fttrz_out") + dttrz_out = self._get_user_param_safe("leyex", "dttrz_out") + umv(dev.fttrz, fttrz_out, dev.dttrz, dttrz_out) + if not self.OMNYTools.yesno("Did the flight tube move out?", "y"): return leyex_in = self._get_user_param_safe("leyex", "in") leyey_in = self._get_user_param_safe("leyey", "in") + + current_leyex = dev.leyex.readback.get() + current_leyey = dev.leyey.readback.get() + + # check if both are close enough (within 0.01) + if np.isclose(current_leyex, leyex_in, atol=0.01) and np.isclose(current_leyey, leyey_in, atol=0.01): + # both already in position -> do nothing + pass + else: + self._open_slits_for_eye() + + if self.csaxs is not None: + self.csaxs.fil_trans(0.05, print_only=False) + umv(dev.leyex, leyex_in, dev.leyey, leyey_in) self.xrayeye_update_frame() @@ -304,9 +416,10 @@ class LamNIOpticsMixin: ) def loptics_in(self): - """Move in the LamNI optics (FZP + OSA).""" + """Move in the LamNI optics (FZP + OSA + central stop).""" self.lfzp_in() self.losa_in() + self.lcs_in() def _check_eye_out_and_optics_in(self, tol=0.003): """True if the setup is in measurement configuration: X-ray eye OUT, @@ -353,10 +466,11 @@ class LamNIOpticsMixin: return eye_out and optics_in and osa_in def loptics_out(self): - """Move out the LamNI optics.""" + """Move out the LamNI optics (central stop + OSA + FZP).""" if "rtx" in dev and dev.rtx.enabled: dev.rtx.controller.feedback_disable() + self.lcs_out() self.losa_out() loptx_out = self._get_user_param_safe("loptx", "out") lopty_out = self._get_user_param_safe("lopty", "out") @@ -370,10 +484,31 @@ class LamNIOpticsMixin: print(f"Interferometer feedback re-established ({time.time() - _t0:.1f} s).") def lcs_in(self): - pass + lcsx_in = self._get_user_param_safe("lcsx", "in") + lcsy_in = self._get_user_param_safe("lcsy", "in") + + current_lcsx = dev.lcsx.readback.get() + current_lcsy = dev.lcsy.readback.get() + + tol = 0.003 + + need_move_cs = not np.isclose(current_lcsx, lcsx_in, atol=tol) or not np.isclose( + current_lcsy, lcsy_in, atol=tol + ) + + if need_move_cs: + umv(dev.lcsx, lcsx_in, dev.lcsy, lcsy_in) + else: + print("Central stop is already at the IN position.") def lcs_out(self): - umv(dev.lcsy, 3) + # only lcsy has an "out" position; lcsx stays where it is + lcsy_out = self._get_user_param_safe("lcsy", "out") + + if not np.isclose(dev.lcsy.readback.get(), lcsy_out, atol=0.003): + umv(dev.lcsy, lcsy_out) + else: + print("Central stop is already at the OUT position.") def losa_in(self): losax_in = self._get_user_param_safe("losax", "in") @@ -421,32 +556,55 @@ class LamNIOpticsMixin: else: print("OSA is already at the OUT position.") + # the 30 nm FZP sits in an additional holder that puts the chip this + # much further downstream (closer to the sample) than the 60 nm FZP. + # Calibrated 2026-10-14 for a sample on the downstream-facing side of the + # sample holder: 250 um / 30 nm FZP at 7.9 keV (focal distance 47.79 mm), + # sample 1 mm beyond the focus at loptz 80.8548 + # -> 137.6 - 80.8548 - 48.79 = 7.955 mm. + _FZP_30NM_HOLDER_OFFSET = 7.955 # mm + + def _fzp_sample_distance(self, loptz_val, outermost_zone_width_nm): + """FZP-to-sample distance in mm for the given loptz position and FZP type.""" + distance = -loptz_val + 85.6 + 52 + if outermost_zone_width_nm == 30: + distance -= self._FZP_30NM_HOLDER_OFFSET + return distance + def lfzp_info(self, mokev_val=-1): if mokev_val == -1: + # The energy readback device is "ccm_energy" (same as flomni's + # ffzp_info() and LamNI.write_pdf_report()); the legacy "mokev" + # device is not in the current configs, so this always bailed out. try: - mokev_val = dev.mokev.readback.get() + mokev_val = dev.ccm_energy.read(cached=True)[dev.ccm_energy.name]["value"] except Exception: print( - "Device mokev does not exist. You can specify the energy in keV as an argument instead." + "Could not read the photon energy from device ccm_energy. " + "You can specify the energy in keV as an argument instead." ) return loptz_val = dev.loptz.read()["loptz"]["value"] - distance = -loptz_val + 85.6 + 52 - print(f"The sample is in a distance of {distance:.1f} mm from the FZP.") + distance = self._fzp_sample_distance(loptz_val, 60) + distance_30nm = self._fzp_sample_distance(loptz_val, 30) + print( + f"The sample is in a distance of {distance:.1f} mm from a 60 nm FZP and " + f"{distance_30nm:.1f} mm from a 30 nm FZP (extra holder, " + f"{self._FZP_30NM_HOLDER_OFFSET:.2f} mm further downstream)." + ) + + print(f"At the current energy of {mokev_val:.4f} keV we have following options:\n") + + wavelength = 1.2398e-9 / mokev_val diameters = [80e-6, 100e-6, 120e-6, 150e-6, 170e-6, 200e-6, 220e-6, 250e-6] console = Console() - table = Table( - title=f"At the current energy of {mokev_val:.4f} keV we have following options:", - box=box.SQUARE, - ) + table = Table(title="Outermost zone width \033[1m60 nm\033[0m", box=box.SQUARE) table.add_column("Diameter", justify="center") table.add_column("Focal distance", justify="center") table.add_column("Current beam size", justify="center") - wavelength = 1.2398e-9 / mokev_val - for diameter in diameters: outermost_zonewidth = 60e-9 focal_distance = diameter * outermost_zonewidth / wavelength @@ -459,9 +617,35 @@ class LamNIOpticsMixin: f"{beam_size:.2f} microns", ) + console.print(table) + + # Outermost zone width 30 nm -- diameters match flomni's 30nm FZP + # (ffzp_info(), flomni_optics_mixin.py) now that the zone plate + # mounted in LamNI is the same one mounted in Flomni. + diameters = [140e-6, 170e-6, 200e-6, 250e-6] + + console = Console() + table = Table(title="Outermost zone width \033[1m30 nm\033[0m", box=box.SQUARE) + table.add_column("Diameter", justify="center") + table.add_column("Focal distance", justify="center") + table.add_column("Current beam size", justify="center") + + for diameter in diameters: + outermost_zonewidth = 30e-9 + focal_distance = diameter * outermost_zonewidth / wavelength + beam_size = ( + -diameter / (focal_distance * 1000) * (focal_distance * 1000 - distance_30nm) * 1e6 + ) + table.add_row( + f"{diameter*1e6:.2f} microns", + f"{focal_distance*1000:.2f} mm", + f"{beam_size:.2f} microns", + ) + console.print(table) print( - "The numbers presented here are for a sample in the plane of the lamni sample holder.\n" + "The numbers presented here are for a sample facing the downstream side " + "of the lamni sample holder.\n" ) collision_offset = self._get_user_param_optional("loptz", "collision_offset") @@ -487,7 +671,7 @@ class LamNIOpticsMixin: print(f" Current losaz {losaz_val:.1f}") print( " The OSA will collide with a normal sample holder at losaz " - f"\033[1m{(collision_offset - losaz_val):.1f}\033[0m" + f"\033[1m{(collision_offset - loptz_val):.1f}\033[0m" ) print(f" Remaining space (right now): \033[1m{remaining_current:.1f}\033[0m") diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py index 0cff8843..25978ecf 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py @@ -3,6 +3,7 @@ from __future__ import annotations import builtins import os import time +from contextlib import contextmanager from typing import TYPE_CHECKING import h5py @@ -10,6 +11,8 @@ import numpy as np from bec_lib import bec_logger +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_alignment_mixin import XEYE_FIT_DATA_VAR + logger = bec_logger.logger bec = builtins.__dict__.get("bec") @@ -29,6 +32,62 @@ if TYPE_CHECKING: from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI +class SmearComposite: + """Running per-pixel projection of camera frames for the smear aid. + + projection="max" keeps the brightest value per pixel. projection="min" + keeps the darkest value per pixel, but skips pixels that are opaque in a + given frame: darker than ``opaque_level`` times that frame's bright + level (its 99th-percentile value). Without that, the sample holder, + which is opaque and rotates through the field of view, would blacken + every pixel it passes at any angle. A pixel that was opaque in every + frame falls back to its plain minimum. opaque_level=0 gives the plain + minimum everywhere. + """ + + def __init__(self, projection: str = "min", opaque_level: float = 0.25): + if projection not in ("min", "max"): + raise ValueError(f"projection must be 'min' or 'max', got {projection!r}") + if not 0 <= opaque_level < 1: + raise ValueError(f"opaque_level must be in [0, 1), got {opaque_level!r}") + self.projection = projection + self.opaque_level = opaque_level + self._plain = None # plain min or max over all frames + self._masked = None # min over non-opaque pixels only + self._seen = None # True where a pixel was non-opaque at least once + + @property + def shape(self): + return None if self._plain is None else self._plain.shape + + @property + def _plain_only(self) -> bool: + return self.projection == "max" or self.opaque_level == 0 + + def add(self, frame: np.ndarray) -> None: + if self._plain_only: + reduce = np.maximum if self.projection == "max" else np.minimum + self._plain = frame.copy() if self._plain is None else reduce(self._plain, frame) + return + open_px = frame > self.opaque_level * np.percentile(frame, 99) + if self._plain is None: + self._plain = frame.copy() + self._masked = frame.copy() + self._seen = open_px + return + self._plain = np.minimum(self._plain, frame) + # first non-opaque sighting of a pixel takes its value; after that, min + first = open_px & ~self._seen + self._masked = np.where(first, frame, self._masked) + self._masked = np.where(open_px, np.minimum(self._masked, frame), self._masked) + self._seen |= open_px + + def image(self) -> np.ndarray | None: + if self._plain_only or self._plain is None: + return self._plain + return np.where(self._seen, self._masked, self._plain) + + # Laminography alignment angles: full 360° in 45° steps LAMNI_ALIGNMENT_ANGLES = [k * 45 for k in range(8)] # [0, 45, 90, 135, 180, 225, 270, 315] @@ -55,6 +114,11 @@ class XrayEyeAlign: PIXEL_CALIBRATION_DEFAULT = 0.2 / 218 # mm/pixel (0.2 mm FOV, 218 px) PIXEL_CALIBRATION_USER_PARAM = "pixel_calibration" + # Fixed rotation (deg) of the elliptical ROI in the extended/smear + # rotation-center calibrations (set at the beamline 2026-10-14). The + # 8-angle X-ray eye alignment offers the ellipse with a free angle. + ROTATION_CENTER_ELLIPSE_ANGLE = -76.0 + def __init__(self, client, lamni: LamNI) -> None: self.client = client self.lamni = lamni @@ -189,7 +253,9 @@ class XrayEyeAlign: # opened at least once -- unlike align()/find_rotation_center(), this # is often the first X-ray-eye call of a session (see leye_in()), so # it must open the widget itself rather than assume it already exists. - if self.gui is None: + # It can also be a stale handle if the user closed the widget, which + # would raise DeletedWidgetError on the first RPC call below. + if self.lamni._lamnigui_is_missing("xeyegui"): self.lamni.lamnigui_show_xeyealign() if not dev.cam_xeye.live_mode_enabled.get(): dev.cam_xeye.live_mode_enabled.put(True) @@ -273,6 +339,35 @@ class XrayEyeAlign: return False def align(self, keep_shutter_open: bool = False): + """Run the full LamNI X-ray eye alignment (see _align_impl() for the + procedure). + + Whatever ends the run -- success, Ctrl-C or ANY other exception (a GUI + RPC error, a missing GUI output key, ...) -- the GUI is put back into + a clean state afterwards: DAP parameter forwarding off and the + alignment crosshair hidden. Previously only KeyboardInterrupt (handled + by the caller) did this, so any other failure left forwarding on and + the crosshair drawn. Mirrors flomni's XrayEyeAlign.align() try/finally + (LamNI has no skip button or ROI vertical lock to reset). + """ + try: + self.gui.reset_zoom() + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Failed to reset the XRayEye zoom: {exc}") + + try: + self._align_impl(keep_shutter_open) + finally: + try: + self.gui.set_dap_params_forwarding(False) + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Failed to disable XRayEye DAP parameter forwarding: {exc}") + try: + self.gui.hide_crosshair() + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Failed to hide XRayEye alignment crosshair: {exc}") + + def _align_impl(self, keep_shutter_open: bool = False): """Run the full LamNI X-ray eye alignment. Args: @@ -307,6 +402,8 @@ class XrayEyeAlign: # __init__ for why this can't live there. self.lamni.reset_correction() self.lamni.reset_xray_eye_correction() + self.lamni.manual_shift_x = 0 + self.lamni.manual_shift_y = 0 self.gui.enable_submit_button(False) @@ -341,179 +438,193 @@ class XrayEyeAlign: self.send_message("Submit centre of FZP.") k = 0 - while True: - if dev.omny_xray_gui.submit.get() == 1: - val_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get() * self.pixel_calibration - val_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get() * self.pixel_calibration - self.alignment_values[k] = [val_x, val_y] - print( - f"Clicked position {k}: " - f"x={self.alignment_values[k][0]:.4f} mm, " - f"y={self.alignment_values[k][1]:.4f} mm" - ) - dev.omny_xray_gui.submit.set(0) - - # Raw pixel position and ROI size at submit time. The - # relevant image is the last captured frame (shutter is - # closed by the time the user clicks submit). - _raw_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get() - _raw_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get() - _raw_w = getattr(dev.omny_xray_gui, f"width_x_{k}").get() - _raw_h = getattr(dev.omny_xray_gui, f"width_y_{k}").get() - _img_idx = len(self.alignment_images) - 1 - print( - f" Submit k={k}: px x={_raw_x:.1f} y={_raw_y:.1f} " - f"w={_raw_w:.1f} h={_raw_h:.1f} img={_img_idx}" - ) - self.roi_pixel_data.append([k, _raw_x, _raw_y, _raw_w, _raw_h, _img_idx]) - - # --- k=0: received FZP centre ---------------------------- - if k == 0: - self.send_message("Please wait - moving sample in...") - self.movement_buttons_enabled(False, False) - self.gui.enable_submit_button(False) - - self.lamni.loptics_out() - #self._disable_rt_feedback() - #time.sleep(0.3) - #self._enable_rt_feedback() - - self.update_frame(keep_shutter_open) - - # Mark the FZP centre on the live view: it stays visible - # as a fixed reference while the sample is aligned at - # each subsequent rotation angle below. - fzp_center_x = dev.omny_xray_gui.xval_x_0.get() - fzp_center_y = dev.omny_xray_gui.yval_y_0.get() - self.gui.set_crosshair_position(fzp_center_x, fzp_center_y) - self.gui.show_crosshair() - - self.send_message("Find the sample and submit its centre.") - self.gui.enable_submit_button(True) - self.movement_buttons_enabled(True, True) - - # --- k=1: sample at 0° - compute base shift -------------- - elif k == 1: - # Displacement from FZP centre to sample centre gives the - # correction needed to bring the sample onto the beam axis. - # Sign conventions match the old alignment.py. - self.shift_xy[0] += ( - self.alignment_values[0][0] - self.alignment_values[1][0] - ) * 1000 # um - self.shift_xy[1] += ( - self.alignment_values[1][1] - self.alignment_values[0][1] - ) * 1000 # um + # The ellipse tool is offered (angle free) for the sample clicks only, + # switched on after the FZP click in the k == 0 branch below. + try: + while True: + if dev.omny_xray_gui.submit.get() == 1: + val_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get() * self.pixel_calibration + val_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get() * self.pixel_calibration + self.alignment_values[k] = [val_x, val_y] print( - f"Base shift: " - f"x={self.shift_xy[0]:.2f} um, " - f"y={self.shift_xy[1]:.2f} um" + f"Clicked position {k}: " + f"x={self.alignment_values[k][0]:.4f} mm, " + f"y={self.alignment_values[k][1]:.4f} mm" ) + dev.omny_xray_gui.submit.set(0) - self.send_message("Please wait - moving to scan centre...") - self.movement_buttons_enabled(False, False) - self.gui.enable_submit_button(False) + # Raw pixel position and ROI size at submit time. The + # relevant image is the last captured frame (shutter is + # closed by the time the user clicks submit). + _raw_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get() + _raw_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get() + _raw_w = getattr(dev.omny_xray_gui, f"width_x_{k}").get() + _raw_h = getattr(dev.omny_xray_gui, f"width_y_{k}").get() + _img_idx = len(self.alignment_images) - 1 + print( + f" Submit k={k}: px x={_raw_x:.1f} y={_raw_y:.1f} " + f"w={_raw_w:.1f} h={_raw_h:.1f} img={_img_idx}" + ) + self.roi_pixel_data.append([k, _raw_x, _raw_y, _raw_w, _raw_h, _img_idx]) - self.scans.lamni_move_to_scan_center( - shift_x=self.shift_xy[0] / 1000, - shift_y=self.shift_xy[1] / 1000, - angle=float(self.get_tomo_angle()), - ).wait() - time.sleep(1) - self.scans.lamni_move_to_scan_center( - shift_x=self.shift_xy[0] / 1000, - shift_y=self.shift_xy[1] / 1000, - angle=float(self.get_tomo_angle()), - ).wait() + # --- k=0: received FZP centre ---------------------------- + if k == 0: + self.send_message("Please wait - moving sample in...") + self.movement_buttons_enabled(False, False) + self.gui.enable_submit_button(False) - dev.omny_xray_gui.angle.set(self.get_tomo_angle()) - self.update_frame(keep_shutter_open) - self.send_message("Submit sample centre and FOV (0 deg).") - self.gui.enable_submit_button(True) - self.movement_buttons_enabled(True, False) - self.update_fov(k) + self.lamni.loptics_out() + #self._disable_rt_feedback() + #time.sleep(0.3) + #self._enable_rt_feedback() - # --- k=2..8: sample at 45, 90, ..., 315 deg ------------- - elif 1 < k <= 8: - self.send_message("Please wait - rotating...") - self.gui.enable_submit_button(False) - self.movement_buttons_enabled(False, False) + self.update_frame(keep_shutter_open) + + # Mark the FZP centre on the live view: it stays visible + # as a fixed reference while the sample is aligned at + # each subsequent rotation angle below. + fzp_center_x = dev.omny_xray_gui.xval_x_0.get() + fzp_center_y = dev.omny_xray_gui.yval_y_0.get() + self.gui.set_crosshair_position(fzp_center_x, fzp_center_y) + self.gui.show_crosshair() + + self.gui.enable_ellipse_roi(True) + self.send_message("Find the sample and submit its centre.") + self.gui.enable_submit_button(True) + self.movement_buttons_enabled(True, True) + + # --- k=1: sample at 0° - compute base shift -------------- + elif k == 1: + # Displacement from FZP centre to sample centre gives the + # correction needed to bring the sample onto the beam axis. + # Sign conventions match the old alignment.py. + self.shift_xy[0] += ( + self.alignment_values[0][0] - self.alignment_values[1][0] + ) * 1000 # um + self.shift_xy[1] += ( + self.alignment_values[1][1] - self.alignment_values[0][1] + ) * 1000 # um + print( + f"Base shift: " + f"x={self.shift_xy[0]:.2f} um, " + f"y={self.shift_xy[1]:.2f} um" + ) + + self.send_message("Please wait - moving to scan centre...") + self.movement_buttons_enabled(False, False) + self.gui.enable_submit_button(False) - target_angle = LAMNI_ALIGNMENT_ANGLES[k - 1] # 45...315 deg - if keep_shutter_open: - # Rotate via the halfway point between previous and - # current angle so the sample is seen sweeping - # smoothly through the intermediate position in live - # view. Only worth the extra move when the shutter - # is open and someone is actually watching -- with - # the shutter closed, jump straight to target_angle - # below. - prev_angle = LAMNI_ALIGNMENT_ANGLES[k - 2] - approach_angle = prev_angle + (target_angle - prev_angle) / 2 - self._disable_rt_feedback() - self.tomo_rotate(approach_angle) self.scans.lamni_move_to_scan_center( shift_x=self.shift_xy[0] / 1000, shift_y=self.shift_xy[1] / 1000, angle=float(self.get_tomo_angle()), ).wait() - self._disable_rt_feedback() - self.tomo_rotate(target_angle) - self.scans.lamni_move_to_scan_center( - shift_x=self.shift_xy[0] / 1000, - shift_y=self.shift_xy[1] / 1000, - angle=float(self.get_tomo_angle()), - ).wait() + time.sleep(1) + self.scans.lamni_move_to_scan_center( + shift_x=self.shift_xy[0] / 1000, + shift_y=self.shift_xy[1] / 1000, + angle=float(self.get_tomo_angle()), + ).wait() - dev.omny_xray_gui.angle.set(self.get_tomo_angle()) - self.update_frame(keep_shutter_open) - self.send_message(f"Submit sample centre ({target_angle} deg).") - self.gui.enable_submit_button(True) - self.movement_buttons_enabled(True, False) - self.update_fov(k) + dev.omny_xray_gui.angle.set(self.get_tomo_angle()) + self.update_frame(keep_shutter_open) + self.send_message("Submit sample centre and FOV (0 deg).") + self.gui.enable_submit_button(True) + self.movement_buttons_enabled(True, False) + self.update_fov(k) - # --- k=9: all angles collected - finish ------------------ - elif k == 9: - self.send_message("All angles collected - computing fit...") - self.gui.enable_submit_button(False) - self.movement_buttons_enabled(False, False) - self.update_fov(k) - self.gui.hide_crosshair() - break + # --- k=2..8: sample at 45, 90, ..., 315 deg ------------- + elif 1 < k <= 8: + self.send_message("Please wait - rotating...") + self.gui.enable_submit_button(False) + self.movement_buttons_enabled(False, False) - k += 1 - dev.omny_xray_gui.step.set(k) + target_angle = LAMNI_ALIGNMENT_ANGLES[k - 1] # 45...315 deg + if keep_shutter_open: + # Rotate via the halfway point between previous and + # current angle so the sample is seen sweeping + # smoothly through the intermediate position in live + # view. Only worth the extra move when the shutter + # is open and someone is actually watching -- with + # the shutter closed, jump straight to target_angle + # below. + prev_angle = LAMNI_ALIGNMENT_ANGLES[k - 2] + approach_angle = prev_angle + (target_angle - prev_angle) / 2 + self._disable_rt_feedback() + self.tomo_rotate(approach_angle) + self.scans.lamni_move_to_scan_center( + shift_x=self.shift_xy[0] / 1000, + shift_y=self.shift_xy[1] / 1000, + angle=float(self.get_tomo_angle()), + ).wait() + self._disable_rt_feedback() + self.tomo_rotate(target_angle) + self.scans.lamni_move_to_scan_center( + shift_x=self.shift_xy[0] / 1000, + shift_y=self.shift_xy[1] / 1000, + angle=float(self.get_tomo_angle()), + ).wait() - # Handle live motor movement buttons. - # Only active during k=0 (FZP) and k=1 (sample at 0 deg) – both - # x and y movements accumulate into shift_xy and are applied via - # lamni_move_to_scan_center so the laminography coordinate - # transformation is correctly applied. This matches the old - # alignment.py exactly. - if k < 2: - _mvx = dev.omny_xray_gui.mvx.get() - _mvy = dev.omny_xray_gui.mvy.get() - if _mvx != 0 or _mvy != 0: - self.shift_xy[0] += _mvx - self.shift_xy[1] += _mvy - self.scans.lamni_move_to_scan_center( - shift_x=self.shift_xy[0] / 1000, - shift_y=self.shift_xy[1] / 1000, - angle=float(self.get_tomo_angle()), - ).wait() - print( - f"Current centre: " - f"horizontal={self.shift_xy[0]:.2f} um, " - f"vertical={self.shift_xy[1]:.2f} um" - ) - dev.omny_xray_gui.mvx.set(0) - dev.omny_xray_gui.mvy.set(0) - self.update_frame(keep_shutter_open) + dev.omny_xray_gui.angle.set(self.get_tomo_angle()) + self.update_frame(keep_shutter_open) + self.send_message(f"Submit sample centre ({target_angle} deg).") + self.gui.enable_submit_button(True) + self.movement_buttons_enabled(True, False) + self.update_fov(k) - time.sleep(0.2) + # --- k=9: all angles collected - finish ------------------ + elif k == 9: + self.send_message("All angles collected - computing fit...") + self.gui.enable_submit_button(False) + self.movement_buttons_enabled(False, False) + self.update_fov(k) + self.gui.hide_crosshair() + break + + k += 1 + dev.omny_xray_gui.step.set(k) + + # Handle live motor movement buttons. + # Only active during k=0 (FZP) and k=1 (sample at 0 deg) – both + # x and y movements accumulate into shift_xy and are applied via + # lamni_move_to_scan_center so the laminography coordinate + # transformation is correctly applied. This matches the old + # alignment.py exactly. + if k < 2: + _mvx = dev.omny_xray_gui.mvx.get() + _mvy = dev.omny_xray_gui.mvy.get() + if _mvx != 0 or _mvy != 0: + self.shift_xy[0] += _mvx + self.shift_xy[1] += _mvy + self.scans.lamni_move_to_scan_center( + shift_x=self.shift_xy[0] / 1000, + shift_y=self.shift_xy[1] / 1000, + angle=float(self.get_tomo_angle()), + ).wait() + print( + f"Current centre: " + f"horizontal={self.shift_xy[0]:.2f} um, " + f"vertical={self.shift_xy[1]:.2f} um" + ) + dev.omny_xray_gui.mvx.set(0) + dev.omny_xray_gui.mvy.set(0) + self.update_frame(keep_shutter_open) + + time.sleep(0.2) + finally: + try: + self.gui.enable_ellipse_roi(False) + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Failed to disable the XRayEye ellipse ROI: {exc}") # ------------------------------------------------------------------ # All submits received - compute offsets and push to GUI fit tab # ------------------------------------------------------------------ + # Clear the previous run's fit parameters first: they'd otherwise + # satisfy _wait_for_dap_fit() immediately and be read back as this + # run's fit before DAP has fitted the new points. + dev.omny_xray_gui.fit_params_x.set({}) + dev.omny_xray_gui.fit_params_y.set({}) self.write_output() fovx = self._xray_fov_xy[0] * self.pixel_calibration * 1000 / 2 @@ -556,7 +667,7 @@ class XrayEyeAlign: self.tomo_rotate(0) print( "Done. You are ready to remove the X-ray eye and start ptychography scans.\n" - "Fine alignment: lamni.tomo_parameters() with offset 0, then lamni.sub_tomo_scan(1,0)" + "Fine alignment: lamni.tomo_parameters(), then lamni.tomo_alignment_scan()" ) # ------------------------------------------------------------------ @@ -643,6 +754,8 @@ class XrayEyeAlign: timestamp = time.strftime("%Y%m%d_%H%M%S") file_h5 = f"~/data/raw/logs/xrayeye_alignmentvalues/xrayeye_alignmentvalues_{timestamp}.h5" self._save_alignment_data(file_h5, fit_data=data) + # Raw offsets for lamni.xrayeye_refit(), which can leave out bad clicks + self.client.set_global_var(XEYE_FIT_DATA_VAR, data.tolist()) # Push to XRayEye widget: feeds waveform_x (row 1) and waveform_y (row 2) self.gui.submit_fit_array(data) @@ -677,16 +790,81 @@ class XrayEyeAlign: ROTATION_CENTER_SAMPLE_TYPES = ("isolated", "extended") - def _collect_click(self, k: int, message: str, label: str = "") -> tuple[float, float]: + def _handle_rotation_center_arrows(self, keep_shutter_open: bool = False): + """Apply a pending GUI arrow-button move during rotation-center + calibration. + + mvx/mvy (um) are accumulated into self._rc_shift (mm, lamni coords, + absolute offset from the configured lsamx/lsamy center) and applied + via lamni_move_to_scan_center with angle=0.0: lsamx/lsamy sit below + the rotation stage, so the move is a plain image translation at any + lsamrot angle (see the math note above). The caller must map each + click back to the configured-center frame using the shift active at + click time. + """ + _mvx = dev.omny_xray_gui.mvx.get() + _mvy = dev.omny_xray_gui.mvy.get() + if _mvx == 0 and _mvy == 0: + return + self._rc_shift[0] += _mvx / 1000 + self._rc_shift[1] += _mvy / 1000 + self.scans.lamni_move_to_scan_center( + shift_x=self._rc_shift[0], shift_y=self._rc_shift[1], angle=0.0 + ).wait() + print( + f"[rotation-center] arrow move -- shift from configured center now " + f"({self._rc_shift[0]:.4f}, {self._rc_shift[1]:.4f}) mm" + ) + dev.omny_xray_gui.mvx.set(0) + dev.omny_xray_gui.mvy.set(0) + self.update_frame(keep_shutter_open) + + @contextmanager + def _ellipse_roi_enabled(self, lock_angle: float | None = None): + """Offer the rotatable elliptical ROI in the GUI for the enclosed clicks. + + Used by the extended/smear rotation-centre calibrations, always entered + after the FZP click (step 0), so the FZP and isolated-particle clicks keep + the rectangle/circle tools. Always switched off again (which also removes + the ellipse and releases its angle lock). + + Args: + lock_angle: if given, the ellipse rotation is fixed to this angle (deg). + """ + self.gui.enable_ellipse_roi(True) + if lock_angle is not None: + self.gui.lock_roi_angle(lock_angle) + try: + yield + finally: + self.gui.enable_ellipse_roi(False) + + def _collect_click( + self, + k: int, + message: str, + label: str = "", + arrows: bool = False, + keep_shutter_open: bool = False, + ) -> tuple[float, float]: """Wait for the operator to submit a click at GUI step *k*. + If *arrows* is True, GUI arrow-button presses are applied while + waiting (see _handle_rotation_center_arrows()). + Returns the click position in mm (converted via pixel_calibration). """ print(f"[rotation-center] step {k}: waiting for submit -- {message}") + if arrows: + # ignore any stale press from before this step + dev.omny_xray_gui.mvx.set(0) + dev.omny_xray_gui.mvy.set(0) dev.omny_xray_gui.step.set(k) self.send_message(message) self.gui.enable_submit_button(True) while True: + if arrows: + self._handle_rotation_center_arrows(keep_shutter_open) if dev.omny_xray_gui.submit.get() == 1: raw_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get() raw_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get() @@ -726,7 +904,7 @@ class XrayEyeAlign: print("[rotation-center] live sweep done") # --- EXPERIMENTAL: rotation-center smear mode ------------------------- - # Max-projection "star-trail" composite aid for the extended-sample + # Min- or max-projection "star-trail" composite aid for the extended-sample # rotation-center click, built while lsamrot rotates continuously. # Operator-visual only -- no automatic circle fitting. See # find_rotation_center_smear_experimental() below for the entry point. @@ -754,9 +932,21 @@ class XrayEyeAlign: keep_shutter_open: bool = False, display_update_interval_s: float = 1.0, poll_interval_s: float = 0.15, + projection: str = "min", + opaque_level: float = 0.25, ) -> np.ndarray: """Rotate lsamrot continuously through sweep_deg while accumulating - a max-projection composite of camera frames grabbed along the way. + a min- or max-projection composite of camera frames grabbed along the way. + + projection="min" (default) keeps the darkest value per pixel and is + meant for absorption contrast (dark features on a bright background): + a max-projection would let the bright background that passes every + pixel at some point wash those features out. projection="max" keeps + the brightest value, for bright features on a dark background. + With "min", pixels darker than opaque_level times a frame's bright + level are treated as opaque (sample holder) in that frame and + skipped, so the rotating holder does not black out the field of + view; see SmearComposite. opaque_level=0 gives the plain minimum. Off-axis features smear into circular arcs as the sample rotates; the common center of curvature of those arcs is the rotation axis @@ -787,9 +977,10 @@ class XrayEyeAlign: The shutter is closed unless keep_shutter_open, so the operator isn't exposing the sample while deciding where to click. """ + accumulator = SmearComposite(projection=projection, opaque_level=opaque_level) print( - f"[rotation-center][smear] EXPERIMENTAL feature: max-projection composite aid, " - "operator-visual only -- no automatic circle fitting." + f"[rotation-center][smear] EXPERIMENTAL feature: {projection}-projection composite " + "aid, operator-visual only -- no automatic circle fitting." ) if not dev.cam_xeye.live_mode_enabled.get(): dev.cam_xeye.live_mode_enabled.put(True) @@ -807,7 +998,6 @@ class XrayEyeAlign: ) report = mv(self.device_manager.devices.lsamrot, target) - composite = None last_frame = None frames_seen = 0 last_push = time.time() @@ -819,22 +1009,22 @@ class XrayEyeAlign: try: while report.status != "COMPLETED": frame = dev.cam_xeye.get_last_image() - if composite is None: - composite = frame.copy() + if accumulator.shape is None: + accumulator.add(frame) first_frame_time = time.time() - elif frame.shape != composite.shape: + elif frame.shape != accumulator.shape: print( f"[rotation-center][smear] skipping frame with mismatched shape " - f"{frame.shape} != {composite.shape}" + f"{frame.shape} != {accumulator.shape}" ) else: if last_frame is None or not np.array_equal(frame, last_frame): frames_seen += 1 last_distinct_time = time.time() - composite = np.maximum(composite, frame) + accumulator.add(frame) last_frame = frame if time.time() - last_push > display_update_interval_s: - self._push_smear_composite(composite) + self._push_smear_composite(accumulator.image()) last_push = time.time() time.sleep(poll_interval_s) except KeyboardInterrupt: @@ -844,6 +1034,7 @@ class XrayEyeAlign: self.gui.set_live_view_signal("image") raise + composite = accumulator.image() self._push_smear_composite(composite) self.gui.set_smear_active(False) if not keep_shutter_open: @@ -867,10 +1058,15 @@ class XrayEyeAlign: return composite def find_rotation_center_smear_experimental( - self, keep_shutter_open: bool = False, apply: bool = True, sweep_deg: float = 360.0 + self, + keep_shutter_open: bool = False, + apply: bool = True, + sweep_deg: float = 360.0, + projection: str = "min", + opaque_level: float = 0.25, ): """EXPERIMENTAL: extended-sample rotation-center calibration aided - by a continuous-rotation max-projection composite (see + by a continuous-rotation min- or max-projection composite (see _smear_sweep()), instead of a single instant of live rotation. Otherwise identical in structure to find_rotation_center(sample_type="extended"): @@ -886,10 +1082,19 @@ class XrayEyeAlign: sweep_deg: angular range of the continuous smear sweep. Defaults to a full 360 deg (complete arcs), but a smaller range may already show enough curvature to locate the center. + projection: "min" (default) for absorption contrast, i.e. dark + features on a bright background; "max" for bright features + on a dark background. See _smear_sweep(). + opaque_level: with projection="min", pixels darker than this + fraction of a frame's bright level are treated as opaque + (sample holder) and skipped; 0 = plain minimum. See + SmearComposite. Returns: tuple: (new_lsamx_center, new_lsamy_center) in mm. """ + # validate before anything moves + SmearComposite(projection=projection, opaque_level=opaque_level) self._prompt_sample_name() self.lamni.lamnigui_show_xeyealign() self._sync_sample_name() @@ -906,7 +1111,8 @@ class XrayEyeAlign: print( "[rotation-center][smear] starting find_rotation_center_smear_experimental" - f"(sweep_deg={sweep_deg!r})" + f"(sweep_deg={sweep_deg!r}, projection={projection!r}, " + f"opaque_level={opaque_level!r})" ) self.send_message("Getting things ready. Please wait...") @@ -924,51 +1130,57 @@ class XrayEyeAlign: self.gui.set_crosshair_position(fzp_x / self.pixel_calibration, fzp_y / self.pixel_calibration) self.gui.show_crosshair() - iteration = 0 - cumulative_shift_x, cumulative_shift_y = 0.0, 0.0 - while True: - iteration += 1 - print(f"[rotation-center][smear] === iteration {iteration} ===") - self.send_message( - "Watch the sample rotate and locate the stationary rotation centre..." - ) - composite = self._smear_sweep(sweep_deg=sweep_deg, keep_shutter_open=keep_shutter_open) - self.movement_buttons_enabled(True, True) - center_x, center_y = self._collect_click( - 1, - "Submit the rotation centre.", - label=f"rotation centre, iter {iteration}", - ) - self.gui.set_live_view_signal("image") - self.smear_composites.append(composite) - self.smear_clicks.append((center_x, center_y)) - delta_x, delta_y = self._compute_shift_to_fzp(fzp_x, fzp_y, center_x, center_y) - cumulative_shift_x += delta_x - cumulative_shift_y += delta_y - print( - f"[rotation-center][smear] cumulative shift from originally-configured center: " - f"({cumulative_shift_x:.4f}, {cumulative_shift_y:.4f}) mm" - ) - self._apply_rotation_center_shift(cumulative_shift_x, cumulative_shift_y) - - self.send_message("Verifying alignment...") - # No 45 deg detour here (unlike the non-smear path below): the - # _smear_sweep above already rotated continuously through the - # full range with the operator watching, so a stop at 45 deg - # would just re-tread ground already covered -- go straight back - # to 0 deg (still a full ~360 deg move since lsamrot doesn't - # wrap) with live view running. - self._live_sweep([0]) - self.update_frame(keep_shutter_open) - if self.lamni.OMNYTools.yesno( - "Alignment acceptable -- stop here? (n = run another iteration)", "y" - ): - print( - f"[rotation-center][smear] operator accepted alignment after " - f"{iteration} iteration(s)" + with self._ellipse_roi_enabled(lock_angle=self.ROTATION_CENTER_ELLIPSE_ANGLE): + iteration = 0 + cumulative_shift_x, cumulative_shift_y = 0.0, 0.0 + while True: + iteration += 1 + print(f"[rotation-center][smear] === iteration {iteration} ===") + self.send_message( + "Watch the sample rotate and locate the stationary rotation centre..." ) - break - print("[rotation-center][smear] operator requested another iteration.") + composite = self._smear_sweep( + sweep_deg=sweep_deg, + keep_shutter_open=keep_shutter_open, + projection=projection, + opaque_level=opaque_level, + ) + self.movement_buttons_enabled(True, True) + center_x, center_y = self._collect_click( + 1, + "Submit the rotation centre.", + label=f"rotation centre, iter {iteration}", + ) + self.gui.set_live_view_signal("image") + self.smear_composites.append(composite) + self.smear_clicks.append((center_x, center_y)) + delta_x, delta_y = self._compute_shift_to_fzp(fzp_x, fzp_y, center_x, center_y) + cumulative_shift_x += delta_x + cumulative_shift_y += delta_y + print( + f"[rotation-center][smear] cumulative shift from originally-configured center: " + f"({cumulative_shift_x:.4f}, {cumulative_shift_y:.4f}) mm" + ) + self._apply_rotation_center_shift(cumulative_shift_x, cumulative_shift_y) + + self.send_message("Verifying alignment...") + # No 45 deg detour here (unlike the non-smear path below): the + # _smear_sweep above already rotated continuously through the + # full range with the operator watching, so a stop at 45 deg + # would just re-tread ground already covered -- go straight back + # to 0 deg (still a full ~360 deg move since lsamrot doesn't + # wrap) with live view running. + self._live_sweep([0]) + self.update_frame(keep_shutter_open) + if self.lamni.OMNYTools.yesno( + "Alignment acceptable -- stop here? (n = run another iteration)", "y" + ): + print( + f"[rotation-center][smear] operator accepted alignment after " + f"{iteration} iteration(s)" + ) + break + print("[rotation-center][smear] operator requested another iteration.") self.gui.hide_crosshair() new_lsamx = self.device_manager.devices.lsamx.readback.read()["lsamx"]["value"] @@ -983,7 +1195,7 @@ class XrayEyeAlign: if apply: if self.lamni.OMNYTools.yesno( f"Update lsamx/lsamy center user parameters to " - f"({new_lsamx:.4f}, {new_lsamy:.4f})?", + f"({new_lsamx:.4f}, {new_lsamy:.4f})?{self._erase_alignment_warning()}", "y", ): dev.lsamx.update_user_parameter({"center": float(new_lsamx)}) @@ -1017,6 +1229,8 @@ class XrayEyeAlign: fzp_xy=(fzp_x, fzp_y), new_center=(new_lsamx, new_lsamy), sweep_deg=sweep_deg, + projection=projection, + opaque_level=opaque_level, ) return (new_lsamx, new_lsamy) @@ -1027,6 +1241,8 @@ class XrayEyeAlign: fzp_xy: tuple[float, float], new_center: tuple[float, float], sweep_deg: float, + projection: str = "min", + opaque_level: float = 0.25, ): """Archive the raw record of a smear calibration run: one composite image and one clicked centre position per iteration, plus the FZP @@ -1045,6 +1261,8 @@ class XrayEyeAlign: ds.attrs["columns"] = ["center_x_mm", "center_y_mm"] f.create_dataset("new_center_mm", data=np.array(new_center)) f.attrs["sweep_deg"] = sweep_deg + f.attrs["projection"] = projection + f.attrs["opaque_level"] = opaque_level print(f"[rotation-center][smear] saved calibration record to {expanded}") # --- end EXPERIMENTAL: rotation-center smear mode --------------------- @@ -1143,6 +1361,18 @@ class XrayEyeAlign: f"interferometer rtx/rty now read ({rtx_after:.2f}, {rty_after:.2f}) um" ) + def _erase_alignment_warning(self) -> str: + """Suffix for the "apply new rotation center?" prompt, naming the + stored alignment that _mark_center_found_and_invalidate_downstream() + would erase. Empty if there is none.""" + existing = self.lamni._existing_alignment() + if not existing: + return "" + return ( + f"\nWARNING: this will erase the existing {' and '.join(existing)} " + "for this sample." + ) + def _mark_center_found_and_invalidate_downstream(self): """Record that the rotation centre was just (re-)established, and invalidate any X-ray-eye/fine-alignment state calibrated around the @@ -1175,7 +1405,9 @@ class XrayEyeAlign: submitted at 0 deg and again at 180 deg; the midpoint of the two is the rotation axis' projected position, independent of the axis tilt (see the module-level math note above). No visual - judgement call and no iteration. + judgement call and no iteration. The GUI arrow buttons work at + both angles (e.g. if the particle starts outside the FOV); their + lsamx/lsamy moves are tracked and subtracted from the clicks. "extended" -- for a non-isolated/textured sample where the rotation centre can be visually identified directly. The sample is swept @@ -1237,61 +1469,90 @@ class XrayEyeAlign: self.gui.show_crosshair() if sample_type == "isolated": + # Running arrow-button shift from the configured center (stage is + # there now, see _ensure_at_configured_center above). + self._rc_shift = [0.0, 0.0] self.movement_buttons_enabled(True, True) - p0 = self._collect_click(1, "Submit particle centre (0 deg).", label="particle @0deg") + p0 = self._collect_click( + 1, + "Submit particle centre (0 deg).", + label="particle @0deg", + arrows=True, + keep_shutter_open=keep_shutter_open, + ) + s0 = tuple(self._rc_shift) self._disable_rt_feedback() self.tomo_rotate(180) self.update_frame(keep_shutter_open) + self.movement_buttons_enabled(True, True) p180 = self._collect_click( - 2, "Submit the SAME particle's centre (180 deg).", label="particle @180deg" + 2, + "Submit the SAME particle's centre (180 deg).", + label="particle @180deg", + arrows=True, + keep_shutter_open=keep_shutter_open, ) - center_x, center_y = (p0[0] + p180[0]) / 2, (p0[1] + p180[1]) / 2 + s180 = tuple(self._rc_shift) + self.movement_buttons_enabled(False, False) + # Map both clicks back to the configured-center frame: a shift s + # moves the image by +s_x in x and -s_y in y (same convention as + # _compute_shift_to_fzp). The midpoint is then the axis position + # with the stage at the configured center, so the target below is + # directly the absolute shift from that center. + p0_ref = (p0[0] - s0[0], p0[1] + s0[1]) + p180_ref = (p180[0] - s180[0], p180[1] + s180[1]) print( - f"[rotation-center] midpoint of ({p0[0]:.4f},{p0[1]:.4f}) and " - f"({p180[0]:.4f},{p180[1]:.4f}) = ({center_x:.4f},{center_y:.4f}) mm " - "-- this is the rotation axis' current projected position" + f"[rotation-center] arrow shift at 0 deg click: ({s0[0]:.4f},{s0[1]:.4f}) mm, " + f"at 180 deg click: ({s180[0]:.4f},{s180[1]:.4f}) mm" + ) + center_x, center_y = (p0_ref[0] + p180_ref[0]) / 2, (p0_ref[1] + p180_ref[1]) / 2 + print( + f"[rotation-center] midpoint of ({p0_ref[0]:.4f},{p0_ref[1]:.4f}) and " + f"({p180_ref[0]:.4f},{p180_ref[1]:.4f}) = ({center_x:.4f},{center_y:.4f}) mm " + "-- this is the rotation axis' projected position at the configured center" ) self._disable_rt_feedback() self.tomo_rotate(0) target_x, target_y = self._compute_shift_to_fzp(fzp_x, fzp_y, center_x, center_y) self._apply_rotation_center_shift(target_x, target_y) else: - iteration = 0 - cumulative_shift_x, cumulative_shift_y = 0.0, 0.0 - while True: - iteration += 1 - print(f"[rotation-center] === iteration {iteration} ===") - self.send_message( - "Watch the sample rotate and locate the stationary rotation centre..." - ) - self._live_sweep([180, 0]) - self.update_frame(keep_shutter_open) - self.movement_buttons_enabled(True, True) - center_x, center_y = self._collect_click( - 1, - "Submit the rotation centre.", - label=f"rotation centre, iter {iteration}", - ) - delta_x, delta_y = self._compute_shift_to_fzp(fzp_x, fzp_y, center_x, center_y) - cumulative_shift_x += delta_x - cumulative_shift_y += delta_y - print( - f"[rotation-center] cumulative shift from originally-configured center: " - f"({cumulative_shift_x:.4f}, {cumulative_shift_y:.4f}) mm" - ) - self._apply_rotation_center_shift(cumulative_shift_x, cumulative_shift_y) - - self.send_message("Verifying alignment...") - self._live_sweep([45, 0]) - self.update_frame(keep_shutter_open) - if self.lamni.OMNYTools.yesno( - "Alignment acceptable -- stop here? (n = run another iteration)", "y" - ): - print( - f"[rotation-center] operator accepted alignment after {iteration} iteration(s)" + with self._ellipse_roi_enabled(lock_angle=self.ROTATION_CENTER_ELLIPSE_ANGLE): + iteration = 0 + cumulative_shift_x, cumulative_shift_y = 0.0, 0.0 + while True: + iteration += 1 + print(f"[rotation-center] === iteration {iteration} ===") + self.send_message( + "Watch the sample rotate and locate the stationary rotation centre..." ) - break - print("[rotation-center] operator requested another iteration.") + self._live_sweep([180, 0]) + self.update_frame(keep_shutter_open) + self.movement_buttons_enabled(False, False) + center_x, center_y = self._collect_click( + 1, + "Submit the rotation centre.", + label=f"rotation centre, iter {iteration}", + ) + delta_x, delta_y = self._compute_shift_to_fzp(fzp_x, fzp_y, center_x, center_y) + cumulative_shift_x += delta_x + cumulative_shift_y += delta_y + print( + f"[rotation-center] cumulative shift from originally-configured center: " + f"({cumulative_shift_x:.4f}, {cumulative_shift_y:.4f}) mm" + ) + self._apply_rotation_center_shift(cumulative_shift_x, cumulative_shift_y) + + self.send_message("Verifying alignment...") + self._live_sweep([45, 0]) + self.update_frame(keep_shutter_open) + if self.lamni.OMNYTools.yesno( + "Alignment acceptable -- stop here? (n = run another iteration)", "y" + ): + print( + f"[rotation-center] operator accepted alignment after {iteration} iteration(s)" + ) + break + print("[rotation-center] operator requested another iteration.") self.gui.hide_crosshair() new_lsamx = self.device_manager.devices.lsamx.readback.read()["lsamx"]["value"] @@ -1306,7 +1567,7 @@ class XrayEyeAlign: if apply: if self.lamni.OMNYTools.yesno( f"Update lsamx/lsamy center user parameters to " - f"({new_lsamx:.4f}, {new_lsamy:.4f})?", + f"({new_lsamx:.4f}, {new_lsamy:.4f})?{self._erase_alignment_warning()}", "y", ): dev.lsamx.update_user_parameter({"center": float(new_lsamx)}) diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/AI_docs/TODO_interferometer_signal_trend.md b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/AI_docs/TODO_interferometer_signal_trend.md new file mode 100644 index 00000000..d4514bed --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/AI_docs/TODO_interferometer_signal_trend.md @@ -0,0 +1,114 @@ +# TODO: interferometer signal-strength trend on the webpage + +Status: **proposed, not implemented.** Discussed 2026-09-19. This is a design +note to capture the idea before it's lost -- no code changes yet. + +## Goal + +A new section on the flomni/lamni/omny status webpage showing the trend of +interferometer signal strength over time (a sparkline/chart, not a live +readout), so drift/degradation is visible at a glance across a session or +beamtime, not just from an interactive `show_signal_strength_interferometer()` +call in the moment. + +## Sampling design + +- **Very low rate on purpose**: roughly one data point per hour, or less -- + explicitly *not* per-projection. A tomo scan already calls a signal-strength + check regularly (flomni: every projection, see below), but sampling that + often would produce far too many points for a trend view (tens of points + per hour per axis) for no benefit -- this is about long-term drift, not + per-projection diagnostics. +- **Timestamped, so per-axis cadence can differ freely.** Each data point is + `(timestamp, value)`; there's no requirement that every axis be sampled at + the same time or the same rate. An axis that's cheap to read opportunistically + (e.g. already fetched as a side effect of something else) can be sampled + more often than one that costs a dedicated round-trip, and the trend for + each axis is still valid independently. Staleness/eventual-consistency + between axes, or between this and the live scan, is fine -- the goal is a + general trend, not point-in-time precision. +- **Retention**: apply the lesson from `eps_alarm_history.json`'s HTTP 413 + bug (see `docs/developer/...` -- fixed 2026-09-18 in `eps_status_generator.py`, + `_MAX_UPLOAD_BYTES`/`_shrink_to_fit`): cap this ring buffer by *both* count + and a hard byte budget from the start, reusing that same pattern, not just + a count cap discovered insufficient after the fact. + +## Per-setup reality check (2026-09-19 survey -- see below for what's actually there) + +The three setups' interferometer-read primitives are **not interchangeable** +-- this can't be one generic helper at the RT-controller layer: + +| Setup | Read primitive | Axes/channels | Threshold/check helper | +|---|---|---|---| +| flomni | `RtFlomniController.read_ssi_interferometer(axis_number)` (`rt_flomni_ophyd.py:462`), generic `j{n}` socket read | 0-3 | `laser_tracker_check_signalstrength()` (`rt_flomni_ophyd.py:466`) -- reads axis 1, warns only, does not act | +| LamNI | **no equivalent single-axis reader.** `RtLamniController.show_signal_strength_interferometer()` (`rt_lamni_ophyd.py:268`) reads 3 channels (ST FZP horizontal/vertical, angle) via distinct `J2`/`J3`/`J7` commands | 3 (different meaning than flomni's 0-3) | none -- no `min_signal`/`low_signal` threshold logic exists in this file at all | +| omny | `RtOMNYController.laser_tracker_check_and_wait_for_signalstrength()` (`rt_omny_ophyd.py:270`) -- different behavior class: polls and *auto-triggers* `omny_interferometer_align_tracking()` if below `low_signal`, not just a warning. `show_signal_strength_interferometer()` (`rt_omny_ophyd.py:736`) reads 5 channels via a `self.ssi` dict from `feedback_get_status_and_ssi()` | 5 (OSA FZP Y/X, ST OSA Y/X, Angle) | yes, but actively corrective, not passive | + +There is no shared `csaxs_bec`-level RT-controller base across the three +(`RtFlomniController`/`RtLamniController`/`RtOMNYController` each subclass the +generic external `ophyd_devices.utils.controller.Controller` directly). LamNI +also doesn't reuse the one shared local module that does exist +(`csaxs_bec/devices/omny/rt/rt_ophyd.py`'s `RtSignalRO`/etc., used by flomni +and omny) -- it redefines its own parallel Signal classes. + +**Conclusion**: the shared piece of this feature should be the trend +storage/rendering (a common global-var shape + webpage section), not the +signal read itself. Each setup needs its own small adapter that knows its own +axis semantics and feeds the shared throttled-stash helper. + +## Where the throttled read would hook in, per setup + +- **flomni**: `laser_tracker_check_signalstrength()` is already called once + per projection during `tomo_scan()` (`flomni.py:3624`) -- cheapest option is + to throttle *at that call site* (check "have I stashed a data point in the + last hour?" before writing one), reusing an already-happening RT round-trip + rather than adding a new one. +- **LamNI**: confirmed (2026-09-19 survey) there is **no** scan-time + signal-strength call anywhere in the LamNI plugin -- `show_signal_strength` + (`lamni.py:724`) is a manual/interactive-only helper, never called from + `tomo_scan_projection` or `tomo_scan`. There's no existing per-projection + hook to throttle against, so this would need a separate low-rate timer + instead -- the natural fit is `WebpageGeneratorBase`'s own `_cycle()` loop + (`OMNY_shared/webpage_generator_base.py`, default 15s cadence, already runs + continuously including when idle), which already has a documented + subclass-override hook for instrument-specific data + (`_collect_setup_data()`, used today by `FlomniWebpageGenerator`/ + `LamniWebpageGenerator` for temperatures/sample name/etc.) -- add the + throttle check there instead of in the scan loop. +- **omny**: blocked on a prerequisite -- **omny has no working webpage + generator at all right now.** `omny_webpage_generator.py` looks like one + (defines `WebpageGeneratorBase`, `FlomniWebpageGenerator`, a + `make_webpage_generator()`, etc.) but is confirmed dead/orphaned: nothing + in `omny.py`/`omny/__init__.py` imports it, and the real factory + (`OMNY_shared/webpage_generator_base.py` ~line 1409) explicitly falls + through to the plain base class for the omny session with a comment noting + this file is "an unwired, stale duplicate." Building a real + `OmnyWebpageGenerator` subclass is a prerequisite for this feature on omny, + not just an extra per-setup adapter -- do not revive the stale duplicate + as-is. + +## Suggested shape (for whenever this is implemented) + +- A global var, e.g. `interferometer_signal_trend`, holding a small + ring-buffer list of `{timestamp, axis, value}` (or per-axis sub-lists), + written via a proxy class mirroring `_ProgressProxy`/`_TomoQueueProxy` + (`OMNY_shared/tomo_queue_mixin.py`) -- the established pattern in this + codebase for cross-process/cross-thread state via + `client.get_global_var`/`set_global_var`. +- A shared rendering piece (webpage HTML/JS section, sparkline or simple line + chart) that reads this same global var shape regardless of which setup + wrote it, so the display code doesn't need to know about per-setup axis + differences -- only each setup's adapter does. +- Byte-budget-capped storage per the eps history lesson above. + +## Open questions (not yet decided) + +- Exact axis(es) worth trending per setup -- flomni's axis 1 is free (already + read every projection); trending flomni's other 3 axes, or any of LamNI's/ + omny's channels, means new dedicated round-trips on whatever cadence is + chosen for those. +- Whether sampling should continue during idle time between scans (needs a + timer-based approach for all three setups, not just LamNI/omny) or only + while a scan is actively running (cheaper, but leaves gaps covering the + most interesting idle-drift periods). +- Exact webpage layout/placement of the new section -- not designed yet. diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/angle_file.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/angle_file.py new file mode 100644 index 00000000..6737c9f6 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/angle_file.py @@ -0,0 +1,323 @@ +""" +Parser for user-supplied tomography angle files (flomni tomo_type 4). + +Pure Python, no BEC imports -- shared by the flomni CLI (tomo_scan(), +tomo_parameters(), the tomo queue) and the TomoParams widget's live preview, +so both validate a file identically. + +File format: plain text, one projection per line. + - column 1: angle in degrees, -2 <= angle <= 362 -- matches the physical + overtravel range of the rotation stage, not just the nominal 0-360 + sweep + - column 2 (optional): sub-tomogram number, integer >= 1 (default 1) + - further, optional 'key=value' tokens overriding acquisition parameters + for this projection and (sticky) every one after it -- see below +Columns are separated by whitespace and/or commas. Everything after a '#' is a +comment; blank lines are ignored. Projections are acquired in file order. + +A line that, after comment-stripping and trimming, is 'END' (case-insensitive) +marks the file as complete: no more angles will ever be appended, and +tomo_scan() may stop once it reaches that point instead of waiting for more. +A file without this marker is read as *incomplete* -- tomo_scan() waits and +polls for more lines rather than stopping at the current end of file, so an +angle file can be grown by appending lines while a scan is running. A +commented-out '# END' is an ordinary comment, not the sentinel, which lets a +marker be staged ahead of time and uncommented when the file is truly done. +Any further non-blank/non-comment content after the sentinel line is an error. + +Per-line acquisition overrides: after the angle and optional subtomo number, +any number of 'key=value' tokens may override acquisition parameters that +would otherwise come from the live tomo_parameters() configuration: + - step= Fermat scan step size, 0.025-20.0 + - exptime= exposure/counting time per frame, 0.001-100.0 + - frames= burst frame count, 1-100 (integer) +Each of these is *sticky*: once set on a line, it applies to that line and +every line after it, until changed again by a later 'key=value' of the same +key -- not just the one line it appears on. A field that is never set on any +line uses whatever the live tomo_shellstep/tomo_countingtime/frames_per_trigger +configuration is at the moment that projection is actually acquired. The +sub-tomogram number is NOT sticky (unchanged from today: per-line, default 1). + +Example:: + + # angle subtomo + 0 1 + 45 1 # still the default step/exptime/frames + 90 1 step=0.5 # step=0.5 from here on... + 135 1 # ...still in effect here + 180 2 step=2 exptime=5 frames=3 + 225 2 # step/exptime/frames all still as set above + 270, 2 + END +""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass + +# Matches the physical overtravel range of the fsamroy rotation stage, not +# just the nominal 0-360 sweep (see Flomni._min_scan_angle()/_max_scan_angle() +# in flomni.py, which gate tomo_scan()'s actual acquisition to the same range). +ANGLE_MIN = -2.0 +ANGLE_MAX = 362.0 + +# Two angles closer than this (mod 180) are reported as a 180-degree-opposite +# pair: in the parallel-beam approximation they carry the same information. +OPPOSITE_TOLERANCE_DEG = 0.05 + +# Per-line override ranges -- match the bounds tomo_parameters_cli() already +# enforces interactively on tomo_shellstep/tomo_countingtime/frames_per_trigger +# (flomni.py), so a value rejected here would also be rejected there. +_STEP_RANGE = (0.025, 20.0) +_EXPTIME_RANGE = (0.001, 100.0) +_FRAMES_RANGE = (1, 100) +_OVERRIDE_KEYS = ("exptime", "frames", "step") + +_SPLIT_RE = re.compile(r"[\s,]+") +_END_SENTINEL = "END" + + +class AngleFileError(ValueError): + """Angle file missing, unreadable, or malformed.""" + + +@dataclass(frozen=True) +class AngleEntry: + """One parsed projection: an angle/sub-tomogram pair, plus whichever + acquisition parameters are overridden (sticky) at this point in the file. + + step/exp_time/frames_per_trigger are None when not overridden by this or + any earlier line -- callers should fall back to the live + tomo_shellstep/tomo_countingtime/frames_per_trigger configuration in that + case. Field names match the kwargs already used for these values + elsewhere in flomni.py (the file's own 'exptime' token is the only name + that differs, spelled out as exp_time here). + """ + + angle: float + subtomo: int + step: float | None = None + exp_time: float | None = None + frames_per_trigger: int | None = None + + +@dataclass(frozen=True) +class AngleFileResult: + """Result of parsing a tomo_type-4 angle file. + + is_complete is True iff the file ends with an 'END' sentinel line, i.e. + no more angles will ever be appended to it. + """ + + angles: list[AngleEntry] + is_complete: bool + + +def read_angle_file(path: str) -> AngleFileResult: + """Parse an angle file into an AngleFileResult, angles in file order. + + Raises: + AngleFileError: file missing/unreadable, a malformed line, an angle + outside [0, 360], a sub-tomogram number < 1, no angles at all, or + content found after the 'END' sentinel line. + """ + if not path: + raise AngleFileError("No angle file set.") + full_path = os.path.abspath(os.path.expanduser(path)) + try: + with open(full_path, "r", encoding="utf-8") as f: + lines = f.readlines() + except OSError as exc: + raise AngleFileError(f"Cannot read angle file {full_path}: {exc}") from exc + + entries: list[AngleEntry] = [] + is_complete = False + end_lineno = None + # Sticky override state, carried forward top-to-bottom across the whole + # file -- None means "no override in effect, use the live global". + sticky_step: float | None = None + sticky_exp_time: float | None = None + sticky_frames: int | None = None + for lineno, raw in enumerate(lines, start=1): + line = raw.split("#", 1)[0].strip() + if not line: + continue + if is_complete: + raise AngleFileError( + f"{full_path}, line {lineno}: content found after the 'END' " + f"marker (line {end_lineno}) -- remove it or move it above 'END'." + ) + if line.upper() == _END_SENTINEL: + is_complete = True + end_lineno = lineno + continue + fields = [f for f in _SPLIT_RE.split(line) if f] + try: + angle = float(fields[0]) + except ValueError as exc: + raise AngleFileError( + f"{full_path}, line {lineno}: invalid angle {fields[0]!r}" + ) from exc + if not ANGLE_MIN <= angle <= ANGLE_MAX: + raise AngleFileError( + f"{full_path}, line {lineno}: angle {angle} outside " + f"[{ANGLE_MIN:g}, {ANGLE_MAX:g}] degrees" + ) + + rest = fields[1:] + subtomo = 1 + if rest and "=" not in rest[0]: + # positional subtomo -- must come immediately after the angle, + # before any key=value tokens + token = rest[0] + try: + subtomo = int(token) + except ValueError as exc: + raise AngleFileError( + f"{full_path}, line {lineno}: invalid sub-tomogram number {token!r}" + ) from exc + if subtomo < 1: + raise AngleFileError( + f"{full_path}, line {lineno}: sub-tomogram number must be >= 1, got {subtomo}" + ) + rest = rest[1:] + + seen_keys: set[str] = set() + for token in rest: + if "=" not in token: + raise AngleFileError( + f"{full_path}, line {lineno}: expected 'key=value', got {token!r}" + ) + key, _, value_str = token.partition("=") + key, value_str = key.strip(), value_str.strip() + if not key or not value_str: + raise AngleFileError( + f"{full_path}, line {lineno}: malformed 'key=value' token {token!r}" + ) + if key not in _OVERRIDE_KEYS: + raise AngleFileError( + f"{full_path}, line {lineno}: unknown key {key!r} " + f"(expected one of {', '.join(_OVERRIDE_KEYS)})" + ) + if key in seen_keys: + raise AngleFileError( + f"{full_path}, line {lineno}: duplicate key {key!r} on the same line" + ) + seen_keys.add(key) + + if key == "frames": + try: + frames_val = int(value_str) + except ValueError as exc: + raise AngleFileError( + f"{full_path}, line {lineno}: invalid frames value {value_str!r}" + ) from exc + lo, hi = _FRAMES_RANGE + if not lo <= frames_val <= hi: + raise AngleFileError( + f"{full_path}, line {lineno}: frames={frames_val} outside [{lo}, {hi}]" + ) + sticky_frames = frames_val + else: + try: + num_val = float(value_str) + except ValueError as exc: + raise AngleFileError( + f"{full_path}, line {lineno}: invalid {key} value {value_str!r}" + ) from exc + lo, hi = _STEP_RANGE if key == "step" else _EXPTIME_RANGE + if not lo <= num_val <= hi: + raise AngleFileError( + f"{full_path}, line {lineno}: {key}={num_val:g} outside [{lo:g}, {hi:g}]" + ) + if key == "step": + sticky_step = num_val + else: + sticky_exp_time = num_val + + entries.append( + AngleEntry( + angle=angle, + subtomo=subtomo, + step=sticky_step, + exp_time=sticky_exp_time, + frames_per_trigger=sticky_frames, + ) + ) + + if not entries: + raise AngleFileError(f"Angle file {full_path} contains no angles.") + return AngleFileResult(angles=entries, is_complete=is_complete) + + +def summarize_angles(entries: list[AngleEntry]) -> dict: + """Summary of a parsed angle list for display/warnings. + + Returns a dict with n, min, max, n_subtomos, n_overridden, and + opposite_pairs -- a list of (index_a, index_b, angle_a, angle_b) for + projections that are (nearly) 180 degrees apart or identical mod 180, + i.e. redundant in the parallel-beam approximation. n_overridden is the + number of entries carrying a non-default step/exp_time/frames_per_trigger. + """ + angles = [e.angle for e in entries] + subtomos = {e.subtomo for e in entries} + n_overridden = sum( + 1 + for e in entries + if e.step is not None or e.exp_time is not None or e.frames_per_trigger is not None + ) + + # Sort by angle mod 180 so near-duplicates end up adjacent (plus the + # wrap-around pair between last and first): O(n log n) instead of O(n^2). + folded = sorted((a % 180.0, i) for i, a in enumerate(angles)) + opposite_pairs = [] + n = len(folded) + for k in range(n): + f_a, i_a = folded[k] + for m in range(k + 1, n + k): + f_b, i_b = folded[m % n] + gap = f_b - f_a if m < n else f_b + 180.0 - f_a + if gap >= OPPOSITE_TOLERANCE_DEG: + break + pair = (min(i_a, i_b), max(i_a, i_b)) + opposite_pairs.append((pair[0], pair[1], angles[pair[0]], angles[pair[1]])) + opposite_pairs = sorted(set(opposite_pairs)) + + return { + "n": len(angles), + "min": min(angles), + "max": max(angles), + "n_subtomos": len(subtomos), + "n_overridden": n_overridden, + "opposite_pairs": opposite_pairs, + } + + +def describe_summary(summary: dict) -> str: + """One-line human-readable summary, e.g. '120 angles, 0.0-359.0 deg, 4 sub-tomograms'. + + Appends ', N with per-line overrides' only when N > 0, so a file with no + step/exptime/frames overrides prints exactly as before this feature. + """ + text = ( + f"{summary['n']} angles, {summary['min']:.2f}-{summary['max']:.2f} deg, " + f"{summary['n_subtomos']} sub-tomogram{'s' if summary['n_subtomos'] != 1 else ''}" + ) + if summary.get("n_overridden"): + text += f", {summary['n_overridden']} with per-line overrides" + return text + + +def describe_opposite_pairs(summary: dict, max_listed: int = 5) -> str | None: + """Warning text for 180-degree-opposite/duplicate pairs, or None if there are none.""" + pairs = summary["opposite_pairs"] + if not pairs: + return None + listed = ", ".join(f"{a:g}/{b:g}" for _, _, a, b in pairs[:max_listed]) + more = f" (+{len(pairs) - max_listed} more)" if len(pairs) > max_listed else "" + return ( + f"{len(pairs)} pair(s) of angles are identical or 180 deg apart " + f"(redundant in parallel-beam geometry): {listed}{more}" + ) diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/EPS_STATUS_DESIGN.md b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/EPS_STATUS_DESIGN.md index 3f6fd8a2..395c00e0 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/EPS_STATUS_DESIGN.md +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/EPS_STATUS_DESIGN.md @@ -15,12 +15,12 @@ Two browser pages backed by one JSON file: 1. **Machine status** — ring current, lifetime, light delivery, undulator gap, beamline status, orbit feedback, injection rate, ring vacuum. 2. **Operator messages** — the SLS control-room message board. -3. **Alarm status** — the EPS alarm banner (green when `AlarmCnt_EPS == 0`, - red with the alarm text otherwise), plus front-end alarms. -4. **Mono cryocooler** — run / alarm / inhibit / interlock / heartbeat. -5. **Beamline synoptic** — a link to the full synoptic page. -6. **Zone detail** — FE / OP / ES / EH1 cards, each a severity-colored summary +3. **Mono cryocooler** — run / alarm / inhibit / interlock / heartbeat. +4. **Beamline synoptic** — a link to the full synoptic page. +5. **Zone detail** — FE / OP / ES / EH1 cards, each a severity-colored summary with an expandable table of every channel. +6. **Alarm status** — the EPS alarm banner (green when `AlarmCnt_EPS == 0`, + red with the alarm text otherwise), plus front-end alarms. 7. **Recent history** — the last 5 EPS + machine events. **`eps_synoptic.html`** — a faithful replica of the caQtDM EPS expert panel @@ -294,7 +294,7 @@ IP-whitelisted upload all apply unchanged. - **`--verify` on a running beamline** (this was done during shutdown). Re-run when the machine is up to confirm the FE photon-shutter PVs - (`X12SA-EPS-PH:SYSTEM`, `X12SA-FE-PH1:OPEN_EPS/CLOSE_EPS`) come back. + (`X12SA-EPS-PH:SYSTEM`) comes back (the FE-PH1 OPEN/CLOSE_EPS PVs were dropped; FE shutter state now comes from `X12SA-FE-PSH1-EMLS:STATE`). - **Cryocooler detail.** The card shows run/alarm/inhibit/heartbeat. Full temperature/pressure detail needs `XDS_CRYOCOOLER_OVERVIEW.ui` (a sub-template not yet supplied). diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_pv_manifest.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_pv_manifest.py index 3f9c943d..acbaae68 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_pv_manifest.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_pv_manifest.py @@ -28,8 +28,6 @@ HOT_PVS = [ ("X12SA-EH1-PSYS:SH-A-OK", "sh_eh1_a_ok", "EH1 shutter A OK"), ("X12SA-OP-PSYS:ALARM-COLLECTIVE", "psys_op_alarm", "OP PSYS collective alarm"), ("X12SA-EH1-PSYS:ALARM-COLLECTIVE", "psys_eh1_alarm", "EH1 PSYS collective alarm"), - ("X12SA-FE-PH1:OPEN_EPS", "fe_ph1_open", "FE photon shutter open"), - ("X12SA-FE-PH1:CLOSE_EPS", "fe_ph1_closed", "FE photon shutter closed"), ("AGEBD-PARAMS:CURRENT", "ring_current", "SLS ring current"), ("AGEBD-PARAMS:LIFETIME", "lifetime", "Beam lifetime"), ("AGEOP-STATUS:LIGHT-DELIVERY", "light_delivery", "Light delivery state"), @@ -362,6 +360,9 @@ def all_bulk_pvs(): if pv not in seen: seen.add(pv); out.append(pv) for pv, _ in MACHINE_PVS: if pv not in seen: seen.add(pv); out.append(pv) + for pvs in OPMSG_PVS: + for pv in pvs: + if pv not in seen: seen.add(pv); out.append(pv) return out def all_pvs(): diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status.html b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status.html index 957388b6..ded779b3 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status.html +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status.html @@ -107,9 +107,6 @@

Operator Messages

-

Alarm Status

-
—
-

Mono Cryocooler

@@ -127,6 +124,9 @@

Zone Detail

+

Alarm Status

+
—
+

Recent History

diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status_generator.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status_generator.py index fe041318..cb19e8ba 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status_generator.py @@ -92,6 +92,21 @@ _HISTORY_MAX = 50 # events persisted per stream _HISTORY_SHOW = 5 # events surfaced to the page per stream _TRACE_MAX = 288 # ring-current samples (24 h at 5 min) _FLAP_COALESCE_S = 60 # re-alarm within this window extends the event +_ALARM_TEXTS_MAX = 100 # distinct alarm texts kept on a single open eps_event; + # only one event is ever open at a time, so this bounds + # total history growth during a long-running flapping alarm + +_MAX_UPLOAD_BYTES = 250_000 # hard ceiling on eps_alarm_history.json's serialized size, + # enforced in HistoryTracker.save(). _HISTORY_MAX/ + # _ALARM_TEXTS_MAX/_TRACE_MAX bound the *shape* of the data + # but not each alarm text's length, and _load() never re-caps + # a stored event's texts list -- only new appends are capped + # -- so a sufficiently verbose/flapping EPS alarm can still + # push the file past the upload server's request-size limit + # (a bare HTTP 413, no size check in upload.php itself; almost + # certainly nginx's client_max_body_size, default 1m). This + # keeps comfortable headroom under that regardless of how the + # data grows. _SEVERITY_NAMES = {0: "NO_ALARM", 1: "MINOR", 2: "MAJOR", 3: "INVALID"} @@ -162,6 +177,56 @@ def _clean(value): return value +def _payload_size(payload: dict) -> int: + return len(json.dumps(payload, default=str)) + + +def _shrink_to_fit(payload: dict, max_bytes: int = _MAX_UPLOAD_BYTES) -> dict: + """Return a shrunk copy of a HistoryTracker.save() payload that + serializes to at most max_bytes. + + Trims progressively less-essential data -- alarm texts per event, then + the ring-current trace, then the oldest events, then (last resort, for a + single pathologically long string) individual field lengths -- stopping + as soon as the payload fits. + """ + payload = { + "eps_events": [dict(ev) for ev in payload["eps_events"]], + "machine_events": list(payload["machine_events"]), + "ring_current_trace": list(payload["ring_current_trace"]), + } + if _payload_size(payload) <= max_bytes: + return payload + + for texts_cap in (20, 5, 1): + for ev in payload["eps_events"]: + texts = ev.get("texts") + if isinstance(texts, list) and len(texts) > texts_cap: + ev["texts"] = texts[-texts_cap:] + if _payload_size(payload) <= max_bytes: + return payload + + while len(payload["ring_current_trace"]) > 12 and _payload_size(payload) > max_bytes: + payload["ring_current_trace"] = payload["ring_current_trace"][::2] + + while len(payload["eps_events"]) > 1 and _payload_size(payload) > max_bytes: + payload["eps_events"].pop(0) + + while len(payload["machine_events"]) > 1 and _payload_size(payload) > max_bytes: + payload["machine_events"].pop(0) + + if _payload_size(payload) > max_bytes: + for ev in payload["eps_events"]: + if isinstance(ev.get("texts"), list): + ev["texts"] = [t[:200] for t in ev["texts"]] + for mev in payload["machine_events"]: + for key in ("from", "to"): + if isinstance(mev.get(key), str): + mev[key] = mev[key][:200] + + return payload + + # --------------------------------------------------------------------------- # History tracking # --------------------------------------------------------------------------- @@ -207,9 +272,12 @@ class HistoryTracker: "machine_events": self.machine_events[-_HISTORY_MAX:], "ring_current_trace": self.ring_trace[-_TRACE_MAX:], } + # Shrink a copy for writing/uploading; the in-memory lists above keep + # their full (count-capped) history regardless of upload-size limits. + payload = _shrink_to_fit(payload) try: tmp = self._path.with_suffix(".tmp") - tmp.write_text(json.dumps(payload, indent=1, default=str)) + tmp.write_text(json.dumps(payload, default=str)) tmp.replace(self._path) except Exception as exc: logger.warning(f"HistoryTracker: cannot write {self._path}: {exc}") @@ -235,7 +303,9 @@ class HistoryTracker: if open_ev is not None: open_ev["count"] = max(open_ev.get("count", 0), count) if text and text not in open_ev.get("texts", []): - open_ev.setdefault("texts", []).append(text) + texts = open_ev.setdefault("texts", []) + texts.append(text) + del texts[:-_ALARM_TEXTS_MAX] return # coalesce a rapid re-alarm with identical text if self.eps_events: @@ -805,9 +875,15 @@ class EpsStatusGenerator: "b_closed": hv("X12SA-OP-PSYS:SH-B-CLOSE"), "b_ok": hv("X12SA-OP-PSYS:SH-B-OK"), }) + # The old X12SA-FE-PH1:OPEN_EPS/CLOSE_EPS PVs no longer exist; derive + # open/closed from the front-end shutter end-switch state (enum + # MOVING/OPEN/CLOSED, may arrive as index or string). + fe_state = (self._bulk_cache.get("X12SA-FE-PSH1-EMLS:STATE") or {}).get("value") + fe_state = {1: "OPEN", 2: "CLOSED"}.get(fe_state, fe_state) + fe_known = isinstance(fe_state, str) and fe_state.upper() in ("OPEN", "CLOSED", "MOVING") shutters["FE"] = { - "a_open": hv("X12SA-FE-PH1:OPEN_EPS"), - "a_closed": hv("X12SA-FE-PH1:CLOSE_EPS"), + "a_open": (fe_state.upper() == "OPEN") if fe_known else None, + "a_closed": (fe_state.upper() == "CLOSED") if fe_known else None, "a_ok": None, "collective_alarm": None, } diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py new file mode 100644 index 00000000..6d5db9d7 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py @@ -0,0 +1,58 @@ +import time + +from csaxs_bec.bec_ipython_client.plugins.cSAXS.filter_transmission import cSAXSFilterTransmission + + +def filters_out_of_beam(dev, tol: float = 0.1) -> tuple[bool, list[str]]: + """Check whether all cSAXS exposure-box filters are retracted. + + Reuses cSAXSFilterTransmission's own axis list/position table (index 0 of + each row is the "out" position) so there is one source of truth for what + "out" means, matching csaxs.fil_trans()/_fil_trans_report()'s own check. + + Tolerates a deployment missing one or more filter axes (e.g. a simulated + session without the real beamline's filter_array_* devices), and `dev` + itself being None (the usual state of the module-level `dev = + builtins.__dict__.get("dev")` idiom used throughout this codebase before + a real BEC session has set it). BEC's device container raises + DeviceConfigError -- not AttributeError -- for a missing device, so a + plain `getattr(dev, axis_name, None)` does NOT safely skip it; wrapping + the lookup itself in try/except does, for both cases. + """ + offending = [] + for axis_name, positions in zip( + cSAXSFilterTransmission._AXES, cSAXSFilterTransmission._POSITIONS_USER + ): + try: + axis_obj = getattr(dev, axis_name, None) + except Exception: + axis_obj = None + out_position = positions[0] + if axis_obj is None or out_position is None: + continue + try: + rb = float(axis_obj.readback.get()) + except Exception: + continue + if abs(rb - out_position) > tol: + offending.append(axis_name) + return not offending, offending + + +def warn_and_confirm(setup, warning: str, interactive: bool = True, force: bool = False) -> bool: + """Eye/optics-check-style warn-and-confirm gate, reusable across plugins. + + Returns True if the caller should proceed. Interactive: red warning + a + "Continue anyway?" prompt defaulting to no. Non-interactive (queued/ + unattended): warns, waits 10s, and always proceeds -- matches + LamNI.tomo_scan()'s existing eye/optics gate so queued jobs never hang on + input(). + """ + if force: + return True + setup.OMNYTools.printredbold(f"WARNING: {warning}") + if interactive: + return setup.OMNYTools.yesno("Continue anyway?", "n") + setup.OMNYTools.printredbold("Proceeding automatically in 10 s (unattended/queued run)...") + time.sleep(10) + return True diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py index 2b333ebd..db4d4bfd 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py @@ -328,12 +328,13 @@ class TomoIDManager: """Registers a tomography measurement in the OMNY sample database and returns its assigned tomo ID. - Non-production accounts (e.g. test accounts like "gac-x01dc") register - against the test server (TEST_OMNY_URL) instead of production, so - testing still gets a real, incrementing tomo ID -- matching the - counter the samples-folder PDF upload reads from on that same test - host -- accepting that the eaccount recorded in that test database - won't be a real e-account. + Real e-accounts (personal, e.g. "e12345") and p-accounts (proposal + accounts, e.g. "p23092") are both registered against OMNY_URL. + Non-production accounts (e.g. test accounts like "gac-x01dc") are not + registered against OMNY_URL at all -- there is no longer a separate + test server to redirect them to, and registering test runs against + the production sample database would pollute it. They get + FALLBACK_TOMO_ID instead. Usage: id_manager = TomoIDManager() @@ -349,13 +350,12 @@ class TomoIDManager: """ OMNY_URL = "https://omny.psi.ch/samples/newmeasurement.php" - TEST_OMNY_URL = "https://omny.psi.ch/samples/newmeasurement.php" FALLBACK_TOMO_ID = 0 @staticmethod def _is_valid_eaccount(eaccount: str) -> bool: - """True for real e-accounts (e.g. "e12345"), False for test accounts.""" - return bool(re.fullmatch(r"e\d{5}", eaccount.strip())) + """True for real e- or p-accounts (e.g. "e12345", "p23092"), False for test accounts.""" + return bool(re.fullmatch(r"[ep]\d{5}", eaccount.strip())) def register( self, @@ -369,20 +369,21 @@ class TomoIDManager: ) -> int: """Register a new measurement and return the assigned tomo ID. - Registers against OMNY_URL (production) for a real e-account, or - TEST_OMNY_URL (test server) otherwise. Returns FALLBACK_TOMO_ID (0) - only if the server actually can't be reached / returns an unusable - response. + Registers against OMNY_URL for a real e- or p-account. For any other + account (e.g. a test/"gac-*" account), skips registration entirely + and returns FALLBACK_TOMO_ID (0) -- there is no separate test + server to register against, and registering test runs against + production would pollute the real sample database. FALLBACK_TOMO_ID + is also returned if the server actually can't be reached / returns + an unusable response. """ - if self._is_valid_eaccount(eaccount): - omny_url = self.OMNY_URL - else: - omny_url = self.TEST_OMNY_URL + if not self._is_valid_eaccount(eaccount): logger.warning( - f"Account '{eaccount}' is not a valid e-account; registering " - f"against the test server ({self.TEST_OMNY_URL}) instead of " - "production -- the eaccount recorded there won't be real." + f"Account '{eaccount}' is not a valid e- or p-account; skipping OMNY " + f"registration (no separate test server to register against) " + f"and falling back to tomo ID {self.FALLBACK_TOMO_ID}." ) + return self.FALLBACK_TOMO_ID params = { "sample": sample_name, @@ -408,7 +409,7 @@ class TomoIDManager: try: response = requests.get( - omny_url, + self.OMNY_URL, params=params, timeout=30, verify=False, # accept self-signed certs diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_angle_file_mixin.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_angle_file_mixin.py new file mode 100644 index 00000000..4a655e9d --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_angle_file_mixin.py @@ -0,0 +1,321 @@ +""" +Shared tomo_type 4 ("angles from file") support for the flomni and LamNI +tomography classes. + +The file format and parser live in angle_file.py. This mixin holds everything +that is identical between the setups: the tomo_angle_file global-var property, +parsing/summary helpers, the per-line override plumbing and the acquisition +loop that follows (and waits for) a growing angle file. + +Host-class contract: + * _TOMO_PARAMS_ERROR exception class raised on any problem + * _ANGLE_FILE_OPPOSITE_WARNING bool, report 180-deg-opposite/duplicate angles + (parallel-beam redundancy -- off for laminography) + * _tomo_scan_at_angle(angle, subtomo_number, **overrides) + * progress, OMNYTools, client, at_each_angle_hook +""" + +from __future__ import annotations + +import builtins +import datetime +import os +import time + +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.angle_file import ( + AngleEntry, + AngleFileError, + AngleFileResult, + describe_opposite_pairs, + describe_summary, + read_angle_file, + summarize_angles, +) + + +class TomoAngleFileMixin: + # tomo_type 4: how often to re-read tomo_angle_file while waiting for + # more lines to be appended, once the scan has caught up to its end. + _TOMO_ANGLE_FILE_POLL_INTERVAL_S = 5.0 + _ANGLE_FILE_OPPOSITE_WARNING = True + # Name of the setup's legacy at-each-angle builtin, or None (ignored-overrides warning). + _AT_EACH_ANGLE_BUILTIN = "flomni_at_each_angle" + # Angle range accepted by tomo_type 4 (mechanical overtravel); the + # parser enforces the same limits. + _ANGLE_FILE_MIN = -2 + _ANGLE_FILE_MAX = 362 + + @property + def tomo_angle_file(self): + """Path of the angle file used by tomo_type 4 (format: see + OMNY_shared/angle_file.py). Only the path is stored -- the file is + read at tomo_scan() start, including resume and queued jobs, so an + edited file always takes effect on the next (re)start. While a scan + is running, it is also polled and re-read whenever the scan catches + up to the current end of file, so new lines appended during a scan + are picked up automatically -- unless the file ends with an 'END' + sentinel line, the scan waits indefinitely for more lines once it + reaches the end rather than stopping.""" + val = self.client.get_global_var("tomo_angle_file") + if val is None: + return "" + return val + + @tomo_angle_file.setter + def tomo_angle_file(self, val: str): + # Deliberately no existence/format check here: tomo_queue_execute() + # setattr()s every snapshotted param for every job, so validating here + # would make e.g. a type-1 job fail because of a stale, irrelevant + # angle file path. Validation happens where type 4 actually uses it + # (tomo_scan(), tomo_parameters(), tomo_queue_reacquire()). + val = str(val or "").strip() + if val: + val = os.path.abspath(os.path.expanduser(val)) + self.client.set_global_var("tomo_angle_file", val) + + def _read_tomo_angle_file(self) -> AngleFileResult: + """Parse self.tomo_angle_file, raising _TOMO_PARAMS_ERROR on any problem.""" + try: + return read_angle_file(self.tomo_angle_file) + except AngleFileError as exc: + raise self._TOMO_PARAMS_ERROR(str(exc)) from exc + + @staticmethod + def _tomo_angle_subtomo_layout(entries: list[AngleEntry]): + """subtomo_sizes (label -> count) and index_in_subtomo (per-entry + position within its own sub-tomogram), for the progress display.""" + subtomo_sizes: dict[int, int] = {} + index_in_subtomo: list[int] = [] + for entry in entries: + index_in_subtomo.append(subtomo_sizes.get(entry.subtomo, 0)) + subtomo_sizes[entry.subtomo] = subtomo_sizes.get(entry.subtomo, 0) + 1 + return subtomo_sizes, index_in_subtomo + + @staticmethod + def _angle_overrides_kwargs(step=None, exp_time=None, frames_per_trigger=None) -> dict: + """Only the non-None overrides, as kwargs to splat into + _at_each_angle()/_tomo_scan_at_angle() -- when nothing is overridden + this is {}, so the call is byte-identical to the pre-override-feature + call (no extra kwargs reach fixed-arity stubs/hooks).""" + return { + k: v + for k, v in ( + ("step", step), + ("exp_time", exp_time), + ("frames_per_trigger", frames_per_trigger), + ) + if v is not None + } + + @staticmethod + def _format_angle_entry(entry: AngleEntry) -> str: + """'angle subtomo [step=.. exptime=.. frames=..]' for scilog/logging.""" + text = f"{entry.angle:g} {entry.subtomo}" + if entry.step is not None: + text += f" step={entry.step:g}" + if entry.exp_time is not None: + text += f" exptime={entry.exp_time:g}" + if entry.frames_per_trigger is not None: + text += f" frames={entry.frames_per_trigger}" + return text + + def _warn_if_hook_ignores_overrides(self, entries: list[AngleEntry]) -> None: + """Print a one-time warning if a custom at_each_angle_hook/ + _at_each_angle is active while entries carry step/exp_time/ + frames_per_trigger overrides -- those hooks own the whole + acquisition (fixed 2-arg contract) and never see the overrides.""" + hook_active = bool(self.at_each_angle_hook) or bool( + self._AT_EACH_ANGLE_BUILTIN and self._AT_EACH_ANGLE_BUILTIN in builtins.__dict__ + ) + if hook_active and any( + e.step is not None or e.exp_time is not None or e.frames_per_trigger is not None + for e in entries + ): + self.OMNYTools.printredbold( + "Warning: an at_each_angle hook is active -- per-line step/exptime/frames " + "overrides in the angle file are ignored (the hook owns the whole acquisition)." + ) + + def _print_opposite_pairs_warning(self, summary) -> None: + if not self._ANGLE_FILE_OPPOSITE_WARNING: + return + warning = describe_opposite_pairs(summary) + if warning: + self.OMNYTools.printredbold(f"Warning: {warning}") + + def _min_scan_angle(self) -> float: + """Lower angle limit (inclusive) accepted by _tomo_scan_at_angle(). + tomo_type 4 files may contain any angle in the mechanical overtravel + range, not just the nominal 0-360 sweep.""" + if self.tomo_type == 4: + return self._ANGLE_FILE_MIN + return 0 + + def _max_scan_angle(self) -> float: + """Upper angle limit (inclusive) accepted by _tomo_scan_at_angle().""" + if self.tomo_type == 4: + return self._ANGLE_FILE_MAX + return self._default_max_scan_angle() + + def _default_max_scan_angle(self) -> float: + """Upper limit for tomo types 1-3; flomni overrides (tomo_angle_range).""" + return 360 + + def _start_angle_file_scan(self, projection_number) -> AngleFileResult: + """Read + report the angle file at tomo_scan() start (before any gate + or motion), so a missing/malformed file fails immediately -- and, in + the tomo queue, marks the job incomplete with this message. The file + need not be complete (end with 'END') yet -- that is the normal + starting state for a file meant to be appended to while the scan runs.""" + file_angles = self._read_tomo_angle_file() + summary = summarize_angles(file_angles.angles) + print(f"Angle file {self.tomo_angle_file}: {describe_summary(summary)}") + if not file_angles.is_complete: + print( + "Angle file has no 'END' marker yet -- the scan will wait for " + "more angles to be appended once it reaches the end of the file." + ) + self._print_opposite_pairs_warning(summary) + self._warn_if_hook_ignores_overrides(file_angles.angles) + if projection_number is not None and projection_number >= len(file_angles.angles): + raise self._TOMO_PARAMS_ERROR( + f"Cannot resume at projection {projection_number}: the angle file now " + f"contains only {len(file_angles.angles)} angles." + ) + return file_angles + + def _init_angle_file_progress(self, file_angles: AngleFileResult, projection_number) -> None: + """Fill the progress fields that are already known from the file, so the + GUI shows the real tomo type/total (and sub-tomogram 1) during the + preparation steps and the 0-deg reference shot that precede the loop, + instead of the blank reset defaults (tomo type 0, projection 0).""" + self.progress["tomo_type"] = "Angles from file" + self.progress["total_projections"] = ( + len(file_angles.angles) if file_angles.is_complete else 0 + ) + if projection_number is None: + self.progress["subtomo"] = 1 + + def _run_angle_file_loop(self, file_angles: AngleFileResult, projection_number) -> None: + """Acquire the angles of tomo_type 4 in file order (no extra 0-deg + damage-estimation shots: sub-tomogram numbers in a user file are just + labels, put 0-deg lines in the file instead). Waits for lines to be + appended while the file has no 'END' marker.""" + error = self._TOMO_PARAMS_ERROR + self.progress["tomo_type"] = "Angles from file" + angles = file_angles.angles + is_complete = file_angles.is_complete + ii = 0 if projection_number is None else projection_number + subtomo_sizes, index_in_subtomo = self._tomo_angle_subtomo_layout(angles) + self.progress["total_projections"] = len(angles) if is_complete else 0 + waiting = False + + while True: + if ii >= len(angles): + if is_complete: + break + # Caught up to the current end of file, which isn't marked + # complete ('END') -- wait and poll instead of stopping, so + # lines appended while this scan is running are picked up + # without needing tomo_scan_resume(). + if not waiting: + print( + f"Reached the end of {self.tomo_angle_file} " + f"({len(angles)} angle(s), no 'END' marker yet) -- " + "waiting for more lines to be appended. Ctrl-C to abort." + ) + waiting = True + tick_start = time.perf_counter() + try: + time.sleep(self._TOMO_ANGLE_FILE_POLL_INTERVAL_S) + except KeyboardInterrupt: + raise error( + "tomo_scan() aborted while waiting for the angle " + f"file {self.tomo_angle_file} to grow past " + f"projection {ii}. Resume with tomo_scan_resume() " + "once more angles (or an 'END' marker) have been " + "added." + ) from None + # Keep the heartbeat fresh so the GUI/webpage staleness + # detectors (120s/90s windows) don't flag this scan as dead + # while it's intentionally idle -- and since that keeps the + # gap _tomo_scan_at_angle() would otherwise see small, + # account the wait as idle time here explicitly. + self.progress["heartbeat"] = datetime.datetime.now().isoformat() + self.progress["accumulated_idle_time"] = self.progress.get( + "accumulated_idle_time", 0.0 + ) + (time.perf_counter() - tick_start) + new_result = self._read_tomo_angle_file() + if new_result.angles[: len(angles)] != angles: + raise error( + f"{self.tomo_angle_file} changed: an already-" + f"acquired line (index < {len(angles)}) was edited " + "or removed. Fix the file (only append new lines " + "after the last acquired one) and resume with " + "tomo_scan_resume()." + ) + if len(new_result.angles) > len(angles): + print( + f"Picked up {len(new_result.angles) - len(angles)} " + f"new angle(s) from {self.tomo_angle_file} " + f"(total now {len(new_result.angles)})." + ) + self._warn_if_hook_ignores_overrides(new_result.angles[len(angles) :]) + waiting = False + angles = new_result.angles + subtomo_sizes, index_in_subtomo = self._tomo_angle_subtomo_layout(angles) + is_complete = new_result.is_complete + self.progress["total_projections"] = len(angles) if is_complete else 0 + if is_complete: + summary = summarize_angles(angles) + print(f"Angle file complete: {describe_summary(summary)}") + self._print_opposite_pairs_warning(summary) + continue + + entry = angles[ii] + self.progress["subtomo"] = entry.subtomo + self.progress["projection"] = ii + self.progress["angle"] = entry.angle + self.progress["subtomo_total_projections"] = subtomo_sizes[entry.subtomo] + self.progress["subtomo_projection"] = index_in_subtomo[ii] + overrides = self._angle_overrides_kwargs( + entry.step, entry.exp_time, entry.frames_per_trigger + ) + self._tomo_scan_at_angle(entry.angle, entry.subtomo, **overrides) + ii += 1 + + def _print_angle_file_summary(self) -> bool: + """Print path + parsed summary (+ 180-deg-pair warning if enabled) of + tomo_angle_file. Returns False (after printing the error) if the file + can't be used, instead of raising.""" + print(f"Angle file: {self.tomo_angle_file or '(not set)'}") + try: + result = read_angle_file(self.tomo_angle_file) + except AngleFileError as exc: + self.OMNYTools.printredbold(f"Angle file not usable: {exc}") + return False + summary = summarize_angles(result.angles) + print(describe_summary(summary)) + if not result.is_complete: + print("No 'END' marker yet -- a scan will wait for more angles once started.") + self._print_opposite_pairs_warning(summary) + return True + + def _prompt_angle_file(self) -> None: + """Ask for tomo_angle_file until it parses, or the user presses + enter to keep the current value.""" + while True: + val = input(f"Angle file ({self.tomo_angle_file}): ").strip() + if not val: + if not self._print_angle_file_summary(): + print("Keeping it anyway -- the scan will refuse to start until it is fixed.") + return + try: + read_angle_file(val) + except AngleFileError as exc: + self.OMNYTools.printredbold(str(exc)) + print("Please try again (enter keeps the current value).") + continue + self.tomo_angle_file = val + self._print_angle_file_summary() + return diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py index fc142da3..6e75d26d 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py @@ -46,11 +46,14 @@ import inspect import json import os import threading +import time import uuid from typing import Callable from typeguard import check_type +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.angle_file import read_angle_file + if builtins.__dict__.get("bec") is not None: dev = builtins.__dict__.get("dev") umv = builtins.__dict__.get("umv") @@ -222,9 +225,19 @@ class TomoQueueMixin: # {1, 4, 5} since it has three such flavors (2/4/8 sub-tomograms). _TOMO_TYPE_1_LIKE: frozenset = frozenset({1}) + # tomo_parameters() GUI window: subclasses set a unique window name (a valid + # Python identifier, e.g. "lamni_tomo_params") and their own error class. + _TOMO_PARAMS_WINDOW_NAME: str = "" + _TOMO_PARAMS_ERROR: type[Exception] = RuntimeError + _TOMO_PARAMS_GUI_TIMEOUT = 20 + # (pos_x, pos_y, width, height) of the tomo parameter window when it is + # created: tall and narrow, so the whole parameter column fits on screen. + _TOMO_PARAMS_WINDOW_GEOMETRY = (50, 50, 730, 1170) + # Name of a registered at_each_angle hook (see register_at_each_angle_hook()), # or None for the default per-projection behaviour. at_each_angle_hook = _GlobalVarParam(None) + tomo_id = _GlobalVarParam(-1, type_=int) def _init_tomo_queue(self) -> None: """Call once from ``__init__``, after ``self.client`` is set. @@ -248,6 +261,42 @@ class TomoQueueMixin: # since-ended session must not linger and look registered. self._publish_at_each_angle_hooks() + # ── tomo parameter GUI window ─────────────────────────────────────────── + + def tomo_parameters(self) -> None: + """Open the tomo parameter GUI (OMNY_TomoParams) in its own window. + + Calling it again while the window is open does nothing. The widget + works on the same global variables as the command line version, + ``tomo_parameters_cli()``, which is not used as an automatic fallback: + if the GUI cannot be opened this raises, so the problem gets fixed. + """ + name = self._TOMO_PARAMS_WINDOW_NAME + error = self._TOMO_PARAMS_ERROR + gui = getattr(self.client, "gui", None) + if gui is None: + raise error( + "No BEC GUI is available in this session, so the tomo parameter window cannot be" + " opened. Use tomo_parameters_cli() for the command line version." + ) + timeout = self._TOMO_PARAMS_GUI_TIMEOUT + try: + if name in gui.windows: + window = getattr(gui, name) + if hasattr(window, "tomo_params"): + print("The tomo parameter window is already open.") + return + else: + gui.new(name, geometry=self._TOMO_PARAMS_WINDOW_GEOMETRY, timeout=timeout) + time.sleep(1) + window = getattr(gui, name) + window.new("OMNY_TomoParams", object_name="tomo_params", timeout=timeout) + except Exception as exc: # pylint: disable=broad-except + raise error( + f"Could not open the tomo parameter window ({type(exc).__name__}: {exc}). Use" + " tomo_parameters_cli() for the command line version." + ) from exc + # ── at_each_angle hook registry ───────────────────────────────────────── def register_at_each_angle_hook(self, name: str, func) -> None: @@ -404,9 +453,8 @@ class TomoQueueMixin: user=user, ) - # Must match whatever host TomoIDManager.OMNY_URL/TEST_OMNY_URL - # registered against, since that host's sample counter is what - # self.tomo_id came from. + # Must match whatever host TomoIDManager.OMNY_URL registered against, + # since that host's sample counter is what self.tomo_id came from. _SAMPLES_UPLOAD_HOSTS = ("https://omny.psi.ch",) def _upload_pdf_report_to_samples(self, pdf_path: str) -> None: @@ -553,6 +601,11 @@ class TomoQueueMixin: if not isinstance(value, (int, float)) or isinstance(value, bool): raise ValueError(f"{action_name}.{name} must be a number, got {value!r}.") lo, hi = field.get("min"), field.get("max") + device_name = field.get("device_limits_from") + if device_name is not None: + device = self.device_manager.devices.get(device_name) + if device is not None: + lo, hi = device.limits if lo is not None and value < lo: raise ValueError(f"{action_name}.{name}={value} is below the minimum {lo}.") if hi is not None and value > hi: @@ -585,23 +638,6 @@ class TomoQueueMixin: args += [dev[name], target] umv(*args) - def optimize_idgap(self, search_range: float = 0.5) -> None: - """Scan idgap over +/- search_range around its current position and - move to the peak of . - - STUB -- not implemented. The peak-detection signal/device is not yet - decided (see flomni's AI_docs/TOMO_QUEUE_COMMAND_JOBS_PLAN.md section - 10). This is the ONLY place that needs to change to make this a real - action: the registry entry, resume policy, and (eventually) GUI - wiring already treat it as a normal idempotent compound action, so - dropping in the real scan-and-move-to-peak logic here (or swapping - which device/signal it reads) is a self-contained change. - """ - print( - f"optimize_idgap(search_range={search_range}): not yet implemented -- " - "peak-detection signal not yet decided. No-op." - ) - def _describe_tomo_queue_actions(self) -> dict: """Serializable description of the action registry + move allow-list. @@ -910,7 +946,9 @@ class TomoQueueMixin: for name, value in job["params"].items(): setattr(self, name, value) if resume_job: - self.tomo_scan_resume() + # from_queue=True: skip tomo_scan_resume()'s + # "unfinished queue job" prompt -- this *is* the queue. + self.tomo_scan_resume(from_queue=True) else: # interactive=False: an unattended queued run must # never block on input() -- see LamNI.tomo_scan()'s @@ -988,6 +1026,35 @@ class TomoQueueMixin: "tomo_queue_reacquire() on a tomo_type-1 job." ) + def _confirm_resume_outside_queue(self) -> bool: + """Guard for a manual ``tomo_scan_resume()`` call. + + If the queue holds an "incomplete"/"running" job (e.g. after a + Ctrl-C abort, which leaves it "running"), resuming by hand bypasses + the queue: the job stays unfinished there, so a later + ``tomo_queue_resume()`` would resume it again and the jobs after it + would not run. Warn, print the hint, and ask. Returns True if the + resume should go ahead (nothing unfinished in the queue, or the + operator confirmed). + """ + unfinished = [ + j for j in self._tomo_queue_proxy.as_list() if j["status"] in ("incomplete", "running") + ] + if not unfinished: + return True + print("\nWARNING: the tomo queue has an unfinished job:") + for job in unfinished: + print( + f" {job['status']:>10s} {job['label']:<20s} {self._describe_tomo_job_line(job)}" + ) + print( + "Use tomo_queue_resume() to resume it and continue with the rest of the queue.\n" + "If you resume manually instead, the queue is not told -- mark the job done " + "afterwards, or it will be resumed again:\n" + " self._tomo_queue_proxy.update_by_id(, status='done')" + ) + return self.OMNYTools.yesno("Run tomo_scan_resume() anyway, bypassing the queue?", "n") + def tomo_queue_resume(self, start_index: int = 0) -> None: """Continue running the tomo queue from wherever it currently stands -- an alias for ``tomo_queue_execute()``, added purely so @@ -1033,7 +1100,8 @@ class TomoQueueMixin: ``tomo_queue_delete()``). projection_number: Flat 0-indexed projection to resume from, for any tomo_type -- see ``_resolve_type1_projection()`` for how - this maps onto a ``_TOMO_TYPE_1_LIKE`` job's (subtomo, angle). + this maps onto a ``_TOMO_TYPE_1_LIKE`` job's (subtomo, angle); for + a tomo_type-4 angle-file job (flOMNI/LaMNI) it is the angle-file index. Raises: TomoQueueError: ``job_index`` is out of range, the job at that @@ -1071,6 +1139,18 @@ class TomoQueueMixin: subtomo_start, start_angle = self._resolve_type1_projection(projection_number) self.progress["subtomo"] = subtomo_start self.progress["angle"] = start_angle + elif self.tomo_type == 4: + # angles from file (flomni) -- bound by the file as it reads now, + # since the resumed tomo_scan() re-reads it too. + if projection_number < 0: + raise ValueError("tomo_queue_reacquire: projection_number must be >= 0.") + n_angles = len(read_angle_file(self.tomo_angle_file).angles) + if projection_number >= n_angles: + raise ValueError( + f"projection_number must be < {n_angles} (number of angles in " + f"{self.tomo_angle_file}) for this job." + ) + self.progress["projection"] = projection_number elif self.tomo_type in (2, 3): if projection_number < 0: raise ValueError("tomo_queue_reacquire: projection_number must be >= 0.") diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py index 0bdddcb1..8b0dbf25 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py @@ -404,14 +404,21 @@ class WebpageGeneratorBase: takes. Used by the UI to compute total-projection counts for queued jobs without hardcoding a per-instrument formula in JS. Recognised "kind"s: - "equally_spaced_grid" (params: n_subtomos) - total = floor(180 / angle_stepsize) * n_subtomos + "equally_spaced_grid" (params: n_subtomos, + optional base_angle, + default 180) + total = floor(base_angle / angle_stepsize) + * n_subtomos (independent of angle_range - see Flomni._subtomo_angle_plan()'s docstring for why: in 360-degree mode, angle_range only splits the n_subtomos into complementary low/high halves, it does not change the - total count) + total count). base_angle is the angular span + ONE sub-tomogram covers: 180 for flomni, 360 + for lamni (which always sweeps the full + circle per sub-tomogram -- see + LamNI._tomo_type1_actual_grid()). "golden_capped" total = golden_max_number_of_projections TQ_PARAM_DISPLAY -- list of (global_var_key, display_label) pairs, in @@ -436,6 +443,10 @@ class WebpageGeneratorBase: whole shared output dir, EPS files included. """ + # Name of the setup's object in the BEC client namespace (e.g. "lamni"), + # used in console hints such as the set_web_password() one. + CLIENT_NAME: str | None = None + HAS_TOMO_QUEUE = True HAS_EPS_STATUS = True TOMO_TYPES = { @@ -809,7 +820,8 @@ class WebpageGeneratorBase: _BOLD_RED = "\033[1;31m" _RESET = "\033[0m" print(f"{_BOLD_RED}***\nWebpageGenerator: '{current_account}' has no web authentication yet.{_RESET}") - print(f"{_BOLD_RED} To set a password run: flomni.set_web_password(\"your_password\")\n***{_RESET}") + prefix = f"{self.CLIENT_NAME}." if self.CLIENT_NAME else "" + print(f"{_BOLD_RED} To set a password run: {prefix}set_web_password(\"your_password\")\n***{_RESET}") def _clear_session_htpasswd(self) -> None: """Write an empty session.htpasswd locally and upload it.""" @@ -828,7 +840,7 @@ class WebpageGeneratorBase: The username is taken from bec.active_account (e.g. 'p23092'). Example:: - flomni.webpage_gen.set_web_password("my_secret_password") + flomni.set_web_password("my_secret_password") """ if self._set_password_url is None: print("set_web_password: no upload URL configured — cannot reach server") @@ -2701,13 +2713,15 @@ function calcProjections(params){{ return (gmax!=null&&gmax>0)?gmax:null; }} if(def.kind==='equally_spaced_grid'){{ - // Always floor(180/step), never floor(angle_range/step): in 360-degree - // mode angle_range only splits the sub-tomograms into complementary - // low/high halves, it does not change the total projection count - - // see Flomni._subtomo_angle_plan()'s docstring. + // Always floor(base_angle/step), never floor(angle_range/step): in + // 360-degree mode angle_range only splits the sub-tomograms into + // complementary low/high halves, it does not change the total + // projection count - see Flomni._subtomo_angle_plan()'s docstring. + // base_angle is the span of one sub-tomogram: 180 (default, flomni) or + // 360 (lamni, which sweeps the full circle in every sub-tomogram). const step=params.tomo_angle_stepsize; if(step>0){{ - const N=Math.floor(180/step); + const N=Math.floor((def.base_angle||180)/step); return N*(def.n_subtomos||1); }} }} diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py index 7174b5af..4d708b9a 100644 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py @@ -74,19 +74,30 @@ class cSAXSFilterTransmission: # ----------------------------------------- # Exposure-box filter configuration (4 x 6) # - # Current hardware (per your message): - # Unit 1 (filter_array_1_x): out, Si400, Ge300, Ti800, Zr20 - # Unit 2 (filter_array_2_x): out, Si200, Si3200, Ti400, Cu20 - # Unit 3 (filter_array_3_x): out, Si100, Si1600, Ti200, Ti3200, Fe5 - # Unit 4 (filter_array_4_x): out, Si50, Si800, Ti100, Ti1600, Ti20 + # Thicknesses are the values from filter.mac rev 1.19, i.e. effective + # thicknesses measured at 18.58 keV rather than nominal foil thicknesses. + # They are used at every energy, which assumes the tabulated attenuation + # lengths are right and only the thickness was uncertain -- the same + # assumption spec made. The odd Si values are measurements, not typos. + # + # Unit 1 (filter_array_1_x): out, Si345.6, Ge300, Ti800, Zr20 + # Unit 2 (filter_array_2_x): out, Si234.6, Si3303, Ti400, Cu20 + # Unit 3 (filter_array_3_x): out, Si137.5, Si1661.5, Ti200, Ti3200, Fe5 + # Unit 4 (filter_array_4_x): out, Si50, Si833.8, Ti100, Ti1600, Ti20 # # Positions 1..6 = [out, m1, m2, m3, m4, m5] # Each entry: ((mat1, th1_um), (mat2, th2_um), enabled_bool) + # + # 'enabled' means the position exists and its transmission can be computed. + # Keeping the Zr/Cu calibration foils enabled is deliberate: they are real + # filters, so _fil_trans_report must account for them when one is parked in + # the beam for an edge scan. Excluding them from automatic selection is a + # separate concern -- see _EXCLUDE_FROM_SEARCH. # ----------------------------------------- _FILTERS: List[Tuple[Tuple[str, float], Tuple[str, float], bool]] = [ # Unit 1 (("none", 0.0), ("none", 0.0), True), # out - (("si", 400.0), ("none", 0.0), True), # Si400 + (("si", 345.6), ("none", 0.0), True), # Si345.6 (("ge", 300.0), ("none", 0.0), True), # Ge300 (("ti", 800.0), ("none", 0.0), True), # Ti800 (("zr", 20.0), ("none", 0.0), True), # Zr20 @@ -94,16 +105,16 @@ class cSAXSFilterTransmission: # Unit 2 (("none", 0.0), ("none", 0.0), True), # out - (("si", 200.0), ("none", 0.0), True), # Si200 - (("si", 3200.0), ("none", 0.0), True), # Si3200 + (("si", 234.6), ("none", 0.0), True), # Si234.6 + (("si", 3303.0), ("none", 0.0), True), # Si3303 (("ti", 400.0), ("none", 0.0), True), # Ti400 (("cu", 20.0), ("none", 0.0), True), # Cu20 (("none", 0.0), ("none", 0.0), False), # unused # Unit 3 (("none", 0.0), ("none", 0.0), True), # out - (("si", 100.0), ("none", 0.0), True), # Si100 - (("si", 1600.0), ("none", 0.0), True), # Si1600 + (("si", 137.5), ("none", 0.0), True), # Si137.5 + (("si", 1661.5), ("none", 0.0), True), # Si1661.5 (("ti", 200.0), ("none", 0.0), True), # Ti200 (("ti", 3200.0), ("none", 0.0), True), # Ti3200 (("fe", 5.0), ("none", 0.0), False), # Fe5 (disabled unless data file provided) @@ -111,12 +122,20 @@ class cSAXSFilterTransmission: # Unit 4 (("none", 0.0), ("none", 0.0), True), # out (("si", 50.0), ("none", 0.0), True), # Si50 - (("si", 800.0), ("none", 0.0), True), # Si800 + (("si", 833.8), ("none", 0.0), True), # Si833.8 (("ti", 100.0), ("none", 0.0), True), # Ti100 (("ti", 1600.0), ("none", 0.0), True), # Ti1600 (("ti", 20.0), ("none", 0.0), True), # Ti20 ] + # Calibration foils, not attenuators. filter.mac flagged these "disabled" so + # that selection by transmission would not pick them, while leaving them + # directly addressable via fil_comb -- they are exactly what you park in the + # beam for a K-edge scan. Zr in particular has its edge at 18.0 keV, so near + # that energy its transmission moves with any small energy change. + # (unit, position), both 1-based. + _EXCLUDE_FROM_SEARCH = frozenset({(1, 5), (2, 5)}) # Zr20, Cu20 + _UNITS = 4 _PER_UNIT = 6 @@ -203,45 +222,9 @@ class cSAXSFilterTransmission: # Only allow fil_trans < 1 when DMM is in THROUGH (both) # and CCM energy > 1 keV. fil_trans(1) is always allowed. # ------------------------------------------------------- - if transmission < 1.0: - try: - dmm_trans = float(epics_get("X12SA-OP-DMM-EMLS-3010:THRU")) - except Exception: - dmm_trans = -1 - try: - dmm_rot = float(epics_get("X12SA-OP-DMM-EMLS-3030:THRU")) - except Exception: - dmm_rot = -1 - try: - ccm_energy = float(epics_get("X12SA-OP-CCM1:ENERGY-GET")) - except Exception: - ccm_energy = -1 - - allowed = (dmm_trans == 1) and (dmm_rot == 1) and (ccm_energy > 1) - - if not allowed: - print("\n⚠️ SAFETY WARNING: Reducing transmission (< 1) typically requires:") - print(" - DMM translation in THROUGH (THRU == 1)") - print(" - DMM rotation in THROUGH (THRU == 1)") - print(" - CCM energy > 1 keV") - print("\nCurrent state:") - print(f" DMM translation THRU : {dmm_trans}") - print(f" DMM rotation THRU : {dmm_rot}") - print(f" CCM energy (keV) : {ccm_energy}") - - # Ask user (default = NO) - if hasattr(self, "OMNYTools") and hasattr(self.OMNYTools, "yesno"): - proceed = self.OMNYTools.yesno( - "Conditions not satisfied. Proceed anyway?", - default="n", - ) - else: - # Safe fallback - proceed = False - - if not proceed: - print("Aborted. Transmission unchanged.") - return None + if transmission < 1.0 and not self._attenuation_allowed(): + print("Aborted. Transmission unchanged.") + return None # --- Energy handling (EPICS only) --- if energy_kev is None: @@ -297,6 +280,155 @@ class cSAXSFilterTransmission: self._execute_combination(best, energy_kev) return None + def fil_comb( + self, + *positions, + energy_kev: Optional[float] = None, + print_only: bool = True, + ) -> Optional[None]: + """ + Set the exposure-box filters to an explicit combination. + + Takes one slot number per unit, 1..6, with 1 = out -- the same numbering + as the SPEC fil_comb. Use this when a particular filter is wanted; use + fil_trans when a particular transmission is wanted. + + The four slot numbers may also be given as a single code, which is the + form fil_trans prints and _fil_trans_report matches, so a reported + combination can be pasted straight back in. + + Examples: + csaxs.fil_comb(1, 4, 1, 1) # Ti 400 only + csaxs.fil_comb(1411) # the same thing + csaxs.fil_comb(1, 2, 3, 1) # Si 234.6 (unit 2) + Si 1661.5 (unit 3) + csaxs.fil_comb(1111) # all out + + Safety: identical to fil_trans. Any combination with transmission < 1 + requires DMM through and the CCM active, or an explicit confirmation. + + Args: + *positions: four slot numbers, one per unit, 1..6 (1 = out). A single + list, tuple, or four-digit code such as 1411 is also accepted. + energy_kev: photon energy. Read from the CCM energy PV if omitted. + print_only: dry run first and prompt before moving. Defaults to True. + """ + if not positions: + print("\nUsage example:") + print(" csaxs.fil_comb(1, 4, 1, 1) # one slot number per unit, 1 = out") + print(" csaxs.fil_comb(1411) # or the same as a single code") + print(" Use csaxs.fil_trans(T) to select by transmission instead.") + print("\nCurrent filter transmission:") + self._fil_trans_report(energy_kev=energy_kev) + return None + + # Accept fil_comb(1411) and fil_comb("1411") -- the same four-digit code + # that fil_trans prints and _fil_trans_report matches -- as well as + # fil_comb(1, 4, 1, 1) and fil_comb([1, 4, 1, 1]). + if len(positions) == 1: + only = positions[0] + if isinstance(only, (list, tuple)): + positions = tuple(only) + elif isinstance(only, (int, str)) and not isinstance(only, bool): + digits = str(only).strip() + if not digits.isdigit(): + raise ValueError( + f"Cannot read {only!r} as a filter combination. Give one slot " + f"number per unit, e.g. fil_comb(1, 4, 1, 1) or fil_comb(1411)." + ) + if len(digits) != self._UNITS: + raise ValueError( + f"A filter code has exactly {self._UNITS} digits, one per unit; " + f"got {digits!r}. Slot numbers run 1..{self._PER_UNIT} with 1 = out, " + f"so fil_comb(1411) is unit 2 at position 4 and the rest out." + ) + positions = tuple(digits) + + # --- Energy handling (EPICS only) --- + if energy_kev is None: + try: + energy_kev = float(epics_get("X12SA-OP-CCM1:ENERGY-GET")) + except Exception as exc: + raise RuntimeError( + "Energy not specified and could not read EPICS PV " + "'X12SA-OP-CCM1:ENERGY-GET'." + ) from exc + else: + energy_kev = float(energy_kev) + + # Validate everything before the safety prompt, so a typo never reaches it. + comb = self._combination_from_positions(positions, energy_kev) + + if comb["transmission"] < 1.0 and not self._attenuation_allowed(): + print("Aborted. Filters unchanged.") + return None + + print("\nExposure-box filter combination request") + print("-" * 60) + print(f"Requested combination : {comb['code']}") + print(f"Photon energy : {energy_kev:.3f} keV") + print(f"Mode : {'PRINT ONLY' if print_only else 'EXECUTE'}") + print("-" * 60) + + self._print_combination(comb, energy_kev, header="Requested combination") + + if print_only: + print("\n[DRY RUN] No motion executed yet.") + if hasattr(self, "OMNYTools") and hasattr(self.OMNYTools, "yesno"): + if self.OMNYTools.yesno( + "Execute motion to the requested filter combination now?", "y" + ): + self._execute_combination(comb, energy_kev) + else: + print("Execution skipped.") + else: + print("No interactive prompt available. Execution skipped (print_only=True).") + return None + + self._execute_combination(comb, energy_kev) + return None + + # ----------------------------- + # Safety + # ----------------------------- + def _attenuation_allowed(self) -> bool: + """ + Check the beamline is in a state where inserting attenuators is sensible. + + Requires DMM translation and rotation in THROUGH and the CCM active. + Returns True to proceed. When the conditions are not met the user is + prompted, defaulting to NO. + """ + try: + dmm_trans = float(epics_get("X12SA-OP-DMM-EMLS-3010:THRU")) + except Exception: + dmm_trans = -1 + try: + dmm_rot = float(epics_get("X12SA-OP-DMM-EMLS-3030:THRU")) + except Exception: + dmm_rot = -1 + try: + ccm_energy = float(epics_get("X12SA-OP-CCM1:ENERGY-GET")) + except Exception: + ccm_energy = -1 + + if (dmm_trans == 1) and (dmm_rot == 1) and (ccm_energy > 1): + return True + + print("\n\u26a0\ufe0f SAFETY WARNING: Reducing transmission (< 1) typically requires:") + print(" - DMM translation in THROUGH (THRU == 1)") + print(" - DMM rotation in THROUGH (THRU == 1)") + print(" - CCM energy > 1 keV") + print("\nCurrent state:") + print(f" DMM translation THRU : {dmm_trans}") + print(f" DMM rotation THRU : {dmm_rot}") + print(f" CCM energy (keV) : {ccm_energy}") + + if hasattr(self, "OMNYTools") and hasattr(self.OMNYTools, "yesno"): + return bool( + self.OMNYTools.yesno("Conditions not satisfied. Proceed anyway?", default="n") + ) + return False + # ----------------------------- # Physics helpers # ----------------------------- @@ -431,10 +563,16 @@ class cSAXSFilterTransmission: for u in range(self._UNITS) ] - # Precompute per-position transmissions + # Precompute per-position transmissions. Positions excluded from the + # search are treated exactly like disabled ones here, i.e. skipped. per_pos_T: List[List[Optional[float]]] = [ - [self._position_transmission(pos_entry, energy_kev) for pos_entry in unit] - for unit in units + [ + None + if (u + 1, i + 1) in self._EXCLUDE_FROM_SEARCH + else self._position_transmission(pos_entry, energy_kev) + for i, pos_entry in enumerate(unit) + ] + for u, unit in enumerate(units) ] combos: List[dict] = [] @@ -477,6 +615,57 @@ class cSAXSFilterTransmission: combos.sort(key=lambda c: c["transmission"]) # ascending return combos + def _combination_from_positions(self, positions, energy_kev: float) -> dict: + """ + Build a combination dict, same shape as the entries from _all_combinations, + from explicit per-unit slot numbers 1..6. + + Every slot is validated before anything moves, so a typo costs nothing. + """ + if len(positions) != self._UNITS: + raise ValueError( + f"fil_comb expects {self._UNITS} positions, got {len(positions)}." + ) + + indices = [] + for unit_idx, pos in enumerate(positions): + try: + pos_int = int(pos) + except Exception: + raise ValueError( + f"Unit {unit_idx + 1}: position must be an integer 1..{self._PER_UNIT}." + ) + if not 1 <= pos_int <= self._PER_UNIT: + raise ValueError( + f"Unit {unit_idx + 1}: position {pos_int} out of range 1..{self._PER_UNIT}." + ) + indices.append(pos_int - 1) + + mats = [] + transmission = 1.0 + for unit_idx, idx in enumerate(indices): + entry = self._FILTERS[unit_idx * self._PER_UNIT + idx] + (mat1, th1), (mat2, th2), enabled = entry + if not enabled: + raise ValueError( + f"Unit {unit_idx + 1} position {idx + 1} is disabled in the filter table." + ) + if self._POSITIONS_USER[unit_idx][idx] is None: + raise ValueError( + f"Unit {unit_idx + 1} position {idx + 1} has no defined coordinate." + ) + transmission *= self._position_transmission(entry, energy_kev) + mats.append( + ((mat1, th1), (mat2, th2) if (mat2 != "none" and th2 > 0.0) else None) + ) + + return { + "code": "".join(str(i + 1) for i in indices), + "indices": indices, + "materials": mats, + "transmission": transmission, + } + def _find_best_combination(self, target_T: float, energy_kev: float) -> dict: """Pick combination with transmission closest to target.""" combos = self._all_combinations(energy_kev) @@ -568,7 +757,7 @@ class cSAXSFilterTransmission: def _fil_trans_report(self, tol: float = 0.1, energy_kev: Optional[float] = None) -> None: """ Report the currently active exposure‑box filter combination. - Determines stage positions via dev..readback.get() + Determines stage positions via dev..readback.get(cached=True) with a tolerance window of ±tol relative to nominal positions. Parameters @@ -610,7 +799,9 @@ class cSAXSFilterTransmission: return try: - rb = float(axis_obj.readback.get()) + # Status report only: the last published readback (cached=True, from redis) + # avoids one RPC round trip per axis. A missing value raises in float() below. + rb = float(axis_obj.readback.get(cached=True)) except Exception: print(f"ERROR: readback unavailable for axis {axis_name}") return diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/slits.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/slits.py index f1653c58..31246a6a 100644 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/slits.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/slits.py @@ -47,7 +47,10 @@ class cSAXSSlits: return None try: - reading = device.read() + # cached=True reads the last published readback from redis. The default + # (cached=False) is one RPC round trip to the device server per device, + # i.e. 24 of them for slits_show_all(). + reading = device.read(cached=True) value = reading[device_name]["value"] return float(value) except Exception: diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md new file mode 100644 index 00000000..1dfd1054 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md @@ -0,0 +1,419 @@ +# Validating PandaBox (`omny_panda_continuous`) recovered positions against RT + +**Purpose of this document:** how to check whether positions recovered from +the PandaBox's Mode B (free-running raw stream, `raw_stream_mode: true`) +capture actually match the true stage positions, using the RT controller's +own trusted per-point averages as ground truth. Read this before continuing +that testing in a new session — it captures the full methodology, the exact +conversion formula, and a bug that was found and fixed this way, so none of +it needs re-deriving from scratch. + +Companion docs: +- `docs/developer/panda_box_free_running_setup.md` — the PandA layout/mode + design doc. Has the mode comparison, signal alias mapping, and (as of + 2026-09-21) the "Known data-quality caveat: stale `frame_counter` + carryover" section this investigation produced. +- `docs/developer/panda_box_free_running_data_schema.md` — the HDF5 field + list for the Mode B raw stream. +- `csaxs_bec/devices/panda_box/panda_box_omny.py` — the device. See + `_drop_stale_frame_counter_rows`/`_reset_raw_stream_state` for the fix + described below. + +--- + +## 1. The problem this validates + +Mode B captures raw samples continuously at `CLOCK1`'s rate for the whole +burst window — **not** one sample per scan point. Only a subset of rows +coincide with an actual detector exposure (`gate_detector_active == 1`), and +even those need to be grouped by which point they belong to +(`frame_counter`). Treating every raw row as its own recovered position +(without grouping/filtering) produces a scatter that roughly traces the +scan's envelope but does not correspond point-for-point to the true +positions — and critically, **no scale/offset correction can fix that**, +because it's a many-to-one correspondence problem, not a coordinate error. +This was confirmed visually (see the git history around 2026-09-20/21 for the +red/blue scatter plot that started this investigation) before the grouping +method below was worked out. + +## 2. Ground truth: `rt_positions` + +The RT controller logs its own per-point values, already correctly averaged +and known-good (used successfully in reconstruction): + +- `rt_positions_data_target_x`/`_y` — commanded (programmed) position. +- `rt_positions_data_average_x_st_fzp`/`_y` — **the trusted measured + position**, in microns. Compare recovered PandA positions against these, + not against `target_x`/`_y`. +- `rt_positions_data_stdev_x_st_fzp`/`_y` — RT's own per-point stdev, useful + as a sanity scale for how much residual noise to expect (order 0.005 µm in + the validated scan). See §9 for a raw-PandA cross-check against this value. + Don't confuse this with `rt_positions_data_average_stdeviations_x_st_fzp`/ + `_y`, which is a running/cumulative mean of `stdev_x_st_fzp` up to that + point in the scan (confirmed by direct comparison, 2026-09-23), not a + per-point noise value — it's the wrong field for a per-point comparison. + +One row per scan point (472 points in the validated scan, `S06313`). + +## 3. Recovering positions from the Mode B raw stream + +### 3.1 Raw fields (all same length, one entry per raw sample) + +Under `entry/collection/devices/omny_panda_continuous/` in the master HDF5: +`omny_panda_continuous_data_/value`, where `` is one of +`interf_st_fzp_x`, `interf_st_fzp_y`, `cap_voltage_fzp_x`, `cap_voltage_fzp_y`, +`frame_counter`, `gate_detector_active` (plus `interf_st_rotx`/`_rotz`, +`pcap_gate_duration_value`, not needed for x/y position recovery). + +### 3.2 Required grouping/filtering — do this before anything else + +1. Filter to rows where `gate_detector_active == 1` (real exposure, not + inter-point flight time). +2. Group the remaining rows by **point** index. For a single-frame-per-point + scan (`frames_per_trigger == 1`), `frame_counter` directly ranges + `0..num_points-1` and *is* the point index. For a burst scan + (`frames_per_trigger > 1`), `frame_counter` increments once per + sub-frame trigger, not once per point (confirmed 2026-09-22 against real + `frames_per_trigger=3` scans, `S00395`/`S00396`/`S00398` — see §7): the + point index is `frame_counter // frames_per_trigger`, and *all* of a + point's sub-frames' gate-active rows get averaged together into that + point's one `rt_positions` value, not kept separate. +3. Average `interf_st_fzp_x`/`_y` and `cap_voltage_fzp_x`/`_y` within each + group. + +Skipping this step is the #1 way to get a result that looks roughly +right-shaped but doesn't actually match — see §1. + +### 3.3 Stale `frame_counter` carryover — check this if a session predates the fix + +PandA's `COUNTER1.OUT` is not cleared by `Arm()`, so the first raw samples of +an acquisition can briefly carry a stale `frame_counter` value left over from +the *previous* acquisition's tail. If that stale value collides with a real +point index later in the same scan, it contaminates that point's group. +`panda_box_omny.py` fixes this client-side as of commit `3eb6e03` +(2026-09-21) — it drops rows before the first observed `frame_counter == 0` +on every arm. **Any HDF5 file captured before that fix was live on the real +device** (i.e. anything from before 2026-09-21, `S06313` included) still has +the stale rows in it and needs the same drop applied manually before +grouping: + +```python +reset_idx = np.argmax(frame_counter_raw == 0.0) # first occurrence +frame_counter_raw = frame_counter_raw[reset_idx:] +# ...apply the same [reset_idx:] slice to every other raw field, in lockstep +``` + +For anything captured after the fix, this is unnecessary (already handled +device-side) — but it's cheap and harmless to keep doing as a defense-in-depth +check; if it ever finds `reset_idx > 0` again on a fixed device, that's a +regression worth investigating. + +### 3.4 Conversion formula (from Mirko, confirmed 2026-09-21) + +```python +factor_x = -0.000309078 +factor_y = 0.000154539 + +interfx = grouped_interf_st_fzp_x * factor_x # microns +interfy = grouped_interf_st_fzp_y * factor_y # microns + +capmuy = -(grouped_cap_voltage_fzp_y) * 5.0 / 20.0 +capmux = (grouped_cap_voltage_fzp_x) * 5.0 / 20.0 + +CapX_FZP_microns = 0.9539 * capmux + 0.0009008 * capmuy +CapY_FZP_microns = -0.023140 * capmux + 1.0120 * capmuy + +pos_x_um = interfx - CapX_FZP_microns # microns -- compare directly against average_x_st_fzp +pos_y_um = interfy - CapY_FZP_microns # microns -- compare directly against average_y_st_fzp +``` + +**Units trap:** the original formula this was transcribed from ends with +`pos_x = (interfx - CapX_FZP_microns) * 1e-6` to get meters, for whatever +consumed it downstream. `rt_positions_data_average_x_st_fzp`/`_y` are in +**microns**, so compare against `pos_x_um`/`pos_y_um` *before* that final +`* 1e-6` — don't convert one side to meters and leave the other in microns. + +`factor_x`/`factor_y` were independently re-derived by least-squares fit +against `average_x_st_fzp`/`_y` from `S06313` and matched Mirko's values to 4 +significant figures (fit: `-3.089108e-04`/`1.545243e-04` vs. given +`-0.000309078`/`0.000154539`) — good cross-check if a future factor is ever +in doubt. + +## 4. Validating a match + +```python +def r2(pred, true): + ss_res = np.sum((true - pred) ** 2) + ss_tot = np.sum((true - true.mean()) ** 2) + return 1 - ss_res / ss_tot +``` + +Expected result on a correctly grouped, correctly converted scan (from +`S06313`, post-fix): +- A single near-constant offset per axis between `pos_x_um`/`pos_y_um` and + `average_x_st_fzp`/`_y` (validated: X ≈ −1.26 µm, Y ≈ +0.99 µm) — this is + expected (RT's internal reference zero vs. the PandA/`INENC` counter's own + free-running zero don't coincide) and irrelevant to reconstruction, since + it's the same constant for every point in one scan. Subtract the mean + residual per axis before judging fit quality. +- After removing that offset: R² > 0.9999 on both axes, residual std ~0.25– + 0.3 nm, max residual under ~1 nm across all points (post-fix; pre-fix, one + point was off by ~116 nm — see §3.3). + +If R² is poor and doesn't clean up after removing a constant offset (i.e. a +best-fit affine still doesn't work — this is the actual symptom that flagged +the original bug, see §1), suspect the grouping/filtering step (§3.2) before +suspecting the conversion factors. + +## 5. Practical notes + +- Use a BEC production venv `python3` for any h5py-based analysis — the + system `python3` doesn't have `h5py` installed. Path is host-dependent: + `/opt/bec_deployments/production/bec_venv/bin/python3` on some hosts, + `/sls/x12sa/config/bec/production/bec_venv/bin/python3` on others (e.g. + `x12sa-bec-001`) — check both if one is missing. +- `S06313`'s files were at `/tmp/S06313/` (`S06313_master.h5` has both + `omny_panda_continuous` and `rt_positions`; `S06313_eiger_*` are detector + data, not needed for this). `/tmp` is not persistent — get a fresh scan's + files from whoever's driving the beamtime if this path is gone. +- Relevant H5 group prefix: + `entry/collection/devices//_data_/value` (and + `.../timestamp`, much shorter — one entry per buffer flush, not per row, + see `_flush_raw_stream_buffer_locked` in `panda_box_omny.py`). + +## 6. Next steps (as of 2026-09-21) + +1. **Burst acquisition** — RESOLVED 2026-09-22, see §7/§3.2: `frame_counter` + increments once per sub-frame trigger, not once per point; + `frame_counter // frames_per_trigger` is the point index, and all + sub-frames of a point get averaged together to match RT's one value per + point. Confirmed against real `frames_per_trigger=3` scans + (`S00395`/`S00396`/`S00398`), R² = 1.0000000 on both axes. +2. **Reduced data mode** — characterized empirically 2026-09-22, see §7 + (`S00399`): captures only rows with `gate_detector_active == 1` (or very + close to it — 99.5% vs. ~39% of rows in normal continuous mode), cutting + total row count roughly in half to a third while keeping the same + per-point exposure sample count and full positional accuracy (R² = + 1.0000000). The underlying PandA-side layout/config change that produces + this is not visible from BEC's `configuration` snapshot in the HDF5 file + (empty for both modes) — this section describes the observed effect on + the data, not the PandA-side mechanism; get that from whoever configured + it if it needs modifying. + +## 7. Post-fix confirmation against live scans (2026-09-22) + +Checked two scans taken after `3eb6e03` landed (both at `/tmp/S/` on +`x12sa-bec-001`, files from 2026-09-22 ~12:45–12:58): + +- **`S00386`** (174 points, ~348k raw rows): `frame_counter` starts at `0` on + the very first raw row and has zero backward steps across the whole raw + stream — no leading stale-value prefix at all, unlike pre-fix `S06313`. + Full grouping+conversion validation against `rt_positions`: R² = + 0.99999997 (X) / 0.99999998 (Y) after removing the expected constant + per-axis offset (−1.23 µm X, +1.08 µm Y, consistent with §4's `S06313` + values); residual std 0.30 nm (X) / 0.25 nm (Y); worst single-point + residual 1.02 nm (X) / 0.51 nm (Y) — no outlier anywhere near the ~116 nm + the pre-fix bug produced. Clean confirmation of the fix. +- **`S00388`** (785 points, ~1.4M raw rows): one backward step, at raw row + 36 (`frame_counter` reads `28` for rows 0–36, then drops to `0` from row + 37 on) — structurally the same leading-stale-prefix signature the fix is + meant to strip, still present in data captured after the fix. However, + every one of those 37 stale rows has `gate_detector_active == 0`, so + they'd never enter a point's average regardless (§3.2 already filters to + `gate_detector_active == 1` before grouping) — this scan happened not to + be exposed to the bug's actual failure mode. Full validation still comes + out clean: R² = 0.99999999 (X) / 0.99999998 (Y), residual std 0.27 nm (X) + / 0.26 nm (Y), max residual 0.85 nm (X) / 0.56 nm (Y). + +**Root-cause investigation (updated 2026-09-22, still not fully resolved):** + +Ruled out first: the leading `frame_counter` column can't be missing from +`S00388`'s first post-arm chunk the way an earlier version of this note +speculated. `pandablocks.connections.DataConnection`'s captured field set is +fixed once per TCP data connection (established once per `on_stage`, before +any `FrameData` is produced), so every `FrameData` chunk within one +acquisition carries the same columns — the "layout doesn't capture +`frame_counter`" fallback in `_drop_stale_frame_counter_rows` +(`counter_entry is None`) isn't reachable here. + +What actually happened, confirmed from `ScanServer.log`/`DeviceServer.log` +for 2026-09-22: the scan immediately preceding `S00388` — scan **#387** +(786 points, `eb7cfd11-3610-4511-94a0-75f49b403e62`) — was opened at +`12:50:10` and force-**aborted** by the user only ~7 seconds later +(`12:50:16`–`17`, `action: 'abort'` in the scan queue). At `exp_time=0.1`s/point +that's on the right order to land around point ~20–30, consistent with the +leftover `frame_counter == 28` that then showed up at the start of `#388`'s +raw stream (`#388` itself opened cleanly at `12:50:39`, no arm retries +logged — matches the "one `on_stage` per scan number" mapping confirmed +against the log). This is exactly the hardware mechanism `3eb6e03`'s commit +message describes (`COUNTER1.OUT` not cleared by `Arm()`) — just triggered +by an **abort mid-scan** rather than a normal scan-to-scan transition, which +is what `S06313` (the original bug report) and `S00386` (clean, §7 above) +both were. + +Why the client-side filter didn't strip it is not fully pinned down. +`PandaBoxOMNY.stop()` (inherited, not overridden) does not call +`_reset_raw_stream_state()` — only `on_stage()` (once per scan) and +`_try_arm()` (right before every `Arm()`) do, and both ran normally for +`#388` per the logs. So by the code's own logic the state should have been +freshly reset well before `#388`'s `Arm()` went out. One structural +suspect, not yet confirmed as the actual cause: `_raw_stream_buffer`/ +`_raw_stream_buffer_row_count`/`_raw_stream_last_flush` are all mutated only +under `_raw_stream_buffer_lock`, but `_raw_stream_seen_reset` is set outside +that lock in `_reset_raw_stream_state()` (after the `with` block) and read +outside the lock at the top of `_drop_stale_frame_counter_rows()` — the one +piece of this acquisition's reset state that isn't synchronized the same +way as everything else it's reset alongside. Whether that gap is actually +reachable in a plain abort → restage sequence (as opposed to needing an +`on_pre_scan` retry, which `#388` didn't have) isn't shown by the logs +available here; would need instrumentation (e.g. temporarily logging +`_raw_stream_seen_reset` transitions) against a reproduced abort-then-rescan +to confirm. + +Practically: harmless in both scans checked so far, since the stale rows' +`gate_detector_active` was `0` throughout — they never entered a point's +average regardless of the row-level filter. But an abort mid-scan is a +routine, user-triggered event (unlike an `on_pre_scan` retry), so this path +is worth closing rather than relying on the gate happening to be low during +the stale window. If revisited: consider (a) locking `_raw_stream_seen_reset` +reads/writes the same as the buffer fields, and (b) also calling +`_reset_raw_stream_state()` from `on_stop`/`stop()`, not only `on_stage`/ +`_try_arm`, so an aborted acquisition can't leave stale state for the next +one to inherit. Re-check against a scan where the stale run's rows show +`gate_detector_active == 1` before considering this fully closed. + +**Mitigation added 2026-09-22:** `omny_panda_continuous` was being armed and +disarmed for *every* scan (any `grid_scan`/`line_scan`/alignment move, not +just `flomni_fermat_scan`), since generic BEC staging has no per-scan device +opt-in and `deviceTags` aren't consulted by it — see "Conditional arming +(`arm_scan_allowlist`)" in `docs/developer/panda_box_free_running_setup.md`. +It's now only actually `Arm()`ed for `flomni_fermat_scan` via the new +`arm_scan_allowlist` device config, which directly cuts down how often this +class of bug can even be triggered (fewer arm cycles → fewer abort→restage +windows like `S00388`'s), independent of whatever the remaining root cause +above turns out to be. + +## 8. Batch validation across `S00391`-`S00399` (2026-09-22) + +Nine `flomni_fermat_scan` runs at `/tmp/S00<391..399>/` on `x12sa-bec-001`, +run as three groups: `S00391`-`394` (single-frame, `frames_per_trigger=1`, +`393` aborted mid-scan), `S00395`-`398` (burst, `frames_per_trigger=3`, +`397` aborted mid-scan), `S00399` (burst, reduced data mode). Note: the +running `bec-device-server` process had been up since before commit +`5590633` (`arm_scan_allowlist`) landed, so it was still running the +pre-`arm_scan_allowlist` code for this whole batch — doesn't matter here +since every scan in this batch is `flomni_fermat_scan`, which +`arm_scan_allowlist` always arms anyway, but worth knowing: this batch +doesn't demonstrate that fix, only the pre-existing stale-`frame_counter` +fix (`3eb6e03`, which does predate the process start and was active). + +**Full position-recovery validation** (§3.2-3.4/§4 methodology, extended for +burst grouping per §6) against `rt_positions`, all nine scans: + +| Scan | mode | points | R² x / y | residual std (nm) x / y | max abs (nm) x / y | +|---|---|---|---|---|---| +| S00391 | single | 99 | 0.9999999 / 0.9999999 | 0.94 / 0.71 | 2.64 / 1.83 | +| S00392 | single | 98 | 0.9999999 / 0.9999999 | 0.95 / 0.76 | 2.75 / 2.09 | +| S00394 | single (post-abort) | 97 | 0.9999999 / 0.9999999 | 0.93 / 0.73 | 2.28 / 2.04 | +| S00395 | burst×3 | 98 | 1.0000000 / 1.0000000 | 0.47 / 0.55 | 0.98 / 1.18 | +| S00396 | burst×3 | 101 | 1.0000000 / 1.0000000 | 0.41 / 0.55 | 1.12 / 1.17 | +| S00398 | burst×3 (post-abort) | 99 | 1.0000000 / 1.0000000 | 0.45 / 0.54 | 1.32 / 1.13 | +| S00399 | burst×3, reduced data | 100 | 1.0000000 / 1.0000000 | 0.48 / 0.50 | 1.69 / 1.14 | + +No point had zero grouped samples in any scan; no residual outlier anywhere +close to the ~116 nm the pre-fix bug produced. Burst-mode R² came out even +tighter than single-frame mode (1.0000000 vs. 0.9999999), consistent with +averaging 3x more raw samples per point. + +**Stale-`frame_counter` prefix — two more clean, precisely-explained +repros**, both immediately following an abort, both harmless (all stale +rows have `gate_detector_active == 0`): + +- `S00393` aborted with `rt_positions` recording exactly 29 points (panda + `frame_counter` max = `29.0`, matching exactly). The very next scan, + `S00394`, starts with 37 rows reading a stale `29.0` before dropping to + `0` at row 37 — same signature as `S00388`, same stale value as `#393`'s + own last completed point. +- `S00397` (burst×3) aborted with `rt_positions` recording exactly 17 + points — 17×3 = 51 sub-frame counts, and panda `frame_counter` max there + is indeed `51.0`. The next scan, `S00398`, starts with 37 rows reading a + stale `51.0` before dropping to `0` at row 37 — same signature again, and + the stale value again matches `#397`'s own last completed sub-frame count + exactly. + +This nails down the trigger mechanism about as precisely as it can be nailed +down from data alone: the leftover `frame_counter` value inherited by the +next scan is *exactly* the aborted scan's last completed count (point count +for single-frame, point×frames_per_trigger for burst) — not some unrelated +stale value from further back. The remaining open question from §7 (why the +client-side filter doesn't strip these rows in the abort→restage case, given +`on_stage`/`_try_arm` both reset state normally beforehand) is unchanged by +this batch — still worth instrumenting if pursued further, though it +remains harmless in every case observed so far because the stale window +never overlaps a gated exposure. + +**Reduced data mode** (`S00399`) validated cleanly at full accuracy — see +§6 for the row-count/gate-fraction characterization. No contamination, no +missing points, same R² bar as the other burst scans. + +## 9. Per-point stdev cross-check against RT's control-loop stdev (`S06313`, 2026-09-23) + +Besides the per-point mean position (§4), PandA's raw samples let you +cross-check RT's own per-point noise estimate +(`rt_positions_data_stdev_x_st_fzp`/`_y`, §2) against an independent +measurement — a single-frame scan has ~998 raw PandA samples landing within +each point's gate window (`CLOCK1`'s free-running rate over one exposure), +so each point's within-group sample scatter is itself a noise estimate. + +### 9.1 Method + +For each grouped point (§3.2, after applying §3.3's stale-prefix drop where +needed), compute `pos_x_um`/`pos_y_um` (§3.4's full formula) **per +individual raw sample**, not just on the group mean, then take the +within-group sample stdev (`ddof=1`). Compare against +`rt_positions_data_stdev_x_st_fzp`/`_y` (not +`average_stdeviations_x_st_fzp`/`_y` — see the note in §2). + +### 9.2 The analog `cap_voltage_fzp_x`/`_y` term is required, not optional + +Naively computing per-sample stdev from `interf_st_fzp_x`/`_y` alone +(skipping the `CapX_FZP_microns`/`CapY_FZP_microns` subtraction in §3.4) +gives a wildly wrong noise estimate: + +| | X stdev (µm) | Y stdev (µm) | corr. with RT stdev (472 points) | +|---|---|---|---| +| Interferometer only, no cap term | 0.072 | 0.049 | 0.22 / 0.24 | +| Full formula (interf − cap, §3.4) | 0.0074 | 0.0063 | 0.98 / 0.99 | +| RT's own stdev | 0.0065 | 0.0058 | — | + +Within a single point, `interf_st_fzp_x` and `cap_voltage_fzp_x` are +anti-correlated at **r ≈ −0.96** (checked on point index 5, n=998 raw +samples). The raw interferometer counts carry a large common-mode +noise/drift component roughly 10x bigger than the true positional noise; the +capacitive-sensor subtraction in §3.4 is what cancels it — the same +combination that's already required for the mean position (§4) turns out to +be even more load-bearing for the noise estimate. Both `FMC_IN.VAL1/VAL2` +(the capacitive analog inputs) and `INENC1`-`4.VAL` (the encoder position +inputs) are captured with `CAPTURE=Value` off the same `CLOCK1` tick (see +`docs/developer/panda_box_free_running_setup.md`), so this isn't a +sampling-rate mismatch between the two channels — the interferometer signal +genuinely is that much noisier on its own. + +**Practical implication:** any future per-sample noise/variance analysis of +Mode B data must use the full interf+cap combination (§3.4), never the raw +interferometer signal alone — skipping the cap term breaks the noise +estimate far more severely than it breaks the mean position. + +### 9.3 Result (`S06313`, all 472 points) + +PandA's within-point stdev (full formula) runs ~8–14% higher than RT's own +stdev (X: 0.0074 vs 0.0065 µm; Y: 0.0063 vs 0.0058 µm), and the two +independent noise estimates correlate at r ≈ 0.98 (X) / 0.99 (Y) +point-by-point. The small systematic excess is expected, not a discrepancy: +RT's stdev comes from its own already-averaged control-loop samples, while +PandA's ~998 raw samples per point pick up additional high-rate noise RT's +loop doesn't report, plus whatever small residual noise the interf/cap +combination itself adds. This doesn't affect the mean-position validation in +§4 (a noise-floor cross-check, not a bias check) — it's a useful additional +confirmation that PandA's raw stream and RT's control loop are seeing +consistent underlying physics, not just consistent means. diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_GUI_TESTING.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_GUI_TESTING.md index ac1eaa22..fe37af3b 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_GUI_TESTING.md +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_GUI_TESTING.md @@ -41,8 +41,10 @@ support on top of it: registry `Flomni.__init__` publishes) so its action dropdown and per-action fields always match whatever's actually in `flomni.py` — no hardcoded mirror. Lets you add steps in order (action + its own fields - only — a device dropdown and target for `move`, a bounded spinbox for - `optimize_idgap`'s `search_range`), remove a step, reorder steps within + only — a device dropdown and target for `move`, three bounded spinboxes + for `move_foptz_with_shift`'s `foptz_target`/`manual_shift_x`/ + `manual_shift_y`, the first of which ranges over `foptz`'s live device + limits rather than a static number), remove a step, reorder steps within the job (move up/down), and set a label + idempotent flag (defaults to "every step idempotent" ANDed, same as the CLI, until you touch the checkbox yourself). On confirm it produces a job dict byte-for-byte @@ -50,7 +52,8 @@ support on top of it: 2. **Command-job-aware queue table.** `TomoQueueDialog`'s table gained a "Details" column; a command job shows `CMD` in the Type column and its step summary + idempotency marker in Details (e.g. - `move{'positions': {'mokev': 6.2}} > optimize_idgap{'search_range': 0.5} [idem]` + `move{'positions': {'ccm_energy': 6.2}} > move_foptz_with_shift{'foptz_target': + 15.0, 'manual_shift_x': 1.0, 'manual_shift_y': -2.0} [idem]` — literally the same string `flomni.tomo_queue_show()` prints). 3. **Self-describing tomo jobs.** "Add current params to queue" now stamps `"kind": "tomo"` and a fresh `"id"` on the job it writes, matching what @@ -201,19 +204,22 @@ file is exactly how a regression sneaks in. ## 4. Test plan — new: command-job builder 6. Click **"Add command…"**. Confirm the dialog opens with an **action - dropdown showing `move` and `optimize_idgap`** (or whatever's currently - in `flomni.py`'s `_TOMO_QUEUE_ACTIONS` — if you've added a third action - since this was written, it should appear automatically with no GUI - change needed; that's the point of reading the live registry). + dropdown showing `move` and `move_foptz_with_shift`** (or whatever's + currently in `flomni.py`'s `_TOMO_QUEUE_ACTIONS` — if you've added a + third action since this was written, it should appear automatically with + no GUI change needed; that's the point of reading the live registry). 7. With `move` selected, confirm the field area shows a **device dropdown** - (should list `mokev`, `idgap`, `ftray` per the current allow-list) and a - **numeric target field**. Pick a device, set a target, click **"Add - step"**. Confirm a line appears in the step list above, formatted like - `move{'positions': {'mokev': 6.2}}`. -8. Switch the action dropdown to `optimize_idgap`. Confirm the field area - now shows a single **search_range** spinbox pre-filled with `0.5`, - range-limited (try typing outside 0–2, confirm the widget clamps it - rather than accepting it). Click **"Add step"**. + (should list `ccm_energy`, `idgap` per the current allow-list -- `foptz` + is deliberately absent, see point 8) and a **numeric target field**. Pick + a device, set a target, click **"Add step"**. Confirm a line appears in + the step list above, formatted like `move{'positions': {'ccm_energy': 6.2}}`. +8. Switch the action dropdown to `move_foptz_with_shift`. Confirm the field + area now shows three spinboxes: **foptz target (absolute)**, + **New manual_shift_x**, **New manual_shift_y**. Confirm + the foptz-target spinbox's range reflects `foptz`'s *live* device limits + (try typing a value past the real limit, confirm the widget clamps it + rather than accepting it -- this is the live-limit lookup, not a static + number). Fill in all three and click **"Add step"**. 9. Confirm the **idempotent checkbox auto-checked itself** after both adds (both current actions default to idempotent=True). Uncheck it by hand, then add another step — confirm it **stays unchecked** (your override @@ -250,9 +256,10 @@ file is exactly how a regression sneaks in. ## 6. Test plan — mixed queue, end to end 17. Build a small realistic sequence via the GUI only: a tomo job ("Add - current params to queue"), then a command job (e.g. a `move` on - `ftray` if you're on the sim — `mokev`/`idgap` only exist on the real - front-end, see plan section 3.1), then another tomo job. + current params to queue"), then a command job (e.g. a `move_foptz_with_shift` + step if you're on the sim — `ccm_energy`/`idgap` only exist on the real + front-end, `foptz` is the only queue-movable device in the sim config), + then another tomo job. 18. From the CLI, run `flomni.tomo_queue_execute()`. Confirm: the tomo job runs, the command job actually moves the device (check `dev..readback.get()` before/after), and the second tomo job @@ -405,8 +412,12 @@ GUI bug and reporting gaps for the hook feature. 42. Queue a `done` tomo job (run one, or add one via CLI with `status` hand-edited to `done`). Select it in the queue dialog and confirm - **"Load into editor"** is enabled. Select a command job (or multiple - rows, or nothing) and confirm it's **disabled** in each case. + **"Load into editor"** is enabled. Select multiple rows (or nothing) + and confirm it's **disabled**. Select a *pending* command job and + confirm the button text instead switches to **"Edit command"** + (enabled) — see 6f below for that path; select a `running`/ + `incomplete`/`done` command job and confirm it's **disabled** (text + reverts to "Load into editor"). 43. Click **"Load into editor"** with nothing else in progress. Confirm the params panel enters edit mode (Edit button hides, Submit/"Add to queue"/Cancel appear) with every field populated from the *job's* @@ -481,6 +492,28 @@ buttons living on two different windows. to queue" — confirm it queues normally with **no** warning (only fires when an edit is actually in progress). +## 6g. Test plan — new: "Edit command" (same button slot as "Load into editor") + +48. Add a multi-step command job via **"Add command…"** (e.g. a `move` step + plus a `move_foptz_with_shift` step), with a custom label and the + idempotent checkbox left at its default. Select it in the queue table + and confirm the button reads **"Edit command"**. +49. Click it. Confirm `CommandJobBuilderDialog` opens titled **"Edit + command job"**, with the step list showing both steps exactly as the + queue table's Details column renders them, the label field pre-filled + with your custom label, and the idempotent checkbox matching what you + set when adding it. +50. Click **Cancel**. Confirm the queue is completely unchanged (same job, + same position, same `id` — check via `flomni.tomo_queue_show()` or + `flomni._tomo_queue_proxy.as_list()` before/after). +51. Reopen it, remove a step, add a different one, change the label, click + **OK**. Confirm the row updates **in place** (same position in the + table, same `id` via the CLI check above) with the new steps/label — + no new row is appended, no old row left behind. +52. Confirm the button is **disabled** for a `running`/`incomplete`/`done` + command job (only `pending` is editable), and also disabled while + **Sort queue…** mode is active. + ## 7. Known limitations of this first iteration (by design, not bugs) Worth knowing before you file something as broken: @@ -501,13 +534,16 @@ Worth knowing before you file something as broken: (`tomo_queue_add_command` with a `positions` dict of more than one device); the GUI builder just doesn't expose it yet. Add extra `move` steps instead if you need to move more than one device. -- **No live device-limit validation.** The plan (section 6.5) mentions - validating against `dev..limits` in addition to the schema's - min/max; this iteration only enforces the schema bounds (via the spinbox - range), not real hardware limits. The execution-time re-check in - `_queue_action_move()` is still the actual safety boundary regardless - (two-layer validation, plan section 3.3), so this is a UX gap, not a - safety gap. +- **Resolved, for fields that opt in: live device-limit validation.** A + float param schema can now declare `"device_limits_from": ""` + (see `move_foptz_with_shift`'s `foptz_target`), which makes both the GUI + spinbox's range and the CLI's add-time check (`_validate_action_kwargs`) + use `dev..limits` instead of the schema's static `min`/`max`. The + plain `move` action's devices (`ccm_energy`/`idgap`) don't use this -- + still just the schema bounds via the spinbox range there. The + execution-time re-check in `_queue_action_move()`/ + `_queue_action_move_foptz_with_shift()` remains the actual safety + boundary regardless (two-layer validation, plan section 3.3). - **Resolved: `Submit` is now a hard block while the beamline is busy**, matching plan section 6.1 literally instead of the earlier soft-warn confirmation dialog. The gap that made the soft warn tempting in the first diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_TESTING.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_TESTING.md index ac1ebee5..8ea5ce73 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_TESTING.md +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/TOMO_QUEUE_TESTING.md @@ -264,8 +264,8 @@ interruptible mid-scan for the crash-resume tests (§4, B5). 10. **✅ Add-time validation rejects bad steps, and rejects them without landing anything in the queue.** `test_add_command_validation_errors`. Unknown action, a device not on the `move` allow-list, an out-of-range/wrong-type - `optimize_idgap.search_range`, and an unknown kwarg are each rejected with the - expected `ValueError`; afterward the queue is confirmed still empty. + `move_foptz_with_shift.foptz_target`, and an unknown kwarg are each rejected + with the expected `ValueError`; afterward the queue is confirmed still empty. 11. **✅ Execution-time allow-list re-check is the real safety boundary.** `test_move_execution_time_allowlist_recheck`. A job with a disallowed device appended directly (bypassing `tomo_queue_add_command`) is refused by @@ -278,10 +278,13 @@ interruptible mid-scan for the crash-resume tests (§4, B5). `test_show_and_webpage_survive_command_job`. A command job (no `params` key) renders its step summary + idempotency marker in both, instead of `KeyError`ing. 14. **✅ Back-compat + real dispatch, combined.** - `test_legacy_job_no_kind_defaults_to_tomo_and_command_moves_ftray`. A `kind`-less + `test_legacy_job_no_kind_defaults_to_tomo_and_command_moves_foptz`. A `kind`-less legacy queue entry still runs as a tomogram; in the same mixed-queue run, a - command job actually moves a device (`ftray`, the sim-testable stand-in for - `mokev`/`idgap` — see the plan doc §3.1) via the real `move` action path. + command job actually moves `foptz` and sets new `manual_shift_x`/ + `manual_shift_y` values via the real `move_foptz_with_shift` action + path (the only sim-movable path now that `ftray` is no longer in the move + allow-list; `foptz` is also the real device this action exists for, unlike + `ftray`'s old sim-only stand-in role). 15. **✅ Non-idempotent resume prompts exactly once, and respects "no".** `test_non_idempotent_command_resume_prompts`. A command job flagged `idempotent=False`, left `incomplete`, triggers the resume prompt (distinct from @@ -294,13 +297,14 @@ interruptible mid-scan for the crash-resume tests (§4, B5). 16. One queued tomogram end-to-end on the real instrument, to confirm the sim's timing/progress semantics match reality closely enough that resume lands on the right angle. -17. `move` on `mokev`/`idgap` end-to-end — neither device is in the flomni sim - config (see the plan doc §3.1), so Step 2's `move` action itself has only been - sim-exercised via `ftray`, never against the real devices it's actually for. +17. `move` on `ccm_energy`/`idgap` end-to-end — neither device is in the flomni + sim config, so Step 2's `move` action itself has only been sim-exercised via + add-time validation (which doesn't require real movement), never a real + execute() against these two devices. -**Gate:** A–D passed on the sim (16/16 tests total). **Steps 1 and 2 are verified -enough to build Step 3 (the GUI) on.** E remains open, and blocks calling `move` on -`mokev`/`idgap` production-ready. +**Gate:** A–D passed on the sim. **Steps 1 and 2 are verified enough to build +Step 3 (the GUI) on.** E remains open, and blocks calling `move` on +`ccm_energy`/`idgap` production-ready. ### A gotcha found testing Step 2 (not a Step-2 bug) @@ -348,11 +352,14 @@ still just its design. - **Registry entry = 4 things:** bound method name, `idempotent` default, help string, and a **params schema** (possibly empty). - **Result-dependent logic lives INSIDE a named action, never across queue steps.** - E.g. "scan idgap, move to peak" is ONE action (`optimize_idgap`) whose method does - the scan, finds the peak, and moves — *not* three queue steps. The queue therefore - never has to carry runtime values between steps; `kwargs` stays plain JSON. A - compound action may take parameters (e.g. `search_range`), declared in its params - schema like any other. + E.g. a hypothetical "scan idgap, move to peak" action would be ONE action whose + method does the scan, finds the peak, and moves — *not* three queue steps. The + queue therefore never has to carry runtime values between steps; `kwargs` stays + plain JSON. The same "bundle, don't split" idea also applies to a compound action + with no runtime-dependent result but a correctness-critical pairing of writes -- + `move_foptz_with_shift` (moves `foptz` + sets new `manual_shift_x`/ + `manual_shift_y` values atomically, deliberately not reachable as two + separate steps) is the concrete example currently in the registry. - **Publish the registry via a global var** (`tomo_queue_actions`) on `Flomni.__init__` so the GUI builds its dropdowns/forms from it — no hardcoded mirror to drift (the `QUEUE_PARAM_NAMES` mirror between `flomni.py` and `tomo_params.py` already has this @@ -399,9 +406,10 @@ Guarded against command jobs (`job.get("kind", "tomo") == "tomo"` before touchin ## 6. Open questions for Mirko — all resolved - **The final action set and device allow-list**, see `TOMO_QUEUE_COMMAND_JOBS_PLAN.md` - §10 — `move` + `optimize_idgap` only, `_TOMO_QUEUE_MOVE_DEVICES = {mokev, idgap}`. - One detail (`optimize_idgap`'s peak-detection signal) deferred to the - implementation session, not blocking. + §10 — originally `move` + `optimize_idgap`, `_TOMO_QUEUE_MOVE_DEVICES = {mokev, + idgap}`. Superseded since: `optimize_idgap` was removed (never commissioned, stayed + a no-op stub), `mokev` was fixed to the real device name `ccm_energy`, and a new + `move_foptz_with_shift` action was added (see §4D10/§4D14 above for its tests). - **`_TOMO_QUEUE_PARAM_NAMES` renamed to `_TOMO_SCAN_PARAM_NAMES`** (it reads live properties, not queue state — the old name was misleading). Done in `flomni.py`; the `tomo_params.py` comment referencing it by name was updated too (the diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/Webpage.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/Webpage.md index 18554f43..a9eca2cb 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/Webpage.md +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/Webpage.md @@ -198,6 +198,13 @@ cannot drift. - **Consider**: the PSI **EPICS archiver REST interface** to sidestep CA firewall concerns. +### Interferometer signal-strength trend (proposed, not implemented) +- See `OMNY_shared/AI_docs/TODO_interferometer_signal_trend.md` for the full + design note (2026-09-19) — a new webpage section trending interferometer + signal strength over time, sampled at ~1 point/hour or less (not + per-projection). Blocked on omny specifically needing a real + `OmnyWebpageGenerator` built first (its current file is dead/unwired code). + ### Deferred - **LamNI**: fill in `LamniWebpageGenerator` — real `tomo_type` key values in `TOMO_TYPES`, device paths / `_collect_setup_data()`, `LamNI.png`. Add a diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/docs/cSAXS_HR August 2018_chip_maxime.pdf b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/cSAXS_HR August 2018_chip_maxime.pdf new file mode 100755 index 00000000..39a3e6bd Binary files /dev/null and b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/cSAXS_HR August 2018_chip_maxime.pdf differ diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 91a603ba..329a2b41 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -19,6 +19,18 @@ from csaxs_bec.bec_ipython_client.plugins.cSAXS import cSAXSBeamlineChecks from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_optics_mixin import FlomniOpticsMixin from csaxs_bec.bec_ipython_client.plugins.flomni.gui_tools import flomniGuiTools from csaxs_bec.bec_ipython_client.plugins.flomni.x_ray_eye_align import XrayEyeAlign +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.angle_file import ( + AngleFileError, + read_angle_file, + summarize_angles, +) +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.filter_check import ( + filters_out_of_beam, + warn_and_confirm, +) +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_angle_file_mixin import ( + TomoAngleFileMixin, +) from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import ( TomoQueueMixin, _GlobalVarParam, @@ -30,7 +42,6 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import ) from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.ptouch_printer import PTouchLabelPrinter from csaxs_bec.devices.omny.galil.galil_ophyd import GalilError -from csaxs_bec.devices.omny.sample_desc_codec import pack_desc, unpack_desc logger = bec_logger.logger @@ -382,10 +393,10 @@ class FlomniSampleTransferMixin: def ensure_fheater_up(self): axis_id = dev.fheater._config["deviceConfig"].get("axis_Id") axis_id_numeric = self.axis_id_to_numeric(axis_id) - low, high = dev.fheater.controller.get_motor_limit_switch(axis_id) + _low, high = dev.fheater.controller.get_motor_limit_switch(axis_id) if high: raise FlomniError("fheater in high limit. How did we get here?? Aborting.") - if not low: + if not self._fheater_is_up(): self.ensure_osa_back() if dev.fheater.readback.get() < -0.2: umv(dev.fheater, -0.2) @@ -461,6 +472,8 @@ class FlomniSampleTransferMixin: umv(dev.fsamx, fsamx_in) dev.fsamx.limits = [fsamx_in - 0.4, fsamx_in + 0.4] + umv(dev.fsamroy, 0) + print("Moving X-ray eye in.") # if self.OMNYTools.yesno( @@ -475,6 +488,8 @@ class FlomniSampleTransferMixin: self.feye_in() print("Moving X-ray optics out.") self.foptics_out() + if dev.fheater.user_parameter.get("enabled"): + self.move_fheater_down() self.xrayeye_update_frame() def laser_tracker_show_all(self): @@ -712,6 +727,9 @@ class FlomniSampleTransferMixin: umv(dev.fsamroy, 0) + self.feye_in() + self.foptics_out() + self.feedback_disable() self.ensure_fheater_up() @@ -731,6 +749,9 @@ class FlomniSampleTransferMixin: self.laser_tracker_off() time.sleep(0.05) + + umv(dev.fsamroy, 90) + self.drive_axis_to_limit(dev.fsamx, "forward") dev.fsamx.limits = [-162, 0] dev.fsamx.controller.socket_put_confirmed("axspeed[4]=25*stppermm[4]") @@ -796,13 +817,11 @@ class FlomniSampleTransferMixin: self.ftransfer_controller_disable_mount_mode() self.ensure_gripper_up() - signal_name = getattr(dev.flomni_samples.sample_names, f"sample{position}") - name, owner = unpack_desc(signal_name.get()) - self.flomni_modify_storage_non_interactive(100, 1, name, owner=owner) - self.flomni_modify_storage_non_interactive(position, 0, "-") + # the whole record (name, owner, measurement status/date) moves to the gripper + dev.flomni_samples.transfer_sample(position, 100) def ftransfer_show_all(self): - dev.flomni_samples.show_all(measured=self.measured_log.snapshot()) + dev.flomni_samples.show_all() def ftransfer_put_sample(self, position: int): self.check_position_is_valid(position) @@ -855,9 +874,8 @@ class FlomniSampleTransferMixin: self.ftransfer_controller_disable_mount_mode() self.ensure_gripper_up() - sample_name, sample_owner = unpack_desc(dev.flomni_samples.sample_in_gripper_name.get()) - self.flomni_modify_storage_non_interactive(100, 0, "-") - self.flomni_modify_storage_non_interactive(position, 1, sample_name, owner=sample_owner) + # the whole record (name, owner, measurement status/date) moves from the gripper + dev.flomni_samples.transfer_sample(100, position) if position == 0: self.ftransfer_flomni_stage_in() @@ -867,18 +885,7 @@ class FlomniSampleTransferMixin: """ Get the name of the sample currently in the given position. """ - signal_name = getattr(dev.flomni_samples.sample_names, f"sample{position}") - return unpack_desc(signal_name.get())[0] - - def sample_get_measured_log_key(self, position: int = 0) -> str: - """Packed `name | owner` identity key for the sample at `position`, - for use with `self.measured_log` -- NOT the same as `sample_name` - (a `sample_get_name()` alias), which is name-only and would silently - under-key the measured log against a different sample of the same - name but a different owner. - """ - name, owner = dev.flomni_samples.get_sample_name_and_owner(position) - return pack_desc(name, owner) + return dev.flomni_samples.get_sample_name(position) def ftransfer_sample_change(self, new_sample_position: int): self.check_tray_in() @@ -987,6 +994,10 @@ class FlomniSampleTransferMixin: self.ftransfer_get_sample(new_sample_position) self.ftransfer_put_sample(0) + # The manual shifts belong to the previous sample; start the new one clean. + self.manual_shift_x = 0.0 + self.manual_shift_y = 0.0 + def ftransfer_modify_storage(self, position: int, used: int): if used: name = input("What's the name of this sample? ") @@ -1013,23 +1024,22 @@ class FlomniSampleTransferMixin: print offer would incorrectly fire on every ordinary transfer step instead of only on a genuine manual clear/replace. """ - if not dev.flomni_samples.is_sample_slot_used(position): - return - prev_name, prev_owner = dev.flomni_samples.get_sample_name_and_owner(position) - prev_packed = pack_desc(prev_name, prev_owner) - record = self.measured_log.pop(prev_packed) - if record is None: + # measurement status/date/owner live on the sample record in the IOC + # (FLOMNI-SAMPLE::STATUS/MEAS_DATE/OWNER) since 2026-10-17 + record = dev.flomni_samples.get_sample_record(position) + if not record["used"] or record["status"] == "none": return + prev_name = record["name"] if self.OMNYTools.yesno( f"Sample '{prev_name}' was measured ({record['status']}) on" - f" {record['measured_date']}. Print a label?", + f" {record['meas_date']}. Print a label?", "y", ): self.label_printer.ensure_ready() printed = self.label_printer.print_label( { - self._PTOUCH_FIELD_ACCOUNT: record["account"], - self._PTOUCH_FIELD_DATE: record["measured_date"], + self._PTOUCH_FIELD_ACCOUNT: record["owner"], + self._PTOUCH_FIELD_DATE: record["meas_date"], self._PTOUCH_FIELD_SAMPLENAME: prev_name, } ) @@ -1042,15 +1052,11 @@ class FlomniSampleTransferMixin: def flomni_modify_storage_non_interactive( self, position: int, used: int, name: str, owner: str = "" ): - packed_name = pack_desc(name, owner) - if position == 100: - dev.flomni_samples.sample_in_gripper.set(used) - dev.flomni_samples.sample_in_gripper_name.set(packed_name) + # position 100 = gripper; a new/renamed sample starts with measurement status "none" + if used: + dev.flomni_samples.set_sample_slot(position, name, owner=owner) else: - signal = getattr(dev.flomni_samples.sample_placed, f"sample{position}") - signal.set(used) - signal_name = getattr(dev.flomni_samples.sample_names, f"sample{position}") - signal_name.set(packed_name) + dev.flomni_samples.unset_sample_slot(position) def check_position_is_valid(self, position: int): if 0 <= position < 21: @@ -1173,7 +1179,7 @@ class FlomniSampleTransferMixin: self.check_tray_in() if position == 0: - umv(dev.ftransx, 11.02, dev.ftransz, 3.5950) + umv(dev.ftransx, 10.95, dev.ftransz, 3.5950) if position == 1: umv( dev.ftransx, @@ -1398,7 +1404,21 @@ class FlomniAlignmentMixin: logger.warning("Not applying any correction.") def reset_tomo_alignment_fit(self): - self.client.delete_global_var("tomo_alignment_fit") + """Reset the alignment fit to all zeros (no offset applied). + + Writes zeros rather than deleting the global var, so that + "tomo_alignment_fit" is always defined and can be tweaked by hand + (see the layout comment above get_alignment_offset). + """ + self.client.set_global_var("tomo_alignment_fit", np.zeros((2, 5)).tolist()) + + def ensure_tomo_alignment_fit(self): + """Define "tomo_alignment_fit" (all zeros) if it does not exist yet. + + An existing fit is left untouched so it survives a client restart. + """ + if self.client.get_global_var("tomo_alignment_fit") is None: + self.reset_tomo_alignment_fit() def read_alignment_offset( self, @@ -1670,85 +1690,9 @@ class _ProgressProxy: return self._load() -class _MeasuredSampleLog: - """Tracks which flomni samples have been measured, for the P-touch - label-printing feature (print an account/date/name label when a - measured sample is cleared or replaced in tray storage). - - Keyed by the packed ``name | owner`` string (see pack_desc/unpack_desc - in sample_desc_codec.py) rather than by slot number, since that packed - string is the identity that already travels with a sample across - ftransfer_get_sample/ftransfer_put_sample transfers between tray - slots, the gripper, and the stage -- tracking by slot number would - require threading a flag through every one of those transfer calls. - Persisted as a BEC global var (same pattern as _ProgressProxy above) - so it's visible to any client session -- CLI or GUI widget -- and - survives a kernel restart. - - Two statuses: "started" (a fresh tomogram began for this sample) and - "completed" (that tomogram's scan loop ran to completion without - error). Both count as "measured" for is_measured()/the print offer -- - "started" without "completed" just means the tomogram may be - incomplete, which callers surface in the offer wording rather than - suppressing the offer entirely. - """ - - _GLOBAL_VAR_KEY = "flomni_measured_samples" - - def __init__(self, client): - self._client = client - - def _load(self) -> dict: - val = self._client.get_global_var(self._GLOBAL_VAR_KEY) - return val if val is not None else {} - - def _save(self, data: dict) -> None: - self._client.set_global_var(self._GLOBAL_VAR_KEY, data) - - def mark_measured(self, sample_key: str, account: str, date: str, status: str) -> None: - """Record `sample_key` as measured, overwriting any existing record. - - Overwriting (rather than merging) means re-measuring a sample from - scratch correctly resets a stale "completed" back to "started". - """ - data = self._load() - data[sample_key] = {"account": account, "measured_date": date, "status": status} - self._save(data) - - def is_measured(self, sample_key: str) -> bool: - return sample_key in self._load() - - def get(self, sample_key: str) -> dict | None: - return self._load().get(sample_key) - - def snapshot(self) -> dict: - """One bulk fetch of the whole log, as a plain dict. - - Used instead of `get` when the caller needs to hand the data to - `dev.flomni_samples.show_all()`, which runs via BEC's - device-server RPC mechanism -- every argument gets msgpack- - serialized, so a bound method (`self.measured_log.get`) can't be - passed directly; a plain dict can. - """ - return self._load() - - def pop(self, sample_key: str) -> dict | None: - """Remove and return the record for `sample_key`, if any. - - Called once the print offer for that sample has been resolved - (printed or declined), so the log stays bounded to - currently-measured-but-not-yet-cleared samples instead of growing - unbounded over a beamtime. - """ - data = self._load() - record = data.pop(sample_key, None) - if record is not None: - self._save(data) - return record - - class Flomni( TomoQueueMixin, + TomoAngleFileMixin, FlomniInitStagesMixin, FlomniSampleTransferMixin, FlomniAlignmentMixin, @@ -1756,11 +1700,20 @@ class Flomni( cSAXSBeamlineChecks, flomniGuiTools, ): - def __init__(self, client): + _TOMO_PARAMS_WINDOW_NAME = "flomni_tomo_params" + _TOMO_PARAMS_ERROR = FlomniError + _ANGLE_FILE_OPPOSITE_WARNING = True # parallel-beam: 180-deg opposites are redundant + + # Backed by a BEC global var (Redis) so the pre-open slit position + # survives a kernel restart while the eye is still in. + _sl1xs_before_eye = _GlobalVarParam(None) + _sl1ys_before_eye = _GlobalVarParam(None) + + def __init__(self, client, csaxs=None): super().__init__() self.client = client + self.csaxs = csaxs self.device_manager = client.device_manager - self.tomo_id = -1 self.special_angles = [] self.special_angle_repeats = 20 self.special_angle_tolerance = 20 @@ -1793,10 +1746,10 @@ class Flomni( self.reconstructor = PtychoReconstructor(self.ptycho_reconstruct_foldername) self.tomo_id_manager = TomoIDManager() self.align = XrayEyeAlign(self.client, self) - self.measured_log = _MeasuredSampleLog(self.client) self.label_printer = PTouchLabelPrinter(host="BRN94DDF8AAB8EC.psi.ch", template_number=1) self.set_client(client) + self.ensure_tomo_alignment_fit() self._maybe_reset_params_on_account_change() def set_web_password(self, password: str) -> None: @@ -1811,14 +1764,19 @@ class Flomni( self._webpage_gen.start(force=True) def _maybe_reset_params_on_account_change(self) -> None: - """Offer a tomo-parameter reset when the active account has changed. + """Offer a tomo-parameter/queue/progress reset when the active + account has changed. Called at BEC session start. The account for which defaults were last applied is stored in the global var ``defaults_applied_for_account`` so it survives client restarts. Only a genuine change of account triggers the (interactive) reset; the same account is a silent no-op, so restarting a client within the same experiment never disturbs tuned - parameters. + parameters, the tomo queue, or in-progress acquisition state. + + On a confirmed reset, this also clears the tomo queue and resets tomo + acquisition progress (via tomo_queue_clear()/tomo_progress_reset()), + since both are leftover state from the previous account/experiment. """ bec = builtins.__dict__.get("bec") try: @@ -1835,11 +1793,13 @@ class Flomni( if self.OMNYTools.yesno( f"New account '{account}' detected (previous: '{last_account}').\n" - "Reset tomo parameters to defaults for the new experiment?", + "Reset tomo parameters to defaults, clear the tomo queue, and " + "reset tomo acquisition progress for the new experiment?", "y", ): self._set_default_tomo_params() - print(f"Tomo parameters reset to defaults for account '{account}'.") + self.tomo_queue_clear() + self.tomo_progress_reset() self.client.set_global_var("defaults_applied_for_account", account) def _set_default_tomo_params(self) -> None: @@ -1850,7 +1810,9 @@ class Flomni( not reset here, as it is overwritten by the next alignment anyway. """ self.tomo_shellstep = 1 + self.fermat_asymmetry = 1.0 self.tomo_countingtime = 0.1 + self.manual_shift_x = 0.0 self.manual_shift_y = 0.0 self.single_point_random_shift_max = 0.0 self.fovx = 20 @@ -1866,7 +1828,9 @@ class Flomni( self.tomo_angle_range = 180 self.golden_projections_at_0_deg_for_damage_estimation = 0 self.zero_deg_reference_at_each_subtomo = False + self.tomo_type1_always_forward = False self.golden_ratio_bunch_size = 20 + self.tomo_angle_file = "" self.frames_per_trigger = 1 self.single_point_instead_of_fermat_scan = False @@ -1995,8 +1959,12 @@ class Flomni( tomo_shellstep = _GlobalVarParam(1) + fermat_asymmetry = _GlobalVarParam(1.0) + tomo_countingtime = _GlobalVarParam(0.1) + manual_shift_x = _GlobalVarParam(0.0) + manual_shift_y = _GlobalVarParam(0.0) # at_each_angle_hook is inherited from TomoQueueMixin: name of a @@ -2033,8 +2001,8 @@ class Flomni( @fovx.setter def fovx(self, val: float): - if val > 200: - raise ValueError("FOV cannot be larger than 200 um.") + if val > 220: + raise ValueError("FOV cannot be larger than 220 um.") self.client.set_global_var("fovx", val) @property @@ -2046,8 +2014,14 @@ class Flomni( @fovy.setter def fovy(self, val: float): - if val > 100: - raise ValueError("FOV cannot be larger than 100 um.") + # Strict "<" to match FlomniFermatScan's own ScanArgument bound + # (gt=0, lt=100) -- fovy=100.0 was previously accepted here (and by + # the GUI/CLI, both capped at an inclusive 100.0) but rejected at + # scan time with ScanInputValidationError, only surfacing on real + # hardware. fovx has no such gap (property and FlomniFermatScan both + # use an inclusive <=220). + if val >= 100: + raise ValueError("FOV must be less than 100 um.") self.client.set_global_var("fovy", val) @property @@ -2068,6 +2042,9 @@ class Flomni( elif val == 3: # equally spaced tomography with starting angles shifted by golden ratio self.client.set_global_var("tomo_type", val) + elif val == 4: + # angles (and optional sub-tomogram numbers) read from tomo_angle_file + self.client.set_global_var("tomo_type", val) else: raise ValueError("Unknown tomo_type.") @@ -2113,11 +2090,15 @@ class Flomni( golden_projections_at_0_deg_for_damage_estimation = _GlobalVarParam(0) + def _default_max_scan_angle(self) -> float: + """Upper limit for tomo types 1-3 (type 4 uses the mixin's fixed range).""" + return self.tomo_angle_range + @property def zero_deg_reference_at_each_subtomo(self): """If True (tomo_type == 1 only), an additional projection at exactly 0 degrees is acquired at the start of every odd (forward) - sub-tomogram - i.e. every time the rotation passes back through + sub-tomogram (every sub-tomogram if tomo_type1_always_forward) - i.e. every time the rotation passes back through 0 degrees - and once more after the final (8th) sub-tomogram completes. Together with sub-tomogram 1's own first projection (which already lands exactly on 0 degrees), this gives a 0-degree @@ -2134,6 +2115,24 @@ class Flomni( def zero_deg_reference_at_each_subtomo(self, val: bool): self.client.set_global_var("zero_deg_reference_at_each_subtomo", val) + @property + def tomo_type1_always_forward(self): + """If True (tomo_type == 1, tomo_angle_range == 180 only), every + sub-tomogram sweeps forward (0 -> 180) instead of alternating + forward/reverse. Each sub-tomogram covers the same angle set either + way, so the combined grid is unchanged; the rotation just returns to + ~0 between sub-tomograms. Because every sub-tomogram then starts at + 0 degrees, zero_deg_reference_at_each_subtomo takes its reference + shot before every sub-tomogram instead of every second one.""" + val = self.client.get_global_var("tomo_type1_always_forward") + if val is None: + return False + return val + + @tomo_type1_always_forward.setter + def tomo_type1_always_forward(self, val: bool): + self.client.set_global_var("tomo_type1_always_forward", bool(val)) + golden_ratio_bunch_size = _GlobalVarParam(20) @property @@ -2197,6 +2196,13 @@ class Flomni( dev = builtins.__dict__.get("dev") bec = builtins.__dict__.get("bec") + all_out, offending = filters_out_of_beam(dev) + if not all_out: + warning = f"Not all filters are out of the beam: {', '.join(offending)}." + if not warn_and_confirm(self, warning, interactive=True, force=False): + print("Aborting tomo alignment scan.") + return + # Only run the full eye-out / optics-in transition if we are not # already in measurement condition with feedback running. That # transition disables and re-enables-with-reset the rt feedback, which @@ -2288,7 +2294,9 @@ class Flomni( f.write(" ".join(map(str, zeros)) + "\n") @staticmethod - def _subtomo_angle_plan(subtomo_number, tomo_angle_range, tomo_angle_stepsize, start_angle=None): + def _subtomo_angle_plan( + subtomo_number, tomo_angle_range, tomo_angle_stepsize, start_angle=None, always_forward=False + ): """ Pure angle-generation logic for one sub-tomogram of an equally spaced 8-sub-tomogram tomography scan (tomo_type == 1). No device @@ -2333,6 +2341,10 @@ class Flomni( scan would produce, at the same total projection count, with zero redundant measurements anywhere. + always_forward (bool): 180 mode only - sweep every sub-tomogram + forward instead of alternating direction (same angle set per + sub-tomogram, only the order changes). Ignored in 360 mode. + Returns: angles (np.ndarray): the N angles (degrees) for this sub-tomogram. subtomo_offset (int): index of this sub-tomogram's first angle @@ -2362,7 +2374,7 @@ class Flomni( if tomo_angle_range == 180: base = 0.0 - forward = bool(subtomo_number % 2) + forward = True if always_forward else bool(subtomo_number % 2) else: mod4 = subtomo_number % 4 base = 0.0 if mod4 in (1, 0) else 180.0 @@ -2443,7 +2455,10 @@ class Flomni( ) subtomo_number = projection_number // N + 1 angles, _, _, _ = self._subtomo_angle_plan( - subtomo_number, self.tomo_angle_range, self.tomo_angle_stepsize + subtomo_number, + self.tomo_angle_range, + self.tomo_angle_stepsize, + always_forward=self.tomo_type1_always_forward, ) return subtomo_number, float(angles[projection_number % N]) @@ -2459,7 +2474,11 @@ class Flomni( print(f"Sub tomo scan with start angle {start_angle} requested.") angles, subtomo_offset, N, step = self._subtomo_angle_plan( - subtomo_number, self.tomo_angle_range, self.tomo_angle_stepsize, start_angle=start_angle + subtomo_number, + self.tomo_angle_range, + self.tomo_angle_stepsize, + start_angle=start_angle, + always_forward=self.tomo_type1_always_forward, ) for i, angle in enumerate(angles): @@ -2492,7 +2511,7 @@ class Flomni( shot: forcing that shot before a sub-tomogram that doesn't actually start near 0 would mean a large, wasted detour.""" if self.tomo_angle_range == 180: - return bool(subtomo_number % 2) + return self.tomo_type1_always_forward or bool(subtomo_number % 2) return subtomo_number % 4 == 1 @staticmethod @@ -2509,9 +2528,10 @@ class Flomni( return not isinstance(exc, FlomniError) @scan_repeat(max_repeats=10, default=True, exc_handler=_retry_unless_flomni_error) - def _tomo_scan_at_angle(self, angle, subtomo_number): + def _tomo_scan_at_angle(self, angle, subtomo_number, *, step=None, exp_time=None, + frames_per_trigger=None): - if 0 <= angle < self.tomo_angle_range + 0.05: + if self._min_scan_angle() <= angle < self._max_scan_angle() + 0.05: now = datetime.datetime.now() prev_heartbeat_str = self.progress.get("heartbeat") if prev_heartbeat_str is not None: @@ -2540,18 +2560,27 @@ class Flomni( if not self.special_angles: self._current_special_angles = [] - if self._current_special_angles: - next_special_angle = self._current_special_angles[0] - if np.isclose(angle, next_special_angle, atol=0.5): - self._current_special_angles.pop(0) - num_repeats = self.special_angle_repeats - else: - num_repeats = 1 + # num_repeats is always defined (it used to be unbound whenever + # special_angles was set but this angle wasn't near the next one + # -> UnboundLocalError, retried 10x by @scan_repeat) and the + # special-angle list is consumed only after the projection + # succeeded, so a retried attempt still sees the same special + # angle and repeat count. + num_repeats = 1 + is_special_angle = bool( + self._current_special_angles + and np.isclose(angle, self._current_special_angles[0], atol=0.5) + ) + if is_special_angle: + num_repeats = self.special_angle_repeats start_scan_number = bec.queue.next_scan_number projection_start = time.perf_counter() + overrides = self._angle_overrides_kwargs(step, exp_time, frames_per_trigger) for i in range(num_repeats): - self._at_each_angle(angle) + self._at_each_angle(angle, **overrides) projection_duration = time.perf_counter() - projection_start + if is_special_angle: + self._current_special_angles.pop(0) end_scan_number = bec.queue.next_scan_number for scan_nr in range(start_scan_number, end_scan_number): @@ -2637,8 +2666,42 @@ class Flomni( Args: interactive: accepted for signature compatibility with LamNI.tomo_scan() (tomo_queue_execute() calls both the same - way) -- unused here, FlOMNI has no fine-alignment gate. + way). FlOMNI has no fine-alignment gate, but it is used by + the filter-out-of-beam check below. + projection_number: For tomo_types 2, 3 and 4, resume from this + (0-indexed) projection. For type 4 this is an index into the + angle file as it reads *now* -- the file is re-read on every + call, including resume, and again during the scan itself + whenever it catches up to the current end of file. """ + dev = builtins.__dict__.get("dev") + + is_new_scan = ( + (self.tomo_type == 1 and subtomo_start == 1 and start_angle is None) + or (self.tomo_type == 2 and projection_number == None) + or (self.tomo_type == 3 and projection_number == None) + or (self.tomo_type == 4 and projection_number is None) + ) + if is_new_scan: + # Clear any stale progress from a previous scan immediately, before + # the angle-file check and the eye/optics-in / filter-out-of-beam + # gates below -- otherwise, if any of those decline or raise (e.g. + # the setup isn't in measurement condition, which disabling rt + # feedback can easily cause), tomo_scan() returns/raises having + # never touched `progress`, and the GUI/webpage keep showing + # projection/angle/start-time from whatever scan last completed -- + # arbitrarily old, and easy to mistake for a scan still running. + self.progress.reset() + + # tomo_type 4: (re-)read the angle file before anything else, so a + # missing/malformed file fails immediately -- before sample DB + # registration, empty frames, or any motion (and, in the tomo queue, + # marks the job incomplete with this message). The file need not be + # complete (end with 'END') yet -- that's the normal starting state + # for a file meant to be appended to while the scan runs. + file_angles = None + if self.tomo_type == 4: + file_angles = self._start_angle_file_scan(projection_number) if not self._check_eye_out_and_optics_in(): print( @@ -2650,6 +2713,13 @@ class Flomni( print("Stopping.") return + all_out, offending = filters_out_of_beam(dev) + if not all_out: + warning = f"Not all filters are out of the beam: {', '.join(offending)}." + if not warn_and_confirm(self, warning, interactive=interactive, force=False): + print("Aborting tomo scan.") + return + self.flomnigui_show_progress() bec = builtins.__dict__.get("bec") @@ -2659,13 +2729,7 @@ class Flomni( bec.builtin_actors.scan_interlock.enabled = True self._current_special_angles = self.special_angles.copy() - # a new tomo scan was started - if ( - (self.tomo_type == 1 and subtomo_start == 1 and start_angle is None) - or (self.tomo_type == 2 and projection_number == None) - or (self.tomo_type == 3 and projection_number == None) - ): - + if is_new_scan: # pylint: disable=undefined-variable # Always attempt registration (even for an empty/test account) # and let add_sample_database() -> TomoIDManager.register() @@ -2680,22 +2744,32 @@ class Flomni( "test additional info", "BEC", ) - self.measured_log.mark_measured( - self.sample_get_measured_log_key(0), - bec.active_account or "", + # measurement status on the sample record (stage slot 0) in the IOC + dev.flomni_samples.set_sample_measured( + 0, + "started", datetime.date.today().strftime("%Y-%m"), - status="started", + owner_if_empty=bec.active_account or "", ) self.write_pdf_report() + # estimated_remaining_time/estimated_finish_time/accumulated_idle_time/ + # heartbeat/subtomo/projection/angle/... were already cleared by the + # self.progress.reset() above, before the gates -- only the actual + # start time is set here, once the scan is genuinely committed to. 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 - # accumulated enough projections to compute a fresh one - self.progress["estimated_remaining_time"] = None - self.progress["estimated_finish_time"] = None - self.progress["accumulated_idle_time"] = 0.0 - self.progress["heartbeat"] = None + if file_angles is not None: + self._init_angle_file_progress(file_angles, projection_number) self.collect_empty_frames() + # Unconditional single 0-degree reference projection at the very + # start of a new tomogram, independent of + # zero_deg_reference_at_each_subtomo / + # golden_projections_at_0_deg_for_damage_estimation -- gives an + # immediate radiation-damage baseline even when those flags are + # off. Real data (uses _tomo_scan_at_angle like any grid + # projection), included in reconstruction like any other + # projection -- not a flat-field/reference-only frame like + # collect_empty_frames() above. Logged under subtomo_number=1. + self._tomo_scan_at_angle(0, 1) try: with scans.dataset_id_on_hold: @@ -2846,6 +2920,8 @@ class Flomni( f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections" ) break + elif self.tomo_type == 4: + self._run_angle_file_loop(file_angles, projection_number) else: raise FlomniError("undefined tomo type") finally: @@ -2862,38 +2938,14 @@ class Flomni( self._print_progress() self._log_tomogram_timing() self.OMNYTools.printgreenbold("Tomoscan finished") - self.measured_log.mark_measured( - self.sample_get_measured_log_key(0), - bec.active_account or "", + # measurement status on the sample record (stage slot 0) in the IOC + dev.flomni_samples.set_sample_measured( + 0, + "completed", datetime.date.today().strftime("%Y-%m"), - status="completed", - ) - idle_s = self.progress.get("accumulated_idle_time", 0.0) - start_str = self.progress.get("tomo_start_time") - elapsed_s = None - if start_str is not None: - try: - elapsed_s = ( - datetime.datetime.now() - datetime.datetime.fromisoformat(start_str) - ).total_seconds() - except (ValueError, TypeError): - elapsed_s = None - timing_lines = [ - "Tomoscan finished.", - f"Measurement ID: {self.tomo_id}", - f"Sample: {self.sample_name}", - ] - hook_description = self._describe_active_hook() - if hook_description: - timing_lines.append(f"At-each-angle hook: {hook_description}") - if elapsed_s is not None: - timing_lines.append(f"Total measurement time: {self._format_duration(elapsed_s)}") - timing_lines.append( - f"Total measurement time excluding detected gaps: {self._format_duration(elapsed_s - idle_s)}" - ) - timing_lines.append( - f"Total measurement time lost to detected gaps: {self._format_duration(idle_s)}" + owner_if_empty=bec.active_account or "", ) + timing_lines = self._build_timing_lines() for line in timing_lines[3:]: print(line) timing_content = "\n".join(timing_lines) @@ -2904,7 +2956,7 @@ class Flomni( ) self._scilog_write(timing_content, "tomoscan") - def tomo_scan_resume(self) -> None: + def tomo_scan_resume(self, from_queue: bool = False) -> None: """Resume a tomo_scan() that crashed or was interrupted, picking up automatically from wherever ``progress`` last reported -- no need to read the last subtomo/angle/projection off the progress printout by @@ -2920,11 +2972,25 @@ class Flomni( queue. tomo_queue_execute() also calls this internally when resuming a job that previously failed partway through, so a failed queue job picks up mid-scan rather than restarting from subtomo 1 / angle 0. + + For tomo_type 4 specifically, this is for recovering from a crash or + an explicit abort (e.g. Ctrl-C) -- it is not needed just to pick up + angle lines appended to the file while a scan is running, since + tomo_scan() already waits for and picks those up on its own. + + If the tomo queue holds an unfinished ("incomplete"/"running") job, + a manual call warns and asks for confirmation, since resuming by + hand bypasses the queue (use tomo_queue_resume() instead). + ``from_queue`` is set by tomo_queue_execute() to skip that prompt. """ if self.progress.get("tomo_start_time") is None: print("No tomo scan in progress to resume -- nothing to do.") return + if not from_queue and not self._confirm_resume_outside_queue(): + print("Aborted.") + return + if self.tomo_type == 1: subtomo_start = self.progress["subtomo"] start_angle = self.progress["angle"] @@ -2933,9 +2999,14 @@ class Flomni( return print(f"Resuming tomo scan at subtomo {subtomo_start}, angle {start_angle:.3f} deg.") self.tomo_scan(subtomo_start=subtomo_start, start_angle=start_angle) - elif self.tomo_type in (2, 3): + elif self.tomo_type in (2, 3, 4): projection_number = self.progress["projection"] print(f"Resuming tomo scan at projection {projection_number}.") + if self.tomo_type == 4: + print( + f"Note: the angle file {self.tomo_angle_file} is re-read now -- its " + f"current content is used from index {projection_number} on." + ) self.tomo_scan(projection_number=projection_number) else: raise FlomniError("undefined tomo type") @@ -2953,6 +3024,72 @@ class Flomni( self._progress_proxy.reset() print("Tomo progress reset.") + def _build_timing_lines(self, header: str = "Tomoscan finished.") -> list: + """Build the timing-report lines (elapsed / idle / excl.-idle + breakdown) from the current ``self.progress`` state. + + Shared by the end-of-scan report in ``tomo_scan()`` and the + on-demand ``tomo_print_timing_report()`` / ``_and_scilog()`` + commands, so a scan interrupted mid-flight can still get this + breakdown -- ``self.progress`` is a BEC global var, not a local, + so it survives a KeyboardInterrupt raised inside ``tomo_scan()``. + """ + idle_s = self.progress.get("accumulated_idle_time", 0.0) + start_str = self.progress.get("tomo_start_time") + elapsed_s = None + if start_str is not None: + try: + elapsed_s = ( + datetime.datetime.now() - datetime.datetime.fromisoformat(start_str) + ).total_seconds() + except (ValueError, TypeError): + elapsed_s = None + timing_lines = [ + header, + f"Measurement ID: {self.tomo_id}", + f"Sample: {self.sample_name}", + ] + hook_description = self._describe_active_hook() + if hook_description: + timing_lines.append(f"At-each-angle hook: {hook_description}") + if elapsed_s is not None: + timing_lines.append(f"Total measurement time: {self._format_duration(elapsed_s)}") + timing_lines.append( + f"Total measurement time excluding detected gaps: {self._format_duration(elapsed_s - idle_s)}" + ) + timing_lines.append( + f"Total measurement time lost to detected gaps: {self._format_duration(idle_s)}" + ) + return timing_lines + + def tomo_print_timing_report(self) -> None: + """Print the current tomo timing breakdown (elapsed / idle / excl. + idle) for whatever scan is or was last in progress. + + Callable at any time, including right after interrupting a scan + with Ctrl-C, since the underlying data (self.progress) survives + the interrupt. Console only -- does not write to scilog. + """ + timing_lines = self._build_timing_lines(header="Tomo timing report (current progress).") + for line in timing_lines: + print(line) + + def tomo_print_timing_report_and_scilog(self) -> None: + """Same as tomo_print_timing_report(), but also writes the report + to scilog -- useful for documenting the timing of a scan that was + interrupted rather than left undocumented. + """ + timing_lines = self._build_timing_lines(header="Tomo timing report (current progress).") + for line in timing_lines: + print(line) + timing_content = "\n".join(timing_lines) + hook_source = self._active_hook_source() + if hook_source: + timing_content += ( + f"\n\nAt-each-angle hook source ('{self.at_each_angle_hook}'):\n{hook_source}" + ) + self._scilog_write(timing_content, "tomoscan") + @staticmethod def _format_duration(seconds: float) -> str: """Format a duration in seconds as a human-readable string, e.g. '2h 03m 15s'.""" @@ -3026,23 +3163,33 @@ class Flomni( # add_sample_database() moved to TomoQueueMixin (OMNY_shared) -- was # byte-identical to LamNI's own copy. - def _at_each_angle(self, angle: float) -> None: + def _at_each_angle( + self, angle: float, *, step: float | None = None, exp_time: float | None = None, + frames_per_trigger: int | None = None, + ) -> None: hook_name = self.at_each_angle_hook if hook_name: + # Fixed 2-arg contract for a user-pluggable hook -- overrides are + # not applicable here (the hook owns the whole acquisition); + # tomo_scan() warns up front when a hook is active and the angle + # file carries overrides. hook = self._resolve_at_each_angle_hook(hook_name, FlomniError) hook(self, angle) return if "flomni_at_each_angle" in builtins.__dict__: # pylint: disable=undefined-variable - flomni_at_each_angle(self, angle) + flomni_at_each_angle(self, angle) # same fixed 2-arg contract return if self.single_point_instead_of_fermat_scan: - self.tomo_acquire_at_angle(angle) + self.tomo_acquire_at_angle(angle, frames_per_trigger=frames_per_trigger, exp_time=exp_time) return - self.tomo_scan_projection(angle, _internal=True) + self.tomo_scan_projection( + angle, _internal=True, step=step, exp_time=exp_time, + frames_per_trigger=frames_per_trigger, + ) def _golden(self, ii, howmany_sorted, maxangle, reverse=False): """returns the iis golden ratio angle of sorted bunches of howmany_sorted and its subtomo number""" @@ -3099,12 +3246,27 @@ class Flomni( random_offset_x: float | None = None, random_offset_y: float | None = None, ): - """write the tomo reconstruct file for the reconstruction queue""" + """Write the tomo reconstruct file for the reconstruction queue. + + Normally called automatically at the end of tomo_scan_projection()/ + tomo_acquire_at_angle(), which keep self._current_scan_list up to + date with the scan number(s) of the projection just acquired + (possibly several, when stitching). When called directly -- e.g. + from the command line after a plain scans.flomni_fermat_scan(), + without going through either of those -- that cached list is either + stale (left over from an earlier tomo scan) or not set at all, so + fall back to just the most recently completed scan number. + """ bec = builtins.__dict__.get("bec") + next_scan_number = bec.queue.next_scan_number + last_scan_number = next_scan_number - 1 + scan_list = getattr(self, "_current_scan_list", None) + if not scan_list or scan_list[-1] != last_scan_number: + scan_list = [last_scan_number] self.reconstructor.folder_name = self.ptycho_reconstruct_foldername self.reconstructor.write( - scan_list=self._current_scan_list, - next_scan_number=bec.queue.next_scan_number, + scan_list=scan_list, + next_scan_number=next_scan_number, base_path=base_path, probe_file_propagation=probe_propagation, random_offset_x=random_offset_x, @@ -3308,12 +3470,14 @@ class Flomni( fovx = rec.get("fovx") fovy = rec.get("fovy") fov_str = f"{fovx} x {fovy} um" if fovx is not None and fovy is not None else "?" + step = rec.get("tomo_shellstep") + step_str = f"{step} um" if step is not None else "?" exposure = rec.get("tomo_countingtime") exp_str = f"{exposure} s" if exposure is not None else "?" duration = rec.get("duration_s") dur_str = self._format_duration(duration) if duration is not None else "?" lines.append( - f"scan {scan_str}: FOV {fov_str}, exposure {exp_str}, duration {dur_str}" + f"scan {scan_str}: FOV {fov_str}, step {step_str}, exposure {exp_str}, duration {dur_str}" ) content = "\n".join(lines) @@ -3323,13 +3487,17 @@ class Flomni( def _write_tomo_scan_number(self, scan_number: int, angle: float, subtomo_number: int) -> None: tomo_scan_numbers_file = os.path.expanduser("~/data/raw/logs/tomography_scannumbers.txt") + sample_name = self.sample_name.replace(" ", "_") with open(tomo_scan_numbers_file, "a+") as out_file: # pylint: disable=undefined-variable out_file.write( - f"{scan_number} {angle} {dev.fsamroy.read()['fsamroy']['value']:.5f} {self.tomo_id} {subtomo_number} {0} {self.sample_name}\n" + f"{scan_number} {angle} {dev.fsamroy.read()['fsamroy']['value']:.5f} {self.tomo_id} {subtomo_number} {0} {sample_name}\n" ) - def tomo_scan_projection(self, angle: float, _internal: bool = False): + def tomo_scan_projection( + self, angle: float, _internal: bool = False, step: float | None = None, + exp_time: float | None = None, frames_per_trigger: int | None = None, + ): """Acquire one fermat-scan projection at `angle`. Note: this ALWAYS runs a fermat scan, regardless of @@ -3342,7 +3510,28 @@ class Flomni( want the fermat path regardless (the fermat branch of _at_each_angle, and tomo_alignment_scan, which needs real ptycho data) pass _internal=True to skip the prompt. + + step/exp_time/frames_per_trigger: optional per-call overrides for + the Fermat step size / exposure time / burst frame count, each + defaulting to self.tomo_shellstep/self.tomo_countingtime/ + self.frames_per_trigger when None (the tomo_type-4 angle-file path + is the only caller that passes these). """ + dev = builtins.__dict__.get("dev") + if not _internal: + all_out, offending = filters_out_of_beam(dev) + if not all_out: + warning = f"Not all filters are out of the beam: {', '.join(offending)}." + if not warn_and_confirm(self, warning, interactive=True, force=False): + # Raise rather than return, for the same reason as the + # fermat-vs-single-point decline below: this can run + # inside a per-angle loop, and a bare return would leave + # the projection silently missing / the job stuck. + raise FlomniError( + "tomo_scan_projection: declined to proceed with filter(s) " + f"in the beam ({', '.join(offending)})." + ) + if not _internal and self.single_point_instead_of_fermat_scan: print( "\x1b[93mWarning: single_point_instead_of_fermat_scan is set, but" @@ -3382,14 +3571,14 @@ class Flomni( # correction_xeye_mu = self.align.lamni_compute_additional_correction_xeye_mu(angle) offsets = self.get_alignment_offset(angle) - sum_offset_x = offsets[0] + sum_offset_x = offsets[0] + self.manual_shift_x sum_offset_y = ( offsets[1] - self.compute_additional_correction_y(angle) - self.compute_additional_correction_y_2(angle) + self.manual_shift_y ) - print(f"Constant shift: y={self.manual_shift_y}") + print(f"Constant shift: x={self.manual_shift_x}, y={self.manual_shift_y}") sum_offset_z = offsets[2] # TODO this fix is while the tracker z is broken probe_propagation = -sum_offset_z * 1e-6 @@ -3397,6 +3586,10 @@ class Flomni( self._current_scan_list = [] + n_step = step if step is not None else self.tomo_shellstep + n_exp_time = exp_time if exp_time is not None else self.tomo_countingtime + n_frames = frames_per_trigger if frames_per_trigger is not None else self.frames_per_trigger + start_scan_number = bec.queue.next_scan_number projection_start = time.perf_counter() for stitch_x in range(-self.stitch_x, self.stitch_x + 1): @@ -3408,9 +3601,11 @@ class Flomni( ceny = sum_offset_y + stitch_y * (self.fovy - self.tomo_stitch_overlap) logger.info( f"scans.flomni_fermat_scan(fovx={self.fovx}, fovy={self.fovy}," - f" step={self.tomo_shellstep}, cenx={cenx}, ceny={ceny}," + f" step={n_step}, fermat_asymmetry={self.fermat_asymmetry}," + f" cenx={cenx}, ceny={ceny}," f" zshift={sum_offset_z}, angle={angle}," - f" exp_time={self.tomo_countingtime}, corridor_size={corridor_size})" + f" exp_time={n_exp_time}, frames_per_trigger={n_frames}," + f" corridor_size={corridor_size})" ) log_message = ( f"{str(datetime.datetime.now())}: flomni scan projection at angle {angle}, scan" @@ -3420,13 +3615,14 @@ class Flomni( scan_kwargs = dict( fovx=self.fovx, fovy=self.fovy, - step=self.tomo_shellstep, + step=n_step, + fermat_asymmetry=self.fermat_asymmetry, cenx=cenx, ceny=ceny, zshift=sum_offset_z, angle=angle, - exp_time=self.tomo_countingtime, - frames_per_trigger=self.frames_per_trigger, + exp_time=n_exp_time, + frames_per_trigger=n_frames, ) if self.corridor_size > 0: @@ -3451,7 +3647,10 @@ class Flomni( self.tomo_reconstruct(probe_propagation=probe_propagation) - def tomo_acquire_at_angle(self, angle: float, frames_per_trigger: int | None = None): + def tomo_acquire_at_angle( + self, angle: float, frames_per_trigger: int | None = None, + exp_time: float | None = None, + ): """ Move fsamroy to `angle`, then move rtx/rty/rtz to the alignment-corrected scan center (same alignment-offset logic as tomo_scan_projection, but @@ -3468,6 +3667,10 @@ class Flomni( angle (float): rotation angle [deg] to move fsamroy to. frames_per_trigger (int, optional): number of burst frames for this acquisition. Defaults to self.frames_per_trigger. + exp_time (float, optional): exposure/counting time per frame for + this acquisition. Defaults to self.tomo_countingtime. (No + `step` parameter: single-point acquisition runs no Fermat + scan, so a step-size override has nothing to apply to.) """ scans = builtins.__dict__.get("scans") @@ -3492,7 +3695,7 @@ class Flomni( # --- alignment offset (same as tomo_scan_projection, no stitching) --- offsets = self.get_alignment_offset(angle) - sum_offset_x = offsets[0] + random_shift_x + sum_offset_x = offsets[0] + self.manual_shift_x + random_shift_x sum_offset_y = ( offsets[1] - self.compute_additional_correction_y(angle) @@ -3500,7 +3703,7 @@ class Flomni( + self.manual_shift_y + random_shift_y ) - print(f"Constant shift: y={self.manual_shift_y}") + print(f"Constant shift: x={self.manual_shift_x}, y={self.manual_shift_y}") sum_offset_z = offsets[2] # TODO this fix is while the tracker z is broken @@ -3525,8 +3728,9 @@ class Flomni( # --- acquire --- n_frames = frames_per_trigger if frames_per_trigger is not None else self.frames_per_trigger + n_exp_time = exp_time if exp_time is not None else self.tomo_countingtime self._current_scan_list = [bec.queue.next_scan_number] - scans.acquire(exp_time=self.tomo_countingtime, frames_per_trigger=n_frames) + scans.acquire(exp_time=n_exp_time, frames_per_trigger=n_frames) self.tomo_reconstruct( probe_propagation=probe_propagation, random_offset_x=random_shift_x, @@ -3574,6 +3778,13 @@ class Flomni( cenx/ceny/zshift are deliberately left at their defaults: they only shift the final position, applied after the FOV keep-or-discard check, so they can never affect the resulting count. + + The real scan draws a fresh random sub-step offset on every call + (see get_flomni_fermat_spiral_pos()'s `seed` argument), which can + shift a point or two across the FOV edge -- so the actual scan-time + count can differ from this estimate by a small amount near the + threshold. A fixed seed is used here only so that this estimate + itself is reproducible from one call to the next. """ from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan @@ -3585,6 +3796,8 @@ class Flomni( step=self.tomo_shellstep, spiral_type=0, center=False, + seed=0, + fermat_asymmetry=self.fermat_asymmetry, ) return len(positions) @@ -3596,12 +3809,14 @@ class Flomni( return FlomniFermatScan.MIN_POSITIONS - def tomo_parameters(self): - """print and update the tomo parameters""" + def tomo_parameters_cli(self): + """print and update the tomo parameters on the command line. + tomo_parameters() opens the GUI version.""" print("Current settings:") - print(f"Counting time = {self.tomo_countingtime} s") + print(f"Counting time per frame = {self.tomo_countingtime} s") print(f"Stepsize microns = {self.tomo_shellstep}") - print(f"FOV (200/100) = {self.fovx}, {self.fovy}") + print(f"Fermat asymmetry (x/y) = {self.fermat_asymmetry}") + print(f"FOV (220/100) = {self.fovx}, {self.fovy}") print(f"Stitching number x,y = {self.stitch_x}, {self.stitch_y}") print(f"Stitching overlap = {self.tomo_stitch_overlap}") if not self.single_point_instead_of_fermat_scan: @@ -3616,6 +3831,7 @@ class Flomni( else: print(f"Estimated Fermat scan points per projection: {expected_positions}") print(f"Reconstruction queue name = {self.ptycho_reconstruct_foldername}") + print(f" _manual_shift_x = {self.manual_shift_x}") print(f" _manual_shift_y = {self.manual_shift_y}") print(f"Frames per trigger (burst) = {self.frames_per_trigger}") print(f"Single point instead of fermat = {self.single_point_instead_of_fermat_scan}") @@ -3653,8 +3869,15 @@ class Flomni( ) if self.tomo_angle_range == 360: print("There are no duplicate angles.") + if self.tomo_angle_range == 180: + print(f"All sub-tomos forward (0->180) = {self.tomo_type1_always_forward}") + zero_deg_where = ( + "every sub-tomo start + end" + if self.tomo_angle_range == 180 and self.tomo_type1_always_forward + else "odd sub-tomo start + end" + ) print( - "0-deg reference shots (odd sub-tomo start + end) =" + f"0-deg reference shots ({zero_deg_where}) =" f" {self.zero_deg_reference_at_each_subtomo}" ) if self.tomo_type == 2: @@ -3682,15 +3905,31 @@ class Flomni( print( "repeating prjections at 0 degrees at the beginning of every second subtomogram" ) + if self.tomo_type == 4: + print("\x1b[1mTomo type 4:\x1b[0m Angles read from file") + self._print_angle_file_summary() print(f"\nSample name: {self.sample_name}\n") if self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"): print("... excellent!") else: - self.tomo_countingtime = self._get_val(" s", self.tomo_countingtime, float) - self.tomo_shellstep = self._get_val(" um", self.tomo_shellstep, float) - self.fovx = self._get_val(" um", self.fovx, float) - self.fovy = self._get_val(" um", self.fovy, float) + from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan + + self.tomo_countingtime = self._get_val( + " s", self.tomo_countingtime, float, min_=0.001, max_=100.0 + ) + self.tomo_shellstep = self._get_val( + " um", self.tomo_shellstep, float, min_=0.025, max_=20.0 + ) + self.fermat_asymmetry = self._get_val( + "", + self.fermat_asymmetry, + float, + min_=FlomniFermatScan.MIN_FERMAT_ASYMMETRY, + max_=FlomniFermatScan.MAX_FERMAT_ASYMMETRY, + ) + self.fovx = self._get_val(" um", self.fovx, float, min_=0.1, max_=220.0) + self.fovy = self._get_val(" um", self.fovy, float, min_=0.1, max_=99.9) if self.single_point_instead_of_fermat_scan: print( "Stitching is disabled while single point instead of fermat scan is" @@ -3699,14 +3938,19 @@ class Flomni( self.stitch_x = 0 self.stitch_y = 0 else: - self.stitch_x = self._get_val("", self.stitch_x, int) - self.stitch_y = self._get_val("", self.stitch_y, int) + self.stitch_x = self._get_val("", self.stitch_x, int, min_=0, max_=10) + self.stitch_y = self._get_val("", self.stitch_y, int, min_=0, max_=10) self.ptycho_reconstruct_foldername = self._get_val( "Reconstruction queue ", self.ptycho_reconstruct_foldername, str ) - self.manual_shift_y = self._get_val(" um", self.manual_shift_y, float) + self.manual_shift_x = self._get_val( + " um", self.manual_shift_x, float, min_=-10.0, max_=10.0 + ) + self.manual_shift_y = self._get_val( + " um", self.manual_shift_y, float, min_=-50.0, max_=50.0 + ) self.frames_per_trigger = self._get_val( - "Frames per trigger (burst)", self.frames_per_trigger, int + "Frames per trigger (burst)", self.frames_per_trigger, int, min_=1, max_=100 ) self.single_point_instead_of_fermat_scan = bool( self._get_val( @@ -3729,13 +3973,16 @@ class Flomni( " um (0 = off)", self.single_point_random_shift_max, float, + min_=0.0, + max_=10.0, ) print("Tomography type:") print(" 1: 8 equally spaced sub-tomograms") print(" 2: Golden ratio tomography") print(" 3: Equally spaced tomography, golden ratio starting angle") - self.tomo_type = self._get_val("Tomography type", self.tomo_type, int) + print(" 4: Angles read from file") + self.tomo_type = self._get_val("Tomography type", self.tomo_type, int, min_=1, max_=4) if self.tomo_type == 1: self.tomo_angle_range = self._get_val( @@ -3750,7 +3997,7 @@ class Flomni( # never reflect a prior adjustment. _, _, current_total = self._tomo_type1_actual_grid() tomo_numberofprojections = self._get_val( - "Total number of projections", current_total, int + "Total number of projections", current_total, int, min_=8, max_=10000 ) # N/step (and therefore total projections) are always # computed against a fixed 180 degrees, independent of @@ -3785,9 +4032,18 @@ class Flomni( ) if self.tomo_angle_range == 360: print("There are no duplicate angles.") + if self.tomo_angle_range == 180: + self.tomo_type1_always_forward = bool( + self._get_val( + "Run all sub-tomos forward (0->180) instead of alternating 1/0?", + int(self.tomo_type1_always_forward), + int, + ) + ) self.zero_deg_reference_at_each_subtomo = bool( self._get_val( - "Take 0-deg reference shots (start of each odd sub-tomo + end) for" + "Take 0-deg reference shots (start of each odd sub-tomo, or every" + " sub-tomo if run forward, + end) for" " damage estimation 1/0?", int(self.zero_deg_reference_at_each_subtomo), int, @@ -3799,11 +4055,15 @@ class Flomni( "Number of projections sorted per bunch (default 20)", self.golden_ratio_bunch_size, int, + min_=1, + max_=100, ) self.golden_max_number_of_projections = self._get_val( "Stop after number of projections (zero for endless)", self.golden_max_number_of_projections, int, + min_=0, + max_=1_000_000, ) self.golden_projections_at_0_deg_for_damage_estimation = self._get_val( "Repeat projections at 0 deg every second subtomo 1/0 ?", @@ -3816,12 +4076,16 @@ class Flomni( "Number of projections per sub-tomogram", int(180 / self.tomo_angle_stepsize), int, + min_=1, + max_=10000, ) self.tomo_angle_stepsize = 180 / numprj self.golden_max_number_of_projections = self._get_val( "Stop after number of projections (zero for endless)", self.golden_max_number_of_projections, int, + min_=0, + max_=1_000_000, ) self.golden_projections_at_0_deg_for_damage_estimation = self._get_val( "Repeat projections at 0 deg every second subtomo", @@ -3829,25 +4093,51 @@ class Flomni( int, ) + if self.tomo_type == 4: + print( + "Angle file: one angle (-2 to 362 deg) per line, optional 2nd column =" + " sub-tomogram number, '#' comments. Read at scan start, and again" + " while the scan runs -- without a final 'END' line the scan waits" + " for more angles to be appended rather than stopping." + ) + self._prompt_angle_file() + @staticmethod - def _get_val(msg: str, default_value, data_type): - return data_type(input(f"{msg} ({default_value}): ") or default_value) + def _get_val(msg: str, default_value, data_type, min_=None, max_=None): + """Prompt for a value, casting to data_type. When min_/max_ are + given, re-prompts (rather than silently clamping) until the typed + value is in range, mirroring the Tomo Parameters GUI's spinbox + bounds (tomo_params.py) so tomo_parameters() enforces the same + limits instead of accepting anything of the right type.""" + while True: + val = data_type(input(f"{msg} ({default_value}): ") or default_value) + if min_ is not None and val < min_: + print(f"Value must be >= {min_}. Please try again.") + continue + if max_ is not None and val > max_: + print(f"Value must be <= {max_}. Please try again.") + continue + return val # Ordered set of all global-var-backed tomo scan parameters: exactly the - # settings shown by tomo_parameters(), plus manual_shift_y/ - # tomo_stitch_overlap/corridor_size/at_each_angle_hook which also affect - # the scan but are only set directly as properties. This is the full - # "parameter set" that tomo_queue_add()/tomo_queue_execute() snapshot - # and restore. + # settings shown by tomo_parameters() (including manual_shift_x/ + # manual_shift_y, which are prompted there too), plus tomo_stitch_overlap/ + # corridor_size/ + # at_each_angle_hook, which also affect the scan but are only ever set + # directly as properties -- tomo_parameters() never prompts for them. + # This is the full "parameter set" that tomo_queue_add()/ + # tomo_queue_execute() snapshot and restore. _TOMO_SCAN_PARAM_NAMES = ( "tomo_countingtime", "tomo_shellstep", + "fermat_asymmetry", "fovx", "fovy", "stitch_x", "stitch_y", "tomo_stitch_overlap", "ptycho_reconstruct_foldername", + "manual_shift_x", "manual_shift_y", "frames_per_trigger", "single_point_instead_of_fermat_scan", @@ -3859,6 +4149,8 @@ class Flomni( "golden_max_number_of_projections", "golden_projections_at_0_deg_for_damage_estimation", "zero_deg_reference_at_each_subtomo", + "tomo_type1_always_forward", + "tomo_angle_file", "corridor_size", "at_each_angle_hook", ) @@ -3866,14 +4158,14 @@ class Flomni( # Curated allow-list for the "move" queue action -- NOT "any device in # dev". Only devices actually reconfigured between tomograms belong here; # this is the difference between a reconfiguration tool and a remote - # control for the whole endstation. mokev/idgap are real front-end - # devices (bl_frontend.yaml, not the flomni sim config); ftray (sample - # transfer tray, harmless to move, untouched by tomo-scan code) is - # included so the move action has a sim-testable target. + # control for the whole endstation. ccm_energy/idgap are real front-end + # devices (bl_frontend.yaml/bl_optics_hutch.yaml, not the flomni sim + # config). foptz is deliberately NOT here -- it has its own dedicated + # "move_foptz_with_shift" action below so the new manual_shift_x/y + # correction can never be forgotten (see that action's docstring). _TOMO_QUEUE_MOVE_DEVICES = { - "mokev": {"label": "Energy", "unit": "keV"}, + "ccm_energy": {"label": "Energy", "unit": "keV"}, "idgap": {"label": "Undulator gap", "unit": "mm"}, - "ftray": {"label": "Sample transfer tray (sim-testable)", "unit": "mm"}, } # Named-action registry for tomo-queue command jobs. Each entry maps a @@ -3895,23 +4187,85 @@ class Flomni( "help": "Move device(s) to absolute position(s).", "params": {"positions": {"type": "device_positions"}}, }, - "optimize_idgap": { - "func": "optimize_idgap", - "idempotent": True, - "help": "Scan idgap over a search range and move to the peak. STUB: no-op.", + "move_foptz_with_shift": { + "func": "_queue_action_move_foptz_with_shift", + "idempotent": True, # absolute move + absolute param set, re-runs harmlessly + "help": ( + "Move foptz (FZP optics-Z) to an absolute target and set new " + "manual_shift_x/manual_shift_y values (replacing the current " + "ones) in the same step -- changing foptz shifts the aligned " + "scan center, so this is the only way to queue a foptz move." + ), "params": { - "search_range": { + "foptz_target": { "type": "float", - "default": 0.5, + "default": 17.0, "min": 0.0, - "max": 2.0, + "max": 27.0, "unit": "mm", - "label": "Search range", - } + "label": "foptz target (absolute)", + # Live ophyd limits (dev.foptz.limits) are checked at add + # time (CommandJobBuilderDialog._build_field_widget / + # TomoQueueMixin._validate_action_kwargs) and again at + # execution time (_queue_action_move_foptz_with_shift) -- + # min/max above are only the static fallback if the live + # device can't be reached. + "device_limits_from": "foptz", + }, + "manual_shift_x": { + "type": "float", + "default": 0.0, + "min": -10.0, + "max": 10.0, + "unit": "um", + "label": "New manual_shift_x", + }, + "manual_shift_y": { + "type": "float", + "default": 0.0, + "min": -50.0, + "max": 50.0, + "unit": "um", + "label": "New manual_shift_y", + }, }, }, } + def _queue_action_move_foptz_with_shift( + self, foptz_target: float, manual_shift_x: float, manual_shift_y: float + ) -> None: + """Move foptz and set new manual_shift_x/y values together. + + manual_shift_x/manual_shift_y are replaced outright by the values + given here, not adjusted relative to whatever they were before. + + foptz changes the FZP-to-sample distance, which shifts the aligned + scan center (interferometer mirrors aren't perfectly parallel to the + beam) -- bundling the new shift into this one action is deliberate: + foptz is NOT in _TOMO_QUEUE_MOVE_DEVICES, so this is the only way to + queue a foptz move, precisely so the shift can't be forgotten. + + FUTURE: manual_shift_x/y could be auto-suggested from a calibration + proportional to the change in foptz, instead of typed in by hand -- + deliberately out of scope here. + + Re-checks foptz's live limits at execution time too (not just add + time, see TomoQueueMixin._validate_action_kwargs) -- the actual + safety boundary, since a value could have arrived via the CLI, + bypassing the add-time check, or the limits could have changed + since the job was queued. + """ + lo, hi = dev.foptz.limits + if not (lo <= foptz_target <= hi): + raise ValueError( + f"Queue move_foptz_with_shift: foptz_target={foptz_target} is " + f"outside foptz's current limits ({lo}, {hi})." + ) + umv(dev.foptz, foptz_target) + self.manual_shift_x = manual_shift_x + self.manual_shift_y = manual_shift_y + def _describe_tomo_job_line(self, job: dict) -> str: """flomni's tomo_queue_show() one-line summary: fov/type/step/ctime/range. @@ -3923,10 +4277,14 @@ class Flomni( p = job["params"] fovx, fovy = p.get("fovx"), p.get("fovy") fov_str = f"{fovx}/{fovy}um" if fovx is not None and fovy is not None else "?" + if p.get("tomo_type") == 4: + angles_str = f"angles={os.path.basename(p.get('tomo_angle_file') or '') or '?'}" + else: + angles_str = f"range={p.get('tomo_angle_range')}deg" return ( f"type={p.get('tomo_type')} fov={fov_str} " f"step={p.get('tomo_shellstep')}um ctime={p.get('tomo_countingtime')}s " - f"range={p.get('tomo_angle_range')}deg" + f"{angles_str}" ) def rt_off(self): @@ -3963,10 +4321,68 @@ class Flomni( stitching = f"{self.stitch_x:.0f}/{self.stitch_y:.0f}" dataset_id = str(self.client.queue.next_dataset_number) account = bec.active_account - # Same grid sub_tomo_scan() actually uses -- see - # _tomo_type1_actual_grid()'s docstring for why this can't just - # recompute int((tomo_angle_range/tomo_angle_stepsize)*8) locally. - _, _, tomo_type1_total_projections = self._tomo_type1_actual_grid() + # The correct total-projections / sub-tomogram-count / angular-step + # values depend on which tomography type is actually configured -- + # mirrors tomo_parameters()'s tomo_type 1/2/3 branching above. This + # branch used to be missing: write_pdf_report() always called + # _tomo_type1_actual_grid() (int(180/tomo_angle_stepsize)*8), which + # is only meaningful for tomo_type==1. For golden-ratio scans + # (tomo_type==2/3) that silently reported a stale, unrelated + # type-1 total left over in the persistent tomo_angle_stepsize + # global var, instead of self.golden_max_number_of_projections -- + # the value the user actually configured and that the web page's + # progress display and tomo_parameters() already use correctly. + if self.tomo_type == 1: + _, achievable_step, total_projections = self._tomo_type1_actual_grid() + subtomo_count_label = "Number of individual sub-tomograms:" + subtomo_count_value = "8" + angle_step_label = "Angular step within sub-tomogram:" + angle_step_value = f"{achievable_step:.2f}" + elif self.tomo_type == 3: + total_projections = ( + self.golden_max_number_of_projections + if self.golden_max_number_of_projections > 0 + else 0 + ) + subtomo_count_label = "Number of projections per sub-tomogram:" + subtomo_count_value = f"{180 / self.tomo_angle_stepsize:.1f}" + angle_step_label = "Angular step within sub-tomogram:" + angle_step_value = f"{self.tomo_angle_stepsize:.2f}" + elif self.tomo_type == 4: + try: + file_angles = read_angle_file(self.tomo_angle_file) + total_projections = len(file_angles.angles) + subtomo_count_value = f"{summarize_angles(file_angles.angles)['n_subtomos']}" + if not file_angles.is_complete: + subtomo_count_value += " (file incomplete, no 'END' marker yet)" + except AngleFileError: + total_projections = 0 + subtomo_count_value = "N/A (angle file not readable)" + subtomo_count_label = "Number of individual sub-tomograms:" + angle_step_label = "Angle file:" + angle_step_value = self.tomo_angle_file or "N/A" + else: + # tomo_type == 2: golden ratio tomography -- there is no fixed + # set of sub-tomograms or a fixed angular step, so report the + # bunch size instead (matches tomo_parameters()'s printout). + total_projections = ( + self.golden_max_number_of_projections + if self.golden_max_number_of_projections > 0 + else 0 + ) + subtomo_count_label = "Sorted in bunches of:" + subtomo_count_value = f"{self.golden_ratio_bunch_size}" + angle_step_label = "Angular step within sub-tomogram:" + angle_step_value = "N/A (golden ratio)" + + total_projections_str = ( + f"{total_projections}" if total_projections else "N/A (manual stop)" + ) + last_scan_number_str = ( + f"{self.client.queue.next_scan_number + total_projections + 10}" + if total_projections + else "N/A" + ) # Same device ffzp_info() already reads. Defensive: may not be # configured/available in every session (e.g. simulated configs). try: @@ -4016,17 +4432,17 @@ class Flomni( f"{'Dataset ID:':<{padding}}{dataset_id}\n", f"{'Sample Info:':<{padding}}Sample Info\n", f"{'e-account:':<{padding}}{account}\n", - f"{'Number of projections:':<{padding}}{tomo_type1_total_projections}\n", + f"{'Number of projections:':<{padding}}{total_projections_str}\n", f"{'First scan number:':<{padding}}{self.client.queue.next_scan_number}\n", - f"{'Last scan number approx.:':<{padding}}" - f"{self.client.queue.next_scan_number + tomo_type1_total_projections + 10}\n", + f"{'Last scan number approx.:':<{padding}}{last_scan_number_str}\n", f"{'Current photon energy:':<{padding}}{energy_str}\n", f"{'Exposure time:':<{padding}}{self.tomo_countingtime:.2f}\n", f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:.2f}\n", + f"{'Fermat asymmetry (x/y):':<{padding}}{self.fermat_asymmetry:.2f}\n", f"{'FOV:':<{padding}}{fovxy}\n", f"{'Stitching:':<{padding}}{stitching}\n", - f"{'Number of individual sub-tomograms:':<{padding}}8\n", - f"{'Angular step within sub-tomogram:':<{padding}}{self.tomo_angle_stepsize:.2f}\n", + f"{subtomo_count_label:<{padding}}{subtomo_count_value}\n", + f"{angle_step_label:<{padding}}{angle_step_value}\n", f"{'FZP diameter:':<{padding}}{fzp_diameter_um} microns\n", f"{'FZP outermost zone width:':<{padding}}{fzp_zone_width_nm} nm\n", f"{'FZP details:':<{padding}}{fzp_details}\n", @@ -4042,6 +4458,7 @@ class Flomni( user_target = os.path.expanduser( f"~/data/raw/documentation/tomo_scan_ID_{self.tomo_id}.pdf" ) + os.makedirs(os.path.dirname(user_target), exist_ok=True) with PDFWriter(user_target) as file: self._add_psi_footer(file) # PDFWriter (bec_lib) has no public image API -- reach into its diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py index 20004b63..f31dd04f 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py @@ -9,6 +9,22 @@ class FlomniOpticsError(Exception): pass +def compute_osa_remaining_space(foptz_val: float, fosaz_val: float) -> float: + """Clearance (mm) between the OSA and a normal OMNY pin for a given + foptz/fosaz pair. <=0 means collision. + + Module-level (not a method) so it can be imported and reused outside a + live Flomni session -- e.g. by the tomo-queue GUI (tomo_params.py), + which only has RPC device access (dev.foptz.readback.get()/ + dev.fosaz.readback.get()), not a Flomni instance to call a bound method + on. FlomniOpticsMixin's own methods below are thin wrappers around this + for the two directions actually used in a live session (varying fosaz + at the current foptz, and vice versa) -- single source of truth for the + formula/constant either way. + """ + return 33 - foptz_val - fosaz_val + + class FlomniOpticsMixin: @staticmethod def _get_user_param_safe(device, var): @@ -21,10 +37,51 @@ class FlomniOpticsMixin: """Clearance (mm) between the OSA and a normal OMNY pin if fosaz were at `fosaz_target`, at the *current* foptz reading. <=0 means collision.""" foptz_val = dev.foptz.readback.get() - return -fosaz_target + (33 - foptz_val) + return compute_osa_remaining_space(foptz_val, fosaz_target) + + def _fheater_is_up(self) -> bool: + axis_id = dev.fheater._config["deviceConfig"].get("axis_Id") + low, _high = dev.fheater.controller.get_motor_limit_switch(axis_id) + return bool(low) + + def _fheater_is_down(self) -> bool: + fheater_in = dev.fheater.user_parameter.get("in") + if fheater_in is None: + return False + return np.isclose(dev.fheater.readback.get(), fheater_in, atol=0.1) def feye_out(self): dev.fsh.fshclose() + + if self.csaxs is not None: + self.csaxs.fil_trans(1, print_only=False) + + # sl1xs_before = self._sl1xs_before_eye + # sl1ys_before = self._sl1ys_before_eye + # if sl1xs_before is not None and sl1ys_before is not None: + # # if the slit was manually re-adjusted while the eye was in, + # # offer to keep the current size instead of restoring the + # # pre-eye value + # current_sl1xs = dev.sl1xs.user_readback.get() + # current_sl1ys = dev.sl1ys.user_readback.get() + # expected_sl1xs = sl1xs_before + 0.3 + # expected_sl1ys = sl1ys_before + 0.3 + # tol = 0.01 # mm (10 microns) + # manually_adjusted = not np.isclose( + # current_sl1xs, expected_sl1xs, atol=tol + # ) or not np.isclose(current_sl1ys, expected_sl1ys, atol=tol) + + # if manually_adjusted and self.OMNYTools.yesno( + # "sl1xs/sl1ys appear to have been adjusted manually while the " + # "eye was in. Keep the current slit size instead of " + # "restoring the pre-eye value?" + # ): + # print("Keeping current slit size, not restoring pre-eye value.") + # else: + # umv(dev.sl1xs, sl1xs_before, dev.sl1ys, sl1ys_before) + # self._sl1xs_before_eye = None + # self._sl1ys_before_eye = None + self.foptics_in() self.flomnigui_show_xeyealign() self.xrayeye_update_frame() @@ -34,6 +91,7 @@ class FlomniOpticsMixin: print("Aborting. With visible parts of the direct beam on the xray eye, it cannot be removed.") return feyex_out = self._get_user_param_safe("feyex", "out") + umv(dev.feyex, feyex_out) # move rotation stage to zero to avoid problems with wires @@ -73,13 +131,22 @@ class FlomniOpticsMixin: current_feyex = dev.feyex.readback.get() current_feyey = dev.feyey.readback.get() - # check if both are close enough (within 0.01) - if np.isclose(current_feyex, feyex_in, atol=0.01) and np.isclose(current_feyey, feyey_in, atol=0.01): - # both already in position → do nothing - pass - else: - # move both axes to the desired "in" positions - umv(dev.feyex, feyex_in, dev.feyey, feyey_in) + # # check if both are close enough (within 0.01) + # if np.isclose(current_feyex, feyex_in, atol=0.01) and np.isclose(current_feyey, feyey_in, atol=0.01): + # # both already in position → do nothing + # pass + # else: + # if "sl1xs" in dev and "sl1ys" in dev: + # self._sl1xs_before_eye = dev.sl1xs.user_readback.get() + # self._sl1ys_before_eye = dev.sl1ys.user_readback.get() + # scans.umv(dev.sl1xs, 0.3, dev.sl1ys, 0.3, relative=True) + + # if self.csaxs is not None: + # self.csaxs.fil_trans(0.05, print_only=False) + + # # move both axes to the desired "in" positions + # umv(dev.feyex, feyex_in, dev.feyey, feyey_in) + umv(dev.feyex, feyex_in, dev.feyey, feyey_in) def _ffzp_in(self): foptx_in = self._get_user_param_safe("foptx", "in") @@ -198,6 +265,13 @@ class FlomniOpticsMixin: ) if need_move_osa: + if not (self._fheater_is_up() or self._fheater_is_down()): + raise FlomniOpticsError( + "Refusing to move OSA to its IN position: fheater is neither " + "at its 'up' (retracted) limit nor at its 'down' (heating) " + "position. An intermediate heater position risks a collision " + "with the OSA. Move the heater fully up or fully down first." + ) remaining = self._osa_remaining_space(fosaz_in) if remaining <= 0: foptz_val = dev.foptz.readback.get() @@ -261,7 +335,13 @@ class FlomniOpticsMixin: def fosa_out(self): - self.ensure_fheater_up() + if not (self._fheater_is_up() or self._fheater_is_down()): + raise FlomniOpticsError( + "Refusing to move OSA to its OUT position: fheater is neither " + "at its 'up' (retracted) limit nor at its 'down' (heating) " + "position. An intermediate heater position risks a collision " + "with the OSA. Move the heater fully up or fully down first." + ) curtain_is_triggered = dev.foptz.controller.fosaz_light_curtain_is_triggered() if not curtain_is_triggered: fosaz_out = self._get_user_param_safe("fosaz", "out") diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 9b66ba4c..105fc6ef 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -43,7 +43,6 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.webpage_generator_base imp _safe_get, _safe_float, ) -from csaxs_bec.devices.omny.sample_desc_codec import unpack_desc class FlomniWebpageGenerator(WebpageGeneratorBase): @@ -54,6 +53,8 @@ class FlomniWebpageGenerator(WebpageGeneratorBase): Logo: flOMNI.png from the same directory as this file. """ + CLIENT_NAME = "flomni" + # Global-var names of the tomo parameters to read live for the # 'Current measurement' section. Kept in the same order as, and matching, # the tomo queue's TQ_PARAM_DISPLAY keys so the two render identically. @@ -73,6 +74,7 @@ class FlomniWebpageGenerator(WebpageGeneratorBase): "single_point_instead_of_fermat_scan", "ptycho_reconstruct_foldername", "at_each_angle_hook", + "tomo_angle_file", ] # label -> dotpath under device_manager.devices @@ -86,7 +88,13 @@ class FlomniWebpageGenerator(WebpageGeneratorBase): # Explicitly set (not just inherited) so flomni's page keeps rendering # its own param names/labels even if WebpageGeneratorBase's default ever # changes for another setup. - TQ_PARAM_DISPLAY = WebpageGeneratorBase.TQ_PARAM_DISPLAY + # tomo_angle_file: tomo_type 4 (angles from file) is flomni-only. The + # page can't read the file itself, so a type-4 job's projection count + # stays blank (not in TOMO_TYPES) -- the live progress card still shows + # the true total, which tomo_scan() sets from the parsed file. + TQ_PARAM_DISPLAY = WebpageGeneratorBase.TQ_PARAM_DISPLAY + [ + ("tomo_angle_file", "Angle file"), + ] def _logo_path(self): return Path(__file__).parent / "flOMNI.png" @@ -97,19 +105,16 @@ class FlomniWebpageGenerator(WebpageGeneratorBase): # ── Sample name ─────────────────────────────────────────────── # slot 0 of flomni_samples is the flOMNI measurement stage itself # (see FlomniSampleStorage.show_all()'s own "flOMNI stage:" label - # for slot 0 -- not an arbitrary tray position). The raw DESC value - # is packed as "name | owner" (sample_desc_codec.pack_desc, added - # 7e8e807) -- unpack it here too, the same way - # FlomniSampleStorage.get_sample_name_and_owner() and show_all() - # already do, so the page doesn't show the raw delimiter-joined - # string as the "sample name". Owner is appended in parentheses, - # mirroring show_all()'s own "(owner: ...)" formatting. - raw_sample = _safe_get(dm, "flomni_samples.sample_names.sample0") - if raw_sample is None: + # for slot 0 -- not an arbitrary tray position). Name and owner are + # separate IOC fields since 2026-10-17 (FLOMNI-SAMPLE:STAGE:NAME / + # :OWNER); owner is appended in parentheses, mirroring show_all()'s + # own "(owner: ...)" formatting. + name = _safe_get(dm, "flomni_samples.sample_names.sample0") + if name is None: sample_name = "N/A" else: - name, owner = unpack_desc(raw_sample) - sample_name = f"{name} (owner: {owner})" if owner else name + owner = _safe_get(dm, "flomni_samples.sample_owners.sample0") + sample_name = f"{name} (owner: {owner})" if owner else str(name) # ── Temperatures ────────────────────────────────────────────── temperatures = { diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py index 83685508..74882c76 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py @@ -48,7 +48,12 @@ class flomniGuiTools: def flomnigui_show_gui(self): if "flomni" in self.gui.windows: self.flomni_window = self.gui.windows["flomni"] - self.gui.flomni.raise_window() + # raise_window() is currently buggy (bec_widgets RPCServer "raise" handling can + # detach/hide the window instead of bringing it to front) - disabled so that + # calling a flomnigui_show_* macro again on an already-open GUI is a safe no-op + # instead of risking hiding the window. See flomnigui_raise() above for the same + # workaround. + # self.gui.flomni.raise_window() else: # geometry: (pos_x, pos_y, w, h) pos_x = self._SCREEN_WIDTH - self._WINDOW_WIDTH @@ -60,7 +65,8 @@ class flomniGuiTools: self.gui.flomni.hide() def flomnigui_raise(self): - self.gui.flomni.raise_window() + pass + #self.gui.flomni.raise_window() def flomnigui_show_xeyealign(self): self.flomnigui_show_gui() @@ -132,20 +138,18 @@ class flomniGuiTools: print("Cannot open camera_overview. Device does not exist.") # Confirm/abort console, docked below the cameras. The hard-stop - # button directly calls dev.ftransy.controller - # .hard_abort_and_restore_positioning_mode() (the same Galil - # hard-stop ftransfer_abort() uses) -- see ConsoleButtonsWidget's - # docstring for why this replaced a blind stop-all-devices - # broadcast. foptx sits on a physically separate Galil - # controller (the optics stage board, not the transfer stage - # board) so it needs its own stop_all_axes() call -- also - # explained in ConsoleButtonsWidget's docstring. + # button sends a stop to each of flomni's three Galil controllers + # (one representative device per controller, in this order, the + # sample-transfer controller first): ftransy (fsamx, fsamy, + # ftransx/y/z, ftray), foptx (optics, eye, heater, tracking) and + # fsamroy (rotation). Afterwards it restores positioning mode on + # the first one (the same Galil hard-stop ftransfer_abort() uses) + # -- see ConsoleButtonsWidget's docstring. self.console = self.gui.flomni.new( "z_ConsoleButtonsWidget", object_name="console", where="bottom", - hard_stop_device_name="ftransy", - extra_hard_stop_device_name="foptx", + hard_stop_device_names=["ftransy", "foptx", "fsamroy"], hard_stop_label="Flomni Motion Stop", ) # set_layout_ratios uses relative weights, not pixels -- there is @@ -261,6 +265,17 @@ class flomniGuiTools: except Exception as e: logger.warning(f"Error in flomnigui_show_progress: {e}") + @staticmethod + def _percent(numerator, denominator) -> float: + """numerator/denominator in percent; 0 when the denominator is not + positive -- e.g. the total of a tomo_type 4 scan whose angle file has + no 'END' marker yet, or an endless golden-ratio scan (0 = unknown). + Without this the division raised, the error was swallowed, and the + GUI stayed frozen on the reset defaults for the whole scan.""" + if not denominator or denominator <= 0: + return 0.0 + return numerator / denominator * 100 + def _flomnigui_update_progress(self): """Update the progress ring bar and center label from the current progress state. @@ -277,11 +292,9 @@ class flomniGuiTools: main_progress_ring = self.progressbar.rings[0] subtomo_progress_ring = self.progressbar.rings[1] - progress = self.progress["projection"] / self.progress["total_projections"] * 100 - subtomo_progress = ( - self.progress["subtomo_projection"] - / self.progress["subtomo_total_projections"] - * 100 + progress = self._percent(self.progress["projection"], self.progress["total_projections"]) + subtomo_progress = self._percent( + self.progress["subtomo_projection"], self.progress["subtomo_total_projections"] ) main_progress_ring.set_value(progress) subtomo_progress_ring.set_value(subtomo_progress) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py index 2a8e23c1..d8263501 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py @@ -41,6 +41,14 @@ class XrayEyeAlign: HEIGHT_CENTERING_SIGN = -1 + # Fit steps k = 1..N_FIT_STEPS are the angles 0, 45, ..., 180 deg (angle = (k - 1) * + # ANGLE_STEP_DEG). The sine fit has 3 free parameters, so at most + # N_FIT_STEPS - MIN_FIT_POINTS of them may be skipped from the GUI (submit == -1), e.g. + # the 90 deg step for a flat sample that is edge-on there. + N_FIT_STEPS = 5 + ANGLE_STEP_DEG = 45 + MIN_FIT_POINTS = 4 + def __init__(self, client, flomni: Flomni) -> None: self.client = client self.flomni = flomni @@ -95,14 +103,50 @@ class XrayEyeAlign: # image_idx refers to alignment_images[image_idx], i.e. the last # frame captured before that submit (shutter is closed at submit time). self.roi_pixel_data = [] + # Fit steps (k) the user skipped from the GUI; they have no alignment_values entry. + self.skipped_steps = [] - def _save_alignment_data(self, file_path: str, fit_data: np.ndarray | None = None): + def _step_angle(self, k: int) -> int: + """Rotation angle in deg of fit step k (1..N_FIT_STEPS).""" + return (k - 1) * self.ANGLE_STEP_DEG + + def _step_prompt(self, k: int) -> str: + """The instruction shown to the user while waiting for the submission of step k.""" + if k == 0: + return "Submit center value of FZP." + if k == 1 and not self._height_centered: + return "Mark the sample height with the selection tool, then submit." + return f"Step {k}/{self.N_FIT_STEPS}: Submit sample center" + + def _skip_refusal_reason(self, k: int) -> str | None: + """Why step k cannot be skipped, or None if a skip is allowed. + + Only real fit points can be skipped: not the FZP center (k == 0) nor the height-marking + submission (the first submission at k == 1), and only while at least MIN_FIT_POINTS + remain for the fit. + """ + if k == 0: + return "the FZP center cannot be skipped" + if k == 1 and not self._height_centered: + return "the sample height marking cannot be skipped" + if len(self.skipped_steps) + 1 > self.N_FIT_STEPS - self.MIN_FIT_POINTS: + return f"at least {self.MIN_FIT_POINTS} positions are needed for the fit" + return None + + def _save_alignment_data( + self, + file_path: str, + fit_data: np.ndarray | None = None, + skipped_angles: list[int] | None = None, + ): expanded = os.path.expanduser(file_path) os.makedirs(os.path.dirname(expanded), exist_ok=True) with h5py.File(expanded, "w") as f: - f.create_dataset( + ds = f.create_dataset( "alignment_values", data=np.array(list(self.alignment_values.values())) ) + # skipped steps have no entry, so say which step each value belongs to + ds.attrs["step_k"] = list(self.alignment_values.keys()) f.create_dataset("alignment_images", data=np.array(self.alignment_images)) if self.roi_pixel_data: ds = f.create_dataset("roi_pixel_data", data=np.array(self.roi_pixel_data)) @@ -110,6 +154,7 @@ class XrayEyeAlign: if fit_data is not None: ds = f.create_dataset("alignment_fit", data=fit_data) ds.attrs["rows"] = ["angles_deg", "offsets_um", "zeros"] + ds.attrs["skipped_angles_deg"] = list(skipped_angles or []) def update_frame(self, keep_shutter_open=False): if self.flomni._flomnigui_check_attribute_not_exists("xeyegui"): @@ -164,6 +209,10 @@ class XrayEyeAlign: try: self._align_impl(keep_shutter_open) finally: + try: + self.gui.enable_skip_button(False) + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Failed to disable XRayEye skip button: {exc}") try: self.gui.set_dap_params_forwarding(False) except Exception as exc: # pylint: disable=broad-except @@ -172,6 +221,10 @@ class XrayEyeAlign: self.gui.hide_crosshair() except Exception as exc: # pylint: disable=broad-except logger.warning(f"Failed to hide XRayEye alignment crosshair: {exc}") + try: + self.gui.unlock_vertical_center() + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Failed to unlock XRayEye ROI vertical center: {exc}") def _align_impl(self, keep_shutter_open=False): if not keep_shutter_open: @@ -182,6 +235,7 @@ class XrayEyeAlign: self.alignment_images = [] self.gui.enable_submit_button(False) + self.gui.enable_skip_button(False) # Initialize xray align device # clear potential pending movement requests @@ -194,6 +248,9 @@ class XrayEyeAlign: # reset shift xy and fov params self._reset_init_values() + # a fresh run must not inherit a stale lock from a previous (possibly + # interrupted) run -- see lock_vertical_center()/unlock_vertical_center() + self.gui.unlock_vertical_center() # Moved here from __init__: this is the actual start of a fresh # alignment run (matching what start_x_ray_eye_alignment() already @@ -203,6 +260,7 @@ class XrayEyeAlign: # there. self.flomni.reset_correction() self.flomni.reset_tomo_alignment_fit() + self.flomni.manual_shift_x = 0 self.flomni.manual_shift_y = 0 self.flomni.lights_off() @@ -240,32 +298,59 @@ class XrayEyeAlign: k = 0 while True: - if dev.omny_xray_gui.submit.get() == 1: + # submit: 0 = idle, 1 = submitted, -1 = skip this step (see the Skip button) + submit_val = dev.omny_xray_gui.submit.get() + if submit_val == -1: + refusal = self._skip_refusal_reason(k) + if refusal is not None: + # refused: the run is untouched, keep waiting at the same step + dev.omny_xray_gui.submit.set(0) + self.gui.enable_skip_button(False) + self.send_message(f"Cannot skip: {refusal}. {self._step_prompt(k)}") + print(f"Skip refused at step {k}: {refusal}.") + submit_val = 0 + if submit_val in (1, -1): + skipped = submit_val == -1 - self.alignment_values[k] = ( - getattr(dev.omny_xray_gui, f"xval_x_{k}").get() * self.pixel_calibration - ) # in mm - print(f"Clicked position {k}: x {self.alignment_values[k]}") - rtx_position = dev.rtx.readback.get() / 1000 - print(f"Current rtx position {rtx_position}") - self.alignment_values[k] -= rtx_position - print(f"Corrected position {k}: x {self.alignment_values[k]}") - # reset submit channel - dev.omny_xray_gui.submit.set(0) + if skipped: + # no xval_x_k / width_*_k were written for this step, so never read them + dev.omny_xray_gui.submit.set(0) + self.skipped_steps.append(k) + _img_idx = len(self.alignment_images) - 1 + self.roi_pixel_data.append([k, np.nan, np.nan, np.nan, np.nan, _img_idx]) + print(f"Skipped position {k} ({self._step_angle(k)} deg)") + if self._step_angle(k) in (0, 180): + print( + "Warning: skipping an end point (0/180 deg) makes the fit noticeably" + " less stable than skipping 45/90/135 deg." + ) + # only allow another skip if the fit can still spare a point + self.gui.enable_skip_button(self._skip_refusal_reason(k) is None) + else: + self.alignment_values[k] = ( + getattr(dev.omny_xray_gui, f"xval_x_{k}").get() * self.pixel_calibration + ) # in mm + print(f"Clicked position {k}: x {self.alignment_values[k]}") + rtx_position = dev.rtx.readback.get() / 1000 + print(f"Current rtx position {rtx_position}") + self.alignment_values[k] -= rtx_position + print(f"Corrected position {k}: x {self.alignment_values[k]}") + # reset submit channel + dev.omny_xray_gui.submit.set(0) - # Raw pixel position and ROI size at submit time. - # The relevant image is the last captured frame (shutter is - # closed by the time the user clicks submit). - _raw_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get() - _raw_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get() - _raw_w = getattr(dev.omny_xray_gui, f"width_x_{k}").get() - _raw_h = getattr(dev.omny_xray_gui, f"width_y_{k}").get() - _img_idx = len(self.alignment_images) - 1 - print( - f" Submit k={k}: px x={_raw_x:.1f} y={_raw_y:.1f} " - f"w={_raw_w:.1f} h={_raw_h:.1f} img={_img_idx}" - ) - self.roi_pixel_data.append([k, _raw_x, _raw_y, _raw_w, _raw_h, _img_idx]) + # Raw pixel position and ROI size at submit time. + # The relevant image is the last captured frame (shutter is + # closed by the time the user clicks submit). + _raw_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get() + _raw_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get() + _raw_w = getattr(dev.omny_xray_gui, f"width_x_{k}").get() + _raw_h = getattr(dev.omny_xray_gui, f"width_y_{k}").get() + _img_idx = len(self.alignment_images) - 1 + print( + f" Submit k={k}: px x={_raw_x:.1f} y={_raw_y:.1f} " + f"w={_raw_w:.1f} h={_raw_h:.1f} img={_img_idx}" + ) + self.roi_pixel_data.append([k, _raw_x, _raw_y, _raw_w, _raw_h, _img_idx]) # Controls whether `k` advances to the next step below. Left # True except for the height-centering submission, which @@ -299,7 +384,7 @@ class XrayEyeAlign: self.gui.show_crosshair() self.send_message( - "Submit height. Use arrows if far off." + "Mark the sample height with the selection tool, then submit. Use arrows if far off." ) self.gui.enable_submit_button(True) self.movement_buttons_enabled(True, True) @@ -332,10 +417,16 @@ class XrayEyeAlign: self.flomni.feedback_enable_with_reset() self._height_centered = True + # From here through step 5, the sample's vertical position is + # physically fixed at the FZP height -- auto-snap every new/ + # dragged/resized selection box's vertical center to the + # crosshair instead of requiring manual placement each time. + self.gui.lock_vertical_center() self.update_frame(keep_shutter_open) self.send_message("Step 1/5: Submit sample center") self.gui.enable_submit_button(True) + self.gui.enable_skip_button(True) self.movement_buttons_enabled(True, False) advance_step = False @@ -351,13 +442,16 @@ class XrayEyeAlign: self.send_message(f"Step {k+1}/5: Submit sample center") self.gui.enable_submit_button(True) self.movement_buttons_enabled(True, False) - self.update_fov(k) + if not skipped: + self.update_fov(k) elif k == 5: # received sample center value at samroy 270 and done self.send_message("done...") self.gui.enable_submit_button(False) self.movement_buttons_enabled(False, False) - self.update_fov(k) + self.gui.enable_skip_button(False) + if not skipped: + self.update_fov(k) self.gui.hide_crosshair() break @@ -422,19 +516,34 @@ class XrayEyeAlign: timestamp = time.strftime("%Y%m%d_%H%M%S") file_h5 = f"~/data/raw/logs/xrayeye_alignmentvalues/xrayeye_alignmentvalues_{timestamp}.h5" - fovx_offsets = np.zeros(5) - for k in range(1, 6): + steps = [k for k in range(1, self.N_FIT_STEPS + 1) if k not in self.skipped_steps] + if len(steps) < self.MIN_FIT_POINTS: + raise RuntimeError( + f"Only {len(steps)} alignment positions available, at least" + f" {self.MIN_FIT_POINTS} are needed for the fit." + ) + + fovx_offsets = np.zeros(len(steps)) + for i, k in enumerate(steps): fovx_offset = self.alignment_values[0] - self.alignment_values[k] - fovx_offsets[k - 1] = fovx_offset + fovx_offsets[i] = fovx_offset print(f"Alignment number {k}, value x {fovx_offset}") + skipped_angles = [self._step_angle(k) for k in self.skipped_steps] + if skipped_angles: + print( + f"Skipped angles {skipped_angles} deg, fitting {len(steps)} positions. With 4" + " points the fit has 1 degree of freedom and cannot flag a bad click - check the" + " plot." + ) + data = np.array( [ - [0, 45, 90, 135, 180], # angles + [self._step_angle(k) for k in steps], # angles fovx_offsets * 1000, # fovx_offset values - [0, 0, 0, 0, 0], + np.zeros(len(steps)), ] ) - self._save_alignment_data(file_h5, fit_data=data) + self._save_alignment_data(file_h5, fit_data=data, skipped_angles=skipped_angles) self.gui.submit_fit_array(data) print(f"fit submited with {data}") \ No newline at end of file diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_data10_to_new_data_structure.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_data10_to_new_data_structure.md new file mode 100644 index 00000000..26ee8171 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_data10_to_new_data_structure.md @@ -0,0 +1,46 @@ +# TODO: OMNY still uses the old `~/Data10` layout, and its PDF report can fail on a missing folder + +LamNI and flomni write their files under the new data structure (`~/data/raw/...`). +OMNY still points at the old `~/Data10/...` tree in several places and needs porting. +Not done yet, to avoid conflicting with other in-progress OMNY changes. + +## 1. Port `~/Data10` paths to the new data structure + +Where OMNY (`csaxs_bec/bec_ipython_client/plugins/omny/`) still uses `~/Data10`: + +| File | Line | Path | LamNI/flomni equivalent | +|---|---|---|---| +| `omny.py` | 1217 | `tomo_reconstruct(base_path="~/Data10/specES1")` | `~/data/raw/logs/reconstruction_queue` | +| `omny.py` | 1236 | `~/Data10/specES1/dat-files/tomography_scannumbers.txt` | `~/data/raw/logs/tomography_scannumbers.txt` | +| `omny.py` | 1484 | `~/Data10/documentation/tomo_scan_ID_.pdf` | `~/data/raw/documentation/tomo_scan_ID_.pdf` | +| `omny.py` | 1489 | `xterm /work/sls/spec/local/XOMNY/bin/upload/upload_last_pon.sh &` | old spec upload script, check it still applies | +| `x_ray_eye_align.py` | 392 | `~/Data10/specES1/internal/xrayeye_alignmentvalues` | `~/data/raw/logs/xrayeye_alignmentvalues/` (timestamped HDF5) | +| `omny_alignment_mixin.py` | 62 | `~/Data10/specES1/internal/` | see the same alignment-values location | + +The reconstruction queue folder in the new layout is created and written by the shared +`PtychoReconstructor.write()` (`OMNY_shared/omny_general_tools.py`), which OMNY does not use +yet. See also `TODO_tomo_reconstruct_stale_scan_list.md` (already ported to flomni and LamNI, +so switching OMNY to the shared reconstructor would cover both items). + +## 2. Create the documentation folder if it does not exist + +`OMNY.write_pdf_report()` (`omny.py:1439`) opens `PDFWriter(user_target)` without creating the +target folder first. `PDFWriter.close()` calls fpdf's `output(path)` +(`bec_lib/pdf_writer.py:90`), which does not create directories, so on an account without the +folder `tomo_scan()` fails with: + +``` +FileNotFoundError: [Errno 2] No such file or directory: '.../documentation/tomo_scan_ID_0.pdf' +``` + +Seen on LamNI; flomni had the identical pattern. Both are fixed by creating the folder before +the writer is opened: + +```python +os.makedirs(os.path.dirname(user_target), exist_ok=True) +with PDFWriter(user_target) as file: +``` + +Apply the same line in `OMNY.write_pdf_report()`, and use the new-structure path from item 1. +Note the later steps in that method (the `upload_last_pon.sh` call and the logbook message) +only run if the PDF write succeeds. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_document_stitching.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_document_stitching.md new file mode 100644 index 00000000..2ab11066 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_document_stitching.md @@ -0,0 +1,31 @@ +# TODO: document stitch_x/stitch_y in the omny user manual + +Status: **not implemented.** `docs/user/ptychography/omny.md` has zero +mentions of stitching, same gap just fixed 2026-09-19 in `flomni.md` and +`lamni.md`. + +## What to add + +Omny's mechanism is essentially identical to Flomni's (same parameter +names, even) — `omny.py:1263-1269`: + +```python +for stitch_x in range(-self.stitch_x, self.stitch_x + 1): + for stitch_y in range(-self.stitch_y, self.stitch_y + 1): + ... + cenx = sum_offset_x + stitch_x * (self.fovx - self.tomo_stitch_overlap) + ceny = sum_offset_y + stitch_y * (self.fovy - self.tomo_stitch_overlap) +``` + +Same paragraph shape as the ones just added to `flomni.md`/`lamni.md` should +carry over directly, substituting nothing (param names are identical): +`stitch_x`/`stitch_y` (default 0), tiled `(2*stitch_x+1) × (2*stitch_y+1)` +grid of scans per projection angle, spaced by `fovx`/`fovy` minus +`tomo_stitch_overlap` (µm, default 0.2), effective FOV roughly +`(2*stitch_x+1) × fovx` by `(2*stitch_y+1) × fovy`. + +Not yet checked: omny.md's actual section structure/where the equivalent of +flomni's "### Tomography" / lamni's "### Test projections and Laminography +scan" section lives, and whether omny has anything resembling +`single_point_instead_of_fermat_scan`'s stitch-forced-to-0 interaction +(flomni does; not confirmed for omny). diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md new file mode 100644 index 00000000..140f989b --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md @@ -0,0 +1,103 @@ +# TODO: port `fermat_asymmetry` (x/y spacing ratio) to OMNY now that it's landed on Flomni + +**Status: `fermat_asymmetry` is now implemented on Flomni** (branch +`fixes/flomni_beamtime_2`), not yet ported to OMNY. See +`docs/plans/flomni-fermat-asymmetry.md` for the original design (the +implementation follows it as written) and `csaxs_bec/scans/flomni_fermat_scan.py` +for the actual landed code (`get_flomni_fermat_spiral_pos`, line ~420 at +time of writing; `MIN_FERMAT_ASYMMETRY`/`MAX_FERMAT_ASYMMETRY` class +constants, line ~70). + +`fermat_asymmetry` is a ratio of x spacing to y spacing; default `1.0` = +symmetric, keeps x spacing fixed at `step` and scales y spacing by the ratio +while preserving the requested FOV. Soft-clamped to `[0.1, 10]` (with a +`logger.warning` when clamped), same style as `zshift`/`corridor_size`. + +**Effective spacing (verified against generated data, see +`docs/plans/flomni-fermat-asymmetry.md`'s conversation for the measurement +method):** x spacing always stays at `step`; y spacing becomes `step / +fermat_asymmetry`. E.g. with `step=1 um`, `fovx=fovy=10 um`: `fermat_asymmetry=0.7` +-> x spacing 1.0 um, y spacing ~1.43 um (sparser), ~70 points; +`fermat_asymmetry=1.3` -> x spacing 1.0 um, y spacing ~0.77 um (denser), +~130 points. Point count scales with the ratio since the FOV is fixed but +the density isn't. Same relationship should be documented for OMNY once +ported (Flomni's user doc has this table -- see +`docs/user/ptychography/flomni.md`, "Effective spacing with +`fermat_asymmetry`" -- OMNY's equivalent doc, if any, should get the same). + +## Important: compose with the already-ported sub-step randomization + +Before this, `fixes/flomni_beamtime_2` already ported a *different* fix to +all three setups including OMNY: a per-call random sub-step offset (see +`docs/plans/fermat-scan-randomization.md`). `OmnyFermatScan.get_omny_fermat_spiral_pos` +(`csaxs_bec/scans/omny_fermat_scan.py`) already has a `seed: int | None = None` +parameter and draws `offset = np.random.default_rng(seed).uniform(0, step)`, +subtracted from both `x` and `y` before the FOV crop check -- this landed +*before* fermat_asymmetry and is unrelated to it, but the two must compose +correctly when porting fermat_asymmetry to OMNY. + +Flomni's landed implementation composes them as: `x = radius*sin(...) - +offset` (unchanged); `y_raw = radius*cos(...) - offset` (offset subtracted +at the *raw*, pre-compression scale, same as x); check `abs(y_raw) > +(length_axis2 * fermat_asymmetry) / 2` (inflated window); on accept, `y = +y_raw / fermat_asymmetry` (compression happens *after* the offset and the +check, not before). This keeps the offset's phase-shift proportional to the +local spacing on both axes (x spacing = step, y spacing = step/fermat_asymmetry, +offset/fermat_asymmetry is the resulting y-phase-shift once compressed) -- +port this exact ordering (offset subtraction -> inflated check -> compression), +not just the offset or the asymmetry in isolation. + +## What needs to happen + +OMNY's `OmnyFermatScan.get_omny_fermat_spiral_pos` +(`csaxs_bec/scans/omny_fermat_scan.py`) uses the byte-for-byte same spiral +algorithm as Flomni's `get_flomni_fermat_spiral_pos` (`radius = step * 0.57 +* sqrt(ii)`, same FOV-window filter, same offset-subtraction-before-check +shape already ported), so the same change ports directly: + +1. Add `MIN_FERMAT_ASYMMETRY = 0.1` / `MAX_FERMAT_ASYMMETRY = 10.0` class + constants to `OmnyFermatScan`, mirroring Flomni's. +2. Add `fermat_asymmetry` to `OmnyFermatScan.__init__` + (`omny_fermat_scan.py:63-79`) as a new `ScanArgument`, store it, pass it + through in `prepare_scan()`'s call to `get_omny_fermat_spiral_pos`. +3. Add the clamp + inflated-window-check + y-compression logic to + `get_omny_fermat_spiral_pos` itself (same reasoning as Flomni: it's also + called directly by preview/estimate code, so clamping there keeps every + caller in sync automatically) -- apply it on top of the existing offset + logic exactly as described above, not before it. +4. Mirror the CLI-side plumbing on `OMNY` + (`csaxs_bec/bec_ipython_client/plugins/omny/omny.py`): a + `tomo_shellstep`-style property pair (OMNY hand-writes + `get_global_var`/`set_global_var` property pairs, e.g. `omny.py:671-679`, + rather than using the `_GlobalVarParam` descriptor Flomni uses), the + `scan_kwargs` dict in the per-angle scan call (~`omny.py:1282-1284`), and + `tomo_parameters()` (~`omny.py:1293-1341`). + +## Difference from Flomni to account for when porting + +OMNY has no tomo-queue system -- `OMNY` does not mix in `TomoQueueMixin` and +has no `_TOMO_SCAN_PARAM_NAMES` (unlike `Flomni`/`LamNI`). So there is no +queue-snapshot list to add the new parameter to on OMNY's side, and no +`tomo_params` Qt widget profile for OMNY today either (`SETUP_PROFILES` in +`tomo_params.py` only has `"flomni"` and `"lamni"` entries) -- the widget +part of the Flomni implementation (the "Fermat asymmetry (x/y)" field with +its "?" help button in `_build_fermat_asymmetry_row`/ +`_show_fermat_asymmetry_help`) doesn't apply until/unless OMNY gets its own +queue + widget support. Scope the OMNY port to: the scan class parameter, +the global var, and `tomo_parameters()` display/prompt only. + +## Tests + +Mirror the three new cases added to `tests/tests_scans/test_flomni_fermat_scan.py` +(`test_fermat_asymmetry_default_matches_symmetric_spiral`, +`test_fermat_asymmetry_biases_spacing_while_preserving_fov`, +`test_fermat_asymmetry_out_of_range_is_clamped_not_raised`) in +`tests/tests_scans/test_omny_fermat_scan.py` -- same technique (compare +against an equivalent call with an inflated FOV and `fermat_asymmetry=1.0`, +same `seed`, to verify the inflate/compress math directly rather than via +an approximate spacing-ratio heuristic). + +## Explicitly not for LamNI + +This feature was scoped to Flomni (and, via this TODO, OMNY) only; do not +add it to `LamniFermatScan` / `LamNI` as part of this port. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_randomize_offset_port.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_randomize_offset_port.md new file mode 100644 index 00000000..bfc80985 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_randomize_offset_port.md @@ -0,0 +1,47 @@ +# TODO: port `randomize_offset` (switch off the sub-step randomization) to OMNY + +**Status: `randomize_offset` is implemented on Flomni only (branch `fixes/flomni_beamtime_2`, +2026-09-20), not yet ported to OMNY.** + +## What landed on Flomni + +The per-call random sub-step offset of the Fermat spiral (see `docs/plans/fermat-scan-randomization.md`) +had no way to be turned off from the scan: `seed` exists only as an argument of the static position +function and is not a scan argument. A fixed seed would only make the offset reproducible anyway -- +the offset is always `uniform(0, step)`, so no seed gives the unshifted lattice. + +Flomni now has an optional scan argument `randomize_offset: bool = True` +(`csaxs_bec/scans/flomni_fermat_scan.py`): + +- `FlomniFermatScan.__init__` takes it as a `ScanArgument` (default `True`, so behaviour is + unchanged), stores `self.randomize_offset` and passes it in `prepare_scan()`. +- `get_flomni_fermat_spiral_pos(..., randomize_offset=True)` computes + `offset = np.random.default_rng(seed).uniform(0, step) if randomize_offset else 0.0`. + With `False` the offset is 0 and `seed` is irrelevant. +- Tests: `test_randomize_offset_default_is_active` and + `test_randomize_offset_false_gives_unshifted_deterministic_lattice` in + `tests/tests_scans/test_flomni_fermat_scan.py`. +- User doc: `docs/user/ptychography/flomni.md` (parameter table + paragraph on the randomization). + +## What needs to happen on OMNY + +`OmnyFermatScan.get_omny_fermat_spiral_pos` (`csaxs_bec/scans/omny_fermat_scan.py`, offset drawn at +~line 319) has the same `seed` argument and the same `offset = ...uniform(0, step)` line, so the +change ports directly: + +1. Add `randomize_offset: Annotated[bool, ScanArgument(...)] = True` to `OmnyFermatScan.__init__`, + with docstring entry, store it and pass it through in `prepare_scan()`. +2. Add `randomize_offset: bool = True` to `get_omny_fermat_spiral_pos` and use + `0.0` instead of the random draw when it is `False`. +3. Mirror the two Flomni tests in `tests/tests_scans/test_omny_fermat_scan.py`. +4. Add the parameter to the parameter table in `docs/user/ptychography/omny.md` (~line 322-339; + the omny table has no `fermat_asymmetry` row yet either, see + `TODO_fermat_asymmetry_port.md`). + +Scope notes: + +- The OMNY tomo loop (`omny.py`, `scan_kwargs` in the per-angle scan call) does not need to pass it; + as on Flomni, tomo scans keep the default (randomized). +- Compose with `fermat_asymmetry` as described in `TODO_fermat_asymmetry_port.md`: `randomize_offset=False` + only sets `offset = 0.0`; the offset-subtraction -> inflated check -> compression ordering stays as is. +- LamNI has the identical offset code (`lamni_fermat_scan.py:323`) but was not asked for this switch. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_filter_and_eye_checks.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_filter_and_eye_checks.md new file mode 100644 index 00000000..451bd93b --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_filter_and_eye_checks.md @@ -0,0 +1,46 @@ +# TODO: OMNY is missing two pre-scan safety checks that flomni/LamNI now have + +Both items below were found while implementing a filter-out-of-beam check for +flomni and LamNI (branch `flomni_fixes_during_beamtimes`). OMNY was deliberately +left out of that work to avoid conflicting with other in-progress OMNY changes on +a separate branch — do both of these there. + +## 1. Filter-out-of-beam check (new feature, not yet on OMNY at all) + +flomni (`flomni.py`) and LamNI (`lamni.py`) now warn hard if any of the four cSAXS +exposure-box filters (`filter_array_1_x` … `filter_array_4_x`) are not at their "out" +position before a scan starts. The check itself is already shared/reusable: + +- `csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py` + - `filters_out_of_beam(dev, tol=0.1) -> (bool, list[str])` + - `warn_and_confirm(setup, warning, interactive=True, force=False) -> bool` — red + warning + "Continue anyway?" prompt (default no) when interactive; warning + 10s + pause + always-proceed when not (for unattended/queued runs). + +To wire it into OMNY, mirror what was done in `flomni.py`/`lamni.py`: + +- `OMNY.tomo_scan()` (`omny.py:1005`): add a check at the top, right where + `Flomni.tomo_scan()`/`LamNI.tomo_scan()` now have theirs — note OMNY's + `tomo_scan()` currently has **no `interactive`/`force` params at all** (OMNY has no + tomo-queue, see item 2 below, so there's no unattended-run case to support yet); + just call `warn_and_confirm(self, warning, interactive=True, force=False)`. +- `OMNY.tomo_scan_projection()` (`omny.py:1244`): currently `def + tomo_scan_projection(self, angle: float)` — no `_internal` flag. Add + `_internal: bool = False`, gate the filter check on `not _internal`, and raise + `OMNYError` on decline (same rationale as flomni/LamNI: a bare `return` inside a + per-angle loop can silently drop a projection). Update its internal call sites to + pass `_internal=True`: `_at_each_angle()` (`omny.py:1166`) and the loop call at + `omny.py:893`. + +## 2. Missing eye-out/optics-in check (pre-existing gap, unrelated to filters) + +Discovered independently while researching the filter check: `Flomni.tomo_scan()` +and `LamNI.tomo_scan()` both hard-warn if `_check_eye_out_and_optics_in()` fails +(X-ray eye IN / optics OUT is not a valid measurement configuration). **`OMNY.tomo_scan()` +(`omny.py:1005`) has no equivalent check at all** — there's no OMNY-side +`_check_eye_out_and_optics_in()`, and nothing calls one. Worth adding the same gate, +following `flomni_optics_mixin.py`/`lamni_optics_mixin.py`'s +`_check_eye_out_and_optics_in()` implementations as the template (device names will +differ for OMNY's axes). + +Not scoped/designed further here — just flagging it so it isn't lost. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_scilog_last_ptycho_scans.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_scilog_last_ptycho_scans.md new file mode 100644 index 00000000..abf0ad76 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_scilog_last_ptycho_scans.md @@ -0,0 +1,44 @@ +# TODO: OMNY is missing the scilog_last_ptycho_scans command (and its timing-log infrastructure) that flomni/LamNI have + +Found while adding a step-size field to flomni's and LamNI's +`scilog_last_ptycho_scans` per-scan summary line (branch +`fixes/flomni_beamtime_2`). OMNY has no equivalent at all, not even the +older/pre-step-size version — porting it is a bigger job than the two-line +fixes just made to flomni/lamni, so it's flagged here instead of attempted +opportunistically. + +## 1. scilog_last_ptycho_scans + timing-log infrastructure (new feature, not yet on OMNY at all) + +flomni (`flomni.py`) and LamNI (`lamni.py`) both have a full +timing-instrumented pipeline behind `scilog_last_ptycho_scans(number_of_scans=1)`: + +- `_TIMING_LOG_DIR`/`_PROJECTION_TIMING_LOG` — an append-only JSONL log of + per-projection timing records, one written per completed projection via + `_log_projection_timing(...)` (flomni.py:3180, lamni.py:966), called from + each setup's per-angle tomo-scan loop (flomni.py:2578/3502, lamni.py:1611). +- `_read_last_timing_records(number_of_scans)` (flomni.py:3265, lamni.py:1051) + — reads back the last N records from that log. +- `scilog_last_ptycho_scans(number_of_scans=1)` (flomni.py:3291, lamni.py:1077) + — prompts for a free-text comment, builds one summary line per record (scan + number(s), FOV, step size, exposure, duration), and sends it via each + setup's tolerant scilog-write helper (`Flomni._scilog_write`, flomni.py:2993; + `LamNI.write_to_scilog`, lamni.py:928). + +OMNY (`omny.py`) only has the much older `write_to_scilog(content, tags=None)` +(omny.py:855) and `_write_subtomo_to_scilog(subtomo_number)` (omny.py:916) — a +per-subtomo one-liner, not a "last N scans" summary command — and no timing +log, no duration measurement, and no `scilog_last_ptycho_scans` at all. +`OMNY.tomo_scan()`/`_tomo_scan_at_angle()` (omny.py:1005/966) would be the +natural hook point for a `_log_projection_timing`-style call, mirroring where +flomni/lamni call theirs. + +Porting this means: adding the timing-log write call to OMNY's per-angle +loop, then copying `_read_last_timing_records`/`scilog_last_ptycho_scans` +across (with OMNY-specific field names in place of `fovx`/`fovy` or +`lamni_piezo_range_x`/`y` — whatever OMNY's equivalent scan-size parameter is +called). Remember to include the `step {value} um` field in the per-scan +line from the start (already added to flomni's and lamni's versions, keyed +off `tomo_shellstep`) rather than needing a second follow-up fix like this +one. + +Not scoped/designed further here — just flagging it so it isn't lost. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tighten_tomo_parameter_ranges.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tighten_tomo_parameter_ranges.md new file mode 100644 index 00000000..1abf4331 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tighten_tomo_parameter_ranges.md @@ -0,0 +1,62 @@ +# TODO: tighten tomo-parameter ranges + enforce them in the CLI wizard + +Status: **not implemented for omny.** Discussed and implemented for Flomni +and LamNI on 2026-09-19 — this note exists so the same pass isn't forgotten +for omny. + +## What was done for Flomni/LamNI + +Two separate things, both worth doing here too: + +1. **Tightened several parameter ranges** that had grown far looser than + practically useful (GUI spinbox ranges in `tomo_params.py`, plus the + matching property/CLI bounds where they existed): + - `tomo_shellstep`: 0.001–1000.0 → **0.025–20.0 µm** + - `stitch_x`/`stitch_y`: 0–50 → **0–10** (flomni only — lamni's own + `lamni_stitch_x`/`y` were left at 0–50) + - `golden_ratio_bunch_size`: 1–10000 → **1–100** + - `tomo_stitch_overlap`: 0.0–50.0 → **0.0–10.0 µm** + - `manual_shift_y` (flomni): -1000–1000 → **-50–50 µm** + - lamni-specific: `tomo_circfov` 0.0–200.0 → **0.0–500.0 µm** (floor kept + at 0.0 — `fov_circular == 0` means "disabled", checked explicitly in + `lamni_fermat_scan.py:484`, so don't raise the floor above 0); + `lamni_piezo_range_x`/`y` 0.0–200.0 → **0.0–80.0 µm**, matching the + "(max 80)" already printed in lamni's CLI prompt text. + +2. **`_get_val()` (the `tomo_parameters()` CLI wizard's input helper) now + supports `min_`/`max_`** and re-prompts until the value is in range, + instead of accepting anything of the right type with zero validation. + Both Flomni's and LamNI's `_get_val()` got this treatment (they're + separate static methods, not shared code) and every prompted field that + has a GUI-defined range got the matching `min_`/`max_` wired in. + +## What's needed for omny + +Omny has its own `tomo_parameters()` and `_get_val()` +(`csaxs_bec/bec_ipython_client/plugins/omny/omny.py:1293` / +`omny.py:1422`) — same bare/unvalidated pattern Flomni and LamNI both had +before this pass. Confirmed (2026-09-19) that omny's parameter names mirror +**flomni's** naming exactly, not lamni's: `fovx`/`fovy`, `manual_shift_y`, +`tomo_type`, `stitch_x`/`stitch_y`, `tomo_stitch_overlap`, +`golden_ratio_bunch_size`, `tomo_shellstep`, `tomo_countingtime` all exist +under those same names in `omny.py`. So the flomni numbers above likely +carry over directly, but this hasn't been checked against omny's own +physical setup/hardware constraints (e.g. its FOV/shellstep/piezo range may +have different practical limits than flomni's cryo ptycho setup) — don't +just copy the numbers blind. + +Steps, mirroring what was done for flomni/lamni: +1. Confirmed (2026-09-19): `tomo_params.py`'s `SETUP_PROFILES` dict has only + `"flomni"` and `"lamni"` entries — **no `"omny"` profile exists**, so + omny doesn't use this shared Tomo Parameters GUI widget at all right now. + Whatever GUI (if any) omny actually uses for entering these parameters + needs to be located first; there's nothing to tighten in `tomo_params.py` + for omny today. (Separately, `omny_webpage_generator.py` was confirmed in + an earlier session to be dead/unwired code — not related to this GUI + question, just don't confuse the two when investigating.) +2. Add `min_`/`max_` support to omny's own `_get_val()` (same mechanism, + copy-pasted independently — these are three separate static methods, not + shared code, by design in this codebase). +3. Confirm with whoever runs omny scans what the actual practical ranges + should be for omny's hardware, rather than assuming flomni's numbers + apply unchanged. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_alignment_fit_always_defined.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_alignment_fit_always_defined.md new file mode 100644 index 00000000..bd554c1d --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_alignment_fit_always_defined.md @@ -0,0 +1,58 @@ +# TODO: port "always define `tomo_alignment_fit`" to OMNY now that it's landed on Flomni + +**Status: implemented on Flomni** (commit `5b688df`, branch +`fixes/flomni_beamtime_2`), not yet ported to OMNY. + +## Problem (as seen on Flomni) + +The `tomo_alignment_fit` global var was deleted by `reset_tomo_alignment_fit()` +(on stage-in and at the start of a new alignment) and never created at +startup. Manual offset tweaks from the client failed with an undefined +variable: + +```python +fit = flomni.client.get_global_var("tomo_alignment_fit") # -> None +fit[0][2] += 5.0 # x offset, microns -> TypeError +``` + +## What Flomni does now (`flomni/flomni.py`) + +- `reset_tomo_alignment_fit()` writes `np.zeros((2, 5)).tolist()` instead of + calling `delete_global_var`, so the var is always defined. +- New `ensure_tomo_alignment_fit()` sets the zero fit **only if** + `get_global_var("tomo_alignment_fit") is None`. It is called from + `Flomni.__init__`, so an existing fit survives a client restart. +- `get_alignment_offset()` is unchanged; with zeros it prints zero offsets + instead of the "Not applying any alignment offsets" message. Same result. + +Fit layout (row 0 = x, row 1 = y): + +``` +# x amp, phase, offset, y amp, phase, offset, 3rd order amp, 3rd order phase +# 0 0 0 1 0 2 1 0 1 1 1 2 1 3 1 4 +``` + +## What to do on OMNY + +`omny/omny_alignment_mixin.py` still has the old behaviour: + +- `reset_tomo_alignment_fit()` (~line 57) does + `self.client.delete_global_var("tomo_alignment_fit")`. +- Nothing defines the var at startup. `OMNY.__init__` is in `omny/omny.py` + (~line 557). + +Steps: + +1. Change `reset_tomo_alignment_fit()` to write `np.zeros((2, 5)).tolist()`. +2. Add `ensure_tomo_alignment_fit()` exactly as on Flomni. +3. Call it from `OMNY.__init__` (after `self.client` is set). +4. Check the callers: at the time of writing, nothing in `omny/` calls + `reset_tomo_alignment_fit()` apart from its definition, so decide where + OMNY should reset the fit (start of an alignment / sample transfer), + mirroring Flomni's `ftransfer_flomni_stage_in()` and + `XrayEyeAlign._align_impl()`. +5. `read_alignment_offset()` in the OMNY mixin builds the fit as `np.zeros((2, 5))` + and stores it with `set_global_var`, so it needs no change. + +LamNI does not reference `tomo_alignment_fit` at all (checked by grep), so no +action there. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_reconstruct_stale_scan_list.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_reconstruct_stale_scan_list.md new file mode 100644 index 00000000..a6722d81 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_reconstruct_stale_scan_list.md @@ -0,0 +1,68 @@ +# TODO: OMNY.tomo_reconstruct() writes a stale/missing scan list + +Found while fixing the same bug in `Flomni.tomo_reconstruct()` +(`csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py:3114`, branch +`flomni_fixes_during_beamtimes`). Not applied to OMNY yet to avoid conflicting +with other in-progress OMNY changes on a separate branch — do it there. + +## The bug + +`OMNY.tomo_reconstruct()` (`csaxs_bec/bec_ipython_client/plugins/omny/omny.py:1217`) +names the reconstruction queue file from a fresh `bec.queue.next_scan_number` +(so the *filename* is always correct), but writes the file's *content* from +`self._current_scan_list`: + +```python +def tomo_reconstruct(self, base_path="~/Data10/specES1"): + """write the tomo reconstruct file for the reconstruction queue""" + bec = builtins.__dict__.get("bec") + base_path = os.path.expanduser(base_path) + ptycho_queue_path = Path(os.path.join(base_path, self.ptycho_reconstruct_foldername)) + ptycho_queue_path.mkdir(parents=True, exist_ok=True) + + last_scan_number = bec.queue.next_scan_number - 1 + ptycho_queue_file = os.path.abspath( + os.path.join(ptycho_queue_path, f"scan_{last_scan_number:05d}.dat") + ) + with open(ptycho_queue_file, "w") as queue_file: + scans = " ".join([str(scan) for scan in self._current_scan_list]) + queue_file.write(f"p.scan_number {scans}\n") + queue_file.write("p.check_nextscan_started 1\n") +``` + +`self._current_scan_list` is only kept up to date by whichever internal method +last ran and then called `tomo_reconstruct()` itself right after (the OMNY +equivalents of flomni's `tomo_scan_projection()`/`tomo_acquire_at_angle()` — +check where `_current_scan_list` is assigned in `omny.py` for the exact call +sites). Call `tomo_reconstruct()` directly from the command line instead — +e.g. after a plain fermat scan run by hand, not through those internal +flows — and the file gets written with whatever scan list happened to be +cached from the *previous* tomo scan (or raises `AttributeError` if none ran +yet this session). Filename right, content wrong/stale. + +## The fix (already applied to flomni, mirror it here) + +`Flomni.tomo_reconstruct()` now falls back to just the most recently +completed scan number whenever the cached list doesn't actually match it: + +```python +bec = builtins.__dict__.get("bec") +next_scan_number = bec.queue.next_scan_number +last_scan_number = next_scan_number - 1 +scan_list = getattr(self, "_current_scan_list", None) +if not scan_list or scan_list[-1] != last_scan_number: + scan_list = [last_scan_number] +``` + +Internal callers are unaffected (their cached list's last entry always equals +`next_scan_number - 1` at the point they call `tomo_reconstruct()`), while a +direct/standalone call now correctly falls back to `[last_scan_number]` +instead of writing stale content or crashing. + +Port the same `getattr(...)`/fallback logic into `OMNY.tomo_reconstruct()` +(note OMNY's version doesn't go through the shared `PtychoReconstructor` +class like flomni's does — it writes the file inline — so the fix applies +directly to the `scans = " ".join(...)` line, not to a shared `write()` +method). + +Not scoped/designed further here — just flagging it so it isn't lost. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_scan_resume_queue_guard.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_scan_resume_queue_guard.md new file mode 100644 index 00000000..ea20d79d --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_scan_resume_queue_guard.md @@ -0,0 +1,25 @@ +# TODO: port the "unfinished queue job" guard on `tomo_scan_resume()` to OMNY + +**Status: implemented on Flomni and LamNI (2026-10), not applicable to OMNY yet** -- +OMNY has no `TomoQueueMixin`, no tomo queue and no `tomo_scan_resume()`. + +## What landed on Flomni/LamNI +- Problem: a Ctrl-C abort of `tomo_queue_execute()` leaves the job "running" (only + `Exception` is caught -> "incomplete"). Calling `tomo_scan_resume()` by hand then finishes + the scan but the queue still has the job unfinished, so a later `tomo_queue_resume()` + resumes it again (at the last projection) and the jobs after it never ran. +- `TomoQueueMixin._confirm_resume_outside_queue()` + (`OMNY_shared/tomo_queue_mixin.py`): if the queue has an "incomplete"/"running" job, print a + warning + hint (use `tomo_queue_resume()`, or mark the job done via + `_tomo_queue_proxy.update_by_id(id, status="done")`) and ask yes/no (default no). +- `tomo_scan_resume(from_queue=False)` in `flomni/flomni.py` and `LamNI/lamni.py` calls it + after the "nothing to resume" check; `tomo_queue_execute()` passes `from_queue=True` so the + unattended queue never prompts. + +## To do for OMNY +When the tomo queue (`TomoQueueMixin`) and a `tomo_scan_resume()` are ported to OMNY, give +`tomo_scan_resume()` the same `from_queue` parameter and guard call, and make sure +`tomo_queue_execute()` calls it with `from_queue=True`. + +Related, not done anywhere: catching `KeyboardInterrupt` in `tomo_queue_execute()` so an abort +marks the job "incomplete" instead of leaving it "running". diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_timing_report.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_timing_report.md new file mode 100644 index 00000000..982947a6 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_timing_report.md @@ -0,0 +1,38 @@ +# TODO: OMNY has no end-of-scan timing summary at all, so it's also missing the new tomo_print_timing_report() commands + +Found while adding `tomo_print_timing_report()` / `tomo_print_timing_report_and_scilog()` +to flomni and LamNI (branch `fixes/flomni_beamtime_2`) so the elapsed/idle +timing breakdown that normally only prints at the end of a *completed* +`tomo_scan()` can also be pulled on demand -- in particular right after +interrupting a scan with Ctrl-C, since the underlying `self.progress` data +(a BEC global var) survives the interrupt even though the old inlined +end-of-scan print block did not run. + +## 1. No "Tomoscan finished" timing block on OMNY at all (pre-existing gap) + +flomni (`flomni.py`) and LamNI (`lamni.py`) both print an elapsed/idle/ +excl.-idle timing breakdown at the tail of `tomo_scan()`, built from +`self.progress` (`tomo_start_time`, `accumulated_idle_time`) via a shared +`_build_timing_lines()` helper (flomni.py, near line 2984; lamni.py, near +line 411). `OMNY.tomo_scan()` (omny.py:1005) has no equivalent block at +all -- it was added to flomni/lamni after the flomni-to-lamni migration and +was never back-ported to OMNY. + +## 2. New on-demand commands also missing + +Both setups now also expose: +- `tomo_print_timing_report()` -- prints the current timing breakdown to + console at any time (mid-scan, after an interrupt, or after completion), + no scilog write. +- `tomo_print_timing_report_and_scilog()` -- same, plus writes the report to + scilog. + +If OMNY is still in active use, porting this means: adding an +`_ProgressProxy`-equivalent `tomo_start_time`/`accumulated_idle_time` +tracking (OMNY may already track some of this differently -- check before +assuming it needs the same proxy), then copying +`_build_timing_lines()`/`tomo_print_timing_report()`/ +`tomo_print_timing_report_and_scilog()` across with OMNY's own +`write_to_scilog`/`_scilog_write` equivalent (omny.py:855). + +Not scoped/designed further here -- just flagging it so it isn't lost. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_xrayeye_dap_fit.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_xrayeye_dap_fit.md new file mode 100644 index 00000000..449279e8 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_xrayeye_dap_fit.md @@ -0,0 +1,37 @@ +# Note: X-ray-eye DAP sine fit (start values) -- what omny inherits, and what is still open + +Status: **nothing to fix in omny code for the fit itself; recorded 2026-09-20 so it isn't rediscovered.** + +## What was fixed (flomni, 2026-09-20) + +The GUI fit tab (`OMNY_XRayEye` in `csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py`) fits +`A*sin(f*x + shift) + intercept` through the DAP server with `f` fixed to 1 deg. The DAP server +used lmfit's `SineModel.guess()` for the start values. For flomni's 5 points over only 0..180 deg +that FFT-based guess is arbitrary and regularly left `shift` stuck at its bound (2*pi), far from +the data (plotted fit missed the submitted points; wrong `X Phase` loaded into flomni). + +Fix: `SINE_FIT_DAP_PARAMETERS` (same file) overrides all five parameters (signed amplitude, start +A=30 / shift=0 within +-pi / intercept=0), so DAP skips `guess()`. 5-pt/0..180 deg: 0 failures in 300 +random datasets through the real `LmfitService1D` (old parameters: 16 of 300). + +## Consequences for omny + +- The widget is shared by flomni, LamNI and omny, so omny's GUI fit tab already uses the new + parameters. Checked with 8 points over 0..315 deg (omny/LamNI layout): 0 of 300 failures both + with the old and the new parameters, i.e. the 8-point full-period case was never affected and + is not made worse. No omny change required. +- The fitted parameters use the same form `A*sin(radians(angle) + phase) + offset` in flomni, + LamNI and omny consumers, so the (now possibly negative) amplitude with phase in [-pi, pi] is + the same curve as before. Nothing downstream needs a sign fix. +- Cosmetic: a row of all zeros (flomni's y row) now fits to ~1e-6 noise instead of exactly 0; + the y plot autoscales that to a curved line at the 1e-6 level. Harmless (flomni overrides y + with zeros in `read_alignment_offset`). + +## Open for omny + +- `omny/x_ray_eye_align.py` is still a verbatim copy of LamNI's (class docstring "for LamNI", + uses `self.lamni.*` and `self.lamni.align.read_xray_eye_correction_from_gui()`), while + `omny/omny_alignment_mixin.py` only reads the Matlab fit from `ptychotomoalign_*.txt` + (`read_alignment_offset`) and has no `read_xray_eye_correction_from_gui`. So omny currently + does not consume the GUI fit at all. When omny's alignment is properly ported, decide whether + it should use the GUI fit (then this DAP fix is what it relies on) or keep the Matlab files. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_zero_deg_reference_projection.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_zero_deg_reference_projection.md new file mode 100644 index 00000000..da4c4e6e --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_zero_deg_reference_projection.md @@ -0,0 +1,40 @@ +# TODO: unconditional 0-degree reference projection at tomogram start + +Status: **not implemented for omny.** Discussed 2026-09-19; implemented for +Flomni and LamNI the same day (see their `tomo_scan()` in `flomni.py`/ +`lamni.py` — search for "Unconditional single 0-degree reference +projection"). + +## What was implemented elsewhere + +At the very start of a new tomogram (not on resume), both Flomni and LamNI +now unconditionally take one real 0-degree projection — `self._tomo_scan_at_angle(0, 1)` +— independent of the `zero_deg_reference_at_each_subtomo` / +`golden_projections_at_0_deg_for_damage_estimation` flags. It's real data +(included in reconstruction, logged under `subtomo_number=1`), not a +reference-only/flat-field frame — this was an explicit choice: the point is +an immediate radiation-damage baseline that exists even when the periodic +damage-estimation flags are off, not a normalization frame. + +Flomni additionally takes 10 flat-field/empty frames at 0° before this +(`collect_empty_frames()` — sample retracted out of the beam via `fsamx` +shifted by `fovx/2`, excluded from reconstruction). **LamNI does not get an +equivalent** — confirmed 2026-09-19 that LamNI has no way to retract the +sample out of the beam at all, so only the 0-degree projection was added +there, no flat-field step. + +## What's needed for omny + +1. **Confirm whether omny can retract the sample out of the beam at all** + (same open question as LamNI, not yet checked for omny specifically) — + determines whether an empty-frames/flat-field step is even possible here, + or whether omny should only get the 0-degree-projection part like LamNI. +2. Locate omny's `tomo_scan()` equivalent (or whatever its tomogram-start + entry point is) and its own `_tomo_scan_at_angle`-equivalent method, and + add the same unconditional call in its "new scan" branch, following the + same placement pattern used in `flomni.py`/`lamni.py`. +3. Decide the logged `subtomo_number` / whether omny's tomo-type numbering + matches flomni/lamni's convention closely enough to reuse `1` directly. + +Not investigated yet — this note exists so the intent isn't lost, not +because the omny-side work has been scoped in detail. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py index d07a2ea1..a2664d3b 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py @@ -1804,10 +1804,11 @@ class OMNY( # already exists on deployed systems. tomo_scan_numbers_file = os.path.expanduser("~/data/raw/logs/tomography_scannumbers.txt") os.makedirs(os.path.dirname(tomo_scan_numbers_file), exist_ok=True) + sample_name = self.sample_name.replace(" ", "_") with open(tomo_scan_numbers_file, "a+") as out_file: # pylint: disable=undefined-variable out_file.write( - f"{scan_number} {angle} {dev.osamroy.read()['osamroy']['value']:.3f} {self.tomo_id} {subtomo_number} {0} {self.sample_name}\n" + f"{scan_number} {angle} {dev.osamroy.read()['osamroy']['value']:.3f} {self.tomo_id} {subtomo_number} {0} {sample_name}\n" ) def collect_empty_frames(self): diff --git a/csaxs_bec/bec_ipython_client/startup/post_startup.py b/csaxs_bec/bec_ipython_client/startup/post_startup.py index 32b6b1ea..16aff438 100644 --- a/csaxs_bec/bec_ipython_client/startup/post_startup.py +++ b/csaxs_bec/bec_ipython_client/startup/post_startup.py @@ -51,7 +51,7 @@ if _args.session.lower() == "lamni": from csaxs_bec.bec_ipython_client.plugins.LamNI import LamNI _session_name = "LamNI" - lamni = LamNI(bec) + lamni = LamNI(bec, csaxs=csaxs) logger.success("LamNI session loaded.") print(r""" ██████╗ ███████╗ ██████╗ ██╗ █████╗ ███╗ ███╗███╗ ██╗██╗ @@ -85,7 +85,10 @@ elif _args.session.lower() == "flomni": from csaxs_bec.bec_ipython_client.plugins.flomni import Flomni _session_name = "flomni" - flomni = Flomni(bec) + if "fttrx1" in dev: + flomni = Flomni(bec, csaxs=csaxs) + else: + flomni = Flomni(bec) logger.success("flomni session loaded.") print(r""" ██████╗ ███████╗ ██████╗ ███████╗██╗ ██████╗ ███╗ ███╗███╗ ██╗██╗ diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index abe03e3b..011e9f46 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -14,6 +14,7 @@ logger = bec_logger.logger _Widgets = { "DataViewer": "DataViewer", + "IDSCameraSettings": "IDSCameraSettings", "OMNY_SampleStorage": "OMNY_SampleStorage", "OMNY_TomoParams": "OMNY_TomoParams", "OMNY_XRayEye": "OMNY_XRayEye", @@ -47,6 +48,24 @@ class DataViewer(RPCBase): """ +class IDSCameraSettings(RPCBase): + """Pick a configured IDS camera and adjust its exposure time / pixel clock.""" + + _IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.ids_camera_settings.ids_camera_settings" + + @rpc_call + def selected_camera(): + """ + Intermediate wrapper used so that the user can optionally chain .setter(...). + """ + + @rpc_call + def set_camera(self, name: "str"): + """ + Select a camera by device name, as if chosen from the dropdown. + """ + + class OMNY_SampleStorage(RPCBase): """View and correct the FlOMNI sample-storage records.""" @@ -175,6 +194,19 @@ class OMNY_XRayEye(RPCBase): enable(int): -1 disable else enable """ + @rpc_timeout(20) + @rpc_call + def enable_skip_button(self, enable: "bool"): + """ + Allow/forbid skipping the current alignment angle. + + The first call also shows the button (it is hidden for clients that never call this). + The button is only clickable while this is True *and* the submit button is enabled, + so it follows the client's "please wait ..." phases automatically. + Args: + enable(bool): True if the client accepts a skip for the current step + """ + @property @rpc_call def sample_name(self): @@ -238,6 +270,70 @@ class OMNY_XRayEye(RPCBase): Hide the alignment target crosshair on the image view. """ + @rpc_timeout(20) + @rpc_call + def lock_vertical_center(self): + """ + Snap every ROI's vertical center to the crosshair's fixed y position + (see ``crosshair_position()``), immediately and again on every future + draw/drag/resize, until ``unlock_vertical_center()`` is called. Used by + ``x_ray_eye_align.py`` once the sample's height has been physically + centered on the FZP, so subsequent alignment boxes no longer need manual + vertical placement. See ``_snap_roi_vertical()``. + + Also settable by the operator directly via the "ROI vertical lock" + toggle in the control panel (``_on_vertical_lock_toggle_changed``) -- + e.g. to temporarily release a script-engaged lock mid-run in order to + freely draw/measure an unrelated box, then re-engage it. Both paths + keep the toggle's visual state in sync with ``_vertical_lock_enabled``. + """ + + @rpc_timeout(20) + @rpc_call + def unlock_vertical_center(self): + """ + Stop auto-snapping ROIs' vertical center -- see ``lock_vertical_center()``. + """ + + @rpc_timeout(20) + @rpc_call + def enable_ellipse_roi(self, enable: "bool"): + """ + Show/hide the elliptical ROI tool and its angle controls. + + Hidden by default; the LamNI client enables it after the FZP centre has been + submitted, for the extended/smear rotation-centre calibrations (angle locked) + and for the X-ray eye alignment (angle free). Disabling removes an existing + ellipse and releases the angle lock. + + Args: + enable(bool): True to offer the ellipse tool. + """ + + @rpc_timeout(20) + @rpc_call + def lock_roi_angle(self, angle: "float | None" = None): + """ + Fix the rotation of elliptical ROIs, now and for ellipses drawn later. + + Args: + angle(float | None): Rotation in degrees. If None, the active ellipse's + current rotation (or, without one, the angle in the spin box) is used. + """ + + @rpc_timeout(20) + @rpc_call + def unlock_roi_angle(self): + """ + Let elliptical ROIs rotate freely again -- see ``lock_roi_angle()``. + """ + + @rpc_call + def roi_angle(self) -> "float | None": + """ + Rotation in degrees of the active elliptical ROI, or None if there is none. + """ + @property @rpc_call def crosshair_visible(self) -> "bool": diff --git a/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py b/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py index c109557f..cd740471 100644 --- a/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py +++ b/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py @@ -2,6 +2,8 @@ from __future__ import annotations import os import signal +import threading +import time from bec_lib import bec_logger from bec_widgets import BECWidget, SafeProperty, SafeSlot @@ -26,11 +28,13 @@ class z_ConsoleButtonsWidget(BECWidget, QWidget): let an unrelated, un-suppressed device error kill the scan worker thread and require a full BEC restart), then sends a real SIGINT to the BEC IPython client process (the parent of the GUI server - process), then directly calls ``hard_stop_device_name``'s - ``.controller.hard_abort_and_restore_positioning_mode()`` -- the same - Galil hard-stop (XQ#STOP,1) the CLI's ``ftransfer_abort()`` uses -- - and, if configured, ``extra_hard_stop_device_name``'s - ``.controller.stop_all_axes()``. + process), then sends a stop (``controller.stop_all_axes()``, i.e. + ``XQ#STOP,1``) to the Galil controller of EVERY device listed in + ``hard_stop_device_names`` -- one device per physical controller, in + that order (put the sample-transfer controller first) -- and finally + restores positioning mode on the FIRST listed controller via + ``.controller.hard_abort_and_restore_positioning_mode()`` (the same + Galil hard-stop the CLI's ``ftransfer_abort()`` uses). SIGINT is sent BEFORE the hard stop, not after: if a client-side loop is actively polling this same transfer (e.g. ftransfer_get_sample's @@ -41,41 +45,38 @@ class z_ConsoleButtonsWidget(BECWidget, QWidget): ``ensure_gripper_up()``, which must not run mid-transfer. Sending SIGINT first (an os.kill() call, effectively instant) gives the target process's own ``except KeyboardInterrupt: ftransfer_abort(); - raise FlomniError(...)`` handler a head start over the hard stop's - own multi-step sequence (which itself takes hundreds of ms due to - its internal polling/settling waits) -- without adding any - artificial delay of its own, so the actual motion stop is still - effectively immediate. + raise FlomniError(...)`` handler a head start over the hard stop. - ``hard_stop_device_name`` names the device whose ``.controller`` the - hard stop is called on (e.g. ``"ftransy"`` for flomni). Without a device - *name* configured, the button is disabled rather than silently doing - nothing. A configured name that doesn't actually resolve to a device - (e.g. missing from the current session's config) is not checked ahead - of time -- it's re-resolved at click time in ``_on_abort()``, which - already logs and no-ops on failure -- so the button being enabled is - not a guarantee the device exists, only that a target was configured. + ``hard_stop_device_names`` is a list of device names, one per Galil + controller (flomni: ``["ftransy", "foptx", "fsamroy"]`` -- the transfer + stages' controller, the optics controller and the rotation controller). + The legacy kwargs ``hard_stop_device_name`` / ``extra_hard_stop_device_name`` + are still accepted and simply form the list ``[first, extra]``. With no + names configured, the button is disabled rather than silently doing + nothing. Names are NOT checked when the widget is built (that live check + proved unreliable, see git history), but they are resolved freshly from + the GUI client's device manager at click time, with a few retries, and + every outcome -- including "device could not be resolved" -- is logged + and shown in the widget's message label, so a controller that was not + stopped is never silent. - ``extra_hard_stop_device_name`` optionally names a second device on a - physically separate Galil controller (e.g. ``"foptx"`` for flomni's - optics stage, a different board/socket port from the transfer stage - ``ftransy`` sits on -- one abort press only halts axes on ``ftransy``'s - controller otherwise). Its ``.controller.stop_all_axes()`` is called - instead of ``hard_abort_and_restore_positioning_mode()``: the latter's - ``#POSMODE``/``mntmod`` handling is specific to the sample-transfer/ - mount program that only runs on the transfer controller, so - ``stop_all_axes()`` (a plain ``XQ#STOP,1``) is the correct generic - "halt everything on this controller" primitive for any other board. - Entirely optional -- unset means only ``hard_stop_device_name`` is - stopped, as before. + The stops are emitted from one thread per controller, started in the + configured order, so a hung RPC to one controller cannot delay the + others. If the client-side proxy does not (yet) expose + ``stop_all_axes`` (the device server publishes exposed methods when it + starts), the raw command ``XQ#STOP,1`` is sent through the always + exposed ``socket_put_and_receive`` instead. """ USER_ACCESS = ["message", "message.setter", "response", "clear_response"] PLUGIN = True def __init__(self, parent=None, **kwargs): - self._hard_stop_device_name = kwargs.pop("hard_stop_device_name", None) - self._extra_hard_stop_device_name = kwargs.pop("extra_hard_stop_device_name", None) + self._hard_stop_device_names = self._collect_hard_stop_names( + kwargs.pop("hard_stop_device_names", None), + kwargs.pop("hard_stop_device_name", None), + kwargs.pop("extra_hard_stop_device_name", None), + ) self._hard_stop_label = kwargs.pop("hard_stop_label", "Motion Stop") super().__init__(parent=parent, **kwargs) self._response = "" @@ -87,22 +88,39 @@ class z_ConsoleButtonsWidget(BECWidget, QWidget): self._client_pid = os.getppid() self._init_ui() - def _hard_stop_available(self) -> bool: - """True if a hard_stop_device_name was configured for this widget. + # A fresh lookup at click time is retried a few times: a lookup can fail + # for a moment while the GUI client's device manager is being (re)filled. + _RESOLVE_ATTEMPTS = 3 + _RESOLVE_DELAY_S = 0.05 + # Upper bound for waiting on the per-controller stop threads. + _STOP_JOIN_TIMEOUT_S = 10.0 - Deliberately does not check whether the name actually resolves to a - live, enabled device: that live check (a getattr(self.dev, name) - device-manager lookup) proved unreliable in practice -- a - confirmed-present, confirmed-enabled device could still evaluate as - unavailable here, permanently disabling the button for the widget's - lifetime with no way to recover short of recreating the dock. Since - whatever device is named is re-resolved with its own try/except in - _on_abort() anyway, a stale or wrong name still fails safely - (logged, no-op) instead of silently doing nothing -- so skipping the - redundant, unreliable pre-check here only removes a false-negative - failure mode, not real safety. + @staticmethod + def _collect_hard_stop_names(names, first=None, extra=None) -> list[str]: + """Ordered, de-duplicated list of the devices whose controllers get a + stop. ``names`` (the ``hard_stop_device_names`` kwarg) wins; without + it the legacy pair ``[first, extra]`` is used.""" + if isinstance(names, str): + names = [names] + candidates = list(names) if names else [first, extra] + result: list[str] = [] + for name in candidates: + if name and name not in result: + result.append(name) + return result + + def _hard_stop_available(self) -> bool: + """True if at least one hard-stop device name was configured. + + Deliberately does not check whether the names actually resolve to + live devices: that live check (a getattr(self.dev, name) + device-manager lookup at construction time) proved unreliable in + practice -- a confirmed-present, confirmed-enabled device could still + evaluate as unavailable, permanently disabling the button for the + widget's lifetime. Names are resolved at click time instead, and an + unresolvable name is reported loudly (log + message label). """ - return bool(self._hard_stop_device_name) + return bool(self._hard_stop_device_names) def _init_ui(self): layout = QVBoxLayout(self) @@ -170,6 +188,9 @@ class z_ConsoleButtonsWidget(BECWidget, QWidget): @SafeSlot() def _on_abort(self): + if not self._hard_stop_device_names: + return # button is disabled in this case; defensive only + # 1) Coordinated abort first: a safe no-op if nothing is queued: when # something IS queue-tracked, this registers a real stop_id so the # device server suppresses the resulting error instead of killing @@ -190,34 +211,113 @@ class z_ConsoleButtonsWidget(BECWidget, QWidget): logger.warning(f"z_ConsoleButtonsWidget: sending SIGINT to client pid {self._client_pid}") os.kill(self._client_pid, signal.SIGINT) - # 3) Hard motion stop(s) -- not delayed by anything above: sending - # SIGINT is a near-instant os.kill() call, so these still run - # effectively immediately. The two devices are independent - # controllers (see class docstring): each is attempted and - # logged on its own, so a failure on one doesn't skip the other. - if self._hard_stop_device_name: - self._stop_device_controller( - self._hard_stop_device_name, "hard_abort_and_restore_positioning_mode" - ) - if self._extra_hard_stop_device_name: - self._stop_device_controller(self._extra_hard_stop_device_name, "stop_all_axes") + # 3) Stop every Galil controller, in the configured order (transfer + # controller first), then 4) restore positioning mode on the first + # one -- last, because it takes >= 0.5 s and must not delay the + # other stops. Nothing here raises: every failure is logged and + # shown in the message label. + stop_results = self._stop_all_controllers() + parts = [ + f"{name}: OK" if ok else f"{name}: NOT STOPPED ({detail})" + for name, ok, detail in stop_results + ] + text = "Motion stop sent -> " + " | ".join(parts) + if not all(ok for _, ok, _ in stop_results): + text += "\nCHECK THE STAGES MANUALLY!" + self.message_label.setText(text) - def _stop_device_controller(self, device_name: str, method_name: str) -> None: - """Resolve device_name and call method_name() on its .controller, - logging and swallowing any failure -- so a missing device or a - failed stop on one controller can't prevent an already-issued stop - on another, or crash the rest of _on_abort().""" + restore_ok, restore_detail = self._restore_positioning_mode(self._hard_stop_device_names[0]) + self.message_label.setText( + text + f"\nPositioning mode: {'restored' if restore_ok else 'NOT restored'}" + + ("" if restore_ok else f" ({restore_detail})") + ) + + def _resolve_controller(self, device_name: str): + """Fresh lookup of ``device_name`` in the GUI client's device manager + (not the ``self.dev`` reference cached at widget construction), with a + few retries. Returns the device's ``.controller`` RPC proxy or raises + RuntimeError with the reason.""" + reason = "unknown" + for attempt in range(self._RESOLVE_ATTEMPTS): + try: + device = self.client.device_manager.devices.get(device_name) + if device is not None: + return device.controller + reason = "device not found in the GUI client's device manager" + except Exception as exc: # DeviceConfigError etc. are not AttributeErrors + reason = f"{type(exc).__name__}: {exc}" + if attempt < self._RESOLVE_ATTEMPTS - 1: + time.sleep(self._RESOLVE_DELAY_S) + raise RuntimeError(reason) + + def _stop_controller(self, device_name: str) -> tuple[bool, str]: + """Send a stop to the Galil controller behind ``device_name``. + Returns (sent, detail); never raises.""" try: - device = getattr(self.dev, device_name, None) - except Exception: - device = None - if device is None: - return - logger.warning(f"z_ConsoleButtonsWidget: hard-stopping {device_name} ({method_name})") + controller = self._resolve_controller(device_name) + except Exception as exc: + logger.error(f"z_ConsoleButtonsWidget: cannot resolve {device_name}: {exc}") + return False, f"not resolved: {exc}" + logger.warning(f"z_ConsoleButtonsWidget: stopping all axes on {device_name}'s controller") try: - getattr(device.controller, method_name)() - except Exception: - logger.exception(f"z_ConsoleButtonsWidget: hard motion stop of {device_name} failed") + stop_all_axes = getattr(controller, "stop_all_axes", None) + if not callable(stop_all_axes): + raise AttributeError("stop_all_axes is not exposed by the device server yet") + reply = stop_all_axes() + logger.warning(f"z_ConsoleButtonsWidget: {device_name} stop_all_axes -> {reply!r}") + return True, str(reply) + except Exception as exc: + logger.warning( + f"z_ConsoleButtonsWidget: stop_all_axes on {device_name} failed ({exc!r}); " + "falling back to raw XQ#STOP,1" + ) + try: + reply = controller.socket_put_and_receive("XQ#STOP,1") + logger.warning(f"z_ConsoleButtonsWidget: {device_name} raw XQ#STOP,1 -> {reply!r}") + return True, str(reply) + except Exception as exc: + logger.exception(f"z_ConsoleButtonsWidget: stopping {device_name} failed") + return False, f"stop failed: {exc}" + + def _stop_all_controllers(self) -> list[tuple[str, bool, str]]: + """Stop the controllers of all configured devices. One thread per + controller, started in the configured order, so a hung RPC to one + controller cannot delay the others. Returns (name, sent, detail) in + the configured order.""" + results: dict[str, tuple[bool, str]] = {} + + def worker(name: str) -> None: + results[name] = self._stop_controller(name) + + threads = [] + for name in self._hard_stop_device_names: + thread = threading.Thread(target=worker, args=(name,), daemon=True) + thread.start() + threads.append(thread) + deadline = time.monotonic() + self._STOP_JOIN_TIMEOUT_S + for thread in threads: + thread.join(max(0.0, deadline - time.monotonic())) + out = [] + for name in self._hard_stop_device_names: + ok, detail = results.get(name, (False, f"no answer within {self._STOP_JOIN_TIMEOUT_S} s")) + out.append((name, ok, detail)) + return out + + def _restore_positioning_mode(self, device_name: str) -> tuple[bool, str]: + """Wait for the transfer routine to halt and switch the controller + behind ``device_name`` back to positioning mode. Never raises.""" + try: + controller = self._resolve_controller(device_name) + logger.warning( + f"z_ConsoleButtonsWidget: restoring positioning mode on {device_name}'s controller" + ) + controller.hard_abort_and_restore_positioning_mode() + return True, "" + except Exception as exc: + logger.exception( + f"z_ConsoleButtonsWidget: restoring positioning mode on {device_name} failed" + ) + return False, str(exc) @SafeProperty(str) def message(self): diff --git a/csaxs_bec/bec_widgets/widgets/designer_plugins.py b/csaxs_bec/bec_widgets/widgets/designer_plugins.py index ad5b6c7c..6f9f30d9 100644 --- a/csaxs_bec/bec_widgets/widgets/designer_plugins.py +++ b/csaxs_bec/bec_widgets/widgets/designer_plugins.py @@ -6,6 +6,10 @@ from __future__ import annotations designer_plugins = { "DataViewer": ("csaxs_bec.bec_widgets.widgets.data_viewer.data_viewer", "DataViewer"), + "IDSCameraSettings": ( + "csaxs_bec.bec_widgets.widgets.ids_camera_settings.ids_camera_settings", + "IDSCameraSettings", + ), "OMNY_SampleStorage": ( "csaxs_bec.bec_widgets.widgets.sample_storage.sample_storage", "OMNY_SampleStorage", @@ -22,6 +26,7 @@ designer_plugins = { widget_icons = { "DataViewer": "find_in_page", + "IDSCameraSettings": "photo_camera", "OMNY_SampleStorage": "widgets", "OMNY_TomoParams": "widgets", "OMNY_XRayEye": "widgets", diff --git a/csaxs_bec/bec_widgets/widgets/ids_camera_settings/__init__.py b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/__init__.py new file mode 100644 index 00000000..a6f28fee --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/__init__.py @@ -0,0 +1,3 @@ +from csaxs_bec.bec_widgets.widgets.ids_camera_settings.ids_camera_settings import IDSCameraSettings + +__all__ = ["IDSCameraSettings"] diff --git a/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings.py b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings.py new file mode 100644 index 00000000..4fca2fc0 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings.py @@ -0,0 +1,511 @@ +"""IDS camera settings widget for cSAXS -- pick a configured IDS camera from +a dropdown and adjust its exposure time / pixel clock. + +The exposure/pixel-clock control logic mirrors OMNY_XRayEye +(bec_widgets/widgets/xray_eye/x_ray_eye.py), which implements the same +controls but hardcoded to a single camera (module-level CAMERA constant). +This widget generalizes that to whichever camera is selected, for beamlines +that configure more than one IDS camera at once (e.g. ptycho_omny.yaml's +cam200..cam203). +""" + +from __future__ import annotations + +import math + +from bec_lib import bec_logger +from bec_lib.endpoints import MessageEndpoints +from bec_widgets import BECWidget, SafeProperty, SafeSlot +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import ( + DeviceComboBox, +) +from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch +from qtpy.QtCore import Qt, QTimer +from qtpy.QtWidgets import QFrame, QGridLayout, QLabel, QSizePolicy, QSlider, QVBoxLayout, QWidget + +logger = bec_logger.logger + + +class IDSCameraComboBox(DeviceComboBox): + """Dropdown listing configured IDS cameras. + + DeviceComboBox's device_filter only distinguishes bec_lib.device base + classes (Device/Positioner/Signal/ComputedSignal) and can't express + "exactly IDSCamera", so this overrides update_devices_from_filters() to + filter by each device's deviceClass config instead -- the same technique + device_table.py uses to filter/display device classes client-side. + + To support another camera class later (once it grows the same + Kind.config exposure/pixel-clock signals this widget depends on), add + its bare class name to CAMERA_DEVICE_CLASSES -- no other change should + be required here. + """ + + PLUGIN = False + RPC = False + + CAMERA_DEVICE_CLASSES: tuple[str, ...] = ("IDSCamera",) + + @staticmethod + def _device_class_name(device) -> str: + """Bare class name from a device's deviceClass config, e.g. + "csaxs_bec.devices.ids_cameras.ids_camera.IDSCamera" -> "IDSCamera".""" + device_class = (getattr(device, "_config", None) or {}).get("deviceClass") or "" + return device_class.rsplit(".", 1)[-1] if device_class else "" + + @SafeSlot() + def update_devices_from_filters(self): + if not self.apply_filter: + return + self.devices = sorted( + device.name + for device in self.dev.enabled_devices + if self._device_class_name(device) in self.CAMERA_DEVICE_CLASSES + ) + + +class IDSCameraSettings(BECWidget, QWidget): + """Pick a configured IDS camera and adjust its exposure time / pixel clock.""" + + ICON_NAME = "photo_camera" + PLUGIN = True + USER_ACCESS = ["selected_camera", "set_camera"] + + # exposure_time_slider works in tenths of a ms internally (QSlider is + # int-only); divide by this to get ms. Same idiom as OMNY_XRayEye. + _EXPOSURE_SLIDER_SCALE = 10 + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self.get_bec_shortcuts() + + self._camera_name: str | None = None + self._auto_exposure_enabled = True + # pixel_clock_slider is index-based over this list (re-fetched once + # per camera, see _init_pixel_clock_options()) rather than a raw MHz + # range -- many uEye sensors only accept a short discrete list of + # pixel clocks, not every value in [min, max] (see OMNY_XRayEye). + self._pixel_clock_options: list[int] = [] + self._last_pixel_clock_mhz: int | None = None + + self._queue_busy = False + self._queue_idle_timer = QTimer(self) + self._queue_idle_timer.setSingleShot(True) + self._queue_idle_timer.setInterval(800) + self._queue_idle_timer.timeout.connect(self._release_queue_busy) + + self._init_ui() + self._make_connections() + self._reset_controls_to_placeholder() + + self.bec_dispatcher.connect_slot( + self.on_queue_status_update, MessageEndpoints.scan_queue_status() + ) + QTimer.singleShot(0, self._init_queue_status) + + def _init_ui(self): + layout = QVBoxLayout(self) + + camera_row = QGridLayout() + camera_row.setColumnStretch(1, 1) + self.camera_label = QLabel("Camera", parent=self) + self.camera_combo = IDSCameraComboBox(parent=self) + self.camera_combo.set_first_element_as_empty = True + camera_row.addWidget(self.camera_label, 0, 0) + camera_row.addWidget(self.camera_combo, 0, 1) + layout.addLayout(camera_row) + + self.status_label = QLabel(parent=self) + layout.addWidget(self.status_label) + + layout.addWidget(self._create_separator()) + + _right_vcenter = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + + self.exposure_grid_widget = QWidget(parent=self) + exposure_grid = QGridLayout(self.exposure_grid_widget) + exposure_grid.setHorizontalSpacing(8) + exposure_grid.setVerticalSpacing(4) + exposure_grid.setColumnStretch(0, 1) + + # No "Auto gain" control here -- see OMNY_XRayEye, which dropped it + # after HW testing found continuous auto-gain isn't useful once + # correctly exposed. IDSCamera pulses it on then off internally, + # once, at connect; there is no persistent UI for it. + self.auto_exposure_label = QLabel("Auto exposure", parent=self) + self.auto_exposure_toggle = ToggleSwitch(parent=self) + self.auto_exposure_toggle.checked = True + self.auto_exposure_toggle.enabled.connect(self.auto_exposure_enabled_changed) + exposure_grid.addWidget(self.auto_exposure_label, 0, 1, _right_vcenter) + exposure_grid.addWidget(self.auto_exposure_toggle, 0, 2, Qt.AlignmentFlag.AlignVCenter) + + # QSlider only takes ints, so exposure time is tracked in tenths of a + # ms internally (_EXPOSURE_SLIDER_SCALE) for one decimal of + # resolution on the display label. + self.exposure_time_label = QLabel("Exposure time", parent=self) + self.exposure_time_slider = QSlider(Qt.Orientation.Horizontal, parent=self) + self.exposure_time_slider.setRange(1, 10000) # placeholder; reseeded per camera + self.exposure_time_value_label = QLabel("-- ms", parent=self) + self.exposure_time_value_label.setMinimumWidth(60) + self.exposure_time_slider.valueChanged.connect(self._update_exposure_time_value_label) + # sliderReleased (fires once, on mouse-up), not valueChanged (fires on + # every tick while dragging) -- don't hammer the device. + self.exposure_time_slider.sliderReleased.connect(self.exposure_time_submitted) + exposure_grid.addWidget(self.exposure_time_label, 1, 1, _right_vcenter) + exposure_grid.addWidget(self.exposure_time_slider, 1, 2, 1, 2) + exposure_grid.addWidget(self.exposure_time_value_label, 1, 4, Qt.AlignmentFlag.AlignVCenter) + + # Pixel clock (MHz): lowering it raises the max achievable exposure + # time, at the cost of frame rate. Many uEye sensors only accept a + # short discrete list of pixel clocks, so this slider's *position* is + # an index into self._pixel_clock_options, not a raw MHz value. + self.pixel_clock_label = QLabel("Pixel clock", parent=self) + self.pixel_clock_slider = QSlider(Qt.Orientation.Horizontal, parent=self) + self.pixel_clock_slider.setRange(0, 0) # placeholder; see _set_pixel_clock_options() + self.pixel_clock_value_label = QLabel("-- MHz", parent=self) + self.pixel_clock_value_label.setMinimumWidth(60) + self.pixel_clock_slider.valueChanged.connect(self._update_pixel_clock_value_label) + self.pixel_clock_slider.sliderReleased.connect(self.pixel_clock_submitted) + exposure_grid.addWidget(self.pixel_clock_label, 2, 1, _right_vcenter) + exposure_grid.addWidget(self.pixel_clock_slider, 2, 2, 1, 2) + exposure_grid.addWidget(self.pixel_clock_value_label, 2, 4, Qt.AlignmentFlag.AlignVCenter) + + layout.addWidget(self.exposure_grid_widget) + layout.addStretch() + + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + + def _make_connections(self): + self.camera_combo.device_selected.connect(self._on_camera_selected) + self.camera_combo.device_reset.connect(self._on_camera_reset) + + def _create_separator(self): + sep = QFrame(parent=self) + sep.setFrameShape(QFrame.Shape.HLine) + sep.setFrameShadow(QFrame.Shadow.Sunken) + sep.setLineWidth(1) + return sep + + ################################################################################ + # Camera selection + ################################################################################ + + @SafeSlot(str) + def _on_camera_selected(self, camera_name: str): + if not camera_name or camera_name == self._camera_name: + return + self._switch_camera(camera_name) + + @SafeSlot() + def _on_camera_reset(self): + if self._camera_name is not None: + self._switch_camera(None) + + @SafeProperty(str) + def selected_camera(self) -> str: + """Name of the currently selected IDS camera, or an empty string if none.""" + return self._camera_name or "" + + @SafeSlot(str) + def set_camera(self, name: str): + """Select a camera by device name, as if chosen from the dropdown.""" + self.camera_combo.set_device(name) + + def _switch_camera(self, camera_name: str | None): + if self._camera_name is not None: + self.bec_dispatcher.disconnect_slot( + self.getting_camera_status, + MessageEndpoints.device_read_configuration(self._camera_name), + ) + self._camera_name = camera_name + self._reset_controls_to_placeholder() + + if camera_name is None: + return + + self.bec_dispatcher.connect_slot( + self.getting_camera_status, MessageEndpoints.device_read_configuration(camera_name) + ) + # device_read_configuration is retained in redis (set_and_publish), so + # seed immediately from the last known value instead of waiting for + # the camera to spontaneously republish -- connect_slot alone only + # delivers messages published *after* it connects. + try: + msg = self.client.connector.get( + MessageEndpoints.device_read_configuration(camera_name) + ) + except Exception as exc: + logger.warning(f"Failed to fetch initial config for {camera_name}: {exc}") + msg = None + if msg is not None: + self.getting_camera_status(msg.content, msg.metadata) + + self._pixel_clock_options = [] + self._last_pixel_clock_mhz = None + QTimer.singleShot(0, self._init_pixel_clock_options) + + def _reset_controls_to_placeholder(self): + self._auto_exposure_enabled = True + self.auto_exposure_toggle.blockSignals(True) + self.auto_exposure_toggle.checked = True + self.auto_exposure_toggle.blockSignals(False) + + self.exposure_time_value_label.setText("-- ms") + + self._pixel_clock_options = [] + self._last_pixel_clock_mhz = None + self.pixel_clock_slider.blockSignals(True) + self.pixel_clock_slider.setRange(0, 0) + self.pixel_clock_slider.blockSignals(False) + self.pixel_clock_value_label.setText("-- MHz") + + if self._camera_name is None: + if self.camera_combo.devices: + self.status_label.setText("No camera selected") + else: + self.status_label.setText("No IDS cameras configured") + else: + self.status_label.setText(f"Camera: {self._camera_name}") + + self._refresh_control_availability() + + def _refresh_control_availability(self): + have_camera = self._camera_name is not None + enabled = have_camera and not self._queue_busy + tooltip = "Disabled while scan queue is busy." if have_camera and self._queue_busy else "" + + self.auto_exposure_toggle.setEnabled(enabled) + self.auto_exposure_toggle.setToolTip(tooltip) + self.exposure_time_slider.setEnabled(enabled and not self._auto_exposure_enabled) + self.exposure_time_slider.setToolTip(tooltip) + self.pixel_clock_slider.setEnabled(enabled and bool(self._pixel_clock_options)) + self.pixel_clock_slider.setToolTip(tooltip) + + ################################################################################ + # Exposure / pixel clock + ################################################################################ + + @SafeSlot(dict, dict) + def getting_camera_status(self, data, meta): + if self._camera_name is None: + return + signals = data.get("signals") or {} + + auto_exp = signals.get(f"{self._camera_name}_auto_exposure_enabled") + if auto_exp is not None: + enabled = bool(auto_exp.get("value")) + self.auto_exposure_toggle.blockSignals(True) + self.auto_exposure_toggle.checked = enabled + self.auto_exposure_toggle.blockSignals(False) + self._auto_exposure_enabled = enabled + self._refresh_control_availability() + + # No auto_gain_enabled handling here -- see the comment above the + # (omitted) toggle in _init_ui. + + # Update the pixel-clock slider's position before the exposure ones + # below -- changing the pixel clock changes the exposure range, and + # both can arrive together in this same message. + pixel_clock = signals.get(f"{self._camera_name}_pixel_clock") + if pixel_clock is not None: + self._set_pixel_clock_display(int(pixel_clock.get("value"))) + + # Reseed the exposure slider's bounds next, so a value arriving in + # the same message is clamped against up-to-date min/max. Round the + # min UP and the max DOWN (never the other way) -- ceil (clamped to + # at least 1) keeps the bound physical instead of claiming 0 ms + # exposure is settable when it isn't. + exposure_min = signals.get(f"{self._camera_name}_exposure_time_min") + exposure_max = signals.get(f"{self._camera_name}_exposure_time_max") + if exposure_min is not None and exposure_max is not None: + lo = max(1, math.ceil(float(exposure_min.get("value")) * self._EXPOSURE_SLIDER_SCALE)) + hi = math.floor(float(exposure_max.get("value")) * self._EXPOSURE_SLIDER_SCALE) + if (lo, hi) != (self.exposure_time_slider.minimum(), self.exposure_time_slider.maximum()): + self.exposure_time_slider.setRange(lo, hi) + + exposure_time = signals.get(f"{self._camera_name}_exposure_time") + if exposure_time is not None: + self.exposure_time_slider.blockSignals(True) + self.exposure_time_slider.setValue( + round(float(exposure_time.get("value")) * self._EXPOSURE_SLIDER_SCALE) + ) + self.exposure_time_slider.blockSignals(False) + self._update_exposure_time_value_label(self.exposure_time_slider.value()) + + @SafeSlot(bool) + def auto_exposure_enabled_changed(self, enabled: bool): + if self._camera_name is None or self._queue_busy: + logger.warning("Ignoring auto-exposure toggle: no camera selected or queue busy.") + return + self.auto_exposure_toggle.blockSignals(True) + self.dev.get(self._camera_name).auto_exposure_enabled.put(enabled) + self.auto_exposure_toggle.checked = enabled + self.auto_exposure_toggle.blockSignals(False) + self._auto_exposure_enabled = enabled + self._refresh_control_availability() + + def _update_exposure_time_value_label(self, raw_value: int): + self.exposure_time_value_label.setText( + f"{raw_value / self._EXPOSURE_SLIDER_SCALE:.1f} ms" + ) + + def exposure_time_submitted(self): + if self._camera_name is None or self._queue_busy: + return + value_ms = self.exposure_time_slider.value() / self._EXPOSURE_SLIDER_SCALE + self.dev.get(self._camera_name).exposure_time.put(value_ms) + + def _init_pixel_clock_options(self): + """One-time-per-camera fetch of the actually-supported pixel clocks + (see IDSCamera.get_pixel_clock_list()'s docstring for why this can't + just be [min, max] from a Kind.config signal) -- not a polling loop, + this list is static per camera, so a single RPC call per camera + selection is the right cost/benefit trade-off.""" + camera_name = self._camera_name + if camera_name is None: + return + try: + options = self.dev.get(camera_name).get_pixel_clock_list() + except Exception as exc: + logger.warning(f"Failed to fetch pixel clock options for {camera_name}: {exc}") + return + if camera_name != self._camera_name: + return # camera changed again while this RPC call was in flight + self._set_pixel_clock_options(sorted(int(v) for v in options)) + + def _set_pixel_clock_options(self, options: list[int]): + self._pixel_clock_options = options + if not options: + self._refresh_control_availability() + return + self.pixel_clock_slider.blockSignals(True) + self.pixel_clock_slider.setRange(0, len(options) - 1) + self.pixel_clock_slider.blockSignals(False) + if self._last_pixel_clock_mhz is not None: + # A status message already arrived before this RPC call + # returned -- apply it now that we can actually resolve it to a + # slider position. + self._set_pixel_clock_display(self._last_pixel_clock_mhz) + else: + self._update_pixel_clock_value_label(self.pixel_clock_slider.value()) + self._refresh_control_availability() + + def _set_pixel_clock_display(self, mhz: int): + """Move the slider to the option nearest `mhz` (from hardware, via + getting_camera_status()) and update the value label -- never a raw + MHz value on the slider itself, only a valid index.""" + self._last_pixel_clock_mhz = mhz + if not self._pixel_clock_options: + # Options not fetched yet -- _set_pixel_clock_options() will + # call back into this once they arrive. + self.pixel_clock_value_label.setText(f"{mhz} MHz") + return + index = min( + range(len(self._pixel_clock_options)), + key=lambda i: abs(self._pixel_clock_options[i] - mhz), + ) + self.pixel_clock_slider.blockSignals(True) + self.pixel_clock_slider.setValue(index) + self.pixel_clock_slider.blockSignals(False) + self._update_pixel_clock_value_label(index) + + def _update_pixel_clock_value_label(self, index: int): + if 0 <= index < len(self._pixel_clock_options): + self.pixel_clock_value_label.setText(f"{self._pixel_clock_options[index]} MHz") + else: + self.pixel_clock_value_label.setText("-- MHz") + + def pixel_clock_submitted(self): + if self._camera_name is None or self._queue_busy: + return + index = self.pixel_clock_slider.value() + if not 0 <= index < len(self._pixel_clock_options): + return + self.dev.get(self._camera_name).pixel_clock.put(self._pixel_clock_options[index]) + + ################################################################################ + # Scan-queue guard + ################################################################################ + + def _update_queue_busy_state(self, busy: bool): + if busy: + self._queue_idle_timer.stop() + self._set_queue_busy(True) + return + if self._queue_busy and not self._queue_idle_timer.isActive(): + self._queue_idle_timer.start() + + def _set_queue_busy(self, busy: bool): + if busy == self._queue_busy: + return + self._queue_busy = busy + self._refresh_control_availability() + + def _release_queue_busy(self): + self._set_queue_busy(False) + + def _init_queue_status(self): + try: + msg = self.client.connector.get(MessageEndpoints.scan_queue_status()) + except Exception as exc: + logger.warning(f"Failed to fetch initial scan queue status for IDSCameraSettings: {exc}") + return + if msg is None: + return + self._update_queue_busy_state(self._is_queue_busy(msg.content)) + + @staticmethod + def _is_queue_busy(msg_content: dict) -> bool: + queues = msg_content.get("queue", {}) if isinstance(msg_content, dict) else {} + primary_queue = queues.get("primary") if isinstance(queues, dict) else None + if primary_queue is None: + return False + + queue_info = getattr(primary_queue, "info", None) + if queue_info is None and isinstance(primary_queue, dict): + queue_info = primary_queue.get("info", []) + if not queue_info: + return False + + idle_statuses = {"STOPPED", "COMPLETED", "IDLE"} + for item in queue_info: + status = getattr(item, "status", None) + if status is None and isinstance(item, dict): + status = item.get("status") + if str(status).upper() not in idle_statuses: + return True + return False + + @SafeSlot(dict, dict) + def on_queue_status_update(self, data, meta): + _ = meta + self._update_queue_busy_state(self._is_queue_busy(data)) + + def cleanup(self): + self._queue_idle_timer.stop() + if self._camera_name is not None: + self.bec_dispatcher.disconnect_slot( + self.getting_camera_status, + MessageEndpoints.device_read_configuration(self._camera_name), + ) + self.bec_dispatcher.disconnect_slot( + self.on_queue_status_update, MessageEndpoints.scan_queue_status() + ) + super().cleanup() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from bec_widgets.utils import BECDispatcher + from bec_widgets.utils.colors import apply_theme + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + apply_theme("light") + dispatcher = BECDispatcher(gui_id="ids_camera_settings") + win = IDSCameraSettings() + + win.resize(400, 250) + win.show() + sys.exit(app.exec_()) diff --git a/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings.pyproject b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings.pyproject new file mode 100644 index 00000000..1d5d16a7 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings.pyproject @@ -0,0 +1 @@ +{'files': ['ids_camera_settings.py']} \ No newline at end of file diff --git a/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings_plugin.py b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings_plugin.py new file mode 100644 index 00000000..775cf5af --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/ids_camera_settings_plugin.py @@ -0,0 +1,57 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_designer import designer_material_icon +from csaxs_bec.bec_widgets.widgets.ids_camera_settings.ids_camera_settings import IDSCameraSettings + +DOM_XML = """ + + + + +""" + + +class IDSCameraSettingsPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = IDSCameraSettings(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(IDSCameraSettings.ICON_NAME) + + def includeFile(self): + return "ids_camera_settings" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "IDSCameraSettings" + + def toolTip(self): + return "Pick a configured IDS camera and adjust its exposure time / pixel clock." + + def whatsThis(self): + return self.toolTip() diff --git a/csaxs_bec/bec_widgets/widgets/ids_camera_settings/register_ids_camera_settings.py b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/register_ids_camera_settings.py new file mode 100644 index 00000000..e044a2f4 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/ids_camera_settings/register_ids_camera_settings.py @@ -0,0 +1,15 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from csaxs_bec.bec_widgets.widgets.ids_camera_settings.ids_camera_settings_plugin import IDSCameraSettingsPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(IDSCameraSettingsPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() 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 b8294780..90e8517f 100644 --- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py +++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py @@ -57,10 +57,15 @@ All state lives on the setup's samples device (not BEC global vars), which is the single source of truth also consulted by the transfer state machine. The widget reads/writes it directly: -FlOMNI, slots 0-20 : sample_placed.sample{N}.set(used) - sample_names.sample{N}.set(name) -FlOMNI, gripper : sample_in_gripper.set(used) - sample_in_gripper_name.set(name) +FlOMNI + writes : set_sample_slot(N, name, owner) / unset_sample_slot(N) + (N = 0–20, 100 = gripper; device methods, all record fields) + reads : one bulk read() of sample_placed / sample_names / + sample_owners / sample_status (gripper: sample_in_gripper*) + +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") @@ -101,7 +106,6 @@ from qtpy.QtWidgets import ( ) from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.ptouch_printer import PTouchLabelPrinter -from csaxs_bec.devices.omny.sample_desc_codec import pack_desc, unpack_desc logger = bec_logger.logger @@ -190,8 +194,7 @@ SETUP_PROFILES: dict[str, dict[str, str]] = { # otransfer_park_slot() rather than imported -- that mixin is a # CLI-session-only plugin (module-level `dev`/`bec` builtins, OMNYTools # dependency) that would be a heavier and more fragile coupling to pull into -# this widget's gui-server process, exactly like _MeasuredSampleLog below is -# deliberately duplicated rather than imported from flomni.py. Slot 1 has no +# this widget's gui-server process. Slot 1 has no # drivable transfer target and is never "active". _OPARKZ_TARGET_POSITIONS: dict[int, float] = { 2: -0.9 - 29 * 3 - 42 - 0.05, @@ -249,54 +252,6 @@ def shuttle_status( return {"parked_at": parked_at, "active": parked_at is not None and parked_at == active_slot} -# ── measured-sample log ────────────────────────────────────────────────────── - - -class _MeasuredSampleLog: - """Tracks which flomni samples have been measured, for the P-touch - label-printing feature. FlOMNI-only -- OMNY has no equivalent yet. - - Deliberately duplicated here rather than imported from - ``flomni.py``'s ``_MeasuredSampleLog`` -- pulling in that whole plugin - module (CLI-only dependencies, module-level ``bec``/``dev`` builtins - access) into this widget's gui-server process is a heavier and more - fragile coupling than re-declaring this ~15-line global-var wrapper, - and this widget already duplicates ``flomni_modify_storage_non_interactive()`` - the same way (see module docstring). Both copies read/write the same - ``flomni_measured_samples`` global-var key, so they stay consistent in - effect despite being independent code, exactly like the existing - ``_write_slot``/``flomni_modify_storage_non_interactive`` duplication. - """ - - _GLOBAL_VAR_KEY = "flomni_measured_samples" - - def __init__(self, client): - self._client = client - - def _load(self) -> dict: - val = self._client.get_global_var(self._GLOBAL_VAR_KEY) - return val if val is not None else {} - - def _save(self, data: dict) -> None: - self._client.set_global_var(self._GLOBAL_VAR_KEY, data) - - def get(self, sample_key: str) -> Optional[dict]: - return self._load().get(sample_key) - - def snapshot(self) -> dict: - """One bulk fetch of the whole log, for callers (like the poll - loop below) that need to look up many sample keys at once without - one redis round-trip per key.""" - return self._load() - - def pop(self, sample_key: str) -> Optional[dict]: - data = self._load() - record = data.pop(sample_key, None) - if record is not None: - self._save(data) - return record - - # ── slot cell widget ───────────────────────────────────────────────────────── @@ -381,7 +336,7 @@ class _SlotCell(QFrame): ) -> None: """Update the cell's displayed state (called by the poll/refresh). - `measured_status` is "started"/"completed" (from `_MeasuredSampleLog`) + `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 @@ -521,7 +476,6 @@ class OMNY_SampleStorage(BECWidget, QWidget): # change-detection in refresh() self._last_state: dict[Any, tuple[bool, str, str, Optional[str]]] = {} self._shuttle_status_labels: dict[str, _ShuttleStatusLabel] = {} - self.measured_log = _MeasuredSampleLog(self.client) self.label_printer = PTouchLabelPrinter( host=PTOUCH_PRINTER_HOST, template_number=PTOUCH_TEMPLATE_NUMBER ) @@ -569,9 +523,8 @@ class OMNY_SampleStorage(BECWidget, QWidget): def _read_all_slots_flomni(self) -> dict[int, tuple[bool, str, str, Optional[str]]]: """Return {slot: (occupied, name, owner, measured_status)} for every slot 0–20 and the gripper from a SINGLE bulk - ``dev.flomni_samples.read()`` round-trip plus a single bulk - ``_MeasuredSampleLog.snapshot()`` (one redis round-trip, not one - per occupied slot). + ``dev.flomni_samples.read()`` round-trip (name, owner and + measurement status are separate IOC fields since 2026-10-17). The per-slot accessors (``is_sample_slot_used`` + ``sample_names.sample{N}.get()``) would be ~44 blocking device @@ -582,8 +535,7 @@ class OMNY_SampleStorage(BECWidget, QWidget): ``flomni_samples_sample_names_sample{N}`` / ``flomni_samples_sample_in_gripper`` / ``flomni_samples_sample_in_gripper_name``), the same keys the CLI - iterated over in ftransfer_sample_change. The raw name signal packs - name+owner (see ``sample_desc_codec``), unpacked here. + iterated over in ftransfer_sample_change. Every underlying signal is an ``auto_monitor=True`` ``EpicsSignal`` (see ``FlomniSampleStorage``), so BEC already keeps a fresh cached @@ -599,40 +551,31 @@ class OMNY_SampleStorage(BECWidget, QWidget): # keep whatever is currently displayed rather than blanking out return {} - try: - measured = self.measured_log.snapshot() - except Exception as exc: - logger.warning(f"OMNY_SampleStorage: measured-log read failed: {exc}") - measured = {} - def _val(key, default=None): entry = data.get(key) if isinstance(entry, dict): return entry.get("value", default) return default - def _measured_status(used: bool, name: str, owner: str) -> Optional[str]: - if not used: - return None - record = measured.get(pack_desc(name, owner)) - return record["status"] if record else None + def _status(used: bool, raw) -> Optional[str]: + status = str(raw) if raw is not None else "" + return status if used and status in ("started", "completed") else None for slot in (STAGE_SLOT, *STORAGE_SLOTS): - used = _val(f"flomni_samples_sample_placed_sample{slot}", 0) - raw_name = _val(f"flomni_samples_sample_names_sample{slot}", EMPTY_NAME) - name, owner = unpack_desc(raw_name if raw_name else EMPTY_NAME) - name = name if name else EMPTY_NAME - result[slot] = (bool(used), name, owner, _measured_status(bool(used), name, owner)) + used = bool(_val(f"flomni_samples_sample_placed_sample{slot}", 0)) + name = _val(f"flomni_samples_sample_names_sample{slot}", EMPTY_NAME) or EMPTY_NAME + owner = _val(f"flomni_samples_sample_owners_sample{slot}", "") or "" + status = _status(used, _val(f"flomni_samples_sample_status_sample{slot}")) + result[slot] = (used, str(name), str(owner), status) - g_used = _val("flomni_samples_sample_in_gripper", 0) - g_raw_name = _val("flomni_samples_sample_in_gripper_name", EMPTY_NAME) - g_name, g_owner = unpack_desc(g_raw_name if g_raw_name else EMPTY_NAME) - g_name = g_name if g_name else EMPTY_NAME + g_used = bool(_val("flomni_samples_sample_in_gripper", 0)) + g_name = _val("flomni_samples_sample_in_gripper_name", EMPTY_NAME) or EMPTY_NAME + g_owner = _val("flomni_samples_sample_in_gripper_owner", "") or "" result[GRIPPER_SLOT] = ( - bool(g_used), - g_name, - g_owner, - _measured_status(bool(g_used), g_name, g_owner), + g_used, + str(g_name), + str(g_owner), + _status(g_used, _val("flomni_samples_sample_in_gripper_status")), ) return result @@ -763,39 +706,32 @@ class OMNY_SampleStorage(BECWidget, QWidget): ``Flomni.flomni_modify_storage_non_interactive()``. """ try: - if slot == GRIPPER_SLOT: - was_used = bool(self._samples.sample_in_gripper.get()) - prev_name, prev_owner = unpack_desc(str(self._samples.sample_in_gripper_name.get())) - else: - was_used = self._samples.is_sample_slot_used(slot) - prev_name, prev_owner = self._samples.get_sample_name_and_owner(slot) + record = self._samples.get_sample_record(slot) except Exception as exc: logger.warning( f"OMNY_SampleStorage: could not read previous state of slot {slot} for the" f" measured-label check: {exc}" ) return - if not was_used: - return - record = self.measured_log.pop(pack_desc(prev_name, prev_owner)) - if record is None: + 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 ({record['status']}) on" - f" {record['measured_date']}. Print a label?", + f" {record['meas_date']}. Print a label?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return fields = { - PTOUCH_FIELD_ACCOUNT: record["account"], - PTOUCH_FIELD_DATE: record["measured_date"], + PTOUCH_FIELD_ACCOUNT: record["owner"], + PTOUCH_FIELD_DATE: record["meas_date"], PTOUCH_FIELD_SAMPLENAME: prev_name, } - # Retry loop instead of a single attempt: the record was already - # popped from measured_log above, so if we gave up after one + # Retry loop instead of a single attempt: the slot record is + # overwritten right after this offer, so if we gave up after one # failure the label's account/date/name would be gone for good # even though the operator could just switch the printer on and # try again a moment later. Mirrors the CLI's ensure_ready() retry @@ -894,13 +830,12 @@ class OMNY_SampleStorage(BECWidget, QWidget): """ self._offer_print_label_for_previous_occupant(slot) try: - packed_name = pack_desc(name, owner) - if slot == GRIPPER_SLOT: - self._samples.sample_in_gripper.set(used) - self._samples.sample_in_gripper_name.set(packed_name) + # device methods write the whole record (slot 100 = gripper); a new or + # renamed sample starts with measurement status "none" + if used: + self._samples.set_sample_slot(slot, name, owner=owner) else: - getattr(self._samples.sample_placed, f"sample{slot}").set(used) - getattr(self._samples.sample_names, f"sample{slot}").set(packed_name) + self._samples.unset_sample_slot(slot) # drop the cached state for this slot so the refresh() right after # a write always repaints it, without waiting for the value to # differ from a possibly-stale cache entry @@ -1576,9 +1511,9 @@ class OMNY_SampleStorage(BECWidget, QWidget): marker = "" if measured_status == "completed": - marker = "✓ measured" + marker = "measured" elif measured_status == "started": - marker = "◐ started" + marker = "started" pdf.set_xy(x + 1, y + h - 6) pdf.set_font("Helvetica", "", 7) pdf.cell(w - 2, 4, marker, align="C") 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 4df22863..7bc355c2 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -21,7 +21,9 @@ Two independent "something is running" signals gate parameter submission and drive an advisory banner: * ``tomo_progress["heartbeat"]`` — set by ``_tomo_scan_at_angle()`` at every - projection, whether the scan was queue- or CLI-started. + projection, whether the scan was queue- or CLI-started. For tomo_type 4, + also refreshed while a scan is waiting at the end of an incomplete angle + file (no projection in progress, but not idle either). * the BEC scan queue — catches a single ptycho projection (or any other scan) run manually outside a tomo run, which writes no heartbeat. The busy logic mirrors bec_widgets' ``BECQueue`` widget so it stays consistent with the @@ -43,6 +45,7 @@ from __future__ import annotations import copy import datetime +import os import uuid from typing import Any, Optional @@ -65,6 +68,7 @@ from qtpy.QtWidgets import ( QLabel, QLineEdit, QListWidget, + QFileDialog, QMessageBox, QPushButton, QScrollArea, @@ -79,39 +83,86 @@ logger = bec_logger.logger # ── constants ──────────────────────────────────────────────────────────────── -# Mirrors _update_type_visibility()'s section/field gating (_sec_type1 shown -# for every equally spaced sub-tomogram type -- 1 for flomni/lamni, 1/4/5 for -# OMNY --, golden_ratio_bunch_size shown only for type 2 within _sec_type23, -# the rest of _sec_type23 shown for type 2/3) -- reused by _job_tooltip() so -# a queued job's hover details don't show fields that are meaningless for -# its own tomo_type (e.g. golden-ratio settings on a type-1 job). -# tomo_angle_range is flomni-only regardless (OMNY has no such property, so -# it's simply never present in an OMNY job's params to begin with). -_TYPE1_ONLY_PARAMS = {"tomo_angle_range", "zero_deg_reference_at_each_subtomo"} +# Mirrors _update_type_visibility()'s section/field gating -- reused by _job_tooltip() so a +# queued job's hover details don't show fields that are meaningless for its own tomo_type +# (e.g. golden-ratio settings on a type-1 job). Which types are "equally spaced sub-tomograms" +# (_sec_type1) or "angles from file" (_sec_type4) is set per setup in SETUP_PROFILES[...] +# ["tomo_types"]: flomni/lamni 1 = sub-tomograms, 4 = angle file; OMNY 1/4/5 = 2/4/8 +# sub-tomograms. tomo_angle_range is flomni-only (OMNY jobs never carry it). +_TYPE1_ONLY_PARAMS = { + "tomo_angle_range", + "zero_deg_reference_at_each_subtomo", + "tomo_type1_always_forward", +} _TYPE2_ONLY_PARAMS = {"golden_ratio_bunch_size"} _TYPE23_PARAMS = { "golden_max_number_of_projections", "golden_projections_at_0_deg_for_damage_estimation", } +_TYPE4_ONLY_PARAMS = {"tomo_angle_file"} -def _irrelevant_params_for_type(tomo_type: Any) -> set[str]: +def _is_angle_file_type(profile: dict, tomo_type: Any) -> bool: + """True if `tomo_type` is this setup's "angles from file" type (flomni/lamni: 4; + OMNY has none -- its type 4 is "4 equally spaced sub-tomograms").""" + return bool(profile["tomo_types"].get(tomo_type, {}).get("angle_file")) + + +def _irrelevant_params_for_type(tomo_type: Any, profile: Optional[dict] = None) -> set[str]: """Param names that don't apply to a job's own tomo_type and should be - hidden from its tooltip.""" + hidden from its tooltip. With the setup's `profile` the sub-tomogram and + angle-file types come from its "tomo_types" (OMNY's 4 means 4 sub-tomograms, + flomni/lamni's 4 means angles from file); without it a setup-neutral guess.""" irrelevant: set[str] = set() - # tomo_type 1 for flomni/lamni; 1/4/5 (all equally spaced sub-tomogram - # flavors) for OMNY -- 4/5 are OMNY-only tomo_type values (flomni/lamni - # jobs never have them), so this is safe without knowing which setup a - # given job's params belong to. - if tomo_type not in (1, 4, 5): + if profile is not None: + info = profile["tomo_types"].get(tomo_type, {}) + subtomo_type = info.get("n_subtomos") is not None + angle_file_type = bool(info.get("angle_file")) + else: + subtomo_type = tomo_type in (1, 5) + angle_file_type = tomo_type == 4 + if not subtomo_type: irrelevant |= _TYPE1_ONLY_PARAMS if tomo_type not in (2, 3): irrelevant |= _TYPE23_PARAMS if tomo_type != 2: irrelevant |= _TYPE2_ONLY_PARAMS + if not angle_file_type: + irrelevant |= _TYPE4_ONLY_PARAMS return irrelevant +def _angle_file_status( + path: str, opposite_warning: bool = True +) -> tuple[Optional[str], Optional[str]]: + """(summary, error) for a tomo_type-4 angle file -- exactly one is None. + The summary includes the 180-degree-pair warning, if any (only when + ``opposite_warning`` -- off for laminography), and a note if + the file has no 'END' marker yet (not an error: a scan started against + an incomplete file waits for more lines to be appended rather than + failing).""" + from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.angle_file import ( + AngleFileError, + describe_opposite_pairs, + describe_summary, + read_angle_file, + summarize_angles, + ) + + try: + result = read_angle_file(path) + except AngleFileError as exc: + return None, str(exc) + summary = summarize_angles(result.angles) + text = describe_summary(summary) + warning = describe_opposite_pairs(summary) if opposite_warning else None + if warning: + text += f"\n⚠ {warning}" + if not result.is_complete: + text += "\n⚠ No 'END' marker yet -- a scan will wait for more angles once started." + return text, None + + STATUS_COLORS = { "pending": "#888888", "running": "#2196F3", @@ -127,12 +178,14 @@ _QUEUE_IDLE_STATUSES = ("STOPPED", "COMPLETED", "IDLE") QUEUE_PARAM_NAMES = ( "tomo_countingtime", "tomo_shellstep", + "fermat_asymmetry", "fovx", "fovy", "stitch_x", "stitch_y", "tomo_stitch_overlap", "ptycho_reconstruct_foldername", + "manual_shift_x", "manual_shift_y", "frames_per_trigger", "single_point_instead_of_fermat_scan", @@ -144,6 +197,8 @@ QUEUE_PARAM_NAMES = ( "golden_max_number_of_projections", "golden_projections_at_0_deg_for_damage_estimation", "zero_deg_reference_at_each_subtomo", + "tomo_type1_always_forward", + "tomo_angle_file", "corridor_size", "at_each_angle_hook", ) @@ -151,12 +206,14 @@ QUEUE_PARAM_NAMES = ( DEFAULTS: dict[str, Any] = { "tomo_countingtime": 0.1, "tomo_shellstep": 1.0, + "fermat_asymmetry": 1.0, "fovx": 20.0, "fovy": 20.0, "stitch_x": 0, "stitch_y": 0, "tomo_stitch_overlap": 0.2, "ptycho_reconstruct_foldername": "ptycho_reconstruct", + "manual_shift_x": 0.0, "manual_shift_y": 0.0, "frames_per_trigger": 1, "single_point_instead_of_fermat_scan": False, @@ -166,16 +223,20 @@ DEFAULTS: dict[str, Any] = { "tomo_angle_range": 180, "tomo_angle_stepsize": 10.0, "zero_deg_reference_at_each_subtomo": False, + "tomo_type1_always_forward": False, "golden_ratio_bunch_size": 20, "golden_max_number_of_projections": 1000.0, "golden_projections_at_0_deg_for_damage_estimation": 0, + "tomo_angle_file": "", "at_each_angle_hook": None, } # Exact tuple from LamNI._TOMO_SCAN_PARAM_NAMES (source of truth in lamni.py). # No tomo_angle_range/single_point_* -- lamni is laminography: always 360 # degrees, no single-point acquisition. lamni_piezo_range_x/y have no flomni -# equivalent; manual_shift_x is lamni-only (flomni only exposes shift_y). +# equivalent. manual_shift_x now exists on both setups, but with a narrower +# +/-10um range on flomni vs. lamni's own bounds (see manual_shift_fields +# below). # zero_deg_reference_at_each_subtomo (tomo_type 1 only) mirrors flomni's -- # ported to lamni as bool(subtomo_number % 2) (every odd sub-tomogram), # confirmed against lamni's own hardware behavior -- not flomni's @@ -195,6 +256,7 @@ LAMNI_QUEUE_PARAM_NAMES = ( "lamni_piezo_range_y", "frames_per_trigger", "tomo_type", + "tomo_angle_file", "tomo_angle_stepsize", "golden_ratio_bunch_size", "golden_max_number_of_projections", @@ -219,6 +281,7 @@ LAMNI_DEFAULTS: dict[str, Any] = { "lamni_piezo_range_y": 20, "frames_per_trigger": 1, "tomo_type": 1, + "tomo_angle_file": "", "tomo_angle_stepsize": 10.0, "golden_ratio_bunch_size": 20, "golden_max_number_of_projections": 1000.0, @@ -499,7 +562,7 @@ class OMNY_TomoParams(BECWidget, QWidget): "tomo_countingtime": lambda: self._add_double( common_form, "tomo_countingtime", - "Counting time (s)", + "Counting time per frame (s)", min_=0.001, max_=100.0, decimals=3, @@ -508,10 +571,11 @@ class OMNY_TomoParams(BECWidget, QWidget): common_form, "tomo_shellstep", "Shell step (µm)", - min_=0.001, - max_=1000.0, + min_=0.025, + max_=20.0, decimals=3, ), + "fermat_asymmetry": lambda: self._build_fermat_asymmetry_row(common_form), "fov": lambda: self._build_double_group(common_form, self._profile["fov_fields"]), "stitch": lambda: self._build_int_group(common_form, self._profile["stitch_fields"]), "tomo_stitch_overlap": lambda: self._add_double( @@ -519,14 +583,16 @@ class OMNY_TomoParams(BECWidget, QWidget): "tomo_stitch_overlap", "Stitch overlap (µm)", min_=0.0, - max_=50.0, + max_=10.0, decimals=3, ), "ptycho_reconstruct_foldername": lambda: self._add_text( common_form, "ptycho_reconstruct_foldername", "Reconstruct folder" ), "manual_shift": lambda: self._build_double_group( - common_form, self._profile["manual_shift_fields"] + common_form, + self._profile["manual_shift_fields"], + help_texts=self._profile.get("manual_shift_help"), ), "piezo_range": lambda: self._build_double_group( common_form, self._profile["piezo_range_fields"] @@ -561,13 +627,25 @@ class OMNY_TomoParams(BECWidget, QWidget): self._sec_type23 = self._build_type23_section() vbox.addWidget(self._sec_type23) + # type-4 section (angles from file) -- only for setups supporting it + self._sec_type4 = None + if any(info.get("angle_file") for info in self._profile["tomo_types"].values()): + self._sec_type4 = self._build_type4_section() + vbox.addWidget(self._sec_type4) + return box def _build_double_group( - self, form: QFormLayout, fields: list[tuple[str, str, float, float, int]] + self, + form: QFormLayout, + fields: list[tuple[str, str, float, float, int]], + help_texts: dict[str, str] | None = None, ) -> None: for key, label, min_, max_, decimals in fields: - self._add_double(form, key, label, min_=min_, max_=max_, decimals=decimals) + help_text = help_texts.get(key) if help_texts else None + self._add_double( + form, key, label, min_=min_, max_=max_, decimals=decimals, help_text=help_text + ) def _build_int_group(self, form: QFormLayout, fields: list[tuple[str, str, int, int]]) -> None: for key, label, min_, max_ in fields: @@ -618,7 +696,9 @@ class OMNY_TomoParams(BECWidget, QWidget): form.addRow(header_row) for key, label, min_, max_, decimals, _axis in self._profile["offset_fields"]: self._add_double(form, key, label, min_=min_, max_=max_, decimals=decimals) - self._build_double_group(form, self._profile["manual_shift_fields"]) + self._build_double_group( + form, self._profile["manual_shift_fields"], help_texts=self._profile.get("manual_shift_help") + ) def _show_offsets_help(self) -> None: QMessageBox.information( @@ -630,6 +710,39 @@ class OMNY_TomoParams(BECWidget, QWidget): "constant shift that will not rotate.", ) + def _build_fermat_asymmetry_row(self, form: QFormLayout) -> None: + """Fermat asymmetry (x/y spacing ratio) field, with a "?" help button + next to it -- same pattern as the offsets section header + (_build_offsets_section/_show_offsets_help above).""" + from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan + + row = QHBoxLayout() + w = QDoubleSpinBox() + w.setDecimals(2) + w.setRange(FlomniFermatScan.MIN_FERMAT_ASYMMETRY, FlomniFermatScan.MAX_FERMAT_ASYMMETRY) + w.setEnabled(False) + self._pw["fermat_asymmetry"] = w + row.addWidget(w, stretch=1) + btn_help = QPushButton("?") + btn_help.setFixedWidth(24) + btn_help.setToolTip("What does Fermat asymmetry do?") + btn_help.clicked.connect(self._show_fermat_asymmetry_help) + row.addWidget(btn_help) + form.addRow("Fermat asymmetry (x/y):", row) + + def _show_fermat_asymmetry_help(self) -> None: + QMessageBox.information( + self, + "Fermat asymmetry", + "Ratio of the Fermat spiral's x spacing to its y spacing (x/y). 1 = " + "symmetric (default). x spacing always stays fixed at the shell step; " + "y spacing scales by this ratio -- values above 1 widen x spacing " + "relative to y (denser sampling in y), values below 1 do the " + "opposite. The requested field of view is preserved either way, so " + "the total number of scan points changes with the ratio (see the " + "'Estimated Fermat scan points' preview below).", + ) + def _build_at_each_angle_hook_row(self, form: QFormLayout) -> None: self._pw["at_each_angle_hook"] = QComboBox() self._pw["at_each_angle_hook"].setEnabled(False) @@ -660,6 +773,9 @@ class OMNY_TomoParams(BECWidget, QWidget): self._pw["tomo_angle_range"].currentIndexChanged.connect( self._update_projection_preview ) + self._pw["tomo_angle_range"].currentIndexChanged.connect( + self._update_always_forward_enabled + ) form.addRow("Angle range:", self._pw["tomo_angle_range"]) # read-only: how many sub-tomograms the currently selected type uses @@ -684,6 +800,8 @@ class OMNY_TomoParams(BECWidget, QWidget): if self._profile["has_zero_deg_reference"]: self._add_bool(form, "zero_deg_reference_at_each_subtomo", "0° reference each sub-tomo") + if self._profile.get("has_always_forward"): + self._add_bool(form, "tomo_type1_always_forward", "Run all sub-tomos forward") return box def _build_type23_section(self) -> QGroupBox: @@ -694,8 +812,16 @@ class OMNY_TomoParams(BECWidget, QWidget): self._add_int( form, "_numprj_type3", "Projections per sub-tomo (type 3)", min_=1, max_=10000 ) + # per-setup floor (SETUP_PROFILES[...]["golden_bunch_size_min"]): lamni's + # golden_ratio_bunch_size setter/CLI reject < 20, so a GUI-legal + # smaller value would raise ValueError in tomo_queue_execute() (which + # setattr()s every job param) and pause the queue. self._add_int( - form, "golden_ratio_bunch_size", "Bunch size (type 2 only)", min_=1, max_=10000 + form, + "golden_ratio_bunch_size", + "Bunch size (type 2 only)", + min_=self._profile.get("golden_bunch_size_min", 1), + max_=100, ) self._add_double( form, @@ -712,6 +838,87 @@ class OMNY_TomoParams(BECWidget, QWidget): ) return box + def _build_type4_section(self) -> QGroupBox: + box = QGroupBox("Type 4 – angles from file") + form = QFormLayout(box) + form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) + + # tomo_angle_file lives in self._pw like any other param, so the + # generic _populate_fields()/_read_fields() handle it; the Browse + # button is enabled/disabled alongside it in _set_fields_enabled(). + file_row = QHBoxLayout() + self._pw["tomo_angle_file"] = QLineEdit() + self._pw["tomo_angle_file"].setEnabled(False) + self._pw["tomo_angle_file"].setPlaceholderText("path to angle file") + self._pw["tomo_angle_file"].setToolTip( + "Plain text, one angle (-2 to 362 deg) per line, optional 2nd column = " + "sub-tomogram number (default 1), '#' starts a comment. Acquired in " + "file order. Only the path is stored: the file is re-read at every " + "scan start, resume, and when a queued job runs." + ) + self._pw["tomo_angle_file"].textChanged.connect(self._update_angle_file_preview) + file_row.addWidget(self._pw["tomo_angle_file"], stretch=1) + self._btn_browse_angle_file = QPushButton("Browse…") + self._btn_browse_angle_file.setEnabled(False) + self._btn_browse_angle_file.clicked.connect(self._browse_angle_file) + file_row.addWidget(self._btn_browse_angle_file) + form.addRow("Angle file:", file_row) + + self._lbl_angle_file_summary = QLabel("---") + self._lbl_angle_file_summary.setWordWrap(True) + form.addRow("Content:", self._lbl_angle_file_summary) + return box + + def _browse_angle_file(self) -> None: + current = self._pw["tomo_angle_file"].text().strip() + start_dir = os.path.dirname(os.path.expanduser(current)) if current else "" + if not start_dir or not os.path.isdir(start_dir): + start_dir = os.path.expanduser("~") + path, _ = QFileDialog.getOpenFileName( + self, + "Select tomography angle file", + start_dir, + "Angle files (*.txt *.dat *.csv);;All files (*)", + ) + if path: + self._pw["tomo_angle_file"].setText(path) + + def _set_angle_file_summary(self, text: str, stylesheet: str) -> None: + """Set _lbl_angle_file_summary's text/style, and recompute its + minimum height from heightForWidth() at its current width. + + QFormLayout sizes a row from the field widget's sizeHint() at + construction time (when the label still holds its short initial + placeholder); it doesn't reliably recompute that row's height later + when setText() swaps in longer, word-wrapped content -- a known Qt + quirk. Without this, multi-line content (e.g. a summary with a + 180-deg-pair or missing-END warning) gets visually clipped instead + of wrapping into a taller row.""" + self._lbl_angle_file_summary.setText(text) + self._lbl_angle_file_summary.setStyleSheet(stylesheet) + w = self._lbl_angle_file_summary.width() + if w > 0: + self._lbl_angle_file_summary.setMinimumHeight( + self._lbl_angle_file_summary.heightForWidth(w) + ) + + def _update_angle_file_preview(self) -> None: + if self._sec_type4 is None: + return + path = self._pw["tomo_angle_file"].text().strip() + if not path: + self._set_angle_file_summary("(no file set)", "color: orange;") + return + summary, error = _angle_file_status( + path, self._profile.get("angle_file_opposite_warning", True) + ) + if error: + self._set_angle_file_summary(error, "color: red;") + else: + self._set_angle_file_summary( + summary, "color: orange;" if "⚠" in summary else "" + ) + def _build_edit_buttons(self) -> QHBoxLayout: layout = QHBoxLayout() self._btn_edit = QPushButton("Edit") @@ -739,14 +946,36 @@ class OMNY_TomoParams(BECWidget, QWidget): # ── widget factory helpers ──────────────────────────────────────────────── def _add_double( - self, form: QFormLayout, key: str, label: str, min_: float, max_: float, decimals: int + self, + form: QFormLayout, + key: str, + label: str, + min_: float, + max_: float, + decimals: int, + help_text: str | None = None, ) -> QDoubleSpinBox: w = QDoubleSpinBox() w.setDecimals(decimals) w.setRange(min_, max_) w.setEnabled(False) self._pw[key] = w - form.addRow(f"{label}:", w) + if help_text is None: + form.addRow(f"{label}:", w) + else: + # same "?" help-button pattern as _build_offsets_section/ + # _build_fermat_asymmetry_row -- a per-field popup instead of a + # per-group one. + row = QHBoxLayout() + row.addWidget(w, stretch=1) + btn_help = QPushButton("?") + btn_help.setFixedWidth(24) + btn_help.setToolTip(f"What does {label} do?") + btn_help.clicked.connect( + lambda: QMessageBox.information(self, label, help_text) + ) + row.addWidget(btn_help) + form.addRow(f"{label}:", row) return w def _add_int(self, form: QFormLayout, key: str, label: str, min_: int, max_: int) -> QSpinBox: @@ -886,6 +1115,12 @@ class OMNY_TomoParams(BECWidget, QWidget): if numprj > 0: params["tomo_angle_stepsize"] = self._profile["type1_base_angle"] / numprj + # store angle files as absolute paths, same as the CLI setter + if params.get("tomo_angle_file"): + params["tomo_angle_file"] = os.path.abspath( + os.path.expanduser(params["tomo_angle_file"]) + ) + # single_point forces stitch to 0 if self._profile["has_single_point"] and params.get("single_point_instead_of_fermat_scan"): for key, _label, _min, _max in self._profile["stitch_fields"]: @@ -906,7 +1141,15 @@ class OMNY_TomoParams(BECWidget, QWidget): if not isinstance(fpt, int) or fpt < 1 or isinstance(fpt, bool): return "frames_per_trigger must be a positive integer" if params.get("tomo_type") not in self._profile["tomo_types"]: - return f"tomo_type must be one of {sorted(self._profile['tomo_types'])}" + allowed = ", ".join(str(t) for t in self._profile["tomo_types"]) + return f"tomo_type must be one of {allowed}" + if _is_angle_file_type(self._profile, params.get("tomo_type")): + _summary, error = _angle_file_status( + params.get("tomo_angle_file", ""), + self._profile.get("angle_file_opposite_warning", True), + ) + if error: + return f"Angle file: {error}" if self._profile["has_180_mode"] and params.get("tomo_angle_range") not in (180, 360): return "tomo_angle_range must be 180 or 360" if self._profile["has_single_point"]: @@ -924,6 +1167,21 @@ class OMNY_TomoParams(BECWidget, QWidget): # ── type visibility ─────────────────────────────────────────────────────── + def _set_form_row_visible(self, form_owner: QGroupBox, key: str, visible: bool) -> None: + """Show/hide one self._pw field together with its QFormLayout label.""" + widget = self._pw.get(key) + if widget is None: + return + widget.setVisible(visible) + form = form_owner.layout() + if isinstance(form, QFormLayout): + idx = form.indexOf(widget) + if idx >= 0: + row, _role = form.getItemPosition(idx) + lbl = form.itemAt(row, QFormLayout.ItemRole.LabelRole) + if lbl and lbl.widget(): + lbl.widget().setVisible(visible) + def _update_type_visibility(self, tomo_type: int) -> None: # "Equally spaced sub-tomograms" section: shown for whichever # tomo_type(s) this profile marks as such (flomni/lamni: type 1 @@ -937,30 +1195,13 @@ class OMNY_TomoParams(BECWidget, QWidget): self._lbl_subtomo_count.setText(str(n_subtomos)) self._pw["_requested_total"].setSingleStep(n_subtomos) self._sec_type23.setVisible(tomo_type in (2, 3)) - # _numprj_type3: only visible for type 3 - _w__numprj_type3 = self._pw.get("_numprj_type3") - if _w__numprj_type3 is not None: - _w__numprj_type3.setVisible(tomo_type == 3) - _form = self._sec_type23.layout() - if isinstance(_form, QFormLayout): - _idx = _form.indexOf(_w__numprj_type3) - if _idx >= 0: - _row, _role = _form.getItemPosition(_idx) - _lbl = _form.itemAt(_row, QFormLayout.ItemRole.LabelRole) - if _lbl and _lbl.widget(): - _lbl.widget().setVisible(tomo_type == 3) - # golden_ratio_bunch_size: only visible for type 2 - _w_golden_ratio_bunch_size = self._pw.get("golden_ratio_bunch_size") - if _w_golden_ratio_bunch_size is not None: - _w_golden_ratio_bunch_size.setVisible(tomo_type == 2) - _form = self._sec_type23.layout() - if isinstance(_form, QFormLayout): - _idx = _form.indexOf(_w_golden_ratio_bunch_size) - if _idx >= 0: - _row, _role = _form.getItemPosition(_idx) - _lbl = _form.itemAt(_row, QFormLayout.ItemRole.LabelRole) - if _lbl and _lbl.widget(): - _lbl.widget().setVisible(tomo_type == 2) + self._set_form_row_visible(self._sec_type23, "_numprj_type3", tomo_type == 3) + self._set_form_row_visible(self._sec_type23, "golden_ratio_bunch_size", tomo_type == 2) + if self._sec_type4 is not None: + angle_file_type = bool(type_info.get("angle_file")) + self._sec_type4.setVisible(angle_file_type) + if angle_file_type: + self._update_angle_file_preview() # ── type-1 projection preview ───────────────────────────────────────────── @@ -1174,8 +1415,10 @@ class OMNY_TomoParams(BECWidget, QWidget): def _enter_edit_mode_with(self, params: dict[str, Any]) -> None: self._populate_fields(params) - self._set_fields_enabled(True) + # _edit_mode must be set first: _set_fields_enabled() consults it + # (e.g. for the 'run all sub-tomos forward' checkbox). self._edit_mode = True + self._set_fields_enabled(True) self._btn_edit.setVisible(False) self._btn_submit.setVisible(True) self._btn_add_edited_to_queue.setVisible(True) @@ -1201,9 +1444,10 @@ class OMNY_TomoParams(BECWidget, QWidget): ``_tomo_scan_at_angle()`` at the start of every projection — this is updated whether the scan was started via the queue or directly via ``.tomo_scan()``, so it's more reliable than queue job status. - A heartbeat fresher than 2 minutes indicates a scan in progress; - older than that it's either done, or stuck long enough that editing - parameters is intentional. + For flomni tomo_type 4, it is also refreshed while the scan waits at + the end of an incomplete angle file. A heartbeat fresher than 2 + minutes indicates a scan in progress; older than that it's either + done, or stuck long enough that editing parameters is intentional. """ import datetime @@ -1365,6 +1609,14 @@ class OMNY_TomoParams(BECWidget, QWidget): self, "Added to queue", f"Added '{jobs[-1]['label']}' to the queue." ) + def _update_always_forward_enabled(self, *_args) -> None: + """'Run all sub-tomos forward' only applies to the 180 degree range.""" + cb = self._pw.get("tomo_type1_always_forward") + combo = self._pw.get("tomo_angle_range") + if cb is None or combo is None: + return + cb.setEnabled(self._edit_mode and combo.currentData() == 180) + def _set_fields_enabled(self, enabled: bool) -> None: skip = {"_requested_total"} if not enabled else set() for key, widget in self._pw.items(): @@ -1381,6 +1633,10 @@ class OMNY_TomoParams(BECWidget, QWidget): self._pw["single_point_random_shift_max"].setEnabled(enabled and single_point) # projection requested total only meaningful in edit mode self._pw["_requested_total"].setEnabled(enabled) + if self._sec_type4 is not None: + self._btn_browse_angle_file.setEnabled(enabled) + if enabled: + self._update_always_forward_enabled() # ── queue dialog ────────────────────────────────────────────────────────── @@ -1529,12 +1785,10 @@ class TomoQueueDialog(QDialog): btn_row = QHBoxLayout() self._btn_add = QPushButton("Add current params to queue") self._btn_add_cmd = QPushButton("Add command…") + # Text/tooltip are dual-purpose ("Load into editor" for a tomo job, + # "Edit command" for a pending command job) and set by + # _update_load_button(), called immediately below via _refresh(). self._btn_load = QPushButton("Load into editor") - self._btn_load.setToolTip( - "Load the selected tomo job's saved settings into the params panel's " - "editor -- doesn't touch live params or the queue itself. Review, " - 'tweak, then Submit or "Add to queue" from there.' - ) self._btn_load.setEnabled(False) self._btn_del = QPushButton("Delete selected") self._btn_dup = QPushButton("Duplicate selected") @@ -1556,7 +1810,7 @@ class TomoQueueDialog(QDialog): btn_exe = QPushButton("Execute queue…") self._btn_add.clicked.connect(self.add_to_queue) self._btn_add_cmd.clicked.connect(self.add_command_to_queue) - self._btn_load.clicked.connect(self._on_load_into_editor) + self._btn_load.clicked.connect(self._on_load_or_edit_clicked) self._btn_del.clicked.connect(self._delete_selected) self._btn_dup.clicked.connect(self._duplicate_selected) self._btn_clr.clicked.connect(self._clear_queue) @@ -1609,7 +1863,7 @@ class TomoQueueDialog(QDialog): _fmt_num(params.get("tomo_shellstep")), _format_hook_detail(params, registered_hooks), ] - tooltip = _job_tooltip(job) + tooltip = _job_tooltip(job, self._profile) for col, text in enumerate(cells): item = QTableWidgetItem(text) # Label/Details are now wide, free-text columns -- left-align @@ -1700,10 +1954,57 @@ class TomoQueueDialog(QDialog): job = jobs[row] return job if _job_kind(job) == "tomo" else None + def _selected_single_pending_command_job(self) -> Optional[dict]: + """Return the single selected job if it's a *pending* command job, + else None. Mirrors _selected_single_tomo_job() above. + + Restricted to self._MOVABLE_STATUSES ("pending" only, not + "incomplete") -- the same set already used for "is this row safe + to reorder". An incomplete command job may have partially executed + steps; editing that is a materially different, riskier case than a + never-started job, and not supported here. + """ + rows = sorted({idx.row() for idx in self._table.selectedIndexes()}) + if len(rows) != 1: + return None + jobs = self._load_queue() + row = rows[0] + if not 0 <= row < len(jobs): + return None + job = jobs[row] + if _job_kind(job) != "command": + return None + return job if job.get("status", "pending") in self._MOVABLE_STATUSES else None + def _update_load_button(self) -> None: - self._btn_load.setEnabled( - not self._sort_mode and self._selected_single_tomo_job() is not None - ) + """self._btn_load is dual-purpose: "Load into editor" for a single + selected tomo job (any status, unchanged behavior), or "Edit + command" for a single selected *pending* command job (new -- see + _on_load_or_edit_clicked()). Anything else disables it. + """ + if self._sort_mode: + self._btn_load.setText("Load into editor") + self._btn_load.setEnabled(False) + return + if self._selected_single_tomo_job() is not None: + self._btn_load.setText("Load into editor") + self._btn_load.setToolTip( + "Load the selected tomo job's saved settings into the params panel's " + "editor -- doesn't touch live params or the queue itself. Review, " + 'tweak, then Submit or "Add to queue" from there.' + ) + self._btn_load.setEnabled(True) + elif self._selected_single_pending_command_job() is not None: + self._btn_load.setText("Edit command") + self._btn_load.setToolTip( + "Open the selected pending command job's steps in the command " + "builder for review/editing. OK replaces this queued job in " + "place (same position); Cancel leaves it untouched." + ) + self._btn_load.setEnabled(True) + else: + self._btn_load.setText("Load into editor") + self._btn_load.setEnabled(False) def _update_duplicate_button(self) -> None: # Unlike Load-into-editor, duplication works for a job of any kind @@ -1711,15 +2012,38 @@ class TomoQueueDialog(QDialog): # "exactly one row selected" is the only requirement. self._btn_dup.setEnabled(not self._sort_mode and self._selected_single_row() >= 0) - def _on_load_into_editor(self) -> None: - job = self._selected_single_tomo_job() - if job is None: + def _on_load_or_edit_clicked(self) -> None: + """Dispatches self._btn_load's two behaviors -- see + _update_load_button()'s docstring. Re-derives the selection fresh + rather than trusting anything cached since the last ~2s poll. + """ + tomo_job = self._selected_single_tomo_job() + if tomo_job is not None: + parent = self.parent() + if parent is None or not hasattr(parent, "load_params_into_editor"): + QMessageBox.warning(self, "Can't load", "No params panel available to load into.") + return + parent.load_params_into_editor(dict(tomo_job.get("params", {})), tomo_job.get("label", "")) return - parent = self.parent() - if parent is None or not hasattr(parent, "load_params_into_editor"): - QMessageBox.warning(self, "Can't load", "No params panel available to load into.") + + command_job = self._selected_single_pending_command_job() + if command_job is None: return - parent.load_params_into_editor(dict(job.get("params", {})), job.get("label", "")) + row = sorted({idx.row() for idx in self._table.selectedIndexes()})[0] + dlg = CommandJobBuilderDialog(self._client, parent=self, job=command_job) + if dlg.exec() == QDialog.DialogCode.Accepted and dlg.job is not None: + jobs = self._load_queue() + if 0 <= row < len(jobs) and jobs[row].get("id") == command_job.get("id"): + jobs[row] = dlg.job + self._save_queue(jobs) + self._refresh() + else: + QMessageBox.warning( + self, + "Queue changed", + "The queue changed while editing this job -- nothing was " + "replaced. Re-select the job and try again.", + ) # ── sort mode ───────────────────────────────────────────────────────────── @@ -1943,12 +2267,20 @@ class CommandJobBuilderDialog(QDialog): assembled ``steps`` list) so CLI and GUI stay interchangeable. The caller is responsible for appending it to the queue -- this dialog only builds the dict, it never touches the queue global var itself. + + Pass an existing command ``job`` dict to pre-populate the builder for + editing (``TomoQueueDialog._on_load_or_edit_clicked()``'s "Edit + command" path) instead of starting empty. ``self.job`` on accept then + keeps that job's original ``id``/``added_at`` (same queue entry being + revised, not a new addition) -- the caller still owns writing it back + into the queue at the right position. """ - def __init__(self, client, parent=None): + def __init__(self, client, parent=None, job: Optional[dict] = None): super().__init__(parent) self._client = client - self.setWindowTitle("Add command job") + self._editing_job = job + self.setWindowTitle("Edit command job" if job is not None else "Add command job") self.setMinimumSize(520, 480) self.job: Optional[dict] = None @@ -1971,6 +2303,28 @@ class CommandJobBuilderDialog(QDialog): "can't be built without it.", ) + if job is not None: + self._populate_from_job(job) + + def _populate_from_job(self, job: dict) -> None: + """Pre-fill the builder from an existing command job (the "Edit + command" path). Steps are re-added in the same {action, kwargs} + shape _on_add_step() produces, using its exact summary format, so + pre-populated rows render identically to freshly-added ones. + """ + self._label_edit.setText(job.get("label", "")) + for step in job.get("steps", []): + action_name = step.get("action") + kwargs = step.get("kwargs", {}) + self._steps.append({"action": action_name, "kwargs": kwargs}) + summary = f"{action_name}{kwargs}" if kwargs else action_name + self._step_list.addItem(summary) + # The job's actual saved flag, not a recomputed default -- mark + # overridden so _recompute_idempotent_default() leaves it alone as + # the operator edits steps. + self._idem_check.setChecked(bool(job.get("idempotent", False))) + self._idem_user_overridden = True + def _build_ui(self): vbox = QVBoxLayout(self) @@ -2049,12 +2403,78 @@ class CommandJobBuilderDialog(QDialog): label = f"{label} ({unit})" self._field_form.addRow(f"{label}:", widget) + if action_name == "move_foptz_with_shift" and "foptz_target" in self._step_field_widgets: + self._build_osa_collision_warning() + + def _build_osa_collision_warning(self) -> None: + """Live, advisory-only OSA-collision warning for + move_foptz_with_shift's foptz_target field. + + Checked against fosaz's *defined* IN position (its "in" + user_parameter), not wherever fosaz happens to be reading right + now -- fosaz is normally parked safely OUT except when actually in + use, so checking the live position would almost never warn. The + real question is "if the OSA were swapped in at its nominal + position while foptz is at this target, would it collide", which + is also exactly what FlomniOpticsMixin.fosa_in() itself checks + (and refuses on) for fosaz moves -- this surfaces the same check + here, at data-entry time, for foptz instead. + + The move itself stays a plain absolute move (see + _queue_action_move_foptz_with_shift's docstring) -- this is + advisory only, the queue action itself doesn't block on it. + """ + from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_optics_mixin import ( + compute_osa_remaining_space, + ) + + lbl = QLabel() + lbl.setWordWrap(True) + lbl.setStyleSheet("color: #B8860B;") + lbl.setVisible(False) + self._field_form.addRow("", lbl) + + foptz_widget, _field = self._step_field_widgets["foptz_target"] + + def _update(_value=None): + fosaz = self._client.device_manager.devices.get("fosaz") + if fosaz is None: + return + try: + fosaz_in = (fosaz.user_parameter or {}).get("in") + except Exception: + return + if fosaz_in is None: + return + clearance = compute_osa_remaining_space(foptz_widget.value(), fosaz_in) + if clearance <= 0: + lbl.setText( + f"Warning: at this foptz target, OSA clearance at its defined " + f"IN position ({fosaz_in:.2f} mm) would be {clearance:.2f} mm " + "(<=0 = collision risk with a normal OMNY pin)." + ) + lbl.setVisible(True) + else: + lbl.setVisible(False) + + foptz_widget.valueChanged.connect(_update) + _update() + def _build_field_widget(self, field: dict) -> QWidget: field_type = field.get("type") if field_type == "float": w = QDoubleSpinBox() w.setDecimals(4) - w.setRange(field.get("min", -1e9), field.get("max", 1e9)) + lo, hi = field.get("min", -1e9), field.get("max", 1e9) + device_name = field.get("device_limits_from") + if device_name is not None: + device = self._client.device_manager.devices.get(device_name) + if device is not None: + try: + lo, hi = device.limits + except Exception: + pass # fall back to the static schema min/max above + w.setRange(lo, hi) w.setValue(field.get("default", 0.0)) return w if field_type == "device_positions": @@ -2152,14 +2572,22 @@ class CommandJobBuilderDialog(QDialog): QMessageBox.warning(self, "No steps", "Add at least one step before confirming.") return label = self._label_edit.text().strip() or f"cmd_{uuid.uuid4().hex[:6]}" + # Editing an existing job keeps its id/added_at -- it's the same + # queue entry being revised, not a new addition. + job_id = self._editing_job["id"] if self._editing_job else uuid.uuid4().hex + added_at = ( + self._editing_job["added_at"] + if self._editing_job + else datetime.datetime.now().isoformat(timespec="seconds") + ) self.job = { "kind": "command", - "id": uuid.uuid4().hex, + "id": job_id, "label": label, "steps": list(self._steps), "idempotent": self._idem_check.isChecked(), "status": "pending", - "added_at": datetime.datetime.now().isoformat(timespec="seconds"), + "added_at": added_at, } self.accept() @@ -2302,6 +2730,11 @@ def _compute_fermat_positions_flomni(params: dict[str, Any]) -> tuple[int, int]: doesn't affect flomni's count either -- every stitched tile is an identically-shaped Fermat scan, just centered differently. + The real scan also draws a fresh random sub-step offset on every call + (see get_flomni_fermat_spiral_pos()'s `seed` argument), which can shift + the actual scan-time count by a point or two near the threshold. A + fixed seed is used here only so this estimate is itself reproducible. + Returns: (estimated_count, minimum_required) """ @@ -2310,10 +2743,18 @@ def _compute_fermat_positions_flomni(params: dict[str, Any]) -> tuple[int, int]: fovx = params.get("fovx", 0.0) fovy = params.get("fovy", 0.0) step = params.get("tomo_shellstep", 0.0) + fermat_asymmetry = params.get("fermat_asymmetry", 1.0) if step <= 0: return 0, FlomniFermatScan.MIN_POSITIONS positions = FlomniFermatScan.get_flomni_fermat_spiral_pos( - -abs(fovx / 2), abs(fovx / 2), -abs(fovy / 2), abs(fovy / 2), step=step, spiral_type=0 + -abs(fovx / 2), + abs(fovx / 2), + -abs(fovy / 2), + abs(fovy / 2), + step=step, + spiral_type=0, + seed=0, + fermat_asymmetry=fermat_asymmetry, ) return len(positions), FlomniFermatScan.MIN_POSITIONS @@ -2351,6 +2792,12 @@ def _compute_fermat_positions_lamni(params: dict[str, Any]) -> tuple[int, int]: no angle field in this widget -- the real per-projection count can vary a bit with angle, since the circular crop is angle-dependent). + The real scan also draws a fresh random sub-step offset on every call + (see get_lamni_fermat_spiral_pos()'s `seed` argument), adding a further + small amount of per-projection count variation on top of the + angle/stitch dependence above. A fixed seed is used here only so this + estimate is itself reproducible. + Returns: (estimated_count, minimum_required) """ @@ -2375,6 +2822,7 @@ def _compute_fermat_positions_lamni(params: dict[str, Any]) -> tuple[int, int]: fovx=piezo_x, fovy=piezo_y, fov_circular=params.get("tomo_circfov", 0.0), + seed=0, ) return len(positions), LamniFermatScan.MIN_POSITIONS @@ -2418,13 +2866,26 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { "defaults": DEFAULTS, "has_180_mode": True, "has_single_point": True, + # tomo_type 4 (angles from file) is flomni-only "has_zero_deg_reference": True, + "has_always_forward": True, + "golden_bunch_size_min": 1, "fov_fields": [ - ("fovx", "FOV x (µm)", 0.1, 200.0, 2), - ("fovy", "FOV y (µm)", 0.1, 100.0, 2), + ("fovx", "FOV x (µm)", 0.1, 220.0, 2), + ("fovy", "FOV y (µm)", 0.1, 99.9, 2), ], - "stitch_fields": [("stitch_x", "Stitch x", 0, 50), ("stitch_y", "Stitch y", 0, 50)], - "manual_shift_fields": [("manual_shift_y", "Manual shift y (µm)", -1000.0, 1000.0, 3)], + "stitch_fields": [("stitch_x", "Stitch x", 0, 10), ("stitch_y", "Stitch y", 0, 10)], + "manual_shift_fields": [ + ("manual_shift_x", "Manual shift x (µm)", -10.0, 10.0, 3), + ("manual_shift_y", "Manual shift y (µm)", -50.0, 50.0, 3), + ], + "manual_shift_help": { + "manual_shift_x": ( + "A constant offset applied on top of the alignment, independent of " + "the rotation angle. Normally zero -- only needed in special cases, " + "e.g. when the FZP-to-sample distance is changed after alignment." + ) + }, "piezo_range_fields": [], "offset_fields": [], "offset_getter": None, @@ -2433,6 +2894,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { "field_order": [ "tomo_countingtime", "tomo_shellstep", + "fermat_asymmetry", "fov", "stitch", "tomo_stitch_overlap", @@ -2446,7 +2908,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { "compute_type1": _compute_type1, "requested_to_stepsize": _requested_to_stepsize, "compute_fermat_positions": _compute_fermat_positions_flomni, - "fermat_position_fields": ["tomo_shellstep", "fovx", "fovy"], + "fermat_position_fields": ["tomo_shellstep", "fermat_asymmetry", "fovx", "fovy"], # cached=True: no active bug here (this was always a direct signal # read, never the device-method/device_rpc round-trip OMNY's getter # had -- see SETUP_PROFILES["omny"]["sample_name_getter"]), but @@ -2459,6 +2921,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { 1: {"label": "8 equally spaced sub-tomograms", "n_subtomos": 8}, 2: {"label": "Golden ratio (sorted in bunches)", "n_subtomos": None}, 3: {"label": "Equally spaced, golden ratio start", "n_subtomos": None}, + 4: {"label": "Angles from file", "n_subtomos": None, "angle_file": True}, }, }, "lamni": { @@ -2468,19 +2931,30 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { "defaults": LAMNI_DEFAULTS, "has_180_mode": False, "has_single_point": False, + # laminography: 180-deg-opposite angles are not redundant + "angle_file_opposite_warning": False, "has_zero_deg_reference": True, - "fov_fields": [("tomo_circfov", "Circular FOV (µm, 0 = disabled)", 0.0, 200.0, 2)], + # lamni.golden_ratio_bunch_size setter/CLI require >= 20 (see + # _build_params_panel's bunch-size spinbox) + "golden_bunch_size_min": 20, + "fov_fields": [("tomo_circfov", "Circular FOV (µm, 0 = disabled)", 0.0, 500.0, 2)], "stitch_fields": [ ("lamni_stitch_x", "Stitch x", 0, 50), ("lamni_stitch_y", "Stitch y", 0, 50), ], + # lamni's manual_shift_x/y are in mm end to end (added to the mm scan + # centre in tomo_scan_projection -> lamni_fermat_scan(shift_x=..., + # Units.mm), applied to lsamx/lsamy in mm; the CLI prints ). This + # was labelled "(µm)" with a +-1000 range, copied from flomni's field + # (which really is um): typing "5" as um would have shifted the + # scan by 5 mm. +-1 mm is the span the old um label implied. "manual_shift_fields": [ - ("manual_shift_x", "Manual shift x (µm)", -1000.0, 1000.0, 3), - ("manual_shift_y", "Manual shift y (µm)", -1000.0, 1000.0, 3), + ("manual_shift_x", "Manual shift x (mm)", -1.0, 1.0, 4), + ("manual_shift_y", "Manual shift y (mm)", -1.0, 1.0, 4), ], "piezo_range_fields": [ - ("lamni_piezo_range_x", "Piezo range x (µm)", 0.0, 200.0, 2), - ("lamni_piezo_range_y", "Piezo range y (µm)", 0.0, 200.0, 2), + ("lamni_piezo_range_x", "Piezo range x (µm)", 0.1, 79.9, 2), + ("lamni_piezo_range_y", "Piezo range y (µm)", 0.1, 79.9, 2), ], # tomo_fovx_offset/tomo_fovy_offset: alignment values (LamNIAlignmentMixin), # not part of _TOMO_SCAN_PARAM_NAMES -- shown/editable here (mirroring @@ -2524,6 +2998,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { 1: {"label": "8 equally spaced sub-tomograms", "n_subtomos": 8}, 2: {"label": "Golden ratio (sorted in bunches)", "n_subtomos": None}, 3: {"label": "Equally spaced, golden ratio start", "n_subtomos": None}, + 4: {"label": "Angles from file", "n_subtomos": None, "angle_file": True}, }, }, "omny": { @@ -2686,7 +3161,7 @@ def _format_hook_detail(params: dict, registered_hooks: list) -> str: return f"hook: {hook_name} (not registered)" -def _job_tooltip(job: dict) -> str: +def _job_tooltip(job: dict, profile: Optional[dict] = None) -> str: """Full-detail multi-line description of a queue job, applied to every cell in its row so hovering anywhere on the row shows what the Label and Details columns truncate at typical dialog widths.""" @@ -2706,7 +3181,7 @@ def _job_tooltip(job: dict) -> str: # works for both flomni and lamni jobs without needing to know which. # Fields irrelevant to this job's own tomo_type (e.g. golden-ratio # settings on a type-1 job) are skipped -- see _irrelevant_params_for_type(). - irrelevant = _irrelevant_params_for_type(params.get("tomo_type")) + irrelevant = _irrelevant_params_for_type(params.get("tomo_type"), profile) for name in sorted(params): if name in irrelevant: continue diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index 6431f5ed..0f355946 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -1,5 +1,7 @@ from __future__ import annotations +import math + import pyqtgraph as pg from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints @@ -9,17 +11,24 @@ from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.widgets.plots.image.image import Image from bec_widgets.widgets.plots.waveform.waveform import Waveform from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree -from bec_widgets.widgets.plots.roi.image_roi import BaseROI, CircularROI, RectangularROI +from bec_widgets.widgets.plots.roi.image_roi import ( + BaseROI, + CircularROI, + EllipticalROI, + RectangularROI, +) from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch from qtpy.QtCore import Qt, QTimer from qtpy.QtWidgets import ( QFrame, QGridLayout, QHBoxLayout, + QDoubleSpinBox, QLabel, QLineEdit, QPushButton, QSizePolicy, + QSlider, QSpinBox, QToolButton, QVBoxLayout, @@ -31,6 +40,53 @@ from qtpy.QtWidgets import ( logger = bec_logger.logger CAMERA = ("cam_xeye", "image") +# Start values / bounds for the DAP fit A*sin(f*x + shift) + intercept of the +# alignment offsets vs. angle (5 points, 0..180 deg, f fixed to 1 deg in rad). +# All five parameters are given so the DAP server does not use lmfit's +# SineModel.guess(): its FFT-based shift guess is arbitrary for 5 points and +# regularly left the fit stuck at the shift bound (2*pi) far from the data. +# A signed amplitude (SineModel defaults to A >= 0) plus a fixed start +# (A=30, shift=0, intercept=0) converged to the global least-squares optimum +# in 1500/1500 random test datasets; A >= 0 failed in a few percent. +SINE_FIT_DAP_PARAMETERS = [ + { + "amplitude": {"value": 30, "vary": True, "min": -1e6, "max": 1e6}, + "frequency": {"value": 0.0174533, "vary": False, "min": 0.01, "max": 0.02}, + "shift": {"value": 0, "vary": True, "min": -math.pi, "max": math.pi}, + }, + { + "slope": {"value": 0, "vary": False, "min": 0.0, "max": 0.02}, + "intercept": {"value": 0, "vary": True, "min": -1e6, "max": 1e6}, + }, +] + + +def ellipse_geometry(roi) -> dict: + """Centre, axes and rotation of an (optionally rotated) ``EllipticalROI``. + + ``EllipticalROI.get_coordinates()`` ignores the rotation (the ROI rotates about + its ``pos()``, not its centre) and sorts the axes by size. Here the centre is + mapped through the ROI transform, ``width``/``height`` are the axes along the + ROI's local x/y, and ``angle`` (degrees, pyqtgraph convention, normalised to + (-90, 90]; an ellipse is symmetric under 180 deg) is the rotation of the local + x axis in data coordinates. + """ + size_x, size_y = roi.state["size"] + centre = roi.mapToParent(pg.Point(size_x / 2, size_y / 2)) + return { + "center_x": centre.x(), + "center_y": centre.y(), + "width": abs(size_x), + "height": abs(size_y), + "angle": _normalize_angle(roi.angle()), + } + + +def _normalize_angle(angle: float) -> float: + """Map an angle in degrees onto (-90, 90].""" + angle = (angle + 90.0) % 180.0 - 90.0 + return 90.0 if math.isclose(angle, -90.0, abs_tol=1e-9) else angle + class TargetCrosshair: """ @@ -239,6 +295,7 @@ class OMNY_XRayEye(BECWidget, QWidget): "on_live_view_enabled", "on_motors_enable", "enable_submit_button", + "enable_skip_button", "sample_name", "sample_name.setter", "enable_move_buttons", @@ -248,6 +305,12 @@ class OMNY_XRayEye(BECWidget, QWidget): "submit_fit_array", "show_crosshair", "hide_crosshair", + "lock_vertical_center", + "unlock_vertical_center", + "enable_ellipse_roi", + "lock_roi_angle", + "unlock_roi_angle", + "roi_angle", "crosshair_visible", "crosshair_visible.setter", "set_crosshair_position", @@ -269,10 +332,20 @@ class OMNY_XRayEye(BECWidget, QWidget): PIXEL_CALIBRATION_USER_PARAM = "pixel_calibration" PIXEL_CALIBRATION_DEFAULT = 1.0 + # exposure_time_slider works in tenths of a ms internally (QSlider is + # int-only); divide by this to get ms. + _EXPOSURE_SLIDER_SCALE = 10 + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) self._live_view_signal = CAMERA[1] self._last_smear_composite = None + # pixel_clock_slider is index-based over this list (populated once, + # see _init_pixel_clock_options()) rather than a raw MHz range -- + # many uEye sensors only accept a short discrete list of pixel + # clocks, not every value in [min, max] (confirmed on hardware). + self._pixel_clock_options: list[int] = [] + self._last_pixel_clock_mhz: int | None = None self._connected_motor = None self._dap_params_forwarding_connected = False self._queue_busy = False @@ -284,7 +357,14 @@ class OMNY_XRayEye(BECWidget, QWidget): self._init_ui() self.target_crosshair = TargetCrosshair(self.image.plot_item) + # See lock_vertical_center()/unlock_vertical_center()/_snap_roi_vertical() + self._vertical_lock_enabled = False + # Elliptical ROI (LamNI centre calibration only, see enable_ellipse_roi()); + # _roi_angle_locked is the fixed rotation in degrees, or None if free. + self._ellipse_enabled = False + self._roi_angle_locked = None self._make_connections() + self.enable_ellipse_roi(False) # Connection to redis endpoints self.bec_dispatcher.connect_slot( @@ -312,9 +392,23 @@ class OMNY_XRayEye(BECWidget, QWidget): ) self.connect_motors() - self.resize(800, 600) + # No self.resize() here: this widget is normally embedded as a dock + # (see gui_tools.py's flomnigui_show_xeyealign()), where the dock + # manager (Qt Advanced Docking System) owns its geometry -- an + # explicit self-resize call fights that, and on a screen too small + # for the assumed geometry (gui_tools.py's hardcoded 2560px-wide + # assumption) corrupts the dock's layout until the operator manually + # moves/resizes the window (forcing Qt to reflow within the actual + # available space) -- confirmed live: reproduces reliably on a + # smaller screen, not on a large one, and only for this widget (the + # only one in this GUI with a self-resize call), never for the + # camera/console docks shown via flomnigui_show_cameras(). The + # standalone `python x_ray_eye.py` harness below already does its + # own win.resize(1000, 800) after construction regardless, so this + # call was never actually needed even for that use case. QTimer.singleShot(0, self._init_queue_status) QTimer.singleShot(0, self._init_gui_trigger) + QTimer.singleShot(0, self._init_pixel_clock_options) def _init_ui(self): self.root_layout = QVBoxLayout(self) @@ -359,12 +453,13 @@ class OMNY_XRayEye(BECWidget, QWidget): header_row.addWidget(self.live_preview_toggle, 0, Qt.AlignmentFlag.AlignVCenter) self.control_panel_layout.addLayout(header_row) - # Shutter/camera-running (row 0) and the smear aid's own status - # switches (row 1) share one grid so the toggle columns actually - # line up regardless of each row's label text width -- two - # independent QHBoxLayouts (the previous approach) each size - # themselves from their own labels' widths, so corresponding - # switches in different rows land at different x-positions. + # Shutter/camera-running (row 0), a separator (row 1), the smear + # aid's own status switches (row 2), and the ROI-vertical-lock + # toggle (row 3) share one grid so the toggle columns actually line + # up regardless of each row's label text width -- independent + # QHBoxLayouts per row (the previous approach) each size themselves + # from their own labels' widths, so corresponding switches in + # different rows would land at different x-positions. self.switch_grid_widget = QWidget(parent=self) switch_grid = QGridLayout(self.switch_grid_widget) switch_grid.setContentsMargins(0, 0, 0, 0) @@ -392,18 +487,131 @@ class OMNY_XRayEye(BECWidget, QWidget): self.smear_active_toggle.checked = False self.smear_active_toggle.setEnabled(False) # read-only status, not operator-togglable + # Manual override for the ROI-vertical-lock feature (see + # lock_vertical_center()/unlock_vertical_center()): a script (e.g. + # x_ray_eye_align.py) engages the lock automatically during the + # angle-alignment steps, but an operator may still want to freely + # draw/measure an unrelated box mid-run -- this toggle lets them + # release it without aborting the run, and re-engage it after. + self.vertical_lock_label = QLabel("ROI vertical lock", parent=self) + self.vertical_lock_toggle = ToggleSwitch(parent=self) + self.vertical_lock_toggle.checked = False + self.vertical_lock_toggle.enabled.connect(self._on_vertical_lock_toggle_changed) + + # Angle fix for the elliptical ROI (see lock_roi_angle()); only visible + # while the ellipse tool is enabled (enable_ellipse_roi()). + self.roi_angle_lock_label = QLabel("ROI angle lock", parent=self) + self.roi_angle_lock_toggle = ToggleSwitch(parent=self) + self.roi_angle_lock_toggle.checked = False + self.roi_angle_lock_toggle.enabled.connect(self._on_roi_angle_lock_toggle_changed) + self.roi_angle_spinbox = QDoubleSpinBox(parent=self) + self.roi_angle_spinbox.setRange(-90.0, 90.0) + self.roi_angle_spinbox.setDecimals(1) + self.roi_angle_spinbox.setSingleStep(1.0) + self.roi_angle_spinbox.setSuffix(" deg") + self.roi_angle_spinbox.setToolTip("Rotation of the elliptical ROI") + self.roi_angle_spinbox.valueChanged.connect(self._on_roi_angle_spinbox_changed) + _right_vcenter = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter switch_grid.addWidget(self.shutter_label, 0, 1, _right_vcenter) switch_grid.addWidget(self.shutter_toggle, 0, 2, Qt.AlignmentFlag.AlignVCenter) switch_grid.addWidget(self.camera_running_label, 0, 3, _right_vcenter) switch_grid.addWidget(self.camera_running_toggle, 0, 4, Qt.AlignmentFlag.AlignVCenter) - switch_grid.addWidget(self.smear_active_label, 1, 1, _right_vcenter) - switch_grid.addWidget(self.smear_active_toggle, 1, 2, Qt.AlignmentFlag.AlignVCenter) - switch_grid.addWidget(self.smear_preview_label, 1, 3, _right_vcenter) - switch_grid.addWidget(self.smear_preview_toggle, 1, 4, Qt.AlignmentFlag.AlignVCenter) + # separator: below shutter/camera-running, above the smear-integration switches + switch_grid.addWidget(self._create_separator(), 1, 0, 1, 5) + switch_grid.addWidget(self.smear_active_label, 2, 1, _right_vcenter) + switch_grid.addWidget(self.smear_active_toggle, 2, 2, Qt.AlignmentFlag.AlignVCenter) + switch_grid.addWidget(self.smear_preview_label, 2, 3, _right_vcenter) + switch_grid.addWidget(self.smear_preview_toggle, 2, 4, Qt.AlignmentFlag.AlignVCenter) + switch_grid.addWidget(self.vertical_lock_label, 3, 1, _right_vcenter) + switch_grid.addWidget(self.vertical_lock_toggle, 3, 2, Qt.AlignmentFlag.AlignVCenter) + switch_grid.addWidget(self.roi_angle_lock_label, 4, 1, _right_vcenter) + switch_grid.addWidget(self.roi_angle_lock_toggle, 4, 2, Qt.AlignmentFlag.AlignVCenter) + switch_grid.addWidget(self.roi_angle_spinbox, 4, 3, 1, 2) + self.control_panel_layout.addWidget(self.switch_grid_widget) - # separator + # separator: below shutter/camera-running/smear switches, above the + # exposure/gain section + self.control_panel_layout.addWidget(self._create_separator()) + + # Exposure/gain section: auto-exposure and auto-gain enable toggles + # (row 0, same layout idiom as switch_grid above), plus a manual + # exposure-time slider (row 1) that's only enabled while + # auto-exposure is off. Its range is a placeholder until seeded from + # the hardware-reported (min, max) at connect time -- see + # getting_camera_status(). + self.exposure_grid_widget = QWidget(parent=self) + exposure_grid = QGridLayout(self.exposure_grid_widget) + exposure_grid.setContentsMargins(0, 0, 0, 0) + exposure_grid.setHorizontalSpacing(8) + exposure_grid.setVerticalSpacing(4) + exposure_grid.setColumnStretch(0, 1) + + # No "Auto gain" control here -- HW testing found continuous + # auto-gain isn't useful once correctly exposed, and takes gain out + # of manual control. IDSCamera now pulses it on then off internally, + # once, at connect (see its on_connected()); there is no persistent + # UI for it. + self.auto_exposure_label = QLabel("Auto exposure", parent=self) + self.auto_exposure_toggle = ToggleSwitch(parent=self) + self.auto_exposure_toggle.checked = True + self.auto_exposure_toggle.enabled.connect(self.auto_exposure_enabled_changed) + + exposure_grid.addWidget(self.auto_exposure_label, 0, 1, _right_vcenter) + exposure_grid.addWidget(self.auto_exposure_toggle, 0, 2, Qt.AlignmentFlag.AlignVCenter) + + # QSlider only takes ints, so exposure time is tracked in tenths of a + # ms internally (_EXPOSURE_SLIDER_SCALE) for one decimal of + # resolution on the display label. + self.exposure_time_label = QLabel("Exposure time", parent=self) + self.exposure_time_slider = QSlider(Qt.Orientation.Horizontal, parent=self) + # Placeholder until reseeded from hardware on connect (see + # getting_camera_status()) -- minimum is 1 (0.1 ms), never 0, since + # 0 ms exposure isn't physical. + self.exposure_time_slider.setRange(1, 10000) + self.exposure_time_slider.setEnabled(False) # auto-exposure starts enabled + self.exposure_time_value_label = QLabel("-- ms", parent=self) + self.exposure_time_value_label.setMinimumWidth(60) + self.exposure_time_slider.valueChanged.connect(self._update_exposure_time_value_label) + # sliderReleased (fires once, on mouse-up), not valueChanged (fires on + # every tick while dragging) -- the throttling choice here, same + # "don't hammer the device" concern as editingFinished was for the + # spinbox this replaces. + self.exposure_time_slider.sliderReleased.connect(self.exposure_time_submitted) + + exposure_grid.addWidget(self.exposure_time_label, 1, 1, _right_vcenter) + exposure_grid.addWidget(self.exposure_time_slider, 1, 2, 1, 2) + exposure_grid.addWidget(self.exposure_time_value_label, 1, 4, Qt.AlignmentFlag.AlignVCenter) + + # Pixel clock (MHz): lowering it raises the max achievable exposure + # time (at the cost of frame rate) -- exposed here since operators + # hitting the exposure slider's ceiling need this knob to go further. + # Many uEye sensors only accept a short discrete list of pixel + # clocks (confirmed on hardware -- most values in the driver's own + # reported min/max range were rejected), so this slider's *position* + # is an index into self._pixel_clock_options, not a raw MHz value -- + # every position it can land on is therefore one the hardware + # actually accepts. The real list is fetched once, over RPC, in + # _init_pixel_clock_options() (it's static per camera, not something + # that needs a live subscription); disabled until then. + self.pixel_clock_label = QLabel("Pixel clock", parent=self) + self.pixel_clock_slider = QSlider(Qt.Orientation.Horizontal, parent=self) + self.pixel_clock_slider.setRange(0, 0) # placeholder; see _set_pixel_clock_options() + self.pixel_clock_slider.setEnabled(False) + self.pixel_clock_value_label = QLabel("-- MHz", parent=self) + self.pixel_clock_value_label.setMinimumWidth(60) + self.pixel_clock_slider.valueChanged.connect(self._update_pixel_clock_value_label) + self.pixel_clock_slider.sliderReleased.connect(self.pixel_clock_submitted) + + exposure_grid.addWidget(self.pixel_clock_label, 2, 1, _right_vcenter) + exposure_grid.addWidget(self.pixel_clock_slider, 2, 2, 1, 2) + exposure_grid.addWidget(self.pixel_clock_value_label, 2, 4, Qt.AlignmentFlag.AlignVCenter) + + self.control_panel_layout.addWidget(self.exposure_grid_widget) + + # separator: below the exposure/gain section, above the alignment + # values (2D positioner + zoom) self.control_panel_layout.addWidget(self._create_separator()) # 2D Positioner (fixed size) @@ -430,10 +638,19 @@ class OMNY_XRayEye(BECWidget, QWidget): self.step_size.setValue(100) # Submit button self.submit_button = QPushButton("Submit", parent=self) + # Skip button: leaves out the current angle of the alignment (flat samples are + # edge-on at 90 deg). Hidden and disabled until a client script opts in via + # enable_skip_button(), so LamNI/omny never see it. + self._skip_enabled = False + self.skip_button = QPushButton("Skip", parent=self) + self.skip_button.setVisible(False) + self.skip_button.setEnabled(False) + self.skip_button.setToolTip("Leave out this angle (at least 4 positions are needed)") # Add to layout form step_size_form.addWidget(QLabel("Step Size", parent=self), 0, 0) step_size_form.addWidget(self.step_size, 0, 1) step_size_form.addWidget(self.submit_button, 2, 0, 1, 2) + step_size_form.addWidget(self.skip_button, 3, 0, 1, 2) # Add form to control panel self.control_panel_layout.addLayout(step_size_form) @@ -487,10 +704,7 @@ class OMNY_XRayEye(BECWidget, QWidget): y=[1], label="fit-x", dap=["SineModel", "LinearModel"], - dap_parameters=[ - {"frequency": {"value": 0.0174533, "vary": False, "min": 0.01, "max": 0.02}}, - {"slope": {"value": 0, "vary": False, "min": 0.0, "max": 0.02}}, - ], + dap_parameters=SINE_FIT_DAP_PARAMETERS, dap_oversample=5, ) self.waveform_y.plot( @@ -498,10 +712,7 @@ class OMNY_XRayEye(BECWidget, QWidget): y=[2], label="fit-y", dap=["SineModel", "LinearModel"], - dap_parameters=[ - {"frequency": {"value": 0.0174533, "vary": False, "min": 0.01, "max": 0.02}}, - {"slope": {"value": 0, "vary": False, "min": 0.0, "max": 0.02}}, - ], + dap_parameters=SINE_FIT_DAP_PARAMETERS, dap_oversample=5, ) self.fit_x = self.waveform_x.curves[0] @@ -528,6 +739,7 @@ class OMNY_XRayEye(BECWidget, QWidget): lambda x: self.motor_control_2d.setProperty("step_size", x) ) self.submit_button.clicked.connect(self.submit) + self.skip_button.clicked.connect(self.skip) # ROIPropertyTree's compact_color only styles the line color; line width # still needs to be forced per-ROI here. self.roi_manager.controller.roiAdded.connect(self._style_new_roi) @@ -536,9 +748,45 @@ class OMNY_XRayEye(BECWidget, QWidget): def _style_new_roi(self, roi): """Force a thinner outline on newly drawn ROIs (color is set via compact_color).""" roi.line_width = self.ROI_LINE_WIDTH + if isinstance(roi, EllipticalROI): + self._setup_ellipse(roi) # Live-update the microns readout while this ROI is drawn/resized. roi.sigRegionChanged.connect(lambda r=roi: self._update_roi_size_readout(r)) self._update_roi_size_readout(roi) + # If the vertical lock is on (see lock_vertical_center()), snap this box's + # vertical center to the crosshair immediately, and again on every future + # drag/resize. + self._snap_roi_vertical(roi) + roi.sigRegionChangeFinished.connect(lambda r=roi: self._snap_roi_vertical(r)) + + def _snap_roi_vertical(self, roi): + """Snap `roi`'s vertical center to the crosshair's fixed y position, keeping + its x position and height/diameter unchanged. No-op unless the vertical lock + is enabled (``lock_vertical_center()``) and the crosshair is visible. Safe to + call from ``roi.sigRegionChangeFinished`` -- if the ROI is already at the + target position this is a no-op, so it cannot loop back into itself. + """ + if not self._vertical_lock_enabled or not self.target_crosshair.is_visible(): + return + _, fzp_y = self.crosshair_position() + if isinstance(roi, EllipticalROI): + # The ellipse rotates about pos(), so move it by the centre's offset. + geo = ellipse_geometry(roi) + dy = fzp_y - geo["center_y"] + if not math.isclose(dy, 0.0, abs_tol=1e-9): + roi.setPos(roi.pos().x(), roi.pos().y() + dy) + return + if isinstance(roi, RectangularROI): + height = roi.get_coordinates(typed=True)["height"] + elif isinstance(roi, CircularROI): + height = roi.get_coordinates(typed=True)["diameter"] + else: + return + current_x = roi.pos().x() + new_origin_y = fzp_y - height / 2 + if math.isclose(roi.pos().y(), new_origin_y, abs_tol=1e-9): + return + roi.set_position(current_x, new_origin_y) def _microns_per_pixel(self): """Resolve microns/pixel and unit label from the camera user parameter. @@ -584,6 +832,14 @@ class OMNY_XRayEye(BECWidget, QWidget): # Read the raw x/y size so a non-circular (ellipse) state is reflected # correctly; for an aspect-locked circle both values are equal. size_x, size_y = roi.state["size"] + elif isinstance(roi, EllipticalROI): + geo = ellipse_geometry(roi) + scale, unit = self._microns_per_pixel() + self.roi_size_line_edit.setText( + f"a = {geo['width'] * scale:.1f} {unit}, b = {geo['height'] * scale:.1f} {unit}, " + f"angle = {geo['angle']:.1f} deg" + ) + return else: self.roi_size_line_edit.setText("") return @@ -648,8 +904,13 @@ class OMNY_XRayEye(BECWidget, QWidget): def enable_move_buttons(self, enabled: bool): self.motor_control_2d.setEnabled(enabled) - def _queue_guarded_toggles(self) -> tuple[ToggleSwitch, ToggleSwitch, ToggleSwitch]: - return (self.live_preview_toggle, self.shutter_toggle, self.camera_running_toggle) + def _queue_guarded_toggles(self) -> tuple[ToggleSwitch, ...]: + return ( + self.live_preview_toggle, + self.shutter_toggle, + self.camera_running_toggle, + self.auto_exposure_toggle, + ) def _set_queue_toggles_blocked(self, blocked: bool): if blocked == self._queue_busy: @@ -740,6 +1001,185 @@ class OMNY_XRayEye(BECWidget, QWidget): """Hide the alignment target crosshair on the image view.""" self.target_crosshair.set_visible(False) + @SafeSlot() + @rpc_timeout(20) + def lock_vertical_center(self): + """Snap every ROI's vertical center to the crosshair's fixed y position + (see ``crosshair_position()``), immediately and again on every future + draw/drag/resize, until ``unlock_vertical_center()`` is called. Used by + ``x_ray_eye_align.py`` once the sample's height has been physically + centered on the FZP, so subsequent alignment boxes no longer need manual + vertical placement. See ``_snap_roi_vertical()``. + + Also settable by the operator directly via the "ROI vertical lock" + toggle in the control panel (``_on_vertical_lock_toggle_changed``) -- + e.g. to temporarily release a script-engaged lock mid-run in order to + freely draw/measure an unrelated box, then re-engage it. Both paths + keep the toggle's visual state in sync with ``_vertical_lock_enabled``. + """ + self._vertical_lock_enabled = True + self.vertical_lock_toggle.blockSignals(True) + self.vertical_lock_toggle.checked = True + self.vertical_lock_toggle.blockSignals(False) + for roi in self.roi_manager.controller.rois: + self._snap_roi_vertical(roi) + + @SafeSlot() + @rpc_timeout(20) + def unlock_vertical_center(self): + """Stop auto-snapping ROIs' vertical center -- see ``lock_vertical_center()``.""" + self._vertical_lock_enabled = False + self.vertical_lock_toggle.blockSignals(True) + self.vertical_lock_toggle.checked = False + self.vertical_lock_toggle.blockSignals(False) + + @SafeSlot(bool) + def _on_vertical_lock_toggle_changed(self, enabled: bool): + """Operator flipped the "ROI vertical lock" toggle directly -- delegate to + lock_vertical_center()/unlock_vertical_center() so both entry points + (this toggle, and script/RPC calls) stay in sync.""" + if enabled: + self.lock_vertical_center() + else: + self.unlock_vertical_center() + + # ------------------------------------------------------------------ + # Elliptical ROI (LamNI rotation-centre calibration) + # ------------------------------------------------------------------ + + def _setup_ellipse(self, roi: EllipticalROI): + """Add independent axis scale handles and the angle bookkeeping to a new ellipse. + + pyqtgraph's ``EllipseROI`` only has a rotate handle and one uniform scale handle, + so the aspect ratio could not be changed after drawing. + """ + roi.addScaleHandle([0.0, 0.5], [1.0, 0.5]) # width, anchored at the opposite side + roi.addScaleHandle([0.5, 1.0], [0.5, 0.0]) # height + roi.sigRegionChanged.connect(lambda r=roi: self._on_ellipse_changed(r)) + if self._roi_angle_locked is not None: + self._apply_locked_angle(roi) + self._set_rotate_handle_visible(roi, self._roi_angle_locked is None) + self._sync_angle_spinbox(roi) + + @staticmethod + def _set_rotate_handle_visible(roi, visible: bool): + for handle in roi.handles: + if handle["type"] == "r": + handle["item"].setVisible(visible) + + def _apply_locked_angle(self, roi): + """Rotate `roi` about its centre to the locked angle (no-op if already there).""" + locked = self._roi_angle_locked + if locked is None: + return + if math.isclose(_normalize_angle(roi.angle()), _normalize_angle(locked), abs_tol=1e-6): + return + roi.setAngle(locked, center=(0.5, 0.5)) + + def _on_ellipse_changed(self, roi): + self._apply_locked_angle(roi) + self._sync_angle_spinbox(roi) + + def _sync_angle_spinbox(self, roi=None): + """Show the active ellipse's rotation in the spin box without re-triggering it.""" + if roi is None: + roi = self.roi_manager.single_active_roi + if not isinstance(roi, EllipticalROI): + return + self.roi_angle_spinbox.blockSignals(True) + self.roi_angle_spinbox.setValue(ellipse_geometry(roi)["angle"]) + self.roi_angle_spinbox.blockSignals(False) + + @SafeSlot(bool) + @rpc_timeout(20) + def enable_ellipse_roi(self, enable: bool): + """Show/hide the elliptical ROI tool and its angle controls. + + Hidden by default; the LamNI client enables it after the FZP centre has been + submitted, for the extended/smear rotation-centre calibrations (angle locked) + and for the X-ray eye alignment (angle free). Disabling removes an existing + ellipse and releases the angle lock. + + Args: + enable(bool): True to offer the ellipse tool. + """ + self._ellipse_enabled = enable + self.roi_manager.toolbar.components.get_action("roi_ellipse").action.setVisible(enable) + for widget in (self.roi_angle_lock_label, self.roi_angle_lock_toggle, self.roi_angle_spinbox): + widget.setVisible(enable) + if not enable: + self.unlock_roi_angle() + self.roi_manager.toolbar.components.get_action("roi_ellipse").action.setChecked(False) + active = self.roi_manager.single_active_roi + if isinstance(active, EllipticalROI): + self.roi_manager.controller.remove_roi(active) + self._update_roi_size_readout(None) + + @SafeSlot() + @rpc_timeout(20) + def lock_roi_angle(self, angle: float | None = None): + """Fix the rotation of elliptical ROIs, now and for ellipses drawn later. + + Args: + angle(float | None): Rotation in degrees. If None, the active ellipse's + current rotation (or, without one, the angle in the spin box) is used. + """ + if angle is None: + active = self.roi_manager.single_active_roi + if isinstance(active, EllipticalROI): + angle = ellipse_geometry(active)["angle"] + else: + angle = self.roi_angle_spinbox.value() + self._roi_angle_locked = _normalize_angle(float(angle)) + self.roi_angle_lock_toggle.blockSignals(True) + self.roi_angle_lock_toggle.checked = True + self.roi_angle_lock_toggle.blockSignals(False) + self.roi_angle_spinbox.blockSignals(True) + self.roi_angle_spinbox.setValue(self._roi_angle_locked) + self.roi_angle_spinbox.blockSignals(False) + for roi in self.roi_manager.controller.rois: + if isinstance(roi, EllipticalROI): + self._apply_locked_angle(roi) + self._set_rotate_handle_visible(roi, False) + self._update_roi_size_readout(roi) + + @SafeSlot() + @rpc_timeout(20) + def unlock_roi_angle(self): + """Let elliptical ROIs rotate freely again -- see ``lock_roi_angle()``.""" + self._roi_angle_locked = None + self.roi_angle_lock_toggle.blockSignals(True) + self.roi_angle_lock_toggle.checked = False + self.roi_angle_lock_toggle.blockSignals(False) + for roi in self.roi_manager.controller.rois: + if isinstance(roi, EllipticalROI): + self._set_rotate_handle_visible(roi, True) + + @SafeSlot(bool) + def _on_roi_angle_lock_toggle_changed(self, enabled: bool): + if enabled: + self.lock_roi_angle() + else: + self.unlock_roi_angle() + + @SafeSlot(float) + def _on_roi_angle_spinbox_changed(self, angle: float): + """Operator typed an angle: rotate the active ellipse (and the lock, if engaged).""" + if self._roi_angle_locked is not None: + self.lock_roi_angle(angle) + return + active = self.roi_manager.single_active_roi + if isinstance(active, EllipticalROI): + active.setAngle(angle, center=(0.5, 0.5)) + + @SafeSlot() + def roi_angle(self) -> float | None: + """Rotation in degrees of the active elliptical ROI, or None if there is none.""" + active = self.roi_manager.single_active_roi + if isinstance(active, EllipticalROI): + return ellipse_geometry(active)["angle"] + return None + @SafeProperty(bool) def crosshair_visible(self) -> bool: """Whether the alignment target crosshair is currently shown.""" @@ -886,11 +1326,142 @@ class OMNY_XRayEye(BECWidget, QWidget): @SafeSlot(dict, dict) def getting_camera_status(self, data, meta): print(f"msg:{data}") - live_mode_enabled = data.get("signals").get(f"{CAMERA[0]}_live_mode_enabled").get("value") + signals = data.get("signals") + live_mode_enabled = signals.get(f"{CAMERA[0]}_live_mode_enabled").get("value") self.camera_running_toggle.blockSignals(True) self.camera_running_toggle.checked = live_mode_enabled self.camera_running_toggle.blockSignals(False) + auto_exp = signals.get(f"{CAMERA[0]}_auto_exposure_enabled") + if auto_exp is not None: + enabled = bool(auto_exp.get("value")) + self.auto_exposure_toggle.blockSignals(True) + self.auto_exposure_toggle.checked = enabled + self.exposure_time_slider.setEnabled(not enabled) + self.auto_exposure_toggle.blockSignals(False) + + # No auto_gain_enabled handling here -- see the comment above the + # (removed) toggle in _init_ui. + + # Update the pixel-clock slider's position before the exposure ones + # below -- changing the pixel clock changes the exposure range, and + # both arrive together in this same message. (No range/bounds to + # reseed here -- pixel_clock_slider's range is fixed once + # self._pixel_clock_options is populated; see _init_pixel_clock_ + # options()/_set_pixel_clock_options(). pixel_clock_min/max still + # exist on the device as informational metadata, just unused here.) + pixel_clock = signals.get(f"{CAMERA[0]}_pixel_clock") + if pixel_clock is not None: + self._set_pixel_clock_display(int(pixel_clock.get("value"))) + + # Reseed the exposure slider's bounds next (rare -- only changes if + # the pixel clock changed), so a value arriving in the same message + # is clamped against up-to-date min/max rather than a stale range. + # Round the min UP and the max DOWN (never the other way) -- e.g. a + # hardware min of 0.04 ms rounds to 0 in tenths-of-ms units, which + # would let the slider claim 0 ms exposure is settable when it isn't; + # ceil (clamped to at least 1) keeps the bound physical instead. + exposure_min = signals.get(f"{CAMERA[0]}_exposure_time_min") + exposure_max = signals.get(f"{CAMERA[0]}_exposure_time_max") + if exposure_min is not None and exposure_max is not None: + lo = max(1, math.ceil(float(exposure_min.get("value")) * self._EXPOSURE_SLIDER_SCALE)) + hi = math.floor(float(exposure_max.get("value")) * self._EXPOSURE_SLIDER_SCALE) + if (lo, hi) != (self.exposure_time_slider.minimum(), self.exposure_time_slider.maximum()): + self.exposure_time_slider.setRange(lo, hi) + + exposure_time = signals.get(f"{CAMERA[0]}_exposure_time") + if exposure_time is not None: + self.exposure_time_slider.blockSignals(True) + self.exposure_time_slider.setValue( + round(float(exposure_time.get("value")) * self._EXPOSURE_SLIDER_SCALE) + ) + self.exposure_time_slider.blockSignals(False) + self._update_exposure_time_value_label(self.exposure_time_slider.value()) + + @SafeSlot(bool) + def auto_exposure_enabled_changed(self, enabled: bool): + if self._manual_toggle_blocked_by_queue(): + logger.warning("Ignoring auto-exposure toggle while scan queue is busy.") + return + self.auto_exposure_toggle.blockSignals(True) + self.dev.get(CAMERA[0]).auto_exposure_enabled.put(enabled) + self.auto_exposure_toggle.checked = enabled + self.exposure_time_slider.setEnabled(not enabled) + self.auto_exposure_toggle.blockSignals(False) + + def _update_exposure_time_value_label(self, raw_value: int): + self.exposure_time_value_label.setText( + f"{raw_value / self._EXPOSURE_SLIDER_SCALE:.1f} ms" + ) + + def exposure_time_submitted(self): + value_ms = self.exposure_time_slider.value() / self._EXPOSURE_SLIDER_SCALE + self.dev.get(CAMERA[0]).exposure_time.put(value_ms) + + def _init_pixel_clock_options(self): + """One-time fetch of the camera's actually-supported pixel clocks + (see get_pixel_clock_list()'s docstring for why this can't just be + [min, max] from a Kind.config signal) -- not a polling loop, this + list is static per camera, so a single RPC call at widget startup is + the right cost/benefit trade-off versus adding a whole signal path + for something that never changes at runtime.""" + try: + options = self.dev.get(CAMERA[0]).get_pixel_clock_list() + except Exception as exc: + logger.warning(f"Failed to fetch pixel clock options for OMNY_XRayEye: {exc}") + return + self._set_pixel_clock_options(sorted(int(v) for v in options)) + + def _set_pixel_clock_options(self, options: list[int]): + self._pixel_clock_options = options + if not options: + self.pixel_clock_slider.setEnabled(False) + return + self.pixel_clock_slider.blockSignals(True) + self.pixel_clock_slider.setRange(0, len(options) - 1) + self.pixel_clock_slider.blockSignals(False) + self.pixel_clock_slider.setEnabled(True) + if self._last_pixel_clock_mhz is not None: + # A status message already arrived before this RPC call + # returned -- apply it now that we can actually resolve it to a + # slider position. + self._set_pixel_clock_display(self._last_pixel_clock_mhz) + else: + # No status message yet either -- keep the label in sync with + # the slider's default position (index 0, the lowest option). + self._update_pixel_clock_value_label(self.pixel_clock_slider.value()) + + def _set_pixel_clock_display(self, mhz: int): + """Move the slider to the option nearest `mhz` (from hardware, e.g. + via getting_camera_status()) and update the value label -- never a + raw MHz value on the slider itself, only a valid index.""" + self._last_pixel_clock_mhz = mhz + if not self._pixel_clock_options: + # Options not fetched yet -- _set_pixel_clock_options() will + # call back into this once they arrive. + self.pixel_clock_value_label.setText(f"{mhz} MHz") + return + index = min( + range(len(self._pixel_clock_options)), + key=lambda i: abs(self._pixel_clock_options[i] - mhz), + ) + self.pixel_clock_slider.blockSignals(True) + self.pixel_clock_slider.setValue(index) + self.pixel_clock_slider.blockSignals(False) + self._update_pixel_clock_value_label(index) + + def _update_pixel_clock_value_label(self, index: int): + if 0 <= index < len(self._pixel_clock_options): + self.pixel_clock_value_label.setText(f"{self._pixel_clock_options[index]} MHz") + else: + self.pixel_clock_value_label.setText("-- MHz") + + def pixel_clock_submitted(self): + index = self.pixel_clock_slider.value() + if not 0 <= index < len(self._pixel_clock_options): + return + self.dev.get(CAMERA[0]).pixel_clock.put(self._pixel_clock_options[index]) + @SafeSlot(bool) def opening_shutter(self, enabled: bool): if self._manual_toggle_blocked_by_queue(): @@ -942,6 +1513,26 @@ class OMNY_XRayEye(BECWidget, QWidget): self.submit_button.setEnabled(True) else: self.submit_button.setEnabled(False) + self._refresh_skip_button() + + @SafeSlot(bool) + @rpc_timeout(20) + def enable_skip_button(self, enable: bool): + """ + Allow/forbid skipping the current alignment angle. + + The first call also shows the button (it is hidden for clients that never call this). + The button is only clickable while this is True *and* the submit button is enabled, + so it follows the client's "please wait ..." phases automatically. + Args: + enable(bool): True if the client accepts a skip for the current step + """ + self.skip_button.setVisible(True) + self._skip_enabled = bool(enable) + self._refresh_skip_button() + + def _refresh_skip_button(self): + self.skip_button.setEnabled(self._skip_enabled and self.submit_button.isEnabled()) @SafeSlot(bool) @rpc_timeout(20) @@ -1012,11 +1603,20 @@ class OMNY_XRayEye(BECWidget, QWidget): if self.roi_manager.single_active_roi is None: logger.warning("No active ROI") return - roi_coordinates = self.roi_manager.single_active_roi.get_coordinates() + active_roi = self.roi_manager.single_active_roi + roi_angle = None + if isinstance(active_roi, EllipticalROI): + roi_coordinates = ellipse_geometry(active_roi) + roi_angle = roi_coordinates["angle"] + else: + roi_coordinates = active_roi.get_coordinates() roi_center_x = roi_coordinates["center_x"] roi_center_y = roi_coordinates["center_y"] # Case of rectangular ROI - if isinstance(self.roi_manager.single_active_roi, RectangularROI): + if isinstance(active_roi, EllipticalROI): + roi_width = roi_coordinates["width"] + roi_height = roi_coordinates["height"] + elif isinstance(self.roi_manager.single_active_roi, RectangularROI): roi_width = roi_coordinates["width"] roi_height = roi_coordinates["height"] elif isinstance(self.roi_manager.single_active_roi, CircularROI): @@ -1033,10 +1633,23 @@ class OMNY_XRayEye(BECWidget, QWidget): getattr(self.dev.omny_xray_gui, f"yval_y_{step}").set(roi_center_y) getattr(self.dev.omny_xray_gui, f"width_x_{step}").set(roi_width) getattr(self.dev.omny_xray_gui, f"width_y_{step}").set(roi_height) + # Rotation only matters for the ellipse; reset it for every other ROI so a + # stale angle from an earlier submit is never read for this step. + angle_signal = getattr(self.dev.omny_xray_gui, f"roi_angle_{step}", None) + if angle_signal is not None: + angle_signal.set(roi_angle if roi_angle is not None else 0.0) self.dev.omny_xray_gui.submit.set(1) finally: self.submit_button.blockSignals(False) + @SafeSlot() + def skip(self): + """Skip the current alignment angle: signal the client with submit = -1 (no ROI needed).""" + # Disable right away so a double click cannot skip the following step as well; the + # client re-enables the buttons once it is ready for the next step. + self.skip_button.setEnabled(False) + self.dev.omny_xray_gui.submit.set(-1) + def cleanup(self): """Cleanup connections on widget close -> disconnect slots and stop live mode of camera.""" self._queue_idle_timer.stop() diff --git a/csaxs_bec/device_configs/bl_detectors.yaml b/csaxs_bec/device_configs/bl_detectors.yaml index 203e04f3..9cf6cc9c 100644 --- a/csaxs_bec/device_configs/bl_detectors.yaml +++ b/csaxs_bec/device_configs/bl_detectors.yaml @@ -1,26 +1,53 @@ -# eiger_1_5: -# description: Eiger 1.5M in-vacuum detector -# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M +# Eiger detectors (JungfrauJoch). Only one may be enabled at a time -- the JFJ broker +# serves a single selected/initialised detector, so switching the active one here also +# requires selecting/initialising the matching detector on the JFJ broker itself. +# JungfrauJoch-side config (detector selection/init, pixel mask, azimuthal integration +# settings) is done in the Web UI at http://sls-jfjoch-001.psi.ch:8080. +# The Eiger 2 Si 500K detector server itself is reachable at 129.129.122.159. + +# eiger2_si_500k: +# description: Eiger 2 Si 500K detector +# deviceClass: csaxs_bec.devices.jungfraujoch.eiger2_si_500k.Eiger2Si500K # deviceConfig: -# detector_distance: 2200 -# beam_center: [870, 1203] +# detector_distance: 1000 +# beam_center: [200, 200] +# # Re-initialise the detector on staging if JFJ is in Error state (shutter must be closed) +# auto_reinitialize: true # onFailure: raise # enabled: True # readoutPriority: async # softwareTrigger: False # deviceTags: # - bl_detectors +# - eiger -eiger_9: - description: Eiger 9M detector - deviceClass: csaxs_bec.devices.jungfraujoch.eiger_9m.Eiger9M +eiger_1_5: + description: Eiger 1.5M in-vacuum detector + deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M deviceConfig: - detector_distance: 6975.225 - beam_center: [1149.71, 1450.89] + detector_distance: 2200 + beam_center: [870, 1203] onFailure: raise enabled: True readoutPriority: async softwareTrigger: False + deviceTags: + - bl_detectors + - eiger + +# eiger_9: +# description: Eiger 9M detector +# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_9m.Eiger9M +# deviceConfig: +# detector_distance: 6975.225 +# beam_center: [1149.71, 1450.89] +# onFailure: raise +# enabled: True +# readoutPriority: async +# softwareTrigger: False +# deviceTags: +# - bl_detectors +# - eiger # ids_cam: # description: IDS camera for live image acquisition diff --git a/csaxs_bec/device_configs/bl_endstation.yaml b/csaxs_bec/device_configs/bl_endstation.yaml index 02785210..9e8c6419 100644 --- a/csaxs_bec/device_configs/bl_endstation.yaml +++ b/csaxs_bec/device_configs/bl_endstation.yaml @@ -104,6 +104,7 @@ xbpm3x: connectionTimeout: 20 userParameter: init_position: -22.5 + in_position: -0.18 # bl_smar_stage to use csaxs reference method. assign number according to axis channel bl_smar_stage: 0 deviceTags: @@ -129,6 +130,7 @@ xbpm3y: connectionTimeout: 20 userParameter: init_position: -2 + in_position: -0.485 # bl_smar_stage to use csaxs reference method. assign number according to axis channel bl_smar_stage: 1 deviceTags: @@ -960,6 +962,7 @@ fttrx1: deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME deviceConfig: prefix: X12SA-ES1-FTS1:TRX1 + tolerance: 0.2 onFailure: retry enabled: true readoutPriority: baseline @@ -1008,6 +1011,7 @@ fttrz: deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME deviceConfig: prefix: X12SA-ES1-FTS1:TRZ1 + tolerance: 0.2 onFailure: retry enabled: true readoutPriority: baseline @@ -1092,6 +1096,7 @@ dttrz: deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME deviceConfig: prefix: X12SA-ES1-DETT:TRZ1 + tolerance: 0.2 onFailure: retry enabled: true readoutPriority: baseline @@ -1151,7 +1156,7 @@ gain_bpm_xbox2: - bl_endstation bpm_xbox2_slowrb: - description: BPM Xbox 2 (First Xbox in ES hutch) readback + description: BPM Xbox 2 (First Xbox in ES hutch) readback (recommended gain 1e8) deviceClass: csaxs_bec.devices.pseudo_devices.bpm.BPM deviceConfig: left_top_ref: galilrioesxbox.analog_in.ch0 diff --git a/csaxs_bec/device_configs/bl_optics_hutch.yaml b/csaxs_bec/device_configs/bl_optics_hutch.yaml index 1267244f..411b809c 100644 --- a/csaxs_bec/device_configs/bl_optics_hutch.yaml +++ b/csaxs_bec/device_configs/bl_optics_hutch.yaml @@ -209,6 +209,7 @@ xbpm2x: userParameter: init_position: 22.5 in_position: -1.5 + # in_position: 0.5 (from Excel file) # bl_smar_stage to use csaxs reference method. assign number according to axis channel bl_smar_stage: 0 deviceTags: diff --git a/csaxs_bec/device_configs/machine.yml b/csaxs_bec/device_configs/machine.yml index 6610aecb..72fecf75 100644 --- a/csaxs_bec/device_configs/machine.yml +++ b/csaxs_bec/device_configs/machine.yml @@ -51,3 +51,32 @@ operator: softwareTrigger: false deviceTags: - machine + +injection_status: + # Top-up gating: '0' / 'Filling Storage Ring' means the storage ring is + # being refilled. readoutPriority is 'monitored' (not 'baseline') so this + # is captured continuously throughout the scan, not just read once at + # scan start - a top-up can start or stop mid-scan. + # + # Enum values of AGETI-CVME-MASTER-TMA:SR-Inj-Status-I (8 states): + # 0: Filling Storage Ring <- top-up in progress + # 1: Waiting manual trig... + # 2: Electron gun event OFF! + # 3: SR Injection ready + # 4: Inj. events OFF + # 5: Beam Dump + # 6: !Event LI-RF set on 10Hz! + # 7: !Injection guard inhibit! + description: SLS injection/top-up status + deviceClass: ophyd_devices.EpicsSignalRO + deviceConfig: + read_pv: AGETI-CVME-MASTER-TMA:SR-Inj-Status-I + auto_monitor: true + string: true + onFailure: buffer + enabled: true + readoutPriority: monitored + readOnly: true + softwareTrigger: false + deviceTags: + - machine diff --git a/csaxs_bec/device_configs/main.yaml b/csaxs_bec/device_configs/main.yaml index 48261723..7a45e202 100644 --- a/csaxs_bec/device_configs/main.yaml +++ b/csaxs_bec/device_configs/main.yaml @@ -16,11 +16,11 @@ endstation: detectors: - !include ./bl_detectors.yaml -xeye: - - !include ./xeye.yaml +# xeye: +# - !include ./xeye.yaml -ssaxs: - - !include ./ssaxs.yaml +# ssaxs: +# - !include ./ssaxs.yaml #sastt: # - !include ./sastt.yaml @@ -31,8 +31,8 @@ ssaxs: #omny: # - !include ./ptycho_omny.yaml -#lamni: -# - !include ./ptycho_lamni.yaml +lamni: + - !include ./ptycho_lamni.yaml # user setup: # - !include ./user_setup.yaml diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index e6b0b8e4..9b3c278d 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -21,8 +21,9 @@ feyex: userParameter: in: -16.453 out: -1 - fttrx_in: 2.3 - fttrx_out: -24 + fttrx_in: 11.025 #-2.3 + fttrx_out: -7 #-20 + #fttrx_out: -27 deviceTags: - ptycho_flomni @@ -46,6 +47,7 @@ feyey: in: -10.09 deviceTags: - ptycho_flomni + fheater: description: Heater Y deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor @@ -62,6 +64,10 @@ fheater: readOnly: false readoutPriority: baseline connectionTimeout: 20 + userParameter: + #in: -0.5 + in: -11.5 + enabled: 0 deviceTags: - ptycho_flomni @@ -82,18 +88,24 @@ foptx: readoutPriority: baseline connectionTimeout: 20 userParameter: + #120 micros, 60 + # in: -13.890 + # out: -14 + #150 micros, 60 + # in: -14.191 + # out: -13.831 #170 micron, 60 nm - in: -13.831 - out: -13.831 + #in: -13.8909 + #out: -13.831 #250 micron, 30 nm, Abe structures # in: -13.8809375 # out: -14.1809 #250 micron, 30 nm, Tomas structures - # in: -14.5490625 - # out: -14.1809 + in: -14.5490625 + out: -14.1809 fzp_diameter: 170 # microns fzp_outermost_zone_width: 60 # nm - detector_distance: -1 # mm, sample-to-detector; unknown for now + detector_distance: 7196 # mm, sample-to-detector; unknown for now fzp_details: "manufacturing notes here" # free-text FZP manufacturing notes deviceTags: - ptycho_flomni @@ -115,15 +127,18 @@ fopty: readoutPriority: baseline connectionTimeout: 20 userParameter: + #150 micron, 60 + # in: 1.02 + # out: 0.869 #170 micron, 60 nm - in: 0.42 - out: 0.57 + #in: 0.42 + #out: 0.57 #250 micron, 30 nm, Abe structures # in: 2.8299 # out: 2.8299 #250 micron, 30 nm, Tomas structures - # in: 2.8419921875 - # out: 2.8419921875 + in: 2.8419921875 + out: 2.8419921875 deviceTags: - ptycho_flomni @@ -277,7 +292,7 @@ ftransy: readoutPriority: baseline connectionTimeout: 20 userParameter: - sensor_voltage: -1.6 + sensor_voltage: -2.2 deviceTags: - ptycho_flomni ftransz: @@ -354,18 +369,24 @@ fosax: readoutPriority: baseline connectionTimeout: 20 userParameter: + #120micron, 60 nm, 7.9 kev + # in: 8.74251 + # out: 5.1 + #150micron, 60 nm, 7.9 kev + # in: 9.0393 + # out: 5.1 #170 micron, 60 nm, 7.6 kev - #in: 8.7568 - #out: 5.1 + # in: 8.7568 + # out: 5.1 #170 micron, 60 nm, 7.9 kev - in: 8.727079 - out: 5.1 + #in: 8.718233 + #out: 5.1 #250 micron, 30 nm, Abe structures # in: 8.7392 # out: 5.1 #250 micron, 30 nm, Tomas structures - # in: 9.420798 - # out: 5.1 + in: 9.400897 + out: 5.1 deviceTags: - ptycho_flomni @@ -386,14 +407,18 @@ fosay: readoutPriority: baseline connectionTimeout: 20 userParameter: + #120 micron, 60 nm, 7.9 kev + # in: -0.645276 + #150 micron, 60 nm, 7.9 kev + #in: -0.6422 #170 micron, 60 nm, 7.6 kev #in: -0.0235 #170 micron, 60 nm, 7.9 kev - in: -0.04603 + #in: -0.062253 #250 micron, 30 nm, Abe structures # in: -2.3684 #250 micron, 30 nm, Tomas structures - # in: -2.383993 + in: -2.401459 deviceTags: - ptycho_flomni fosaz: @@ -413,18 +438,25 @@ fosaz: readoutPriority: baseline connectionTimeout: 20 userParameter: + #150 micron, 60 nm + # in: 11.0998 + # out: 6 #170 micron, 60 nm, 7.6 kev #in: 8.5 #out: 6 #170 micron, 60 nm, 7.9 kev, foptz 16.9, probe size 7.5 mu - in: 13.1 - out: 6 + #in: 14.1 + #out: 4 # micron, 30 nm, 7.9 kev, very close to the sample. make sure foptz is 32.02 or smaller //abe's fzp's - # in: 0.5 - # out: -5 + in: 0.4 + out: -5 deviceTags: - ptycho_flomni + + + + ############################################################ #################### flOMNI RT motors ###################### ############################################################ @@ -625,31 +657,84 @@ calculated_signal: ############################################################ #################### OMNY Pandabox ######################### ############################################################ -omny_panda: +# Mode A (legacy per-gate aggregate acquisition) and Mode B (continuous free-running +# raw-sample acquisition, see docs/developer/panda_box_free_running_setup.md) need different +# PandA hardware layouts and can't run simultaneously (see that doc's "Open question"). Only +# one of the two devices below should be active at a time, matching whichever layout is +# currently loaded on the PandA. +# +# NOTE 2026-09-15: setting enabled: false on omny_panda_continuous was not sufficient by +# itself to stop it being active in the running BEC session (both devices ended up +# connected/active simultaneously against the same physical PandA at once) - a device that +# was already connected apparently doesn't get disconnected just by disabling it in a config +# reload. Commenting the block out is the reliable way to guarantee it's not loaded; do the +# same for omny_panda when switching to Mode B, and don't rely on enabled: false alone +# without also confirming (e.g. restarting the device server) that the other device has +# actually dropped its connection. +# +# Currently: Mode B active for speed testing beyond the initial 100 Hz, Mode A commented out +# (both modes confirmed working end-to-end 2026-09-15; this swap 2026-09-15 is for Mode B +# speed testing specifically). +# omny_panda: +# readoutPriority: async +# deviceClass: csaxs_bec.devices.panda_box.panda_box_omny.PandaBoxOMNY +# deviceConfig: +# host: omny-panda.psi.ch +# signal_alias: +# FMC_IN.VAL1.Min: cap_voltage_fzp_y_min +# FMC_IN.VAL1.Max: cap_voltage_fzp_y_max +# FMC_IN.VAL1.Mean: cap_voltage_fzp_y_mean +# FMC_IN.VAL2.Min: cap_voltage_fzp_x_min +# FMC_IN.VAL2.Max: cap_voltage_fzp_x_max +# FMC_IN.VAL2.Mean: cap_voltage_fzp_x_mean +# INENC1.VAL.Max: interf_st_fzp_y_max +# INENC1.VAL.Mean: interf_st_fzp_y_mean +# INENC1.VAL.Min: interf_st_fzp_y_min +# INENC2.VAL.Max: interf_st_fzp_x_max +# INENC2.VAL.Mean: interf_st_fzp_x_mean +# INENC2.VAL.Min: interf_st_fzp_x_min +# INENC3.VAL.Max: interf_st_rotz_max +# INENC3.VAL.Mean: interf_st_rotz_mean +# INENC3.VAL.Min: interf_st_rotz_min +# INENC4.VAL.Max: interf_st_rotx_max +# INENC4.VAL.Mean: interf_st_rotx_mean +# INENC4.VAL.Min: interf_st_rotx_min +# PCAP.GATE_DURATION.Value: pcap_gate_duration_value +# enabled: true +# readOnly: false +# softwareTrigger: false +# deviceTags: +# - ptycho_flomni + +omny_panda_continuous: readoutPriority: async deviceClass: csaxs_bec.devices.panda_box.panda_box_omny.PandaBoxOMNY deviceConfig: host: omny-panda.psi.ch + raw_stream_mode: true + # Only actually Arm() PandA for scans that consume its data -- generic BEC staging + # (stage_all_devices/pre_scan_all_devices) otherwise arms this device for every scan type + # (grid_scan, line_scan, alignment moves, ...), not just the ptycho scan that reads it. See + # docs/developer/panda_box_free_running_setup.md and + # AI_docs/PANDA_POSITION_VALIDATION.md sec 7 (stale frame_counter exposure). + arm_scan_allowlist: + - flomni_fermat_scan signal_alias: - FMC_IN.VAL1.Min: cap_voltage_fzp_y_min - FMC_IN.VAL1.Max: cap_voltage_fzp_y_max - FMC_IN.VAL1.Mean: cap_voltage_fzp_y_mean - FMC_IN.VAL2.Min: cap_voltage_fzp_x_min - FMC_IN.VAL2.Max: cap_voltage_fzp_x_max - FMC_IN.VAL2.Mean: cap_voltage_fzp_x_mean - INENC1.VAL.Max: interf_st_fzp_y_max - INENC1.VAL.Mean: interf_st_fzp_y_mean - INENC1.VAL.Min: interf_st_fzp_y_min - INENC2.VAL.Max: interf_st_fzp_x_max - INENC2.VAL.Mean: interf_st_fzp_x_mean - INENC2.VAL.Min: interf_st_fzp_x_min - INENC3.VAL.Max: interf_st_rotz_max - INENC3.VAL.Mean: interf_st_rotz_mean - INENC3.VAL.Min: interf_st_rotz_min - INENC4.VAL.Max: interf_st_rotx_max - INENC4.VAL.Mean: interf_st_rotx_mean - INENC4.VAL.Min: interf_st_rotx_min + FMC_IN.VAL1.Value: cap_voltage_fzp_y + FMC_IN.VAL2.Value: cap_voltage_fzp_x + INENC1.VAL.Value: interf_st_fzp_y + INENC2.VAL.Value: interf_st_fzp_x + INENC3.VAL.Value: interf_st_rotz + INENC4.VAL.Value: interf_st_rotx PCAP.GATE_DURATION.Value: pcap_gate_duration_value + # TTLIN1 (detector trigger) feeds both blocks below - confirmed 2026-09-15 against the + # omny-panda hardware, see docs/developer/panda_box_free_running_setup.md. PCAP.BITS0 + # is a shared 32-bit word; PandaBoxOMNY masks it to bit 0 (TTLIN1.VAL) before + # publishing gate_detector_active. COUNTER1 counts TTLIN1's falling edge, i.e. one + # increment per completed exposure; it also doubles as the on_complete signal. Confirmed + # working end-to-end 2026-09-15; now under speed testing beyond the initial 100 Hz. + PCAP.BITS0.Value: gate_detector_active + COUNTER1.OUT.Value: frame_counter enabled: true readOnly: false softwareTrigger: false diff --git a/csaxs_bec/device_configs/ptycho_lamni.yaml b/csaxs_bec/device_configs/ptycho_lamni.yaml index bb294dc3..8c0169ec 100644 --- a/csaxs_bec/device_configs/ptycho_lamni.yaml +++ b/csaxs_bec/device_configs/ptycho_lamni.yaml @@ -19,7 +19,13 @@ leyex: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: 14.117 + in: 13.18 + # flight tube / detector table positions used by leye_out() (*_in) and + # leye_in() (*_out) + fttrz_in: 1381.433 + fttrz_out: 2000 + dttrz_in: 12614.99 + dttrz_out: 13263 deviceTags: - ptycho_lamni leyey: @@ -39,7 +45,7 @@ leyey: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: 48.069 + in: 47.94 out: 0.5 deviceTags: - ptycho_lamni @@ -60,13 +66,21 @@ loptx: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: -0.244 - out: -0.699 + in: -0.8698 + #out: -0.8665 + out: -0.8659 # 170 micron, 60 nm - fzp_diameter: 170 # microns - fzp_outermost_zone_width: 60 # nm + fzp_diameter: 250 # microns + fzp_outermost_zone_width: 30 # nm detector_distance: -1 # mm, sample-to-detector; unknown for now - fzp_details: "manufacturing notes here" # free-text FZP manufacturing notes + fzp_details: "30 nm Abe Tomas 05/2026" # free-text FZP manufacturing notes + # in: -0.244 + # out: -0.699 + # # 170 micron, 60 nm + # fzp_diameter: 170 # microns + # fzp_outermost_zone_width: 60 # nm + # detector_distance: -1 # mm, sample-to-detector; unknown for now + # fzp_details: "manufacturing notes here" # free-text FZP manufacturing notes deviceTags: - ptycho_lamni lopty: @@ -86,8 +100,8 @@ lopty: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: 3.724 - out: 3.53 + in: 2.6266 + out: 3.029 deviceTags: - ptycho_lamni loptz: @@ -97,8 +111,8 @@ loptz: axis_Id: D host: mpc2680.psi.ch limits: - - 0 - - 0 + - 70 + - 83 port: 8081 sign: -1 enabled: true @@ -106,6 +120,10 @@ loptz: readOnly: false readoutPriority: baseline connectionTimeout: 20 + userParameter: + # OSA collides with the sample holder when loptz + losaz reaches this + # value (measured 2026-10-14: loptz 77.8676, losaz 10.0029) + collision_offset: 87.87 deviceTags: - ptycho_lamni lsamrot: @@ -144,7 +162,7 @@ lsamx: readoutPriority: baseline connectionTimeout: 20 userParameter: - center: 8.768 + center: 9.68 deviceTags: - ptycho_lamni lsamy: @@ -164,7 +182,7 @@ lsamy: readoutPriority: baseline connectionTimeout: 20 userParameter: - center: 10.041 + center: 9.47 deviceTags: - ptycho_lamni @@ -189,7 +207,7 @@ losax: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: -1.442 + in: -1.6437 deviceTags: - ptycho_lamni losay: @@ -209,7 +227,7 @@ losay: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: -0.171 + in: -0.2109 out: 3.8 deviceTags: - ptycho_lamni @@ -230,11 +248,54 @@ losaz: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: -1 + in: 6.8288 out: -3 deviceTags: - ptycho_lamni +lcsx: + description: CS X + deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor + deviceConfig: + axis_Id: D + host: mpc2680.psi.ch + limits: + - 0 + - 0 + port: 8085 + sign: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: -1.831 + deviceTags: + - ptycho_lamni + +lcsy: + description: CS Y + deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor + deviceConfig: + axis_Id: E + host: mpc2680.psi.ch + limits: + - 0 + - 0 + port: 8085 + sign: -1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: baseline + connectionTimeout: 20 + userParameter: + in: 1.0795 + out: 5 + deviceTags: + - ptycho_lamni + ############################################################ #################### flOMNI RT motors ###################### ############################################################ @@ -297,7 +358,7 @@ cam_xeye: deviceConfig: camera_id: 15 bits_per_pixel: 24 - num_rotation_90: 3 + num_rotation_90: 1 transpose: false force_monochrome: true m_n_colormode: 1 @@ -306,7 +367,7 @@ cam_xeye: readOnly: false readoutPriority: async userParameter: - pixel_calibration: 0.0009174311926605505 # mm/pixel (= 0.2 / 218) + pixel_calibration: 0.00045872 # mm/pixel (= 0.2 / 218) deviceTags: - ptycho_lamni #################### GUI Signals ########################### diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml index 97dd2f98..a4eaa26e 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml @@ -104,6 +104,9 @@ fheater: readOnly: false readoutPriority: baseline connectionTimeout: 20 + userParameter: + in: -0.5 + enabled: 1 deviceTags: - simulated_flomni diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml index b8ffe91e..63600cb4 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml @@ -141,7 +141,7 @@ lsamrot: sign: 1 sim_stppermm: 50154.32099 sim_encpermm: 36000 - sim_velocity: 5 # 48 #4x than hw + sim_velocity: 48 # 4x faster than hw sim_initial_position: 0 tolerance: 1 enabled: true diff --git a/csaxs_bec/device_configs/smargon.yaml b/csaxs_bec/device_configs/smargon.yaml new file mode 100644 index 00000000..fe004d1e --- /dev/null +++ b/csaxs_bec/device_configs/smargon.yaml @@ -0,0 +1,86 @@ +# Example BEC device-config entries for the SmarGon goniometer (via smargopolo). +# +# Per-axis architecture (like the smaract motors): each SCS axis is its own top-level +# motor sharing ONE SmargopoloController (singleton per host:port), plus a thin +# `smargon` coordinator for whole-goniometer ops. All entries must use the SAME +# host/port so they share the controller. +# +# The motor NAME (the top-level key: sgx, sgchi, ...) is yours to choose. The +# `scs_axis` field binds it to the fixed smargopolo axis (SHX/SHY/SHZ/CHI/PHI/OMEGA). +# +# Referencing is a deliberate operator action: after loading, run once +# dev.smargon.reference() # (or dev.sgx.controller.reference()) + +# --- Coordinator (whole-goniometer ops: reference/mode/move_scs/diagnostics) ------ +smargon: + description: SmarGon coordinator (referencing, mode, coordinated moves) + deviceClass: csaxs_bec.devices.smargon.smargon.SmarGon + deviceConfig: + host: tt-smargopolo # cSAXS beamline smargopolo REST API + port: 3000 # REST API port (per maintainer). NB: :8080 is the web GUI, not the API + timeout: 5.0 + deviceTags: [goniometer, smargon] + enabled: true + onFailure: retry + readOnly: false + readoutPriority: baseline + softwareTrigger: false + +# --- SCS axis motors -------------------------------------------------------------- +# Limits from the MX-lab reference client (resources_2/Smargon.py) -- VERIFY per build. +# NOTE: SHZ is not centred on 0 (home ~18); PHI is continuous (client wraps to +# [-180,180) -- open question whether smargopolo wraps server-side). +sgx: + description: SmarGon SHX (sample X) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: {scs_axis: SHX, host: tt-smargopolo, port: 3000, limits: [-2, 2], tolerance: 0.001} + deviceTags: [goniometer, smargon] + enabled: true + onFailure: retry + readoutPriority: baseline + +sgy: + description: SmarGon SHY (sample Y) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: {scs_axis: SHY, host: tt-smargopolo, port: 3000, limits: [-2, 2], tolerance: 0.001} + deviceTags: [goniometer, smargon] + enabled: true + onFailure: retry + readoutPriority: baseline + +sgz: + description: SmarGon SHZ (sample Z) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: {scs_axis: SHZ, host: tt-smargopolo, port: 3000, limits: [10, 22], tolerance: 0.001} + deviceTags: [goniometer, smargon] + enabled: true + onFailure: retry + readoutPriority: baseline + +sgchi: + description: SmarGon CHI (goniometer tilt) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: {scs_axis: CHI, host: tt-smargopolo, port: 3000, limits: [0, 65], tolerance: 0.01} + deviceTags: [goniometer, smargon] + enabled: true + onFailure: retry + readoutPriority: baseline + +sgphi: + description: SmarGon PHI (goniometer rotation) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: {scs_axis: PHI, host: tt-smargopolo, port: 3000, limits: [-180, 180], tolerance: 0.01} + deviceTags: [goniometer, smargon] + enabled: true + onFailure: retry + readoutPriority: baseline + +# --- Optional OMEGA rotation drive (only on builds that have it) ------------------- +# sgomega: +# description: SmarGon OMEGA (rotation drive) +# deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor +# deviceConfig: {scs_axis: OMEGA, host: tt-smargopolo, port: 3000, tolerance: 0.01} +# deviceTags: [goniometer, smargon] +# enabled: true +# onFailure: retry +# readoutPriority: baseline diff --git a/csaxs_bec/device_configs/smargon_sim_session.yaml b/csaxs_bec/device_configs/smargon_sim_session.yaml new file mode 100644 index 00000000..d04fed72 --- /dev/null +++ b/csaxs_bec/device_configs/smargon_sim_session.yaml @@ -0,0 +1,124 @@ +# Self-contained laptop test session for the SmarGon device (SIMULATION). +# +# Per-axis motors (dev.sgx, dev.sgy, dev.sgz, dev.sgchi, dev.sgphi, dev.sgomega) + +# a `smargon` coordinator, all sharing one simulated controller, plus a sim monitor +# so scans record data. No smargopolo server / hardware needed. +# +# Load in the client: +# bec.config.update_session_with_file( +# '/Users/menzel/BEC_development/stack/csaxs_bec-smargon/csaxs_bec/device_configs/smargon_sim_session.yaml') +# Then: dev.smargon.reference(); umv(dev.sgx, 0.5); dev.wm('sg*') +# +# NOTE: update_session_with_file REPLACES the current session. PREREQUISITE: the +# running BEC must import csaxs_bec.devices.smargon (repoint the editable install at +# the smargon worktree; see RUNNING_BEC.md). + +smargon: + description: SmarGon coordinator (SIMULATION) + deviceClass: csaxs_bec.devices.smargon.smargon.SmarGon + deviceConfig: { simulation: true, gradual_motion: true } + deviceTags: [goniometer, smargon, simulation] + enabled: true + onFailure: retry + readoutPriority: baseline + +sgx: + description: SmarGon SHX (sim) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: + { + scs_axis: SHX, + simulation: true, + gradual_motion: true, + limits: [-2, 2], + tolerance: 0.001, + } + deviceTags: [goniometer, smargon, simulation] + enabled: true + onFailure: retry + readoutPriority: baseline + +sgy: + description: SmarGon SHY (sim) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: + { + scs_axis: SHY, + simulation: true, + gradual_motion: true, + limits: [-2, 2], + tolerance: 0.001, + } + deviceTags: [goniometer, smargon, simulation] + enabled: true + onFailure: retry + readoutPriority: baseline + +sgz: + description: SmarGon SHZ (sim) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: + { + scs_axis: SHZ, + simulation: true, + gradual_motion: true, + limits: [10, 22], + tolerance: 0.001, + } + deviceTags: [goniometer, smargon, simulation] + enabled: true + onFailure: retry + readoutPriority: baseline + +sgchi: + description: SmarGon CHI (sim) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: + { + scs_axis: CHI, + simulation: true, + gradual_motion: true, + limits: [0, 65], + tolerance: 0.01, + } + deviceTags: [goniometer, smargon, simulation] + enabled: true + onFailure: retry + readoutPriority: baseline + +sgphi: + description: SmarGon PHI (sim) + deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor + deviceConfig: + { + scs_axis: PHI, + simulation: true, + gradual_motion: true, + limits: [-180, 180], + tolerance: 0.01, + } + deviceTags: [goniometer, smargon, simulation] + enabled: true + onFailure: retry + readoutPriority: baseline + +# sgomega: +# description: SmarGon OMEGA (sim) +# deviceClass: csaxs_bec.devices.smargon.positioner.SmargopoloMotor +# deviceConfig: {scs_axis: OMEGA, simulation: true, gradual_motion: true, tolerance: 0.01} +# deviceTags: [goniometer, smargon, simulation] +# enabled: true +# onFailure: retry +# readoutPriority: baseline + +sim_mon: + description: Simulated 1D monitor (readout for test scans over SmarGon axes) + deviceClass: ophyd_devices.SimMonitor + deviceConfig: + sim_init: + model: "GaussianModel" + deviceTags: [monitor, simulation] + enabled: true + onFailure: retry + readoutPriority: monitored + softwareTrigger: false diff --git a/csaxs_bec/device_configs/test_ids_camera_41.yaml b/csaxs_bec/device_configs/test_ids_camera_41.yaml new file mode 100644 index 00000000..d865cd04 --- /dev/null +++ b/csaxs_bec/device_configs/test_ids_camera_41.yaml @@ -0,0 +1,26 @@ +# TEMPORARY test config: a single IDS color camera (camera_id 41), used to +# manually verify the exposure/auto-gain controls added in +# docs/plans/ids-camera-manual-exposure.md against real hardware, without +# reconfiguring a production beamline. Named "cam_xeye" so the OMNY_XRayEye +# widget (which hardcodes that device name) can be pointed at it directly. +# +# Delete this file once real-hardware verification of the exposure/gain +# feature is done -- it is not meant to be loaded in a production session. +cam_xeye: + description: Test IDS color camera (ID 41) for exposure/auto-gain widget verification + deviceClass: csaxs_bec.devices.ids_cameras.ids_camera.IDSCamera + deviceConfig: + camera_id: 41 + bits_per_pixel: 24 + num_rotation_90: 0 + transpose: false + force_monochrome: false + m_n_colormode: 1 + enabled: true + onFailure: buffer + readOnly: false + readoutPriority: async + userParameter: + pixel_calibration: 1.0 + deviceTags: + - test_config diff --git a/csaxs_bec/device_configs/user_template.yaml b/csaxs_bec/device_configs/user_template.yaml index c279f293..122f9c51 100644 --- a/csaxs_bec/device_configs/user_template.yaml +++ b/csaxs_bec/device_configs/user_template.yaml @@ -21,6 +21,7 @@ samx: ############################################################ ########### OWIS LTM80 with micro-stepping ################# ############################################################ +# usually controller 18/19/20; Irun 1; Step F; both switches to the left (off); Adress 0 samy: description: Owis motor stage samx deviceClass: ophyd_devices.devices.psi_motor.EpicsUserMotorVME diff --git a/csaxs_bec/device_configs/xeye.yaml b/csaxs_bec/device_configs/xeye.yaml index 2539bdd5..7f30b2b4 100644 --- a/csaxs_bec/device_configs/xeye.yaml +++ b/csaxs_bec/device_configs/xeye.yaml @@ -5,7 +5,7 @@ eyex: prefix: X12SA-ES2-ES04 motor_resolution: 0.00125 base_velocity: 0.0625 - velocity: 1 + velocity: 5 backlash_distance: 0.125 acceleration: 0.2 user_offset_dir: 0 @@ -25,7 +25,7 @@ eyey: prefix: X12SA-ES2-ES05 motor_resolution: 0.00125 base_velocity: 0.0625 - velocity: 1 + velocity: 5 backlash_distance: 0.125 acceleration: 0.2 user_offset_dir: 0 @@ -38,19 +38,19 @@ eyey: softwareTrigger: false -cam_xeye: - description: Camera Xray eye ID1 - deviceClass: csaxs_bec.devices.ids_cameras.ids_camera.IDSCamera - deviceConfig: - camera_id: 1 - bits_per_pixel: 24 - num_rotation_90: 2 - transpose: true - force_monochrome: true - m_n_colormode: 1 - enabled: true - onFailure: buffer - readOnly: false - readoutPriority: async +# cam_xeye: +# description: Camera Xray eye ID1 +# deviceClass: csaxs_bec.devices.ids_cameras.ids_camera.IDSCamera +# deviceConfig: +# camera_id: 2 +# bits_per_pixel: 24 +# num_rotation_90: 2 +# transpose: true +# force_monochrome: true +# m_n_colormode: 1 +# enabled: true +# onFailure: buffer +# readOnly: false +# readoutPriority: async # userParameter: # pixel_calibration: 0.00044247787610619477 # mm/pixel (= 0.05 / 113) diff --git a/csaxs_bec/devices/allied_vision_cameras/allied_vision_aravis_camera.py b/csaxs_bec/devices/allied_vision_cameras/allied_vision_aravis_camera.py index 5e41faf0..92a41c7f 100644 --- a/csaxs_bec/devices/allied_vision_cameras/allied_vision_aravis_camera.py +++ b/csaxs_bec/devices/allied_vision_cameras/allied_vision_aravis_camera.py @@ -247,6 +247,8 @@ class AlliedVisionAravisCamera(PSIDeviceBase): def on_destroy(self): """Clean up resources when the device is destroyed.""" + # Stop acquisition before disconnecting so the live thread cannot keep polling. + self.stop_live_mode() self.cam.on_disconnect() super().on_destroy() diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py index ad05299d..bf1151d1 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -21,7 +21,7 @@ DELAY PAIRS: PULSE train for the MCS card. The MCS card needs one extra pulse to forward points. DELAY CHANNELS: -- a = t0 + 2ms (2ms delay to allow the shutter to open) +- a = t0 + DEFAULT_SHUTTER_TO_OPEN_DELAY (to allow the shutter to open) - b = a + 1us (short pulse) - c = t0 - d = a + exp_time * burst_count @@ -92,6 +92,81 @@ DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = { DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.SINGLE_SHOT +# NOTE How long the shutter is given to open before the first gate of a burst. +# The shutter fires on `cd` at t0 and the acquisition on `ab` is held back by this +# much, so whatever the shutter still owes at that point is lost from the FIRST +# point of every line and from nothing else. +# +# Measured 2026-08-25 from the first-point deficit in four commissioning scans, +# against the 2e-3 that was in place when they were taken: +# +# scan exp_time first/near lost +# 324 15 ms 0.655 5.18 ms +# 254 20 ms 0.732 5.36 ms +# 411 50 ms 0.881 5.93 ms +# 450 50 ms 0.879 6.04 ms +# +# The deficit is a fixed time, not a fixed fraction: it varies by 17% across a +# 3.3x range of exposure while the fraction varies by 2.9x. That is what makes it +# a shutter opening late rather than anything dose-dependent. The same deficit +# appears on the integrated scattering -- a different detector behind a different +# gate -- so the cause sits upstream of both readout chains. +# +# 2e-3 already allowed, ~5.6e-3 still lost => the shutter needs about 7.6 ms, +# rounded up to 8 ms. The spread across the four scans is 0.9 ms, so the third +# digit is not meaningful, and overshooting costs only the difference in dead +# time at the start of each line. +# To re-measure after changing this, take the first point of each line over the +# mean of its next few neighbours, averaged over all lines, and multiply the +# shortfall by exp_time. That is the exposure still being lost, in seconds. +# Compare against its own neighbours rather than the whole-line mean: a scan +# whose intensity drifts along the line otherwise reports a deficit that is not +# there. +DEFAULT_SHUTTER_TO_OPEN_DELAY = 8e-3 + +# Guard rail for set_shutter_to_open_delay. The delay is paid once per line, so a +# fat-fingered value would quietly stretch every line rather than fail. +MAX_SHUTTER_TO_OPEN_DELAY = 50e-3 + +# NOTE How long it takes a move issued by the scan server to reach the motor. +# +# cont_grid issues the line move and fires the burst a few milliseconds later, on +# the assumption that the only thing in between is the acceleration ramp. It is +# not: the move first has to cross the scan server -> device server -> EPICS -> +# controller chain, and that latency does not shrink when the scan is slow while +# the computed acc_time does. The burst therefore starts before the stage. +# +# Measured 2026-08-25 from the position readback of five commissioning scans, as +# the distance the stage was behind the trigger grid once the lag stopped growing: +# +# scan exp_time v_cmd lag latency +# 254 20 ms 0.5 mm/s 29.7 pts 0.594 s +# 450 50 ms 0.1 mm/s 11.9 pts 0.595 s +# 324 15 ms 0.667 mm/s 41.5 pts 0.623 s +# 473 100 ms 0.065 mm/s 6.3 pts 0.630 s +# 411 50 ms 0.1 mm/s 12.7 pts 0.635 s +# +# Constant to +-3.5% across a 6.7x range of exposure and a 10x range of velocity, +# on two axes, which is what makes compensating with a constant sound. In scan +# 473, sampled 74 times per line, the lag appears in the first interval and then +# holds at 6.3-6.4 points for the rest of the line while the velocity sits at +# exactly the commanded 0.065 mm/s: a start-up offset, not a velocity error. +# +# Deliberately NOT solved by polling for motion. Observing the readback costs a +# round trip of this same ~0.6 s, so it would trade a systematic offset for a +# jitter of similar size -- and a constant offset displaces every line equally, +# where a varying one shears the image line by line and cannot be undone +# afterwards. The real fix is to trigger the DDG from the motor +# (scan_type: hardware_triggered), taking the round trip out of the timing chain +# altogether; this is the stopgap until then. +# +# Set to 0 to get the previous behaviour back without redeploying. +DEFAULT_MOVE_START_LATENCY = 0.6 + +# Guard rail for set_move_start_latency. Paid once per line, like the shutter +# delay, so an implausible value costs scan time rather than raising. +MAX_MOVE_START_LATENCY = 5.0 + # NOTE Default readout times for each channel, can be adapted as needed. # These values are relevant to calculate proper widths of the timing signals. # They also define a minimum exposure time that can be used as they are subtracted @@ -103,7 +178,7 @@ DEFAULT_READOUT_TIMES = {"ab": 2e-4, "cd": 2e-4, "ef": 2e-4, "gh": 2e-4} # 0.2 # If the trigger scheme changes, adapt the values here together with the README and # PDF `trigger_scheme_ddg1_ddg2.pdf`. DEFAULT_REFERENCES: list[tuple[LiteralChannels, CHANNELREFERENCE]] = [ - ("A", CHANNELREFERENCE.T0), # T0 + 2ms delay + ("A", CHANNELREFERENCE.T0), # T0 + shutter-to-open delay ("B", CHANNELREFERENCE.A), ("C", CHANNELREFERENCE.T0), # T0 ("D", CHANNELREFERENCE.C), @@ -140,7 +215,11 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): "keep_shutter_open_during_scan", "set_trigger", "get_shutter_to_open_delay", + "set_shutter_to_open_delay", + "get_move_start_latency", + "set_move_start_latency", "prepare_mcs_on_trigger", + "show_all", ] # TODO Consider using the 'fsh' device instead. @@ -173,7 +252,8 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): super().__init__( name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs ) - self._shutter_to_open_delay = 2e-3 + self._shutter_to_open_delay = DEFAULT_SHUTTER_TO_OPEN_DELAY + self._move_start_latency = DEFAULT_MOVE_START_LATENCY self.device_manager = device_manager self._poll_thread = threading.Thread(target=self._poll_event_status, daemon=True) self._poll_thread_run_event = threading.Event() @@ -244,11 +324,70 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): """Get the current delay that is set to open the shutter before the exposure time.""" return self._shutter_to_open_delay + def get_move_start_latency(self) -> float: + """Get the delay a scan should leave between issuing a line move and triggering.""" + return self._move_start_latency + + def set_move_start_latency(self, latency: float) -> None: + """Set how long a scan waits after issuing a line move before it triggers. + + This is not a property of the delay generator. It is the latency of the + scan server -> device server -> EPICS -> controller path, and it lives here + because this is where cont_grid already fetches its trigger timing. Too + small and the burst starts before the stage does, so the first points of + every line pile up at the line start; too large and the stage runs past + the start of the line before the first gate fires. + + Set to 0 for the behaviour that preceded this parameter. + + Args: + latency (float): Delay in seconds. + + Raises: + ValueError: If negative or above MAX_MOVE_START_LATENCY. + """ + latency = float(latency) + if not 0 <= latency <= MAX_MOVE_START_LATENCY: + raise ValueError( + f"Move-start latency must be between 0 and {MAX_MOVE_START_LATENCY} s," + f" got {latency}." + ) + self._move_start_latency = latency + + def set_shutter_to_open_delay(self, delay: float) -> None: + """Set how long the shutter is given to open before the first gate fires. + + Exists so the value can be converged without redeploying the device server. + Too small and the first point of every line is under-exposed; too large and + every line is stretched by the difference for nothing. To measure the + result, see the note at DEFAULT_SHUTTER_TO_OPEN_DELAY. + + Args: + delay (float): Delay in seconds. 0 starts the acquisition together with + the shutter trigger. + + Raises: + ValueError: If the delay is negative or above MAX_SHUTTER_TO_OPEN_DELAY. + """ + delay = float(delay) + if not 0 <= delay <= MAX_SHUTTER_TO_OPEN_DELAY: + raise ValueError( + f"Shutter-to-open delay must be between 0 and" + f" {MAX_SHUTTER_TO_OPEN_DELAY} s, got {delay}." + ) + self._shutter_to_open_delay = delay + def keep_shutter_open_during_scan(self, open: True) -> None: """ Method to configure the delay generator for keeping the shutter open during a scans. - This means that the additional delay to open the shutter needs to be removed (2e-3) - from the timing of the signals. + This means that the additional delay to open the shutter needs to be removed + from the timing of the signals: a shutter that is already open owes nothing + to the first gate. + + NOTE Switching this off restores DEFAULT_SHUTTER_TO_OPEN_DELAY, and so + discards any value set with set_shutter_to_open_delay. Tune with the setter + and leave this alone, or change the default if the tuned value is the one + the beamline should keep. Args: open (bool): If True, the shutter will be kept open during the scan. @@ -257,7 +396,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): if open is True: self._shutter_to_open_delay = 0 else: - self._shutter_to_open_delay = 2e-3 + self._shutter_to_open_delay = DEFAULT_SHUTTER_TO_OPEN_DELAY def on_stage(self) -> None: """ @@ -274,8 +413,10 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): - We check if any default burst parameters need to be set, and set them if needed. - We calculate the burst pulse width based on the exposure time and frames_per_trigger. - We set the burst_period and the shutter signal (delay pairs cd) to be - exposure_time * frames_per_trigger + 3ms (2ms for shutter to open, 1ms to close). - - We set the delay pairs ab to be 2ms delayed (to allow the shutter to open) with a width of 1us to trigger DDG2. + exposure_time * frames_per_trigger + twice the shutter-to-open delay + (once to open, once to close). + - We set the delay pairs ab to be delayed by the shutter-to-open delay (to + allow the shutter to open) with a width of 1us to trigger DDG2. - We set the delay pairs ef to be triggered after the shutter closes with a width of 1us to trigger the MCS card. - Finally, we add a short sleep to ensure that the IOC and DDG HW process the values properly. """ @@ -333,7 +474,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): self.burst_period.put(total_exposure_time) # Trigger DDG2 - # a = t0 + 2ms, b = a + 1us + # a = t0 + shutter-to-open delay, b = a + 1us # a has reference to t0, b has reference to a # AB is delayed by the shutter opening time, and the falling edge indicates the shutter has # fully closed, it has to be considered as the blocking signal for the next acquisition to start. @@ -343,7 +484,8 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): # Trigger shutter # d = c/t0 + self._shutter_to_open_delay + exp_time * burst_count + 1ms # c has reference to t0, d has reference to c - # Shutter opens without delay at t0, closes after exp_time * burst_count + 2ms (self._shutter_to_open_delay) + # Shutter opens without delay at t0, closes after + # exp_time * burst_count + self._shutter_to_open_delay self.set_delay_pairs(channel="cd", delay=0, width=shutter_width) self.set_delay_pairs( diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py index 0141d5dc..7b19dda8 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py @@ -22,7 +22,10 @@ Burst mode is enabled: - Burst period is set to the exposure time. """ +from __future__ import annotations + import time +from typing import TYPE_CHECKING from bec_lib.logger import bec_logger from bec_server.scan_server.scans.scan_base import ScanInfo as ScanServerScanInfo @@ -42,6 +45,9 @@ from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import ) from csaxs_bec.devices.utils.utils import fetch_scan_info +if TYPE_CHECKING: # pragma: no cover + from bec_lib.devicemanager import DeviceManagerBase, ScanInfo + logger = bec_logger.logger ######################## @@ -113,6 +119,101 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): distributes the trigger to the detectors. The DDG2 is triggered by the DDG1 through the EXT/EN channel. """ + # Methods exposed to the BEC client, i.e. callable as dev.ddg2.() + USER_ACCESS = [ + "get_readout_times", + "set_readout_times", + "effective_readout_times", + "show_all", + ] + + def __init__( + self, + name: str, + prefix: str = "", + scan_info: ScanInfo | None = None, + device_manager: DeviceManagerBase | None = None, + readout_times: dict[str, float] | None = None, + **kwargs, + ): + """ + Args: + readout_times: per-channel-pair readout times in seconds, merged over + DEFAULT_READOUT_TIMES. Only the pairs given are overridden, e.g. + ``{"ab": 3.0e-3}``. See set_readout_times() for what this controls. + """ + super().__init__( + name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs + ) + self._readout_times: dict[str, float] = dict(DEFAULT_READOUT_TIMES) + if readout_times: + self.set_readout_times(**readout_times) + + def effective_readout_times(self) -> dict[str, float]: + """Return the readout times actually in force for the upcoming scan. + + The scan may request a readout time of its own; it wins when it is larger than + this device's configured value. Scans default readout_time to 0, so the + configured value is the floor rather than a fallback. + + The result defines the exposure window -- period minus gap -- that every + detector in the scan is expected to integrate over, whether it reads the gate + (Falcon) or runs its own timer from the rising edge (Eiger). + + Returns: + dict[str, float]: effective readout time per channel pair, in seconds. + """ + scan_readout = 0.0 + if self.scan_parameters is not None: + scan_readout = float(getattr(self.scan_parameters, "readout_time", 0.0) or 0.0) + return {ch: max(scan_readout, rt) for ch, rt in self._readout_times.items()} + + def get_readout_times(self) -> dict[str, float]: + """Return the readout times per channel pair, in seconds. + + Returns: + dict[str, float]: a copy of the effective readout times. + """ + return dict(self._readout_times) + + def set_readout_times(self, **channels: float) -> dict[str, float]: + """Set the readout time of one or more channel pairs, in seconds. + + The readout time is subtracted from the exposure time to obtain the pulse + width, while the burst period stays at the exposure time. Raising it therefore + widens the gap between consecutive detector triggers and shortens the exposure + by the same amount; the frame rate is unaffected. + + This matters for detectors that need a minimum dead time between frames. The + default of 0.2 ms is shorter than some detectors can handle -- FalconcSAXS, for + instance, declares MIN_READOUT = 3 ms -- and a value that is too short shows up + as silently dropped frames rather than as an error. + + Channel pair 'ab' is the one multiplexed to the detectors, so it is normally the + only one worth changing. The new value applies from the next on_stage, i.e. the + next scan; it does not alter a scan already staged. + + Args: + **channels: channel pair to readout time in seconds, e.g. ``ab=3.0e-3``. + + Returns: + dict[str, float]: the effective readout times after the update. + + Raises: + ValueError: for an unknown channel pair or a non-positive value. + """ + for channel, value in channels.items(): + if channel not in self._readout_times: + raise ValueError( + f"Unknown channel pair {channel!r}; expected one of " + f"{sorted(self._readout_times)}" + ) + if value <= 0: + raise ValueError(f"readout time for {channel!r} must be > 0, got {value}") + self._readout_times[channel] = float(value) + logger.info(f"DDG {self.name} readout times set to {self._readout_times}") + return self.get_readout_times() + def on_init(self) -> None: """Initialize the device""" self.scan_parameters: ScanServerScanInfo | None = None @@ -193,9 +294,14 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): # NOTE Check if the exposure time is longer than all readout times. # Raise a ValueError if requested exposure time is too short. - if any(exp_time <= rt for rt in DEFAULT_READOUT_TIMES.values()): + # NOTE The gap is the scan's readout_time when the scan asks for one, otherwise this + # device's configured value. Scans default readout_time to 0, hence the max(). + # Every detector must derive its exposure window from the same number -- see + # effective_readout_times(). + readouts = self.effective_readout_times() + if any(exp_time <= rt for rt in readouts.values()): raise ValueError( - f"Exposure time {exp_time} is too short for the readout times {DEFAULT_READOUT_TIMES}" + f"Exposure time {exp_time} is too short for the readout times {readouts}" ) ######################################### @@ -208,8 +314,13 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): if self.burst_period.get() != exp_time: self.burst_period.put(exp_time) - # Calculate the pulse width for the channel pair 'ab' - burst_pulse_width = exp_time - DEFAULT_READOUT_TIMES["ab"] + # Calculate the pulse width for the channel pair 'ab'. The readout time is the + # gap left between consecutive triggers; see set_readout_times(). + burst_pulse_width = exp_time - readouts["ab"] + logger.info( + f"DDG {self.name} exposure window: period {exp_time}s, gap {readouts['ab']}s, " + f"pulse width {burst_pulse_width}s" + ) # Trigger detectors with delay 0, and pulse width = exp_time - readout_time self.set_delay_pairs(channel="ab", delay=0, width=burst_pulse_width) diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py b/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py index a5cdeffc..5efcb5f7 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py @@ -18,6 +18,7 @@ from bec_lib.logger import bec_logger from ophyd import Component as Cpt from ophyd import Device, EpicsSignal, EpicsSignalRO, Kind, Signal from ophyd_devices import StatusBase, SubscriptionStatus +from prettytable import PrettyTable from typeguard import typechecked from csaxs_bec.devices.epics.delay_generator_csaxs.error_registry import ERROR_CODES @@ -764,6 +765,109 @@ class DelayGeneratorCSAXS(Device): else: return f"Unknown error code: {msg}" + def show_all(self) -> None: + """Print the current delay pair, IO and trigger/burst settings. + + Covers everything needed to reconstruct the timing configuration at a glance: + per-pair delay, width, channel references and IO levels, plus the trigger and + burst configuration. + """ + pair_channels: dict[DelayChannelNames, tuple[LiteralChannels, LiteralChannels]] = { + "ab": ("A", "B"), + "cd": ("C", "D"), + "ef": ("E", "F"), + "gh": ("G", "H"), + } + + def _reference_name(value: int) -> str: + try: + return CHANNELREFERENCE(value).name + except ValueError: + return str(value) + + def _polarity_name(value: int) -> str: + try: + return OUTPUTPOLARITY(value).name + except ValueError: + return str(value) + + pairs = PrettyTable() + pairs.title = f"{self.name} delay pair settings" + pairs.field_names = [ + "Pair", + "Ref (ch1)", + "Delay [s]", + "Ref (ch2)", + "Width [s]", + "Polarity", + "Amplitude [V]", + "Offset [V]", + ] + pairs.add_row( + [ + "T0", + "-", + "-", + "-", + "-", + _polarity_name(self.t0.polarity.get()), + self.t0.amplitude.get(), + self.t0.offset.get(), + ] + ) + for pair_name in pair_channels: + pair = getattr(self, pair_name) + pairs.add_row( + [ + pair_name.upper(), + _reference_name(pair.ch1.reference.get()), + pair.delay.get(), + _reference_name(pair.ch2.reference.get()), + pair.width.get(), + _polarity_name(pair.io.polarity.get()), + pair.io.amplitude.get(), + pair.io.offset.get(), + ] + ) + print(pairs) + + try: + trigger_source = TRIGGERSOURCE(self.trigger_source.get()).name + except ValueError: + trigger_source = str(self.trigger_source.get()) + try: + burst_config = BURSTCONFIG(self.burst_config.get()).name + except ValueError: + burst_config = str(self.burst_config.get()) + + trigger = PrettyTable() + trigger.title = f"{self.name} trigger/burst settings" + trigger.field_names = [ + "Trigger Source", + "Trigger Level [V]", + "Trigger Rate [Hz]", + "Trigger Holdoff [s]", + "Burst Mode", + "Burst Count", + "Burst Delay [s]", + "Burst Period [s]", + "Burst Config", + ] + trigger.add_row( + [ + trigger_source, + self.trigger_level.get(), + self.trigger_rate.get(), + self.trigger_holdoff.get(), + bool(self.burst_mode.get()), + self.burst_count.get(), + self.burst_delay.get(), + self.burst_period.get(), + burst_config, + ] + ) + print(trigger) + if __name__ == "__main__": ddg = DelayGeneratorCSAXS(name="ddg", prefix="X12SA-CPCL-DDG1:") diff --git a/csaxs_bec/devices/epics/falcon_csaxs.py b/csaxs_bec/devices/epics/falcon_csaxs.py index 1f5487de..d818cf10 100644 --- a/csaxs_bec/devices/epics/falcon_csaxs.py +++ b/csaxs_bec/devices/epics/falcon_csaxs.py @@ -3,6 +3,8 @@ import enum import os import threading +import time +from typing import TYPE_CHECKING from typing import Literal from bec_lib.file_utils import get_full_path @@ -16,8 +18,18 @@ from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase from csaxs_bec.devices.utils.utils import fetch_scan_info +if TYPE_CHECKING: # pragma: no cover + from bec_lib.devicemanager import DeviceManagerBase, ScanInfo + logger = bec_logger.logger +# NOTE Layout of the written HDF5 file, resolved by the IOC relative to its own working +# directory. The cSAXS SITORO IOC ships it as cfg/layout.xml (installed at +# /ioc/X12SA-CPCL-FALCONX1/cfg/layout.xml). Override per deployment with the +# xml_file_name key in deviceConfig, or set it to "" to fall back to the plugin's +# built-in layout. +DEFAULT_XML_FILE_NAME = "cfg/layout.xml" + class FalconError(Exception): """Base class for exceptions in this module.""" @@ -65,12 +77,45 @@ class FalconcSAXS(PSIDeviceBase, FalconControl): MIN_READOUT (float) : Minimum readout time for the detector """ + # Methods exposed to the BEC client, i.e. callable as dev.falcon.() + USER_ACCESS = ["prime", "is_primed"] + # specify minimum readout time for detector MIN_READOUT = 3e-3 _pv_timeout = 3 # Timeout for PV operations in seconds file_event = Cpt(FileEventSignal, name="file_event") + def __init__( + self, + name: str, + prefix: str = "", + scan_info: "ScanInfo | None" = None, + device_manager: "DeviceManagerBase | None" = None, + xml_file_name: str | None = None, + **kwargs, + ): + """ + Every deviceConfig key must appear here by name. The device server builds the + init kwargs by intersecting the config keys with the class signature's named + parameters, so a bare *args/**kwargs signature silently drops 'prefix' and the + device is built with empty PV names. + + Args: + name: device name. + prefix: EPICS prefix, e.g. 'X12SA-SITORO:'. + scan_info: scan info object. + device_manager: BEC device manager. + xml_file_name: HDF5 layout description, as the IOC resolves it. Defaults to + DEFAULT_XML_FILE_NAME; pass "" to use the plugin's built-in layout. + """ + self._xml_file_name_config = ( + DEFAULT_XML_FILE_NAME if xml_file_name is None else xml_file_name + ) + super().__init__( + name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs + ) + def on_init(self) -> None: """Initialize Falcon Sitoro detector""" self._lock = threading.RLock() @@ -78,6 +123,7 @@ class FalconcSAXS(PSIDeviceBase, FalconControl): self._value_pixel_per_buffer = 20 self._queue_size = 2000 self._full_path = "" + self._xml_file_name = self._xml_file_name_config self.scan_parameters: ScanServerScanInfo | None = None def on_connected(self): @@ -127,13 +173,122 @@ class FalconcSAXS(PSIDeviceBase, FalconControl): # Sets the number of pixels/spectra in the buffer self.pixels_per_buffer.put(self._value_pixel_per_buffer) + def is_primed(self) -> bool: + """ + Check whether the HDF5 plugin has already been primed. + + The plugin only knows the array dimensions and data type once a single NDArray + has passed through it. Before that, its ArraySize readbacks are all zero and + ophyd refuses to stage it. + + Returns: + bool: True if the plugin is primed, False otherwise. + """ + return sum(self.hdf5.array_size.get()) > 0 + + def prime(self, timeout: float = 5) -> None: + """ + Prime the HDF5 plugin by pushing a single spectrum through it. + + The areaDetector file plugin refuses to be staged until it has seen one NDArray, + raising ophyd's UnprimedPlugin. That state lives in the IOC, so this has to be + done once after every restart of the SITORO IOC, and survives restarts of BEC. + + The generic HDF5Plugin.warmup() cannot be used here as it drives 'parent.cam', + which the Falcon (an ADBase, not a DetectorBase) does not have. Instead the + detector is briefly switched to user-advanced pixels, so that no external gate + signal is needed and a missing trigger cable cannot mask the priming. No file + is written. + + The trigger configuration is restored afterwards, also if the priming fails. + + Args: + timeout (float): Time in seconds to wait for the array to reach the plugin. + Defaults to 5. + + Raises: + FalconError: If the plugin is still unprimed after the timeout. + """ + if self.is_primed(): + logger.info(f"HDF5 plugin of {self.name} is already primed.") + return + + # NOTE on_stage does not touch the trigger configuration, only on_connected does. + # Restoring it here is therefore not optional; a Falcon left in USER advance mode + # silently ignores the gate and starves every subsequent scan. + collect_mode = self.collect_mode.get() + pixel_advance_mode = self.pixel_advance_mode.get() + ignore_gate = self.ignore_gate.get() + pixels_per_buffer = self.pixels_per_buffer.get() + + with self._lock: + try: + self.on_stop() + self.hdf5.enable.put(1) + # Prime only; no file should be written. + self.hdf5.capture.put(0) + + self.set_trigger( + mapping_mode=MappingSource.MAPPING, + trigger_source=TriggerSource.USER, + ignore_gate=1, + ) + self.pixels_per_buffer.put(1) + self.pixels_per_run.put(1) + self.preset_real_time.put(0.1) + + self.start_all.put(1) + # The buffer is only emitted once it is full. The IOC may refuse a buffer + # size of 1, so advance as many pixels as it actually accepted. + for _ in range(int(self.pixels_per_buffer.get()) + 1): + self.next_pixel.put(1) + + if not self._wait_for_primed(timeout): + raise FalconError( + f"HDF5 plugin of {self.name} is still unprimed after {timeout}s. " + "Check that the HDF5 plugin is enabled and that nd_array_mode is set." + ) + logger.info( + f"HDF5 plugin of {self.name} primed: array_size={self.hdf5.array_size.get()}" + ) + finally: + self.stop_all.put(1) + self.set_trigger( + mapping_mode=collect_mode, + trigger_source=pixel_advance_mode, + ignore_gate=ignore_gate, + ) + self.pixels_per_buffer.put(pixels_per_buffer) + self.erase_all.put(1) + + def _wait_for_primed(self, timeout: float) -> bool: + """Poll the plugin's array size until it reports a non-zero shape. + + Args: + timeout (float): Maximum time to wait in seconds. + + Returns: + bool: True if the plugin reported a non-zero array size within the timeout. + """ + start = time.time() + while time.time() - start < timeout: + if self.is_primed(): + return True + time.sleep(0.1) + return self.is_primed() + def _initialize_detector_backend(self) -> None: """Initialize the detector backend for Falcon.""" # Enable HDF5 plugin self.hdf5.enable.put(1) - # Use layout.xml file for cSAXS Falcon. FIXME:Should be checked if IOC runs on different host. - self.hdf5.xml_file_name.put("layout.xml") + # Layout of the written file. The IOC resolves this relative to its own working + # directory, so a bare "layout.xml" does not resolve -- the cSAXS SITORO IOC keeps + # it under cfg/. An unreadable layout does not fail here: the plugin refuses to + # open the output file later, reporting only "Error opening file ... status=-1", + # so the value is verified against XMLValid_RBV right away. + self.hdf5.xml_file_name.put(self._xml_file_name) + self._check_xml_layout() # TODO Check if lazy open is needed and wanted! self.hdf5.lazy_open.put(1) @@ -147,6 +302,34 @@ class FalconcSAXS(PSIDeviceBase, FalconControl): # Set nd_array mode to 1: This means segmentation into Spectra within EPICS, 1 is activate, 0 is deactivate self.nd_array_mode.put(1) + def _check_xml_layout(self, timeout: float = 1.0) -> bool: + """Warn if the IOC could not read the configured HDF5 layout description. + + Args: + timeout (float): Time in seconds to allow the IOC to parse the file. + + Returns: + bool: True if the IOC reports the layout as valid. + """ + if not self._xml_file_name: + return True # built-in layout requested; nothing to validate + deadline = time.time() + timeout + valid = "" + while time.time() < deadline: + valid = str(self.hdf5.xml_valid.get()) + if valid.lower() in ("yes", "1"): + return True + time.sleep(0.05) + logger.error( + f"{self.name}: the IOC cannot use the HDF5 layout " + f"{self._xml_file_name!r} (XMLValid={valid!r}, " + f"error={self.hdf5.xml_error_msg.get()!r}). Writing will fail with an " + "'Error opening file' until this is corrected. The path is resolved by the " + 'IOC, not by BEC; set xml_file_name in deviceConfig, or "" for the ' + "plugin's built-in layout." + ) + return False + def on_stage(self): """ This method is called when the detector is staged for acquisition. diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py index e2b87184..70511295 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -70,11 +70,14 @@ def suppress_mca_callbacks(mcs_card: MCSCard, restore_after_timeout: None | floa """ with mcs_card._rlock: mcs_card._omit_mca_callbacks.set() # pylint: disable=protected-access - try: - yield - finally: - if restore_after_timeout is not None: - time.sleep(restore_after_timeout) + try: + # MCA callbacks must be able to acquire the data lock while suppression + # is active so they are discarded instead of queued until afterward. + yield + finally: + if restore_after_timeout is not None: + time.sleep(restore_after_timeout) + with mcs_card._rlock: mcs_card._omit_mca_callbacks.clear() # pylint: disable=protected-access @@ -467,7 +470,16 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): logger.info( f"Software triggered scan: {self._current_data_index}/{self.scan_parameters.num_points} points received." ) - if self._current_data_index == self.scan_parameters.num_points: + if self._current_data_index > self.scan_parameters.num_points: + exception = RuntimeError( + f"MCS card {self.name} emitted {self._current_data_index} " + f"data points, but the scan requested " + f"{self.scan_parameters.num_points}." + ) + logger.error(str(exception)) + for callback in self._scan_done_callbacks: + callback(exception=exception) + elif self._current_data_index == self.scan_parameters.num_points: for callback in self._scan_done_callbacks: callback(exception=None) else: diff --git a/csaxs_bec/devices/ids_cameras/base_integration/camera.py b/csaxs_bec/devices/ids_cameras/base_integration/camera.py index b28bbb08..5c2fa6f4 100644 --- a/csaxs_bec/devices/ids_cameras/base_integration/camera.py +++ b/csaxs_bec/devices/ids_cameras/base_integration/camera.py @@ -235,23 +235,130 @@ class Camera: ) def set_auto_gain(self, enable: bool): - """Enable or disable auto gain.""" - enable = ueye.c_int(1) if enable else ueye.c_int(0) - value_to_return = ueye.c_double() + """Enable or disable auto gain. + + is_SetAutoParameter's pval1/pval2 are `double *` (the SDK reads/writes + 8 bytes through them), not `int *` -- passing a ueye.c_int() here (as + this used to do) hands the driver a 4-byte buffer to read a double + out of, so it reads 4 bytes of adjacent memory as the rest of the + mantissa/exponent. The resulting garbage value is essentially never + exactly 0.0/1.0, so the driver rejects it -- this is what raised + UEyeException here instead of actually toggling auto gain. + """ + enable_value = ueye.c_double(1.0) if enable else ueye.c_double(0.0) check_error( self.ueye.is_SetAutoParameter( - self.cam.h_cam, ueye.IS_SET_ENABLE_AUTO_GAIN, enable, value_to_return + self.cam.h_cam, ueye.IS_SET_ENABLE_AUTO_GAIN, enable_value, enable_value ), "IDSCameraObject", ) def set_auto_shutter(self, enable: bool): - """Enable or disable auto exposure.""" - enable = ueye.c_int(1) if enable else ueye.c_int(0) - value_to_return = ueye.c_double() + """Enable or disable auto exposure. See set_auto_gain() for why + pval1/pval2 must be c_double, not c_int.""" + enable_value = ueye.c_double(1.0) if enable else ueye.c_double(0.0) check_error( self.ueye.is_SetAutoParameter( - self.cam.h_cam, ueye.IS_SET_ENABLE_AUTO_SHUTTER, enable, value_to_return + self.cam.h_cam, ueye.IS_SET_ENABLE_AUTO_SHUTTER, enable_value, enable_value + ), + "IDSCameraObject", + ) + + def get_exposure_range(self) -> tuple[float, float, float]: + """Get the (min, max, increment) exposure time range (ms) at the + camera's current pixel clock. Lowering the pixel clock (see + set_pixel_clock()) raises the achievable max exposure time, at the + cost of frame rate. + """ + param = (ueye.c_double * 3)() + check_error( + self.ueye.is_Exposure( + self.cam.h_cam, + ueye.IS_EXPOSURE_CMD_GET_EXPOSURE_RANGE, + param, + self.ueye.sizeof(param), + ), + "IDSCameraObject", + ) + return float(param[0]), float(param[1]), float(param[2]) + + def get_pixel_clock(self) -> int: + """Get the camera's current pixel clock (MHz).""" + value = ueye.UINT() + check_error( + self.ueye.is_PixelClock( + self.cam.h_cam, ueye.IS_PIXELCLOCK_CMD_GET, value, self.ueye.sizeof(value) + ), + "IDSCameraObject", + ) + return int(value.value) + + def get_pixel_clock_range(self) -> tuple[int, int, int]: + """Get the (min, max, increment) pixel clock range (MHz). + + Confirmed on hardware: this reports a *linear* range/increment, but + not every value in it is actually accepted by is_PixelClock's SET + command -- many uEye sensors only support a short discrete list of + clocks (e.g. setting 49, 60, 73 MHz all failed on camera 41, even + though they fell inside this range). Use get_pixel_clock_list() to + find out what's actually settable; treat this range as informational + only. + """ + param = (ueye.UINT * 3)() + check_error( + self.ueye.is_PixelClock( + self.cam.h_cam, + ueye.IS_PIXELCLOCK_CMD_GET_RANGE, + param, + self.ueye.sizeof(param), + ), + "IDSCameraObject", + ) + return int(param[0]), int(param[1]), int(param[2]) + + def get_pixel_clock_list(self) -> list[int]: + """Get the actual list of pixel clock values (MHz) this camera + accepts -- see get_pixel_clock_range()'s docstring for why this, + not that range, is what callers should validate/snap against.""" + count = ueye.UINT() + check_error( + self.ueye.is_PixelClock( + self.cam.h_cam, + ueye.IS_PIXELCLOCK_CMD_GET_NUMBER, + count, + self.ueye.sizeof(count), + ), + "IDSCameraObject", + ) + n = int(count.value) + if n <= 0: + return [] + values = (ueye.UINT * n)() + check_error( + self.ueye.is_PixelClock( + self.cam.h_cam, + ueye.IS_PIXELCLOCK_CMD_GET_LIST, + values, + self.ueye.sizeof(values), + ), + "IDSCameraObject", + ) + return sorted(int(v) for v in values) + + def set_pixel_clock(self, value: int) -> None: + """Set the camera's pixel clock (MHz). Lowering it raises the max + achievable exposure time (see get_exposure_range()), at the cost of + frame rate. Does not itself validate value against + get_pixel_clock_list() -- callers (IDSCamera.set_pixel_clock()) are + expected to snap to a supported value first; passing an unsupported + one here raises UEyeException.""" + pixel_clock = ueye.UINT(value) + check_error( + self.ueye.is_PixelClock( + self.cam.h_cam, + ueye.IS_PIXELCLOCK_CMD_SET, + pixel_clock, + self.ueye.sizeof(pixel_clock), ), "IDSCameraObject", ) diff --git a/csaxs_bec/devices/ids_cameras/ids_camera.py b/csaxs_bec/devices/ids_cameras/ids_camera.py index e429c914..a2b5135c 100644 --- a/csaxs_bec/devices/ids_cameras/ids_camera.py +++ b/csaxs_bec/devices/ids_cameras/ids_camera.py @@ -63,6 +63,57 @@ class IDSCamera(PSIDeviceBase): doc="Enable or disable live mode.", kind=Kind.config, ) + exposure_time = Cpt( + Signal, + name="exposure_time", + value=0.0, + doc="Camera exposure time (ms).", + kind=Kind.config, + ) + auto_exposure_enabled = Cpt( + Signal, + name="auto_exposure_enabled", + value=True, + doc="Enable/disable auto-exposure (auto-shutter).", + kind=Kind.config, + ) + auto_gain_enabled = Cpt( + Signal, + name="auto_gain_enabled", + value=True, + doc="Enable/disable auto-gain.", + kind=Kind.config, + ) + exposure_time_min = Cpt( + Signal, + name="exposure_time_min", + value=0.0, + doc="Minimum exposure time (ms) at the camera's current pixel clock.", + kind=Kind.config, + ) + exposure_time_max = Cpt( + Signal, + name="exposure_time_max", + value=1000.0, + doc="Maximum exposure time (ms) at the camera's current pixel clock.", + kind=Kind.config, + ) + pixel_clock = Cpt( + Signal, + name="pixel_clock", + value=0, + doc=( + "Camera pixel clock (MHz). Lowering it raises the max achievable " + "exposure time (see exposure_time_max), at the cost of frame rate." + ), + kind=Kind.config, + ) + pixel_clock_min = Cpt( + Signal, name="pixel_clock_min", value=0, doc="Minimum pixel clock (MHz).", kind=Kind.config + ) + pixel_clock_max = Cpt( + Signal, name="pixel_clock_max", value=0, doc="Maximum pixel clock (MHz).", kind=Kind.config + ) USER_ACCESS = [ "start_live_mode", @@ -73,8 +124,22 @@ class IDSCamera(PSIDeviceBase): "push_preview_image", "push_smear_preview", "get_live_fps", + "get_exposure_time", + "set_exposure_time", + "set_auto_exposure_enabled", + "set_auto_gain_enabled", + "get_exposure_time_range", + "get_pixel_clock", + "get_pixel_clock_range", + "get_pixel_clock_list", + "set_pixel_clock", ] + # How long to hold auto_gain_enabled on during the connect-time pulse + # (see on_connected()) before turning it back off -- long enough for a + # few frames from the continuously-running capture to be processed. + _AUTO_GAIN_SETTLE_S = 0.5 + def __init__( self, *, @@ -125,6 +190,10 @@ class IDSCamera(PSIDeviceBase): self.image.transpose = transpose self._force_monochrome = force_monochrome self.live_mode_enabled.subscribe(self._on_live_mode_enabled_changed, run=False) + self.exposure_time.subscribe(self._on_exposure_time_changed, run=False) + self.auto_exposure_enabled.subscribe(self._on_auto_exposure_enabled_changed, run=False) + self.auto_gain_enabled.subscribe(self._on_auto_gain_enabled_changed, run=False) + self.pixel_clock.subscribe(self._on_pixel_clock_changed, run=False) self.live_mode_enabled.put(bool(live_mode)) ############## Live Mode Methods ############## @@ -278,6 +347,110 @@ class IDSCamera(PSIDeviceBase): """ self.smear_preview.put(data) + ############## Exposure / Gain ############## + + def _on_exposure_time_changed(self, *args, value, **kwargs): + try: + self.cam.exposure_time = value + except Exception: + # Caught (not re-raised) so this doesn't surface as ophyd's generic + # "Subscription value callback exception" -- which logs the same + # traceback but without this context. The Signal itself already + # holds `value` regardless (ophyd updates the cache before running + # subscribers), so it now reflects the *requested*, not confirmed, + # exposure time if the hardware write failed -- re-set it (e.g. via + # set_exposure_time()) once the underlying issue is resolved. + logger.exception( + f"{self.name}: failed to set exposure_time={value} on hardware." + ) + + def _on_auto_exposure_enabled_changed(self, *args, value, **kwargs): + try: + self.cam.set_auto_shutter(bool(value)) + except Exception: + logger.exception( + f"{self.name}: failed to set auto_exposure_enabled={bool(value)} on hardware." + ) + + def _on_auto_gain_enabled_changed(self, *args, value, **kwargs): + try: + self.cam.set_auto_gain(bool(value)) + except Exception: + logger.exception( + f"{self.name}: failed to set auto_gain_enabled={bool(value)} on hardware." + ) + + def _on_pixel_clock_changed(self, *args, value, **kwargs): + try: + self.cam.set_pixel_clock(int(value)) + # The exposure range is pixel-clock-dependent -- re-derive it from + # hardware immediately so exposure_time_min/max (and the GUI + # slider bound to them) never lag behind the new pixel clock. + exp_min, exp_max, _inc = self.cam.get_exposure_range() + self.exposure_time_min.put(exp_min) + self.exposure_time_max.put(exp_max) + except Exception: + logger.exception(f"{self.name}: failed to set pixel_clock={int(value)} on hardware.") + + def get_exposure_time(self) -> float: + """Get the current exposure time (ms), from the cached Signal value.""" + return float(self.exposure_time.get()) + + def set_exposure_time(self, value: float) -> None: + """Set the exposure time (ms). Does not itself disable auto-exposure -- + call set_auto_exposure_enabled(False) first, or the driver will keep + overriding it.""" + self.exposure_time.put(value) + + def set_auto_exposure_enabled(self, enable: bool) -> None: + self.auto_exposure_enabled.put(bool(enable)) + + def set_auto_gain_enabled(self, enable: bool) -> None: + self.auto_gain_enabled.put(bool(enable)) + + def get_exposure_time_range(self) -> tuple[float, float]: + """Get the (min, max) exposure time (ms) at the camera's current + pixel clock, from the cached Signal values (seeded on connect; see + on_connected()).""" + return float(self.exposure_time_min.get()), float(self.exposure_time_max.get()) + + def get_pixel_clock(self) -> int: + """Get the camera's current pixel clock (MHz), from the cached Signal + value.""" + return int(self.pixel_clock.get()) + + def get_pixel_clock_range(self) -> tuple[int, int]: + """Get the (min, max) pixel clock (MHz), from the cached Signal + values (seeded on connect; see on_connected()). Informational only -- + see get_pixel_clock_list() for what's actually settable.""" + return int(self.pixel_clock_min.get()), int(self.pixel_clock_max.get()) + + def get_pixel_clock_list(self) -> list[int]: + """Get the list of pixel clock values (MHz) this camera actually + accepts. Confirmed on hardware: not every value between + get_pixel_clock_range()'s min/max is valid -- many uEye sensors only + support a short discrete list; set_pixel_clock() snaps to the + nearest one from this list rather than passing an arbitrary value + straight to the driver.""" + return self.cam.get_pixel_clock_list() + + def set_pixel_clock(self, value: int) -> None: + """Set the pixel clock (MHz). Lowering it raises the max achievable + exposure time (see get_exposure_time_range()), at the cost of frame + rate. Snaps to the nearest value in get_pixel_clock_list() -- passing + an arbitrary MHz value from that (min, max) range is not guaranteed + to be accepted by the driver.""" + target = int(value) + options = self.cam.get_pixel_clock_list() + if options and target not in options: + nearest = min(options, key=lambda v: abs(v - target)) + logger.info( + f"{self.name}: pixel_clock={target} is not one of this camera's supported " + f"values {options}; using nearest supported value {nearest} instead." + ) + target = nearest + self.pixel_clock.put(target) + ############## User Interface Methods ############## def on_connected(self): @@ -285,10 +458,59 @@ class IDSCamera(PSIDeviceBase): self.cam.force_monochrome = self._force_monochrome self.cam.on_connect() self.live_mode_enabled.put(bool(self._inputs.get("live_mode", False))) + # auto_exposure_enabled defaults to True, but there is no SDK query + # for the camera's *current* auto-shutter state (only an + # enable-setter) -- so unlike live_mode_enabled/exposure_time, this + # used to just leave the Signal at its declared default without ever + # telling the hardware. is_ResetToDefault() (called during + # IDSCameraObject init, i.e. every connect) leaves the sensor with + # auto-shutter off, so a fresh session started under-exposed until an + # operator happened to toggle the GUI switch off and back on -- which + # is what actually issued the enable call for the first time. + # Round-trip through its subscribe callback now (same idiom as + # exposure_time below) so the declared default is actually enforced + # on hardware on every connect. + self.auto_exposure_enabled.put(bool(self.auto_exposure_enabled.get())) + # auto_gain_enabled: HW testing found continuous auto-gain isn't + # useful here -- once correctly exposed it has nothing further to + # adjust, and leaving it on takes gain out of the operator's manual + # control. But *some* one-time gain correction at connect is still + # needed (same under-exposed-at-start symptom as above, confirmed to + # be specifically about gain, not just auto-exposure). So pulse it on + # briefly, then back off -- "in the background": one settle period + # to let the driver correct the gain from a few live frames, then + # leave gain fixed at whatever it converged to, under manual control + # from then on. This is the only place auto_gain_enabled is toggled; + # there is no persistent GUI control for it (see OMNY_XRayEye). + self.auto_gain_enabled.put(True) + time.sleep(self._AUTO_GAIN_SETTLE_S) + self.auto_gain_enabled.put(False) + # Seed exposure_time from the real hardware value once, so the GUI shows + # a real number immediately on connect instead of the 0.0 placeholder. + # This round-trips through _on_exposure_time_changed, which writes the + # same value back to the driver -- a harmless one-time no-op write. + self.exposure_time.put(self.cam.exposure_time) + # Seed the exposure time range (depends on the current pixel clock) so + # the GUI can bound its slider to values the hardware will actually + # accept, instead of a generic placeholder. + exp_min, exp_max, _inc = self.cam.get_exposure_range() + self.exposure_time_min.put(exp_min) + self.exposure_time_max.put(exp_max) + # Seed the pixel clock and its range the same way. This round-trips + # through _on_pixel_clock_changed, which re-derives exposure_time_min/ + # max from hardware again -- redundant with the lines just above on + # this first call, but it's what keeps them correct after any later + # pixel-clock change from the GUI. + pc_min, pc_max, _pc_inc = self.cam.get_pixel_clock_range() + self.pixel_clock_min.put(pc_min) + self.pixel_clock_max.put(pc_max) + self.pixel_clock.put(self.cam.get_pixel_clock()) self.set_rect_roi(0, 0, self.cam.cam.width.value, self.cam.cam.height.value) def on_destroy(self): """Clean up resources when the device is destroyed.""" + # Stop acquisition before disconnecting so the live thread cannot keep polling. + self.stop_live_mode() self.cam.on_disconnect() super().on_destroy() diff --git a/csaxs_bec/devices/jungfraujoch/eiger.py b/csaxs_bec/devices/jungfraujoch/eiger.py index cdcf382a..b5dec40c 100644 --- a/csaxs_bec/devices/jungfraujoch/eiger.py +++ b/csaxs_bec/devices/jungfraujoch/eiger.py @@ -8,10 +8,21 @@ A few notes on setup and operation of the Eiger detectors through the JungfrauJo - Changes in energy may take time, good to implement logic that only resets energy if needed. - For the Eiger, the frame_time_us in DetectorSettings is ignored, only the frame_time_us in the DatasetSettings is relevant -- The bit_depth will be adjusted automatically based on the exp_time. Here, we need to ensure - that subsequent triggers properly consider the readout_time of the boards. For the Eiger detectors - at cSAXS, a readout time of 20us is configured through the JungfrauJoch deployment config. This - setting is sufficiently large for the detectors if they run in parallel mode. +- The bit_depth will be adjusted automatically based on the exp_time (32 bit above 2622 us), + and in 32 bit the detector accumulates ~2.3 ms subframes. JungfrauJoch's own deployment config + budgets 20 us of board readout between frames, which is right for 16 bit parallel mode but far + smaller than the subframe granularity -- so a frame can outlast the trigger period without + anything reporting an error. +- The Eiger is TRIGGERED rather than gated (DetectorTiming.TRIGGER), for stability: gating + it proved unreliable. It therefore ignores the gate width and runs its own timer from the + rising edge. The pulse train is deliberately shaped so that this timer coincides with the + gate -- the Eiger is meant to behave *as if* it were gated, so that every detector in the + scan integrates the same window. Sending it anything other than the gated window breaks + that, silently, and is only visible on a scope. +- scan_info's exp_time is the trigger PERIOD, not the exposure. The delay generator gates for + exp_time - readout_time, and on_stage sends that same window as image_time_us so the detector + is not still acquiring when the next trigger arrives. The readout time must agree with DDG2's + DEFAULT_READOUT_TIMES["ab"]; a scan-level readout_time overrides both when larger. - beam_center and detector settings are required input arguments, thus, they may be set to wrong values for acquisitions to start. Please keep this in mind. @@ -53,7 +64,10 @@ from ophyd import DeviceStatus from ophyd_devices import FileEventSignal, PreviewSignal from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase -from csaxs_bec.devices.jungfraujoch.jungfrau_joch_client import JungfrauJochClient +from csaxs_bec.devices.jungfraujoch.jungfrau_joch_client import ( + JungfrauJochClient, + JungfrauJochClientError, +) from csaxs_bec.devices.jungfraujoch.jungfraujoch_preview import JungfrauJochPreview from csaxs_bec.devices.utils.utils import fetch_scan_info @@ -65,7 +79,32 @@ if TYPE_CHECKING: # pragma no cover logger = bec_logger.logger -EIGER_READOUT_TIME_US = 500e-6 # 500 microseconds in s +# NOTE Two distinct numbers that used to be one. MIN_EXP_TIME is a validation floor +# (its value is inherited from the former EIGER_READOUT_TIME_US and has no documented +# provenance). EIGER_READOUT_TIME is the gap left between the end of one acquisition and +# the next trigger; it must match the gap the delay generator leaves in the pulse train, +# DDG2's DEFAULT_READOUT_TIMES["ab"], or the detector is told to acquire for longer than +# the trigger period allows. +MIN_EXP_TIME = 500e-6 # s +EIGER_READOUT_TIME = 2e-4 # s -- matches DDG2 DEFAULT_READOUT_TIMES["ab"] + +# NOTE The JungfrauJoch broker reports packet loss as a plain error with no error code, +# so the only way to recognise it is the wording of its status message. If JFJoch ever +# rephrases this, the match stops working and the error raises again -- which is the safe +# direction to fail in. +MISSING_PACKETS_MESSAGE = "Missing packets in data collection" + +# NOTE Default for raise_on_missing_packets, i.e. whether a missing-packet error from the +# broker fails the scan. False while the 9M is losing packets during the 2026-09 beamtime +# and scans must not die on it. Set it back to True once that is resolved, and put +# "raise_on_missing_packets: false" in the deviceConfig of the detector that still needs +# it -- per detector, rather than for every Eiger at once. +RAISE_ON_MISSING_PACKETS = False +# NOTE Each model declares its own value and passes it here; that duplication is +# deliberate, since readout is a property of the detector (a 9M has more modules than a +# 1.5M, and the Falcon needs 3 ms). They all happen to be 2e-4 today only because no +# measured per-model number exists yet. This constant is the fallback for a model that +# does not state one; deviceConfig's readout_time overrides it per deployment. class EigerError(Exception): @@ -78,11 +117,16 @@ class Eiger(PSIDeviceBase): Args: name (str) : Name of the device - detector_name (str): Name of the detector. Supports ["EIGER 9M", "EIGER 8.5M (tmp)", "EIGER 1.5M"] + detector_name (str): Name of the detector. Supports ["EIGER 9M", "EIGER 8.5M (tmp)", "EIGER 1.5M", "Dectris EIGER2 Si 500K"] host (str): Hostname of the Jungfrau Joch server. port (int): Port of the Jungfrau Joch server. scan_info (ScanInfo): The scan info to use. device_manager (DeviceManagerDS): The device manager to use. + raise_on_missing_packets (bool): Whether a 'missing packets' error from the + JungfrauJoch broker fails the scan. See RAISE_ON_MISSING_PACKETS. + auto_reinitialize (bool): Whether the detector is re-initialised on staging if + JungfrauJoch is in ERROR state, instead of requiring a manual 'Initialize' in the + JungfrauJoch WEB UI. Requires the shutter to be closed between scans. **kwargs: Additional keyword arguments. """ @@ -92,6 +136,9 @@ class Eiger(PSIDeviceBase): "print_detector_distance_and_center", "set_azi_settings", "get_azi_settings", + "get_raise_on_missing_packets", + "set_raise_on_missing_packets", + "get_missing_packet_events", ] file_event = Cpt(FileEventSignal, name="file_event") @@ -100,19 +147,25 @@ class Eiger(PSIDeviceBase): def __init__( self, name: str, - detector_name: Literal["EIGER 9M", "EIGER 8.5M (tmp)", "EIGER 1.5M"], + detector_name: Literal[ + "EIGER 9M", "EIGER 8.5M (tmp)", "EIGER 1.5M", "Dectris EIGER2 Si 500K" + ], host: str = "http://sls-jfjoch-001", port: int = 8080, detector_distance: float = 100.0, beam_center: tuple[int, int] = (0, 0), scan_info: ScanInfo = None, - readout_time: float = EIGER_READOUT_TIME_US, + readout_time: float = EIGER_READOUT_TIME, + raise_on_missing_packets: bool = RAISE_ON_MISSING_PACKETS, + auto_reinitialize: bool = False, + dectris_url: str | None = None, device_manager=None, **kwargs, ): super().__init__(name=name, scan_info=scan_info, device_manager=device_manager, **kwargs) self._host = f"{host}:{port}" - self.jfj_client = JungfrauJochClient(host=self._host, parent=self) + # dectris_url: base URL of the Dectris detector server, its status is logged on errors. + self.jfj_client = JungfrauJochClient(host=self._host, parent=self, dectris_url=dectris_url) # NOTE: fetch this information from JungfrauJochClient during on_connected! self.jfj_preview_client = JungfrauJochPreview( url="tcp://129.129.95.114:5400", cb=self._preview_callback @@ -130,6 +183,48 @@ class Eiger(PSIDeviceBase): if self.device_manager is not None: self.device_manager: DeviceManagerDS self.scan_parameters: ScanServerScanInfo | None = None + self._raise_on_missing_packets = bool(raise_on_missing_packets) + self._missing_packet_events = 0 + self._auto_reinitialize = bool(auto_reinitialize) + + def get_raise_on_missing_packets(self) -> bool: + """ + Whether a 'missing packets' error from the JungfrauJoch broker fails the scan. + + Returns: + bool: True if missing packets raise an EigerError, False if they are tolerated. + """ + return self._raise_on_missing_packets + + def set_raise_on_missing_packets(self, value: bool) -> None: + """ + Set whether a 'missing packets' error from the JungfrauJoch broker fails the scan. + + NOTE This is shared by every client of this device server, and it does NOT survive a + server restart: '__init__' runs again and restores the value from deviceConfig. To make + a choice stick for a beamtime, set 'raise_on_missing_packets' in the deviceConfig of the + detector instead. + + Args: + value (bool): True to raise on missing packets, False to tolerate them. + """ + self._raise_on_missing_packets = bool(value) + logger.warning( + f"Device {self.name}: raise_on_missing_packets is now " + f"{self._raise_on_missing_packets}." + ) + + def get_missing_packet_events(self) -> int: + """ + How many missing-packet errors have been tolerated since this device was created. + + Reset by a server restart, like the flag itself. Non-zero means acquisitions completed + that the broker had flagged, so the data of those scans may be incomplete. + + Returns: + int: Number of tolerated missing-packet errors. + """ + return self._missing_packet_events def _preview_callback(self, message: dict) -> None: """ @@ -141,11 +236,30 @@ class Eiger(PSIDeviceBase): message (dict): The message received from the preview stream. """ if message.get("type", "") == "image": - data = message.get("data", {}).get("default", None) - if data is None: + channels = message.get("data", {}) + # The data dict's key varies by detector: "default" for eiger_1_5/eiger_9, + # but a threshold name (e.g. "threshold_1") for the multi-threshold-capable + # Eiger2 Si 500K. Prefer "default" when present, else take whichever single + # channel the broker actually sent. + frame = channels.get("default") + if frame is None: + frame = next(iter(channels.values()), None) + if frame is None: logger.error(f"Received image message on device {self.name} without data.") return - self.preview_image.put(data) + self.preview_image.put(frame) + + # NOTE The device server applies every deviceConfig entry through update_config, which only + # accepts keys that exist as attributes of the device. Hence this needs to be a property, + # a constructor argument alone is rejected with 'Unknown config parameter'. + # pylint: disable=missing-function-docstring + @property + def auto_reinitialize(self) -> bool: + return self._auto_reinitialize + + @auto_reinitialize.setter + def auto_reinitialize(self, value: bool) -> None: + self._auto_reinitialize = bool(value) # pylint: disable=missing-function-docstring @property @@ -251,7 +365,7 @@ class Eiger(PSIDeviceBase): """ start_time = time.time() logger.debug(f"On connected called for {self.name}") - self.jfj_client.stop(request_timeout=3) + self.jfj_client.stop(request_timeout=3, reason="on_connected") # Check which detector is selected # Get available detectors @@ -291,6 +405,10 @@ class Eiger(PSIDeviceBase): # self.jfj_client.connect_and_initialise(timeout=10) # Setup Detector settings, here we may also set the energy already as this might be time consuming + # NOTE frame_time_us is required by the API but ignored for the Eiger ("For EIGER + # detector this is default frame time, not used otherwise"); the exposure comes + # from image_time_us in DatasetSettings. The 500 is a placeholder -- it is NOT the + # readout time, and it is in microseconds, unlike every other time in this module. settings = DetectorSettings(frame_time_us=int(500), timing=DetectorTiming.TRIGGER) self.jfj_client.set_detector_settings(settings, timeout=5) @@ -319,8 +437,16 @@ class Eiger(PSIDeviceBase): logger.info(f"Device {self.name} on_stage called.") start_time = time.time() self.scan_parameters = fetch_scan_info(self.scan_info) + if self._auto_reinitialize: + try: + self.jfj_client.recover_from_error() + except JungfrauJochClientError as exc: + raise EigerError( + f"Device {self.name} is in ERROR state and could not be re-initialised " + f"automatically, please initialise it in the WEB UI: {self._host}. {exc}" + ) from exc if self.jfj_client.detector_state != DetectorState.IDLE: - self.jfj_client.stop(request_timeout=0.5) + self.jfj_client.stop(request_timeout=0.5, reason="on_stage, detector not IDLE") self.jfj_client.wait_for_idle(timeout=5) # TODO Reactivate @@ -344,9 +470,21 @@ class Eiger(PSIDeviceBase): # Setting up exp_time and num_triggers acquisition parameter exp_time = self.scan_parameters.exp_time - if exp_time <= self._readout_time: # Exp_time must be at least the readout time + if exp_time <= MIN_EXP_TIME: raise ValueError( - f"Value error on device {self.name}: Exposure time {exp_time}s is less than readout time {self._readout_time}s." + f"Value error on device {self.name}: Exposure time {exp_time}s is below the " + f"minimum exposure time {MIN_EXP_TIME}s." + ) + # NOTE exp_time is the trigger PERIOD, not the exposure. The delay generator gates + # for exp_time - readout_time, so the detector must acquire for the same window or + # it is still busy when the next trigger arrives. Scans default readout_time to 0, + # hence the max() against this device's configured value. + readout_time = max(self.scan_parameters.readout_time, self._readout_time) + acquisition_time = exp_time - readout_time + if acquisition_time <= 0: + raise ValueError( + f"Value error on device {self.name}: readout time {readout_time}s leaves no " + f"acquisition time within the trigger period {exp_time}s." ) self._num_triggers = int( self.scan_parameters.num_points * self.scan_parameters.frames_per_trigger @@ -369,7 +507,7 @@ class Eiger(PSIDeviceBase): # Create dataset settings for API call. data_settings = DatasetSettings( - image_time_us=int(exp_time * 1e6), + image_time_us=int(acquisition_time * 1e6), ntrigger=self._num_triggers, file_prefix=path, beam_x_pxl=float(self._beam_center[0]), @@ -379,6 +517,10 @@ class Eiger(PSIDeviceBase): ) logger.debug(f"Setting data_settings: {yaml.dump(data_settings.to_dict(), indent=4)}") prep_time = time.time() + logger.info( + f"{self.name} exposure window: period {exp_time}s, readout {readout_time}s, " + f"acquisition {acquisition_time}s" + ) self.jfj_client.wait_for_idle(timeout=10) # Ensure we are in IDLE state self.jfj_client.start(settings=data_settings) # Takes around ~0.6s -> 6s currently. @@ -424,6 +566,12 @@ class Eiger(PSIDeviceBase): # NOTE: This adjust the time (s) that should be waited for completion of the scan. timeout = self._wait_for_on_complete while time.time() - start_time < timeout: + if self.jfj_client.detector_state == DetectorState.ERROR: + self.jfj_client.log_dectris_status("ERROR state during acquisition") + raise EigerError( + f"Device {self.name} is in ERROR state during acquisition. Broker status: " + f"{yaml.dump(self.jfj_client.jfj_status.to_dict(), indent=4)}" + ) if self.jfj_client.wait_for_idle(timeout=1, raise_on_timeout=False): # Call API endpoints to get broker status broker_status = self.jfj_client.jfj_status @@ -434,13 +582,28 @@ class Eiger(PSIDeviceBase): logger.info( f"JungfrauJoch broker status: {yaml.dump(broker_status.to_dict(), indent=4)}" ) - if broker_status.message_severity == "error": # Raise on error - raise EigerError( - f"Device {self.name} acquisition completed with error status from JungfrauJoch broker: {yaml.dump(broker_status.to_dict(), indent=4)}" - ) - # logger.warning( - # f"Device {self.name} acquisition completed with error status from JungfrauJoch broker: {yaml.dump(broker_status.to_dict(), indent=4)}" - # ) + if broker_status.message_severity == "error": + # NOTE Missing packets can be tolerated per detector, because the frame + # count is still checked below: what is being let through here is "the + # broker flagged packet loss but delivered the expected number of + # images", not a short acquisition. + if ( + MISSING_PACKETS_MESSAGE in (broker_status.message or "") + and not self._raise_on_missing_packets + ): + self._missing_packet_events += 1 + logger.warning( + f"Device {self.name} reported missing packets and the error is " + f"being tolerated (raise_on_missing_packets is False), so the " + f"data of this scan may be incomplete. That is tolerated event " + f"{self._missing_packet_events} for this device since it was " + f"created. Broker status: " + f"{yaml.dump(broker_status.to_dict(), indent=4)}" + ) + else: + raise EigerError( + f"Device {self.name} acquisition completed with error status from JungfrauJoch broker: {yaml.dump(broker_status.to_dict(), indent=4)}" + ) # Call API endpoint to get statistics statistics: MeasurementStatistics = ( self.jfj_client.api.statistics_data_collection_get(_request_timeout=5) @@ -478,7 +641,8 @@ class Eiger(PSIDeviceBase): def on_stop(self) -> None: """Called when the device is stopped.""" - self.jfj_client.stop(request_timeout=0.5) + logger.info(f"Device {self.name} on_stop called, cancelling JungfrauJoch acquisition.") + self.jfj_client.stop(request_timeout=0.5, reason="on_stop") self.task_handler.shutdown() def on_destroy(self): diff --git a/csaxs_bec/devices/jungfraujoch/eiger2_si_500k.py b/csaxs_bec/devices/jungfraujoch/eiger2_si_500k.py new file mode 100644 index 00000000..de4af5c4 --- /dev/null +++ b/csaxs_bec/devices/jungfraujoch/eiger2_si_500k.py @@ -0,0 +1,66 @@ +""" +Eiger 2 Si 500K specific integration. It is based on the Eiger base integration for the JungfrauJoch backend +which is placed in eiger_csaxs, and where code that is equivalent for the Eiger9M, Eiger1.5M and +Eiger2 Si 500K is shared. + +Please check the eiger_csaxs.py class for more details about the relevant services. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from csaxs_bec.devices.jungfraujoch.eiger import RAISE_ON_MISSING_PACKETS, Eiger + +# Gap this model needs between the end of an acquisition and the next trigger. Kept +# per model on purpose -- readout is a detector property, not a beamline constant. The +# delay generator's gap (DDG2 DEFAULT_READOUT_TIMES["ab"]) must be at least as large as +# the largest value among the detectors in a scan, or the slowest one overruns. +EIGER2_SI_500K_READOUT_TIME = 2e-4 # s -- placeholder until a measured value exists +DETECTOR_NAME = "Dectris EIGER2 Si 500K" +# Detector server (Simplon API). Its status is logged when the detector goes into error state. +DECTRIS_URL = "http://129.129.122.159" + + +if TYPE_CHECKING: # pragma no cover + from bec_lib.devicemanager import ScanInfo + from bec_server.device_server.device_server import DeviceManagerDS + + +# pylint:disable=invalid-name +class Eiger2Si500K(Eiger): + """ + Eiger 2 Si 500K specific integration. + + The logic implemented here is coupled to the DelayGenerator integration, + repsonsible for the global triggering of all devices through a single Trigger logic. + Please check the eiger.py class for more details about the integration of relevant backend + services. The detector_name must be set to "Dectris EIGER2 Si 500K": + """ + + USER_ACCESS = Eiger.USER_ACCESS + [] # Add more user_access methods here. + + def __init__( + self, + name: str, + detector_distance: float = 100.0, + beam_center: tuple[float, float] = (0.0, 0.0), + scan_info: ScanInfo = None, + device_manager: DeviceManagerDS = None, + readout_time: float = EIGER2_SI_500K_READOUT_TIME, + raise_on_missing_packets: bool = RAISE_ON_MISSING_PACKETS, + dectris_url: str | None = DECTRIS_URL, + **kwargs, + ) -> None: + super().__init__( + name=name, + detector_name=DETECTOR_NAME, + dectris_url=dectris_url, + readout_time=readout_time, + raise_on_missing_packets=raise_on_missing_packets, + detector_distance=detector_distance, + beam_center=beam_center, + scan_info=scan_info, + device_manager=device_manager, + **kwargs, + ) diff --git a/csaxs_bec/devices/jungfraujoch/eiger_1_5m.py b/csaxs_bec/devices/jungfraujoch/eiger_1_5m.py index 22432c7c..f5bbcbf5 100644 --- a/csaxs_bec/devices/jungfraujoch/eiger_1_5m.py +++ b/csaxs_bec/devices/jungfraujoch/eiger_1_5m.py @@ -9,9 +9,13 @@ from __future__ import annotations from typing import TYPE_CHECKING -from csaxs_bec.devices.jungfraujoch.eiger import Eiger +from csaxs_bec.devices.jungfraujoch.eiger import RAISE_ON_MISSING_PACKETS, Eiger -EIGER1_5M_READOUT_TIME_US = 500e-6 # 500 microseconds in s +# Gap this model needs between the end of an acquisition and the next trigger. Kept +# per model on purpose -- readout is a detector property, not a beamline constant. The +# delay generator's gap (DDG2 DEFAULT_READOUT_TIMES["ab"]) must be at least as large as +# the largest value among the detectors in a scan, or the slowest one overruns. +EIGER1_5M_READOUT_TIME = 2e-4 # s -- placeholder until a measured value exists DETECTOR_NAME = "EIGER 1.5M" @@ -40,12 +44,15 @@ class Eiger1_5M(Eiger): beam_center: tuple[float, float] = (0.0, 0.0), scan_info: ScanInfo = None, device_manager: DeviceManagerDS = None, + readout_time: float = EIGER1_5M_READOUT_TIME, + raise_on_missing_packets: bool = RAISE_ON_MISSING_PACKETS, **kwargs, ) -> None: super().__init__( name=name, detector_name=DETECTOR_NAME, - readout_time=EIGER1_5M_READOUT_TIME_US, + readout_time=readout_time, + raise_on_missing_packets=raise_on_missing_packets, detector_distance=detector_distance, beam_center=beam_center, scan_info=scan_info, diff --git a/csaxs_bec/devices/jungfraujoch/eiger_9m.py b/csaxs_bec/devices/jungfraujoch/eiger_9m.py index f2061032..4419c61f 100644 --- a/csaxs_bec/devices/jungfraujoch/eiger_9m.py +++ b/csaxs_bec/devices/jungfraujoch/eiger_9m.py @@ -14,13 +14,17 @@ from __future__ import annotations from typing import TYPE_CHECKING -from csaxs_bec.devices.jungfraujoch.eiger import Eiger +from csaxs_bec.devices.jungfraujoch.eiger import RAISE_ON_MISSING_PACKETS, Eiger if TYPE_CHECKING: # pragma no cover from bec_lib.devicemanager import ScanInfo from bec_server.device_server.device_server import DeviceManagerDS -EIGER9M_READOUT_TIME_US = 500e-6 # 500 microseconds in s +# Gap this model needs between the end of an acquisition and the next trigger. Kept +# per model on purpose -- readout is a detector property, not a beamline constant. The +# delay generator's gap (DDG2 DEFAULT_READOUT_TIMES["ab"]) must be at least as large as +# the largest value among the detectors in a scan, or the slowest one overruns. +EIGER9M_READOUT_TIME = 2e-4 # s -- placeholder until a measured value exists DETECTOR_NAME = "EIGER 9M" # "EIGER 9M"" @@ -44,12 +48,15 @@ class Eiger9M(Eiger): beam_center: tuple[float, float] = (0.0, 0.0), scan_info: ScanInfo = None, device_manager: DeviceManagerDS = None, + readout_time: float = EIGER9M_READOUT_TIME, + raise_on_missing_packets: bool = RAISE_ON_MISSING_PACKETS, **kwargs, ) -> None: super().__init__( name=name, detector_name=DETECTOR_NAME, - readout_time=EIGER9M_READOUT_TIME_US, + readout_time=readout_time, + raise_on_missing_packets=raise_on_missing_packets, detector_distance=detector_distance, beam_center=beam_center, scan_info=scan_info, diff --git a/csaxs_bec/devices/jungfraujoch/jungfrau_joch_client.py b/csaxs_bec/devices/jungfraujoch/jungfrau_joch_client.py index dab8cf6c..2e99453b 100644 --- a/csaxs_bec/devices/jungfraujoch/jungfrau_joch_client.py +++ b/csaxs_bec/devices/jungfraujoch/jungfrau_joch_client.py @@ -3,9 +3,11 @@ from __future__ import annotations import enum +import json import threading import time import traceback +from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING import requests @@ -14,6 +16,7 @@ from bec_lib.logger import bec_logger from jfjoch_client.api.default_api import DefaultApi from jfjoch_client.api_client import ApiClient from jfjoch_client.configuration import Configuration +from jfjoch_client.exceptions import ServiceException from jfjoch_client.models.broker_status import BrokerStatus from jfjoch_client.models.dataset_settings import DatasetSettings from jfjoch_client.models.detector_settings import DetectorSettings @@ -23,6 +26,33 @@ logger = bec_logger.logger if TYPE_CHECKING: # pragma: no cover from ophyd import Device +# Keys of the Dectris detector server (Simplon API 1.8.0) endpoint detector/api/1.8.0/status/ +DECTRIS_DETECTOR_STATUS_KEYS = ( + "state", + "series_unique_id", + "time", + "temperature", + "humidity", + "board_000/th0_temp", + "board_000/th0_humidity", + "link_0", + "link_1", + "link_2", + "link_3", +) + + +def _simplify_dectris_status(data): + """ + Reduce a Simplon status response to its values. A single entry {"value": x, ...} becomes x, + a collection {"name": {"value": x, ...}, ...} becomes {"name": x, ...}. + """ + if isinstance(data, dict): + if "value" in data: + return data["value"] + return {key: _simplify_dectris_status(val) for key, val in data.items()} + return data + # pylint: disable=raise-missing-from # pylint: disable=broad-except @@ -54,12 +84,21 @@ class JungfrauJochClient: host (str): Hostname of the Jungfrau Joch broker service. Default is "http://sls-jfjoch-001:8080" parent (Device, optional): Parent ophyd device, used for logging purposes. + dectris_url (str, optional): Base URL of the Dectris detector server (Simplon API), e.g. + "http://129.129.122.159". If set, its read-only status endpoints are logged + by log_dectris_status for debugging detector errors. Default is None (off). """ def __init__( - self, host: str = "http://sls-jfjoch-001:8080", parent: Device | None = None + self, + host: str = "http://sls-jfjoch-001:8080", + parent: Device | None = None, + dectris_url: str | None = None, ) -> None: + self._dectris_url = dectris_url.rstrip("/") if dectris_url else None self._initialised = False + self._recover_lock = threading.Lock() + self._recoveries = 0 configuration = Configuration(host=host) api_client = ApiClient(configuration) self.api = DefaultApi(api_client) @@ -97,10 +136,153 @@ class JungfrauJochClient: status = self.detector_state # TODO: #135 Check if the detector has to be in INACTIVE state before initialisation if status != DetectorState.IDLE: - self.api.initialize_post() - self.wait_for_idle(timeout) + self._initialize(timeout) self.initialised = True + def _initialize(self, timeout: float) -> None: + """ + Trigger the initialisation of the detector and wait until it is done. The X-ray shutter + must be closed, and the call can take up to a few minutes depending on the detector. + + Args: + timeout (float): Timeout in seconds to wait for the initialisation to finish. + """ + self.api.initialize_post() + self.wait_for_idle(timeout) + + def recover_from_error(self, timeout: float = 60) -> bool: + """ + Re-initialise the detector if JungfrauJoch is in ERROR state, equivalent to pressing + 'Initialize' in the JungfrauJoch WEB UI. Must only be called between acquisitions, as + the initialisation requires a closed shutter and is not safe during a measurement. + Only a single attempt is made per call. + + Args: + timeout (float): Timeout in seconds to wait for the detector to become IDLE again. + + Returns: + bool: True if the detector was re-initialised, False if it was not in ERROR state. + + Raises: + JungfrauJochClientError: If the detector is not IDLE after the re-initialisation. + """ + with self._recover_lock: + if self.detector_state != DetectorState.ERROR: + return False + self._recoveries += 1 + logger.warning( + f"JungfrauJoch backend for device {self._parent_name} is in ERROR state, " + f"re-initialising the detector (recovery #{self._recoveries} since startup)." + ) + # Capture the detector server state before the re-initialisation wipes it. + dectris_status = self.log_dectris_status( + f"before re-init (recovery #{self._recoveries})" + ) + try: + self._initialize(timeout) + except Exception as exc: + hint = "" + series = self._dectris_still_armed(dectris_status) + if series is not None: + hint = ( + f" Dectris detector server is still armed (state ready, series {series}) " + "from a previous acquisition that was not cancelled. JungfrauJoch cannot " + "re-initialise until it is disarmed. Disarm it on the Dectris server, or " + "re-seat the detector fibres." + ) + raise JungfrauJochClientError( + f"Error on device {self._parent_name}: re-initialisation after detector " + f"error failed: {exc}{hint}" + ) from exc + state = self.detector_state + if state != DetectorState.IDLE: + raise JungfrauJochClientError( + f"Error on device {self._parent_name}: detector is in state {state.value} " + "after re-initialisation, expected IDLE." + ) + logger.info(f"Device {self._parent_name} re-initialised and back in IDLE state.") + return True + + def log_dectris_status(self, reason: str, timeout: float = 2) -> dict | None: + """ + Log the status endpoints of the Dectris detector server (Simplon API) as a single warning + line of compact JSON. Only read-only GET requests are made. Does nothing if no dectris_url + was configured. This never raises and takes at most roughly the timeout, it is meant to be + called while handling a detector error. + + Args: + reason (str): Why the status is logged, included in the log line. + timeout (float): Timeout in seconds of each HTTP request. Requests run in parallel. + + Returns: + dict | None: The logged status values, None if disabled or the status could not be read. + """ + if self._dectris_url is None: + return None + try: + base = self._dectris_url + urls = { + f"detector/{key}": f"{base}/detector/api/1.8.0/status/{key}" + for key in DECTRIS_DETECTOR_STATUS_KEYS + } + urls["detector/error"] = f"{base}/detector/api/1.8.0/status/error" + for component in ("stream", "monitor", "filewriter"): + urls[component] = f"{base}/{component}/api/1.8.0/status/" + + def _get(url: str): + try: + response = requests.get(url, timeout=timeout) + response.raise_for_status() + return _simplify_dectris_status(response.json()) + except Exception as exc: + return f"<{type(exc).__name__}: {str(exc)[:120]}>" + + with ThreadPoolExecutor(max_workers=len(urls)) as pool: + results = dict(zip(urls, pool.map(_get, urls.values()))) + logger.warning( + f"Dectris detector server status for device {self._parent_name} ({reason}): " + f"{json.dumps(results, separators=(',', ':'), default=str)}" + ) + return results + except Exception as exc: + logger.warning(f"Could not log Dectris detector server status ({reason}): {exc}") + return None + + @staticmethod + def _dectris_still_armed(results: dict | None) -> str | None: + """ + Check a status returned by log_dectris_status for a detector server that is still armed, + i.e. a series was started but never finished or cancelled. JungfrauJoch then refuses to + initialise with "Detector busy". + + Returns: + str | None: The series id of the armed series ("unknown" if not available), None if + the detector is not armed or the status is not available. + """ + if not isinstance(results, dict): + return None + stream = results.get("stream") + stream_state = stream.get("state") if isinstance(stream, dict) else None + if results.get("detector/state") != "ready" and stream_state != "acquire": + return None + series = results.get("detector/series_unique_id") + return series if isinstance(series, str) and not series.startswith("<") else "unknown" + + @staticmethod + def _api_error_message(exc: Exception) -> str | None: + """ + Extract the message of a JungfrauJoch HTTP 500 error. The response body is a plain + string that jfjoch_client fails to parse into its ErrorMessage model (pydantic + ValidationError), so the message is read from the raw body of the ServiceException. + """ + if not isinstance(exc, ServiceException): + return None + try: + body = json.loads(exc.body) + return f"{body['msg']} ({body['reason']})" + except Exception: + return exc.body or exc.reason + def set_detector_settings(self, settings: dict | DetectorSettings, timeout: int = 10) -> None: """ Set the detector settings. The state of JungfrauJoch must be in IDLE, @@ -190,12 +372,21 @@ class JungfrauJochClient: f"{yaml.dump(settings, indent=4)}. \nTraceback: {content}." ) - def stop(self, request_timeout: float = 0.5) -> None: - """Stop the acquisition, this only logs errors and is not raising.""" + def stop(self, request_timeout: float = 0.5, reason: str = "") -> None: + """ + Stop the acquisition, this only logs errors and is not raising. After the cancel, the + Dectris detector server status is logged (if configured) to show whether it disarmed. + + Args: + request_timeout (float): Timeout in seconds for the cancel request. + reason (str): Why the acquisition is stopped, included in the log lines. + """ def _stop_call(self): try: self.api.cancel_post_with_http_info() # (_request_timeout=request_timeout) + logger.info(f"Cancel sent to JungfrauJoch for device {self._parent_name} ({reason}).") + self.log_dectris_status(f"after cancel ({reason})") except requests.exceptions.Timeout: content = traceback.format_exc() logger.error( @@ -237,12 +428,15 @@ class JungfrauJochClient: return False except Exception as exc: content = traceback.format_exc() + message = self._api_error_message(exc) logger.info( - f"Error on device {self._parent_name} in wait_for_idle. Full traceback: {content}" + f"Error on device {self._parent_name} in wait_for_idle. " + f"{message or ''} Full traceback: {content}" ) if raise_on_timeout: raise JungfrauJochClientError( - f"Error on device {self._parent_name} in wait_for_idle: {content}" + f"Error on device {self._parent_name} in wait_for_idle: " + f"{message or content}" ) from exc return False return True diff --git a/csaxs_bec/devices/omny/flomni_sample_storage.py b/csaxs_bec/devices/omny/flomni_sample_storage.py index 380d8d1a..2ccce0c8 100644 --- a/csaxs_bec/devices/omny/flomni_sample_storage.py +++ b/csaxs_bec/devices/omny/flomni_sample_storage.py @@ -1,5 +1,27 @@ +"""flOMNI sample storage on the flOMNI soft IOC (``ioc_flomni.py`` in the flOMNI repo). + +One record per slot, all fields autosaved by the IOC:: + + FLOMNI-SAMPLE::USED 0 = free, 1 = sample present + FLOMNI-SAMPLE::NAME sample name (long string, up to 256 chars) + FLOMNI-SAMPLE::OWNER e-account of the owner + FLOMNI-SAMPLE::STATUS none / started / completed (measurement status) + FLOMNI-SAMPLE::MEAS_DATE date of the measurement + + = STAGE (slot 0, the flOMNI sample stage), T01 ... T20 (tray slots 1-20), + GRIPPER (slot 100) + +Replaces (2026-10-17) the former ``XOMNY-SAMPLE_DB_flomni:GET`` / ``.DESC`` records, +where name and owner were packed into one 40-character DESC (``sample_desc_codec``) +and the measurement status lived in a separate BEC global variable keyed by that +packed string. The status now 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,13 +29,36 @@ from ophyd import DynamicDeviceComponent as Dcpt from ophyd import EpicsSignal from prettytable import PrettyTable -from csaxs_bec.devices.omny.sample_desc_codec import pack_desc, unpack_desc +PV_PREFIX = "FLOMNI-SAMPLE:" +GRIPPER_SLOT = 100 +STAGE_SLOT = 0 +TRAY_SLOTS = range(1, 21) +EMPTY_NAME = "-" +STATUS_NONE = "none" +STATUS_STARTED = "started" +STATUS_COMPLETED = "completed" +STATUS_VALUES = (STATUS_NONE, STATUS_STARTED, STATUS_COMPLETED) class FlomniSampleStorageError(Exception): pass +def _slot_pv(slot_nr: int) -> str: + if slot_nr == STAGE_SLOT: + return "STAGE" + if slot_nr == GRIPPER_SLOT: + return "GRIPPER" + return f"T{slot_nr:02d}" + + +def _slot_signals(field: str, **kwargs) -> dict: + return { + f"sample{i}": (EpicsSignal, f"{PV_PREFIX}{_slot_pv(i)}:{field}", dict(auto_monitor=True, **kwargs)) + for i in range(21) + } + + class FlomniSampleStorage(Device): USER_ACCESS = [ "is_sample_slot_used", @@ -25,30 +70,36 @@ class FlomniSampleStorage(Device): "get_sample_name", "get_sample_owner", "get_sample_name_and_owner", + "get_sample_status", + "get_sample_meas_date", + "get_sample_record", + "set_sample_measured", + "transfer_sample", "show_all", ] SUB_VALUE = "value" _default_sub = SUB_VALUE - sample_placed = { - f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_flomni{i}:GET", {"auto_monitor": True}) for i in range(21) - } - sample_placed = Dcpt(sample_placed) - sample_names = { - f"sample{i}": (EpicsSignal, f"XOMNY-SAMPLE_DB_flomni{i}:GET.DESC", {"string": True, "auto_monitor": True}) - for i in range(21) - } - sample_names = Dcpt(sample_names) + # slots 0-20 (0 = flOMNI stage); attribute names kept from the former device + sample_placed = Dcpt(_slot_signals("USED")) + sample_names = Dcpt(_slot_signals("NAME", string=True)) + sample_owners = Dcpt(_slot_signals("OWNER", string=True)) + sample_status = Dcpt(_slot_signals("STATUS", string=True)) + sample_meas_date = Dcpt(_slot_signals("MEAS_DATE", string=True)) - sample_in_gripper = Cpt( - EpicsSignal, name="sample_in_gripper", read_pv="XOMNY-SAMPLE_DB_flomni100:GET", auto_monitor=True - ) + # gripper (slot 100) + sample_in_gripper = Cpt(EpicsSignal, read_pv=f"{PV_PREFIX}GRIPPER:USED", auto_monitor=True) sample_in_gripper_name = Cpt( - EpicsSignal, - name="sample_in_gripper_name", - read_pv="XOMNY-SAMPLE_DB_flomni100:GET.DESC", - string=True, - auto_monitor=True + EpicsSignal, read_pv=f"{PV_PREFIX}GRIPPER:NAME", string=True, auto_monitor=True + ) + sample_in_gripper_owner = Cpt( + EpicsSignal, read_pv=f"{PV_PREFIX}GRIPPER:OWNER", string=True, auto_monitor=True + ) + sample_in_gripper_status = Cpt( + EpicsSignal, read_pv=f"{PV_PREFIX}GRIPPER:STATUS", string=True, auto_monitor=True + ) + sample_in_gripper_meas_date = Cpt( + EpicsSignal, read_pv=f"{PV_PREFIX}GRIPPER:MEAS_DATE", string=True, auto_monitor=True ) def __init__(self, prefix="", *, name, **kwargs): @@ -60,72 +111,132 @@ class FlomniSampleStorage(Device): self.wait_for_connection() self._run_subs(sub_type=self.SUB_VALUE, timestamp=timestamp, obj=self) - def set_sample_slot(self, slot_nr: int, name: str, owner: str = "") -> bool: - if slot_nr > 20: - raise FlomniSampleStorageError(f"Invalid slot number {slot_nr}.") + # ── slot addressing ────────────────────────────────────────────────────── + @staticmethod + def _check_slot(slot_nr: int) -> int: + slot_nr = int(slot_nr) + if slot_nr == GRIPPER_SLOT or 0 <= slot_nr <= 20: + return slot_nr + raise FlomniSampleStorageError( + f"Invalid slot number {slot_nr} (0 = stage, 1-20 = tray, {GRIPPER_SLOT} = gripper)." + ) - getattr(self.sample_placed, f"sample{slot_nr}").set(1) - getattr(self.sample_names, f"sample{slot_nr}").set(pack_desc(name, owner)) + def _sig(self, field: str, slot_nr: int) -> EpicsSignal: + slot_nr = self._check_slot(slot_nr) + if slot_nr == GRIPPER_SLOT: + return { + "used": self.sample_in_gripper, + "name": self.sample_in_gripper_name, + "owner": self.sample_in_gripper_owner, + "status": self.sample_in_gripper_status, + "meas_date": self.sample_in_gripper_meas_date, + }[field] + group = { + "used": self.sample_placed, + "name": self.sample_names, + "owner": self.sample_owners, + "status": self.sample_status, + "meas_date": self.sample_meas_date, + }[field] + return getattr(group, f"sample{slot_nr}") - def unset_sample_slot(self, slot_nr: int) -> bool: - if slot_nr > 20: - raise FlomniSampleStorageError(f"Invalid slot number {slot_nr}.") + @staticmethod + def _str(val) -> str: + return "" if val is None else str(val) - getattr(self.sample_placed, f"sample{slot_nr}").set(0) - getattr(self.sample_names, f"sample{slot_nr}").set("-") + # ── write ──────────────────────────────────────────────────────────────── + def _write(self, slot_nr: int, used: int, name: str, owner: str, status: str, meas_date: str): + if status not in STATUS_VALUES: + raise FlomniSampleStorageError(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_nr).set(name).wait(timeout=5) + self._sig("owner", slot_nr).set(owner or "").wait(timeout=5) + self._sig("status", slot_nr).set(status).wait(timeout=5) + self._sig("meas_date", slot_nr).set(meas_date or "").wait(timeout=5) + self._sig("used", slot_nr).set(int(used)).wait(timeout=5) - def set_sample_in_gripper(self, name: str, owner: str = "") -> bool: - self.sample_in_gripper.set(1) - self.sample_in_gripper_name.set(pack_desc(name, owner)) + def set_sample_slot( + self, slot_nr: int, name: str, owner: str = "", status: str = STATUS_NONE, meas_date: str = "" + ): + """Place a sample record in slot `slot_nr` (0-20, or 100 = gripper).""" + self._write(slot_nr, 1, name, owner, status, meas_date) - def unset_sample_in_gripper(self) -> bool: - self.sample_in_gripper.set(0) - self.sample_in_gripper_name.set("-") + def unset_sample_slot(self, slot_nr: int): + """Clear slot `slot_nr` (0-20, or 100 = gripper).""" + self._write(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_SLOT, name, owner, status, meas_date) + + def unset_sample_in_gripper(self): + self.unset_sample_slot(GRIPPER_SLOT) + + def set_sample_measured(self, slot_nr: int, status: str, meas_date: str, owner_if_empty: str = ""): + """Record the measurement status/date of the sample in `slot_nr`. + + 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. + """ + if status not in STATUS_VALUES: + raise FlomniSampleStorageError(f"Invalid status {status!r}, expected one of {STATUS_VALUES}.") + if owner_if_empty and not self.get_sample_owner(slot_nr): + self._sig("owner", slot_nr).set(owner_if_empty).wait(timeout=5) + self._sig("status", slot_nr).set(status).wait(timeout=5) + self._sig("meas_date", slot_nr).set(meas_date or "").wait(timeout=5) + + def transfer_sample(self, from_slot: int, to_slot: int): + """Move the complete record (name, owner, status, date) from one slot to + another (0-20, 100 = gripper) and clear the source slot.""" + rec = self.get_sample_record(from_slot) + self.set_sample_slot(to_slot, rec["name"], rec["owner"], rec["status"], rec["meas_date"]) + self.unset_sample_slot(from_slot) + + # ── read ───────────────────────────────────────────────────────────────── def is_sample_slot_used(self, slot_nr: int) -> bool: - val = getattr(self.sample_placed, f"sample{slot_nr}").get() - return bool(val) + return bool(self._sig("used", slot_nr).get(use_monitor=False)) def is_sample_in_gripper(self) -> bool: - val = self.sample_in_gripper.get() - return bool(val) + return self.is_sample_slot_used(GRIPPER_SLOT) def get_sample_name(self, slot_nr) -> str: - val = getattr(self.sample_names, f"sample{slot_nr}").get() - return unpack_desc(str(val))[0] + name = self._str(self._sig("name", slot_nr).get()) + return name if name else EMPTY_NAME def get_sample_owner(self, slot_nr) -> str: - val = getattr(self.sample_names, f"sample{slot_nr}").get() - return unpack_desc(str(val))[1] + return self._str(self._sig("owner", slot_nr).get()) def get_sample_name_and_owner(self, slot_nr) -> tuple: - val = getattr(self.sample_names, f"sample{slot_nr}").get() - return unpack_desc(str(val)) + return (self.get_sample_name(slot_nr), self.get_sample_owner(slot_nr)) - def show_all(self, measured: Optional[dict] = None): - """Print the tray grid plus loaded tray/gripper/stage samples. + def get_sample_status(self, slot_nr) -> str: + status = self._str(self._sig("status", slot_nr).get()) + return status if status in STATUS_VALUES else STATUS_NONE - `measured`, if given, is a plain dict (as returned by - `_MeasuredSampleLog.snapshot()`) mapping packed - `pack_desc(name, owner)` strings to {"status": ..., ...} records -- - this device class stays decoupled from the BEC-client-backed - measured-sample log that produces it; callers (e.g. - `Flomni.ftransfer_show_all()`) inject the snapshot instead. + def get_sample_meas_date(self, slot_nr) -> str: + return self._str(self._sig("meas_date", slot_nr).get()) - Must be a plain, msgpack-serializable dict, not a callable: this - method runs via BEC's device-server RPC mechanism, which - serializes every argument before sending it, so a bound method - (an earlier version of this passed `self.measured_log.get`) fails - with "can not serialize 'method' object". - """ + def get_sample_record(self, slot_nr) -> dict: + return { + "used": self.is_sample_slot_used(slot_nr), + "name": self.get_sample_name(slot_nr), + "owner": self.get_sample_owner(slot_nr), + "status": self.get_sample_status(slot_nr), + "meas_date": self.get_sample_meas_date(slot_nr), + } - def _measured_suffix(name: str, owner: str) -> str: - if not measured: - return "" - record = measured.get(pack_desc(name, owner)) - if record is None: - return "" - return f" [measured: {record['status']}]" + def show_all(self): + """Print the tray grid plus loaded tray/gripper/stage samples.""" + + def _suffix(slot_nr: int) -> str: + owner = self.get_sample_owner(slot_nr) + status = self.get_sample_status(slot_nr) + text = f" (owner: {owner})" if owner else "" + if status != STATUS_NONE: + date = self.get_sample_meas_date(slot_nr) + text += f" [measured: {status}{', ' + date if date else ''}]" + return text t = PrettyTable() t.title = "flOMNI sample storage" @@ -141,21 +252,15 @@ class FlomniSampleStorage(Device): t.add_row(row) print(t) print("\n\nFollowing samples are currently loaded:\n") - for ct in range(1, 21): + for ct in TRAY_SLOTS: if self.is_sample_slot_used(ct): - name, owner = self.get_sample_name_and_owner(ct) - owner_suffix = f" (owner: {owner})" if owner else "" - print(f" Position {ct:2.0f}: {name}{owner_suffix}{_measured_suffix(name, owner)}") - if self.sample_in_gripper.get(): - name, owner = unpack_desc(str(self.sample_in_gripper_name.get())) - owner_suffix = f" (owner: {owner})" if owner else "" - print(f"\n Gripper: {name}{owner_suffix}{_measured_suffix(name, owner)}\n") + print(f" Position {ct:2.0f}: {self.get_sample_name(ct)}{_suffix(ct)}") + if self.is_sample_in_gripper(): + print(f"\n Gripper: {self.get_sample_name(GRIPPER_SLOT)}{_suffix(GRIPPER_SLOT)}\n") else: print(f"\n Gripper: no sample\n") - if self.is_sample_slot_used(0): - name, owner = self.get_sample_name_and_owner(0) - owner_suffix = f" (owner: {owner})" if owner else "" - print(f" flOMNI stage: {name}{owner_suffix}{_measured_suffix(name, owner)}\n") + if self.is_sample_slot_used(STAGE_SLOT): + print(f" flOMNI stage: {self.get_sample_name(STAGE_SLOT)}{_suffix(STAGE_SLOT)}\n") else: print(f" flOMNI stage: no sample\n") diff --git a/csaxs_bec/devices/omny/galil/fgalil_ophyd.py b/csaxs_bec/devices/omny/galil/fgalil_ophyd.py index da9e8b5a..e955ae71 100644 --- a/csaxs_bec/devices/omny/galil/fgalil_ophyd.py +++ b/csaxs_bec/devices/omny/galil/fgalil_ophyd.py @@ -42,6 +42,7 @@ class FlomniGalilController(GalilController): "lights_on", "print_command_history", "hard_abort_and_restore_positioning_mode", + "stop_all_axes", ] def is_axis_moving(self, axis_Id, axis_Id_numeric) -> bool: diff --git a/csaxs_bec/devices/omny/galil/galil_ophyd.py b/csaxs_bec/devices/omny/galil/galil_ophyd.py index de0ce5ff..41553378 100644 --- a/csaxs_bec/devices/omny/galil/galil_ophyd.py +++ b/csaxs_bec/devices/omny/galil/galil_ophyd.py @@ -47,6 +47,7 @@ class GalilController(Controller): "all_axes_referenced", "print_command_history", "hard_abort_and_restore_positioning_mode", + "stop_all_axes", ] OKBLUE = "\033[94m" @@ -58,9 +59,17 @@ class GalilController(Controller): @threadlocked def socket_put(self, val: str) -> None: + time.sleep(0.01) self.command_history.append(f"[PUT]: {val}") self.sock.put(f"{val}\r".encode()) + @threadlocked + def socket_get(self) -> str: + time.sleep(0.01) + response = self.sock.receive().decode() + self.command_history.append(f"[GET]: {response}") + return response + @retry_once def socket_put_confirmed(self, val: str) -> None: """Send message to controller and ensure that it is received by checking that the socket receives a colon. @@ -240,7 +249,7 @@ class GalilController(Controller): if not limit: raise GalilError(f"Failed to drive axis {axis_Id}/{axis_Id_numeric} to limit.") else: - print("Limit reached.") + print("Limit switch reached.") def find_reference(self, axis_Id_numeric: int, verbose=0, raise_error=1) -> None: """ diff --git a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py index 123aab5f..0cf66f39 100644 --- a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py +++ b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py @@ -375,7 +375,7 @@ class RtFlomniController(Controller): return False def laser_tracker_wait_on_target(self): - max_repeat = 25 + max_repeat = 100 count = 0 self.laser_tracker_galil_enable() while not self.laser_tracker_on_target(): @@ -383,6 +383,7 @@ class RtFlomniController(Controller): time.sleep(0.5) count += 1 if count == 10: + print("Waiting for the tracker.") ftrackz_con = self.device_manager.devices.ftrackz.obj.controller ftrackz_con.socket_put_confirmed("tracken=1") ftrackz_con.socket_put_confirmed("trackyct=0") diff --git a/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py b/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py index c9c70f87..22ea1cf4 100644 --- a/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py +++ b/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py @@ -53,6 +53,7 @@ class RtLamniController(Controller): "show_signal_strength_interferometer", "show_analog_signals", "show_feedback_status", + "show_interferometer_positions", ] def __init__( @@ -293,7 +294,7 @@ class RtLamniController(Controller): def show_interferometer_positions(self) -> dict: return_table = (self.socket_put_and_receive("J4")).split(",") - loop_status = bool(int(float(return_table[0]))) + loop_status = self.feedback_is_running() pos_y = float(return_table[1]) pos_x = float(return_table[2]) diff --git a/csaxs_bec/devices/omny/webcam_viewer.py b/csaxs_bec/devices/omny/webcam_viewer.py index a150b27c..d4b3e1d5 100644 --- a/csaxs_bec/devices/omny/webcam_viewer.py +++ b/csaxs_bec/devices/omny/webcam_viewer.py @@ -5,13 +5,32 @@ import numpy as np from ophyd import Device, Component as Cpt, Kind, Signal from ophyd_devices import PreviewSignal import traceback +from urllib.parse import urlsplit from bec_lib.logger import bec_logger logger = bec_logger.logger +# The camera server passes "exposure" straight to V4L2 exposure_time_absolute, +# which is in units of 100 us. +EXPOSURE_UNIT_MS = 0.1 +# V4L2 auto_exposure menu values used by the camera server. +AUTO_EXPOSURE_ON = 3 # aperture priority +AUTO_EXPOSURE_OFF = 1 # manual +SETTINGS_TIMEOUT_S = 3 + + class WebcamViewer(Device): - USER_ACCESS = ["start_live_mode", "stop_live_mode"] + USER_ACCESS = [ + "start_live_mode", + "stop_live_mode", + "get_exposure_time", + "set_exposure_time", + "set_auto_exposure_enabled", + "set_gain", + "set_brightness", + "refresh_settings", + ] preview = Cpt(PreviewSignal, ndim=2, num_rotation_90=0, transpose=False) live_mode_enabled = Cpt( Signal, @@ -20,6 +39,24 @@ class WebcamViewer(Device): doc="Enable or disable live mode.", kind=Kind.config, ) + exposure_time = Cpt( + Signal, + name="exposure_time", + value=0.0, + doc="Camera exposure time (ms). Only effective with auto exposure disabled.", + kind=Kind.config, + ) + auto_exposure_enabled = Cpt( + Signal, + name="auto_exposure_enabled", + value=True, + doc="Enable/disable camera auto exposure.", + kind=Kind.config, + ) + gain = Cpt(Signal, name="gain", value=0.0, doc="Camera gain.", kind=Kind.config) + brightness = Cpt( + Signal, name="brightness", value=0.0, doc="Camera brightness.", kind=Kind.config + ) def __init__(self, url:str, name:str, num_rotation_90=0, transpose=False, **kwargs) -> None: super().__init__(name=name, **kwargs) @@ -31,7 +68,17 @@ class WebcamViewer(Device): self._live_mode_lock = threading.RLock() self.preview.num_rotation_90 = num_rotation_90 self.preview.transpose = transpose + parts = urlsplit(url) + self._settings_url = f"{parts.scheme}://{parts.netloc}/camera/settings" + self._refreshing = False self.live_mode_enabled.subscribe(self._on_live_mode_enabled_changed, run=False) + self.exposure_time.subscribe(self._on_exposure_time_changed, run=False) + self.auto_exposure_enabled.subscribe(self._on_auto_exposure_enabled_changed, run=False) + self.gain.subscribe(self._on_gain_changed, run=False) + self.brightness.subscribe(self._on_brightness_changed, run=False) + # Load the camera's current settings in the background, so an unreachable + # camera server cannot block the device from loading. + threading.Thread(target=self._initial_refresh, daemon=True).start() def start_live_mode(self) -> None: self.live_mode_enabled.put(True) @@ -39,6 +86,93 @@ class WebcamViewer(Device): def stop_live_mode(self) -> None: self.live_mode_enabled.put(False) + # ---- camera settings (HTTP /camera/settings on the camera server) ---- + + def _post_settings(self, payload: dict) -> None: + """POST settings to the camera server; log instead of raising on failure.""" + try: + response = requests.post( + self._settings_url, json=payload, timeout=SETTINGS_TIMEOUT_S + ) + response.raise_for_status() + applied = response.json().get("applied", {}) + bad = {k: v for k, v in applied.items() if isinstance(v, str)} + if bad: + logger.error(f"{self.name}: camera server rejected settings {bad}") + except Exception: # pylint: disable=broad-except + logger.error( + f"{self.name}: failed to apply {payload} at {self._settings_url}: " + f"{traceback.format_exc()}" + ) + + def _on_exposure_time_changed(self, *args, value, **kwargs) -> None: + if self._refreshing: + return + payload = {"exposure": float(value) / EXPOSURE_UNIT_MS} + if self.auto_exposure_enabled.get(): + logger.warning( + f"{self.name}: exposure_time set while auto exposure is enabled; " + "it has no effect until set_auto_exposure_enabled(False)." + ) + self._post_settings(payload) + + def _on_auto_exposure_enabled_changed(self, *args, value, **kwargs) -> None: + if self._refreshing: + return + self._post_settings({"auto_exposure": AUTO_EXPOSURE_ON if value else AUTO_EXPOSURE_OFF}) + + def _on_gain_changed(self, *args, value, **kwargs) -> None: + if not self._refreshing: + self._post_settings({"gain": float(value)}) + + def _on_brightness_changed(self, *args, value, **kwargs) -> None: + if not self._refreshing: + self._post_settings({"brightness": float(value)}) + + def get_exposure_time(self) -> float: + """Get the cached exposure time (ms). Use refresh_settings() to re-read the camera.""" + return float(self.exposure_time.get()) + + def set_exposure_time(self, value: float) -> None: + """Set the exposure time (ms). Disables auto exposure first, otherwise the camera ignores it.""" + if self.auto_exposure_enabled.get(): + self.auto_exposure_enabled.put(False) + self.exposure_time.put(value) + + def set_auto_exposure_enabled(self, enable: bool) -> None: + self.auto_exposure_enabled.put(bool(enable)) + + def set_gain(self, value: float) -> None: + self.gain.put(value) + + def set_brightness(self, value: float) -> None: + self.brightness.put(value) + + def _initial_refresh(self) -> None: + try: + self.refresh_settings() + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"{self.name}: could not read camera settings from {self._settings_url}: {exc}") + + def refresh_settings(self) -> dict: + """Read the current settings from the camera server into the Signals.""" + response = requests.get(self._settings_url, timeout=SETTINGS_TIMEOUT_S) + response.raise_for_status() + data = response.json() + self._refreshing = True + try: + if "exposure" in data: + self.exposure_time.put(float(data["exposure"]) * EXPOSURE_UNIT_MS) + if "auto_exposure" in data: + self.auto_exposure_enabled.put(float(data["auto_exposure"]) >= 2.0) + if "gain" in data: + self.gain.put(float(data["gain"])) + if "brightness" in data: + self.brightness.put(float(data["brightness"])) + finally: + self._refreshing = False + return data + def _on_live_mode_enabled_changed(self, *args, value, **kwargs) -> None: self._apply_live_mode(bool(value)) diff --git a/csaxs_bec/devices/omny/xray_epics_gui.py b/csaxs_bec/devices/omny/xray_epics_gui.py index 0f0760de..a18e4df6 100644 --- a/csaxs_bec/devices/omny/xray_epics_gui.py +++ b/csaxs_bec/devices/omny/xray_epics_gui.py @@ -32,6 +32,12 @@ class OMNYXRayAlignGUI(Device): for i in range(11): locals()[f"width_x_{i}"] = Cpt(Signal, value=0) + # Generate roi_angle_0 to roi_angle_10 (rotation of an elliptical ROI in degrees, + # see OMNY_XRayEye; width_x/width_y are then the ellipse axes along/perpendicular + # to this rotation) + for i in range(11): + locals()[f"roi_angle_{i}"] = Cpt(Signal, value=0) + # Generate xval_x_0 to xval_x_10 for i in range(11): locals()[f"xval_x_{i}"] = Cpt(Signal, value=0) diff --git a/csaxs_bec/devices/panda_box/layouts/omny.ini b/csaxs_bec/devices/panda_box/layouts/omny.ini new file mode 100644 index 00000000..e2c505bf --- /dev/null +++ b/csaxs_bec/devices/panda_box/layouts/omny.ini @@ -0,0 +1,876 @@ +OUTENC1.DATA.DELAY=0 +OUTENC2.DATA.DELAY=0 +OUTENC3.DATA.DELAY=0 +OUTENC4.DATA.DELAY=0 +OUTENC1.A.DELAY=0 +OUTENC2.A.DELAY=0 +OUTENC3.A.DELAY=0 +OUTENC4.A.DELAY=0 +OUTENC1.QPERIOD.UNITS=s +OUTENC2.QPERIOD.UNITS=s +OUTENC3.QPERIOD.UNITS=s +OUTENC4.QPERIOD.UNITS=s +OUTENC1.Z.DELAY=0 +OUTENC2.Z.DELAY=0 +OUTENC3.Z.DELAY=0 +OUTENC4.Z.DELAY=0 +OUTENC1.B.DELAY=0 +OUTENC2.B.DELAY=0 +OUTENC3.B.DELAY=0 +OUTENC4.B.DELAY=0 +OUTENC1.ENABLE.DELAY=0 +OUTENC2.ENABLE.DELAY=0 +OUTENC3.ENABLE.DELAY=0 +OUTENC4.ENABLE.DELAY=0 +PCOMP1.ENABLE.DELAY=0 +PCOMP2.ENABLE.DELAY=0 +TTLOUT1.VAL.DELAY=0 +TTLOUT2.VAL.DELAY=0 +TTLOUT3.VAL.DELAY=0 +TTLOUT4.VAL.DELAY=0 +TTLOUT5.VAL.DELAY=0 +TTLOUT6.VAL.DELAY=0 +TTLOUT7.VAL.DELAY=0 +TTLOUT8.VAL.DELAY=0 +TTLOUT9.VAL.DELAY=0 +TTLOUT10.VAL.DELAY=0 +PCAP.TS_END.CAPTURE=No +PCAP.GATE.DELAY=1 +PCAP.TS_START.CAPTURE=No +PCAP.ENABLE.DELAY=0 +PCAP.BITS2.CAPTURE=No +PCAP.BITS0.CAPTURE=No +PCAP.BITS3.CAPTURE=No +PCAP.BITS1.CAPTURE=No +PCAP.GATE_DURATION.CAPTURE=Value +PCAP.TS_TRIG.CAPTURE=No +PCAP.TRIG.DELAY=1 +SFP3_SYNC_IN.POS3.UNITS= +SFP3_SYNC_IN.POS3.OFFSET=0 +SFP3_SYNC_IN.POS3.SCALE=1 +SFP3_SYNC_IN.POS3.CAPTURE=No +SFP3_SYNC_IN.POS4.UNITS= +SFP3_SYNC_IN.POS4.OFFSET=0 +SFP3_SYNC_IN.POS4.SCALE=1 +SFP3_SYNC_IN.POS4.CAPTURE=No +SFP3_SYNC_IN.POS2.UNITS= +SFP3_SYNC_IN.POS2.OFFSET=0 +SFP3_SYNC_IN.POS2.SCALE=1 +SFP3_SYNC_IN.POS2.CAPTURE=No +SFP3_SYNC_IN.POS1.UNITS= +SFP3_SYNC_IN.POS1.OFFSET=0 +SFP3_SYNC_IN.POS1.SCALE=1 +SFP3_SYNC_IN.POS1.CAPTURE=No +DIV1.INP.DELAY=0 +DIV2.INP.DELAY=0 +DIV1.ENABLE.DELAY=0 +DIV2.ENABLE.DELAY=0 +INENC1.CLK.DELAY=0 +INENC2.CLK.DELAY=0 +INENC3.CLK.DELAY=0 +INENC4.CLK.DELAY=0 +INENC1.VAL.UNITS= +INENC2.VAL.UNITS= +INENC3.VAL.UNITS= +INENC4.VAL.UNITS= +INENC1.VAL.OFFSET=0 +INENC2.VAL.OFFSET=0 +INENC3.VAL.OFFSET=0 +INENC4.VAL.OFFSET=0 +INENC1.VAL.SCALE=1 +INENC2.VAL.SCALE=1 +INENC3.VAL.SCALE=1 +INENC4.VAL.SCALE=1 +INENC1.VAL.CAPTURE=Min Max Mean +INENC2.VAL.CAPTURE=Min Max Mean +INENC3.VAL.CAPTURE=Min Max Mean +INENC4.VAL.CAPTURE=Min Max Mean +INENC1.CLK_PERIOD.UNITS=s +INENC2.CLK_PERIOD.UNITS=s +INENC3.CLK_PERIOD.UNITS=s +INENC4.CLK_PERIOD.UNITS=s +INENC1.FRAME_PERIOD.UNITS=s +INENC2.FRAME_PERIOD.UNITS=s +INENC3.FRAME_PERIOD.UNITS=s +INENC4.FRAME_PERIOD.UNITS=s +SFP3_SYNC_OUT.BIT8.DELAY=0 +SFP3_SYNC_OUT.BIT1.DELAY=0 +SFP3_SYNC_OUT.BIT3.DELAY=0 +SFP3_SYNC_OUT.BIT2.DELAY=0 +SFP3_SYNC_OUT.BIT5.DELAY=0 +SFP3_SYNC_OUT.BIT4.DELAY=0 +SFP3_SYNC_OUT.BIT7.DELAY=0 +SFP3_SYNC_OUT.BIT6.DELAY=0 +SEQ1.ENABLE.DELAY=0 +SEQ2.ENABLE.DELAY=0 +SEQ1.PRESCALE.UNITS=s +SEQ2.PRESCALE.UNITS=s +SEQ1.BITA.DELAY=0 +SEQ2.BITA.DELAY=0 +SEQ1.BITC.DELAY=0 +SEQ2.BITC.DELAY=0 +SEQ1.BITB.DELAY=0 +SEQ2.BITB.DELAY=0 +SEQ1.TABLE.QUEUED_LINES=0 +SEQ2.TABLE.QUEUED_LINES=0 +SRGATE1.SET.DELAY=0 +SRGATE2.SET.DELAY=0 +SRGATE3.SET.DELAY=0 +SRGATE4.SET.DELAY=0 +SRGATE1.ENABLE.DELAY=0 +SRGATE2.ENABLE.DELAY=0 +SRGATE3.ENABLE.DELAY=0 +SRGATE4.ENABLE.DELAY=0 +SRGATE1.RST.DELAY=0 +SRGATE2.RST.DELAY=0 +SRGATE3.RST.DELAY=0 +SRGATE4.RST.DELAY=0 +LUT1.INPB.DELAY=0 +LUT2.INPB.DELAY=0 +LUT3.INPB.DELAY=0 +LUT4.INPB.DELAY=0 +LUT5.INPB.DELAY=0 +LUT6.INPB.DELAY=0 +LUT7.INPB.DELAY=0 +LUT8.INPB.DELAY=0 +LUT1.INPC.DELAY=0 +LUT2.INPC.DELAY=0 +LUT3.INPC.DELAY=0 +LUT4.INPC.DELAY=0 +LUT5.INPC.DELAY=0 +LUT6.INPC.DELAY=0 +LUT7.INPC.DELAY=0 +LUT8.INPC.DELAY=0 +LUT1.INPA.DELAY=0 +LUT2.INPA.DELAY=0 +LUT3.INPA.DELAY=0 +LUT4.INPA.DELAY=0 +LUT5.INPA.DELAY=0 +LUT6.INPA.DELAY=0 +LUT7.INPA.DELAY=0 +LUT8.INPA.DELAY=0 +LUT1.INPD.DELAY=0 +LUT2.INPD.DELAY=0 +LUT3.INPD.DELAY=0 +LUT4.INPD.DELAY=0 +LUT5.INPD.DELAY=0 +LUT6.INPD.DELAY=0 +LUT7.INPD.DELAY=0 +LUT8.INPD.DELAY=0 +LUT1.INPE.DELAY=0 +LUT2.INPE.DELAY=0 +LUT3.INPE.DELAY=0 +LUT4.INPE.DELAY=0 +LUT5.INPE.DELAY=0 +LUT6.INPE.DELAY=0 +LUT7.INPE.DELAY=0 +LUT8.INPE.DELAY=0 +CALC1.OUT.UNITS= +CALC2.OUT.UNITS= +CALC1.OUT.OFFSET=0 +CALC2.OUT.OFFSET=0 +CALC1.OUT.SCALE=1 +CALC2.OUT.SCALE=1 +CALC1.OUT.CAPTURE=No +CALC2.OUT.CAPTURE=No +LVDSOUT1.VAL.DELAY=0 +LVDSOUT2.VAL.DELAY=0 +CLOCK1.ENABLE.DELAY=0 +CLOCK2.ENABLE.DELAY=0 +CLOCK1.PERIOD.UNITS=s +CLOCK2.PERIOD.UNITS=s +CLOCK1.WIDTH.UNITS=s +CLOCK2.WIDTH.UNITS=us +COUNTER1.OUT.UNITS= +COUNTER2.OUT.UNITS= +COUNTER3.OUT.UNITS= +COUNTER4.OUT.UNITS= +COUNTER5.OUT.UNITS= +COUNTER6.OUT.UNITS= +COUNTER7.OUT.UNITS= +COUNTER8.OUT.UNITS= +COUNTER1.OUT.OFFSET=0 +COUNTER2.OUT.OFFSET=0 +COUNTER3.OUT.OFFSET=0 +COUNTER4.OUT.OFFSET=0 +COUNTER5.OUT.OFFSET=0 +COUNTER6.OUT.OFFSET=0 +COUNTER7.OUT.OFFSET=0 +COUNTER8.OUT.OFFSET=0 +COUNTER1.OUT.SCALE=1 +COUNTER2.OUT.SCALE=1 +COUNTER3.OUT.SCALE=1 +COUNTER4.OUT.SCALE=1 +COUNTER5.OUT.SCALE=1 +COUNTER6.OUT.SCALE=1 +COUNTER7.OUT.SCALE=1 +COUNTER8.OUT.SCALE=1 +COUNTER1.OUT.CAPTURE=No +COUNTER2.OUT.CAPTURE=No +COUNTER3.OUT.CAPTURE=No +COUNTER4.OUT.CAPTURE=No +COUNTER5.OUT.CAPTURE=No +COUNTER6.OUT.CAPTURE=No +COUNTER7.OUT.CAPTURE=No +COUNTER8.OUT.CAPTURE=No +COUNTER1.DIR.DELAY=0 +COUNTER2.DIR.DELAY=0 +COUNTER3.DIR.DELAY=0 +COUNTER4.DIR.DELAY=0 +COUNTER5.DIR.DELAY=0 +COUNTER6.DIR.DELAY=0 +COUNTER7.DIR.DELAY=0 +COUNTER8.DIR.DELAY=0 +COUNTER1.ENABLE.DELAY=0 +COUNTER2.ENABLE.DELAY=0 +COUNTER3.ENABLE.DELAY=0 +COUNTER4.ENABLE.DELAY=0 +COUNTER5.ENABLE.DELAY=0 +COUNTER6.ENABLE.DELAY=0 +COUNTER7.ENABLE.DELAY=0 +COUNTER8.ENABLE.DELAY=0 +COUNTER1.TRIG.DELAY=0 +COUNTER2.TRIG.DELAY=0 +COUNTER3.TRIG.DELAY=0 +COUNTER4.TRIG.DELAY=0 +COUNTER5.TRIG.DELAY=0 +COUNTER6.TRIG.DELAY=0 +COUNTER7.TRIG.DELAY=0 +COUNTER8.TRIG.DELAY=0 +PULSE1.ENABLE.DELAY=0 +PULSE2.ENABLE.DELAY=0 +PULSE3.ENABLE.DELAY=0 +PULSE4.ENABLE.DELAY=0 +PULSE1.DELAY.UNITS=s +PULSE2.DELAY.UNITS=s +PULSE3.DELAY.UNITS=s +PULSE4.DELAY.UNITS=s +PULSE1.TRIG.DELAY=0 +PULSE2.TRIG.DELAY=0 +PULSE3.TRIG.DELAY=0 +PULSE4.TRIG.DELAY=0 +PULSE1.WIDTH.UNITS=s +PULSE2.WIDTH.UNITS=s +PULSE3.WIDTH.UNITS=s +PULSE4.WIDTH.UNITS=s +PULSE1.STEP.UNITS=s +PULSE2.STEP.UNITS=s +PULSE3.STEP.UNITS=s +PULSE4.STEP.UNITS=s +FILTER1.OUT.UNITS= +FILTER2.OUT.UNITS= +FILTER1.OUT.OFFSET=0 +FILTER2.OUT.OFFSET=0 +FILTER1.OUT.SCALE=1 +FILTER2.OUT.SCALE=1 +FILTER1.OUT.CAPTURE=No +FILTER2.OUT.CAPTURE=No +FILTER1.ENABLE.DELAY=0 +FILTER2.ENABLE.DELAY=0 +FILTER1.TRIG.DELAY=0 +FILTER2.TRIG.DELAY=0 +FMC_IN.VAL8.UNITS=V +FMC_IN.VAL8.OFFSET=0 +FMC_IN.VAL8.SCALE=4.65661287e-09 +FMC_IN.VAL8.CAPTURE=No +FMC_IN.VAL1.UNITS=V +FMC_IN.VAL1.OFFSET=0 +FMC_IN.VAL1.SCALE=4.65661287e-09 +FMC_IN.VAL1.CAPTURE=Min Max Mean +FMC_IN.VAL3.UNITS=V +FMC_IN.VAL3.OFFSET=0 +FMC_IN.VAL3.SCALE=4.65661287e-09 +FMC_IN.VAL3.CAPTURE=No +FMC_IN.VAL2.UNITS=V +FMC_IN.VAL2.OFFSET=0 +FMC_IN.VAL2.SCALE=4.65661287e-09 +FMC_IN.VAL2.CAPTURE=Min Max Mean +FMC_IN.VAL5.UNITS=V +FMC_IN.VAL5.OFFSET=0 +FMC_IN.VAL5.SCALE=4.65661287e-09 +FMC_IN.VAL5.CAPTURE=No +FMC_IN.VAL4.UNITS=V +FMC_IN.VAL4.OFFSET=0 +FMC_IN.VAL4.SCALE=4.65661287e-09 +FMC_IN.VAL4.CAPTURE=No +FMC_IN.VAL7.UNITS=V +FMC_IN.VAL7.OFFSET=0 +FMC_IN.VAL7.SCALE=4.65661287e-09 +FMC_IN.VAL7.CAPTURE=No +FMC_IN.VAL6.UNITS=V +FMC_IN.VAL6.OFFSET=0 +FMC_IN.VAL6.SCALE=4.65661287e-09 +FMC_IN.VAL6.CAPTURE=No +PGEN1.OUT.UNITS= +PGEN2.OUT.UNITS= +PGEN1.OUT.OFFSET=0 +PGEN2.OUT.OFFSET=0 +PGEN1.OUT.SCALE=1 +PGEN2.OUT.SCALE=1 +PGEN1.OUT.CAPTURE=No +PGEN2.OUT.CAPTURE=No +PGEN1.ENABLE.DELAY=0 +PGEN2.ENABLE.DELAY=0 +PGEN1.TABLE.QUEUED_LINES=0 +PGEN2.TABLE.QUEUED_LINES=0 +PGEN1.TRIG.DELAY=0 +PGEN2.TRIG.DELAY=0 +TTLIN1.TERM=High-Z +TTLIN2.TERM=High-Z +TTLIN3.TERM=High-Z +TTLIN4.TERM=High-Z +TTLIN5.TERM=High-Z +TTLIN6.TERM=High-Z +OUTENC1.DATA=ZERO +OUTENC2.DATA=ZERO +OUTENC3.DATA=ZERO +OUTENC4.DATA=ZERO +OUTENC1.A=ZERO +OUTENC2.A=ZERO +OUTENC3.A=ZERO +OUTENC4.A=ZERO +OUTENC1.ENCODING=Unsigned Binary +OUTENC2.ENCODING=Unsigned Binary +OUTENC3.ENCODING=Unsigned Binary +OUTENC4.ENCODING=Unsigned Binary +OUTENC1.QPERIOD=0 +OUTENC2.QPERIOD=0 +OUTENC3.QPERIOD=0 +OUTENC4.QPERIOD=0 +OUTENC1.Z=ZERO +OUTENC2.Z=ZERO +OUTENC3.Z=ZERO +OUTENC4.Z=ZERO +OUTENC1.B=ZERO +OUTENC2.B=ZERO +OUTENC3.B=ZERO +OUTENC4.B=ZERO +OUTENC1.VAL=ZERO +OUTENC2.VAL=ZERO +OUTENC3.VAL=ZERO +OUTENC4.VAL=ZERO +OUTENC1.PROTOCOL=Quadrature +OUTENC2.PROTOCOL=Quadrature +OUTENC3.PROTOCOL=Quadrature +OUTENC4.PROTOCOL=Quadrature +OUTENC1.ENABLE=ZERO +OUTENC2.ENABLE=ZERO +OUTENC3.ENABLE=ZERO +OUTENC4.ENABLE=ZERO +OUTENC1.GENERATOR_ERROR=No +OUTENC2.GENERATOR_ERROR=No +OUTENC3.GENERATOR_ERROR=No +OUTENC4.GENERATOR_ERROR=No +OUTENC1.BITS=0 +OUTENC2.BITS=0 +OUTENC3.BITS=0 +OUTENC4.BITS=0 +PCOMP1.PULSES=0 +PCOMP2.PULSES=0 +PCOMP1.PRE_START=0 +PCOMP2.PRE_START=0 +PCOMP1.ENABLE=ZERO +PCOMP2.ENABLE=ZERO +PCOMP1.DIR=Positive +PCOMP2.DIR=Positive +PCOMP1.START=0 +PCOMP2.START=0 +PCOMP1.WIDTH=0 +PCOMP2.WIDTH=0 +PCOMP1.INP=ZERO +PCOMP2.INP=ZERO +PCOMP1.RELATIVE=Absolute +PCOMP2.RELATIVE=Absolute +PCOMP1.STEP=0 +PCOMP2.STEP=0 +TTLOUT1.FINE_DELAY=0 +TTLOUT2.FINE_DELAY=0 +TTLOUT3.FINE_DELAY=0 +TTLOUT4.FINE_DELAY=0 +TTLOUT5.FINE_DELAY=0 +TTLOUT6.FINE_DELAY=0 +TTLOUT7.FINE_DELAY=0 +TTLOUT8.FINE_DELAY=0 +TTLOUT9.FINE_DELAY=0 +TTLOUT10.FINE_DELAY=0 +TTLOUT1.QUARTER_DELAY=0 +TTLOUT2.QUARTER_DELAY=0 +TTLOUT3.QUARTER_DELAY=0 +TTLOUT4.QUARTER_DELAY=0 +TTLOUT5.QUARTER_DELAY=0 +TTLOUT6.QUARTER_DELAY=0 +TTLOUT7.QUARTER_DELAY=0 +TTLOUT8.QUARTER_DELAY=0 +TTLOUT9.QUARTER_DELAY=0 +TTLOUT10.QUARTER_DELAY=0 +TTLOUT1.VAL=ZERO +TTLOUT2.VAL=ZERO +TTLOUT3.VAL=ZERO +TTLOUT4.VAL=ZERO +TTLOUT5.VAL=ZERO +TTLOUT6.VAL=ZERO +TTLOUT7.VAL=ZERO +TTLOUT8.VAL=ZERO +TTLOUT9.VAL=ZERO +TTLOUT10.VAL=ZERO +PCAP.GATE=TTLIN1.VAL +PCAP.ENABLE=ONE +PCAP.SHIFT_SUM=0 +PCAP.TRIG_EDGE=Falling +PCAP.TRIG=TTLIN1.VAL +DIV1.DIVISOR=0 +DIV2.DIVISOR=0 +DIV1.FIRST_PULSE=OutN +DIV2.FIRST_PULSE=OutN +DIV1.INP=ZERO +DIV2.INP=ZERO +DIV1.ENABLE=ZERO +DIV2.ENABLE=ZERO +INENC1.ENCODING=Unsigned Binary +INENC2.ENCODING=Unsigned Binary +INENC3.ENCODING=Unsigned Binary +INENC4.ENCODING=Unsigned Binary +INENC1.CLK=ZERO +INENC2.CLK=ZERO +INENC3.CLK=ZERO +INENC4.CLK=ZERO +INENC1.MSB_DISCARD=0 +INENC2.MSB_DISCARD=0 +INENC3.MSB_DISCARD=0 +INENC4.MSB_DISCARD=0 +INENC1.LSB_DISCARD=0 +INENC2.LSB_DISCARD=0 +INENC3.LSB_DISCARD=0 +INENC4.LSB_DISCARD=0 +INENC1.CLK_SRC=Internally Generated +INENC2.CLK_SRC=Internally Generated +INENC3.CLK_SRC=Internally Generated +INENC4.CLK_SRC=Internally Generated +INENC1.PROTOCOL=ZMI +INENC2.PROTOCOL=ZMI +INENC3.PROTOCOL=ZMI +INENC4.PROTOCOL=ZMI +INENC1.RST_ON_Z=0 +INENC2.RST_ON_Z=0 +INENC3.RST_ON_Z=0 +INENC4.RST_ON_Z=0 +INENC1.CLK_PERIOD=0.2 +INENC2.CLK_PERIOD=0 +INENC3.CLK_PERIOD=0 +INENC4.CLK_PERIOD=0 +INENC1.BITS=0 +INENC2.BITS=0 +INENC3.BITS=0 +INENC4.BITS=0 +INENC1.FRAME_PERIOD=0.2 +INENC2.FRAME_PERIOD=0 +INENC3.FRAME_PERIOD=0 +INENC4.FRAME_PERIOD=0 +BITS.A=0 +BITS.C=0 +BITS.D=0 +BITS.B=1 +SFP3_SYNC_OUT.POS4=ZERO +SFP3_SYNC_OUT.POS2=ZERO +SFP3_SYNC_OUT.POS3=ZERO +SFP3_SYNC_OUT.POS1=ZERO +SFP3_SYNC_OUT.BIT8=ZERO +SFP3_SYNC_OUT.BIT1=ZERO +SFP3_SYNC_OUT.BIT3=ZERO +SFP3_SYNC_OUT.BIT2=ZERO +SFP3_SYNC_OUT.BIT5=ZERO +SFP3_SYNC_OUT.BIT4=ZERO +SFP3_SYNC_OUT.BIT7=ZERO +SFP3_SYNC_OUT.BIT6=ZERO +SEQ1.ENABLE=ZERO +SEQ2.ENABLE=ZERO +SEQ1.PRESCALE=0 +SEQ2.PRESCALE=0 +SEQ1.BITA=ZERO +SEQ2.BITA=ZERO +SEQ1.BITC=ZERO +SEQ2.BITC=ZERO +SEQ1.BITB=ZERO +SEQ2.BITB=ZERO +SEQ1.REPEATS=0 +SEQ2.REPEATS=0 +SEQ1.POSB=ZERO +SEQ2.POSB=ZERO +SEQ1.POSC=ZERO +SEQ2.POSC=ZERO +SEQ1.POSA=ZERO +SEQ2.POSA=ZERO +SYSTEM.CLOCK_SOURCE=int clock +SYSTEM.TIMESTAMP_SOURCE=None +SRGATE1.WHEN_DISABLED=Set output low +SRGATE2.WHEN_DISABLED=Set output low +SRGATE3.WHEN_DISABLED=Set output low +SRGATE4.WHEN_DISABLED=Set output low +SRGATE1.RST_EDGE=Rising +SRGATE2.RST_EDGE=Rising +SRGATE3.RST_EDGE=Rising +SRGATE4.RST_EDGE=Rising +SRGATE1.SET=ZERO +SRGATE2.SET=ZERO +SRGATE3.SET=ZERO +SRGATE4.SET=ZERO +SRGATE1.SET_EDGE=Rising +SRGATE2.SET_EDGE=Rising +SRGATE3.SET_EDGE=Rising +SRGATE4.SET_EDGE=Rising +SRGATE1.ENABLE=ZERO +SRGATE2.ENABLE=ZERO +SRGATE3.ENABLE=ZERO +SRGATE4.ENABLE=ZERO +SRGATE1.RST=ZERO +SRGATE2.RST=ZERO +SRGATE3.RST=ZERO +SRGATE4.RST=ZERO +FMC_OUT.GAIN4=5V +FMC_OUT.GAIN1=5V +FMC_OUT.GAIN3=5V +FMC_OUT.GAIN2=5V +FMC_OUT.VAL1=ZERO +FMC_OUT.VAL3=ZERO +FMC_OUT.VAL2=ZERO +FMC_OUT.VAL4=ZERO +LUT1.INPB=ZERO +LUT2.INPB=ZERO +LUT3.INPB=ZERO +LUT4.INPB=ZERO +LUT5.INPB=ZERO +LUT6.INPB=ZERO +LUT7.INPB=ZERO +LUT8.INPB=ZERO +LUT1.INPC=ZERO +LUT2.INPC=ZERO +LUT3.INPC=ZERO +LUT4.INPC=ZERO +LUT5.INPC=ZERO +LUT6.INPC=ZERO +LUT7.INPC=ZERO +LUT8.INPC=ZERO +LUT1.INPA=ZERO +LUT2.INPA=ZERO +LUT3.INPA=ZERO +LUT4.INPA=ZERO +LUT5.INPA=ZERO +LUT6.INPA=ZERO +LUT7.INPA=ZERO +LUT8.INPA=ZERO +LUT1.INPD=ZERO +LUT2.INPD=ZERO +LUT3.INPD=ZERO +LUT4.INPD=ZERO +LUT5.INPD=ZERO +LUT6.INPD=ZERO +LUT7.INPD=ZERO +LUT8.INPD=ZERO +LUT1.INPE=ZERO +LUT2.INPE=ZERO +LUT3.INPE=ZERO +LUT4.INPE=ZERO +LUT5.INPE=ZERO +LUT6.INPE=ZERO +LUT7.INPE=ZERO +LUT8.INPE=ZERO +LUT1.FUNC=0x00000000 +LUT2.FUNC=0x00000000 +LUT3.FUNC=0x00000000 +LUT4.FUNC=0x00000000 +LUT5.FUNC=0x00000000 +LUT6.FUNC=0x00000000 +LUT7.FUNC=0x00000000 +LUT8.FUNC=0x00000000 +LUT1.TYPEA=Input-Level +LUT2.TYPEA=Input-Level +LUT3.TYPEA=Input-Level +LUT4.TYPEA=Input-Level +LUT5.TYPEA=Input-Level +LUT6.TYPEA=Input-Level +LUT7.TYPEA=Input-Level +LUT8.TYPEA=Input-Level +LUT1.TYPEC=Input-Level +LUT2.TYPEC=Input-Level +LUT3.TYPEC=Input-Level +LUT4.TYPEC=Input-Level +LUT5.TYPEC=Input-Level +LUT6.TYPEC=Input-Level +LUT7.TYPEC=Input-Level +LUT8.TYPEC=Input-Level +LUT1.TYPEB=Input-Level +LUT2.TYPEB=Input-Level +LUT3.TYPEB=Input-Level +LUT4.TYPEB=Input-Level +LUT5.TYPEB=Input-Level +LUT6.TYPEB=Input-Level +LUT7.TYPEB=Input-Level +LUT8.TYPEB=Input-Level +LUT1.TYPEE=Input-Level +LUT2.TYPEE=Input-Level +LUT3.TYPEE=Input-Level +LUT4.TYPEE=Input-Level +LUT5.TYPEE=Input-Level +LUT6.TYPEE=Input-Level +LUT7.TYPEE=Input-Level +LUT8.TYPEE=Input-Level +LUT1.TYPED=Input-Level +LUT2.TYPED=Input-Level +LUT3.TYPED=Input-Level +LUT4.TYPED=Input-Level +LUT5.TYPED=Input-Level +LUT6.TYPED=Input-Level +LUT7.TYPED=Input-Level +LUT8.TYPED=Input-Level +CALC1.INPC=ZERO +CALC2.INPC=ZERO +CALC1.INPA=ZERO +CALC2.INPA=ZERO +CALC1.SHIFT=0 +CALC2.SHIFT=0 +CALC1.INPB=ZERO +CALC2.INPB=ZERO +CALC1.INPD=ZERO +CALC2.INPD=ZERO +CALC1.TYPEA=Value +CALC2.TYPEA=Value +CALC1.TYPEC=Value +CALC2.TYPEC=Value +CALC1.TYPEB=Value +CALC2.TYPEB=Value +CALC1.TYPED=Value +CALC2.TYPED=Value +LVDSOUT1.FINE_DELAY=0 +LVDSOUT2.FINE_DELAY=0 +LVDSOUT1.QUARTER_DELAY=0 +LVDSOUT2.QUARTER_DELAY=0 +LVDSOUT1.VAL=ZERO +LVDSOUT2.VAL=ZERO +CLOCK1.ENABLE=ZERO +CLOCK2.ENABLE=ZERO +CLOCK1.PERIOD=1 +CLOCK2.PERIOD=0.2 +CLOCK1.WIDTH=3e-05 +CLOCK2.WIDTH=5 +COUNTER1.OUT_MODE=On-Change +COUNTER2.OUT_MODE=On-Change +COUNTER3.OUT_MODE=On-Change +COUNTER4.OUT_MODE=On-Change +COUNTER5.OUT_MODE=On-Change +COUNTER6.OUT_MODE=On-Change +COUNTER7.OUT_MODE=On-Change +COUNTER8.OUT_MODE=On-Change +COUNTER1.SET=0 +COUNTER2.SET=0 +COUNTER3.SET=0 +COUNTER4.SET=0 +COUNTER5.SET=0 +COUNTER6.SET=0 +COUNTER7.SET=0 +COUNTER8.SET=0 +COUNTER1.DIR=ZERO +COUNTER2.DIR=ZERO +COUNTER3.DIR=ZERO +COUNTER4.DIR=ZERO +COUNTER5.DIR=ZERO +COUNTER6.DIR=ZERO +COUNTER7.DIR=ZERO +COUNTER8.DIR=ZERO +COUNTER1.ENABLE=PCAP.ACTIVE +COUNTER2.ENABLE=ZERO +COUNTER3.ENABLE=ZERO +COUNTER4.ENABLE=ZERO +COUNTER5.ENABLE=ZERO +COUNTER6.ENABLE=ZERO +COUNTER7.ENABLE=ZERO +COUNTER8.ENABLE=ZERO +COUNTER1.TRIG=TTLIN1.VAL +COUNTER2.TRIG=ZERO +COUNTER3.TRIG=ZERO +COUNTER4.TRIG=ZERO +COUNTER5.TRIG=ZERO +COUNTER6.TRIG=ZERO +COUNTER7.TRIG=ZERO +COUNTER8.TRIG=ZERO +COUNTER1.TRIG_EDGE=Falling +COUNTER2.TRIG_EDGE=Rising +COUNTER3.TRIG_EDGE=Rising +COUNTER4.TRIG_EDGE=Rising +COUNTER5.TRIG_EDGE=Rising +COUNTER6.TRIG_EDGE=Rising +COUNTER7.TRIG_EDGE=Rising +COUNTER8.TRIG_EDGE=Rising +COUNTER1.MIN=0 +COUNTER2.MIN=0 +COUNTER3.MIN=0 +COUNTER4.MIN=0 +COUNTER5.MIN=0 +COUNTER6.MIN=0 +COUNTER7.MIN=0 +COUNTER8.MIN=0 +COUNTER1.MAX=0 +COUNTER2.MAX=0 +COUNTER3.MAX=0 +COUNTER4.MAX=0 +COUNTER5.MAX=0 +COUNTER6.MAX=0 +COUNTER7.MAX=0 +COUNTER8.MAX=0 +COUNTER1.START=0 +COUNTER2.START=0 +COUNTER3.START=0 +COUNTER4.START=0 +COUNTER5.START=0 +COUNTER6.START=0 +COUNTER7.START=0 +COUNTER8.START=0 +COUNTER1.STEP=1 +COUNTER2.STEP=1 +COUNTER3.STEP=0 +COUNTER4.STEP=0 +COUNTER5.STEP=0 +COUNTER6.STEP=0 +COUNTER7.STEP=0 +COUNTER8.STEP=0 +PULSE1.PULSES=0 +PULSE2.PULSES=0 +PULSE3.PULSES=0 +PULSE4.PULSES=0 +PULSE1.ENABLE=ONE +PULSE2.ENABLE=ZERO +PULSE3.ENABLE=ZERO +PULSE4.ENABLE=ZERO +PULSE1.DELAY=0 +PULSE2.DELAY=0.2 +PULSE3.DELAY=0.3 +PULSE4.DELAY=0.4 +PULSE1.TRIG=ZERO +PULSE2.TRIG=ZERO +PULSE3.TRIG=ZERO +PULSE4.TRIG=ZERO +PULSE1.TRIG_EDGE=Rising +PULSE2.TRIG_EDGE=Rising +PULSE3.TRIG_EDGE=Rising +PULSE4.TRIG_EDGE=Rising +PULSE1.WIDTH=0.001 +PULSE2.WIDTH=0.1 +PULSE3.WIDTH=0.1 +PULSE4.WIDTH=0.1 +PULSE1.STEP=0 +PULSE2.STEP=0 +PULSE3.STEP=0 +PULSE4.STEP=0 +FILTER1.INP=ZERO +FILTER2.INP=ZERO +FILTER1.MODE=average +FILTER2.MODE=difference +FILTER1.ENABLE=ZERO +FILTER2.ENABLE=ZERO +FILTER1.TRIG=ZERO +FILTER2.TRIG=ZERO +FMC_IN.GAIN5=10V +FMC_IN.GAIN4=10V +FMC_IN.GAIN7=10V +FMC_IN.GAIN6=10V +FMC_IN.GAIN1=10V +FMC_IN.GAIN3=10V +FMC_IN.GAIN2=10V +FMC_IN.GAIN8=10V +PGEN1.REPEATS=0 +PGEN2.REPEATS=0 +PGEN1.ENABLE=ZERO +PGEN2.ENABLE=ZERO +PGEN1.TRIG=ZERO +PGEN2.TRIG=ZERO +*METADATA.LABEL_CALC1= +*METADATA.LABEL_FILTER1= +*METADATA.LABEL_CALC2= +*METADATA.LABEL_SRGATE4= +*METADATA.LABEL_FMC_IN1= +*METADATA.LABEL_TTLIN6= +*METADATA.LABEL_SRGATE1= +*METADATA.LABEL_TTLIN4= +*METADATA.LABEL_TTLIN5= +*METADATA.LABEL_TTLIN2=TTL input 2 +*METADATA.LABEL_TTLIN3= +*METADATA.LABEL_CLOCK1= +*METADATA.LABEL_TTLIN1=TTL input 1 +*METADATA.LABEL_PCOMP1= +*METADATA.LABEL_LUT8= +*METADATA.LABEL_BITS1=Soft inputs and constant bits +*METADATA.LABEL_COUNTER8= +*METADATA.LABEL_COUNTER7= +*METADATA.LABEL_COUNTER6= +*METADATA.LABEL_COUNTER5= +*METADATA.LABEL_COUNTER4= +*METADATA.LABEL_COUNTER3= +*METADATA.LABEL_COUNTER2= +*METADATA.LABEL_COUNTER1= +*METADATA.LABEL_TTLOUT3=TTL output 3 +*METADATA.LABEL_OUTENC2= +*METADATA.LABEL_OUTENC3= +*METADATA.LABEL_SYSTEM1=System control FPGA +*METADATA.LABEL_OUTENC1= +*METADATA.LABEL_PGEN2= +*METADATA.LABEL_OUTENC4= +*METADATA.LABEL_PGEN1= +*METADATA.LABEL_LUT2= +*METADATA.LABEL_LUT3= +*METADATA.LABEL_LUT1=Lookup table 1 +*METADATA.LABEL_LUT6= +*METADATA.LABEL_LUT7= +*METADATA.LABEL_LUT4= +*METADATA.LABEL_LUT5= +*METADATA.LABEL_LVDSOUT2= +*METADATA.LABEL_LVDSOUT1= +*METADATA.LABEL_SFP3_SYNC_OUT1= +*METADATA.LABEL_PULSE3= +*METADATA.LABEL_TTLOUT7= +*METADATA.LABEL_SRGATE2= +*METADATA.LABEL_LVDSIN2= +*METADATA.LABEL_LVDSIN1= +*METADATA.LABEL_SEQ1= +*METADATA.LABEL_CLOCK2= +*METADATA.LABEL_PULSE4= +*METADATA.LABEL_INENC1= +*METADATA.LABEL_INENC2= +*METADATA.LABEL_INENC3= +*METADATA.LABEL_INENC4= +*METADATA.LABEL_PULSE1= +*METADATA.LABEL_PULSE2= +*METADATA.LABEL_FILTER2= +*METADATA.LABEL_TTLOUT10= +*METADATA.LABEL_SEQ2= +*METADATA.LABEL_PCOMP2= +*METADATA.LABEL_DIV1= +*METADATA.LABEL_SRGATE3= +*METADATA.LABEL_FMC_OUT1= +*METADATA.LABEL_DIV2= +*METADATA.LABEL_SFP3_SYNC_IN1= +*METADATA.LABEL_TTLOUT8= +*METADATA.LABEL_TTLOUT9= +*METADATA.LABEL_TTLOUT6= +*METADATA.LABEL_PCAP1=Position capture control +*METADATA.LABEL_TTLOUT4=TTL output 4 +*METADATA.LABEL_TTLOUT5= +*METADATA.LABEL_TTLOUT2=TTL output 2 +*METADATA.DESIGN= +*METADATA.LABEL_TTLOUT1=TTL output 1 +SEQ1.TABLE None: + # Free-running raw-stream configuration. Defaults preserve today's behavior: + # raw_stream_mode=False means _receive_frame_data/on_stage/on_complete are + # byte-for-byte identical to before this mode was added. + self.raw_stream_mode = raw_stream_mode + self.raw_stream_flush_interval = raw_stream_flush_interval + self.raw_stream_flush_row_count = raw_stream_flush_row_count + self._raw_stream_buffer: dict[str, dict[str, Any]] = {} + self._raw_stream_buffer_row_count = 0 + self._raw_stream_buffer_lock = threading.Lock() + self._raw_stream_last_flush = time.monotonic() + # Whether frame_counter == 0 has been observed yet for the current acquisition; see + # _FRAME_COUNTER_SIGNAL_NAME above. Reset for every fresh arm, not just once per stage, + # since on_pre_scan retries re-arm the box (and thus reset COUNTER1's counting) too. + self._raw_stream_seen_reset = False + + # Scan names (ScanServerScanInfo.scan_name, e.g. "flomni_fermat_scan") this device + # should actually be armed for. None (default) means "arm for every scan" -- today's + # behavior, fully backward compatible. Generic BEC staging (stage_all_devices/ + # pre_scan_all_devices in bec_server) arms every enabled device for every scan type, + # not just the ones that consume this device's data; this lets a beamline config opt + # this device out of Arm()/COUNTER1 cycling (and the associated stale-frame_counter + # exposure, see AI_docs/PANDA_POSITION_VALIDATION.md sec 7) for scans that don't need it. + self.arm_scan_allowlist = arm_scan_allowlist + + # Seconds to wait for the PandA READY event per arming attempt in on_pre_scan. + self.pre_scan_timeout = pre_scan_timeout + # Set while the data readout thread is parked (not inside _run_data_readout), so a + # retry in on_pre_scan can wait for the old readout loop's cleanup to be finished. + self._readout_idle = threading.Event() + self._readout_idle.set() + + super().__init__( + name=name, + host=host, + signal_alias=signal_alias, + scan_info=scan_info, + device_manager=device_manager, + **kwargs, + ) def on_init(self): super().on_init() self._acquisition_group = "burst" self._timeout_on_completed = 10 self.scan_parameters: ScanServerScanInfo | None = None + # Safe default (arm): matches arm_scan_allowlist=None semantics, and covers the case + # where on_pre_scan/on_complete are called before any on_stage (some tests do this). + self._should_arm_panda: bool = True + + def _compute_should_arm_panda(self) -> bool: + """Whether this scan should actually Arm() the PandA and wait on completion. See the + class-level note on arm_scan_allowlist. Fails open (arms) whenever the allowlist isn't + set or scan_parameters isn't available yet, preserving today's unconditional-arm + behavior in both cases.""" + if self.arm_scan_allowlist is None: + return True + if self.scan_parameters is None: + return True + return self.scan_parameters.scan_name in self.arm_scan_allowlist def on_stage(self): start_time = time.time() super().on_stage() self.scan_parameters = fetch_scan_info(self.scan_info) + # Recomputed fresh every stage cycle: the same device instance is staged for many + # different scan types across a session, not just the ones in arm_scan_allowlist. + self._should_arm_panda = self._compute_should_arm_panda() + + self._reset_raw_stream_state() + # TODO, adjust as seen fit. # Adjust the acquisition group based on scan parameters if needed - if self.scan_parameters.scan_type == "hardware_triggered": + if self.raw_stream_mode: + self._acquisition_group = "free_running" + elif self.scan_parameters.scan_type == "hardware_triggered": self._acquisition_group = "fly" elif self.scan_parameters.scan_type == "software_triggered": if self.scan_parameters.frames_per_trigger == 1: @@ -37,8 +169,126 @@ class PandaBoxOMNY(PandaBox): logger.info(f"PandaBox {self.name} on_stage completed in {time.time() - start_time:.3f}s.") + def _run_data_readout(self) -> None: + """Same as the base class, but tracks whether the readout loop is running. The base + loop ends with `_reset_panda()`, which clears `data_thread_run_event`; a retry in + `_arm_with_retry` must not set that event again before this cleanup has run.""" + self._readout_idle.clear() + try: + super()._run_data_readout() + finally: + self._readout_idle.set() + + def on_pre_scan(self): + """Arm the PCAP module. Unlike the base class this does not wait forever for the READY + event: if it does not arrive, the box is forcibly reset and arming is retried once, + and only if that fails as well the returned status fails, so the scan is aborted with + an error instead of hanging silently in pre_scan. + + If arm_scan_allowlist excludes the current scan (see _should_arm_panda/on_stage), PandA + is left untouched entirely -- no Arm() is sent -- and this resolves immediately. + """ + if not self._should_arm_panda: + status = StatusBase(obj=self) + status.set_finished() + return status + # Not registered with cancel_on_stop: the status of each single attempt in _try_arm is, + # so a stop makes the task raise DeviceStoppedError, which fails this status. Cancelling + # this one as well would make the task handler try to resolve it a second time. + return self.task_handler.submit_task(self._arm_with_retry, run=True) + + def _arm_with_retry(self) -> None: + """Task run by on_pre_scan: try to arm, force an abort/reset and try again if the + first attempt fails, raise if all attempts fail. Every retry is printed and logged.""" + attempts = self._PRE_SCAN_ARM_ATTEMPTS + for attempt in range(1, attempts + 1): + if self._try_arm(): + if attempt > 1: + logger.info( + f"PandaBox {self.name} armed successfully on attempt {attempt}/{attempts}." + ) + return + if attempt < attempts: + msg = ( + f"PandaBox {self.name} did not report READY within {self.pre_scan_timeout}s " + f"(arming attempt {attempt}/{attempts}). Forcing abort/reset and retrying." + ) + print(msg) + logger.warning(f"{msg} {self._arm_diagnostics()}") + self._reset_panda() + # Wait for the old readout loop to finish its own _reset_panda() cleanup, it + # would otherwise clear the run event we set next. + self._readout_idle.wait(timeout=2) + self.data_thread_run_event.set() + msg = ( + f"PandaBox {self.name} could not be armed after {attempts} attempts (no READY within " + f"{self.pre_scan_timeout}s each). The box was reset; check the connection and the " + "PandA state." + ) + logger.error(f"{msg} {self._arm_diagnostics()}") + self._reset_panda() + raise RuntimeError(msg) + + def _try_arm(self) -> bool: + """One arming attempt: wait for the READY event, then send Arm(). Returns False if + READY did not arrive in time or the box reported a conflicting state (FRAME/END, + i.e. an acquisition is still running). A stop/unstage is not retried.""" + if self.stopped: + raise DeviceStoppedError(f"Device {self.name} has been stopped") + status = StatusBase(obj=self) + cb_id = self.add_status_callback( + status=status, success=[PandaState.READY], failure=[PandaState.FRAME, PandaState.END] + ) + self.cancel_on_stop(status) + try: + status.wait(timeout=self.pre_scan_timeout) + except DeviceStoppedError: + self.remove_status_callback(cb_id) + raise + except Exception as exc: # pylint: disable=broad-except + # Timeout, or the box reported FRAME/END while we waited for READY + self.remove_status_callback(cb_id) + logger.warning(f"PandaBox {self.name} arming attempt failed: {exc!r}") + return False + # Reset raw-stream state right before arming, not just once in on_stage: a retry here + # re-arms the box (and thus resets COUNTER1's counting) too, so the stale-value window + # applies again on every attempt, not just the first. + self._reset_raw_stream_state() + # Sent from here rather than from a status callback (as the base class does), so the + # scan is only released after Arm() actually went out. + self._arm() + return True + + def _arm_diagnostics(self) -> str: + return ( + f"[host={self.host}, panda_state={self.panda_state}, " + f"data_thread_alive={self.data_thread.is_alive()}, " + f"data_thread_run_event={self.data_thread_run_event.is_set()}]" + ) + + def on_unstage(self): + """Make sure no buffered raw-stream data is left behind before resetting the device.""" + self._flush_raw_stream_buffer() + return super().on_unstage() + def on_complete(self): - """On complete is called after the scan is complete. We need to wait for the capture to complete before we can disarm the PandaBox.""" + """On complete is called after the scan is complete. We need to wait for the capture to + complete before we can disarm the PandaBox -- unless this device was never armed for + this scan (see _should_arm_panda/on_stage), in which case there is nothing to wait for + and no wait-loop is started. _disarm() is deliberately not called here in that case: + the box is already guaranteed disarmed (on_stage waits for DISARMED before staging), + nothing armed it since Arm() was skipped, and on_unstage()/stop() unconditionally + disarm anyway at the end of the lifecycle.""" + if not self._should_arm_panda: + status = StatusBase(obj=self) + status.set_finished() + return status + if self.raw_stream_mode: + return self._on_complete_free_running() + return self._on_complete_burst() + + def _on_complete_burst(self): + """Legacy completion check: wait for the exact expected number of gated captures.""" def _check_capture_complete(): captured = 0 @@ -66,6 +316,164 @@ class PandaBoxOMNY(PandaBox): self.cancel_on_stop(status_captured) return status_captured + def _on_complete_free_running(self): + """Completion check for continuous free-running capture: wait for the frame counter + (COUNTER1, aliased to frame_counter) to reach the exact expected number of frames. + + *PCAP.CAPTURED? can't be used here, unlike _on_complete_burst: on this layout PCAP's + gate/trig are driven continuously by an internal clock (CLOCK1), so the captured + count never naturally stops increasing while PCAP stays armed -- there's nothing to + settle. COUNTER1 instead increments on the detector-trigger's falling edge, i.e. once + per completed exposure, so it reaches num_points * frames_per_trigger -- the same + target _on_complete_burst waits for -- exactly when the last exposure finishes. See + docs/developer/panda_box_free_running_setup.md. + """ + + def _check_frame_count_complete(): + counted = 0 + start_time = time.monotonic() + try: + expected_frames = int( + self.scan_parameters.num_points * self.scan_parameters.frames_per_trigger + ) + while counted < expected_frames: + ret = self.send_raw("COUNTER1.OUT?") + counted = int(float(ret[0].split("=")[-1])) + time.sleep(0.01) + if (time.monotonic() - start_time) > self._timeout_on_completed / 2: + logger.info( + f"Waiting for frame_counter on device {self.name} to complete: " + f"counted {counted}/{expected_frames} frames." + ) + if (time.monotonic() - start_time) > self._timeout_on_completed: + raise TimeoutError( + f"Pandabox {self.name} did not complete after " + f"{self._timeout_on_completed}s with frames counted " + f"{counted}/{expected_frames}" + ) + finally: + self._flush_raw_stream_buffer() + self._disarm() + + status_captured = self.task_handler.submit_task(_check_frame_count_complete, run=True) + self.cancel_on_stop(status_captured) + return status_captured + + def convert_frame_data(self, frame_data: FrameData) -> dict[str, Any]: + """Same as the base class, but in raw_stream_mode additionally masks + ``gate_detector_active`` down to its own bit within the shared PCAP.BITS0 capture + word -- see the class-level note on ``_GATE_BIT_OFFSET`` for why the raw word can't + be trusted as a clean 0/1 on its own. + """ + out = super().convert_frame_data(frame_data) + if self.raw_stream_mode and self._GATE_SIGNAL_NAME in out: + entry = out[self._GATE_SIGNAL_NAME] + entry["value"] = [(int(v) >> self._GATE_BIT_OFFSET) & 1 for v in entry["value"]] + return out + + def _receive_frame_data(self, data: FrameData) -> None: + """Callback to receive frame data from the PandaBox. + + In legacy mode this is identical to the base class: publish immediately, one + FrameData in, one `.data.put()` out. In free-running mode, converted rows are + accumulated in a buffer and only published once a row-count or time threshold is + reached, to bound the message rate to the BEC message bus. + """ + if not self.raw_stream_mode: + super()._receive_frame_data(data) + return + + converted = self.convert_frame_data(frame_data=data) + converted = self._drop_stale_frame_counter_rows(converted) + if converted is None: + # Every row in this chunk still carried a stale frame_counter from the previous + # acquisition; nothing here belongs to the current one. + return + with self._raw_stream_buffer_lock: + self._merge_into_raw_stream_buffer(converted) + elapsed = time.monotonic() - self._raw_stream_last_flush + if ( + self._raw_stream_buffer_row_count >= self.raw_stream_flush_row_count + or elapsed >= self.raw_stream_flush_interval + ): + self._flush_raw_stream_buffer_locked() + + def _reset_raw_stream_state(self) -> None: + """Drop any leftover buffered rows and mark that frame_counter == 0 has not yet been + observed, for a fresh acquisition. Called from on_stage (once per scan) and from + _try_arm right before every Arm() (including on_pre_scan retries), since each Arm() + resets COUNTER1's counting and thus reopens the stale-value window described at + _FRAME_COUNTER_SIGNAL_NAME above. Also drops whatever was buffered from a previous, + now-abandoned attempt, since that data belongs to an acquisition that never completed. + """ + with self._raw_stream_buffer_lock: + self._raw_stream_buffer = {} + self._raw_stream_buffer_row_count = 0 + self._raw_stream_last_flush = time.monotonic() + self._raw_stream_seen_reset = False + + def _drop_stale_frame_counter_rows( + self, converted: dict[str, dict[str, Any]] + ) -> dict[str, dict[str, Any]] | None: + """Drop rows captured before frame_counter first reads 0 for this acquisition. + + See the class-level note on `_FRAME_COUNTER_SIGNAL_NAME`: PandA's COUNTER1.OUT is not + cleared by Arm(), so the first raw samples of a new acquisition can briefly carry a + stale value left over from the previous acquisition's tail. Truncating every signal's + row list at the same index keeps all channels aligned. + + Returns the (possibly truncated) dict, or None if every row in this chunk is still + stale and should be dropped entirely. + """ + if self._raw_stream_seen_reset: + return converted + counter_entry = converted.get(self._FRAME_COUNTER_SIGNAL_NAME) + if counter_entry is None: + # This layout/config doesn't capture frame_counter -- nothing to filter against. + self._raw_stream_seen_reset = True + return converted + reset_index = next( + (i for i, v in enumerate(counter_entry["value"]) if v == 0), None + ) + if reset_index is None: + return None + self._raw_stream_seen_reset = True + if reset_index == 0: + return converted + return { + key: {**entry, "value": entry["value"][reset_index:]} + for key, entry in converted.items() + } + + def _merge_into_raw_stream_buffer(self, converted: dict[str, dict[str, Any]]) -> None: + """Append newly converted rows into the pending raw-stream buffer. Must be called + while holding `_raw_stream_buffer_lock`.""" + row_count = 0 + for key, entry in converted.items(): + buffered = self._raw_stream_buffer.setdefault( + key, {"value": [], "timestamp": entry["timestamp"]} + ) + buffered["value"].extend(entry["value"]) + buffered["timestamp"] = entry["timestamp"] + row_count = max(row_count, len(entry["value"])) + self._raw_stream_buffer_row_count += row_count + + def _flush_raw_stream_buffer(self) -> None: + """Flush any pending buffered raw-stream data, taking the lock. Safe to call from + any thread, e.g. on_complete/on_unstage, even if nothing is buffered.""" + with self._raw_stream_buffer_lock: + self._flush_raw_stream_buffer_locked() + + def _flush_raw_stream_buffer_locked(self) -> None: + """Flush any pending buffered raw-stream data. Must be called while holding + `_raw_stream_buffer_lock`.""" + self._raw_stream_last_flush = time.monotonic() + if not self._raw_stream_buffer: + return + out, self._raw_stream_buffer = self._raw_stream_buffer, {} + self._raw_stream_buffer_row_count = 0 + self.data.put(out, acquisition_group=self._acquisition_group) + if __name__ == "__main__": import time diff --git a/csaxs_bec/devices/sim/sim_cameras.py b/csaxs_bec/devices/sim/sim_cameras.py index 98c40e18..eae4b735 100644 --- a/csaxs_bec/devices/sim/sim_cameras.py +++ b/csaxs_bec/devices/sim/sim_cameras.py @@ -238,6 +238,20 @@ class _SimIDSBackend: self._connected = False self._rgb = rgb self._noise_std = float(noise_std) + # Realistic default well under a 5 Hz (200 ms) live-mode frame period -- + # was 10000.0 (10 s), inconsistent with get_exposure_range()'s own + # microsecond-scaled formula below and with the real IDSCamera Cpt's + # documented units/placeholder (exposure_time is in ms, default max + # placeholder 1000.0 ms). A 10 s default meant every fresh sim connect + # seeded exposure_time.put(10000) -- ~50x a 5 Hz period. + self._exposure_time = 10.0 # ms + self._auto_exposure = True + self._auto_gain = True + self._pixel_clock = 20 # MHz + self._pixel_clock_range = (5, 40, 1) # (min, max, increment) MHz -- informational only + # Mimics real uEye hardware only accepting a short discrete list, not + # every value in the range above (see get_pixel_clock_list()). + self._pixel_clock_options = [5, 10, 20, 30, 40] self._width = width self._height = height self._rotation_coupling = rotation_coupling @@ -282,6 +296,43 @@ class _SimIDSBackend: def on_disconnect(self): self._connected = False + @property + def exposure_time(self) -> float: + return self._exposure_time + + @exposure_time.setter + def exposure_time(self, value: float): + self._exposure_time = value + + def set_auto_shutter(self, enable: bool): + self._auto_exposure = bool(enable) + + def set_auto_gain(self, enable: bool): + self._auto_gain = bool(enable) + + def get_exposure_range(self) -> tuple[float, float, float]: + # Loosely mimics the real driver's pixel-clock-dependent ceiling: a + # higher pixel clock -> shorter max frame time -> lower max exposure. + # Scaled in ms (the real IDSCamera's documented exposure_time unit): + # 20_000.0 / pixel_clock_MHz gives ~1000 ms at the default 20 MHz + # pixel clock, matching exposure_time_max's own real-device placeholder + # default. The previous 1_000_000.0 scale was microsecond-sized, + # producing a ~50 s ceiling here instead. + max_exposure = 20_000.0 / max(self._pixel_clock, 1) + return 0.1, max_exposure, 0.1 + + def get_pixel_clock(self) -> int: + return self._pixel_clock + + def get_pixel_clock_range(self) -> tuple[int, int, int]: + return self._pixel_clock_range + + def get_pixel_clock_list(self) -> list[int]: + return list(self._pixel_clock_options) + + def set_pixel_clock(self, value: int): + self._pixel_clock = int(value) + def _current_angle_deg(self) -> float: coupling = self._rotation_coupling galil = SimStateRegistry.get( diff --git a/csaxs_bec/devices/sim/sim_lamni.py b/csaxs_bec/devices/sim/sim_lamni.py index 406c237c..abfa4ac6 100644 --- a/csaxs_bec/devices/sim/sim_lamni.py +++ b/csaxs_bec/devices/sim/sim_lamni.py @@ -12,7 +12,8 @@ RT (LAMNI_server.cpp, port 3333): its own protocol dialect — As (start cap sampling, no reply) / Ar -> 8-field cap sensor data, Ss (start rt sampler, no reply) / Sr -> 7-field sampler sums, V velocity limit (no reply), a (no reply) / ar -> angle, - pa/pr, scan s/sc/sd/sr/sh, r -> 21-field sample rows + pa/pr, scan s/sc/sd/sr/sh (sd only starts, and the scan only continues, while the + feedback loop is running), r -> 21-field sample rows (x/y target-avg-stdev, five cap-sensor avg/stdev pairs, angle avg/stdev), o/t. """ @@ -183,6 +184,9 @@ class SimRtLamniState: num_pos = len(self.scan_positions) if num_pos == 0 or self.scan_mode > 0: return num_pos + if not self.feedback_running: + logger.warning("[sim rt lamni] scan not started: feedback loop is not running") + return num_pos self.scan_mode = 3 self.scan_current = 0 self._scan_abort.clear() @@ -198,6 +202,13 @@ class SimRtLamniState: if self._scan_abort.wait(self.point_dwell_s): return with self.lock: + if not self.feedback_running: + logger.warning( + "[sim rt lamni] scan aborted at point" + f" {self.scan_current}: feedback loop stopped" + ) + self.scan_mode = 0 + return self.scan_current = index + 1 self.targets[: len(self.scan_positions[index])] = self.scan_positions[index] with self.lock: @@ -305,6 +316,8 @@ class SimRtLamniSocket(SimSocketBase): return f"{mode:.0f},{num_pos:.0f},{current:.0f}\n" if args.startswith("d"): num_pos = state.start_scan() + if num_pos > 0 and not state.feedback_running: + return "Scan not started: feedback loop not running.\n" return f"Scan mode started with #positions in scan {num_pos:.0f}. Timing: Detector Trigger.\n" if args.startswith("h"): state.clear_scan() diff --git a/csaxs_bec/devices/sim/sim_sample_storage.py b/csaxs_bec/devices/sim/sim_sample_storage.py index 602740e7..f70c11d9 100644 --- a/csaxs_bec/devices/sim/sim_sample_storage.py +++ b/csaxs_bec/devices/sim/sim_sample_storage.py @@ -1,7 +1,7 @@ """ Simulated flOMNI sample storage. -`FlomniSampleStorage` is purely EPICS-backed (XOMNY-SAMPLE_DB_flomni* PVs). The simulated +`FlomniSampleStorage` is purely EPICS-backed (FLOMNI-SAMPLE:: PVs). The simulated version follows the patched-device pattern of `simulated_beamline_devices`: the real class is instantiated with all PVs replaced by in-memory mock PVs, so `set_sample_slot`, `show_all`, the gripper bookkeeping and the sample-transfer scripts run unchanged; the diff --git a/csaxs_bec/devices/smargon/README.md b/csaxs_bec/devices/smargon/README.md new file mode 100644 index 00000000..53a74a2b --- /dev/null +++ b/csaxs_bec/devices/smargon/README.md @@ -0,0 +1,113 @@ +# SmarGon goniometer (BEC integration via smargopolo) + +BEC integration for the [SmarGon](https://www.smaract.com/en/smargon) goniometer. +It controls the **virtual SmarGon Coordinate System (SCS) axes** through the +[smargopolo](http://smargopolo:3000) RESTful API. smargopolo runs the kinematics and +drives the underlying `q1..q6` SmarAct MCS2 stages; this device never commands those +directly (that is the separate low-level `csaxs_bec.devices.smaract` device). + +## Architecture (per-axis, like the `smaract` device) + +Each SCS axis is its **own top-level BEC motor** sharing a **singleton +`SmargopoloController`** (one per `host:port`), plus a thin `SmarGon` **coordinator** +for whole-goniometer operations. This makes `dev.wm`, `umv`, limits and the GUI motor +widgets work natively per axis. + +``` +smargon/ + transport/ # ophyd-free control plane (unit-testable, no BEC needed) + base.py # SmargopoloTransport ABC + Axis/BeamlineAxis/Mode + AXIS_INFO + rest.py # RestTransport -- real urllib client against :3000 + fake.py # FakeTransport -- in-memory sim (offline mode + tests) + errors.py # typed exceptions + controller.py # SmargopoloController(OphydObject) -- singleton per host:port; + # owns transport + lock + mode; referencing / move_scs / nudge / q1..q6 + positioner.py # SmargopoloMotor(Device, PositionerBase) -- one SCS axis, top-level motor + smargon.py # SmarGon(PSIDeviceBase) -- thin coordinator (dev.smargon), delegates +``` + +The `transport/` layer is shared with (and unchanged from) the original design and is +structured like the Canon CR-N300 device (HTTP transport + in-memory fake + threaded +poll-to-tolerance moves), since both wrap a non-EPICS, poll-to-confirm HTTP backend. + +## Devices & naming + +- **One motor per axis**, top-level: the YAML key is the BEC name (**you choose it**: + `sgx`, `sgchi`, …); a `scs_axis:` field binds it to the fixed smargopolo axis name + (`SHX/SHY/SHZ/CHI/PHI/OMEGA`). OMEGA is optional hardware — add a motor entry only on + builds that have it. +- **`dev.smargon`** — the coordinator: `reference()`, `get_mode()`, `is_ready()`, + `wait_until_ready()`, `move_scs(...)`, `nudge_beamline(...)`, `motor_positions()`, + `stop_all()`, `transport_info()`, plus read-only `mode`/`rosout` signals. +- The same whole-goniometer ops are reachable from any axis as + `dev..controller.()` (the motors expose `USER_ACCESS = ["controller"]`). + +Because the motors expose `low_limit_travel`/`high_limit_travel` signals and alias +their readback to the device name, `dev.wm(dev.sgchi)` shows real readback/limits +(`low == high` means unbounded, EpicsMotor convention). + +## Referencing (read this before moving) + +smargopolo only follows targets in `Mode.READY`. **Referencing physically moves the +goniometer**, so it is a deliberate operator action — never automatic: + +```python +dev.smargon.get_mode() # 'UNINITIALIZED' on a fresh server +dev.smargon.reference() # PUT /mode?mode=1, waits until READY (moves hardware!) +umv(dev.sgx, 0.5) # now honoured +``` + +Any axis move while not READY raises `SmargopoloModeError` with a "reference first" +message. `dev.smargon.mode` / `.rosout` surface the state machine and last ROS message. + +## Usage + +```python +umv(dev.sgx, 0.5) # single-axis move (mm) +dev.wm('sg*') # readback/setpoint/limits table +dev.smargon.move_scs(SHX=0.1, SHZ=18) # coordinated move in one /targetSCS call +dev.smargon.nudge_beamline(BX=0.01) # beamline-frame nudge (/nudgeBCS) +scans.grid_scan(dev.sgx, -1, 1, 11, dev.sgchi, 0, 60, 7, exp_time=0.1, relative=False) +``` + +## Configuration + +See `csaxs_bec/device_configs/smargon.yaml` (real) and `smargon_sim_session.yaml` +(laptop simulation). All entries for one goniometer must share `host`/`port` so they +share the controller. Motor `deviceConfig` keys: `scs_axis`, `host`, `port`, `limits` +`[low, high]` (**verify per build**), `tolerance`, `poll_interval`, `move_timeout`. +Simulation: `simulation: true` (+ optional `gradual_motion: true`) uses `FakeTransport`, +no server required. + +## Limits & coupled-axis faults + +SCS soft limits are **user-set** (per experiment), not hardware ranges: a SmarGon's +true per-axis travel is coupled across all axes (like a hexapod) and can't be reliably +declared in advance. smargopolo owns the real limits. Both failure modes are handled: + +- **Rejected up front** — the PUT returns an error and the move status fails immediately. +- **Faulted mid-move** — if a target is accepted (`202`) but the controller goes to + `Mode.ERROR` after partial motion, the move waiters detect it on the next poll and + fail the status with the `rosout` detail (see `SmargopoloController.raise_if_error`). + +Move completion is by **readback tolerance** (user-set; done on the first in-tolerance +sample, so active position-hold dithering doesn't block completion). + +## Tests + +```sh +pytest tests/tests_devices/test_smargon_transport.py tests/tests_devices/test_smargon_device.py +``` + +All run against `FakeTransport` — no hardware or server needed. + +## Open items / to confirm with the smargopolo maintainer (Wayne Glettig) + +- **Stop semantics**: no explicit stop endpoint; we retarget axes to their readback + ("Follow Target" halt). Confirm this is the intended way to abort a move. +- **Mid-move fault signal**: we infer faults from `Mode.ERROR`. Confirm an + out-of-range / coupled-limit violation reliably drives `/mode` to 99 (and that + `rosout` carries the reason), so the abort path is dependable. +- **PHI wrap**: the MX-lab reference client wraps PHI to `[-180,180)` before sending. + Confirm whether smargopolo wraps server-side, or whether the motor should. +- *(resolved)* OMEGA is drivable via `/targetSCS` wherever it physically exists. diff --git a/csaxs_bec/devices/smargon/__init__.py b/csaxs_bec/devices/smargon/__init__.py new file mode 100644 index 00000000..da631e22 --- /dev/null +++ b/csaxs_bec/devices/smargon/__init__.py @@ -0,0 +1,37 @@ +"""SmarGon goniometer integration for BEC (via the smargopolo REST API). + +Per-axis architecture (mirrors the ``smaract`` device): each SCS axis is a top-level +:class:`SmargopoloMotor` sharing a singleton :class:`SmargopoloController` per +smargopolo server; :class:`SmarGon` is a thin coordinator (``dev.smargon``) for +whole-goniometer operations (referencing, mode, coordinated moves, diagnostics). + +The control transport (:mod:`.transport`) is ophyd-free and independently importable. +""" + +from __future__ import annotations + +from .controller import SmargopoloController +from .positioner import SmargopoloMotor +from .smargon import SmarGon +from .transport import ( + Axis, + BeamlineAxis, + FakeTransport, + Mode, + RestTransport, + SmargopoloInfo, + SmargopoloTransport, +) + +__all__ = [ + "SmarGon", + "SmargopoloController", + "SmargopoloMotor", + "Axis", + "BeamlineAxis", + "Mode", + "SmargopoloTransport", + "SmargopoloInfo", + "FakeTransport", + "RestTransport", +] diff --git a/csaxs_bec/devices/smargon/controller.py b/csaxs_bec/devices/smargon/controller.py new file mode 100644 index 00000000..d5a7ca88 --- /dev/null +++ b/csaxs_bec/devices/smargon/controller.py @@ -0,0 +1,284 @@ +"""Shared controller for the SmarGon goniometer (smargopolo backend). + +``SmargopoloController`` is a **singleton per ``(host, port, simulation)``** (the same +pattern as :class:`ophyd_devices.utils.controller.Controller`): every +:class:`~csaxs_bec.devices.smargon.positioner.SmargopoloMotor` and the thin +:class:`~csaxs_bec.devices.smargon.smargon.SmarGon` coordinator that point at the same +smargopolo server share **one** controller instance -- one transport, one lock, one +``/mode`` state machine. + +It owns the whole-goniometer operations that don't belong to a single axis: +referencing / the mode state machine, the coordinated multi-axis move +(``/targetSCS`` in one request), beamline nudges, and the raw ``q1..q6`` diagnostics. +All hardware access goes through the injected +:class:`~csaxs_bec.devices.smargon.transport.base.SmargopoloTransport`, serialised +behind ``self._lock``. +""" + +from __future__ import annotations + +import threading +import time + +from ophyd import OphydObject + +from .transport.base import Axis, BeamlineAxis, Mode, SmargopoloTransport +from .transport.errors import SmargopoloModeError +from .transport.fake import FakeTransport +from .transport.rest import RestTransport + +try: # pragma: no cover + from bec_lib.logger import bec_logger + + logger = bec_logger.logger +except Exception: # pragma: no cover + import logging + + logger = logging.getLogger(__name__) + + +class SmargopoloController(OphydObject): + """One shared control plane for a smargopolo server (singleton per host/port). + + Reachable from any axis as ``dev..controller`` (the motors set + ``USER_ACCESS = ["controller"]``) and mirrored by the ``dev.smargon`` + coordinator. + """ + + _controller_instances: dict[str, "SmargopoloController"] = {} + _initialized = False + + SUB_CONNECTION_CHANGE = "connection_change" + + USER_ACCESS = [ + "reference", + "is_ready", + "wait_until_ready", + "get_mode", + "move_scs", + "nudge_beamline", + "motor_positions", + "stop_all", + "transport_info", + ] + + def __new__(cls, *, host: str = "smargopolo", port: int = 3000, simulation: bool = False, **_): + key = f"{host}:{port}:{'sim' if simulation else 'real'}" + if key not in cls._controller_instances: + cls._controller_instances[key] = object.__new__(cls) + return cls._controller_instances[key] + + def __init__( + self, + *, + host: str = "smargopolo", + port: int = 3000, + simulation: bool = False, + timeout: float = 5.0, + gradual_motion: bool = False, + device_manager=None, + name: str = "smargopolo_controller", + parent=None, + **kwargs, + ) -> None: + if self._initialized: + # Singleton: a later constructor may pass a device_manager the first + # one lacked -- adopt it, but do not rebuild the transport. + if device_manager is not None and getattr(self, "device_manager", None) is None: + self.device_manager = device_manager + return + super().__init__(name=name, parent=parent, **kwargs) + self._lock = threading.RLock() + self._host = host + self._port = port + self._simulation = simulation + self.device_manager = device_manager + if simulation: + self.transport: SmargopoloTransport = FakeTransport(gradual_motion=gradual_motion) + else: + self.transport = RestTransport(host=host, port=port, timeout=timeout) + self._axes: dict[Axis, object] = {} # scs_axis -> SmargopoloMotor (registered by motors) + self._connected = False + self._initialized = True + + # -- axis registry ----------------------------------------------------- + + def register_axis(self, motor) -> None: + """Called by each :class:`SmargopoloMotor` so the controller can find it.""" + self._axes[Axis(motor.scs_axis)] = motor + + # -- connection lifecycle ---------------------------------------------- + + def connect(self, timeout: float = 10.0) -> None: + with self._lock: + if self._connected: + return + self.transport.connect(timeout=timeout) + self._connected = True + + def close(self) -> None: + with self._lock: + try: + self.transport.close() + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"{self.name}: transport close failed: {exc}") + finally: + self._connected = False + + @property + def connected(self) -> bool: + return self._connected + + # -- locked transport passthroughs ------------------------------------- + + def get_position(self, axis: Axis) -> float: + with self._lock: + return self.transport.get_position(Axis(axis)) + + def get_positions(self) -> dict: + with self._lock: + return self.transport.get_positions() + + def move_absolute(self, targets: dict) -> None: + with self._lock: + self.transport.move_absolute({Axis(a): float(v) for a, v in targets.items()}) + + def move_relative(self, deltas: dict) -> None: + with self._lock: + self.transport.move_relative({Axis(a): float(v) for a, v in deltas.items()}) + + def stop(self, axes: list | None = None) -> None: + with self._lock: + self.transport.stop([Axis(a) for a in axes] if axes is not None else None) + + def get_info(self): + with self._lock: + return self.transport.get_info() + + def mode(self) -> Mode: + """Current state-machine mode (enum) -- used internally by axes/coordinator.""" + with self._lock: + return self.transport.get_mode() + + # -- state machine / referencing (whole-goniometer ops) ---------------- + + def require_ready(self) -> None: + """Raise :class:`SmargopoloModeError` unless the goniometer is READY.""" + m = self.mode() + if m is not Mode.READY: + raise SmargopoloModeError( + f"{self.name} is in {m.name} (mode {int(m)}); call reference() and wait for " + "READY before moving" + ) + + def raise_if_error(self) -> None: + """Raise if smargopolo went to ERROR (used to abort in-flight moves). + + A target accepted with ``202`` can still fault after partial motion when a + coupled hardware limit is hit; move waiters poll this so the move fails with + the controller's ``rosout`` detail instead of running to the move timeout. + """ + info = self.get_info() + if info.mode is Mode.ERROR: + raise SmargopoloModeError( + f"{self.name}: smargopolo entered ERROR during motion " + f"(likely a hardware/coupled-axis limit): {info.rosout or 'no detail'}" + ) + + def reference(self, wait: bool = True, timeout: float = 120.0) -> str: + """Reference the goniometer (``REFERENCING`` -> ``READY``). + + WARNING: this physically moves the goniometer to find its reference marks. + Deliberate operator action. Returns the resulting mode name. + """ + with self._lock: + self.transport.set_mode(Mode.REFERENCING) + logger.info(f"{self.name}: referencing started") + if wait: + m = self.wait_until_ready(timeout=timeout) + logger.info(f"{self.name}: referencing finished -> {m.name}") + return m.name + return self.mode().name + + def is_ready(self) -> bool: + return self.mode() is Mode.READY + + def wait_until_ready(self, timeout: float = 120.0, poll: float = 0.5) -> Mode: + deadline = time.monotonic() + timeout + while True: + m = self.mode() + if m is Mode.READY: + return m + if m is Mode.ERROR: + info = self.get_info() + raise SmargopoloModeError(f"{self.name}: smargopolo in ERROR: {info.rosout}") + if time.monotonic() > deadline: + raise TimeoutError(f"{self.name}: not READY after {timeout}s (mode {m.name})") + time.sleep(poll) + + def get_mode(self) -> str: + """State-machine mode name, for the user console.""" + return self.mode().name + + # -- coordinated / diagnostic ops -------------------------------------- + + def move_scs(self, wait: bool = True, **targets: float) -> None: + """Move several SCS axes at once in a single ``/targetSCS`` request. + + Keys are SCS axis names, e.g. ``move_scs(SHX=0.1, SHZ=18)``. Each targeted + axis is limit-checked against its registered motor (if any) before sending. + """ + self.require_ready() + parsed = {Axis(k): float(v) for k, v in targets.items()} + for ax, val in parsed.items(): + motor = self._axes.get(ax) + if motor is not None: + motor.check_value(val) + self.move_absolute(parsed) + if wait: + for ax, val in parsed.items(): + self._wait_axis(ax, val) + + def _wait_axis(self, axis: Axis, target: float, timeout: float | None = None) -> None: + motor = self._axes.get(axis) + tol = getattr(motor, "_tolerance", 1e-3) + poll = getattr(motor, "_poll_interval", 0.1) + deadline = time.monotonic() + (timeout or getattr(motor, "_move_timeout", 60.0)) + while True: + pos = self.get_position(axis) + if motor is not None: + motor._update_readback(pos) # noqa: SLF001 + if abs(pos - target) <= tol: + return + self.raise_if_error() + if time.monotonic() > deadline: + raise TimeoutError(f"{axis.value}: move to {target} timed out") + time.sleep(poll) + + def nudge_beamline(self, modify_offset: bool = False, **deltas: float) -> None: + """Nudge the sample along beamline axes (``BX``/``BY``/``BZ``).""" + self.require_ready() + parsed = {BeamlineAxis(k): float(v) for k, v in deltas.items()} + with self._lock: + self.transport.nudge_beamline(parsed, modify_offset=modify_offset) + + def stop_all(self) -> None: + """Halt all SCS axes.""" + self.stop(None) + + def motor_positions(self) -> dict: + """Raw ``q1..q6`` motor positions (read-only diagnostics).""" + return self.get_info().motor_positions + + def transport_info(self) -> dict: + info = self.get_info() if self.connected else None + return { + "connected": self.connected, + "simulation": self._simulation, + "host": self._host, + "port": self._port, + "mode": info.mode.name if info else None, + "serial_number": getattr(info, "serial_number", None), + "seq": getattr(info, "seq", None), + "axes": sorted(a.value for a in self._axes), + } diff --git a/csaxs_bec/devices/smargon/positioner.py b/csaxs_bec/devices/smargon/positioner.py new file mode 100644 index 00000000..032e1079 --- /dev/null +++ b/csaxs_bec/devices/smargon/positioner.py @@ -0,0 +1,238 @@ +"""Per-axis SmarGon motor -- a top-level BEC device for one SCS axis. + +``SmargopoloMotor`` is an independent ophyd positioner (``Device`` + +``PositionerBase``): each SCS axis (SHX/SHY/SHZ/CHI/PHI/OMEGA) is its own top-level +BEC motor, exactly like the ``smaract`` motors. It talks to hardware only through a +shared :class:`~csaxs_bec.devices.smargon.controller.SmargopoloController` (singleton +per smargopolo server), so ``dev.sgx`` and ``dev.sgchi`` pointing at the same host +share one transport, one lock and one mode state machine. + +Two conventions matter for BEC's motor UX to work natively (``dev.wm``, ``umv``, the +GUI motor widgets): + +* the readback signal is aliased to the device name, so ``read()`` yields a key equal + to the device name (what ``dev.wm`` looks up), and +* soft limits are published via ``low_limit_travel`` / ``high_limit_travel`` signals, + which the device server broadcasts as the device's limits (``low == high`` means + unbounded, following the EpicsMotor convention). +""" + +from __future__ import annotations + +import threading +import time +from typing import Callable + +from ophyd import Component as Cpt +from ophyd import Device, DeviceStatus, Kind, PositionerBase, Signal +from ophyd.utils import LimitError + +from .controller import SmargopoloController +from .transport.base import AXIS_INFO, Axis + + +class SmargopoloMotor(Device, PositionerBase): + """One SCS axis of a SmarGon goniometer, as a standalone BEC motor. + + Parameters + ---------- + scs_axis: + Which smargopolo SCS axis this motor drives (``"SHX"``, ``"CHI"``, ...). + host / port / simulation / timeout / gradual_motion: + Select/parameterise the shared :class:`SmargopoloController`. + limits: + Optional ``(low, high)`` soft limits in SCS units. ``low == high`` (the + default ``(0, 0)``) means unbounded -- the controller still enforces the + real coupled hardware limits. + tolerance / poll_interval / move_timeout: + Move-completion tolerance and polling cadence / timeout. + egu: + Engineering unit; defaults to the axis' natural unit (mm or deg). + """ + + readback = Cpt(Signal, value=0.0, kind=Kind.hinted) + setpoint = Cpt(Signal, value=0.0, kind=Kind.normal) + motor_is_moving = Cpt(Signal, value=0, kind=Kind.normal) + low_limit_travel = Cpt(Signal, value=0.0, kind=Kind.config) + high_limit_travel = Cpt(Signal, value=0.0, kind=Kind.config) + + SUB_READBACK = "readback" + _default_sub = SUB_READBACK + + USER_ACCESS = ["controller"] + + def __init__( + self, + prefix: str = "", + *, + name: str, + scs_axis: str, + host: str = "smargopolo", + port: int = 3000, + simulation: bool = False, + timeout: float = 5.0, + gradual_motion: bool = False, + limits: tuple[float, float] | list | None = None, + tolerance: float = 0.001, + poll_interval: float = 0.1, + move_timeout: float = 60.0, + egu: str | None = None, + device_manager=None, + kind=None, + parent=None, + **kwargs, + ) -> None: + self.scs_axis = Axis(scs_axis) + self._tolerance = tolerance + self._poll_interval = poll_interval + self._move_timeout = move_timeout + self._egu = egu if egu is not None else AXIS_INFO[self.scs_axis].egu + self._stopped = False + self._move_thread: threading.Thread | None = None + # Optional extra gate (parent/tests). The controller READY-gate is always applied. + self.pre_move_hook: Callable[[float], None] | None = None + + # Shared, singleton controller for this smargopolo server. + self.controller = SmargopoloController( + host=host, + port=port, + simulation=simulation, + timeout=timeout, + gradual_motion=gradual_motion, + device_manager=device_manager, + ) + + super().__init__(prefix, name=name, parent=parent, kind=kind, **kwargs) + + # Alias readback to the device name so dev.wm() / BEC find it under `name`. + self.readback.name = self.name + + if limits is not None: + self.low_limit_travel.put(float(limits[0])) + self.high_limit_travel.put(float(limits[1])) + + self.controller.register_axis(self) + + # -- positioner interface ---------------------------------------------- + + @property + def limits(self) -> tuple[float, float]: + return (self.low_limit_travel.get(), self.high_limit_travel.get()) + + @property + def low_limit(self) -> float: + return self.limits[0] + + @property + def high_limit(self) -> float: + return self.limits[1] + + @property + def egu(self) -> str: + return self._egu + + @property + def position(self) -> float: + return self.readback.get() + + def check_value(self, value: float) -> None: + low, high = self.limits + if low != high and not low <= value <= high: + raise LimitError(f"{self.name}: position {value} outside limits {(low, high)}") + + # -- connection lifecycle ---------------------------------------------- + + # pylint: disable=arguments-differ + def wait_for_connection(self, all_signals: bool = False, timeout: float = 10.0) -> None: + self.controller.connect(timeout=timeout) + try: + self._update_readback(self.controller.get_position(self.scs_axis)) + except Exception as exc: # pylint: disable=broad-except + self.log.warning("%s: initial readback failed: %s", self.name, exc) + super().wait_for_connection(all_signals=all_signals, timeout=timeout) + + # -- motion ------------------------------------------------------------ + + def _update_readback(self, value: float) -> None: + old = self.readback.get() + self.readback.put(value) + self._set_position(value) + self._run_subs( + sub_type=self.SUB_READBACK, old_value=old, value=value, timestamp=time.time() + ) + + def _poll_until_target(self, target: float, status: DeviceStatus) -> None: + deadline = time.monotonic() + self._move_timeout + try: + while not self._stopped: + pos = self.controller.get_position(self.scs_axis) + self._update_readback(pos) + if abs(pos - target) <= self._tolerance: + break + self.controller.raise_if_error() + if time.monotonic() > deadline: + raise TimeoutError( + f"{self.name}: move to {target} timed out after {self._move_timeout}s" + ) + time.sleep(self._poll_interval) + self._update_readback(self.controller.get_position(self.scs_axis)) + except Exception as exc: # pylint: disable=broad-except + self.motor_is_moving.put(0) + if not status.done: + status.set_exception(exc) + return + self.motor_is_moving.put(0) + if self._stopped: + if not status.done: + status.set_exception(RuntimeError(f"{self.name}: move interrupted by stop()")) + return + if not status.done: + status.set_finished() + + def move(self, position: float, wait: bool = True, timeout=None, moved_cb=None) -> DeviceStatus: + """Move to *position*; return a status that completes at target.""" + self.check_value(position) + self.controller.require_ready() + if self.pre_move_hook is not None: + self.pre_move_hook(position) + self._stopped = False + self.setpoint.put(position) + + status = DeviceStatus(self, timeout=timeout or self._move_timeout) + if moved_cb is not None: + status.add_callback(moved_cb) + self.motor_is_moving.put(1) + + try: + self.controller.move_absolute({self.scs_axis: position}) + except Exception as exc: # pylint: disable=broad-except + self.motor_is_moving.put(0) + status.set_exception(exc) + return status + + self._move_thread = threading.Thread( + target=self._poll_until_target, args=(position, status), daemon=True + ) + self._move_thread.start() + + if wait: + status.wait() + return status + + def set(self, position: float, **kwargs) -> DeviceStatus: + """Alias for :meth:`move` with ``wait=False`` (BEC positioner interface).""" + return self.move(position, wait=False, **kwargs) + + def stop(self, *, success: bool = False) -> None: + """Halt this axis immediately (best-effort: never raises during teardown).""" + self._stopped = True + try: + self.controller.stop([self.scs_axis]) + except Exception as exc: # pylint: disable=broad-except + self.log.warning("%s: transport stop failed: %s", self.name, exc) + finally: + if self._move_thread is not None: + self._move_thread.join(timeout=2.0) + self._move_thread = None + self.motor_is_moving.put(0) + super().stop(success=success) diff --git a/csaxs_bec/devices/smargon/smargon.py b/csaxs_bec/devices/smargon/smargon.py new file mode 100644 index 00000000..528a26b3 --- /dev/null +++ b/csaxs_bec/devices/smargon/smargon.py @@ -0,0 +1,182 @@ +"""SmarGon coordinator -- a thin top-level device for whole-goniometer operations. + +In the per-axis architecture the SCS axes are independent top-level +:class:`~csaxs_bec.devices.smargon.positioner.SmargopoloMotor` devices +(``dev.sgx``, ``dev.sgchi``, ...). ``SmarGon`` is a small companion device +(``dev.smargon``) that exposes the operations that belong to the *whole* goniometer +rather than one axis -- referencing / the mode state machine, the coordinated +multi-axis move, beamline nudges and the raw ``q1..q6`` diagnostics. + +It owns no axes and no motion logic of its own: it holds a reference to the same +shared :class:`~csaxs_bec.devices.smargon.controller.SmargopoloController` the motors +use (singleton per smargopolo server) and delegates to it. The same operations are +also reachable from any axis as ``dev..controller.()``. +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +from ophyd import Component as Cpt +from ophyd import Kind, Signal +from ophyd.utils import ReadOnlyError +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase + +from .controller import SmargopoloController +from .transport.base import Mode + +if TYPE_CHECKING: # pragma: no cover + from bec_lib.devicemanager import ScanInfo + +try: # pragma: no cover + from bec_lib.logger import bec_logger + + logger = bec_logger.logger +except Exception: # pragma: no cover + import logging + + logger = logging.getLogger(__name__) + + +class SmargopoloModeSignal(Signal): + """Read-only signal exposing the smargopolo state-machine mode (as an int).""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._metadata.update(connected=True, write_access=False) + + # pylint: disable=arguments-differ + def get(self, **kwargs): + value = int(self.parent.controller.mode()) + self._readback = value + return value + + def put(self, value, **kwargs): + raise ReadOnlyError(f"{self.name} is read-only; use reference() to change mode") + + @property + def timestamp(self): + return time.time() + + +class SmargopoloRosoutSignal(Signal): + """Read-only signal exposing the last ROS message (carries error detail).""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._metadata.update(connected=True, write_access=False) + + # pylint: disable=arguments-differ + def get(self, **kwargs): + value = self.parent.controller.get_info().rosout + self._readback = value + return value + + def put(self, value, **kwargs): + raise ReadOnlyError(f"{self.name} is read-only") + + @property + def timestamp(self): + return time.time() + + +class SmarGon(PSIDeviceBase): + """Thin coordinator for the whole SmarGon goniometer (``dev.smargon``). + + Delegates every operation to the shared :class:`SmargopoloController`; holds no + axes. Point it at the same ``host``/``port`` as the axis motors. + """ + + USER_ACCESS = [ + "reference", + "is_ready", + "wait_until_ready", + "get_mode", + "move_scs", + "nudge_beamline", + "motor_positions", + "stop_all", + "transport_info", + "controller", + ] + + mode = Cpt(SmargopoloModeSignal, value=int(Mode.UNINITIALIZED), kind=Kind.normal) + rosout = Cpt(SmargopoloRosoutSignal, value="", kind=Kind.config) + + def __init__( + self, + *, + name: str, + host: str = "smargopolo", + port: int = 3000, + simulation: bool = False, + timeout: float = 5.0, + gradual_motion: bool = False, + scan_info: "ScanInfo | None" = None, + device_manager=None, + **kwargs, + ) -> None: + self.controller = SmargopoloController( + host=host, + port=port, + simulation=simulation, + timeout=timeout, + gradual_motion=gradual_motion, + device_manager=device_manager, + ) + super().__init__(name=name, scan_info=scan_info, device_manager=device_manager, **kwargs) + + # -- connection lifecycle ---------------------------------------------- + + # pylint: disable=arguments-differ + def wait_for_connection(self, all_signals: bool = False, timeout: float = 10.0) -> None: + self.controller.connect(timeout=timeout) + try: + info = self.controller.get_info() + logger.info( + f"{self.name} connected to smargopolo (S/N {info.serial_number!r}, " + f"mode {info.mode.name})" + ) + if info.mode is not Mode.READY: + logger.warning( + f"{self.name}: smargopolo is {info.mode.name}; call reference() before moving" + ) + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"{self.name}: could not read smargopolo info: {exc}") + super().wait_for_connection(all_signals=all_signals, timeout=timeout) + + def on_stop(self) -> None: + """Halt all SCS axes.""" + self.controller.stop_all() + + # -- delegated whole-goniometer operations ----------------------------- + + def reference(self, wait: bool = True, timeout: float = 120.0) -> str: + """Reference the goniometer (READY). WARNING: physically moves it.""" + return self.controller.reference(wait=wait, timeout=timeout) + + def is_ready(self) -> bool: + return self.controller.is_ready() + + def wait_until_ready(self, timeout: float = 120.0, poll: float = 0.5) -> str: + return self.controller.wait_until_ready(timeout=timeout, poll=poll).name + + def get_mode(self) -> str: + return self.controller.get_mode() + + def move_scs(self, wait: bool = True, **targets: float) -> None: + """Coordinated multi-axis move in one ``/targetSCS`` request.""" + self.controller.move_scs(wait=wait, **targets) + + def nudge_beamline(self, modify_offset: bool = False, **deltas: float) -> None: + self.controller.nudge_beamline(modify_offset=modify_offset, **deltas) + + def motor_positions(self) -> dict: + return self.controller.motor_positions() + + def stop_all(self) -> None: + self.controller.stop_all() + + def transport_info(self) -> dict: + return self.controller.transport_info() diff --git a/csaxs_bec/devices/smargon/transport/__init__.py b/csaxs_bec/devices/smargon/transport/__init__.py new file mode 100644 index 00000000..1dd3cf7e --- /dev/null +++ b/csaxs_bec/devices/smargon/transport/__init__.py @@ -0,0 +1,47 @@ +"""Control-plane transport for the SmarGon goniometer (via smargopolo REST API). + +Pure standard-library; imports neither ophyd nor BEC, so the transport is unit +testable without hardware and standalone control works with the BEC stack absent. +""" + +from __future__ import annotations + +from .base import ( + AXIS_INFO, + MOTOR_CHANNELS, + Axis, + AxisLimits, + BeamlineAxis, + Mode, + SmargopoloInfo, + SmargopoloTransport, +) +from .errors import ( + SmargopoloAxisError, + SmargopoloCommandError, + SmargopoloConnectionError, + SmargopoloModeError, + SmargopoloTimeoutError, + SmargopoloTransportError, +) +from .fake import FakeTransport +from .rest import RestTransport + +__all__ = [ + "Axis", + "AxisLimits", + "AXIS_INFO", + "BeamlineAxis", + "Mode", + "MOTOR_CHANNELS", + "SmargopoloInfo", + "SmargopoloTransport", + "FakeTransport", + "RestTransport", + "SmargopoloTransportError", + "SmargopoloConnectionError", + "SmargopoloTimeoutError", + "SmargopoloModeError", + "SmargopoloCommandError", + "SmargopoloAxisError", +] diff --git a/csaxs_bec/devices/smargon/transport/base.py b/csaxs_bec/devices/smargon/transport/base.py new file mode 100644 index 00000000..530d42a6 --- /dev/null +++ b/csaxs_bec/devices/smargon/transport/base.py @@ -0,0 +1,217 @@ +"""Transport abstraction for the SmarGon goniometer via the smargopolo server. + +This module is intentionally free of any ophyd / BEC imports so it can be unit +tested without hardware and so the ophyd device depends only on this small +interface. The device talks to the goniometer exclusively through the +:class:`SmargopoloTransport` interface; concrete implementations are: + +* :class:`~csaxs_bec.devices.smargon.transport.rest.RestTransport` -- the real + client against the smargopolo RESTful API (``http://:``), and +* :class:`~csaxs_bec.devices.smargon.transport.fake.FakeTransport` -- an in-memory + simulator used for offline mode and the test-suite. + +Coordinate frames +----------------- +smargopolo runs the kinematics; we drive the *virtual* SmarGon Coordinate System +(SCS) axes, never the underlying q1..q6 motor stages. The frames are: + +* **SCS** -- the user axes: ``SHX SHY SHZ OMEGA CHI PHI OX OY OZ`` (mm and deg). + Settable via ``/targetSCS`` (+ ``/targetSCS_rel``), read via ``/readbackSCS``. +* **MCS** -- the raw motor positions ``q1..q6``, read-only via ``/readbackMCS`` + (diagnostics only; we never command them directly). +* **BCS** -- beamline-frame nudges ``BX BY BZ`` via ``/nudgeBCS`` / ``/nudgeOBCS`` + (relative only; no absolute target or readback). + +Units: linear axes in mm, angular axes in deg. +""" + +from __future__ import annotations + +import abc +import enum +from dataclasses import dataclass, field + + +class Axis(str, enum.Enum): + """The SmarGon Coordinate System (SCS) axes. + + ``OMEGA`` is optional hardware: some SLS SmarGons have a rotation drive, some + do not. The ophyd device enables/disables it per deployment via config; the + transport supports it unconditionally. + """ + + SHX = "SHX" + SHY = "SHY" + SHZ = "SHZ" + OMEGA = "OMEGA" + CHI = "CHI" + PHI = "PHI" + OX = "OX" + OY = "OY" + OZ = "OZ" + + +class BeamlineAxis(str, enum.Enum): + """Beamline Coordinate System (BCS) axes -- nudge-only.""" + + BX = "BX" + BY = "BY" + BZ = "BZ" + + +# Raw motor channels reported by ``/readbackMCS`` (read-only diagnostics). +MOTOR_CHANNELS: tuple[str, ...] = ("q1", "q2", "q3", "q4", "q5", "q6") + + +class Mode(enum.IntEnum): + """The smargopolo overall state machine (``/mode``).""" + + UNINITIALIZED = 0 + REFERENCING = 1 + READY = 2 # "Follow Target" -- the only mode in which moves are honoured + ERROR = 99 + + +@dataclass(frozen=True) +class AxisLimits: + """Inclusive low/high travel limits for an SCS axis, plus its unit. + + Authoritative SCS travel ranges depend on the specific SmarGon build and are + not published in the REST API docs. The defaults below are deliberately + ``None`` (unbounded): per-deployment soft limits should be supplied via the + device config and are enforced in the ophyd layer. ``egu`` is informational. + """ + + egu: str + low: float | None = None + high: float | None = None + + +# Engineering units per SCS axis (linear in mm, angular in deg). Limits are left +# unbounded by default -- see :class:`AxisLimits`. +AXIS_INFO: dict[Axis, AxisLimits] = { + Axis.SHX: AxisLimits(egu="mm"), + Axis.SHY: AxisLimits(egu="mm"), + Axis.SHZ: AxisLimits(egu="mm"), + Axis.OMEGA: AxisLimits(egu="deg"), + Axis.CHI: AxisLimits(egu="deg"), + Axis.PHI: AxisLimits(egu="deg"), + Axis.OX: AxisLimits(egu="mm"), + Axis.OY: AxisLimits(egu="mm"), + Axis.OZ: AxisLimits(egu="deg"), +} + + +@dataclass +class SmargopoloInfo: + """Identity / health snapshot read from ``/readbackMCS``.""" + + mode: Mode = Mode.UNINITIALIZED + serial_number: str = "" + corr_type: int = 0 + seq: int = 0 # loop counter; should advance if the ROS control loop is alive + rosout: str = "" # last message logged on /rosout (carries error detail) + motor_positions: dict[str, float] = field(default_factory=dict) + + +class SmargopoloTransport(abc.ABC): + """Control-plane interface to a SmarGon goniometer via smargopolo. + + A transport owns the HTTP session lifecycle, exposes the ``/mode`` state + machine (including referencing), drives the SCS axes (absolute and relative + targets), reads back SCS positions and raw motor positions, and applies BCS + nudges. + + All methods raise subclasses of :class:`SmargopoloTransportError` on failure. + Implementations must be safe to call from a single controlling thread; the + ophyd device serialises access behind a shared lock. + """ + + # -- session lifecycle ------------------------------------------------- + + @abc.abstractmethod + def connect(self, *, timeout: float | None = None) -> None: + """Open/verify the HTTP session with smargopolo. + + Idempotent. Does **not** reference the goniometer -- referencing is a + deliberate operator action (see :meth:`set_mode`). + """ + + @abc.abstractmethod + def close(self) -> None: + """Release the session. Idempotent and must never raise.""" + + @property + @abc.abstractmethod + def connected(self) -> bool: + """Whether the server is reachable (last contact succeeded).""" + + # -- state machine ----------------------------------------------------- + + @abc.abstractmethod + def get_mode(self) -> Mode: + """Return the current state-machine mode.""" + + @abc.abstractmethod + def set_mode(self, mode: Mode) -> None: + """Set the state-machine mode (``PUT /mode?mode=N``). + + Setting :attr:`Mode.REFERENCING` starts referencing, which physically + moves the goniometer to find its reference marks before transitioning to + :attr:`Mode.READY`. + """ + + @abc.abstractmethod + def get_info(self) -> SmargopoloInfo: + """Return an identity/health snapshot (mode, serial, rosout, motors).""" + + # -- motion ------------------------------------------------------------ + + @abc.abstractmethod + def get_position(self, axis: Axis) -> float: + """Return the current readback of *axis* in its SCS unit.""" + + @abc.abstractmethod + def get_positions(self) -> dict[Axis, float]: + """Return the full SCS readback as ``{Axis: value}``.""" + + @abc.abstractmethod + def move_absolute(self, targets: dict[Axis, float]) -> None: + """Command an absolute SCS move of one or more axes (non-blocking). + + Returns as soon as the target is accepted (smargopolo replies ``202``); + completion is observed by the caller polling :meth:`get_position`. + Requires :attr:`Mode.READY` -- otherwise raises + :class:`~csaxs_bec.devices.smargon.transport.errors.SmargopoloModeError`. + """ + + @abc.abstractmethod + def move_relative(self, deltas: dict[Axis, float]) -> None: + """Command a relative SCS move (``/targetSCS_rel``). Requires READY.""" + + @abc.abstractmethod + def stop(self, axes: list[Axis] | None = None) -> None: + """Halt *axes* (or all SCS axes) by retargeting them to their readback. + + smargopolo exposes no explicit stop endpoint; in ``Mode.READY`` ("Follow + Target") setting the target equal to the current readback brings the axis + to rest. Best-effort: must not raise during teardown. + """ + + # -- diagnostics ------------------------------------------------------- + + @abc.abstractmethod + def get_motor_positions(self) -> dict[str, float]: + """Return the raw ``q1..q6`` motor positions (read-only).""" + + # -- beamline-frame nudges -------------------------------------------- + + @abc.abstractmethod + def nudge_beamline( + self, deltas: dict[BeamlineAxis, float], *, modify_offset: bool = False + ) -> None: + """Nudge the sample along the beamline axes (``/nudgeBCS``). + + With ``modify_offset=True`` the O-vector is nudged instead + (``/nudgeOBCS``). Relative only. Requires READY. + """ diff --git a/csaxs_bec/devices/smargon/transport/errors.py b/csaxs_bec/devices/smargon/transport/errors.py new file mode 100644 index 00000000..8908d6b2 --- /dev/null +++ b/csaxs_bec/devices/smargon/transport/errors.py @@ -0,0 +1,37 @@ +"""Typed exceptions for the smargopolo transport layer. + +Defined in a tiny, dependency-free module so that both the transport +implementations and the ophyd device can import them without pulling in ophyd or +any networking stack. +""" + +from __future__ import annotations + + +class SmargopoloTransportError(Exception): + """Base class for all smargopolo transport errors.""" + + +class SmargopoloConnectionError(SmargopoloTransportError): + """Raised when the transport cannot reach the smargopolo server (HTTP failure).""" + + +class SmargopoloTimeoutError(SmargopoloTransportError): + """Raised when a request to the smargopolo server exceeds its timeout.""" + + +class SmargopoloModeError(SmargopoloTransportError): + """Raised when a command is attempted in the wrong state-machine mode. + + smargopolo only follows position targets in ``Mode.READY``. Commanding motion + while ``UNINITIALIZED``/``REFERENCING``/``ERROR`` raises this so the caller can + surface "reference the goniometer first" rather than silently doing nothing. + """ + + +class SmargopoloCommandError(SmargopoloTransportError): + """Raised when the server rejects a command or returns an error response.""" + + +class SmargopoloAxisError(SmargopoloCommandError): + """Raised when an axis name is unknown or a target is otherwise invalid.""" diff --git a/csaxs_bec/devices/smargon/transport/fake.py b/csaxs_bec/devices/smargon/transport/fake.py new file mode 100644 index 00000000..7fcc1e40 --- /dev/null +++ b/csaxs_bec/devices/smargon/transport/fake.py @@ -0,0 +1,225 @@ +"""In-memory fake smargopolo transport. + +:class:`FakeTransport` implements the full :class:`SmargopoloTransport` interface +with no network access. It backs two things: + +* **offline / simulation mode** -- set ``simulation: true`` in the device config + and the ophyd device loads without a real smargopolo server, and +* **the unit-test suite** -- tests inject it (or assert against it) instead of + touching hardware. + +It models the ``/mode`` state machine: it starts ``UNINITIALIZED`` and refuses +motion until referenced. ``set_mode(REFERENCING)`` transitions to ``READY`` +(instantly by default, or after ``reference_time`` of simulated travel). + +Motion is *instant* by default; pass ``gradual_motion=True`` to advance positions +toward target at a finite speed against the injected clock, for exercising the +device's polling / status path. +""" + +from __future__ import annotations + +from typing import Callable + +from .base import ( + AXIS_INFO, + MOTOR_CHANNELS, + Axis, + BeamlineAxis, + Mode, + SmargopoloInfo, + SmargopoloTransport, +) +from .errors import SmargopoloModeError + +# A plausible home pose (matches the example script's home target). +_DEFAULT_POSITIONS: dict[Axis, float] = { + Axis.SHX: 0.0, + Axis.SHY: 0.0, + Axis.SHZ: 18.0, + Axis.OMEGA: 0.0, + Axis.CHI: 0.0, + Axis.PHI: 0.0, + Axis.OX: 0.0, + Axis.OY: 0.0, + Axis.OZ: 180.0, +} + +# Default move speed (axis-units/s) when ``gradual_motion`` is on. +_DEFAULT_SPEED = 50.0 + + +class FakeTransport(SmargopoloTransport): + """A deterministic, hardware-free :class:`SmargopoloTransport` implementation.""" + + def __init__( + self, + *, + gradual_motion: bool = False, + reference_time: float = 0.0, + serial_number: str = "MCS2-FAKE0001", + time_func: Callable[[], float] | None = None, + ) -> None: + import time as _time + + self._time = time_func or _time.monotonic + self._gradual = gradual_motion + self._reference_time = float(reference_time) + self._serial = serial_number + + self._connected = False + self._mode = Mode.UNINITIALIZED + self._seq = 0 + self._rosout = "" + + self._positions = dict(_DEFAULT_POSITIONS) + self._targets = dict(_DEFAULT_POSITIONS) + self._move_start_pos: dict[Axis, float] = {} + self._move_start_t: dict[Axis, float] = {} + self._reference_done_t: float | None = None + + # Test hooks: count calls and optionally inject failures. + self.call_log: list[str] = [] + self.fail_next: dict[str, Exception] = {} + + # -- internal helpers -------------------------------------------------- + + def _record(self, name: str) -> None: + self._seq += 1 + self.call_log.append(name) + exc = self.fail_next.pop(name, None) + if exc is not None: + raise exc + + def _settle(self, axis: Axis) -> None: + """Advance gradual-motion state for *axis* to 'now'.""" + if axis not in self._move_start_t: + return + elapsed = max(0.0, self._time() - self._move_start_t[axis]) + start = self._move_start_pos[axis] + target = self._targets[axis] + travelled = _DEFAULT_SPEED * elapsed + if travelled >= abs(target - start): + self._positions[axis] = target + self._move_start_t.pop(axis, None) + self._move_start_pos.pop(axis, None) + else: + direction = 1.0 if target >= start else -1.0 + self._positions[axis] = start + direction * travelled + + def _settle_referencing(self) -> None: + if self._mode is not Mode.REFERENCING or self._reference_done_t is None: + return + if self._time() >= self._reference_done_t: + self._mode = Mode.READY + self._reference_done_t = None + + def _require_ready(self) -> None: + self._settle_referencing() + if self._mode is not Mode.READY: + raise SmargopoloModeError( + f"smargopolo is in {self._mode.name} (mode {int(self._mode)}); reference the " + "goniometer (set Mode.READY) before commanding motion" + ) + + def _start_move(self, axis: Axis, value: float) -> None: + self._targets[axis] = value + if not self._gradual: + self._positions[axis] = value + return + self._move_start_pos[axis] = self._positions[axis] + self._move_start_t[axis] = self._time() + + # -- session lifecycle ------------------------------------------------- + + def connect(self, *, timeout: float | None = None) -> None: + self._record("connect") + self._connected = True + + def close(self) -> None: + self._record("close") + self._connected = False + + @property + def connected(self) -> bool: + return self._connected + + # -- state machine ----------------------------------------------------- + + def get_mode(self) -> Mode: + self._settle_referencing() + return self._mode + + def set_mode(self, mode: Mode) -> None: + self._record(f"set_mode:{Mode(mode).name}") + mode = Mode(mode) + if mode is Mode.REFERENCING: + if self._reference_time > 0 and self._gradual: + self._mode = Mode.REFERENCING + self._reference_done_t = self._time() + self._reference_time + else: + self._mode = Mode.READY # instant referencing + else: + self._mode = mode + + def get_info(self) -> SmargopoloInfo: + return SmargopoloInfo( + mode=self.get_mode(), + serial_number=self._serial, + corr_type=1, + seq=self._seq, + rosout=self._rosout, + motor_positions=self.get_motor_positions(), + ) + + # -- motion ------------------------------------------------------------ + + def get_position(self, axis: Axis) -> float: + axis = Axis(axis) + if self._gradual: + self._settle(axis) + return self._positions[axis] + + def get_positions(self) -> dict[Axis, float]: + return {a: self.get_position(a) for a in Axis} + + def move_absolute(self, targets: dict[Axis, float]) -> None: + self._record("move_absolute") + self._require_ready() + for axis, value in targets.items(): + self._start_move(Axis(axis), float(value)) + + def move_relative(self, deltas: dict[Axis, float]) -> None: + self._record("move_relative") + self._require_ready() + for axis, delta in deltas.items(): + axis = Axis(axis) + self._start_move(axis, self.get_position(axis) + float(delta)) + + def stop(self, axes: list[Axis] | None = None) -> None: + self._record("stop") + chosen = list(axes) if axes is not None else list(Axis) + for axis in chosen: + axis = Axis(axis) + if self._gradual: + self._settle(axis) + self._targets[axis] = self._positions[axis] + self._move_start_t.pop(axis, None) + + # -- diagnostics ------------------------------------------------------- + + def get_motor_positions(self) -> dict[str, float]: + # No real kinematics in the fake; surface a stable, inspectable mapping. + return {q: 0.0 for q in MOTOR_CHANNELS} + + # -- beamline-frame nudges -------------------------------------------- + + def nudge_beamline( + self, deltas: dict[BeamlineAxis, float], *, modify_offset: bool = False + ) -> None: + self._record("nudge_obcs" if modify_offset else "nudge_bcs") + self._require_ready() + # The fake does not model the BCS->SCS kinematics; nudges are accepted as + # no-ops on the SCS readback so callers can exercise the code path. + for _axis in deltas: + BeamlineAxis(_axis) # validate the axis name diff --git a/csaxs_bec/devices/smargon/transport/rest.py b/csaxs_bec/devices/smargon/transport/rest.py new file mode 100644 index 00000000..507adffc --- /dev/null +++ b/csaxs_bec/devices/smargon/transport/rest.py @@ -0,0 +1,212 @@ +"""Real smargopolo RESTful-API transport. + +Standard-library only (``urllib``); imports neither ophyd nor any third-party +networking stack, mirroring the rest of the transport layer. Talks to the +smargopolo server documented in ``projects/smargon/resources`` -- e.g.:: + + GET http://smargopolo:3000/readbackSCS + PUT http://smargopolo:3000/targetSCS?SHX=0.1&SHZ=18 + PUT http://smargopolo:3000/mode?mode=1 + +PUTs return ``202 Accepted`` with the updated target (not a motion confirmation); +move completion is observed by the device polling :meth:`get_position`. +""" + +from __future__ import annotations + +import json +import socket +import urllib.error +import urllib.parse +import urllib.request + +from .base import ( + AXIS_INFO, + MOTOR_CHANNELS, + Axis, + BeamlineAxis, + Mode, + SmargopoloInfo, + SmargopoloTransport, +) +from .errors import ( + SmargopoloCommandError, + SmargopoloConnectionError, + SmargopoloModeError, + SmargopoloTimeoutError, +) + + +class RestTransport(SmargopoloTransport): + """:class:`SmargopoloTransport` backed by the smargopolo HTTP REST API.""" + + def __init__(self, *, host: str = "smargopolo", port: int = 3000, timeout: float = 5.0) -> None: + self._host = host + self._port = int(port) + self._timeout = float(timeout) + self._base = f"http://{host}:{self._port}" + self._connected = False + + # -- HTTP helpers ------------------------------------------------------ + + def _url(self, resource: str, params: dict | None = None) -> str: + url = f"{self._base}/{resource.lstrip('/')}" + if params: + # smargopolo expects bare ``?A=1&B=2`` query strings. + url += "?" + urllib.parse.urlencode(params) + return url + + def _request(self, resource: str, *, method: str = "GET", params: dict | None = None) -> dict: + url = self._url(resource, params) + req = urllib.request.Request(url, method=method) + try: + with urllib.request.urlopen(req, timeout=self._timeout) as resp: + body = resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + self._connected = True # we reached the server; it returned an error + raise SmargopoloCommandError( + f"{method} {url} -> HTTP {exc.code}: {exc.reason}" + ) from exc + except socket.timeout as exc: + self._connected = False + raise SmargopoloTimeoutError( + f"{method} {url} timed out after {self._timeout}s" + ) from exc + except urllib.error.URLError as exc: + self._connected = False + raise SmargopoloConnectionError(f"{method} {url} failed: {exc.reason}") from exc + self._connected = True + if not body: + return {} + try: + return json.loads(body) + except json.JSONDecodeError as exc: + raise SmargopoloCommandError(f"{method} {url} returned non-JSON: {body!r}") from exc + + def _get(self, resource: str) -> dict: + return self._request(resource, method="GET") + + def _put(self, resource: str, params: dict) -> dict: + return self._request(resource, method="PUT", params=params) + + # -- session lifecycle ------------------------------------------------- + + def connect(self, *, timeout: float | None = None) -> None: + # A successful readback confirms the server is up and the loop is running. + self._get("readbackMCS") + self._connected = True + + def close(self) -> None: + self._connected = False + + @property + def connected(self) -> bool: + return self._connected + + # -- state machine ----------------------------------------------------- + + def get_mode(self) -> Mode: + data = self._get("readbackMCS") + return self._coerce_mode(data.get("mode")) + + def set_mode(self, mode: Mode) -> None: + self._put("mode", {"mode": int(mode)}) + + def get_info(self) -> SmargopoloInfo: + data = self._get("readbackMCS") + rosout = data.get("rosout") or {} + position = data.get("position") or {} + return SmargopoloInfo( + mode=self._coerce_mode(data.get("mode")), + serial_number=data.get("serial_number", "") or "", + corr_type=int(data.get("corr_type", 0) or 0), + seq=int(data.get("seq", 0) or 0), + rosout=rosout.get("msg", "") or "", + motor_positions={q: float(position.get(q, 0.0) or 0.0) for q in MOTOR_CHANNELS}, + ) + + @staticmethod + def _coerce_mode(value) -> Mode: + try: + return Mode(int(value)) + except (TypeError, ValueError): + return Mode.ERROR + + # -- motion ------------------------------------------------------------ + + def get_position(self, axis: Axis) -> float: + return self.get_positions()[Axis(axis)] + + def get_positions(self) -> dict[Axis, float]: + data = self._get("readbackSCS") + out: dict[Axis, float] = {} + for axis in Axis: + if axis.value in data: + out[axis] = float(data[axis.value]) + return out + + def _require_ready(self) -> None: + mode = self.get_mode() + if mode is not Mode.READY: + raise SmargopoloModeError( + f"smargopolo is in {mode.name} (mode {int(mode)}); reference the goniometer " + "(set Mode.READY) before commanding motion" + ) + + def move_absolute(self, targets: dict[Axis, float]) -> None: + if not targets: + return + self._require_ready() + self._put("targetSCS", {Axis(a).value: float(v) for a, v in targets.items()}) + + def move_relative(self, deltas: dict[Axis, float]) -> None: + if not deltas: + return + self._require_ready() + self._put("targetSCS_rel", {Axis(a).value: float(v) for a, v in deltas.items()}) + + def stop(self, axes: list[Axis] | None = None) -> None: + # Retarget the chosen axes to their current readback: in "Follow Target" + # mode this halts them. Best-effort -- never raise. + try: + positions = self.get_positions() + except Exception: # pylint: disable=broad-except + return + chosen = list(axes) if axes is not None else list(positions) + targets = {a: positions[a] for a in chosen if a in positions} + if not targets: + return + try: + self._put("targetSCS", {Axis(a).value: float(v) for a, v in targets.items()}) + except Exception: # pylint: disable=broad-except + pass + + # -- diagnostics ------------------------------------------------------- + + def get_motor_positions(self) -> dict[str, float]: + return self.get_info().motor_positions + + # -- beamline-frame nudges -------------------------------------------- + + def nudge_beamline( + self, deltas: dict[BeamlineAxis, float], *, modify_offset: bool = False + ) -> None: + if not deltas: + return + self._require_ready() + resource = "nudgeOBCS" if modify_offset else "nudgeBCS" + self._put(resource, {BeamlineAxis(a).value: float(v) for a, v in deltas.items()}) + + # -- introspection ----------------------------------------------------- + + @property + def host(self) -> str: + return self._host + + @property + def port(self) -> int: + return self._port + + +# Re-exported for callers that want axis units without importing base directly. +__all__ = ["RestTransport", "AXIS_INFO"] diff --git a/csaxs_bec/file_writer/csaxs_nexus.py b/csaxs_bec/file_writer/csaxs_nexus.py index 5ac05c11..aaa37ae1 100644 --- a/csaxs_bec/file_writer/csaxs_nexus.py +++ b/csaxs_bec/file_writer/csaxs_nexus.py @@ -3,6 +3,38 @@ from __future__ import annotations import numpy as np from bec_server.file_writer.default_writer import DefaultFormat +# Physical metadata for the NXdetector block, per Eiger model. Looked up by the device +# name resolved via _active_eiger_name(); a model not listed here (e.g. a future one) +# still gets a working NXdetector group via _DEFAULT_EIGER_INFO rather than silently +# losing its data links, which is the failure mode this table replaces. +_EIGER_DETECTOR_INFO = { + "eiger_1_5": { + "description": "Eiger 1.5M detector, in-house developed, Paul Scherrer Institute", + "type": "Single-photon counting detector, 320 micron-thick Si chip", + "pixel_size_um": 75.0, + "orientation_transpose": 1, + "orientation_rot90": 3, + }, + "eiger2_si_500k": { + "description": "Dectris EIGER2 Si 500K detector", + "type": "Single-photon counting detector, 450 micron-thick Si chip", + "pixel_size_um": 75.0, + # TODO orientation not yet determined for this detector -- confirm with beam + # during commissioning, then fill these in (see eiger_1_5 for the convention). + # JungfrauJoch-side config (pixel mask, azimuthal integration settings, detector + # selection/init) is done in the Web UI at http://sls-jfjoch-001.psi.ch:8080. + "orientation_transpose": None, + "orientation_rot90": None, + }, +} +_DEFAULT_EIGER_INFO = { + "description": "Eiger detector (Dectris/PSI, JungfrauJoch)", + "type": "Single-photon counting detector", + "pixel_size_um": 75.0, + "orientation_transpose": None, + "orientation_rot90": None, +} + class cSAXSNeXusFormat(DefaultFormat): """ @@ -35,7 +67,10 @@ class cSAXSNeXusFormat(DefaultFormat): /slit_3 … slit_5 /filter_set /beam_stop_1 … beam_stop_2 - /eiger_1_5 NXdetector + / NXdetector ← named after whichever Eiger-family + device (deviceTag "eiger") is enabled + in bl_detectors.yaml, e.g. eiger_1_5 + or eiger2_si_500k; see _active_eiger_name /mcs NXdetector /flOMNI NXpositioner @@ -48,7 +83,8 @@ class cSAXSNeXusFormat(DefaultFormat): bs1x / bs1y → bs1x / bs1y bs2x / bs2y → bs2x / bs2y dettrx → dettrx - eiger_4 → eiger_1_5 + eiger_4 → eiger_1_5, eiger_9 or eiger2_si_500k (whichever carries the + "eiger" deviceTag and is enabled -- see _active_eiger_name) mcs → mcs filter_array → filter_array_1_x … filter_array_4_x xbpm3 → xbpm3x / xbpm3y (stage positions; signal readouts TODO) @@ -94,6 +130,23 @@ class cSAXSNeXusFormat(DefaultFormat): except Exception: pass + def _active_eiger_name(self) -> str | None: + """Name of the currently enabled Eiger-family detector, if any. + + Resolved via the 'eiger' deviceTag rather than a hardcoded device name, so this + layout follows whichever Eiger model is actually active in bl_detectors.yaml + (only one is ever enabled at a time). + """ + try: + candidates = [ + dev + for dev in self.device_manager.devices.get_devices_with_tags(["eiger"]) + if dev.enabled and dev.name in self.file_references + ] + except Exception: + return None + return candidates[0].name if candidates else None + def _slit_blades(self, group, prefix: str) -> None: """ Store individual blade motor positions for a 4-blade slit set. @@ -118,7 +171,8 @@ class cSAXSNeXusFormat(DefaultFormat): # Canonical paths referenced by multiple groups RT_POS_PATH = "/entry/instrument/flOMNI/rt_positions" - EIGER_COLL = "/entry/collection/file_references/eiger_1_5" + eiger_name = self._active_eiger_name() + EIGER_COLL = f"/entry/collection/file_references/{eiger_name}" if eiger_name else None # ── Root entry ──────────────────────────────────────────────────────── entry = self.storage.create_group("entry") @@ -157,11 +211,12 @@ class cSAXSNeXusFormat(DefaultFormat): nxdata.attrs["NX_class"] = "NXdata" nxdata.attrs["signal"] = "data" # Detector frames - try: - for k in self.file_references["eiger_1_5"].hinted_h5_entries.keys(): - self._safe_soft_link(nxdata, k, f"{EIGER_COLL}/{k}") - except Exception: - pass + if eiger_name: + try: + for k in self.file_references[eiger_name].hinted_h5_entries.keys(): + self._safe_soft_link(nxdata, k, f"{EIGER_COLL}/{k}") + except Exception: + pass # Scan positions self._safe_soft_link(nxdata, "positions", RT_POS_PATH) @@ -172,11 +227,12 @@ class cSAXSNeXusFormat(DefaultFormat): # Convenience group mirroring the old /entry/data hardlink from layout.xml. data_soft = entry.create_group("data_soft") data_soft.attrs["NX_class"] = "NXentry" - try: - for k in self.file_references["eiger_1_5"].hinted_h5_entries.keys(): - self._safe_soft_link(data_soft, k, f"{EIGER_COLL}/{k}") - except Exception: - pass + if eiger_name: + try: + for k in self.file_references[eiger_name].hinted_h5_entries.keys(): + self._safe_soft_link(data_soft, k, f"{EIGER_COLL}/{k}") + except Exception: + pass # ── /entry/control ──────────────────────────────────────────────────── control = entry.create_group("control") @@ -357,38 +413,34 @@ class cSAXSNeXusFormat(DefaultFormat): units="mm", description="Detector x-translation stage", ) - # ── Eiger 1.5M detector ─────────────────────────────────────────────── - if ( - "eiger_1_5" in self.device_manager.devices - and self.device_manager.devices.eiger_1_5.enabled - and "eiger_1_5" in self.file_references - ): - eiger = instrument.create_group("eiger_1_5") + # ── Eiger detector (whichever model is currently active) ──────────────── + if eiger_name: + info = _EIGER_DETECTOR_INFO.get(eiger_name, _DEFAULT_EIGER_INFO) + eiger = instrument.create_group(eiger_name) eiger.attrs["NX_class"] = "NXdetector" - eiger.create_dataset("x_pixel_size", data=75.0).attrs["units"] = "um" - eiger.create_dataset("y_pixel_size", data=75.0).attrs["units"] = "um" + eiger.create_dataset("x_pixel_size", data=info["pixel_size_um"]).attrs["units"] = "um" + eiger.create_dataset("y_pixel_size", data=info["pixel_size_um"]).attrs["units"] = "um" eiger.create_dataset("polar_angle", data=0.0).attrs["units"] = "degrees" eiger.create_dataset("azimuthal_angle", data=0.0).attrs["units"] = "degrees" eiger.create_dataset("rotation_angle", data=0.0).attrs["units"] = "degrees" - eiger.create_dataset( - "description", - data="Eiger 1.5M detector, in-house developed, Paul Scherrer Institute", - ) - eiger.create_dataset( - "type", - data="Single-photon counting detector, 320 micron-thick Si chip", - ) + eiger.create_dataset("description", data=info["description"]) + eiger.create_dataset("type", data=info["type"]) orientation = eiger.create_group("orientation") orientation.attrs["description"] = ( "Orientation defines the number of counterclockwise rotations by 90 deg " "followed by a transposition to reach the 'cameraman orientation', " "looking towards the beam." ) - orientation.create_dataset("transpose", data=1) - orientation.create_dataset("rot90", data=3) + # Only written once known for this model (see _EIGER_DETECTOR_INFO); a model + # pending commissioning gets the group and convention documented but no + # guessed values. + if info["orientation_transpose"] is not None: + orientation.create_dataset("transpose", data=info["orientation_transpose"]) + if info["orientation_rot90"] is not None: + orientation.create_dataset("rot90", data=info["orientation_rot90"]) # Soft-link recorded frame data from the BEC collection try: - for k in self.file_references["eiger_1_5"].hinted_h5_entries.keys(): + for k in self.file_references[eiger_name].hinted_h5_entries.keys(): self._safe_soft_link(eiger, k, f"{EIGER_COLL}/{k}") except Exception: pass @@ -396,7 +448,7 @@ class cSAXSNeXusFormat(DefaultFormat): try: eiger.create_ext_link( "pixel_mask", - self.file_references["eiger_1_5"].file_path, + self.file_references[eiger_name].file_path, "/entry/instrument/detector/pixel_mask", ) except Exception: diff --git a/csaxs_bec/macros/run_cont_grid_scan_for_table_row.py b/csaxs_bec/macros/run_cont_grid_scan_for_table_row.py index cdfbd530..d4099d76 100644 --- a/csaxs_bec/macros/run_cont_grid_scan_for_table_row.py +++ b/csaxs_bec/macros/run_cont_grid_scan_for_table_row.py @@ -7,9 +7,77 @@ This dictionary is the input for this macro. from __future__ import annotations +import traceback + import numpy as np +from bec_lib.logger import bec_logger from bec_lib.scan_repeat import scan_repeat +# NOTE No module-level assignments in a macro: the loader rejects any ast.Assign at module +# level (bec_lib.macro_update_handler.has_executable_code) and refuses the whole file, so +# the logger is fetched inside the functions rather than bound to a module global. + + +def _row_context(row: dict) -> str: + """One line of scan context, so a failure report identifies the row on its own.""" + parts = [ + f"sample '{row.get('sample_name')}'", + f"ID {row.get('sample_id')}", + f"template {row.get('template')}", + f"fast {row.get('fast_axis')} {row.get('fast_start')}..{row.get('fast_stop')}" + f" step {row.get('fast_step')}", + f"stepper {row.get('stepper_axis')} {row.get('stepper_start')}..{row.get('stepper_stop')}" + f" step {row.get('stepper_step')}", + f"exp_time {row.get('exposure_time')}", + ] + if row.get("template") == "tensor": + parts.append( + f"rotx {row.get('rotx_axis')}={row.get('rotx')}, " + f"roty {row.get('roty_axis')}={row.get('roty')}" + ) + return ", ".join(str(p) for p in parts) + + +def _report_failure(info_msg: str, exc: Exception, row: dict, phone_numbers) -> None: + """Report a failure everywhere it might be looked for, without ever masking it. + + The original exception is always re-raised by the caller. Notification channels are + best effort and individually guarded: an unreachable SciLog must not replace the + error it was trying to report, which is how a one-line DeviceConfigError went + undiagnosed through three retries and several beamtime hours. + + The traceback is printed AND logged. Logging matters because bec_logger fans out to + the log files and to Redis, so the failure is then visible in `bec-log-monitor` and + afterwards in the logs -- not only on whichever console happened to run the macro. + """ + context = _row_context(row) + detail = traceback.format_exc() + + print(f"{info_msg}\n {context}\n {type(exc).__name__}: {exc}\n{detail}") + bec_logger.logger.error(f"{info_msg} [{context}]\n{detail}") + + try: + msg = bec.messaging.scilog.new() + msg.add_tags("error") + msg.add_text(info_msg) + msg.add_text(context) + msg.add_text(f"Error details: {type(exc).__name__}: {exc}") + msg.add_text(f"```\n{detail}\n```") + msg.send() + except Exception: # pylint: disable=broad-except + # Never let the reporting channel become the reported error. + print(f"Additionally, posting this failure to SciLog failed:\n{traceback.format_exc()}") + bec_logger.logger.warning(f"SciLog post failed while reporting: {info_msg}") + + if phone_numbers: + try: + msg = bec.messaging.signal.new() + msg.add_text(f"{info_msg} {context}") + msg.send(scope=phone_numbers) + except Exception: # pylint: disable=broad-except + print(f"Additionally, the SMS notification failed:\n{traceback.format_exc()}") + bec_logger.logger.warning(f"SMS notification failed while reporting: {info_msg}") + # TODO Default True here means that ANY error will be retried up to 3 times. This is dangerous in the sense that # if the error is due to a misconfiguration of the scan parameters, it will just keep retrying and failing. @@ -69,11 +137,14 @@ def run_cont_grid_scan_for_table_row( try: # Move motors to the starting position of the scan move_command = [] - roty_motor = dev.devices[row["roty_axis"]] + # NOTE `dev` IS the device container (bec_lib client binds + # dev = device_manager.devices), so `dev.devices[...]` asks the container for a + # device named "devices" and raises DeviceConfigError before any motor moves. + roty_motor = dev[row["roty_axis"]] setpoint_roty = roty_motor.setpoint.get(cached=True) if not np.isclose(setpoint_roty, row["roty"]): move_command.extend([row["roty_axis"], row["roty"]]) - setpoint_rotx = dev.devices[row["rotx_axis"]].setpoint.get(cached=True) + setpoint_rotx = dev[row["rotx_axis"]].setpoint.get(cached=True) if not np.isclose(setpoint_rotx, row["rotx"]): move_command.extend([row["rotx_axis"], row["rotx"]]) # Wait for all motor movements to complete @@ -84,18 +155,7 @@ def run_cont_grid_scan_for_table_row( "Error while moving motors to starting position for sample " f"'{row['sample_name']}' (ID: {row['sample_id']})." ) - print(info_msg) - # Send message to scilog with error tag and text - msg = bec.messaging.scilog.new() - msg.add_tags("error") - msg.add_text(info_msg) - msg.add_text(f"Error details: {e}") - msg.send() - # Notify the user via SMS if phone numbers are provided - if phone_numbers: - msg = bec.messaging.signal.new() - msg.add_text(info_msg) - msg.send(scope=phone_numbers) + _report_failure(info_msg, e, row, phone_numbers) raise # Scan command @@ -118,16 +178,5 @@ def run_cont_grid_scan_for_table_row( f"Error while executing cont_grid scan for sample '{row['sample_name']}' " f"with ID '{row['sample_id']}'." ) - print(info_msg) - # Send message to scilog with error tag and text - msg = bec.messaging.scilog.new() - msg.add_tags("error") - msg.add_text(info_msg) - msg.add_text(f"Error details: {e}") - msg.send() - # Notify the user via SMS if phone numbers are provided - if phone_numbers: - msg = bec.messaging.signal.new() - msg.add_text(info_msg) - msg.send(scope=phone_numbers) + _report_failure(info_msg, e, row, phone_numbers) raise diff --git a/csaxs_bec/scans/fermat_spiral_utils.py b/csaxs_bec/scans/fermat_spiral_utils.py new file mode 100644 index 00000000..b42272b7 --- /dev/null +++ b/csaxs_bec/scans/fermat_spiral_utils.py @@ -0,0 +1,35 @@ +"""Shared helpers for Fermat-spiral scan position generation (flomni/omny/lamni).""" + +from __future__ import annotations + +import numpy as np + + +def compute_fermat_spiral_n_max( + length_axis1: float, length_axis2: float, step: float, safety_factor: float = 1.1 +) -> int: + """ + Size the Fermat-spiral loop's iteration cap. + + The spiral radius at iteration ``ii`` grows as ``step * 0.57 * sqrt(ii)``, so sizing + the cap from the box's area alone (``length_axis1 * length_axis2 * 3.2 / step**2``) + under-estimates the iterations needed for elongated (non-square) boxes: the maximum + radius that formula reaches is a function of area, not of the box's actual corner + distance, so the spiral can exhaust its iterations before reaching the far corners of + a long, narrow box. Taking the max with a cap derived from the corner distance itself + fixes that while leaving square/near-square boxes unaffected (the area-based term + already dominates there). + + Args: + length_axis1 (float): Full extent of the scan box along the first axis. + length_axis2 (float): Full extent of the scan box along the second axis. + step (float): Spiral step size. + safety_factor (float): Margin applied to the corner-distance-based term. + + Returns: + int: Iteration cap for the Fermat-spiral generation loop. + """ + n_max_area = int(length_axis1 * length_axis2 * 3.2 / step / step) + corner_radius = np.sqrt((length_axis1 / 2) ** 2 + (length_axis2 / 2) ** 2) + n_max_corner = int((corner_radius / (0.57 * step)) ** 2 * safety_factor) + return max(n_max_area, n_max_corner) diff --git a/csaxs_bec/scans/flomni_fermat_scan.py b/csaxs_bec/scans/flomni_fermat_scan.py index 3dd851b9..f07cbca8 100644 --- a/csaxs_bec/scans/flomni_fermat_scan.py +++ b/csaxs_bec/scans/flomni_fermat_scan.py @@ -32,6 +32,7 @@ from bec_server.scan_server.scans.scan_base import ScanBase, ScanType from bec_server.scan_server.scans.scan_modifier import scan_hook from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import TRIGGERSOURCE +from csaxs_bec.scans.fermat_spiral_utils import compute_fermat_spiral_n_max logger = bec_logger.logger @@ -56,6 +57,7 @@ class FlomniFermatScan(ScanBase): "cenx", "ceny", "step", + "fermat_asymmetry", "zshift", "angle", "corridor_size", @@ -65,19 +67,23 @@ class FlomniFermatScan(ScanBase): MIN_POSITIONS = 20 # Minimum number of positions required for a valid scan MAX_CORRIDOR_SIZE = 3 # Corridor size is capped at this value (in um) for stability + MIN_FERMAT_ASYMMETRY = 0.1 # Practical working range for fermat_asymmetry (soft-clamped) + MAX_FERMAT_ASYMMETRY = 10.0 def __init__( # fmt: off self, - fovx: Annotated[float, ScanArgument(display_name="Fovx", description="FOV in the piezo plane (i.e. piezo range). Max 200 um.", units=Units.µm, gt=0, lt=200)], - fovy: Annotated[float, ScanArgument(display_name="Fovy", description="FOV in the piezo plane (i.e. piezo range). Max 100 um.", units=Units.µm, gt=0, lt=100)], - cenx: Annotated[float, ScanArgument(display_name="Cenx", description="Center position in x.", units=Units.µm)], - ceny: Annotated[float, ScanArgument(display_name="Ceny", description="Center position in y.", units=Units.µm)], - step: Annotated[float, ScanArgument(display_name="Step", description="Step size.", units=Units.µm)], - zshift: Annotated[float, ScanArgument(display_name="Zshift", description="Shift in z. ", units=Units.µm)], - angle: Annotated[float, ScanArgument(display_name="Angle", description="Rotation angle (will rotate first)", units=Units.deg)], + fovx: Annotated[float, ScanArgument(display_name="Fovx", description="FOV in the piezo plane (i.e. piezo range). Max 220 um.", units=Units.µm, gt=0, le=220, example=10)], + fovy: Annotated[float, ScanArgument(display_name="Fovy", description="FOV in the piezo plane (i.e. piezo range). Max 100 um.", units=Units.µm, gt=0, lt=100, example=10)], + cenx: Annotated[float, ScanArgument(display_name="Cenx", description="Center position in x.", units=Units.µm, example=0)], + ceny: Annotated[float, ScanArgument(display_name="Ceny", description="Center position in y.", units=Units.µm, example=0)], + step: Annotated[float, ScanArgument(display_name="Step", description="Step size.", units=Units.µm, example=1)], + zshift: Annotated[float, ScanArgument(display_name="Zshift", description="Shift in z. ", units=Units.µm, example=0)], + angle: Annotated[float, ScanArgument(display_name="Angle", description="Rotation angle (will rotate first)", units=Units.deg, example=0)], corridor_size: Annotated[float | None, ScanArgument(display_name="Corridor Size", description="Corridor size for the corridor optimization.", units=Units.µm)] = None, - exp_time: DefaultArgType.ExposureTime = 0, + fermat_asymmetry: Annotated[float, ScanArgument(display_name="Fermat Asymmetry", description="Ratio of x spacing to y spacing (x/y). 1 = symmetric. >1 widens x spacing / tightens y spacing; <1 the opposite. FOV is preserved; point count changes with the ratio.", gt=0, example=1)] = 1.0, + randomize_offset: Annotated[bool, ScanArgument(display_name="Randomize Offset", description="Randomize the sub-step offset of the Fermat lattice on every scan (decorrelates position-tied reconstruction errors across a tomographic series). Set to False for the unshifted lattice.", example=True)] = True, + exp_time: DefaultArgType.ExposureTime = 0.1, frames_per_trigger: DefaultArgType.FramesPerTrigger = 1, burst_at_each_point: DefaultArgType.BurstAtEachPoint = 1, **kwargs, @@ -87,7 +93,7 @@ class FlomniFermatScan(ScanBase): flOMNI Fermat Scan Args: - fovx (float): FOV in the piezo plane (i.e. piezo range). Max 200 um. + fovx (float): FOV in the piezo plane (i.e. piezo range). Max 220 um. fovy (float): FOV in the piezo plane (i.e. piezo range). Max 100 um. cenx (float): Center position in x. ceny (float): Center position in y. @@ -95,6 +101,11 @@ class FlomniFermatScan(ScanBase): zshift (float): Shift in z. angle (float): Rotation angle (will rotate first) corridor_size (float | None): Corridor size for the corridor optimization. + fermat_asymmetry (float): Ratio of x spacing to y spacing (x/y). 1 = symmetric. + x spacing stays fixed at step, y spacing scales by this ratio. The FOV is + preserved, so the point count changes with the ratio. + randomize_offset (bool): Randomize the sub-step offset of the Fermat lattice on every + scan. Default True. If False, the lattice is not shifted. exp_time (float): Exposure time in seconds frames_per_trigger (int): Number of frames per trigger for devices that support configurable frame counts per trigger. burst_at_each_point (int): Number of triggers and readouts at each point. @@ -112,6 +123,8 @@ class FlomniFermatScan(ScanBase): self.zshift = zshift self.angle = angle self.corridor_size = corridor_size + self.fermat_asymmetry = fermat_asymmetry + self.randomize_offset = randomize_offset self.exp_time = exp_time self.frames_per_trigger = frames_per_trigger self.burst_at_each_point = burst_at_each_point @@ -157,6 +170,8 @@ class FlomniFermatScan(ScanBase): cenx=self.cenx, ceny=self.ceny, zshift=self.zshift, + fermat_asymmetry=self.fermat_asymmetry, + randomize_offset=self.randomize_offset, ) if len(positions) < self.MIN_POSITIONS: @@ -418,6 +433,9 @@ class FlomniFermatScan(ScanBase): cenx: float = 0.0, ceny: float = 0.0, zshift: float = 0.0, + seed: int | None = None, + fermat_asymmetry: float = 1.0, + randomize_offset: bool = True, ): """ Calculate positions for a Fermat spiral scan. @@ -430,10 +448,32 @@ class FlomniFermatScan(ScanBase): step(float): stepsize spiral_type(int): 0 for traditional Fermat spiral center(bool): whether to include the center position + seed(int | None): seed for the per-call random sub-step offset (see below). + Pass a fixed value for reproducible output (e.g. in tests); leave as None + to draw a fresh offset every call. + fermat_asymmetry(float): ratio of x spacing to y spacing (x/y). 1 = symmetric + (default). x spacing stays fixed at step; y spacing scales by this ratio. + The requested FOV (m1/m2 range) is preserved, so the point count changes + with the ratio. Soft-clamped to [MIN_FERMAT_ASYMMETRY, MAX_FERMAT_ASYMMETRY]. + randomize_offset(bool): draw the random sub-step offset (default). If False the + offset is 0 and the lattice is unshifted; seed is then irrelevant. Returns: positions(array): positions """ + if fermat_asymmetry < FlomniFermatScan.MIN_FERMAT_ASYMMETRY: + logger.warning( + f"fermat_asymmetry is smaller than {FlomniFermatScan.MIN_FERMAT_ASYMMETRY}. It" + f" will be limited to {FlomniFermatScan.MIN_FERMAT_ASYMMETRY}." + ) + fermat_asymmetry = FlomniFermatScan.MIN_FERMAT_ASYMMETRY + elif fermat_asymmetry > FlomniFermatScan.MAX_FERMAT_ASYMMETRY: + logger.warning( + f"fermat_asymmetry is larger than {FlomniFermatScan.MAX_FERMAT_ASYMMETRY}. It" + f" will be limited to {FlomniFermatScan.MAX_FERMAT_ASYMMETRY}." + ) + fermat_asymmetry = FlomniFermatScan.MAX_FERMAT_ASYMMETRY + positions = [] phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2.0) + spiral_type * np.pi @@ -441,19 +481,33 @@ class FlomniFermatScan(ScanBase): length_axis1 = np.abs(m1_stop - m1_start) length_axis2 = np.abs(m2_stop - m2_start) - n_max = int(length_axis1 * length_axis2 * 3.2 / step / step) + # y is checked against an inflated half-width (see below), so the spiral must be + # grown far enough to still cover the real fovy after the y-compression. + n_max = compute_fermat_spiral_n_max(length_axis1, length_axis2 * fermat_asymmetry, step) z_pos = zshift + # Randomize the sampling lattice's sub-step phase on every call (i.e. every + # projection), matching the legacy spec behaviour: this decorrelates position-tied + # reconstruction errors that would otherwise accumulate into ring artifacts across + # a tomographic series. + offset = np.random.default_rng(seed).uniform(0, step) if randomize_offset else 0.0 + logger.info(f"Fermat spiral sub-step offset: {offset:.4f} um") + for ii in range(start, n_max): radius = step * 0.57 * np.sqrt(ii) + x = radius * np.sin(ii * phi) - offset + # y_raw is the pre-compression lattice coordinate: kept at the isotropic + # spacing/phase so the crop check below (against the inflated fovy) sees the + # same sub-step randomization as x, then compressed by fermat_asymmetry + # afterwards -- x spacing stays fixed at step, y spacing becomes step/fermat_asymmetry. + y_raw = radius * np.cos(ii * phi) - offset # FOV is restructed below at check pos in range - if abs(radius * np.sin(ii * phi)) > length_axis1 / 2: + if abs(x) > length_axis1 / 2: continue - if abs(radius * np.cos(ii * phi)) > length_axis2 / 2: + if abs(y_raw) > (length_axis2 * fermat_asymmetry) / 2: continue - x = radius * np.sin(ii * phi) - y = radius * np.cos(ii * phi) + y = y_raw / fermat_asymmetry positions.append([x + cenx, y + ceny, z_pos]) left_lower_corner = [min(m1_start, m1_stop) + cenx, min(m2_start, m2_stop) + ceny, z_pos] right_upper_corner = [max(m1_start, m1_stop) + cenx, max(m2_start, m2_stop) + ceny, z_pos] diff --git a/csaxs_bec/scans/lamni_components.py b/csaxs_bec/scans/lamni_components.py index 71f0f6d7..27c23d79 100644 --- a/csaxs_bec/scans/lamni_components.py +++ b/csaxs_bec/scans/lamni_components.py @@ -87,6 +87,13 @@ class LamNIComponents(ScanComponents): logger.info(f"Current uncompensated drift of setup is x={x_drift:.3f}, y={y_drift:.3f}") + # a drift this large is not trusted (e.g. interferometer reset or error); + # drop it so the second iteration does not apply it either + if np.abs(y_drift) > 150 or np.abs(x_drift) > 150: + logger.info("Drift exceeds 150 microns, ignoring it.") + x_drift = 0.0 + y_drift = 0.0 + move_x = ( x_stage + lsamx_center + self.lamni_to_stage_coordinates(x_drift, y_drift)[0] / 1000 ) @@ -100,11 +107,7 @@ class LamNIComponents(ScanComponents): self._dev.lsamx.read_only = False self._dev.lsamy.read_only = False - if ( - np.abs(y_drift) > 150 - or np.abs(x_drift) > 150 - or (coarse_move_req_y < 0.003 and coarse_move_req_x < 0.003) - ): + if coarse_move_req_y < 0.003 and coarse_move_req_x < 0.003: logger.info("No drift correction.") else: logger.info( diff --git a/csaxs_bec/scans/lamni_fermat_scan.py b/csaxs_bec/scans/lamni_fermat_scan.py index f152ea82..743d8f39 100644 --- a/csaxs_bec/scans/lamni_fermat_scan.py +++ b/csaxs_bec/scans/lamni_fermat_scan.py @@ -26,6 +26,8 @@ from bec_server.scan_server.errors import ScanAbortion from bec_server.scan_server.scans.scan_base import ScanBase, ScanType from bec_server.scan_server.scans.scan_modifier import scan_hook +from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import TRIGGERSOURCE +from csaxs_bec.scans.fermat_spiral_utils import compute_fermat_spiral_n_max from csaxs_bec.scans.lamni_components import LamNIComponents logger = bec_logger.logger @@ -59,7 +61,7 @@ class LamniFermatScan(ScanBase): "fov_circular", "stitch_overlap", ], - "Acquisition Parameters": ["exp_time", "frames_per_trigger", "readout_time"], + "Acquisition Parameters": ["exp_time", "frames_per_trigger"], } MIN_POSITIONS = 20 # Minimum number of positions required for a valid scan @@ -67,9 +69,9 @@ class LamniFermatScan(ScanBase): def __init__( # fmt: off self, - fovx: Annotated[float, ScanArgument(display_name="FOV x", description="FOV in the piezo plane (i.e. piezo range). Max 80 um", units=Units.µm, gt=0, lt=80)], - fovy: Annotated[float, ScanArgument(display_name="FOV y", description="FOV in the piezo plane (i.e. piezo range). Max 80 um", units=Units.µm, gt=0, lt=80)], - step: Annotated[float, ScanArgument(display_name="Step", description="Step size", units=Units.µm)], + fovx: Annotated[float, ScanArgument(display_name="FOV x", description="FOV in the piezo plane (i.e. piezo range). Max 80 um", units=Units.µm, gt=0, lt=80, example=10)], + fovy: Annotated[float, ScanArgument(display_name="FOV y", description="FOV in the piezo plane (i.e. piezo range). Max 80 um", units=Units.µm, gt=0, lt=80, example=10)], + step: Annotated[float, ScanArgument(display_name="Step", description="Step size", units=Units.µm, example=1)], shift_x: Annotated[float, ScanArgument(display_name="Shift X", description="Extra shift in x. The shift is directly applied to the scan. It will not be auto-rotated.", units=Units.mm)] = 0, shift_y: Annotated[float, ScanArgument(display_name="Shift Y", description="Extra shift in y. The shift is directly applied to the scan. It will not be auto-rotated.", units=Units.mm)] = 0, center_x: Annotated[float, ScanArgument(display_name="Center X", description="Center position in x at 0 deg. This shift is rotated using the geometry of LamNI. It is determined by the first 'click' in the x-ray eye alignment procedure.", units=Units.mm)] = 0, @@ -80,9 +82,8 @@ class LamniFermatScan(ScanBase): fov_circular: Annotated[float, ScanArgument(display_name="Fov Circular", description="Generate a circular field of view in the sample plane. This is an additional cropping within the rectangular fovx/fovy range", units=Units.µm)] = 0, stitch_overlap: Annotated[float, ScanArgument(display_name="Stitch Overlap", description="Overlap of the stitched regions", units=Units.µm)] = 1, corridor_size: Annotated[float | None, ScanArgument(display_name="Corridor Size", description="Corridor size for the corridor optimization.", units=Units.µm)] = None, - exp_time: DefaultArgType.ExposureTime = 0, + exp_time: DefaultArgType.ExposureTime = 0.1, frames_per_trigger: DefaultArgType.FramesPerTrigger = 1, - readout_time: DefaultArgType.ReadoutTime = 0, **kwargs, # fmt: on ): @@ -105,7 +106,6 @@ class LamniFermatScan(ScanBase): exp_time (float): Exposure time in seconds corridor_size (float | None): Corridor size for the corridor optimization. If None, the corridor size will be estimated. frames_per_trigger (int): Number of frames per trigger for devices that support configurable frame counts per trigger. - readout_time (float): Configuration for devices that support configurable readout times. Returns: ScanReport @@ -127,12 +127,9 @@ class LamniFermatScan(ScanBase): self.stitch_overlap = stitch_overlap self.exp_time = exp_time self.frames_per_trigger = frames_per_trigger - self.readout_time = readout_time self.corridor_size = corridor_size - self.update_scan_info( - exp_time=exp_time, frames_per_trigger=frames_per_trigger, readout_time=readout_time - ) + self.update_scan_info(exp_time=exp_time, frames_per_trigger=frames_per_trigger) @scan_hook def prepare_scan(self): @@ -210,6 +207,8 @@ class LamniFermatScan(ScanBase): devices, e.g. devices that have a short timeout. The pre-scan logic is typically implemented on the device itself. """ + # Prepare DDG1 to be triggered externally by the RT controller + self.dev.ddg1.set_trigger(TRIGGERSOURCE.EXT_RISING_EDGE.value) self.actions.pre_scan_all_devices() @scan_hook @@ -240,6 +239,7 @@ class LamniFermatScan(ScanBase): Post-scan steps to be executed after the main scan logic. """ self.actions.complete_all_devices() + self.dev.ddg1.set_trigger(TRIGGERSOURCE.SINGLE_SHOT.value) @scan_hook def unstage(self): @@ -261,6 +261,7 @@ class LamniFermatScan(ScanBase): This is a good place to implement any cleanup logic that needs to be executed in case of an exception, such as returning the devices to a safe state or moving the motors back to their starting position. """ + self.dev.ddg1.set_trigger(TRIGGERSOURCE.SINGLE_SHOT.value) ####################################################### ######### Helper methods for the scan logic ########### @@ -286,6 +287,7 @@ class LamniFermatScan(ScanBase): fovx: float | None = None, fovy: float | None = None, fov_circular: float = 0.0, + seed: int | None = None, ) -> np.ndarray: """Generate positions for a LamNI Fermat spiral scan. @@ -298,6 +300,9 @@ class LamniFermatScan(ScanBase): spiral_type (float, optional): Angular offset in radians that determines the shape of the spiral. A spiral with spiral_type=2 is the same as spiral_type=0. Defaults to 0. center (bool, optional): Add a center point. Defaults to False. + seed (int | None, optional): seed for the per-call random sub-step offset (see + below). Pass a fixed value for reproducible output (e.g. in tests); leave as + None to draw a fresh offset every call. Returns: np.ndarray: Array of positions for the Fermat spiral scan. @@ -309,7 +314,14 @@ class LamniFermatScan(ScanBase): length_axis1 = np.abs(m1_stop - m1_start) length_axis2 = np.abs(m2_stop - m2_start) - n_max = int(length_axis1 * length_axis2 * 3.2 / step / step) + n_max = compute_fermat_spiral_n_max(length_axis1, length_axis2, step) + + # Randomize the sampling lattice's sub-step phase on every call (i.e. every + # projection), matching the legacy spec behaviour: this decorrelates position-tied + # reconstruction errors that would otherwise accumulate into ring artifacts across + # a tomographic series. + offset = np.random.default_rng(seed).uniform(0, step) + logger.info(f"Fermat spiral sub-step offset: {offset:.4f} um") total_shift_x, total_shift_y = LamniFermatScan._compute_total_shift( center_x=center_x, @@ -326,8 +338,8 @@ class LamniFermatScan(ScanBase): for ii in range(start, n_max): radius = step * 0.57 * np.sqrt(ii) - x = radius * np.sin(ii * phi) - y = radius * np.cos(ii * phi) + x = radius * np.sin(ii * phi) - offset + y = radius * np.cos(ii * phi) - offset if LamniFermatScan._lamni_check_pos_in_fov_range_and_circ_fov( x=x, y=y, diff --git a/csaxs_bec/scans/omny_fermat_scan.py b/csaxs_bec/scans/omny_fermat_scan.py index c5a92e19..cada6751 100644 --- a/csaxs_bec/scans/omny_fermat_scan.py +++ b/csaxs_bec/scans/omny_fermat_scan.py @@ -23,13 +23,15 @@ import numpy as np from scipy.spatial import cKDTree from bec_lib import messages +from bec_lib.endpoints import MessageEndpoints from bec_lib.logger import bec_logger from bec_lib.scan_args import DefaultArgType, ScanArgument, Units from bec_server.scan_server.errors import ScanAbortion -from bec_server.scan_server.scans import MessageEndpoints from bec_server.scan_server.scans.scan_base import ScanBase, ScanType from bec_server.scan_server.scans.scan_modifier import scan_hook +from csaxs_bec.scans.fermat_spiral_utils import compute_fermat_spiral_n_max + logger = bec_logger.logger @@ -60,23 +62,22 @@ class OmnyFermatScan(ScanBase): "angle", "corridor_size", ], - "Acquisition Parameters": ["exp_time", "frames_per_trigger", "readout_time"], + "Acquisition Parameters": ["exp_time", "frames_per_trigger"], } def __init__( # fmt: off self, - fovx: Annotated[float, ScanArgument(display_name="Fovx", description="FOV in the piezo plane (i.e. piezo range).", units=Units.µm, gt=0, lt=200)], - fovy: Annotated[float, ScanArgument(display_name="Fovy", description="FOV in the piezo plane (i.e. piezo range).", units=Units.µm, gt=0, lt=100)], - cenx: Annotated[float, ScanArgument(display_name="Cenx", description="Center position in x.", units=Units.µm)], - ceny: Annotated[float, ScanArgument(display_name="Ceny", description="Center position in y.", units=Units.µm)], - step: Annotated[float, ScanArgument(display_name="Step", description="Step size.", units=Units.µm)], - zshift: Annotated[float, ScanArgument(display_name="Zshift", description="Shift in z.", units=Units.µm)], + fovx: Annotated[float, ScanArgument(display_name="Fovx", description="FOV in the piezo plane (i.e. piezo range).", units=Units.µm, gt=0, lt=200, example=10)], + fovy: Annotated[float, ScanArgument(display_name="Fovy", description="FOV in the piezo plane (i.e. piezo range).", units=Units.µm, gt=0, lt=100, example=10)], + cenx: Annotated[float, ScanArgument(display_name="Cenx", description="Center position in x.", units=Units.µm, example=0)], + ceny: Annotated[float, ScanArgument(display_name="Ceny", description="Center position in y.", units=Units.µm, example=0)], + step: Annotated[float, ScanArgument(display_name="Step", description="Step size.", units=Units.µm, example=1)], + zshift: Annotated[float, ScanArgument(display_name="Zshift", description="Shift in z.", units=Units.µm, example=0)], angle: Annotated[float | None, ScanArgument(display_name="Angle", description="Rotation angle (will rotate first)", units=Units.deg)] = None, corridor_size: Annotated[float | None, ScanArgument(display_name="Corridor Size", description="Corridor size for the corridor optimization. ", units=Units.µm)] = None, - exp_time: DefaultArgType.ExposureTime = 0, + exp_time: DefaultArgType.ExposureTime = 0.1, frames_per_trigger: DefaultArgType.FramesPerTrigger = 1, - readout_time: DefaultArgType.ReadoutTime = 0, **kwargs, # fmt: on ): @@ -94,7 +95,6 @@ class OmnyFermatScan(ScanBase): corridor_size (float | None): Corridor size for the corridor optimization. exp_time (float): Exposure time in seconds frames_per_trigger (int): Number of frames per trigger for devices that support configurable frame counts per trigger. - readout_time (float): Configuration for devices that support configurable readout times. Returns: ScanReport @@ -111,7 +111,6 @@ class OmnyFermatScan(ScanBase): self.corridor_size = corridor_size self.exp_time = exp_time self.frames_per_trigger = frames_per_trigger - self.readout_time = readout_time if self.zshift > 100: logger.warning("The zshift is larger than 100 um. It will be limited to 100 um.") @@ -127,9 +126,7 @@ class OmnyFermatScan(ScanBase): ) self.corridor_size = self.MAX_CORRIDOR_SIZE - self.update_scan_info( - exp_time=exp_time, frames_per_trigger=frames_per_trigger, readout_time=readout_time - ) + self.update_scan_info(exp_time=exp_time, frames_per_trigger=frames_per_trigger) self.omny_rotation_status = None @@ -149,6 +146,9 @@ class OmnyFermatScan(ScanBase): step=self.step, spiral_type=0, center=False, + cenx=self.cenx, + ceny=self.ceny, + zshift=self.zshift, ) if len(positions) < self.MIN_POSITIONS: @@ -299,6 +299,7 @@ class OmnyFermatScan(ScanBase): cenx: float = 0.0, ceny: float = 0.0, zshift: float = 0.0, + seed: int | None = None, ): """ Calculate positions for a Fermat spiral scan. @@ -314,6 +315,9 @@ class OmnyFermatScan(ScanBase): cenx(float): center offset in x ceny(float): center offset in y zshift(float): shift in z + seed(int | None): seed for the per-call random sub-step offset (see below). + Pass a fixed value for reproducible output (e.g. in tests); leave as None + to draw a fresh offset every call. Returns: positions(array): positions @@ -325,22 +329,37 @@ class OmnyFermatScan(ScanBase): length_axis1 = np.abs(m1_stop - m1_start) length_axis2 = np.abs(m2_stop - m2_start) - n_max = int(length_axis1 * length_axis2 * 3.2 / step / step) + n_max = compute_fermat_spiral_n_max(length_axis1, length_axis2, step) z_pos = zshift + # Randomize the sampling lattice's sub-step phase on every call (i.e. every + # projection), matching the legacy spec behaviour: this decorrelates position-tied + # reconstruction errors that would otherwise accumulate into ring artifacts across + # a tomographic series. + offset = np.random.default_rng(seed).uniform(0, step) + logger.info(f"Fermat spiral sub-step offset: {offset:.4f} um") + for ii in range(start, n_max): radius = step * 0.57 * np.sqrt(ii) + x = radius * np.sin(ii * phi) - offset + y = radius * np.cos(ii * phi) - offset # FOV is restructed below at check pos in range - if abs(radius * np.sin(ii * phi)) > length_axis1 / 2: + if abs(x) > length_axis1 / 2: continue - if abs(radius * np.cos(ii * phi)) > length_axis2 / 2: + if abs(y) > length_axis2 / 2: continue - x = radius * np.sin(ii * phi) - y = radius * np.cos(ii * phi) positions.append([x + cenx, y + ceny, z_pos]) - left_lower_corner = [min(m1_start, m1_stop) + cenx, min(m2_start, m2_stop) + ceny, z_pos] - right_upper_corner = [max(m1_start, m1_stop) + cenx, max(m2_start, m2_stop) + ceny, z_pos] + left_lower_corner = [ + min(m1_start, m1_stop) + cenx, + min(m2_start, m2_stop) + ceny, + z_pos, + ] + right_upper_corner = [ + max(m1_start, m1_stop) + cenx, + max(m2_start, m2_stop) + ceny, + z_pos, + ] positions.append(left_lower_corner) positions.append(right_upper_corner) return np.array(positions) diff --git a/csaxs_bec/scans/scans_v4/cont_grid.py b/csaxs_bec/scans/scans_v4/cont_grid.py index f9c7f32c..e5566d03 100644 --- a/csaxs_bec/scans/scans_v4/cont_grid.py +++ b/csaxs_bec/scans/scans_v4/cont_grid.py @@ -44,6 +44,7 @@ class ContinuousMotorParameter(TypedDict): acc_time: float | None premove_distance: float | None shutter_open_delay: float | None + move_start_latency: float | None num_lines: int | None @@ -270,7 +271,15 @@ class ContGrid(ScanBase): self.fast_axis.velocity.set(self._cont_motor_params["target_velocity"]).wait(timeout=5) self.fast_axis.acceleration.set(self._cont_motor_params["acc_time"]).wait(timeout=5) move_status = self.actions.set(motors, positions, wait=False) - time.sleep(self._cont_motor_params["acc_time"]) + # Wait for the move to reach the motor, and then for the stage to get up to + # speed. Only the second of those is acc_time; the first is the latency of + # the scan server -> device server -> EPICS -> controller path, which does + # not shrink with the scan velocity while acc_time does. Without it the + # burst starts before the stage and the first points of every line pile up + # at the line start. See DEFAULT_MOVE_START_LATENCY for the measurement. + time.sleep( + self._cont_motor_params["move_start_latency"] + self._cont_motor_params["acc_time"] + ) self.ddg1.trigger_shot.put(1) while not move_status.done: self.actions.read_monitored_devices(wait=True) @@ -350,6 +359,7 @@ class ContGrid(ScanBase): def _fetch_device_params(self): self._cont_motor_params["shutter_open_delay"] = self.ddg1.get_shutter_to_open_delay() + self._cont_motor_params["move_start_latency"] = self.ddg1.get_move_start_latency() self._cont_motor_params["original_acceleration"] = self.fast_axis.acceleration.get() self._cont_motor_params["original_velocity"] = self.fast_axis.velocity.get() self._cont_motor_params["base_velocity"] = self.fast_axis.base_velocity.get() diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index d4bb2cbb..00000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/custom.css b/docs/_static/custom.css deleted file mode 100644 index 047e2784..00000000 --- a/docs/_static/custom.css +++ /dev/null @@ -1,170 +0,0 @@ -/* Template Inherited from SciPy and modified for PyQtGraph purposes - https://github.com/scipy/scipy/blob/9ae8fd0f4341d7d8785777d460cca4f7b6a93edd/doc/source/_static/scipy.css - SPDX-License-Identifier: BSD-3-Clause */ - -/* Remove parenthesis around module using fictive font and add them back. - This is needed for better wrapping in the sidebar. */ - .bd-sidebar .nav li > a > code { - white-space: nowrap; - } - - .bd-sidebar .nav li > a > code:before { - content:'('; - } - - .bd-sidebar .nav li > a > code:after { - content:')'; - } - - .bd-sidebar .nav li > a { - font-family: "no-parens", sans-serif; - } - - /* Retrieved from https://codepen.io/jonneal/pen/bXLEdB (MIT) - It replaces (, ) with a zero-width font. This version is lighter than - the original font from Adobe. - */ - @font-face { - font-family: no-parens; - src: url("data:application/x-font-woff;base64,d09GRk9UVE8AABuoAAoAAAAASrAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABDRkYgAAANJAAADlwAABk8NN4INERTSUcAABugAAAACAAAAAgAAAABT1MvMgAAAVAAAABRAAAAYABfsZtjbWFwAAAEQAAACM0AABnoJENu0WhlYWQAAAD0AAAAMwAAADYFl9tDaGhlYQAAASgAAAAeAAAAJAdaA+9obXR4AAAbgAAAAB8AABAGA+gAfG1heHAAAAFIAAAABgAAAAYIAVAAbmFtZQAAAaQAAAKbAAAF6yBNB5Jwb3N0AAANEAAAABMAAAAg/7gAMnjaY2BkYGBg5G6tPXx8azy/zVcGZuYXQBGGiz6un+F0zf8O5hzmAiCXmYEJJAoAkoQNcAB42mNgZGBgLvjfASRfMNQw1DDnMABFUAATAHAaBFEAAAAAUAAIAQAAeNpjYGZ+wTiBgZWBgamLKYKBgcEbQjPGMRgx3GFAAt//r/v/+/7///wPGOxBfEcXJ38GBwaG//+ZC/53MDAwFzBUJOgz/kfSosDAAAAMpBWaAAAAeNqdU9tu00AQPU6TcqmoRIV46YvFE5Vgm7ZOVDVPSS8iIkqquBTxhJzEuSiOHWwnwH8g/oHfgW9A/AZnx5smQZWg2MrumZ0z47MzEwCP8R0W9GNhS1b95HCPVoY3sIsdg/MrnAJO8NLgTTzEgEwr/4DWF3ww2MJTq2BwDtvWrsEbKFt7BudXOAWk1nuDN/HE+mHwfTjWL4O34OQWeR7lvuZaBm/Dyf+s9qKOb9cCLxy3/cEs8OIDVXRKlepZrVURp/hot2rn136cjKLQziiXrgHDKO1G4Vxb6viwMvHGfpT2VTDqHKqSKh85xfIyE04RYYrPiDFiCYZIYeMbf4co4gBHeHGDS0RV9MjvwCd2GZWQ72PC3UYdIbr0xsynV098PXqeS96U5yfY5/tRXkXGIpuSyAl9e8SrX6khIC/EGG3aA8zEjqlHUZVDVRXyz8hrCVpELuMyf4sn57imJ6baEVkhs69mueSN1k+GZKWiLMT8xqdwzIpUqNZjdl84fZ4GzNqhRzFWoczaOWSXb9X0P3X89xqmzDjlyT6uGDWSrBdyi1S+F1FvymhdR60gY2j9XdohraxvM+KeVMwmf2jU1tHg3pIvhGuZG2sZ9OTcVm/9s++krCd7KjPaoarFXGU5PVmfsaauVM8l1nNTFa2u6HhLdIVXVP2Gu7arnKc21ybtOifDlTu1uZ5yb3Ji6uLROPNdyPw38Y77a3o0R+f2qSqrTizWJ1ZGq09EeySnI/ZlKhXWypXc1Zcb3r2uNmsUrfUkkZguWX1h2mbO9L/F45r1YioKJ1LLRUcSU7+e6f9E7qInbukfEM0lNuSpzmpzviLmjmVGMk26c5miv3VV/THJCRXrzk55ltCrtQXc9R0H9OvKN34D31P2fwB42i3YLfAsS2GG8X9Pf3dP97QjqOBAUAUOHDhwxAUHLnHgwIEDBw4cOHDgEgeOuIsjLnHgAMU1tw7PnvNs1fT7zlfV7q9rd2bn7e0tv729RZYvsySWb76Ft9fr82wN77fHt/F+e3m73+8J74/8zPsxvdbqu3fvXjsYg2e/P/LTP33f367PfMj67sPZjXjsh/iU/V+If7W/Tvms/XPEF+xfJL5kf73lr9i/SnzN/nXiG/Z/I/7d/k3iW/ZvE/9h/0/iO/bvEt+zf5/4gf2HxI/sPyZ+Yn99xJ/Zf078wv5L4lf2XxO/sf+W+C/7fxO/s/+e+IP9f4iP7H8k/mT/f+LP9r8Qf7X/jfiH/WPik48+9E/Y8e4Tpvjv72cl6B/wD/oH/IP+Af+gf8A/6B/wD/oH/IP+Af+gf8A/6B/wD/oH/IP+Af+gf8A/6B/wD/oH/IP+Af+gf8A/6B/wD/oH/IP+Af+gf8A/6B/wD/oH/IP+Af+gf8A/6B/wD/oH/IP+4X8Z/8/OXATnIjAXwbkIkAfnIjAX4eVPv15fA/0v/C/9L/wv/S/8L/1fX5lL/wv/S/8L/0v/C/9L/wv/S/8L/0v/C/9L/wv/S/8L/0v/C/9L/wv/S/8L/0v/C/9L/wv/S/8L/0v/C/9L/wv/S/8L/0v/C/9L/wv/S/8L/0v/C/9L/wv/S/8L/0v/C/9L/wv/S/8L/0v/C/9L/9cvXNQ/4h/1j/hH/SP+Uf+If9Q/4h/1j/hH/SP+Uf+If9Q/4h/1j/hH/SP+Uf+If9Q/4h/1j/hH/SP+Uf+If9Q/4h/1j/hH/SP+Uf+If9Q/4h/1j/hH/SP+Uf+If9Q/4h/1j/hH/SP+Uf+If9Q/4h/1j/hH/SP+Uf/XlSXpn/BP+if8k/4J/6R/wj/pn/BP+if8k/4J/6R/wj/pn/BP+if8k/4J/6R/wj/pn/BP+if8k/4J/6R/wj/pn/BP+if8k/4J/6R/wj/pn/BP+if8k/4J/6R/wj/pn/BP+if8k/4J/6R/wj/pn/BP+if8k/4J/6T/6yqf9c/4Z/0z/ln/jH/WP+Of9c/4Z/0z/ln/jH/WP+Of9c/4Z/0z/ln/jH/WP+Of9c/4Z/0z/ln/jH/WP+Of9c/4Z/0z/ln/jH/WP+Of9c/4Z/0z/ln/jH/WP+Of9c/4Z/0z/ln/jH/WP+Of9c/4Z/0z/ln/jH/WvzAW/Qv+Rf+Cf9G/4F/0L/gX/Qv+Rf+Cf9G/4F/0L/gX/Qv+Rf+Cf9G/4F/0L/gX/Qv+Rf+Cf9G/4F/0L/gX/Qv+Rf+Cf9G/4F/0L/gX/Qv+Rf+Cf9G/4F/0L/gX/Qv+Rf+Cf9G/4F/0L/gX/Qv+Rf+Cf9G/4F/0r6/bT/0r/lX/in/Vv+Jf9a/4V/0r/lX/in/Vv+Jf9a/4V/0r/lX/in/Vv+Jf9a/4V/0r/lX/in/Vv+Jf9a/4V/0r/lX/in/Vv+Jf9a/4V/0r/lX/in/Vv+Jf9a/4V/0r/lX/in/Vv+Jf9a/4V/0r/lX/in/Vv378uuX/4P+65W/6N1aa/g3/pn/Dv+nf8G/6N/yb/g3/pn/Dv+nf8G/6N/yb/g3/pn/Dv+nf8G/6N/yb/g3/pn/Dv+nf8G/6N/yb/g3/pn/Dv+nf8G/6N/yb/g3/pn/Dv+nf8G/6N/yb/g3/pn/Dv+nf8G/6N/yb/g3/pn/Dv+nfGbv+Hf+uf8e/69/x7/p3/Lv+Hf+uf8e/69/x7/p3/Lv+Hf+uf8e/69/x7/p3/Lv+Hf+uf8e/69/x7/p3/Lv+Hf+uf8e/69/x7/p3/Lv+Hf+uf8e/69/x7/p3/Lv+Hf+uf8e/69/x7/p3/Lv+Hf+uf8e/69/x7/q//kEP/Qf+Q/+B/9B/4D/0H/gP/Qf+Q/+B/9B/4D/0H/gP/Qf+Q/+B/9B/4D/0H/gP/Qf+Q/+B/9B/4D/0H/gP/Qf+Q/+B/9B/4D/0H/gP/Qf+Q/+B/9B/4D/0H/gP/Qf+Q/+B/9B/4D/0H/gP/Qf+Q/+B/9B/4D/0n4xT/4n/1H/iP/Wf+E/9J/5T/4n/1H/iP/Wf+E/9J/5T/4n/1H/iP/Wf+E/9J/5T/4n/1H/iP/Wf+E/9J/5T/4n/1H/iP/Wf+E/9J/5T/4n/1H/iP/Wf+E/9J/5T/4n/1H/iP/Wf+E/9J/5T/4n/1H/iP/Wf+E/9X8+Dbv1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9b/xv/W/8b/1v/G/9F+PSf+G/9F/4L/0X/kv/hf/Sf+G/9F/4L/0X/kv/hf/Sf+G/9F/4L/0X/kv/hf/Sf+G/9F/4L/0X/kv/hf/Sf+G/9F/4L/0X/kv/hf/Sf+G/9F/4L/0X/kv/hf/Sf+G/9F/4L/0X/kv/hf/Sf+G/9F/4L/0X/kv/zbj13/hv/Tf+W/+N/9Z/47/13/hv/Tf+W/+N/9Z/47/13/hv/Tf+W/+N/9Z/47/13/hv/Tf+W/+N/9Z/47/13/hv/Tf+W/+N/9Z/47/13/hv/Tf+W/+N/9Z/47/13/hv/Tf+W/+N/9Z/47/13/hv/Tf+W/+N/9b/eT1y1v/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/B/9H/wf/R/8H/0f/5+PWY/4P/6zH/0f/gf/Q/7Dj6H/yP/gf/o//B/+h/8D/6H/yP/gf/o//B/+h/8D/6H/yP/gf/o//B/+h/8D/6H/yP/gf/o//B/+h/8D/6H/yP/gf/o//B/+h/8D/6H/yP/gf/o//B/+h/8D/6H/yP/gf/o//B/+h/8D/6H/zPB/9/AsqUaXgAAAB42mNgZgCD/1sZjBiwAAAswgHqAHja7ZhVc5BNkIWn/QWCEzRAcHd3d3eX4J4Awd0luLu7e3B3d3d3h4RgC99e7I9YnoupOjXdXaempqamGxyjA4AoxVoENmtZvENAp/Z/ZdbwROF+IT5JwhNDeBIM+e4T4SJYkiTkJj5J/TzwSR5WK3pYs5hh9X1S+SVI6pPSCYBGqx0Q9F+Zci1adgpuG9yrRGBQry5tW7cJ9s+eNVuOjH/XXP7/RfjX6NU1uGXHrv7lOjUP7BIU2CUguGUL/7RtgoOD8mfJ0qNHj8wBf8MyNw/smCVd5v9N+c/c/9nMlD1rznzO/XFvv8mBc84DD/5IV8FVdJVcZVfFVXXVXHVXw9V0tVxtV8fVdfVcfdfANXSNXGPXxDV1Aa6Za+5auJaulWvt2ri2rp1r7zq4jq6TC3RBrrPr4rq6YNfNdXc9XE/Xy/V2fVxf18/1dwPcQDfIDXZD3FA3zA13I9xIN8qNdiFujBvrxrnxboKb6Ca5yW6Km+qmueluhpvpZrnZbo6b6+a5+W6BW+gWucVuiVvqlrnlboVb6Va51W6NW+vWufVug9voNrnNbovb6ra5ULfd7XA73S632+1xe90+t98dcAfdIXfYHXFH3TF33J1wJ90pd9qdcWfdOXfeXXAX3SV32V1xV901d93dcDfdLXfb3XF33T133z1wD90j99g9cU/dM/fcvXAv3Sv32r1xb9079959cB/dJ/fZfXFfXZgLd99chPvufrif7pf7DX+vCgIBg4CC/Tn/SBAZooAPRIVoEB1iQEyIBbEhDvhCXIgH8SEBJIRE4AeJIQkkBX9IBskhBaSEVJAa0kBaSAfpIQNkhEyQGbJAVsgG2SEH5IRckBvyQF7IB/mhABSEQlAYikBRKAbFoQSUhFJQGspAWSgH5aECVIRKUBmqQFWoBtWhBtSEWlAb6kBdqAf1oQE0hEbQGJpAUwiAZtAcWkBLaAWtoQ20hXbQHjpAR+gEgRAEnaELdIVg6AbdoQf0hF7QG/pAX+gH/WEADIRBMBiGwFAYBsNhBIyEUTAaQmAMjIVxMB4mwESYBJNhCkyFaTAdZsBMmAWzYQ7MhXkwHxbAQlgEi2EJLIVlsBxWwEpYBathDayFdbAeNsBG2ASbYQtshW0QCtthB+yEXbAb9sBe2Af74QAchENwGI7AUTgGx+EEnIRTcBrOwFk4B+fhAlyES3AZrsBVuAbX4QbchFtwG+7AXbgH9+EBPIRH8BiewFN4Bs/hBbyEV/Aa3sBbeAfv4QN8hE/wGb7AVwiDcPgGEfAdfsBP+AW/0SEgIiGjoKKhh5EwMkZBH4yK0TA6xsCYGAtjYxz0xbgYD+NjAkyIidAPE2MSTIr+mAyTYwpMiakwNabBtJgO02MGzIiZMDNmwayYDbNjDsyJuTA35sG8mA/zYwEsiIWwMBbBolgMi2MJLImlsDSWwbJYDstjBayIlbAyVsGqWA2rYw2sibWwNtbBulgP62MDbIiNsDE2waYYgM2wObbAltgKW2MbbIvtsD12wI7YCQMxCDtjF+yKwdgNu2MP7Im9sDf2wb7YD/vjAByIg3AwDsGhOAyH4wgciaNwNIbgGByL43A8TsCJOAkn4xScitNwOs7AmTgLZ+McnIvzcD4uwIW4CBfjElyKy3A5rsCVuApX4xpci+twPW7AjbgJN+MW3IrbMBS34w7cibtwN+7BvbgP9+MBPIiH8DAewaN4DI/jCTyJp/A0nsGzeA7P4wW8iJfwMl7Bq3gNr+MNvIm38Dbewbt4D+/jA3yIj/AxPsGn+Ayf4wt8ia/wNb7Bt/gO3+MH/Iif8DN+wa8YhuH4DSPwO/7An/gL/zy7BIRExCSkZORRJIpMUciHolI0ik4xKCbFotgUh3wpLsWj+JSAElIi8qPElISSkj8lo+SUglJSKkpNaSgtpaP0lIEyUibKTFkoK2Wj7JSDclIuyk15KC/lo/xUgApSISpMRagoFaPiVIJKUikqTWWoLJWj8lSBKlIlqkxVqCpVo+pUg2pSLapNdagu1aP61IAaUiNqTE2oKQVQM2pOLagltaLW1IbaUjtqTx2oI3WiQAqiztSFulIwdaPu1IN6Ui/qTX2oL/Wj/jSABtIgGkxDaCgNo+E0gkbSKBpNITSGxtI4Gk8TaCJNosk0habSNJpOM2gmzaLZNIfm0jyaTwtoIS2ixbSEltIyWk4raCWtotW0htbSOlpPG2gjbaLNtIW20jYKpe20g3bSLtpNe2gv7aP9dIAO0iE6TEfoKB2j43SCTtIpOk1n6Cydo/N0gS7SJbpMV+gqXaPrdINu0i26TXfoLt2j+/SAHtIjekxP6Ck9o+f0gl7SK3pNb+gtvaP39IE+0if6TF/oK4VROH2jCPpOP+gn/aLf7BgYmZhZWNnY40gcmaOwD0flaBydY3BMjsWxOQ77clyOx/E5ASfkROzHiTkJJ2V/TsbJOQWn5FScmtNwWk7H6TkDZ+RMnJmzcFbOxtk5B+fkXJyb83Bezsf5uQAX5EJcmItwUS7GxbkEl+RSXJrLcFkux+W5AlfkSlyZq3BVrsbVuQbX5Fpcm+twXa7H9bkBN+RG3JibcFMO4GbcnFtwS27FrbkNt+V23J47cEfuxIEcxJ25C3flYO7G3bkH9+Re3Jv7cF/ux/15AA/kQTyYh/BQHsbDeQSP5FE8mkN4DI/lcTyeJ/BEnsSTeQpP5Wk8nWfwTJ7Fs3kOz+V5PJ8X8EJexIt5CS/lZbycV/BKXsWreQ2v5XW8njfwRt7Em3kLb+VtHMrbeQfv5F28m/fwXt7H+/kAH+RDfJiP8FE+xsf5BJ/kU3yaz/BZPsfn+QJf5Et8ma/wVb7G1/kG3+RbfJvv8F2+x/f5AT/kR/yYn/BTfsbP+QW/5Ff8mt/wW37H7/kDf+RP/Jm/8FcO43D+xhH8nX/wT/7Fv+XPt09QSFhEVEw8iSSRJYr4SFSJJtElhsSUWBJb4oivxJV4El8SSEJJJH6SWJJIUvGXZJJcUkhKSSWpJY2klXSSXjJIRskkmSWLZJVskl1ySE7JJbklj+SVfJJfCkhBKSSFpYgUlWJSXEpISSklpaWMlJVyUl4qSEWpJJWlilSValJdakhNqSW1pY7UlXpSXxpIQ2kkjaWJNJUAaSbNpYW0lFbSWtpIW2kn7aWDdJROEihB0lm6SFcJlm7SXXpIT+klvaWP9JV+0l8GyEAZJINliAyVYTJcRshIGSWjJUTGyFgZJ+NlgkyUSTJZpshUmSbTZYbMlFkyW+bIXJkn82WBLJRFsliWyFJZJstlhayUVbJa1shaWSfrZYNslE2yWbbIVtkmobJddshO2SW7ZY/slX2yXw7IQTkkh+WIHJVjclxOyEk5JafljJyVc3JeLshFuSSX5YpclWtyXW7ITbklt+WO3JV7cl8eyEN5JI/liTyVZ/JcXshLeSWv5Y28lXfyXj7IR/kkn+WLfJUwCZdvEiHf5Yf8lF/yW52CopKyiqqaehpJI2sU9dGoGk2jawyNqbE0tsZRX42r8TS+JtCEmkj9NLEm0aTqr8k0uabQlJpKU2saTavpNL1m0IyaSTNrFs2q2TS75tCcmktzax7Nq/k0vxbQglpIC2sRLarFtLiW0JJaSktrGS2r5bS8VtCKWkkraxWtqtW0utbQmlpLa2sdrav1tL420IbaSBtrE22qAdpMm2sLbamttLW20bbaTttrB+2onTRQg7SzdtGuGqzdtLv20J7aS3trH+2r/bS/DtCBOkgH6xAdqsN0uI7QkTpKR2uIjtGxOk7H6wSdqJN0sk7RqTpNp+sMnamzdLbO0bk6T+frAl2oi3SxLtGlukyX6wpdqat0ta7RtbpO1+sG3aibdLNu0a26TUN1u+7QnbpLd+se3av7dL8e0IN6SA/rET2qx/S4ntCTekpP6xk9q+f0vF7Qi3pJL+sVvarX9Lre0Jt6S2/rHb2r9/S+PtCH+kgf6xN9qs/0ub7Ql/pKX+sbfavv9L1+0I/6ST/rF/2qYRqu3zRCv+sP/am/9Lc5A0MjYxNTM/MskkW2KOZjUS2aRbcYFtNiWWyLY74W1+JZfEtgCS2R+VliS2JJzd+SWXJLYSktlaW2NJbW0ll6y2AZLZNltiyW1bJZdsthOS2X5bY8ltfyWX4rYAWtkBW2IlbUillxK2ElrZSVtjJW1spZeatgFa2SVbYqVtWqWXWrYTWtltW2OlbX6ll9a2ANrZE1tibW1AKsmTW3FtbSWllra2NtrZ21tw7W0TpZoAVZZ+tiXS3Yull362E9rZf1tj7W1/pZfxtgA22QDbYhNtSG2XAbYSNtlI22EBtjY22cjbcJNtEm2WSbYlNtmk23GTbTZtlsm2NzbZ7NtwW20BbZYltiS22ZLbcVttJW2WpbY2ttna23DbbRNtlm22JbbZuF2nbbYTttl+22PbbX9tl+O2AH7ZAdtiN21I7ZcTthJ+2UnbYzdtbO2Xm7YBftkl22K3bVrtl1u2E37Zbdtjt21+7ZfXtgD+2RPbYn9tSe2XN7YS/tlb22N/bW3tl7+2Af7ZN9ti/21cIs3L5ZhH23H/bTftlv72/LjR557ImnnnmeF8mL7EXxfLyoXjQvuhfDi+nF8mJ7cTxfL64Xz4vvJfASeok8Py+xl8RL6vl7ybzkXgovpZfKS+2l8dJ66bz0XgYvo5fJy+xl8bJ62bzsXg4vp5fLy+3l8fJ6+bz8XgGvoFfIK+wV8Yp6xbziXgmvpFfKK+2V8cp65bzyXgX/7z6hESlDISxG6LeMoRQWI4J9f/X9NjSir/2s+yuN77eLFnbkRw5ZtsH3+5HwPBL+VZc18/150f6oHBLUyvfPbh758VWj/eMf//jHP/7xj/9//B1wRw5P6pN6ll+CTLG+jwvxk9IhuifynigRz3z/B+I69cx42u3BAQ0AAAgDoG/WNvBjGERgmg0AAADwwAGHXgFoAAAAAAEAAAAA"); - unicode-range: U+0028, U+0029; - } - - /* Colors from: - - Wong, B. Points of view: Color blindness. - Nat Methods 8, 441 (2011). https://doi.org/10.1038/nmeth.1618 - */ - - /* If the active version has the name "dev", style it orange */ - #version_switcher_button[data-active-version-name*="dev"] { - background-color: #E69F00; - border-color: #E69F00; - color: white; - } - - /* green for `stable` */ - #version_switcher_button[data-active-version-name*="stable"] { - background-color: #009E73; - border-color: #009E73; - color: white; - } - - /* red for `old` */ - #version_switcher_button:not([data-active-version-name*="stable"]):not([data-active-version-name*="dev"]):not([data-active-version-name*="pull"]) { - background-color: #980F0F; - border-color: #980F0F; - color: white; - } - - /* Main index page overview cards */ - - .index-card .sd-card { - background: #fff; - border-radius: 0; - padding: 30px 10px 20px 10px; - margin: 10px 0px; - } - - .index-card .sd-card-header { - text-align: center; - } - - .index-card .sd-card-header .sd-card-text { - margin: 0px; - } - - .index-card .sd-card-img-top { - height: 52px; - width: 52px; - margin-left: auto; - margin-right: auto; - } - - .index-card .sd-card-header { - border: none; - background-color:white; - color: #150458 !important; - font-size: var(--pst-font-size-h5); - font-weight: bold; - padding: 2.5rem 0rem 0.5rem 0rem; - border-bottom: none !important; - } - - .index-card .sd-card-footer { - border: none; - background-color:white; - border-top: none !important; - } - - .index-card .sd-card-footer .sd-card-text{ - max-width: 220px; - margin-left: auto; - margin-right: auto; - } - - .custom-button { - background-color:#DCDCDC; - border: none; - color: #484848; - text-align: center; - text-decoration: none; - display: inline-block; - font-size: 0.9rem; - border-radius: 0.5rem; - max-width: 120px; - padding: 0.5rem 0rem; - } - - .custom-button a { - color: #484848; - } - - .custom-button p { - margin-top: 0; - margin-bottom: 0rem; - color: #484848; - } - - /* Dark theme tweaking - - Matplotlib images are in png and inverted while other output - types are assumed to be normal images. - - */ - html[data-theme=dark] img[src*='.svg']:not(.only-dark):not(.dark-light) { - filter: brightness(0.8) invert(0.82) contrast(1.2); - background: unset - } - - html[data-theme=dark] .MathJax_SVG * { - fill: var(--pst-color-text-base); - } - - /* Main index page overview cards */ - - html[data-theme=dark] .index-card .sd-card { - background-color:var(--pst-color-background); - border: none - } - - html[data-theme=dark] .sd-shadow-sm { - box-shadow: 0 .1rem 0.5rem rgba(250, 250, 250, .2) !important - } - - html[data-theme=dark] .index-card .sd-card-header { - background-color:var(--pst-color-background); - color: #150458 !important; - } - - html[data-theme=dark] .index-card .sd-card-footer { - background-color:var(--pst-color-background); - } - - /* - Hide TypeAlias Classes - */ - - dt#ColorMapSpecifier { - visibility: hidden; - } \ No newline at end of file diff --git a/docs/_templates/custom-class-template.rst b/docs/_templates/custom-class-template.rst deleted file mode 100644 index d64b80d5..00000000 --- a/docs/_templates/custom-class-template.rst +++ /dev/null @@ -1,34 +0,0 @@ -{{ fullname | escape | underline}} - -.. currentmodule:: {{ module }} - -.. autoclass:: {{ objname }} - :members: - :show-inheritance: - :inherited-members: - :special-members: __call__, __add__, __mul__ - - {% block methods %} - {% if methods %} - .. rubric:: {{ _('Methods') }} - - .. autosummary:: - :nosignatures: - {% for item in methods %} - {%- if not item.startswith('_') %} - ~{{ name }}.{{ item }} - {%- endif -%} - {%- endfor %} - {% endif %} - {% endblock %} - - {% block attributes %} - {% if attributes %} - .. rubric:: {{ _('Attributes') }} - - .. autosummary:: - {% for item in attributes %} - ~{{ name }}.{{ item }} - {%- endfor %} - {% endif %} - {% endblock %} \ No newline at end of file diff --git a/docs/_templates/custom-module-template.rst b/docs/_templates/custom-module-template.rst deleted file mode 100644 index dd90e32e..00000000 --- a/docs/_templates/custom-module-template.rst +++ /dev/null @@ -1,66 +0,0 @@ -{{ fullname | escape | underline}} - -.. automodule:: {{ fullname }} - - {% block attributes %} - {% if attributes %} - .. rubric:: Module attributes - - .. autosummary:: - :toctree: - {% for item in attributes %} - {{ item }} - {%- endfor %} - {% endif %} - {% endblock %} - - {% block functions %} - {% if functions %} - .. rubric:: {{ _('Functions') }} - - .. autosummary:: - :toctree: - :nosignatures: - {% for item in functions %} - {{ item }} - {%- endfor %} - {% endif %} - {% endblock %} - - {% block classes %} - {% if classes %} - .. rubric:: {{ _('Classes') }} - - .. autosummary:: - :toctree: - :template: custom-class-template.rst - :nosignatures: - {% for item in classes %} - {{ item }} - {%- endfor %} - {% endif %} - {% endblock %} - - {% block exceptions %} - {% if exceptions %} - .. rubric:: {{ _('Exceptions') }} - - .. autosummary:: - :toctree: - {% for item in exceptions %} - {{ item }} - {%- endfor %} - {% endif %} - {% endblock %} - -{% block modules %} -{% if modules %} -.. autosummary:: - :toctree: - :template: custom-module-template.rst - :recursive: -{% for item in modules %} - {{ item }} -{%- endfor %} -{% endif %} -{% endblock %} \ No newline at end of file diff --git a/docs/api_reference/api_reference.md b/docs/api_reference/api_reference.md deleted file mode 100644 index b8d36650..00000000 --- a/docs/api_reference/api_reference.md +++ /dev/null @@ -1,12 +0,0 @@ -(api_reference)= -# API Reference - -```{eval-rst} -.. autosummary:: - :toctree: _autosummary - :template: custom-module-template.rst - :recursive: - - csaxs_bec - -``` \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index bdc4cb93..00000000 --- a/docs/conf.py +++ /dev/null @@ -1,67 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# For the full list of built-in configuration values, see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Project information ----------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information - - -project = "SLS cSAXS" -copyright = "2024, Paul Scherrer Institute" -author = "Paul Scherrer Institute" - -# -- General configuration --------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration - - -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.autosummary", - # "sphinx.ext.coverage", - "sphinx.ext.viewcode", - "sphinx.ext.napoleon", - "sphinx_toolbox.collapse", - "sphinx_copybutton", - "myst_parser", - "sphinx_design", - "sphinx_inline_tabs", -] - -myst_enable_extensions = [ - "amsmath", - "attrs_inline", - "colon_fence", - "deflist", - "dollarmath", - "fieldlist", - "html_admonition", - "html_image", - "replacements", - "smartquotes", - "strikethrough", - "substitution", - "tasklist", -] - -autosummary_generate = True # Turn on sphinx.ext.autosummary -add_module_names = False # Remove namespaces from class/method signatures -autodoc_inherit_docstrings = True # If no docstring, inherit from base class -set_type_checking_flag = True # Enable 'expensive' imports for sphinx_autodoc_typehints -autoclass_content = "both" # Include both class docstring and __init__ -autodoc_mock_imports = [] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] - -language = "Python" - -# -- Options for HTML output ------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output - -html_theme = "pydata_sphinx_theme" -html_static_path = ["_static"] -html_css_files = ["custom.css"] -# html_logo = "./assets/csaxs_icon.png" -html_title = "SLS cSAXS" diff --git a/docs/developer/developer.md b/docs/developer/developer.md index 664c3d38..4682bd8f 100644 --- a/docs/developer/developer.md +++ b/docs/developer/developer.md @@ -1,45 +1,16 @@ -(developer)= # Developer Information for developers and maintainers of the cSAXS beamline documentation and software. -```{toctree} ---- -maxdepth: 2 -hidden: true ---- +
-editing_docs -lamni_smear_architecture +- ![](../assets/index_contribute.svg){ width="32" height="32" .middle .card-icon } **[Editing the documentation](editing_docs.md)** + { .card-heading } -``` + Conventions for writing these Zensical docs and how changes go live on Read the Docs. +- ![](../assets/biotech.svg){ width="32" height="32" .middle .card-icon } **[LamNI smear aid architecture](lamni_smear_architecture.md)** + { .card-heading } -*** + Device/ipython-client/GUI widget interfaces for the smear rotation-center calibration aid, the min/max composite with opaque-pixel masking, and the `bw-generate-cli` regeneration pitfall. -````{grid} 2 -:gutter: 5 - -```{grid-item-card} -:link: developer.editing_docs -:link-type: ref -:img-top: /assets/index_contribute.svg -:text-align: center -:class-item: index-card - -## Editing the documentation - -Conventions for writing these MyST/Sphinx docs and how changes go live on Read the Docs. -``` - -```{grid-item-card} -:link: developer.lamni_smear_architecture -:link-type: ref -:text-align: center -:class-item: index-card - -## LamNI smear aid architecture - -Device/ipython-client/GUI widget interfaces for the smear rotation-center calibration aid, and the `bw-generate-cli` regeneration pitfall. -``` - -```` +
diff --git a/docs/developer/editing_docs.md b/docs/developer/editing_docs.md index 7b10a2c8..0b01f050 100644 --- a/docs/developer/editing_docs.md +++ b/docs/developer/editing_docs.md @@ -1,143 +1,165 @@ -(developer.editing_docs)= - # Editing the documentation -This documentation is built with [Sphinx](https://www.sphinx-doc.org) using the -[MyST](https://myst-parser.readthedocs.io) Markdown parser, and hosted on -[Read the Docs](https://sls-csaxs.readthedocs.io). The source lives in `docs/` of -the `csaxs_bec` repository. - -:::{important} -The pages use **MyST Markdown**, which is a superset of CommonMark — *not* -Gitea/GitHub-flavored Markdown. Most plain Markdown works as expected, but the -conventions below are specific to this build and need to be followed for links, -admonitions, and navigation to render correctly. -::: +This site is built with [Zensical](https://zensical.org/) and hosted on +[Read the Docs](https://sls-csaxs.readthedocs.io). Pages live in `docs/`; +`zensical.toml` at the repository root controls navigation and rendering. ## How a change goes live -There are two ways to propose an edit: +- **In the browser.** Open a page in Gitea and click **Edit** to propose a change + through a branch and pull request (or a personal fork if required). -- **In the browser (easiest, no git required).** Open the page in Gitea and click - **Edit**. Gitea commits your change to a new branch and opens a pull request for you; - if you do not have write access it first creates a personal fork automatically. -- **Locally.** Create a branch, edit the `.md` files under `docs/`, push, and open a - pull request. +- **Locally.** Create a branch, edit the Markdown pages, push, and open a pull request. -A documentation maintainer then reviews and merges the pull request into `main`. -Merging is restricted to a few people by design — opening the pull request is all that -is expected of an author. +A documentation maintainer reviews and merges the pull request into `main`. +The `.gitea/workflows/rtd_deploy.yml` action then triggers a Read the Docs build +of `latest`. The `.readthedocs.yaml` configuration installs the documentation +requirements, builds the site, and copies it to +Read the Docs' HTML output directory. -Once merged, the `.gitea/workflows/rtd_deploy.yml` action fires on the push to `main` -and triggers a Read the Docs build of the `latest` version; the site updates a minute -or two later. Nothing needs to be built or uploaded by hand. +## Preview locally + +On Linux or macOS, run these commands from the repository root: + +```bash +python -m venv .venv-docs +source .venv-docs/bin/activate +python -m pip install -e '.[docs]' +zensical serve +``` + +Open the local URL printed by Zensical. The preview reloads automatically after +edits; stop it with ++ctrl+c++. + +To generate the static site: + +```bash +zensical build --clean +``` + +Output is written to `site/`. + +The `docs` extra in `pyproject.toml` installs Zensical and PyMdown Extensions +alongside the repository's regular dependencies. Read the Docs uses the same editable installation command. +Do not commit the generated `site/` directory. The API reference is currently +omitted. ## Anatomy of a page -Every content page starts with a **cross-reference label** followed by a single -top-level heading: +Start with one H1 heading: -```md -(user.saxs.online_integration)= +```markdown # Setup online radial integration on Jungfraujoch ``` -- The label `(user.saxs.online_integration)=` is what other pages, the section landing - pages, and the `{ref}` role link to. **Do not delete or rename it** without - updating every reference, or links silently break. -- The `# Title` is the page's H1. Sphinx needs exactly one; section pages do not - inherit a title from the file name the way the old wiki did. +Headings receive native Zensical anchors automatically. Use these anchors for +section links, or link directly to the Markdown file when referring to a whole +page. Copy a heading's permalink in the preview to get its anchor. + +For an explicit, stable anchor, attach an ID directly to the heading: + +```markdown +## Online integration { #online-integration } +``` + +Reference it with `[Online integration](page.md#online-integration)`. Use heading +anchors rather than separate HTML `` tags. ## Conventions -### Code blocks — always tag the language +### Lists and tables -Use a language on every fenced block so syntax highlighting and the copy button work: +Leave a blank line before a list or table that follows a paragraph. Indent +paragraphs, nested lists, callouts, and code blocks inside a list item by four +spaces. These rules keep procedures numbered continuously and prevent tables +and list markers from appearing as plain text. -````md +### Code blocks + +Tag fenced code blocks with `python`, `bash`, or `text` so highlighting and +copy buttons work: + +````markdown ```bash module load Python/3.11.11 pip install "pyFAI[gui]" ``` ```` -Use ```` ```text ```` for plain output (e.g. error messages) and ```` ```python ```` -for Python. +### Callouts -### Call-outs — use admonitions, not bold "Note:" +Use an admonition with its contents indented by four spaces: -Instead of `**Note:** …`, use a [colon-fence admonition](https://myst-parser.readthedocs.io/en/latest/syntax/admonitions.html): - -```md -:::{note} -Jungfraujoch currently only accepts `uint32` TIFF mask files. -::: +```markdown +!!! note + Jungfraujoch currently only accepts `uint32` TIFF mask files. ``` -Available types include `note`, `tip`, `important`, `warning`, `caution`, `danger`. +Use `note`, `tip`, `warning`, or `danger`; a custom title follows the type: -### Links and cross-references - -- **To another page in this documentation**, link by its label, not by file path: - - ```md - See {ref}`the Jungfraujoch setup guide `. - ``` - -- **External links** use ordinary Markdown: `[pyFAI](https://pyfai.readthedocs.io)`. -- **Downloadable files** (PDFs, etc.) must use the `{download}` role — a plain link - to a non-image file is *not* copied into the build and will 404: - - ```md - - {download}`SAXS standards plate (PDF) ` - ``` - -- **Images** use the `{figure}` directive (or standard `![alt](file.png)`); place the - image file next to the page that uses it: - - ````md - ```{figure} omny_shuttle.png - The OMNY sample shuttle. - ``` - ```` - -### Adding a new page - -1. Create `docs/
/.md` with a label and H1 as above. -2. Add it to the parent section's `{toctree}` so it appears in the navigation, e.g. - in `docs/user/saxs/saxs.md`: - - ````md - ```{toctree} - --- - maxdepth: 2 - hidden: true - --- - - online_integration - - ``` - ```` - - List entries by file name **without** the `.md` extension. - -## Preview locally - -```bash -cd docs -pip install -r requirements.txt # plus `pip install .` from the repo root for the API reference -make html +```markdown +!!! danger "In an emergency" + Call 3333. ``` -Open `docs/_build/html/index.html` in a browser. +### Keyboard shortcuts and tabs -## Pitfalls +Use `++ctrl+c++` for ++ctrl+c++ and `++tab++` for ++tab++. Use content tabs for +alternative workflows, such as previewing or building the site: -- **Broken cross-references are warnings, not errors.** The build still succeeds and - publishes, but the link won't work. Check the Read the Docs build log (or the local - `make html` output) for `WARNING` lines after editing. -- **Everything here is public.** The site is world-readable. Avoid credentials, license - keys, personal logins, and anything not meant for a general audience. Internal - hostnames and `/sls/...` paths are fine if you intend them to be public. -- **Placeholders.** Bracketed values such as `[p-group]` or `[ra-c-110]` are meant to be - substituted by the reader; keep them consistent across a page. +````markdown +=== "Preview" + + ```bash + zensical serve + ``` + +=== "Build" + + ```bash + zensical build --clean + ``` +```` + +Keep required procedure steps and warnings outside tabs so readers see them +regardless of which alternative they select. + +### Links, downloads, and images + +Link to Markdown source paths relative to the current page. Zensical rewrites them +to the generated HTML URLs. For example, from this page: + +```markdown +[Online integration](../user/saxs/online_integration.md) +[Angle files](../user/ptychography/flomni.md#angles-from-a-file-tomo_type-4) +[Safety checklist (PDF)](../assets/safety-at-csaxs.pdf) +``` + +PDFs and other assets under `docs/` are copied automatically. Place images next to +the page that uses them and use ordinary Markdown: + +```markdown +![The OMNY sample shuttle](omny_shuttle.png) +``` + +Use `[label](https://example.org)` for external links. Put placeholder values such +as `` inside backticks so they are displayed as text. + +### Adding a page + +1. Create `docs/
/.md` with an H1. +2. Add the page to `project.nav` in `zensical.toml`. +3. Add a link from the relevant section overview where useful. +4. Build and check the resulting page and its links. + +The `docs/plans/` directory contains working implementation plans and is excluded +from the published site. The P-touch implementation plan is also excluded from publication. Navigation entries alone do not exclude other pages from publication. + +## Before opening a pull request + +- [ ] Check the build output. +- [ ] Click through changed links; a successful build alone does not prove every external link works. +- [ ] Confirm that callouts, images, tables, and downloads render correctly. + +The documentation is public. Keep credentials, license keys, and private personal +information out of pages and downloadable files. diff --git a/docs/developer/lamni_smear_architecture.md b/docs/developer/lamni_smear_architecture.md index 008b6788..2304a007 100644 --- a/docs/developer/lamni_smear_architecture.md +++ b/docs/developer/lamni_smear_architecture.md @@ -1,9 +1,7 @@ -(developer.lamni_smear_architecture)= - # LamNI smear rotation-center aid: architecture notes The experimental continuous-rotation "smear" calibration aid (see -{ref}`the user-facing description `) touches all +[the user-facing description](../user/ptychography/lamni.md)) touches all three layers of a typical BEC GUI feature — a **device**, an **ipython-client plugin**, and a **GUI widget** — running as three separate processes talking over BEC's Redis-backed message bus. It's a useful worked example for how @@ -27,15 +25,17 @@ ipython client (XrayEyeAlign, LamNI) ``` - **`IDSCamera`** (`csaxs_bec/devices/ids_cameras/ids_camera.py`, simulated - counterpart `csaxs_bec/devices/sim/sim_cameras.py`) runs in the device - server process and owns the actual camera hardware (or its simulation). + counterpart `csaxs_bec/devices/sim/sim_cameras.py`) runs in the device + server process and owns the actual camera hardware (or its simulation). + - **`XrayEyeAlign`** / **`LamNI`** (`csaxs_bec/bec_ipython_client/plugins/LamNI/`) - run in the operator's ipython client process and orchestrate the - calibration procedure — rotating the sample, grabbing frames, driving the - GUI. + run in the operator's ipython client process and orchestrate the + calibration procedure — rotating the sample, grabbing frames, driving the + GUI. + - **`XRayEye`** (`csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py`) is a - Qt widget running in its own GUI server process, displaying the live image - and collecting the operator's click. + Qt widget running in its own GUI server process, displaying the live image + and collecting the operator's click. Devices and widgets each expose a `USER_ACCESS` list of methods callable remotely; the ipython client reaches them as `dev.cam_xeye.(...)` and @@ -48,13 +48,14 @@ calls. `IDSCamera` exposes two `PreviewSignal` components: - **`image`** — the live-acquisition channel. A background thread - (`_live_mode_loop`) continuously grabs frames and `put()`s them here at - ~5 Hz while `live_mode_enabled` is `True`. Configured per-device with - `num_rotation_90`/`transpose` (e.g. `num_rotation_90=3` for `cam_xeye`), - applied automatically inside `PreviewSignal.put()`. + (`_live_mode_loop`) continuously grabs frames and `put()`s them here at + ~5 Hz while `live_mode_enabled` is `True`. Configured per-device with + `num_rotation_90`/`transpose` (e.g. `num_rotation_90=3` for `cam_xeye`), + applied automatically inside `PreviewSignal.put()`. + - **`smear_preview`** — a second, independent channel, added for this - feature, with **no** rotation/transpose configured. Nothing else writes to - it, so publishing here never races with the live thread. + feature, with **no** rotation/transpose configured. Nothing else writes to + it, so publishing here never races with the live thread. `PreviewSignal` (and every `BECMessageSignal` subclass) has `rpc_access` hardcoded off, so it is **not** reachable as a plain attribute from client @@ -64,16 +65,18 @@ method that does the `.put()` server-side, where the signal *is* a normal ophyd attribute: - `get_last_image()` — read side; returns `self.image.get().data`, i.e. - already display-oriented (rotation/transpose already applied by whichever - `.put()` call stored it). + already display-oriented (rotation/transpose already applied by whichever + `.put()` call stored it). + - `push_preview_image(data)` — write side for `image`. Since data built from - `get_last_image()` frames has already been transformed once, this method - undoes the transform first so `self.image.put()`'s own transform doesn't - apply it a second time (compensation is reverse-order: transpose first, - then rotate by `-num_rotation_90`). + `get_last_image()` frames has already been transformed once, this method + undoes the transform first so `self.image.put()`'s own transform doesn't + apply it a second time (compensation is reverse-order: transpose first, + then rotate by `-num_rotation_90`). + - `push_smear_preview(data)` — write side for `smear_preview`. No - compensation needed or applied, since that channel has no transform - configured. + compensation needed or applied, since that channel has no transform + configured. **Why two channels, not one:** the first working version pushed composites through `push_preview_image` (the `image` channel), which meant briefly @@ -118,41 +121,61 @@ lands after the sweep but before the click completes. stepping the motor: 1. `scans.mv(dev.lsamrot, target)` (the file's `mv()` wrapper, sibling of the - existing blocking `umv()`) issues the rotation **non-blocking** — it - returns a `ScanReport` immediately rather than waiting for the move to - finish. + existing blocking `umv()`) issues the rotation **non-blocking** — it + returns a `ScanReport` immediately rather than waiting for the move to + finish. + 2. A plain loop polls `report.status` (exactly what `ScanReport.wait()` does - internally) and, while it isn't `"COMPLETED"`, grabs a frame via - `get_last_image()` and folds it into a running `np.maximum` composite — - all single-threaded, since the rotation is progressing concurrently on - the server regardless of what the client does between polls. + internally) and, while it isn't `"COMPLETED"`, grabs a frame via + `get_last_image()` and folds it into a running composite — + all single-threaded, since the rotation is progressing concurrently on + the server regardless of what the client does between polls. The + composite is `np.minimum` for `projection="min"` (default; absorption + contrast, dark features on a bright background) or `np.maximum` for + `projection="max"` (bright features on a dark background). A + max-projection is useless for absorption: the bright background passes + every pixel at some point during the rotation and washes the dark + features out. Taking the minimum is equivalent to inverting the frames, + taking the maximum and inverting back, without needing the camera's + full-scale value. The accumulation lives in `SmearComposite` + (`x_ray_eye_align.py`). For `"min"` it also skips opaque pixels: in each + frame, pixels darker than `opaque_level` (default 0.25) times the frame's + 99th-percentile value count as opaque and are left out, so the sample + holder rotating through the view does not black out every pixel it ever + covers. It keeps the plain minimum alongside the masked one and falls + back to it for pixels that were opaque in every frame; `opaque_level=0` + is the plain minimum. Both values are validated up front (`ValueError` + before any motion) and stored as `projection`/`opaque_level` attributes + in the calibration HDF5. + 3. Every `display_update_interval_s`, the current composite is pushed to - `smear_preview` so the operator watches it grow; off-axis features trace - arcs as the sample turns, and the common center of curvature of those - arcs is the rotation axis. + `smear_preview` so the operator watches it grow; off-axis features trace + arcs as the sample turns, and the common center of curvature of those + arcs is the rotation axis. + 4. Once the move completes, a final push leaves the finished composite - displayed, the shutter is closed (unless `keep_shutter_open`), and the - operator submits a click — reusing the exact same `_collect_click()` / - `_compute_shift_to_fzp()` / `_apply_rotation_center_shift()` machinery and - cumulative-shift accounting as the production `..._extended()` path. + displayed, the shutter is closed (unless `keep_shutter_open`), and the + operator submits a click — reusing the exact same `_collect_click()` / + `_compute_shift_to_fzp()` / `_apply_rotation_center_shift()` machinery and + cumulative-shift accounting as the production `..._extended()` path. ## Pitfall: regenerate the RPC client stub after changing `USER_ACCESS` -:::{important} -Whenever you add or rename a method in a plugin widget's `USER_ACCESS` list -(e.g. `XRayEye.USER_ACCESS` in `x_ray_eye.py`), you must run: +!!! warning "Important" + Whenever you add or rename a method in a plugin widget's `USER_ACCESS` list + (e.g. `XRayEye.USER_ACCESS` in `x_ray_eye.py`), you must run: -```bash -bw-generate-cli --target csaxs_bec -``` + ```bash + bw-generate-cli --target csaxs_bec + ``` + + and commit the resulting changes to `csaxs_bec/bec_widgets/widgets/client.py` + (and `designer_plugins.py`). Skipping this step does **not** raise an error + at import time or widget-construction time — it fails much later, and + confusingly, as an `AttributeError` raised from deep inside + `bec_widgets`'s RPC plumbing, even after fully restarting both the ipython + client and the GUI server process. -and commit the resulting changes to `csaxs_bec/bec_widgets/widgets/client.py` -(and `designer_plugins.py`). Skipping this step does **not** raise an error -at import time or widget-construction time — it fails much later, and -confusingly, as an `AttributeError` raised from deep inside -`bec_widgets`'s RPC plumbing, even after fully restarting both the ipython -client and the GUI server process. -::: The reason: `csaxs_bec/bec_widgets/widgets/client.py` is a **checked-in generated file** containing one RPC stub class per plugin widget, mirroring diff --git a/docs/developer/panda_box_free_running_data_schema.md b/docs/developer/panda_box_free_running_data_schema.md new file mode 100644 index 00000000..01cbeed1 --- /dev/null +++ b/docs/developer/panda_box_free_running_data_schema.md @@ -0,0 +1,49 @@ +# PandABox free-running (`raw_stream_mode`) raw data schema + +> **Status: schema for the acquisition side only.** This document defines +> what raw data `omny_panda` writes to its HDF5 async dataset when +> `raw_stream_mode: true` (see `docs/developer/panda_box_free_running_setup.md` +> for the hardware/mode setup, confirmed against real PandA hardware +> 2026-09-15). Reconstructing the previous averaged/stdev-per-position view +> from this raw data (binning by the `gate_detector_active` transitions or +> timestamps) is a separate, not-yet-planned analysis task and is +> intentionally out of scope here. + +## Where this data lands + +`omny_panda` is configured with `readoutPriority: async` +(`csaxs_bec/device_configs/ptycho_flomni.yaml`). Its `data` signal is a +`DynamicSignal` (`ophyd_devices/devices/panda_box/panda_box.py`) that BEC's +async file writer appends to an HDF5 dataset per scan, under this device's +async data group. In `raw_stream_mode`, each write corresponds to one +coalesced flush (see the setup doc's mode-comparison table), not one raw +PandA network frame — the row-level data below is still preserved exactly, +just batched differently for publishing efficiency. + +## Per-row fields + +Each row corresponds to one PCAP capture tick (one raw sample at the +free-running capture rate — `CLOCK1`'s configured rate, started at 100 Hz for +initial testing on 2026-09-15, see the setup doc for the current rate): + +| Field (BEC signal name) | Source PandA block | Meaning | +|---|---|---| +| `cap_voltage_fzp_y`/`cap_voltage_fzp_x` etc. (raw analog/encoder value) | `FMC_IN.VAL1.Value` / `FMC_IN.VAL2.Value` / `INENC1`-`4.VAL.Value` | The instantaneous raw sample at this capture tick, replacing the Mode A aggregate (Mean/Min/Max). All six channels (both FMC analog inputs and all four encoders) are switched to raw capture together on this layout — not a subset. | +| `gate_detector_active` | `PCAP.BITS0.Value`, bit 0 (`TTLIN1.VAL`), masked in software — see setup doc | The detector-trigger signal, sampled at this same tick: `1` while a detector trigger is active, `0` otherwise. `PCAP.BITS0` is a shared 32-bit word; `PandaBoxOMNY` masks it down to bit 0 before this reaches the HDF5 file (see `_GATE_BIT_OFFSET` in `panda_box_omny.py`) — the raw word itself is not a clean 0/1. This is what a future reconstruction step would use to delimit which raw samples belong to which detector-gate window. | +| `frame_counter` | `COUNTER1.OUT.Value`, triggered off `TTLIN1.VAL`'s falling edge (i.e. increments once per *completed* detector exposure, not once per PCAP capture tick as originally guessed here) | Monotonically incrementing hardware counter. Doubles as the completion signal for `on_complete` (`PandaBoxOMNY._on_complete_free_running`, `panda_box_omny.py`) — waits for it to reach `num_points * frames_per_trigger`, since `*PCAP.CAPTURED?` free-runs continuously and never naturally stops while armed. Also the post-hoc data-completeness record: a gap-free, strictly increasing sequence means no detector exposures were missed between the PandA and the HDF5 file; a gap indicates lost data (e.g. from the Redis stream's `MAXLEN` trimming if the file writer fell behind — see the acquisition plan's data-path notes). Because it counts completed exposures rather than capture ticks, it verifies detector-frame completeness directly rather than raw-sample completeness. | +| `PCAP.TS_TRIG.Value` (existing) | `PCAP.TS_TRIG` | Absolute/relative timestamp already captured today; usable for time-alignment against other detectors' data during future reconstruction. | +| `PCAP.GATE_DURATION.Value` (existing, aliased as `pcap_gate_duration_value`) | `PCAP.GATE_DURATION` | Existing field, retained; less meaningful once the gate is held open continuously for the whole burst window rather than pulsed per point (see setup doc, Mode B gate/arm row) — re-evaluate whether this still needs to be captured now that real hardware testing has started. | + +All rows across a burst window share the same set of fields; there is no +per-point boundary marker in this raw stream other than what can be derived +from `gate_detector_active` transitions and/or absolute timestamps — no +explicit "scan point index" field is captured by the PandA itself. + +## What this does *not* include + +- No on-the-fly or post-scan computation of Mean/Min/Max/stdev from the raw + samples — the raw stream is the complete record; deriving the previous + per-point statistics from it is a separate future task. + +- No changes to how Mode A (legacy) data is written — this schema only + applies when `raw_stream_mode: true`. diff --git a/docs/developer/panda_box_free_running_setup.md b/docs/developer/panda_box_free_running_setup.md new file mode 100644 index 00000000..8b54b474 --- /dev/null +++ b/docs/developer/panda_box_free_running_setup.md @@ -0,0 +1,366 @@ +# PandABox (`omny_panda`) burst acquisition: two operation modes + +> **Status: Mode B confirmed working end-to-end against real PandA hardware +> (2026-09-15, omny-panda.psi.ch)** — correct per-channel raw values, masked +> `gate_detector_active`, and `frame_counter`-based completion all verified, +> then speed-tested up to 20 kHz with no reported issues (though see the +> caveat under "Saving / restoring layouts" — the saved `omny_freerun.ini` +> layout itself was captured at 10 kHz, not 20 kHz). `ptycho_flomni.yaml` +> currently has `omny_panda_continuous` (Mode B) active — note the PandA's +> Design was switched back to `OMNY` on 2026-09-15 solely to capture the +> `omny.ini` layout file below, so double-check which Design is actually +> loaded on the physical PandA before running a scan against either config. + +## Context + +flomni burst scans acquire `frames_per_trigger` sub-frames per scan point. +Historically, the PandABox (`omny_panda`, device class `PandaBoxOMNY` in +`csaxs_bec/devices/panda_box/panda_box_omny.py`) reports one PCAP-aggregated +statistic (Mean/Min/Max/Sum/Diff) per gate, discarding the time structure +within a gate. This document describes the two supported operation modes — +the existing per-gate aggregate mode, and a new continuous free-running raw +mode — and how the PandA hardware and `ptycho_flomni.yaml` device config must +be set up for each. + +Mode selection is a single software flag (`raw_stream_mode` in the device +config); it does **not** by itself reconfigure the PandA hardware. The PandA +layout must independently match the intended mode (see "Open question" at +the end) before that mode can be used. + +## Mode comparison + +| | Mode A: legacy (default) | Mode B: free-running (new) | +|---|---|---| +| Selected by | `raw_stream_mode` unset / `false` | `raw_stream_mode: true` | +| PCAP capture fields | `Mean`/`Min`/`Max`/`Sum`/`Diff` per gate | `Value` (raw instantaneous sample) + gate bit + frame counter | +| Gate/arm timing | Pulsed per point / sub-frame (rate = scan rate × `frames_per_trigger`) | PCAP held open continuously for the burst window, free-running at `CLOCK1`'s configured rate (started at **100 Hz** for initial testing on 2026-09-15; will be ramped up as testing progresses) | +| `_acquisition_group` (BEC async metadata) | `fly` / `monitored` / `burst`, chosen from `scan_type`/`frames_per_trigger` | `free_running` | +| Message publishing | One `.data.put()` per PandA network frame, unchanged | Coalesced client-side: flushed every `raw_stream_flush_row_count` rows or `raw_stream_flush_interval` seconds, whichever comes first (mitigates Redis/HDF5 message-rate risk — see the acquisition plan's data-path notes) | +| Completion check (`on_complete`) | Exact count: waits for `*PCAP.CAPTURED?` to reach `num_points * frames_per_trigger` | Exact count: waits for `COUNTER1.OUT?` (`frame_counter`) to reach `num_points * frames_per_trigger`. `*PCAP.CAPTURED?` can't be used here — `CLOCK1` drives `PCAP.gate`/`PCAP.trig` continuously (see "Continuous gate/arm" below), so the captured count never naturally stops increasing while armed; `COUNTER1` instead counts completed detector exposures directly (it's clocked off the detector-trigger's falling edge), so it reaches the expected total exactly when the last exposure finishes | +| Data completeness verification | Implicit in the exact-count completion check | Same mechanism as Mode A: `frame_counter` (`COUNTER1`) is both the completion signal and the post-hoc completeness record — a gap in an otherwise-monotonic sequence in the HDF5 data means dropped rows even though the expected total was reached | + +Both modes are implemented in the same `PandaBoxOMNY` class; `raw_stream_mode` +defaults to `false` so any device config that doesn't set it keeps today's +exact behavior unchanged. + +## Physical wiring (Mode B only) + +The detector-trigger signal is patched into **TTL input 1** (`TTLIN1`) on the +PandA front panel. That same `TTLIN1.VAL` signal feeds both downstream +consumers (see "Block configuration" below): it is the source counted by +`COUNTER1` (the frame counter) and the source captured into the gate bit. + +## Block configuration + +### Mode A (legacy) — reference baseline + +The blocks already configured and aliased today, for reference / so this +layout can be restored unambiguously (see `ptycho_flomni.yaml`): + +- `FMC_IN.VAL1`/`FMC_IN.VAL2` (capacitive sensor analog inputs) — `Min`, + `Max`, `Mean` capture fields. + +- `INENC1`-`INENC4.VAL` (position-capture encoder inputs) — `Min`, `Max`, + `Mean` capture fields. + +- `PCAP.GATE_DURATION` — `Value`. +- PCAP gate is pulsed once per point / sub-frame by the existing external + trigger electronics (unchanged by this work). + +### Mode B (new) — free-running raw capture + +- **Gate-bit input**: the detector-trigger signal, wired into `TTLIN1` (see + "Physical wiring" above). `TTLIN1.VAL` is captured into **`PCAP.BITS0`, + bit offset 0** — confirmed 2026-09-15 against the omny-panda hardware via: + + ```text + TTLIN1.VAL.CAPTURE_WORD? -> OK =PCAP.BITS0 + TTLIN1.VAL.OFFSET? -> OK =0 + ``` + + `PCAP.BITS0` is a shared 32-bit capture word — other captured `bit_out` + signals (several TTLIN/LVDSIN/encoder bits are also flagged for capture on + this layout) may occupy other bit positions of the same word, so the raw + `PCAP.BITS0.Value` is **not** itself a clean 0/1. `PandaBoxOMNY` masks it + down to bit 0 in `raw_stream_mode` (see `_GATE_BIT_OFFSET` in + `panda_box_omny.py`) before publishing it as `gate_detector_active` — treat + that masking as required, not optional, unless it's confirmed nothing else + shares `BITS0`. Re-run the query above (read-only, safe against live + hardware) if the PandA layout is ever rebuilt, since bit assignment is + fixed by the FPGA build and not guaranteed stable across rebuilds. + +- **Frame counter**: `COUNTER1`, triggered off `TTLIN1.VAL`'s **falling + edge** (see layout below) — i.e. it increments once per *completed* + detector exposure, not once per PCAP capture tick as originally guessed + here. Captured with `CAPTURE=Value`; this is both the data-completeness + signal and the completion-detection signal (`frame_counter` in + `signal_alias`, see below, and "Completion check" in the mode-comparison + table above) — once the last expected exposure's trigger pulse has fallen, + `COUNTER1.OUT` reaches `num_points * frames_per_trigger` exactly. + +- **Raw analog/position capture**: `FMC_IN.VAL1`, `FMC_IN.VAL2` and all four + `INENC1`-`4.VAL` are switched from aggregate capture to `CAPTURE=Value` + on this layout (confirmed 2026-09-15) — i.e. every analog/encoder channel + goes raw together, not a subset. This means the current PandA layout + cannot simultaneously produce Mode A's aggregate (Min/Max/Mean) data — see + "Open question" below. + +- **Continuous gate/arm**: `CLOCK1`, enabled by the constant `ONE`, drives + `PCAP`'s `gate` and `trig` inputs continuously (instead of the external + per-point trigger used in Mode A), so PCAP free-runs at `CLOCK1`'s + configured rate. Testing started at **100 Hz** on 2026-09-15 and will be + ramped up; there is no PandA-side layout change needed to change the + rate, only `CLOCK1`'s period. + + ```text + TTLIN1 (TTL input 1, detector trigger) + ├──▶ COUNTER1.trig, falling edge (Up/Down pulse counter) ──▶ COUNTER1.OUT = frame_counter (CAPTURE=Value) + │ = completion signal too: reaches num_points * frames_per_trigger when the last exposure finishes + └──▶ captured into PCAP.BITS0 bit 0 ──▶ masked in software = gate_detector_active + + CLOCK1 (enabled by constant ONE) ──▶ PCAP.gate, PCAP.trig (continuous arm/capture) + ``` + +### Possible variant: gate continuous capture by exposure-active (proposed, not implemented) + +Discussed 2026-09-19, not yet implemented or tested against hardware. Today +`PCAP.gate` and `PCAP.trig` are both driven by `CLOCK1.OUT`, so PCAP +free-runs continuously at `CLOCK1`'s rate regardless of whether the detector +is actually exposing -- including idle/dead time between exposures. The +proposal is to split the two roles so they're driven by different sources: + +```text +CLOCK1 (enabled by constant ONE) ──▶ PCAP.trig (sample-rate clock, unchanged) +TTLIN1.VAL (detector-trigger / exposure-active) ──▶ PCAP.gate (only capture while exposing) +``` + +`PCAP.trig` keeps ticking at `CLOCK1`'s configured rate exactly as today; +`PCAP.gate` switches from `CLOCK1.OUT` to `TTLIN1.VAL`, so a `CLOCK1` tick +only produces a captured row while the detector is actively exposing -- +idle-time ticks are simply dropped, not written. + +This should be a pure PandA-layout change -- no `ptycho_flomni.yaml` or +`panda_box_omny.py` change would be needed, since it doesn't change which +BEC signal names exist or what they mean: + +- `TTLIN1.VAL` is already proven usable as a `PCAP.gate` source -- it's what + Mode A already uses (`PCAP.GATE`/`PCAP.TRIG` = `TTLIN1.VAL` there). + +- `frame_counter` (`COUNTER1`, clocked directly off `TTLIN1.VAL`'s falling + edge, not off `PCAP`) is unaffected -- still counts every completed + exposure and still works as the completion/grouping signal exactly as + today. + +- `gate_detector_active` (`PCAP.BITS0` bit 0, also `TTLIN1.VAL`) would become + trivially always `1` on every captured row, since a row is only ever + captured while that signal is high -- harmless, since `frame_counter` + already does the actual per-exposure grouping, not the gate bit. + +Motivation: at 20 kHz continuous (current, ungated behavior), the +free-running raw stream produces roughly 67-86 MB/minute (~4-5 GB/hour) of +raw payload across the 10 captured fields (see +`panda_box_free_running_data_schema.md` for the field list; the range +reflects uncertainty over whether the four `INENC` channels report as +`int32` or `float64`). Gating capture to only actual exposure windows would +cut that in proportion to the detector's duty cycle -- e.g. roughly 10x less +at a 10% duty cycle -- with no BEC-side software change required. + +If this is implemented: update this section (and the "Continuous gate/arm" +wiring diagram above) to confirmed status, record which `.ini` layout +captures it, and re-verify the `PCAP.BITS0` bit-0 assignment per the caveat +under "Gate-bit input" above (bit assignment is fixed by the FPGA build, not +guaranteed stable across a layout rebuild). + +## Saving / restoring layouts + +Use `ophyd_devices/devices/panda_box/utility_scripts.py` to save the current +PandA configuration to a `.ini` layout file and reload it later, so a +configuration survives a PandA power cycle. Both layouts are saved in this +repo under `csaxs_bec/devices/panda_box/layouts/` — `omny.ini` (Mode A) and +`omny_freerun.ini` (Mode B), captured 2026-09-15 directly from the PandA +design named `OMNY`/`omny_freerun` respectively (switch the PandA's active +Design first, then save): + +```text +python ophyd_devices/devices/panda_box/utility_scripts.py \ + --host omny-panda.psi.ch --save-layout csaxs_bec/devices/panda_box/layouts/omny.ini + +python ophyd_devices/devices/panda_box/utility_scripts.py \ + --host omny-panda.psi.ch --save-layout csaxs_bec/devices/panda_box/layouts/omny_freerun.ini +``` + +To restore either, swap `--save-layout` for `--load-layout` with the same +file. Diffing the two confirms the mode-specific differences documented +above: `PCAP.GATE`/`PCAP.TRIG` are `TTLIN1.VAL` in Mode A vs `CLOCK1.OUT` in +Mode B; `CLOCK1.ENABLE` is `ZERO` (off) in Mode A vs `ONE` in Mode B; +`INENC1`-`4.VAL.CAPTURE`/`FMC_IN.VAL1/VAL2.CAPTURE` are `Min Max Mean` in +Mode A vs `Value` in Mode B; `PCAP.BITS0.CAPTURE`/`COUNTER1.OUT.CAPTURE` are +`No` in Mode A vs `Value` in Mode B. + +`omny_freerun.ini` was captured with `CLOCK1.PERIOD=0.0001` (100 µs → 10 kHz) +— note this doesn't match the 20 kHz reported as tested successfully the +same day, so it may not be the exact layout state the 20 kHz test ran at; +worth double-checking before relying on this file as "the" validated +high-rate layout. + +## Signal alias mapping + +The physical PandA block feeding each BEC signal name, kept here and in +`csaxs_bec/device_configs/ptycho_flomni.yaml` (`omny_panda.deviceConfig.signal_alias` for +Mode A, `omny_panda_continuous.deviceConfig.signal_alias` for Mode B) in sync as a single +source of truth: + +| PandA block | Mode | BEC signal name | +|---|---|---| +| `FMC_IN.VAL1.Min`/`.Max`/`.Mean` | A | `cap_voltage_fzp_y_min`/`_max`/`_mean` | +| `FMC_IN.VAL2.Min`/`.Max`/`.Mean` | A | `cap_voltage_fzp_x_min`/`_max`/`_mean` | +| `INENC1.VAL.Min`/`.Max`/`.Mean` | A | `interf_st_fzp_y_min`/`_max`/`_mean` | +| `INENC2.VAL.Min`/`.Max`/`.Mean` | A | `interf_st_fzp_x_min`/`_max`/`_mean` | +| `INENC3.VAL.Min`/`.Max`/`.Mean` | A | `interf_st_rotz_min`/`_max`/`_mean` | +| `INENC4.VAL.Min`/`.Max`/`.Mean` | A | `interf_st_rotx_min`/`_max`/`_mean` | +| `PCAP.GATE_DURATION.Value` | A, B | `pcap_gate_duration_value` | +| `FMC_IN.VAL1/VAL2.Value`, `INENC1`-`4.VAL.Value` | B | `cap_voltage_fzp_y`/`_x`, `interf_st_fzp_y`/`_x`/`_rotz`/`_rotx` | +| `PCAP.BITS0.Value` (bit 0 = `TTLIN1.VAL`, confirmed 2026-09-15; masked in software, see above) | B | `gate_detector_active` | +| `COUNTER1.OUT.Value` (confirmed 2026-09-15, triggered off `TTLIN1.VAL`'s falling edge) | B | `frame_counter` | + +The BEC-side data schema produced by Mode B (what ends up in the HDF5 async +dataset) is documented separately in +`docs/developer/panda_box_free_running_data_schema.md`. + +## Known data-quality caveat: stale `frame_counter` carryover (found + fixed 2026-09-21) + +Diagnosed against a real scan (`S06313`): grouping raw `interf_st_fzp_x`/`_y` +samples by `frame_counter` (restricted to `gate_detector_active == 1`) and +averaging per group reproduces `rt_positions`'s trusted +`average_x_st_fzp`/`average_y_st_fzp` to R² > 0.9999 once a single constant +per-axis offset is removed -- an expected reference-zero difference between +the PandA/`INENC` counter and RT's own internal tracking, irrelevant to +reconstruction since it's the same constant for every point in a scan. This +also confirms the grouping/filtering method itself: naively treating every +raw row as its own position (without grouping by `frame_counter` and +filtering to `gate_detector_active == 1`) does not reproduce the true +positions and cannot be fixed by any scale/offset correction, since most raw +rows are samples taken between real exposures, not at them (see "Continuous +gate/arm" above). + +One point out of 472 was a clear outlier under that method: its +`frame_counter` group had 38 extra samples (1036 vs. the usual ~998) and its +recomputed position was off by ~116 nm in Y, against ~5 nm typical. + +Root cause: PandA's `COUNTER1.OUT` (the block behind `frame_counter`) is not +cleared by `Arm()`. `COUNTER1.ENABLE=PCAP.ACTIVE` only gates *counting*, not +the persisted output value, and `COUNTER1.SET` -- the block's reset-trigger +input -- is wired to the constant `ZERO` in the saved layout (see +`layouts/omny_freerun.ini`), i.e. never pulsed on arm, unlike `ENABLE` which +*is* wired to a live source (`PCAP.ACTIVE`). So the first raw samples of a new +acquisition can briefly carry a stale `frame_counter` value left over from the +previous acquisition's tail -- observed directly in `S06313`'s raw data: 36 +samples reading a stale `167` before dropping to `0` and counting correctly +from there. Harmless on its own, except that stale value collided with a real +point index later in the *same* scan (point 167), so grouping by +`frame_counter` silently mixed ~38 stale rows into that one point's average. + +Fix, implemented in `panda_box_omny.py` (software-only, no PandA layout +change needed): every time PCAP is armed -- including `on_pre_scan` retries, +since each `Arm()` reopens the same stale-value window -- `PandaBoxOMNY` now +drops any raw rows captured before it observes `frame_counter == 0` for the +first time in that acquisition (`_drop_stale_frame_counter_rows`, called from +`_receive_frame_data`), and drops any leftover buffered rows from an +abandoned previous attempt at the same point (`_reset_raw_stream_state`, +called from `on_stage` and from `_try_arm` right before every `Arm()`). Every +signal's row list is truncated at the same index to keep channels aligned. +Verified against `S06313`: dropping the (37, in that scan) leading stale rows +before grouping brings point 167's residual from ~116 nm down to ~0.1 nm, in +line with the rest of the scan. + +A more thorough fix would rewire `COUNTER1.SET` on the PandA layout itself +(from the constant `ZERO` to `PCAP.ACTIVE`'s rising edge, matching how +`ENABLE` is already wired) so `OUT` resets at the source on every arm, rather +than relying on the client to detect and discard the stale prefix. Not +implemented here since it requires a PandA layout change to be tested against +live hardware; the software-side drop is a correct and sufficient fix in the +meantime, and remains a reasonable defense-in-depth even if the layout is +fixed later. If this is revisited: re-verify against a range of scans +(especially ones with `frame_counter` staying below a few hundred, where a +stale-value collision like point 167's is most likely) before removing the +software-side check. + +This also means the "gap in an otherwise-monotonic sequence" completeness +check described in the mode-comparison table's "Data completeness +verification" row needs one caveat: a single backward step at the very start +of the raw sequence, from the previous acquisition's terminal value down to +`0`, is this expected (now-filtered) artifact, not a sign of dropped rows. + +## Conditional arming (`arm_scan_allowlist`, added 2026-09-22) + +Generic BEC staging (`stage_all_devices`/`pre_scan_all_devices` in +`bec_server.scan_server.scans.scan_actions`) has no concept of "this device +only matters for scan X" -- every `enabled: true` device gets `stage()`/ +`pre_scan()`/`unstage()` called for *every* scan that runs (`grid_scan`, +`line_scan`, alignment moves, ...), not just the ones that actually consume +its data. `deviceTags` (e.g. `ptycho_flomni`) are never consulted by that +staging code -- they're purely a client-side grouping convenience. So +without any further gating, `omny_panda_continuous` was being `Arm()`ed and +disarmed for every single scan run during a session, even though only +`flomni_fermat_scan` reads its data. That's unnecessary hardware arm/disarm +cycling, and it directly compounds the stale-`frame_counter` bug above: more +arm cycles mean more windows where a mid-scan abort can leave a stale +counter value for the *next* acquisition to inherit (see +`AI_docs/PANDA_POSITION_VALIDATION.md` sec 7 for a real incident traced to +exactly this). + +`PandaBoxOMNY` now takes an `arm_scan_allowlist: list[str] | None` constructor +kwarg (`None` by default -- arm for every scan, unchanged behavior). When +set, `on_stage()` computes `_should_arm_panda` from +`scan_parameters.scan_name` against that list; `on_pre_scan()` skips sending +`Arm()` entirely (resolves immediately) when it's `False`, and `on_complete()` +skips the wait-for-frame-count poll loop the same way. `on_stage()`'s own +readout-thread/TCP-connection startup is untouched by this -- it doesn't +touch PandA's hardware register state (`COUNTER1`), unlike `Arm()`, so it +stays out of scope. `ptycho_flomni.yaml`'s `omny_panda_continuous` sets +`arm_scan_allowlist: [flomni_fermat_scan]`. + +## Switching between modes + +`omny_panda` (Mode A) and `omny_panda_continuous` (Mode B) need different PandA hardware +layouts and can't run simultaneously (see "Open question" below), so exactly one should be +active in `ptycho_flomni.yaml` at a time, matching whichever layout is currently loaded on +the PandA — **comment out the other one's block entirely**, don't rely on `enabled: false` +alone. + +That's a correction from earlier in this project: `enabled: false` on a device BEC has never +connected to is an inert placeholder — `connect_device` in +`bec_server/device_server/devices/devicemanager.py` is only called when `enabled` — so the +theory was that both entries could stay uncommented and switching would just be flipping the +flag. In practice (2026-09-15), disabling `omny_panda_continuous` this way was **not** +sufficient once it had already been connected in the running session: both devices ended up +active simultaneously against the same physical PandA. A device that's already connected +doesn't appear to get disconnected just because a later config reload marks it disabled. +Commenting the block out (so BEC never even constructs the device object) is the reliable +way to guarantee it's gone; if in doubt, confirm the other device has actually dropped its +connection (e.g. via a device server restart) rather than trusting `enabled: false` alone. + +Confirmed end-to-end 2026-09-15: Mode B tested working (correct per-channel values, masked +`gate_detector_active`, `frame_counter`-based completion), then switched back to Mode A +(commented out) for A/B comparison testing. + +## Open question, now partially resolved + +Can both modes coexist under a single static PandA layout — i.e. is mode +switching purely the `raw_stream_mode` software flag, with the PandA always +capturing both the aggregate and raw fields simultaneously — or do Mode A +and Mode B require two distinct saved layouts that must be loaded before a +scan depending on the desired mode? + +As currently wired for testing (2026-09-15), the answer looks like **two +distinct layouts are required**: `FMC_IN.VAL1/VAL2` and `INENC1`-`4.VAL` are +each set to `CAPTURE=Value` (single selection), not simultaneously +`CAPTURE=Value,Min,Max,Mean` — so this layout cannot also produce Mode A's +aggregate statistics at the same time. This has not yet been deliberately +tested against the alternative (whether the PandA capture UI actually +supports selecting `Value` together with `Min`/`Max`/`Mean` on the same +field) — if that turns out to work, a single layout could serve both modes. +Once Mode B testing is far enough along to save a named layout (see +"Saving / restoring layouts" below), settle this explicitly and record the +answer plus, if two layouts are needed, the load procedure to switch between +them. diff --git a/docs/index.md b/docs/index.md index 85dd5352..5edf0e7b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,51 +1,17 @@ -:html_theme.sidebar_secondary.remove: - # cSAXS documentation Welcome to the cSAXS documentation. This documentation is divided into two main sections: the user guide and the developer guide. The user guide is intended for users of the cSAXS beamline, while the developer guide is intended for developers of the cSAXS beamline. -

+
-````{grid} 2 -:gutter: 5 +- ![](assets/index_user_guide.svg){ width="32" height="32" .middle .card-icon } **[User guide](user/user.md)** + { .card-heading } -```{grid-item-card} -:link: introduction -:link-type: ref -:img-top: /assets/index_user_guide.svg -:text-align: center -:class-item: index-card + Information for users of the cSAXS beamline. -## User guide +- ![](assets/index_contribute.svg){ width="32" height="32" .middle .card-icon } **[Developer guide](developer/developer.md)** + { .card-heading } -Information for users of the cSAXS beamline. -``` + Information for developers of the cSAXS beamline. -```{grid-item-card} -:link: developer -:link-type: ref -:img-top: /assets/index_contribute.svg -:text-align: center -:class-item: index-card - -## Developer guide - -Information for developers of the cSAXS beamline. -``` - -```` - - -```{toctree} ---- -numbered: true -maxdepth: 2 -hidden: true ---- - -introduction/introduction -safety/safety -user/user -developer/developer -api_reference/api_reference -``` +
diff --git a/docs/introduction/introduction.md b/docs/introduction/introduction.md index 5ebeb808..d5b34190 100644 --- a/docs/introduction/introduction.md +++ b/docs/introduction/introduction.md @@ -1,4 +1,3 @@ -(introduction)= # The cSAXS Beamline ## Overview diff --git a/docs/log.txt b/docs/log.txt deleted file mode 100644 index c01452a4..00000000 --- a/docs/log.txt +++ /dev/null @@ -1,470 +0,0 @@ -docstring of bec_widgets.utils.bec_table.BECTable.Shadow.from_bytes:9: WARNING: Inline interpreted text or phrase reference start-string without end-string. -docstring of bec_widgets.utils.bec_table.BECTable.Shadow.to_bytes:7: WARNING: Inline interpreted text or phrase reference start-string without end-string. -docstring of bec_widgets.utils.bec_table.BECTable.Shape.from_bytes:9: WARNING: Inline interpreted text or phrase reference start-string without end-string. -docstring of bec_widgets.utils.bec_table.BECTable.Shape.to_bytes:7: WARNING: Inline interpreted text or phrase reference start-string without end-string. -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.examples.general_app.general_app.BECGeneralApp, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.examples.general_app.general_app.BECGeneralApp, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.examples.jupyter_console.jupyter_console_window.JupyterConsoleWindow, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.examples.jupyter_console.jupyter_console_window.JupyterConsoleWindow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECFigure:1: WARNING: duplicate object description of bec_widgets.cli.client.BECFigure, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECFigure, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECFigure.axes:1: WARNING: duplicate object description of bec_widgets.cli.client.BECFigure.axes, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECFigure, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECFigure.change_layout:1: WARNING: duplicate object description of bec_widgets.cli.client.BECFigure.change_layout, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECFigure, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECFigure.change_theme:1: WARNING: duplicate object description of bec_widgets.cli.client.BECFigure.change_theme, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECFigure, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECFigure.clear_all:1: WARNING: duplicate object description of bec_widgets.cli.client.BECFigure.clear_all, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECFigure, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECFigure.export:1: WARNING: duplicate object description of bec_widgets.cli.client.BECFigure.export, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECFigure, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.BECFigure.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECFigure, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECFigure.image:1: WARNING: duplicate object description of bec_widgets.cli.client.BECFigure.image, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECFigure, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECFigure.motor_map:1: WARNING: duplicate object description of bec_widgets.cli.client.BECFigure.motor_map, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECFigure, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECFigure.plot:1: WARNING: duplicate object description of bec_widgets.cli.client.BECFigure.plot, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECFigure, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECFigure.remove:1: WARNING: duplicate object description of bec_widgets.cli.client.BECFigure.remove, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECFigure, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECFigure.widget_list:1: WARNING: duplicate object description of bec_widgets.cli.client.BECFigure.widget_list, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECFigure, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECFigure.widgets:1: WARNING: duplicate object description of bec_widgets.cli.client.BECFigure.widgets, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECFigure, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageItem:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageItem, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageItem, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageItem.get_data:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageItem.get_data, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageItem, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageItem.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageItem, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageItem.set:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageItem.set, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageItem, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageItem.set_auto_downsample:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageItem.set_auto_downsample, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageItem, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageItem.set_autorange:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageItem.set_autorange, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageItem, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageItem.set_autorange_mode:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageItem.set_autorange_mode, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageItem, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageItem.set_color_map:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageItem.set_color_map, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageItem, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageItem.set_fft:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageItem.set_fft, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageItem, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageItem.set_log:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageItem.set_log, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageItem, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageItem.set_monitor:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageItem.set_monitor, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageItem, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageItem.set_opacity:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageItem.set_opacity, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageItem, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageItem.set_rotation:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageItem.set_rotation, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageItem, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageItem.set_transpose:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageItem.set_transpose, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageItem, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageItem.set_vrange:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageItem.set_vrange, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageItem, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.add_custom_image:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.add_custom_image, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.add_image_by_config:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.add_image_by_config, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.export:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.export, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.image:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.image, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.images:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.images, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.lock_aspect_ratio:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.lock_aspect_ratio, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.remove:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.remove, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_autorange:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_autorange, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_autorange_mode:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_autorange_mode, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_color_map:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_color_map, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_fft:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_fft, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_grid:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_grid, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_image_properties:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_image_properties, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_log:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_log, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_monitor:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_monitor, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_processing:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_processing, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_rotation:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_rotation, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_title:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_title, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_transpose:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_transpose, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_vrange:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_vrange, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_x_label:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_x_label, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_x_lim:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_x_lim, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_x_scale:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_x_scale, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_y_label:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_y_label, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_y_lim:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_y_lim, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageShow.set_y_scale:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageShow.set_y_scale, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageShow, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget.image:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.image, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget.lock_aspect_ratio:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.lock_aspect_ratio, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget.set:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.set, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget.set_fft:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.set_fft, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget.set_grid:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.set_grid, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget.set_log:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.set_log, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget.set_rotation:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.set_rotation, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget.set_title:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.set_title, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget.set_transpose:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.set_transpose, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget.set_vrange:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.set_vrange, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget.set_x_label:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.set_x_label, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget.set_x_lim:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.set_x_lim, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget.set_x_scale:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.set_x_scale, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget.set_y_label:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.set_y_label, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget.set_y_lim:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.set_y_lim, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECImageWidget.set_y_scale:1: WARNING: duplicate object description of bec_widgets.cli.client.BECImageWidget.set_y_scale, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMap:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMap, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMap, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMap.change_motors:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMap.change_motors, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMap, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMap.export:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMap.export, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMap, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMap.get_data:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMap.get_data, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMap, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMap.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMap, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMap.remove:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMap.remove, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMap, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMap.reset_history:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMap.reset_history, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMap, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMap.set_background_value:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMap.set_background_value, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMap, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMap.set_max_points:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMap.set_max_points, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMap, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMap.set_num_dim_points:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMap.set_num_dim_points, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMap, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMap.set_precision:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMap.set_precision, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMap, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMap.set_scatter_size:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMap.set_scatter_size, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMap, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMapWidget:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMapWidget, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMapWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMapWidget.change_motors:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMapWidget.change_motors, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMapWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMapWidget.export:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMapWidget.export, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMapWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMapWidget.get_data:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMapWidget.get_data, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMapWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMapWidget.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMapWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMapWidget.reset_history:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMapWidget.reset_history, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMapWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMapWidget.set_background_value:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMapWidget.set_background_value, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMapWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMapWidget.set_max_points:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMapWidget.set_max_points, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMapWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMapWidget.set_num_dim_points:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMapWidget.set_num_dim_points, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMapWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMapWidget.set_precision:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMapWidget.set_precision, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMapWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECMotorMapWidget.set_scatter_size:1: WARNING: duplicate object description of bec_widgets.cli.client.BECMotorMapWidget.set_scatter_size, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECMotorMapWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECPlotBase:1: WARNING: duplicate object description of bec_widgets.cli.client.BECPlotBase, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECPlotBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECPlotBase.export:1: WARNING: duplicate object description of bec_widgets.cli.client.BECPlotBase.export, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECPlotBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.BECPlotBase.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECPlotBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECPlotBase.lock_aspect_ratio:1: WARNING: duplicate object description of bec_widgets.cli.client.BECPlotBase.lock_aspect_ratio, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECPlotBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECPlotBase.remove:1: WARNING: duplicate object description of bec_widgets.cli.client.BECPlotBase.remove, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECPlotBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECPlotBase.set:1: WARNING: duplicate object description of bec_widgets.cli.client.BECPlotBase.set, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECPlotBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECPlotBase.set_grid:1: WARNING: duplicate object description of bec_widgets.cli.client.BECPlotBase.set_grid, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECPlotBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECPlotBase.set_legend_label_size:1: WARNING: duplicate object description of bec_widgets.cli.client.BECPlotBase.set_legend_label_size, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECPlotBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECPlotBase.set_title:1: WARNING: duplicate object description of bec_widgets.cli.client.BECPlotBase.set_title, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECPlotBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECPlotBase.set_x_label:1: WARNING: duplicate object description of bec_widgets.cli.client.BECPlotBase.set_x_label, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECPlotBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECPlotBase.set_x_lim:1: WARNING: duplicate object description of bec_widgets.cli.client.BECPlotBase.set_x_lim, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECPlotBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECPlotBase.set_x_scale:1: WARNING: duplicate object description of bec_widgets.cli.client.BECPlotBase.set_x_scale, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECPlotBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECPlotBase.set_y_label:1: WARNING: duplicate object description of bec_widgets.cli.client.BECPlotBase.set_y_label, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECPlotBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECPlotBase.set_y_lim:1: WARNING: duplicate object description of bec_widgets.cli.client.BECPlotBase.set_y_lim, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECPlotBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECPlotBase.set_y_scale:1: WARNING: duplicate object description of bec_widgets.cli.client.BECPlotBase.set_y_scale, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECPlotBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECQueue:1: WARNING: duplicate object description of bec_widgets.cli.client.BECQueue, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECQueue, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.BECQueue.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECQueue, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECStatusBox:1: WARNING: duplicate object description of bec_widgets.cli.client.BECStatusBox, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECStatusBox, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.BECStatusBox.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECStatusBox, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.add_dap:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.add_dap, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.clear_all:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.clear_all, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.curves:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.curves, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.export:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.export, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.get_all_data:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.get_all_data, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.get_curve:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.get_curve, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.get_dap_params:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.get_dap_params, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.lock_aspect_ratio:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.lock_aspect_ratio, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.plot:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.plot, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.remove:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.remove, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.remove_curve:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.remove_curve, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.scan_history:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.scan_history, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.set:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.set, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.set_colormap:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.set_colormap, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.set_grid:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.set_grid, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.set_legend_label_size:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.set_legend_label_size, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.set_title:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.set_title, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.set_x:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.set_x, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.set_x_label:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.set_x_label, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.set_x_lim:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.set_x_lim, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.set_x_scale:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.set_x_scale, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.set_y_label:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.set_y_label, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.set_y_lim:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.set_y_lim, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveform.set_y_scale:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveform.set_y_scale, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveform, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.add_dap:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.add_dap, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.curves:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.curves, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.export:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.export, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.export_to_matplotlib:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.export_to_matplotlib, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.get_all_data:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.get_all_data, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.get_dap_params:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.get_dap_params, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.lock_aspect_ratio:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.lock_aspect_ratio, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.plot:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.plot, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.remove_curve:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.remove_curve, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.scan_history:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.scan_history, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.set:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.set, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.set_auto_range:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.set_auto_range, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.set_grid:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.set_grid, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.set_legend_label_size:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.set_legend_label_size, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.set_title:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.set_title, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.set_x:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.set_x, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.set_x_label:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.set_x_label, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.set_x_lim:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.set_x_lim, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.set_x_scale:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.set_x_scale, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.set_y_label:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.set_y_label, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.set_y_lim:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.set_y_lim, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECWaveformWidget.set_y_scale:1: WARNING: duplicate object description of bec_widgets.cli.client.BECWaveformWidget.set_y_scale, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.DeviceBox:1: WARNING: duplicate object description of bec_widgets.cli.client.DeviceBox, other instance in api_reference/_autosummary/bec_widgets.cli.client.DeviceBox, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.DeviceBox.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.DeviceBox, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.DeviceComboBox:1: WARNING: duplicate object description of bec_widgets.cli.client.DeviceComboBox, other instance in api_reference/_autosummary/bec_widgets.cli.client.DeviceComboBox, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.DeviceComboBox.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.DeviceComboBox, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.DeviceInputBase:1: WARNING: duplicate object description of bec_widgets.cli.client.DeviceInputBase, other instance in api_reference/_autosummary/bec_widgets.cli.client.DeviceInputBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.DeviceInputBase.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.DeviceInputBase, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.DeviceLineEdit:1: WARNING: duplicate object description of bec_widgets.cli.client.DeviceLineEdit, other instance in api_reference/_autosummary/bec_widgets.cli.client.DeviceLineEdit, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.DeviceLineEdit.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.DeviceLineEdit, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Ring:1: WARNING: duplicate object description of bec_widgets.cli.client.Ring, other instance in api_reference/_autosummary/bec_widgets.cli.client.Ring, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.Ring.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.Ring, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Ring.reset_connection:1: WARNING: duplicate object description of bec_widgets.cli.client.Ring.reset_connection, other instance in api_reference/_autosummary/bec_widgets.cli.client.Ring, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Ring.set_background:1: WARNING: duplicate object description of bec_widgets.cli.client.Ring.set_background, other instance in api_reference/_autosummary/bec_widgets.cli.client.Ring, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Ring.set_color:1: WARNING: duplicate object description of bec_widgets.cli.client.Ring.set_color, other instance in api_reference/_autosummary/bec_widgets.cli.client.Ring, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Ring.set_line_width:1: WARNING: duplicate object description of bec_widgets.cli.client.Ring.set_line_width, other instance in api_reference/_autosummary/bec_widgets.cli.client.Ring, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Ring.set_min_max_values:1: WARNING: duplicate object description of bec_widgets.cli.client.Ring.set_min_max_values, other instance in api_reference/_autosummary/bec_widgets.cli.client.Ring, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Ring.set_start_angle:1: WARNING: duplicate object description of bec_widgets.cli.client.Ring.set_start_angle, other instance in api_reference/_autosummary/bec_widgets.cli.client.Ring, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Ring.set_update:1: WARNING: duplicate object description of bec_widgets.cli.client.Ring.set_update, other instance in api_reference/_autosummary/bec_widgets.cli.client.Ring, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Ring.set_value:1: WARNING: duplicate object description of bec_widgets.cli.client.Ring.set_value, other instance in api_reference/_autosummary/bec_widgets.cli.client.Ring, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.RingProgressBar:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.RingProgressBar.add_ring:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar.add_ring, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.RingProgressBar.enable_auto_updates:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar.enable_auto_updates, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.RingProgressBar.remove_ring:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar.remove_ring, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.RingProgressBar.reset_diameter:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar.reset_diameter, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.RingProgressBar.rings:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar.rings, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.RingProgressBar.set_colors_directly:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar.set_colors_directly, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.RingProgressBar.set_colors_from_map:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar.set_colors_from_map, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.RingProgressBar.set_diameter:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar.set_diameter, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.RingProgressBar.set_gap:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar.set_gap, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.RingProgressBar.set_line_widths:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar.set_line_widths, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.RingProgressBar.set_min_max_values:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar.set_min_max_values, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.RingProgressBar.set_number_of_bars:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar.set_number_of_bars, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.RingProgressBar.set_precision:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar.set_precision, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.RingProgressBar.set_value:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar.set_value, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.RingProgressBar.update_config:1: WARNING: duplicate object description of bec_widgets.cli.client.RingProgressBar.update_config, other instance in api_reference/_autosummary/bec_widgets.cli.client.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.ScanControl:1: WARNING: duplicate object description of bec_widgets.cli.client.ScanControl, other instance in api_reference/_autosummary/bec_widgets.cli.client.ScanControl, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.ScanControl.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.ScanControl, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.StopButton:1: WARNING: duplicate object description of bec_widgets.cli.client.StopButton, other instance in api_reference/_autosummary/bec_widgets.cli.client.StopButton, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.StopButton.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.StopButton, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.TextBox:1: WARNING: duplicate object description of bec_widgets.cli.client.TextBox, other instance in api_reference/_autosummary/bec_widgets.cli.client.TextBox, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.TextBox.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.TextBox, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.TextBox.set_color:1: WARNING: duplicate object description of bec_widgets.cli.client.TextBox.set_color, other instance in api_reference/_autosummary/bec_widgets.cli.client.TextBox, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.TextBox.set_font_size:1: WARNING: duplicate object description of bec_widgets.cli.client.TextBox.set_font_size, other instance in api_reference/_autosummary/bec_widgets.cli.client.TextBox, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.TextBox.set_text:1: WARNING: duplicate object description of bec_widgets.cli.client.TextBox.set_text, other instance in api_reference/_autosummary/bec_widgets.cli.client.TextBox, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.VSCodeEditor:1: WARNING: duplicate object description of bec_widgets.cli.client.VSCodeEditor, other instance in api_reference/_autosummary/bec_widgets.cli.client.VSCodeEditor, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.VSCodeEditor.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.VSCodeEditor, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.WebsiteWidget:1: WARNING: duplicate object description of bec_widgets.cli.client.WebsiteWidget, other instance in api_reference/_autosummary/bec_widgets.cli.client.WebsiteWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.WebsiteWidget.back:1: WARNING: duplicate object description of bec_widgets.cli.client.WebsiteWidget.back, other instance in api_reference/_autosummary/bec_widgets.cli.client.WebsiteWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.WebsiteWidget.forward:1: WARNING: duplicate object description of bec_widgets.cli.client.WebsiteWidget.forward, other instance in api_reference/_autosummary/bec_widgets.cli.client.WebsiteWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.WebsiteWidget.get_url:1: WARNING: duplicate object description of bec_widgets.cli.client.WebsiteWidget.get_url, other instance in api_reference/_autosummary/bec_widgets.cli.client.WebsiteWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.WebsiteWidget.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.WebsiteWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.WebsiteWidget.reload:1: WARNING: duplicate object description of bec_widgets.cli.client.WebsiteWidget.reload, other instance in api_reference/_autosummary/bec_widgets.cli.client.WebsiteWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.WebsiteWidget.set_url:1: WARNING: duplicate object description of bec_widgets.cli.client.WebsiteWidget.set_url, other instance in api_reference/_autosummary/bec_widgets.cli.client.WebsiteWidget, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.__add__:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.__add__, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.__mul__:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.__mul__, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.capitalize:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.capitalize, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.casefold:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.casefold, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.center:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.center, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.count:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.count, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.encode:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.encode, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.endswith:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.endswith, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.expandtabs:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.expandtabs, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.find:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.find, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.format:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.format, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.format_map:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.format_map, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.index:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.index, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.isalnum:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.isalnum, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.isalpha:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.isalpha, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.isascii:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.isascii, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.isdecimal:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.isdecimal, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.isdigit:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.isdigit, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.isidentifier:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.isidentifier, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.islower:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.islower, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.isnumeric:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.isnumeric, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.isprintable:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.isprintable, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.isspace:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.isspace, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.istitle:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.istitle, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.isupper:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.isupper, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.join:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.join, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.ljust:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.ljust, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.lower:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.lower, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.lstrip:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.lstrip, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.maketrans:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.maketrans, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.partition:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.partition, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.removeprefix:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.removeprefix, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.removesuffix:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.removesuffix, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.replace:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.replace, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.rfind:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.rfind, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.rindex:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.rindex, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.rjust:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.rjust, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.rpartition:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.rpartition, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.rsplit:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.rsplit, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.rstrip:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.rstrip, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.split:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.split, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.splitlines:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.splitlines, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.startswith:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.startswith, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.strip:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.strip, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.swapcase:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.swapcase, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.title:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.title, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.translate:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.translate, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.upper:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.upper, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.Widgets.zfill:1: WARNING: duplicate object description of bec_widgets.cli.client.Widgets.zfill, other instance in api_reference/_autosummary/bec_widgets.cli.client.Widgets, use :no-index: for one of them -docstring of bec_widgets.examples.plugin_example_pyside.tictactoetaskmenu.TicTacToeDialog.DialogCode.from_bytes:9: WARNING: Inline interpreted text or phrase reference start-string without end-string. -docstring of bec_widgets.examples.plugin_example_pyside.tictactoetaskmenu.TicTacToeDialog.DialogCode.to_bytes:7: WARNING: Inline interpreted text or phrase reference start-string without end-string. -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.examples.plugin_example_pyside.tictactoetaskmenu.TicTacToeDialog, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.examples.plugin_example_pyside.tictactoetaskmenu.TicTacToeDialog, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.qt_utils.error_popups.ExampleWidget, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.qt_utils.error_popups.ExampleWidget, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QDialog.DialogCode:1: WARNING: duplicate object description of PySide6.QtWidgets.QDialog.DialogCode, other instance in api_reference/_autosummary/bec_widgets.examples.plugin_example_pyside.tictactoetaskmenu.TicTacToeDialog, use :no-index: for one of them -docstring of bec_widgets.qt_utils.settings_dialog.SettingsDialog.DialogCode.from_bytes:9: WARNING: Inline interpreted text or phrase reference start-string without end-string. -docstring of bec_widgets.qt_utils.settings_dialog.SettingsDialog.DialogCode.to_bytes:7: WARNING: Inline interpreted text or phrase reference start-string without end-string. -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.qt_utils.settings_dialog.SettingWidget, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.qt_utils.settings_dialog.SettingWidget, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.qt_utils.settings_dialog.SettingsDialog, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.qt_utils.settings_dialog.SettingsDialog, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.examples.plugin_example_pyside.tictactoe.TicTacToe, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.examples.plugin_example_pyside.tictactoe.TicTacToe, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.stop_button.stop_button.StopButton, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.stop_button.stop_button.StopButton, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QFrame.Shadow:1: WARNING: duplicate object description of PySide6.QtWidgets.QFrame.Shadow, other instance in api_reference/_autosummary/bec_widgets.utils.bec_table.BECTable, use :no-index: for one of them -docstring of bec_widgets.widgets.text_box.text_box.TextBox.Shadow.from_bytes:9: WARNING: Inline interpreted text or phrase reference start-string without end-string. -docstring of bec_widgets.widgets.text_box.text_box.TextBox.Shadow.to_bytes:7: WARNING: Inline interpreted text or phrase reference start-string without end-string. -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QFrame.Shape:1: WARNING: duplicate object description of PySide6.QtWidgets.QFrame.Shape, other instance in api_reference/_autosummary/bec_widgets.utils.bec_table.BECTable, use :no-index: for one of them -docstring of bec_widgets.widgets.text_box.text_box.TextBox.Shape.from_bytes:9: WARNING: Inline interpreted text or phrase reference start-string without end-string. -docstring of bec_widgets.widgets.text_box.text_box.TextBox.Shape.to_bytes:7: WARNING: Inline interpreted text or phrase reference start-string without end-string. -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QAbstractScrollArea.SizeAdjustPolicy:1: WARNING: duplicate object description of PySide6.QtWidgets.QAbstractScrollArea.SizeAdjustPolicy, other instance in api_reference/_autosummary/bec_widgets.utils.bec_table.BECTable, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QFrame.StyleMask:1: WARNING: duplicate object description of PySide6.QtWidgets.QFrame.StyleMask, other instance in api_reference/_autosummary/bec_widgets.utils.bec_table.BECTable, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.text_box.text_box.TextBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.text_box.text_box.TextBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.toggle.toggle.ToggleSwitch, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.toggle.toggle.ToggleSwitch, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.utils.bec_table.BECTable, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.utils.bec_table.BECTable, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_popups.curve_dialog.curve_dialog.CurveSettings, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_popups.curve_dialog.curve_dialog.CurveSettings, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_popups.curve_dialog.curve_dialog.RemoveButton, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_popups.curve_dialog.curve_dialog.RemoveButton, use :no-index: for one of them -docstring of bec_widgets.widgets.waveform.waveform_popups.dap_summary_dialog.dap_summary_dialog.FitSummaryWidget.DialogCode.from_bytes:9: WARNING: Inline interpreted text or phrase reference start-string without end-string. -docstring of bec_widgets.widgets.waveform.waveform_popups.dap_summary_dialog.dap_summary_dialog.FitSummaryWidget.DialogCode.to_bytes:7: WARNING: Inline interpreted text or phrase reference start-string without end-string. -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_popups.curve_dialog.curve_dialog.StyleComboBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_popups.curve_dialog.curve_dialog.StyleComboBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_popups.dap_summary_dialog.dap_summary_dialog.FitSummaryWidget, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_popups.dap_summary_dialog.dap_summary_dialog.FitSummaryWidget, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_widget.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_widget.BECWaveformWidget, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/developer/developer.md:6: WARNING: toctree contains reference to nonexisting document 'developer/api_reference/api_reference' -/Users/wakonig_k/software/work/bec-widgets/docs/index.md:60: WARNING: 'toctree': Invalid option value for 'numbered': true: invalid literal for int() with base 10: 'true' [myst.directive_option] -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client:1: WARNING: duplicate object description of bec_widgets.cli.client, other instance in api_reference/_autosummary/bec_widgets.cli.client, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECCurve:1: WARNING: duplicate object description of bec_widgets.cli.client.BECCurve, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECCurve, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECCurve.dap_params:1: WARNING: duplicate object description of bec_widgets.cli.client.BECCurve.dap_params, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECCurve, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECCurve.get_data:1: WARNING: duplicate object description of bec_widgets.cli.client.BECCurve.get_data, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECCurve, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.BECCurve.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECCurve, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECCurve.remove:1: WARNING: duplicate object description of bec_widgets.cli.client.BECCurve.remove, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECCurve, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECCurve.set:1: WARNING: duplicate object description of bec_widgets.cli.client.BECCurve.set, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECCurve, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECCurve.set_color:1: WARNING: duplicate object description of bec_widgets.cli.client.BECCurve.set_color, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECCurve, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECCurve.set_color_map_z:1: WARNING: duplicate object description of bec_widgets.cli.client.BECCurve.set_color_map_z, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECCurve, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECCurve.set_data:1: WARNING: duplicate object description of bec_widgets.cli.client.BECCurve.set_data, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECCurve, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECCurve.set_pen_style:1: WARNING: duplicate object description of bec_widgets.cli.client.BECCurve.set_pen_style, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECCurve, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECCurve.set_pen_width:1: WARNING: duplicate object description of bec_widgets.cli.client.BECCurve.set_pen_width, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECCurve, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECCurve.set_symbol:1: WARNING: duplicate object description of bec_widgets.cli.client.BECCurve.set_symbol, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECCurve, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECCurve.set_symbol_color:1: WARNING: duplicate object description of bec_widgets.cli.client.BECCurve.set_symbol_color, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECCurve, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECCurve.set_symbol_size:1: WARNING: duplicate object description of bec_widgets.cli.client.BECCurve.set_symbol_size, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECCurve, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDock:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDock, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDock, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDock.add_widget:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDock.add_widget, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDock, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDock.attach:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDock.attach, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDock, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDock.detach:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDock.detach, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDock, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDock.get_widgets_positions:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDock.get_widgets_positions, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDock, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDock.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDock, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDock.hide_title_bar:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDock.hide_title_bar, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDock, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDock.list_eligible_widgets:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDock.list_eligible_widgets, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDock, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDock.move_widget:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDock.move_widget, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDock, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDock.remove:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDock.remove, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDock, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDock.remove_widget:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDock.remove_widget, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDock, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDock.set_title:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDock.set_title, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDock, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDock.show_title_bar:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDock.show_title_bar, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDock, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDock.widget_list:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDock.widget_list, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDock, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDockArea:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDockArea, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDockArea, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDockArea.add_dock:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDockArea.add_dock, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDockArea, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDockArea.attach_all:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDockArea.attach_all, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDockArea, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDockArea.clear_all:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDockArea.clear_all, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDockArea, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.BECGuiClientMixin.close:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDockArea.close, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDockArea, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDockArea.detach_dock:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDockArea.detach_dock, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDockArea, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.RPCBase.gui_is_alive:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDockArea.gui_is_alive, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDockArea, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDockArea.panels:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDockArea.panels, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDockArea, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDockArea.remove_dock:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDockArea.remove_dock, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDockArea, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDockArea.restore_state:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDockArea.restore_state, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDockArea, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDockArea.save_state:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDockArea.save_state, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDockArea, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDockArea.selected_device:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDockArea.selected_device, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDockArea, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client_utils.BECGuiClientMixin.show:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDockArea.show, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDockArea, use :no-index: for one of them -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/client.py:docstring of bec_widgets.cli.client.BECDockArea.temp_areas:1: WARNING: duplicate object description of bec_widgets.cli.client.BECDockArea.temp_areas, other instance in api_reference/_autosummary/bec_widgets.cli.client.BECDockArea, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.qt_utils.toolbar.ModularToolBar, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.qt_utils.toolbar.ModularToolBar, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.image.image_widget.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.image.image_widget.BECImageWidget, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.jupyter_console.jupyter_console.BECJupyterConsole, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.jupyter_console.jupyter_console.BECJupyterConsole, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.motor_map.motor_map_dialog.motor_map_settings.MotorMapSettings, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.motor_map.motor_map_dialog.motor_map_settings.MotorMapSettings, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.motor_map.motor_map_widget.BECMotorMapWidget, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.motor_map.motor_map_widget.BECMotorMapWidget, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.examples.plugin_example_pyside.tictactoe.TicTacToe, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.examples.plugin_example_pyside.tictactoe.TicTacToe, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.ring_progress_bar.ring_progress_bar.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.ring_progress_bar.ring_progress_bar.RingProgressBar, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.scan_control.scan_control.ScanControl, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.scan_control.scan_control.ScanControl, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.scan_control.scan_group_box.ScanCheckBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.scan_control.scan_group_box.ScanCheckBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.scan_control.scan_group_box.ScanDoubleSpinBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.scan_control.scan_group_box.ScanDoubleSpinBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.scan_control.scan_group_box.ScanGroupBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.scan_control.scan_group_box.ScanGroupBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QAbstractSpinBox.ButtonSymbols:1: WARNING: duplicate object description of PySide6.QtWidgets.QAbstractSpinBox.ButtonSymbols, other instance in api_reference/_autosummary/bec_widgets.widgets.scan_control.scan_group_box.ScanDoubleSpinBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QAbstractSpinBox.CorrectionMode:1: WARNING: duplicate object description of PySide6.QtWidgets.QAbstractSpinBox.CorrectionMode, other instance in api_reference/_autosummary/bec_widgets.widgets.scan_control.scan_group_box.ScanDoubleSpinBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.scan_control.scan_group_box.ScanLineEdit, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.scan_control.scan_group_box.ScanLineEdit, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QAbstractSpinBox.StepEnabledFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QAbstractSpinBox.StepEnabledFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.scan_control.scan_group_box.ScanDoubleSpinBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QAbstractSpinBox.StepType:1: WARNING: duplicate object description of PySide6.QtWidgets.QAbstractSpinBox.StepType, other instance in api_reference/_autosummary/bec_widgets.widgets.scan_control.scan_group_box.ScanDoubleSpinBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.scan_control.scan_group_box.ScanSpinBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.scan_control.scan_group_box.ScanSpinBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.vscode.vscode.VSCodeEditor, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.vscode.vscode.VSCodeEditor, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.website.website.WebsiteWidget, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.website.website.WebsiteWidget, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QComboBox.InsertPolicy:1: WARNING: duplicate object description of PySide6.QtWidgets.QComboBox.InsertPolicy, other instance in api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_popups.curve_dialog.curve_dialog.StyleComboBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.device_box.device_box.DeviceBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.device_box.device_box.DeviceBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QComboBox.SizeAdjustPolicy:1: WARNING: duplicate object description of PySide6.QtWidgets.QComboBox.SizeAdjustPolicy, other instance in api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_popups.curve_dialog.curve_dialog.StyleComboBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.device_combobox.device_combobox.DeviceComboBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.device_combobox.device_combobox.DeviceComboBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.device_line_edit.device_line_edit.DeviceLineEdit, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.device_line_edit.device_line_edit.DeviceLineEdit, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.position_indicator.position_indicator.PositionIndicator, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.position_indicator.position_indicator.PositionIndicator, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.bec_status_box.bec_status_box.BECStatusBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.bec_status_box.bec_status_box.BECStatusBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.bec_status_box.status_item.StatusItem, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.bec_status_box.status_item.StatusItem, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QAbstractItemView.CursorAction:1: WARNING: duplicate object description of PySide6.QtWidgets.QAbstractItemView.CursorAction, other instance in api_reference/_autosummary/bec_widgets.utils.bec_table.BECTable, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QAbstractItemView.DragDropMode:1: WARNING: duplicate object description of PySide6.QtWidgets.QAbstractItemView.DragDropMode, other instance in api_reference/_autosummary/bec_widgets.utils.bec_table.BECTable, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QAbstractItemView.DropIndicatorPosition:1: WARNING: duplicate object description of PySide6.QtWidgets.QAbstractItemView.DropIndicatorPosition, other instance in api_reference/_autosummary/bec_widgets.utils.bec_table.BECTable, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QAbstractItemView.EditTrigger:1: WARNING: duplicate object description of PySide6.QtWidgets.QAbstractItemView.EditTrigger, other instance in api_reference/_autosummary/bec_widgets.utils.bec_table.BECTable, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtGui.QPaintDevice.PaintDeviceMetric:1: WARNING: duplicate object description of PySide6.QtGui.QPaintDevice.PaintDeviceMetric, other instance in api_reference/_autosummary/bec_widgets.widgets.spinner.spinner.SpinnerWidget, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QWidget.RenderFlag:1: WARNING: duplicate object description of PySide6.QtWidgets.QWidget.RenderFlag, other instance in api_reference/_autosummary/bec_widgets.widgets.spinner.spinner.SpinnerWidget, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QAbstractItemView.ScrollHint:1: WARNING: duplicate object description of PySide6.QtWidgets.QAbstractItemView.ScrollHint, other instance in api_reference/_autosummary/bec_widgets.utils.bec_table.BECTable, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QAbstractItemView.ScrollMode:1: WARNING: duplicate object description of PySide6.QtWidgets.QAbstractItemView.ScrollMode, other instance in api_reference/_autosummary/bec_widgets.utils.bec_table.BECTable, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QAbstractItemView.SelectionBehavior:1: WARNING: duplicate object description of PySide6.QtWidgets.QAbstractItemView.SelectionBehavior, other instance in api_reference/_autosummary/bec_widgets.utils.bec_table.BECTable, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QAbstractItemView.SelectionMode:1: WARNING: duplicate object description of PySide6.QtWidgets.QAbstractItemView.SelectionMode, other instance in api_reference/_autosummary/bec_widgets.utils.bec_table.BECTable, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QFrame.Shadow:1: WARNING: duplicate object description of PySide6.QtWidgets.QFrame.Shadow, other instance in api_reference/_autosummary/bec_widgets.widgets.text_box.text_box.TextBox, use :no-index: for one of them -docstring of bec_widgets.widgets.bec_queue.bec_queue.BECQueue.Shadow.from_bytes:9: WARNING: Inline interpreted text or phrase reference start-string without end-string. -docstring of bec_widgets.widgets.bec_queue.bec_queue.BECQueue.Shadow.to_bytes:7: WARNING: Inline interpreted text or phrase reference start-string without end-string. -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QFrame.Shape:1: WARNING: duplicate object description of PySide6.QtWidgets.QFrame.Shape, other instance in api_reference/_autosummary/bec_widgets.widgets.text_box.text_box.TextBox, use :no-index: for one of them -docstring of bec_widgets.widgets.bec_queue.bec_queue.BECQueue.Shape.from_bytes:9: WARNING: Inline interpreted text or phrase reference start-string without end-string. -docstring of bec_widgets.widgets.bec_queue.bec_queue.BECQueue.Shape.to_bytes:7: WARNING: Inline interpreted text or phrase reference start-string without end-string. -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QAbstractScrollArea.SizeAdjustPolicy:1: WARNING: duplicate object description of PySide6.QtWidgets.QAbstractScrollArea.SizeAdjustPolicy, other instance in api_reference/_autosummary/bec_widgets.widgets.text_box.text_box.TextBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QAbstractItemView.State:1: WARNING: duplicate object description of PySide6.QtWidgets.QAbstractItemView.State, other instance in api_reference/_autosummary/bec_widgets.utils.bec_table.BECTable, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/docstring of PySide6.QtWidgets.QFrame.StyleMask:1: WARNING: duplicate object description of PySide6.QtWidgets.QFrame.StyleMask, other instance in api_reference/_autosummary/bec_widgets.widgets.text_box.text_box.TextBox, use :no-index: for one of them -/Users/wakonig_k/software/work/bec-widgets/docs/user/customisation.md: WARNING: document isn't included in any toctree -/Users/wakonig_k/software/work/bec-widgets/docs/user/getting_started/introduction.md: WARNING: document isn't included in any toctree -/Users/wakonig_k/software/work/bec-widgets/docs/user/widgets/scan_control.md: WARNING: document isn't included in any toctree -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/auto_updates.py:docstring of bec_widgets.cli.auto_updates.AutoUpdates:1: WARNING: more than one target found for cross-reference 'BECDockArea': bec_widgets.cli.client.BECDockArea, bec_widgets.widgets.dock.dock_area.BECDockArea -/Users/wakonig_k/.pyenv/versions/bec_widgets_docs/lib/python3.10/site-packages/bec_widgets/cli/auto_updates.py:docstring of bec_widgets.cli.auto_updates.AutoUpdates.get_default_figure:1: WARNING: more than one target found for cross-reference 'BECFigure': bec_widgets.cli.client.BECFigure, bec_widgets.widgets.figure.figure.BECFigure -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.qt_utils.toolbar.DeviceSelectionAction.rst:2: WARNING: more than one target found for cross-reference 'DeviceComboBox': bec_widgets.cli.client.DeviceComboBox, bec_widgets.widgets.device_combobox.device_combobox.DeviceComboBox -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.dock.dock_area.BECDockArea.rst:2: WARNING: more than one target found for cross-reference 'BECDock': bec_widgets.cli.client.BECDock, bec_widgets.widgets.dock.dock.BECDock -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.dock.dock_area.BECDockArea.rst:2: WARNING: more than one target found for cross-reference 'BECDock': bec_widgets.cli.client.BECDock, bec_widgets.widgets.dock.dock.BECDock -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.dock.dock_area.BECDockArea.rst:2: WARNING: more than one target found for cross-reference 'BECDock': bec_widgets.cli.client.BECDock, bec_widgets.widgets.dock.dock.BECDock -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.figure.figure.BECFigure.rst:2: WARNING: more than one target found for cross-reference 'BECPlotBase': bec_widgets.cli.client.BECPlotBase, bec_widgets.widgets.figure.plots.plot_base.BECPlotBase -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.figure.figure.BECFigure.rst:2: WARNING: more than one target found for cross-reference 'BECImageShow': bec_widgets.cli.client.BECImageShow, bec_widgets.widgets.figure.plots.image.image.BECImageShow -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.figure.figure.BECFigure.rst:2: WARNING: more than one target found for cross-reference 'BECMotorMap': bec_widgets.cli.client.BECMotorMap, bec_widgets.widgets.figure.plots.motor_map.motor_map.BECMotorMap -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.figure.figure.BECFigure.rst:2: WARNING: more than one target found for cross-reference 'BECWaveform': bec_widgets.cli.client.BECWaveform, bec_widgets.widgets.figure.plots.waveform.waveform.BECWaveform -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.figure.figure.WidgetHandler.rst:2: WARNING: more than one target found for cross-reference 'BECPlotBase': bec_widgets.cli.client.BECPlotBase, bec_widgets.widgets.figure.plots.plot_base.BECPlotBase -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.figure.plots.image.image.BECImageShow.rst:2: WARNING: more than one target found for cross-reference 'BECImageItem': bec_widgets.cli.client.BECImageItem, bec_widgets.widgets.figure.plots.image.image_item.BECImageItem -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.figure.plots.image.image.BECImageShow.rst:2: WARNING: more than one target found for cross-reference 'BECImageItem': bec_widgets.cli.client.BECImageItem, bec_widgets.widgets.figure.plots.image.image_item.BECImageItem -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.figure.plots.image.image.BECImageShow.rst:2: WARNING: more than one target found for cross-reference 'BECImageItem': bec_widgets.cli.client.BECImageItem, bec_widgets.widgets.figure.plots.image.image_item.BECImageItem -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.figure.plots.image.image.BECImageShow.rst:2: WARNING: more than one target found for cross-reference 'BECImageItem': bec_widgets.cli.client.BECImageItem, bec_widgets.widgets.figure.plots.image.image_item.BECImageItem -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.figure.plots.waveform.waveform.BECWaveform.rst:2: WARNING: more than one target found for cross-reference 'BECCurve': bec_widgets.cli.client.BECCurve, bec_widgets.widgets.figure.plots.waveform.waveform_curve.BECCurve -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.figure.plots.waveform.waveform.BECWaveform.rst:2: WARNING: more than one target found for cross-reference 'BECCurve': bec_widgets.cli.client.BECCurve, bec_widgets.widgets.figure.plots.waveform.waveform_curve.BECCurve -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.figure.plots.waveform.waveform.BECWaveform.rst:2: WARNING: more than one target found for cross-reference 'BECCurve': bec_widgets.cli.client.BECCurve, bec_widgets.widgets.figure.plots.waveform.waveform_curve.BECCurve -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.figure.plots.waveform.waveform.BECWaveform.rst:2: WARNING: more than one target found for cross-reference 'BECCurve': bec_widgets.cli.client.BECCurve, bec_widgets.widgets.figure.plots.waveform.waveform_curve.BECCurve -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.figure.plots.waveform.waveform.BECWaveform.rst:2: WARNING: more than one target found for cross-reference 'BECCurve': bec_widgets.cli.client.BECCurve, bec_widgets.widgets.figure.plots.waveform.waveform_curve.BECCurve -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.figure.plots.waveform.waveform.BECWaveform.rst:2: WARNING: more than one target found for cross-reference 'BECCurve': bec_widgets.cli.client.BECCurve, bec_widgets.widgets.figure.plots.waveform.waveform_curve.BECCurve -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.ring_progress_bar.ring_progress_bar.RingProgressBar.rst:2: WARNING: more than one target found for cross-reference 'Ring': bec_widgets.cli.client.Ring, bec_widgets.widgets.ring_progress_bar.ring.Ring -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_widget.BECWaveformWidget.rst:2: WARNING: more than one target found for cross-reference 'BECCurve': bec_widgets.cli.client.BECCurve, bec_widgets.widgets.figure.plots.waveform.waveform_curve.BECCurve -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_widget.BECWaveformWidget.rst:2: WARNING: more than one target found for cross-reference 'BECCurve': bec_widgets.cli.client.BECCurve, bec_widgets.widgets.figure.plots.waveform.waveform_curve.BECCurve -/Users/wakonig_k/software/work/bec-widgets/docs/api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_widget.BECWaveformWidget.rst:2: WARNING: more than one target found for cross-reference 'BECCurve': bec_widgets.cli.client.BECCurve, bec_widgets.widgets.figure.plots.waveform.waveform_curve.BECCurve -/Users/wakonig_k/software/work/bec-widgets/docs/user/getting_started/quick_start.md:18: WARNING: 'myst' cross-reference target not found: 'user.widgets.waveform_1d' [myst.xref_missing] -/Users/wakonig_k/software/work/bec-widgets/docs/user/getting_started/quick_start.md:25: WARNING: local id not found in doc 'api_reference/_autosummary/bec_widgets.cli.client.BECFigure': 'bec_widgets.cli.client.BECFigure.plot' [myst.xref_missing] -/Users/wakonig_k/software/work/bec-widgets/docs/user/getting_started/quick_start.md:30: WARNING: local id not found in doc 'api_reference/_autosummary/bec_widgets.cli.client.BECWaveform': 'bec_widgets.cli.client.BECWaveform.set_title' [myst.xref_missing] -/Users/wakonig_k/software/work/bec-widgets/docs/user/getting_started/quick_start.md:30: WARNING: local id not found in doc 'api_reference/_autosummary/bec_widgets.cli.client.BECWaveform': 'bec_widgets.cli.client.BECWaveform.set_x_label' [myst.xref_missing] -/Users/wakonig_k/software/work/bec-widgets/docs/user/getting_started/quick_start.md:30: WARNING: local id not found in doc 'api_reference/_autosummary/bec_widgets.cli.client.BECWaveform': 'bec_widgets.cli.client.BECWaveform.set_x_lim' [myst.xref_missing] -/Users/wakonig_k/software/work/bec-widgets/docs/user/getting_started/quick_start.md:30: WARNING: 'myst' cross-reference target not found: 'user.widgets.waveform_1d' [myst.xref_missing] -/Users/wakonig_k/software/work/bec-widgets/docs/user/getting_started/quick_start.md:46: WARNING: 'myst' cross-reference target not found: '/api_reference/_autosummary/bec_widgets.cli.client.BECWaveForm' [myst.xref_missing] -/Users/wakonig_k/software/work/bec-widgets/docs/user/getting_started/quick_start.md:54: WARNING: 'myst' cross-reference target not found: 'user.widgets.image' [myst.xref_missing] -/Users/wakonig_k/software/work/bec-widgets/docs/user/getting_started/quick_start.md:58: WARNING: Lexing literal_block 'gui.add_dock? # shows the dockstring of the add_dock method\n' as "python" resulted in an error at token: '?'. Retrying in relaxed mode. diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 32bb2452..00000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.https://www.sphinx-doc.org/ - exit /b 1 -) - -if "%1" == "" goto help - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/plans/fermat-scan-randomization.md b/docs/plans/fermat-scan-randomization.md new file mode 100644 index 00000000..9c89e5a6 --- /dev/null +++ b/docs/plans/fermat-scan-randomization.md @@ -0,0 +1,94 @@ +# Fix needed: restore random sub-step offset in Fermat spiral scans (OMNY, flOMNI, LamNI) + +**Status:** Implemented on `fixes/flomni_beamtime_2`, pending real/simulated-hardware +verification. All three generators now draw a fresh `np.random.default_rng(seed).uniform(0, +step)` offset per call and subtract it from the raw lattice `x`/`y` before the FOV crop check +(same scalar offset for both axes, matching the legacy spec). Decisions made on the open +questions below: no scan-metadata logging (a `logger.info` line is emitted instead); the +opt-out is a `seed: int | None = None` parameter (default `None` = fresh randomization); +x/y share one scalar offset. The four "preview/estimate" call sites that predict Fermat +point counts before a real scan runs (`flomni.py`/`lamni.py`'s +`_expected_fermat_position_count()`, `tomo_params.py`'s `_compute_fermat_positions_flomni`/ +`_compute_fermat_positions_lamni`) now pin `seed=0` so their own repeated-call estimates stay +reproducible; their docstrings note the real scan's count can differ by a point or two near +the threshold because of the randomization. Tests updated to match (`seed=0` on the +comparison calls in `test_fermat_position_warning.py`, `test_tomo_params_widget_math.py`, +`test_lamni_tomo_params_widget_math.py`); full suite passes (725 passed). + +**Applies to:** all three setups — **OMNY, flOMNI, and LamNI**. The fix must be applied consistently across all three, not just one. + +## Background + +Ptychographic tomography ring artifacts arise from any error that is systematically fixed relative to a common coordinate frame across all projection angles (e.g. a local reconstruction inconsistency always occurring at the same scan-grid position). Randomizing the Fermat scan grid's sub-step phase from projection to projection is a known mitigation: it decorrelates such position-tied errors so they don't accumulate into a ring during backprojection. + +## What the old spec code did + +In the legacy spec macros (`spec/scan_handling.mac`, `_rtscan_point_by_point`, fermat branch), a fresh random offset was drawn **on every call** (i.e. every projection) and used to shift the sampling lattice before cropping to the field of view: + +```c +_fermat_offset = step*rand(100)/100 // uniform random in [0, step) + +for (ind0=1; ind0<=_arb_num1; ind0++) { + _radius = step/sqrt(PI) * sqrt(ind0) + _angle = 8*PI/(1+sqrt(5))/(1+sqrt(5)) * ind0 + tmp_x = radius*cos(angle) + tmp_y = radius*sin(angle) + if (tmp_x in [-(fovx/2)+offset, (fovx/2)+offset] and + tmp_y in [-(fovy/2)+offset, (fovy/2)+offset]) keep it +} +final_x = tmp_x + cenx - _fermat_offset +final_y = tmp_y + ceny - _fermat_offset +``` + +Net effect (worked out by algebra): the entire Fermat lattice is shifted by `-offset` before cropping to the FOV window, with `offset` redrawn uniformly from `[0, step)` on every scan. So the exact sub-step "phase" of the sampling lattice relative to the sample/FOV origin varies from projection to projection, rather than being bit-for-bit identical every time. + +## Current state in BEC (the problem) + +None of the three current Fermat spiral generators in `csaxs_bec` draw any random number. All three are fully deterministic given `(cenx, ceny, step, fovx, fovy, ...)`: + +| Setup | File | Function | +|--------|------|----------| +| flOMNI | `csaxs_bec/csaxs_bec/scans/flomni_fermat_scan.py` | `get_flomni_fermat_spiral_pos` (staticmethod) | +| OMNY | `csaxs_bec/csaxs_bec/scans/omny_fermat_scan.py` | `get_omny_fermat_spiral_pos` (instance method) | +| LamNI | `csaxs_bec/csaxs_bec/scans/lamni_fermat_scan.py` | `get_lamni_fermat_spiral_pos` (staticmethod) | + +All three share the same core loop shape: + +```python +for ii in range(start, n_max): + radius = step * 0.57 * np.sqrt(ii) + x = radius * np.sin(ii * phi) + y = radius * np.cos(ii * phi) + # ... FOV/circular-FOV crop check ... + positions.append([x + cenx, y + ceny, z_pos]) +``` + +Since `cenx`/`ceny` come from the per-angle alignment/offset computation (not randomized), every projection at a given nominal center currently samples the **exact same** lattice phase relative to the sample. This is a plausible, currently-unmitigated contributor to ring artifacts in reconstructions from all three instruments. + +Note: there is an existing `random.uniform` call in `csaxs_bec/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py` (`single_point_random_shift_max`, used in `tomo_acquire_at_angle`). That is a **separate, unrelated** mechanism for single-point burst acquisitions — it does not touch the Fermat spiral generators and does not address this issue. + +## What needs to happen + +1. Add a per-call random sub-step offset to all three spiral generators (`get_flomni_fermat_spiral_pos`, `get_omny_fermat_spiral_pos`, `get_lamni_fermat_spiral_pos`), reproducing the intent of the old spec `_fermat_offset` (uniform draw in `[0, step)`, applied as a lattice-phase shift before FOV cropping). +2. Apply the fix **identically in spirit** across OMNY, flOMNI, and LamNI — the three functions currently share near-identical structure and should stay in sync on this behavior. Watch for the small existing differences between them (flOMNI/LamNI take `cenx`/`ceny` as static args; OMNY reads `self.cenx`/`self.ceny`; LamNI has additional stitching/shift/circular-FOV parameters that the offset must compose correctly with). +3. Decide and document (open questions below) rather than guessing silently. + +## Open questions for the implementing session to resolve (and record the decision) + +- **Reproducibility/logging:** should the drawn offset be logged into scan metadata (e.g. via `update_scan_info`) so a given projection's exact grid phase can be recovered/audited later, the way `_arb_num1`/positions already are? +- **Opt-out for testing/repeatability:** should there be a parameter (e.g. `randomize_offset: bool = True`, or an explicit `seed`) so existing/new unit tests and commissioning scans can request deterministic output? +- **X/Y coupling:** the original spec used the *same* scalar `_fermat_offset` for both the x- and y-shift. Decide whether to preserve that (simpler, matches historical behavior) or draw independent offsets per axis (stronger decorrelation, but diverges from the reference implementation) — pick one and note the reasoning. +- **Interaction with existing FOV/circular-FOV cropping logic**, especially in LamNI's `_lamni_check_pos_in_fov_range_and_circ_fov` and any stitching offsets — the offset must shift the lattice consistently with those checks, not just the final translation. + +## Tests likely needing updates + +- `csaxs_bec/csaxs_bec/tests/tests_scans/test_flomni_fermat_scan.py` +- `csaxs_bec/csaxs_bec/tests/tests_scans/test_omny_fermat_scan.py` +- `csaxs_bec/csaxs_bec/tests/tests_scans/test_lamni_fermat_scan.py` + +Any test currently asserting exact/deterministic position arrays will need either a fixed seed, the opt-out parameter above, or an update to assert distributional/structural properties instead of exact values. + +## Non-goals + +- Do not change the underlying spiral density/index-to-radius/angle formula (`radius = step * 0.57 * sqrt(ii)`, golden-angle `phi`) — only the lattice-phase (offset) behavior is in scope. +- Do not touch `single_point_random_shift_max` / `tomo_acquire_at_angle` in `flomni.py` — unrelated mechanism, out of scope here. diff --git a/docs/plans/flomni-fermat-asymmetry.md b/docs/plans/flomni-fermat-asymmetry.md new file mode 100644 index 00000000..940eab62 --- /dev/null +++ b/docs/plans/flomni-fermat-asymmetry.md @@ -0,0 +1,237 @@ +# Plan: `fermat_asymmetry` (x/y spacing ratio) for the Flomni Fermat scan + +Status: **implemented on `fixes/flomni_beamtime_2`**, pending real/simulated-hardware +verification. Followed this plan as written: scan class (`FlomniFermatScan`), the +`fermat_asymmetry` global var on `Flomni`, the queue/CLI plumbing (`_TOMO_SCAN_PARAM_NAMES`, +`scan_kwargs`, `tomo_parameters()` print/prompt, `_set_default_tomo_params()`), and the +`tomo_params` Qt widget (field + a "?" help button next to it, following the widget's +existing offsets-section help-button pattern -- `_build_fermat_asymmetry_row`/ +`_show_fermat_asymmetry_help`). Tests added to `tests/tests_scans/test_flomni_fermat_scan.py` +verify the inflate/compress math directly (against an equivalent call with an inflated FOV +and `fermat_asymmetry=1.0`) rather than via an approximate spacing-ratio heuristic, which a +first attempt showed is too noisy on a Fermat spiral. Also updated flOMNI's user +documentation (`docs/user/ptychography/flomni.md`) and the OMNY port TODO +(`csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md`) to +describe how this composes with the already-landed sub-step randomization (see +`docs/plans/fermat-scan-randomization.md`). Full test suite passes (728 passed). + +## Context + +The Flomni ptycho Fermat-spiral scan (`FlomniFermatScan`) currently generates +an isotropic spiral: the local point spacing is the same in x and y, +controlled by a single `step` parameter, and points are kept only if they +fall inside the requested field of view (`fovx` × `fovy`). For some samples +it's desirable to bias the spacing — e.g. because the sample or an expected +feature is elongated in one direction — so that the effective spacing in x +differs from y while the requested field of view stays exactly as specified. + +This plan introduces `fermat_asymmetry` (ratio = x_spacing / y_spacing, +default `1.0` = symmetric). `>1` means x spacing is larger than y spacing (y +is denser); `<1` is the reverse. Because spacing changes while the FOV area +stays fixed, the total number of positions will change with the ratio. + +Scope: the scan argument, the BEC global variable, the queuing path (client +→ queue → scan-server validation → scan execution), the Flomni CLI's +`tomo_parameters()`, and the `tomo_params` Qt widget's Flomni profile. OMNY +and LamNI are out of scope for the code change itself — OMNY is flagged +separately for a future port (see +`csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md`); +LamNI is excluded on purpose. + +## The math + +Current core loop, `FlomniFermatScan.get_flomni_fermat_spiral_pos` (static +method), `csaxs_bec/scans/flomni_fermat_scan.py:410-463`: + +```python +length_axis1 = np.abs(m1_stop - m1_start) +length_axis2 = np.abs(m2_stop - m2_start) +n_max = compute_fermat_spiral_n_max(length_axis1, length_axis2, step) +... +for ii in range(start, n_max): + radius = step * 0.57 * np.sqrt(ii) + if abs(radius * np.sin(ii * phi)) > length_axis1 / 2: + continue + if abs(radius * np.cos(ii * phi)) > length_axis2 / 2: + continue + x = radius * np.sin(ii * phi) + y = radius * np.cos(ii * phi) + positions.append([x + cenx, y + ceny, z_pos]) +``` + +This is an isotropic Fermat spiral: `step` controls spacing equally in both +directions. To bias it while keeping x spacing pinned at `step` and the +requested FOV fixed: + +- Generate the spiral exactly as today (unchanged x0/y0). Filter x against + the true `fovx` half-width as before. For y, generate/filter against an + **inflated** half-width `length_axis2 * fermat_asymmetry`, then **compress + the accepted y-coordinate back down** by dividing by `fermat_asymmetry` + before storing the final position. +- This keeps x spacing at `step` (untouched), while y spacing becomes + `step / fermat_asymmetry` — "keep x spacing constant, scale y spacing by + the ratio". When `fermat_asymmetry == 1.0` the math reduces exactly to + today's formula (no behavior change for existing callers/tests). +- The FOV corner points appended after the loop (lines 459-462) stay + untouched — they represent the real, requested physical FOV box and must + not be skewed by the asymmetry. +- `compute_fermat_spiral_n_max` (`csaxs_bec/scans/fermat_spiral_utils.py:8`) + must be called with the *inflated* y half-width (`length_axis2 * + fermat_asymmetry`), since that's the actual unscaled extent the loop needs + to reach before compression. + +Range: `fermat_asymmetry` must be `> 0` (zero/negative makes the +y-compression nonsensical) — reject that outright via the scan argument's +`gt=0` bound (validated by BEC's `ScanInputValidator` at queue-submit time, +same mechanism as every other `Annotated[..., ScanArgument(...)]` +parameter). Additionally, clamp the practical working range to `[0.1, 10]` +with a warning, the same soft-clamp style already used for `zshift` and +`corridor_size` in this file (`flomni_fermat_scan.py:121-134`). Put this +clamp **inside `get_flomni_fermat_spiral_pos` itself** (not in `__init__`), +because that static method is also called directly — bypassing `__init__` — +by the point-count preview helpers (`Flomni._expected_fermat_position_count()` +and the widget's `_compute_fermat_positions_flomni()`, see below). Clamping +at that one shared call site keeps the real scan and every preview in sync +automatically. + +## Files to change + +### 1. `csaxs_bec/scans/flomni_fermat_scan.py` + +- Add class constants next to `MIN_POSITIONS`/`MAX_CORRIDOR_SIZE` (line + 67-68): `MIN_FERMAT_ASYMMETRY = 0.1`, `MAX_FERMAT_ASYMMETRY = 10.0`. +- Add `"fermat_asymmetry"` to `gui_config["Scan Parameters"]` (line 54-63). +- Add a new `__init__` parameter (near `step`, line 77): + ```python + fermat_asymmetry: Annotated[float, ScanArgument(display_name="Fermat Asymmetry", description="Ratio of x spacing to y spacing (x/y). 1 = symmetric. >1 widens x spacing / tightens y spacing; <1 the opposite. FOV is preserved; point count changes with the ratio.", gt=0, example=1)] = 1.0, + ``` + Store as `self.fermat_asymmetry = fermat_asymmetry` and document it in the + docstring `Args:` block. +- Pass it through in `prepare_scan()`'s call to `get_flomni_fermat_spiral_pos` + (line 150-161): add `fermat_asymmetry=self.fermat_asymmetry`. +- Update `get_flomni_fermat_spiral_pos` (line 410-463): add + `fermat_asymmetry: float = 1.0` parameter, implement the clamp (with + `logger.warning` when out of `[MIN_FERMAT_ASYMMETRY, MAX_FERMAT_ASYMMETRY]`) + and the x/y-scaling math described above, replacing the direct + `radius*sin`/`radius*cos` filter+store with the generate-against-inflated- + window / compress-on-accept logic. + +### 2. `csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py` + +- Add a new global-var-backed property next to `tomo_shellstep` + (line 2005): `fermat_asymmetry = _GlobalVarParam(1.0)`. +- Add `"fermat_asymmetry"` to `_TOMO_SCAN_PARAM_NAMES` (line 3900-3914) so + it's snapshotted/restored by `tomo_queue_add()`/`tomo_queue_execute()` + like every other tomo scan parameter (`tomo_queue_mixin.py:625`, `899`). + No migration needed for already-queued jobs: `tomo_queue_execute()` only + sets attributes present in a job's saved `params` dict + (`tomo_queue_mixin.py:898-899`), so older queued jobs simply leave the + live `fermat_asymmetry` value untouched — the same behavior every + previously-added parameter already relies on. +- Thread it into the per-projection scan call, `scan_kwargs` dict + (line 3478-3488): add `fermat_asymmetry=self.fermat_asymmetry`, and into + the accompanying `logger.info(...)` repro string (line 3467-3472) for + consistency with the other logged kwargs. +- Pass it into the preview helper, `_expected_fermat_position_count()` + (line 3638-3646): add `fermat_asymmetry=self.fermat_asymmetry` to the + `get_flomni_fermat_spiral_pos(...)` call. +- Surface it in `tomo_parameters()`: + - print current value near line 3661-3662 (alongside step/FOV). + - add a prompt line near line 3749-3751, following the existing + `self.tomo_shellstep = self._get_val(...)` pattern. + +### 3. `csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py` (Qt widget) + +The widget is largely data-driven off a few module-level tables plus a +per-setup `SETUP_PROFILES["flomni"]` dict, so most of the wiring is +declarative: + +- Add `"fermat_asymmetry"` to `QUEUE_PARAM_NAMES` (line 127-149, the tuple + mirroring `Flomni._TOMO_SCAN_PARAM_NAMES`) and to `DEFAULTS` + (line 151-173): `"fermat_asymmetry": 1.0`. **Do not** touch + `LAMNI_QUEUE_PARAM_NAMES` (line 184) — LamNI is explicitly excluded. +- Add a token builder to the `token_builders` dict in `_build_params_panel` + (~line 463-470), matching the existing `tomo_shellstep` entry's style: + ```python + "fermat_asymmetry": lambda: self._add_double( + common_form, + "fermat_asymmetry", + "Fermat asymmetry (x/y)", + min_=0.1, + max_=10.0, + decimals=2, + ), + ``` +- Add `"fermat_asymmetry"` to `SETUP_PROFILES["flomni"]["field_order"]` + (~line 2280-2291), right after `"tomo_shellstep"`. +- Add `"fermat_asymmetry"` to `SETUP_PROFILES["flomni"]["fermat_position_fields"]` + (line 2296) so the existing live point-count preview wiring (the loop at + line 505-508 that connects each listed field's `valueChanged` signal to + `_update_fermat_position_preview`) picks it up automatically — no new + signal-wiring code needed. +- Update `_compute_fermat_positions_flomni()` (line 2162-2189): read + `fermat_asymmetry = params.get("fermat_asymmetry", 1.0)` and pass + `fermat_asymmetry=fermat_asymmetry` into the + `FlomniFermatScan.get_flomni_fermat_spiral_pos(...)` call, so the GUI's + "Estimated Fermat scan points" preview never drifts from the real + algorithm (matches this function's own stated purpose in its docstring). + +No other widget code needs to change: `_load_params()` (line 1411-1416) and +`add_edited_to_queue()`'s snapshot (line 1266) both iterate generically over +`self._profile["param_names"]`, and `_job_tooltip()` (line 2432-2458) iterates +generically over `sorted(job["params"])` — all three automatically pick up +`fermat_asymmetry` once it's in `QUEUE_PARAM_NAMES`/`DEFAULTS` and has a +widget in `self._pw`. + +**Deliberately left out**: the fixed-column queue table (`_build_ui`, +`QTableWidget(0, 8)`, headers `["#", "Label", "Status", "Type", +"Projections", "Exp (s)", "Step (µm)", "Details"]`, populated around line +1522-1533) is not extended with a new column for `fermat_asymmetry`. Adding +a column means touching header setup, column-width logic, and the +alignment-index checks (`col in (1, 7)`) in the same function — more +surface area for a cosmetic addition. The value is still fully visible via +the row tooltip (`_job_tooltip`, which is generic). Revisit only if this +turns out to be commonly-varied-enough to want at-a-glance visibility. + +### 4. Tests — `tests/tests_scans/test_flomni_fermat_scan.py` + +Add cases alongside the existing +`test_flomni_fermat_spiral_reaches_elongated_fov_corners`: + +- `fermat_asymmetry=1.0` (or omitted) reproduces exactly today's positions + (no-op / backward-compatibility check). +- For `fermat_asymmetry > 1` and `< 1`, the ratio of median nearest-neighbor + spacing along x vs. along y (via `scipy.spatial.cKDTree`, same technique + already used in `_estimate_corridor_size`) approximately matches the + requested ratio, while `max(abs(x)) <= fovx/2` and `max(abs(y)) <= fovy/2` + still hold. +- A value outside `[0.1, 10]` (e.g. `100`) is silently clamped rather than + raising, and does not change the FOV bounds. + +## Verification + +1. `cd /sls/x12sa/config/bec/production/csaxs_bec && python -m pytest tests/tests_scans/test_flomni_fermat_scan.py -v` +2. Sanity-check the position generator directly for a few ratios: + ```python + from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan + for r in (0.5, 1.0, 2.0): + pos = FlomniFermatScan.get_flomni_fermat_spiral_pos(-10, 10, -5, 5, step=1, fermat_asymmetry=r) + print(r, len(pos), pos[:, 0].max(), pos[:, 1].max()) + ``` +3. In a BEC client session against the Flomni deployment: confirm + `flomni.fermat_asymmetry` reads/writes via the global var (persists + across a client restart), shows up in `flomni.tomo_parameters()`, and + that `scans.flomni_fermat_scan(..., fermat_asymmetry=2.0)` is accepted by + the queue (client-side `ScanInputValidator` and server-side + `ScanAssembler` both validate against the new `ScanArgument`). +4. Open the `tomo_params` widget for the flomni profile: confirm the new + "Fermat asymmetry (x/y)" field appears in the parameter panel in the + right position, edit it, confirm the "Estimated Fermat scan points" + preview updates live, and confirm that queuing/loading a job round-trips + the value correctly. + +## Out of scope + +- `OmnyFermatScan` / `OMNY` — flagged for a future port, see + `csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md`. +- `LamniFermatScan` / `LamNI` — explicitly excluded, not planned. diff --git a/docs/plans/flomni-fsamx-pid-centering.md b/docs/plans/flomni-fsamx-pid-centering.md new file mode 100644 index 00000000..1d3cbd0b --- /dev/null +++ b/docs/plans/flomni-fsamx-pid-centering.md @@ -0,0 +1,166 @@ +# Plan: improve the `fsamx` PID centering in `move_samx_to_scan_region` + +Status: **DRAFT — NOT FINAL, NEEDS FURTHER DISCUSSION. Not implemented.** +Written during live beamtime operation on request; no code changes made — see +the conversation this plan came from. It rests on one day of logs +(2026-09-18) and small samples (18 first-move cases, 25 later-iteration steps) +and has not been tested on the instrument. The open questions at the bottom +must be settled before any code is changed. + +## Context + +`RtFlomniController.move_samx_to_scan_region` +(`csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py`, ~lines 181-230) centres +`fsamx` after the rt stage has moved to `cenx`. It reads the PID X voltage +(`get_pid_x()`, `g` command on the rt controller, range ±10 V) and compares it +with the expected voltage (`rtx` user parameter `rt_pid_voltage`, stored by +`feedback_enable_with_reset`). + +- If `|V - expected|` is below the start threshold: `No correction of fsamx needed`. +- Otherwise loop until `|V - expected| < 1`, each iteration doing + `pid_x_correction -= (V - expected) * 0.006` and moving `fsamx` to + `fsamx_in + cenx/1000*0.7 + pid_x_correction`, then `laser_tracker_on()`. + +**Trigger for this plan.** The start threshold was lowered from 2.0 V to 1.2 V +(uncommitted edit in the production working tree when this was written) with +the aim of reducing the target range below 0.05 V. Afterwards corrections rose +from about 8.5 to about 49 per hour and the readings still swung by about +±1-2 V. + +**Data source.** `/var/log/bec/DeviceServer.log` and `ScanServer.log` on +`x12sa-bec-001.psi.ch`, 2026-09-18 08:00-20:19. These logs are not part of the +repo. The relevant log messages come from the `rt_flomni_ophyd` logger +(`Current PID voltage`, `Moving fsamx to …, PID portion of that …`, +`No correction of fsamx needed`, `No further correction needed`). + +## Findings this plan rests on + +1. **The loop gain is too small, not too large.** The measured stage response + is 13.4 µm/V (rms 0.18 V over 25 steps at constant `cenx`), while the code + uses `0.006` mm/V = 6 µm/V. Each step therefore removes only about 45 % of + the error (measured contraction ×0.57, predicted ×0.55). Going from 1.2 V to + below 0.05 V takes about 6 iterations, each followed by a laser tracker + wait. At constant `cenx` the loop does not overshoot. +2. **The first move of a sequence double counts.** It applies the `cenx` + feed-forward jump (`0.7*Δcenx` since the last move, median 35 µm) and a + correction increment `-0.006*(V-e)` computed from a reading that already + contains that `cenx` lag. The sign of V flips after the first move in 12 of + 18 cases, e.g. V = -2.44 V with a +36.7 µm feed-forward jump and a +14.6 µm + correction step gives V = +1.82 V. A two-term fit suggests the true + feed-forward factor is nearer 0.8 than 0.7 (weak evidence, n = 18). +3. **Blind steps at the rail overshoot.** In four cases (12:25, 13:56, 18:42, + 19:04) a scan stop was followed by a reading pinned at -10.0 V. The loop + then made three fixed +60 µm moves (`0.006 * 10 V`), the reading flipped to + +10.0 V, and the laser tracker waited 37-100 s for the target. Twice the + wait exceeded 100 polls and raised + `RtError: Failed to reach laser target position`. +4. **`pid_x_correction` is lost on reload.** After the 19:30 device reload it + restarted from 0 (first PID part -0.009), while `fsamx` itself stayed where + it was. +5. **About 1 V of scan-to-scan alternation is unexplained.** Consecutive scan + points alternate high/low with a period of about 46 s. It was present all + day, including before the threshold change (08:00-08:30, 10:00-16:00). The + `cenx` and last-`fsamx`-position model leaves a residual of about 1 V + (R² 0.67 before 19:30, 0.30 after 19:36). This is a rotation-tomography + run: every scan point is a new `angle` and `cenx` follows it. +6. **The `Expected PID voltage` log line is commented out** + (`rt_flomni_ophyd.py:196`). The expected value only appears once per + restart as `Using PID voltage from rtx user parameter`. + +## The math + +Notation: `V` = PID voltage, `e` = expected voltage, `s` = stage response +(mm/V), `x` = `fsamx` position relative to `fsamx_in` (mm), `c` = `cenx`. + +Plant (fits the in-loop data to 0.18 V rms): + +``` +V - e = (x - x*(c)) / s, x*(c) = 0.7*c/1000 + drift, s ≈ 0.0134 mm/V +``` + +Current step: `Δx = -0.006 * (V - e)`, i.e. gain `g = 0.006` and per-step error +ratio `r = 1 - g/s ≈ 0.55`. To reach a residual `ε` from `V0` needs +`n = ln(ε/V0) / ln(r)` steps: 6 steps for 1.2 V → 0.05 V. + +Proposed step (Newton, damped): `Δx = -DAMP * s * (V - e)` with +`DAMP ≈ 0.85`. With a 15 % gain error the ratio is about 0.15, so 1.5 V → 0.03 V +in two iterations. `s` may be adapted from the last two iterations +(`s_est = Δx_prev / (V_prev - V_now)`), clamped to 8-20 µm/V. + +Double counting: on the first move `V - e` already contains `-(0.7*(c - c_prev)/1000)/s`, +which the feed-forward jump removes. Adding `-0.006*(V-e)` on top overshoots. + +## Proposed changes (all in `move_samx_to_scan_region`) + +1. **Newton step** as above instead of the fixed small gain. +2. **Remove the double counting.** + - **Option A (preferred):** compute the target from the actual position, + `x_target = x_now - DAMP*s*(V-e)`, and store + `pid_x_correction = x_target - 0.7*cenx/1000`, so the `cenx` lag is never + accumulated as drift. + - **Option B:** apply the feed-forward move first, re-read V, then correct. + Simpler, but costs about 1 s plus a tracker wait per correction. +3. **Derive `x_now` from the `fsamx` readback** instead of a running sum, so a + reload does not reset the bookkeeping. +4. **Clamp steps at the rail.** When `|V| >= 9.9`, cap the step at about 30 µm + and re-read instead of making blind ±60 µm steps. +5. **Hysteresis.** Start about 0.5 V, stop about 0.15 V (currently start 1.2 V, + stop 1.0 V, so the next `cenx` step re-triggers immediately). +6. **Logging.** + - Restore the `Expected PID voltage` line next to each `Current PID voltage`. + - Log `angle`, `cenx`, V and the `fsamx` readback per scan point. + - Log `laser_tracker_show_all()` output when `laser_tracker_wait_on_target` + times out (the tracker's beam position, target and piezo state are not + logged today). + +Sketch (not tested): + +```python +S, DAMP, FF, VCLIP = 0.0134, 0.85, 0.7e-3, 8.0 # mm/V, -, mm per cenx unit, V +x_now = fsamx.obj.readback.get() - fsamx_in # actual position (mm) +for _ in range(MAX_ITER): + v = self.get_pid_x() - expected_voltage + if abs(v) < V_STOP: + break + x_now -= DAMP * S * np.clip(v, -VCLIP, VCLIP) + fsamx.obj.pid_x_correction = x_now - FF * cenx # drift not explained by cenx + fsamx.obj.move(fsamx_in + x_now, wait=True) + time.sleep(0.1) + self.laser_tracker_on() +``` + +## What this will not fix + +The unexplained ~1 V scan-to-scan alternation (finding 5) is not addressed. A +0.05 V threshold would chase it with a move on nearly every scan point, and each +move costs a laser tracker wait. The plan therefore assumes about 0.15 V as the +stop level until the alternation is understood. + +## Verification (once approved) + +- **Offline replay:** run the proposed step logic against the logged + (V, `cenx`, position) sequences from 2026-09-18 and compare iterations per + correction and residual V with what happened. +- **Bench check:** on the instrument, force a known offset of about 2 V and + confirm convergence to below 0.15 V in at most 3 iterations with no sign + flip; repeat with about 5 V. +- **Rail case:** stop and restart a scan mid-way and confirm no ±10 V + overshoot and no tracker timeout. +- **Rate check:** confirm corrections per hour do not rise above the + 8.5/hour baseline. +- **Unit tests:** the step function should be a pure function so it can be + tested without hardware (see `tests/tests_devices`). + +## Open questions for discussion + +1. Is a threshold of 0.05 V realistic while the alternation is unexplained? The + plan assumes about 0.15 V for the stop level. +2. What is the true feed-forward factor: 0.7 (code) or about 0.8 (fit)? +3. Option A or B for the double counting? +4. Should the alternation be diagnosed first (angle dependence, backlash, the + `v0`/`v1` FZP feedback toggle) before any code change? +5. Is the 13.4 µm/V response stable across days? Only 2026-09-18 was analysed; + the rotated logs from 2026-09-11 to 2026-09-17 could be checked. +6. Should the already-made 1.2 V threshold change be kept, reverted, or left + until this is settled? It is currently uncommitted in the production working + tree. diff --git a/docs/plans/flomni-xrayeye-skip-button.md b/docs/plans/flomni-xrayeye-skip-button.md new file mode 100644 index 00000000..60f650e8 --- /dev/null +++ b/docs/plans/flomni-xrayeye-skip-button.md @@ -0,0 +1,120 @@ +# Plan: "Skip" button on the X-ray-eye alignment widget (flomni: leave out one angle, e.g. 90 deg) + +Status: **implemented 2026-09-21** on a BEC test server (tests: `tests_bec_ipython_client/test_flomni_xray_eye_skip.py`, +`tests_bec_widgets/test_x_ray_eye_skip_button.py`); tested live in the flomni simulator (works, ok'd by Ana); **not yet tried on real hardware** (see Verification). +Written after a read-only stability study of the DAP sine fit with 4 instead of 5 angles. + +Deviations from the plan below: the widget couples the Skip button to the Submit button +(`enable_skip_button(True)` only takes effect while Submit is enabled), so the client calls it +just once per run phase instead of next to every `enable_submit_button`. A refused skip re-sends +the pending step prompt. The h5 `alignment_values` dataset got a `step_k` attr and +`alignment_fit` a `skipped_angles_deg` attr. Skipping 0/180 is warned about, not forbidden. + +## Context + +Flat samples are edge-on at 90 deg, so their centre cannot be submitted there. The flomni X-ray-eye +alignment (`XrayEyeAlign._align_impl` in +`csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py`) submits 5 angles +0/45/90/135/180. The GUI fit tab (`OMNY_XRayEye` in +`csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py`) fits +`A*sin(f*x + shift) + intercept` through the DAP server (`LmfitService1D`, model +`SineModel + LinearModel`, `f` fixed to 1 deg, start values `SINE_FIT_DAP_PARAMETERS`), i.e. 3 free +parameters. With 4 points there is 1 degree of freedom (today 2). + +Wanted: a **Skip** button on the widget. One position may be skipped as long as at least 4 values +remain for the fit; otherwise error. + +## Stability study (does the fit still work with 4 points?) + +**Yes.** Method: (1) linear noise propagation for several angle sets, (2) Monte Carlo through the +real `LmfitService1D` (`configure(data_x=, data_y=, parameters=SINE_FIT_DAP_PARAMETERS, +oversample=5)` then `process()`, in `bec_venv`) with the widget's exact start values, compared with +the global least-squares optimum. Truth `A*sin(rad(angle)+phase)+C` plus Gaussian noise. There is no +real alignment data on disk, so the click noise sigma is unknown (pixel = 0.44 um, hand-placed ROI +centre, probably 1-3 um); results are in units of sigma. + +- ~49000 fits, **0 failed fits**. +- RMS error of the fitted correction curve over 0-180 / 0-360 deg (units of sigma): + + | angles used | cond. number | 0-180 | 0-360 | + |---|---|---|---| + | 0,45,90,135,180 (today) | 3.11 | 0.71 | 1.12 | + | skip 90: 0,45,135,180 | 3.23 | 0.88 | 1.29 | + | skip 45 or 135 | 2.87 | 0.82 | 1.17 | + | skip 0 or 180 | 5.36 | 1.02 | 1.91 | + + Monte Carlo at sigma = 1 um agrees (skip 90: median 0.72 sigma vs 0.61 sigma, p95 1.6 vs 1.2). + So skipping 90/45/135 is cheap (+15..25% noise); skipping an end point (0/180) is clearly worse + but still usable. +- Optimizer misses versus the global optimum only occur for a large offset |C| >~ 45 um (start + intercept C = 0 is far away): 15/4000 in that regime for both 4 and 5 points, 0/4000 for + |C| <= 20 um. A property of the start values, not of the point count. Optional hardening: seed + the intercept from the data mean (the static widget parameters cannot do that themselves). +- What is really lost with 4 points is **redundancy**: 1 dof, the residual has one fixed pattern + (0.71, -1, 1, -0.71 for 0,45,135,180), so chi-square / the plot cannot tell which click was bad, + and a mis-click at 0/180 (leverage 0.83) is mostly absorbed into phase/offset. +- Other 4-point layouts (0,60,120,180: 0.80 sigma; 0,30,150,180: 1.17 sigma). Extending beyond + 180 deg is the most robust option if the flat-sample geometry and fsamroy travel allow it: + 0,45,135,225 gives 0.84 / 0.93 sigma (0-180 / 0-360). + +## Decision: what does Skip send to the client script? + +**Reuse the existing `submit` signal with a sentinel: `omny_xray_gui.submit.set(-1)`** +(0 = idle, 1 = submit, -1 = skip). The widget writes no `xval_x_k / yval_y_k / width_*_k` for a skip. + +- The client already polls exactly this one signal (`submit.get() == 1`), so there is no new polling + path and a skip is atomic (no race between "values written" and "flag set"). +- No device change. A new `skip` `Signal` on `OMNYXRayAlignGUI` + (`csaxs_bec/devices/omny/xray_epics_gui.py`) would need a device-server reload; `submit` exists. +- No NaN/stale-data trick. `submit = 1` with NaN in `xval_x_k` would make every consumer + (`alignment_values`, `update_fov`, `roi_pixel_data`) responsible for noticing it, and the stale + values of an earlier run would be read if one forgets. With -1 the client never reads that step. + +Rejected: separate `skip` signal (cleaner, but needs device reload), NaN payload (implicit, +error-prone). + +## Implementation plan + +**Widget** (`x_ray_eye.py`; shared with LamNI and omny, so it must be inert for them): + +- `self.skip_button = QPushButton("Skip", ...)` under the Submit button, **disabled by default**. + Its slot does `self.dev.omny_xray_gui.submit.set(-1)` (no ROI needed, unlike `submit()`). +- New RPC `enable_skip_button(enable: bool)`: add to `USER_ACCESS` next to `enable_submit_button` + and regenerate `csaxs_bec/bec_widgets/widgets/client.py` (auto-generated by `generate_cli.py`). + LamNI/omny never call it, so the button stays disabled there. + +**Client** (`flomni/x_ray_eye_align.py`): + +- Loop: `submit_val = dev.omny_xray_gui.submit.get()`; act on `1` or `-1`; reset to 0 as today. +- `MIN_FIT_POINTS = 4`; track `self.skipped_steps`. A skip is legal only for real fit points (steps + 1-5 **after** `_height_centered`; not k = 0 FZP centre, not the height-centering submission at + k == 1) and only while `5 - n_skipped - 1 >= MIN_FIT_POINTS`. Otherwise do nothing to the run, + `send_message("Cannot skip: at least 4 positions are needed for the fit")` and keep waiting, so + the error is immediate and not after the whole rotation series. Call `enable_skip_button(False)` + once the budget is used and `True` wherever `enable_submit_button(True)` is called for steps 1-5. +- Skip branch: no `alignment_values[k]`, store a NaN / "skipped" row in `roi_pixel_data`, **no + `update_fov(k)`** (would read stale widths), then advance like a submit: for `1 <= k < 5` rotate + to the next angle (`tomo_rotate(k * 45)`, `update_frame`, next step message); at `k == 5` finish + (`hide_crosshair`, `break`) without `update_fov`. +- `write_output()`: build angles/offsets only from non-skipped steps (e.g. `[0,45,135,180]`); + final guard `if len(angles) < MIN_FIT_POINTS: raise`. `submit_fit_array` and the fit tab are + already length-agnostic. Record the skipped angles in the h5 (`alignment_fit` attrs). +- Messages: with 4 points "1 dof: the fit cannot flag a bad click - check the plot"; extra warning + if the skipped angle is 0 or 180 (noisier fit). Step numbering "k/5" stays. + +## Verification + +- Mock/`test_wo_movements=True` run of the client loop with a fake `submit` sequence: (1,1,-1,1,1) + ends with 4 fit points; a second -1 is refused with a message; -1 at k == 0 or at the + height-centering submission is refused; -1 at the last step finishes cleanly. +- `write_output()` gives the 4-point `angles`/`offsets`; the fit tab plots 4 points; DAP parameters + arrive in `omny_xray_gui.fit_params_x`; `flomni.read_alignment_offset(get_data_from_gui=True)` + loads sane `tomo_alignment_fit[0]`. +- Regression: the default 5-submit run is unchanged; LamNI/omny show Skip disabled. +- Hardware: dry run at the beamline with a flat sample, skipping 90 deg. + +## Open questions + +- Real click noise sigma (no saved alignment data on the analysis machine). +- Should skipping 0/180 be forbidden instead of only warned about? +- Is an angle beyond 180 deg possible for flat samples (better 4-point layout)? diff --git a/docs/plans/ids-camera-manual-exposure.md b/docs/plans/ids-camera-manual-exposure.md new file mode 100644 index 00000000..a9ba4965 --- /dev/null +++ b/docs/plans/ids-camera-manual-exposure.md @@ -0,0 +1,454 @@ +# Plan: Manual exposure / auto-gain control for IDSCamera + xrayeye widget knobs + +Status: **done — implemented and merged into `main`**, after four rounds of +real-hardware verification (see the addenda below for the bugs found and fixed +each round: startup auto-exposure/gain never actually engaging, a zero-ms +exposure floor, auto-gain being more useful as a one-time startup pulse than a +persistent toggle, and the pixel-clock slider needing to be index-based over +the camera's real discrete list instead of a ranged value). + +## Context + +`IDSCamera` (the cSAXS ophyd device wrapping IDS uEye cameras via the vendor's `pyueye` +SDK) currently gives operators no way to leave auto-exposure/auto-gain and drive the +camera manually. The underlying driver already has almost everything needed — it's just +not wired up or exposed to BEC clients: + +- `Camera.exposure_time` (get/set property) — `base_integration/camera.py:220-235` +- `Camera.set_auto_shutter(enable)` — `base_integration/camera.py:248-257` (auto-exposure toggle) +- `Camera.set_auto_gain(enable)` — `base_integration/camera.py:237-246` (auto-gain toggle) + +None of these three are called anywhere, and none are on `IDSCamera.USER_ACCESS`, so a +BEC client can't reach them today. + +`OMNY_XRayEye` (`csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py`) is the GUI panel +for the flomni/LamNI/OMNY X-ray-eye alignment camera (`cam_xeye`, an `IDSCamera` +device). It should get exposure/auto-gain control knobs too, using the same +cached/event-driven pattern it already uses for its "Camera running" toggle, rather than +polling the device with RPC calls. + +Per user decisions (2026-09-11): cover exposure control + the auto-gain enable/disable +toggle only (no manual gain *value* — the driver has no SDK call for that yet); leave +connect-time default behavior untouched (no new constructor/config kwarg — the camera +keeps whatever state `is_ResetToDefault()` leaves it in unless a client/GUI explicitly +calls the new methods); and be careful to use cached readings wherever possible rather +than polling the hardware for GUI display. + +## Design: `Cpt(Signal, kind=Kind.config)`, not plain methods only + +`OMNY_XRayEye`'s existing "Camera running" toggle is the template for a control knob +that both *writes* device state and *reflects* it back without polling: + +- **Write**: `self.dev.get(CAMERA[0]).live_mode_enabled.put(enabled)` (`x_ray_eye.py:882`) + — RPC `.put()` on an ophyd `Cpt(Signal, kind=Kind.config)` on the device + (`ids_camera.py:59-65`). +- **Read (cached/event-driven, no polling)**: the widget subscribes once, at + construction, to `MessageEndpoints.device_read_configuration(CAMERA[0])` + (`x_ray_eye.py:293-295`) — BEC pushes **all** `Kind.config` signals of the device + together in one message whenever any of them changes. `getting_camera_status` + (`x_ray_eye.py:886-892`) reads `data["signals"]["cam_xeye_live_mode_enabled"]["value"]` + and updates the toggle via the `blockSignals(True)/.checked = .../blockSignals(False)` + idiom (to avoid re-triggering the write handler). + +This matches the repo's established convention for Signal-backed device state +(confirmed against `slit_control.py`, `sample_storage.py`, `tomo_params.py`: +`read(cached=True)` for polled numeric readbacks, and +`bec_dispatcher.connect_slot(..., device_read_configuration(...))` for `Kind.config` +signals, which is cheaper still — zero RPC round-trips, pure pub/sub). So exposure_time +and the two auto-enable flags should be `Cpt(Signal, kind=Kind.config)` on `IDSCamera` +(not plain methods only), with thin `USER_ACCESS` wrapper methods kept on top for +ipython/scripted use — mirroring how `start_live_mode()`/`stop_live_mode()` are just +wrappers around `live_mode_enabled.put()`. **No code should poll `get_exposure_time()` / +`get_gain()` RPCs from a `QTimer` loop.** + +## Changes + +### 1. `csaxs_bec/devices/ids_cameras/ids_camera.py` + +Add three new components, next to `live_mode_enabled` (`ids_camera.py:59-65`): + +```python +exposure_time = Cpt( + Signal, name="exposure_time", value=0.0, + doc="Camera exposure time (ms).", kind=Kind.config, +) +auto_exposure_enabled = Cpt( + Signal, name="auto_exposure_enabled", value=True, + doc="Enable/disable auto-exposure (auto-shutter).", kind=Kind.config, +) +auto_gain_enabled = Cpt( + Signal, name="auto_gain_enabled", value=True, + doc="Enable/disable auto-gain.", kind=Kind.config, +) +``` + +In `__init__`, alongside the existing `live_mode_enabled.subscribe(...)` +(`ids_camera.py:127`): + +```python +self.exposure_time.subscribe(self._on_exposure_time_changed, run=False) +self.auto_exposure_enabled.subscribe(self._on_auto_exposure_enabled_changed, run=False) +self.auto_gain_enabled.subscribe(self._on_auto_gain_enabled_changed, run=False) +``` + +New callbacks + thin `USER_ACCESS` wrappers, in a new +`############## Exposure / Gain ##############` section (placed after the live-mode/ROI +methods and before `############## User Interface Methods ##############`): + +```python +def _on_exposure_time_changed(self, *args, value, **kwargs): + self.cam.exposure_time = value + +def _on_auto_exposure_enabled_changed(self, *args, value, **kwargs): + self.cam.set_auto_shutter(bool(value)) + +def _on_auto_gain_enabled_changed(self, *args, value, **kwargs): + self.cam.set_auto_gain(bool(value)) + +def get_exposure_time(self) -> float: + """Get the current exposure time (ms), from the cached Signal value.""" + return float(self.exposure_time.get()) + +def set_exposure_time(self, value: float) -> None: + """Set the exposure time (ms). Does not itself disable auto-exposure -- + call set_auto_exposure_enabled(False) first, or the driver will keep overriding it.""" + self.exposure_time.put(value) + +def set_auto_exposure_enabled(self, enable: bool) -> None: + self.auto_exposure_enabled.put(bool(enable)) + +def set_auto_gain_enabled(self, enable: bool) -> None: + self.auto_gain_enabled.put(bool(enable)) +``` + +(Confirm the SDK's exposure unit — `is_Exposure`/`IS_EXPOSURE_CMD_*` is documented in +milliseconds; adjust the docstring if inspection shows otherwise.) + +Add the four wrapper names to `USER_ACCESS` (`ids_camera.py:67-76`): +`"get_exposure_time"`, `"set_exposure_time"`, `"set_auto_exposure_enabled"`, +`"set_auto_gain_enabled"`. + +In `on_connected()` (`ids_camera.py:283-288`), after `self.cam.on_connect()`, seed the +`exposure_time` signal from the real hardware value once, so the GUI shows a real number +immediately on connect instead of the `0.0` placeholder: + +```python +self.exposure_time.put(self.cam.exposure_time) +``` + +(This round-trips through `_on_exposure_time_changed`, which writes the same value back +to the driver — a harmless one-time no-op write, same idiom as the existing +`self.live_mode_enabled.put(bool(self._inputs.get("live_mode", False)))` line right +below it.) `auto_exposure_enabled`/`auto_gain_enabled` are **not** seeded from hardware — +there is no SDK query for current auto-shutter/auto-gain state in this driver (only +enable-setters), so, like `live_mode_enabled`, they start at their declared default +(`True`, matching the uEye SDK's typical post-reset default) and only reflect reality +once explicitly set through this API. + +No changes needed in `base_integration/camera.py` — `exposure_time`, `set_auto_shutter`, +`set_auto_gain` already exist there exactly as needed. + +### 2. `csaxs_bec/devices/sim/sim_cameras.py` + +`SimIDSCamera` replaces `self.cam` with `_SimIDSBackend` (line 211), which currently has +no `exposure_time` attribute and no `set_auto_shutter`/`set_auto_gain` methods — +`on_connected()`'s `self.exposure_time.put(self.cam.exposure_time)` and the new +subscribe callbacks would raise `AttributeError` against `SimIDSCamera` otherwise. Add to +`_SimIDSBackend` (mirroring `_SimAlliedVisionBackend.exposure_time`, lines 397-403): + +```python +def __init__(self, ...): + ... + self._exposure_time = 10000.0 # ms + self._auto_exposure = True + self._auto_gain = True + +@property +def exposure_time(self) -> float: + return self._exposure_time + +@exposure_time.setter +def exposure_time(self, value: float): + self._exposure_time = value + +def set_auto_shutter(self, enable: bool): + self._auto_exposure = bool(enable) + +def set_auto_gain(self, enable: bool): + self._auto_gain = bool(enable) +``` + +### 3. Tests — `tests/tests_devices/test_ids_camera.py` + +```python +def test_get_set_exposure_time(ids_camera): + ids_camera.set_exposure_time(1234.5) + assert ids_camera.cam.exposure_time == 1234.5 + assert ids_camera.get_exposure_time() == 1234.5 + +def test_set_auto_exposure_enabled(ids_camera): + ids_camera.set_auto_exposure_enabled(False) + ids_camera.cam.set_auto_shutter.assert_called_once_with(False) + +def test_set_auto_gain_enabled(ids_camera): + ids_camera.set_auto_gain_enabled(False) + ids_camera.cam.set_auto_gain.assert_called_once_with(False) + +def test_on_connected_seeds_exposure_time(ids_camera): + ids_camera.cam.on_connect = mock.Mock() + ids_camera.cam.exposure_time = 4200.0 + ids_camera.on_connected() + assert ids_camera.get_exposure_time() == 4200.0 +``` + +## Phase 2: GUI control knobs in `OMNY_XRayEye` + +### 4. `csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py` + +**Imports**: add `QDoubleSpinBox` to the `qtpy.QtWidgets` import block (`x_ray_eye.py:15-29`) +— only `QSpinBox` is imported today. + +**New widgets in `_init_ui`**, extending the existing `switch_grid` (`x_ray_eye.py:368-404`) +with a 3rd row (same `QGridLayout` the shutter/camera-running and smear toggles already +share, so columns keep lining up): + +```python +self.auto_exposure_label = QLabel("Auto exposure", parent=self) +self.auto_exposure_toggle = ToggleSwitch(parent=self) +self.auto_exposure_toggle.checked = True +self.auto_exposure_toggle.enabled.connect(self.auto_exposure_enabled_changed) + +self.auto_gain_label = QLabel("Auto gain", parent=self) +self.auto_gain_toggle = ToggleSwitch(parent=self) +self.auto_gain_toggle.checked = True +self.auto_gain_toggle.enabled.connect(self.auto_gain_enabled_changed) + +switch_grid.addWidget(self.auto_exposure_label, 2, 1, _right_vcenter) +switch_grid.addWidget(self.auto_exposure_toggle, 2, 2, Qt.AlignmentFlag.AlignVCenter) +switch_grid.addWidget(self.auto_gain_label, 2, 3, _right_vcenter) +switch_grid.addWidget(self.auto_gain_toggle, 2, 4, Qt.AlignmentFlag.AlignVCenter) +``` + +An exposure-time spinbox, added as its own small form near the step-size form +(`x_ray_eye.py:424-439`), mirroring that form's `QGridLayout` structure: + +```python +exposure_form = QGridLayout() +self.exposure_time_spin = QDoubleSpinBox(parent=self) +self.exposure_time_spin.setRange(0.01, 1000.0) # ms; confirm real driver range +self.exposure_time_spin.setDecimals(2) +self.exposure_time_spin.setSuffix(" ms") +self.exposure_time_spin.setEnabled(False) # auto-exposure starts enabled +self.exposure_time_spin.editingFinished.connect(self.exposure_time_submitted) +exposure_form.addWidget(QLabel("Exposure time", parent=self), 0, 0) +exposure_form.addWidget(self.exposure_time_spin, 0, 1) +self.control_panel_layout.addLayout(exposure_form) +``` + +Using `editingFinished` (fires once, on Enter/focus-loss) rather than `valueChanged` +(fires on every arrow-button click/scroll tick) is the throttling choice here — the +"don't hammer the device" concern applied to a manual-entry control instead of a poll +loop. + +**New handlers**, alongside `camera_running_enabled`/`opening_shutter` +(`x_ray_eye.py:875-906`): + +```python +@SafeSlot(bool) +def auto_exposure_enabled_changed(self, enabled: bool): + if self._manual_toggle_blocked_by_queue(): + logger.warning("Ignoring auto-exposure toggle while scan queue is busy.") + return + self.auto_exposure_toggle.blockSignals(True) + self.dev.get(CAMERA[0]).auto_exposure_enabled.put(enabled) + self.auto_exposure_toggle.checked = enabled + self.exposure_time_spin.setEnabled(not enabled) + self.auto_exposure_toggle.blockSignals(False) + +@SafeSlot(bool) +def auto_gain_enabled_changed(self, enabled: bool): + if self._manual_toggle_blocked_by_queue(): + logger.warning("Ignoring auto-gain toggle while scan queue is busy.") + return + self.auto_gain_toggle.blockSignals(True) + self.dev.get(CAMERA[0]).auto_gain_enabled.put(enabled) + self.auto_gain_toggle.checked = enabled + self.auto_gain_toggle.blockSignals(False) + +def exposure_time_submitted(self): + self.dev.get(CAMERA[0]).exposure_time.put(self.exposure_time_spin.value()) +``` + +**Extend the existing `getting_camera_status`** (`x_ray_eye.py:886-892`) — no new Redis +subscription needed, since `exposure_time`/`auto_exposure_enabled`/`auto_gain_enabled` +are `Kind.config` signals on the same device, so they already arrive in the same +`device_read_configuration(CAMERA[0])` message `live_mode_enabled` uses: + +```python +@SafeSlot(dict, dict) +def getting_camera_status(self, data, meta): + signals = data.get("signals") + live_mode_enabled = signals.get(f"{CAMERA[0]}_live_mode_enabled").get("value") + self.camera_running_toggle.blockSignals(True) + self.camera_running_toggle.checked = live_mode_enabled + self.camera_running_toggle.blockSignals(False) + + auto_exp = signals.get(f"{CAMERA[0]}_auto_exposure_enabled") + if auto_exp is not None: + enabled = bool(auto_exp.get("value")) + self.auto_exposure_toggle.blockSignals(True) + self.auto_exposure_toggle.checked = enabled + self.exposure_time_spin.setEnabled(not enabled) + self.auto_exposure_toggle.blockSignals(False) + + auto_gain = signals.get(f"{CAMERA[0]}_auto_gain_enabled") + if auto_gain is not None: + self.auto_gain_toggle.blockSignals(True) + self.auto_gain_toggle.checked = bool(auto_gain.get("value")) + self.auto_gain_toggle.blockSignals(False) + + exposure_time = signals.get(f"{CAMERA[0]}_exposure_time") + if exposure_time is not None: + self.exposure_time_spin.blockSignals(True) + self.exposure_time_spin.setValue(float(exposure_time.get("value"))) + self.exposure_time_spin.blockSignals(False) +``` + +The `is not None` guards are defensive (harmless if this ever runs against an older +`IDSCamera` revision without these signals) and cost nothing. + +## Verification + +- Unit tests: `pytest tests/tests_devices/test_ids_camera.py -v`. GUI widget tests (if + any exist under `tests/` for `x_ray_eye.py` — check before implementing) should cover + `getting_camera_status` updating the three new widgets from a synthetic message dict, + and `exposure_time_submitted`/`auto_*_enabled_changed` calling the right RPC `.put()`. +- Manual/simulation check: run against `SimIDSCamera` (no real hardware needed) — + confirm toggling "Auto exposure" off enables the spinbox, entering a value + Enter (or + focus-loss) calls `exposure_time.put(...)`, and the value round-trips back through + `getting_camera_status` into the spinbox display without any added polling. +- Manual/integration check on real hardware (during a non-live test session): open the + xrayeye widget against a connected `cam_xeye`, toggle auto-exposure off, set an + exposure time, and confirm the live image brightness responds and stays stable + (doesn't drift back, confirming auto-exposure is actually off). + +## Addendum: HW-testing findings, round 1 (2026-09-14) + +Real-hardware testing against `test_ids_camera_41.yaml` surfaced one bug and two +follow-up improvements, all now implemented: + +- **Bug**: toggling "Auto gain" raised `ophyd.ophydobj | [ERROR] | Subscription value + callback exception` from the device server. Root cause: `Camera.set_auto_gain()` / + `set_auto_shutter()` (`base_integration/camera.py`) passed `ueye.c_int()` for + `is_SetAutoParameter`'s `pval1`/`pval2`, which the SDK defines as `double *` (8 bytes) + — the driver read 4 bytes past a `c_int` (4 bytes) as the rest of the double, got a + near-never-exactly-0.0/1.0 garbage value, and rejected it. Fixed by passing + `ueye.c_double(1.0/0.0)` instead, matching upstream pyueye examples. Also wrapped all + three hardware-write callbacks (`_on_exposure_time_changed`, + `_on_auto_exposure_enabled_changed`, `_on_auto_gain_enabled_changed`) in try/except so + a *future* driver-level failure logs clearly from `IDSCamera` itself instead of + surfacing only as ophyd's generic subscription-exception message. +- **Exposure ceiling**: the max settable exposure time is bounded by the camera's + current pixel clock (lower pixel clock -> longer max exposure, less frame rate). + Added `Camera.get_exposure_range()` (`IS_EXPOSURE_CMD_GET_EXPOSURE_RANGE`) and + `get_pixel_clock()`/`get_pixel_clock_range()`/`set_pixel_clock()` + (`IS_PIXELCLOCK_CMD_GET`/`_GET_RANGE`/`_SET`) to `base_integration/camera.py`, exposed + on `IDSCamera` as `get_exposure_time_range()`/`get_pixel_clock()`/ + `get_pixel_clock_range()`/`set_pixel_clock()` (`USER_ACCESS`). New + `exposure_time_min`/`exposure_time_max` `Kind.config` signals are seeded in + `on_connected()` and re-seeded by `set_pixel_clock()`. +- **Widget**: replaced the exposure-time spinbox with a `QSlider` bounded to + `exposure_time_min`/`max` (seeded from the device_read_configuration message, same as + everything else — still no polling), and reorganized the control panel: a horizontal + separator below the shutter/camera-running/smear switches, then the exposure/gain + section (auto-exposure toggle, auto-gain toggle, exposure slider), then another + separator, then the alignment values (2D positioner + zoom) below. + +## Addendum: HW-testing findings, round 2 (2026-09-14) + +Follow-up round after the auto-gain fix confirmed working on hardware: + +- **Bug**: after a fresh device-server start the image was noticeably under-exposed, + fixed by toggling "Auto gain" off then on. Root cause: `auto_exposure_enabled`/ + `auto_gain_enabled` default to `True`, but unlike `live_mode_enabled`/`exposure_time`, + `on_connected()` never actually *applied* that default to hardware — it only left the + Signal's cached value at `True` without ever calling `set_auto_shutter()`/ + `set_auto_gain()`. `is_ResetToDefault()` (called on every connect, inside + `IDSCameraObject.__init__`) leaves the sensor's auto-shutter/auto-gain off, so the + camera actually ran with both off until an operator happened to re-toggle the GUI + switch — which is what issued the enable call for the first time. Fixed by + round-tripping both through their subscribe callbacks in `on_connected()` (` + self.auto_exposure_enabled.put(bool(self.auto_exposure_enabled.get()))` and the same + for `auto_gain_enabled`), same idiom as `exposure_time` already used. (This also fully + explains "auto gain seems to adjust once, then stay fixed" — it wasn't a one-shot + algorithm limitation, it just was never actually enabled until manually toggled; once + actually engaged it converges and correctly stays put for an unchanging scene.) +- **Bug**: the exposure-time slider allowed a literal 0 ms, which isn't physical. + Root cause: `getting_camera_status()` rounded the hardware-reported min (e.g. some + sub-0.1 ms value) to the nearest tenth of a ms for the slider's integer units, and + `round()` can round a small-but-nonzero min down to `0`. Fixed by rounding the min UP + (`math.ceil`, clamped to at least 1) and the max DOWN (`math.floor`) instead of nearest + — the slider must never claim a bound the hardware won't actually accept. +- **Feature**: added a "Pixel clock" slider to the widget (was previously script-only + via `set_pixel_clock()`), directly below the exposure-time slider in the exposure/gain + section, bounded to `pixel_clock_min`/`max` (also new `Kind.config` signals, seeded on + connect the same way as the exposure ones) — since operators hitting the exposure + slider's ceiling need this knob to raise it further, without dropping to a script. + +## Addendum: HW-testing findings, round 3 (2026-09-14) + +Follow-up after confirming the round-1/2 fixes on hardware (startup exposure now good). +Two more findings: + +- **"Auto gain is kind of useless" (design change, not a bug)**: continuous auto-gain + has nothing further to adjust once the image is correctly exposed, and leaving it + enabled takes gain out of the operator's manual control. Changed `on_connected()` to + pulse `auto_gain_enabled` on then off (`_AUTO_GAIN_SETTLE_S = 0.5`s in between, long + enough for a few frames from the already-running continuous capture to be processed), + instead of leaving it enabled like `auto_exposure_enabled`. This still fixes the + under-exposed-at-start symptom (a one-time gain correction) while leaving gain fixed + under manual control afterward. Removed the "Auto gain" toggle from `OMNY_XRayEye` + entirely (`_init_ui`, `getting_camera_status`, `_queue_guarded_toggles`, + `auto_gain_enabled_changed`) — `auto_gain_enabled` is now purely an internal + connect-time mechanism (still on `IDSCamera.USER_ACCESS` via `set_auto_gain_enabled()` + for scripted use), not a persistent GUI control. +- **Bug**: the pixel-clock slider raised `failed to set pixel_clock= on hardware` + for essentially every value dragged to (49, 60, 73 MHz all failed on camera 41). Root + cause: `get_pixel_clock_range()`'s (min, max, increment) describes a *linear* range, + but `IS_PIXELCLOCK_CMD_SET` doesn't actually accept every value in it — many uEye + sensors only support a short discrete list of pixel clocks. Added + `Camera.get_pixel_clock_list()` (`IS_PIXELCLOCK_CMD_GET_NUMBER` + + `IS_PIXELCLOCK_CMD_GET_LIST`, the SDK's authoritative source for what's actually + settable) and `IDSCamera.get_pixel_clock_list()`. `IDSCamera.set_pixel_clock()` now + snaps to the nearest value in that list before writing, instead of passing the raw + slider value straight to the driver — so the widget doesn't need to know about the + discrete list itself; it just gets a corrected value back on the next + `device_read_configuration` message, same round-trip pattern as everything else here. + +## Addendum: HW-testing findings, round 4 (2026-09-14) + +Round 3's server-side snap-to-nearest fixed what actually got *written*, but the +slider itself still let an operator drag to (and briefly display) any integer in +[min, max] before self-correcting on the next status message — reported as "the +pixel clock slider still allows for any integer setting and not the specific ones". +That's a worse interaction than just rejecting bad values: the operator sees the +slider land somewhere it can't actually stay. + +Fixed by making `pixel_clock_slider` index-based over the real discrete list instead +of ranged over `[pixel_clock_min, pixel_clock_max]`: + +- `OMNY_XRayEye` fetches `get_pixel_clock_list()` once over RPC at widget startup + (`_init_pixel_clock_options()`, `QTimer.singleShot(0, ...)` alongside the widget's + other one-time init calls) — this list is static per camera, so a one-time call is + the right trade-off versus adding a whole new signal/subscription path for something + that never changes at runtime. The slider is disabled until this arrives. +- The slider's range becomes `[0, len(options)-1]`; its *position* is an index into + `self._pixel_clock_options`, so every position it can physically be dragged to + (`pixel_clock_submitted()`) is one the hardware has already confirmed it accepts — + no more relying on a post-hoc correction the operator has to notice. +- `getting_camera_status()`'s pixel_clock handling now maps the hardware-reported MHz + value to the *nearest* option's index (`_set_pixel_clock_display()`) rather than + setting the slider to a raw MHz value directly. +- `pixel_clock_min`/`pixel_clock_max` signals are unchanged on `IDSCamera` (still + informational, still seeded on connect) but are no longer read by the widget, which + no longer needs a numeric range at all. diff --git a/docs/plans/xrayeye-vertical-lock.md b/docs/plans/xrayeye-vertical-lock.md new file mode 100644 index 00000000..a0cfd008 --- /dev/null +++ b/docs/plans/xrayeye-vertical-lock.md @@ -0,0 +1,145 @@ +# Plan: Auto-lock vertical center of the sample selection box to the FZP height (flomni X-ray eye alignment) + +Status: **not implemented** (written during live beamtime operation on request; no +code changes made — see conversation this plan came from). + +## Context + +The flomni X-ray eye alignment procedure (`XrayEyeAlign._align_impl` in +`csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py`) works in two phases: + +1. **Step 0** — the user draws a selection box around the FZP and submits it + (`xval_x_0`/`yval_y_0`). This position is stored and a fixed crosshair + (`TargetCrosshair`, drawn via `self.gui.set_crosshair_position()` / + `show_crosshair()`) is placed on the live view at that FZP center — the + horizontal line of that crosshair marks the FZP's vertical (`y`) position. +2. **Height centering** — the user draws one more box around the sample and + submits it (still logically "step 1"). The script computes + `delta_y_mm = HEIGHT_CENTERING_SIGN * (fzp_center_y - mark_y) * pixel_calibration` + and moves `fsamy` so the sample lines up with the FZP height on camera + (`x_ray_eye_align.py:307-340`). `self._height_centered` flips to `True` + right after this move. +3. **Steps 1-5 (angles 0°/45°/90°/135°/180°)** — for each rotation angle, the + user draws a new box around the sample and submits it; only the box's `x` + center is used (`alignment_values[k]`, used later to compute per-angle + fovx offsets). The vertical (`y`) position of these boxes is not used for + anything, but the user currently still has to eyeball/re-draw it near the + right height each time. + +Once `_height_centered` becomes `True`, the sample's vertical position is +physically fixed for the rest of the run — it should equal the FZP's `y` +(the same value the crosshair's horizontal line is drawn at). The goal is +for the selection box in steps 1-5 to no longer require manual vertical +placement: draw the box anywhere, and its vertical center snaps to the FZP +height automatically, staying correct even if the user drags or resizes it +further, right up until they hit submit. + +This must **not** affect the box drawn during the height-centering step +itself (the one submission where `k == 1 and not self._height_centered`) — +that box's vertical position is the actual measurement the height +correction is computed from, so it must remain fully free. + +## Design + +Add an explicit widget-side "vertical lock" flag, toggled by the flomni +script via a new RPC method pair on `OMNY_XRayEye`, mirroring the existing +`show_crosshair()`/`hide_crosshair()` pattern (`x_ray_eye.py:238-261` for the +USER_ACCESS declarations, `x_ray_eye.py:833-874` for implementations). This +avoids adding a new EPICS/ophyd `Signal` and avoids ambiguity from +overloading the existing `step` counter (confirmed by reading +`x_ray_eye_align.py`: `step` reads `1` for *both* the height-centering box +and the first real angle-0° box, so `step` alone can't distinguish "lock" +from "don't lock"). + +- New widget state: `self._vertical_lock_enabled: bool = False`, initialized + alongside `self.target_crosshair` (`x_ray_eye.py:299`). +- New RPC methods on `OMNY_XRayEye` (USER_ACCESS, same section as + `show_crosshair`/`hide_crosshair`): `lock_vertical_center()` / + `unlock_vertical_center()`. They just set `self._vertical_lock_enabled`. +- New helper `_snap_roi_vertical(self, roi)`: + - No-op if `not self._vertical_lock_enabled` or the crosshair isn't + visible. + - Otherwise reads the crosshair's fixed `y` (the same value used by + `crosshair_position()`, `x_ray_eye.py:868-874`), reads the roi's current + `x` origin and height via `roi.pos()` / `roi.get_coordinates(typed=True)`, + computes `new_origin_y = fzp_y - height / 2` (ROIs are positioned by + bottom-left origin + size, not by center — confirmed via + `bec_widgets/widgets/plots/roi/image_roi.py`), and calls + `roi.set_position(current_x, new_origin_y)`. Guard against signal + re-entrancy (block signals or a re-entrancy flag) while calling + `set_position`, since this will itself be triggered from a + region-changed signal. +- Wire this helper into two places: + - `_style_new_roi` (`x_ray_eye.py:630-636`, connected to + `self.roi_manager.controller.roiAdded`) — call it right after the ROI is + styled, so a freshly-drawn box snaps immediately (`roiAdded` already + fires only after the user finishes drawing/releases the mouse, so this + naturally happens post-draw, not mid-drag). + - Each new ROI's `sigRegionChangeFinished` (pyqtgraph's inherited + "drag/resize finished" signal) — connect it in the same place `roiAdded` + is handled, so any subsequent manual drag or resize snaps back to the + correct vertical center as soon as the user releases the mouse, without + fighting the live drag. (No extra visual affordance beyond this — the + box stays draggable/resizable in both axes, it's just always corrected + back to the right vertical center on release.) + +### Script-side wiring (`x_ray_eye_align.py`) + +- Call `self.gui.lock_vertical_center()` right after + `self._height_centered = True` is set (line 334), before the "Step 1/5" + prompt — so every box from here through step 5 is auto-centered + vertically. The height-centering box itself (processed just before this + line) is unaffected since the lock isn't enabled yet at that point. +- Call `self.gui.unlock_vertical_center()`: + - At the start of a fresh run, alongside `self._reset_init_values()` + (`x_ray_eye_align.py:196`) or where the crosshair is implicitly reset, + so a new alignment run doesn't inherit a stale lock. + - In `align()`'s `finally` block (`x_ray_eye_align.py:172`), alongside the + existing `self.gui.hide_crosshair()` cleanup call, so the lock never + outlives a run (including on error/interruption). + +LamNI and OMNY have their own near-identical `x_ray_eye_align.py` scripts +but different height/rotation procedures — out of scope here. The widget +change is inert by default (`_vertical_lock_enabled` starts `False` and is +only ever set by a script that explicitly opts in), so it's safe to leave +their scripts untouched. + +## Files to change + +- `csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py` + - Add `self._vertical_lock_enabled` state near `self.target_crosshair` + init (~line 299). + - Add `lock_vertical_center()` / `unlock_vertical_center()` USER_ACCESS + RPC methods (declare near ~238-261, implement near ~833-874, next to the + crosshair show/hide methods). + - Add `_snap_roi_vertical(self, roi)` helper. + - In `_style_new_roi` (~630-636): call `_snap_roi_vertical(roi)` after + existing styling, and connect `roi.sigRegionChangeFinished` to + `lambda: self._snap_roi_vertical(roi)`. +- `csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py` + - Add `self.gui.lock_vertical_center()` call after line 334 + (`self._height_centered = True`). + - Add `self.gui.unlock_vertical_center()` calls at run start (near line + 196) and in `align()`'s `finally` block (near line 172). + +## Verification + +Since this is real beamtime hardware/procedure code, verify without +disrupting operation: +1. Read back the diff carefully against the exact step sequence traced above + (k=0 FZP, k=1 first pass height-centering, k=1 second pass angle-0°, + k=2..5 angles 45°-180°) to confirm the lock is enabled exactly once, + right after `_height_centered` flips, and disabled on both run-start and + run-teardown paths. +2. If a test/offline instance of the GUI is available (`test_wo_movements` + flag on `XrayEyeAlign`, or a non-production BEC session), run through a + full alignment: confirm the FZP box and height-centering box are + completely unaffected (free vertical placement, `delta_y_mm` computed + correctly), then confirm boxes drawn for steps 1-5 immediately snap their + vertical center to the crosshair's horizontal line, and stay snapped + after manual drag/resize. +3. Confirm no infinite signal loop / visual jitter when dragging or resizing + a locked box (guard against `_snap_roi_vertical`'s own `set_position` + call re-triggering `sigRegionChangeFinished`). +4. Only test live on the actual flomni beamline once available and outside + critical beamtime, per standing practice for this codebase. diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 247a183f..00000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -sphinx<9 -sphinx_copybutton -recommonmark -sphinx-toolbox -pydata-sphinx-theme -sphinx-copybutton -sphinx-inline-tabs -myst-parser -sphinx-design -tomli diff --git a/docs/safety/safety.md b/docs/safety/safety.md index a846d1fb..4a52af3a 100644 --- a/docs/safety/safety.md +++ b/docs/safety/safety.md @@ -1,5 +1,3 @@ -(safety)= - # Safety at cSAXS Working safely at the beamline is everyone's responsibility. All activities must @@ -8,30 +6,28 @@ with the ongoing work. Please read this page and the full checklist before starting your experiment, and discuss any safety-related question with your local contact. -```{important} -**No experiment may be conducted before the signed Safety Declaration form has -been handed to your local contact.** Every person working at the beamline must be -named on that form. Violations result in the immediate termination of the -experiment. +!!! warning "Important" + **No experiment may be conducted before the signed Safety Declaration form has + been handed to your local contact.** Every person working at the beamline must be + named on that form. Violations result in the immediate termination of the + experiment. -📄 **[Open the Safety Declaration form (PDF)](https://www.psi.ch/en/media/58834/download)** -``` + 📄 **[Open the Safety Declaration form (PDF)](https://www.psi.ch/en/media/58834/download)** -```{admonition} In an emergency -:class: danger -- **Call 3333** — be ready to state *Who? What? When? Where?* -- Know the location of the nearest **emergency exit**, **fire extinguisher**, - **emergency shower / eye wash**, and **first-aid kit**. -- Never hesitate to ask your local contact for advice or help. -``` +!!! danger "In an emergency" + - **Call 3333** — be ready to state *Who? What? When? Where?* + - Know the location of the nearest **emergency exit**, **fire extinguisher**, + **emergency shower / eye wash**, and **first-aid kit**. + - Never hesitate to ask your local contact for advice or help. + ## Safety checklist (printable PDF) Your local contact will go through the *Safety at cSAXS* checklist with you, covering emergencies, general behaviour, working inside the hutch, and experiment safety. It is available as a printable PDF. -{download}`⬇ Download the "Safety at cSAXS" checklist (PDF) <../assets/safety-at-csaxs.pdf>` +[⬇ Download the "Safety at cSAXS" checklist (PDF)](../assets/safety-at-csaxs.pdf) ## Key safety links diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 00000000..45b68390 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,61 @@ +/* The original card SVGs have fixed dark fills; keep them visible in dark mode. */ +[data-md-color-scheme="slate"] .md-typeset .card-icon { + filter: brightness(0) invert(0.85); +} + +/* Keep section cards balanced when icon proportions and title lengths differ. */ +.md-typeset .section-grid { + grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr)); + gap: 1rem; +} + +.md-typeset .section-grid.cards > ul > li { + position: relative; + padding: 1.1rem; +} + +/* Stretch the existing heading link across its card without extra tab stops. */ +.md-typeset .section-grid .card-heading a::after { + position: absolute; + inset: 0; + content: ""; +} + +.md-typeset .section-grid.cards > ul > li:focus-within { + outline: 2px solid var(--md-accent-fg-color); + outline-offset: 2px; +} + +.md-typeset .section-grid .card-heading { + display: flex; + align-items: center; + gap: 0.7rem; + min-height: 2.6rem; + margin: 0 0 0.8rem; + line-height: 1.4; +} + +.md-typeset .section-grid .card-icon { + flex: 0 0 1.6rem; + width: 1.6rem; + height: 1.6rem; + object-fit: contain; + margin: 0; +} + +.md-typeset .section-grid .card-heading strong { + font-size: 0.85rem; +} + +.md-typeset .section-grid .card-heading a { + text-decoration: none; +} + +.md-typeset .section-grid .card-heading a:hover, +.md-typeset .section-grid .card-heading a:focus-visible { + text-decoration: underline; +} + +.md-typeset .section-grid .card-heading + p { + margin: 0; +} diff --git a/docs/user/before_you_arrive/before_you_arrive.md b/docs/user/before_you_arrive/before_you_arrive.md index 3064ea7a..6f0e8c80 100644 --- a/docs/user/before_you_arrive/before_you_arrive.md +++ b/docs/user/before_you_arrive/before_you_arrive.md @@ -1,5 +1,4 @@ -(user.before_you_arrive)= # Before you arrive -## Before you arrive \ No newline at end of file +## Before you arrive diff --git a/docs/user/data_analysis/computing.md b/docs/user/data_analysis/computing.md index 54283d44..7e0b660c 100644 --- a/docs/user/data_analysis/computing.md +++ b/docs/user/data_analysis/computing.md @@ -1,4 +1,3 @@ -(user.data_analysis.computing)= # Online data analysis We can access the online computing resources from the beamline consoles when logged in as the e-account. diff --git a/docs/user/data_analysis/data_analysis.md b/docs/user/data_analysis/data_analysis.md index c145f374..848027e3 100644 --- a/docs/user/data_analysis/data_analysis.md +++ b/docs/user/data_analysis/data_analysis.md @@ -1,34 +1,13 @@ -(user.data_analysis)= # Data analysis Welcome to the data analysis section of the cSAXS beamline. -```{toctree} ---- -maxdepth: 2 -hidden: true ---- +
-computing +- ![](../../assets/index_user_guide.svg){ width="32" height="32" .middle .card-icon } **[Online data analysis](computing.md)** + { .card-heading } -``` + Allocating compute nodes, programming using Python and Matlab during the beamtime. +
-*** - -````{grid} 2 -:gutter: 5 - -```{grid-item-card} -:link: user.data_analysis.computing -:link-type: ref -:img-top: /assets/index_user_guide.svg -:text-align: center -:class-item: index-card - -## Online data analysis - -Allocating compute nodes, programming using Python and Matlab during the beamtime. -``` - -```` -For offline analysis, more detailed documentation on use of slurm on Ra, description of NoMachine setup is available at https://dari.pages.psi.ch/ra/compute.html/. +For offline analysis, more detailed documentation on use of slurm on Ra, description of NoMachine setup is available at . diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index 5796480b..b7fe0c6e 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -1,4 +1,3 @@ -(user.ptychography.flomni)= # flOMNI flOMNI is an instrument for tomographic measurements via X-ray ptychography. The sample environment is at atmospheric pressure and room temperature (or higher). @@ -9,7 +8,7 @@ Samples are to be mounted on [OMNY pins](https://www.dora.lib4ri.ch/psi/islandor … a step-by-step guide for users -(user.ptychography.flomni.transfer)= + ### Sample storage and transfer Print the current usage of the sample storage @@ -35,8 +34,8 @@ __If something goes wrong__, there are additional commands to perform a manual o | command | explanation | | --- | --- | -| `flomni.ftransfer_get_sample()` | Pick sample from | -| `flomni.ftransfer_put_sample()` | Mount sample at | +| `flomni.ftransfer_get_sample()` | Pick sample from <position> | +| `flomni.ftransfer_put_sample()` | Mount sample at <position> | | `flomni.ftransfer_flomni_stage_in()` | stage to measurement position | | `flomni.ftransfer_flomni_stage_out()` | stage to the sample change position | @@ -56,15 +55,17 @@ If you see your sample already at the approximately correct height: 1. `flomni.xrayeye_alignment_start()` start the coarse alignment of the sample by measuring (clicking in the X-ray eye software) the sample position at its height and then angles of 0, 45, 90, 135, 180 degrees. The GUI will present a fit of this data, which is automatically loaded to BEC for aligning the sample. + **Flat samples** are edge-on at 90 degrees, so their center cannot be measured there: press **Skip** in the GUI instead of Submit to leave that angle out. One angle may be skipped, the fit needs at least 4 of the 5 angles; a second Skip is refused with a message. Skipping 45, 90 or 135 degrees costs little accuracy, skipping 0 or 180 degrees makes the fit noticeably noisier (a warning is printed). The FZP center and the height marking cannot be skipped. With 4 points the fit cannot flag a bad click, so check the plot. The skipped angles are stored in the alignment h5 file. + Otherwise adjust the height manually: -1. `flomni.umvr_fsamy_tracked(0.01)`, attention: unit , move the sample stage relative up (positive) or down (negative) until the sample is approximately vertically centered in xray eye screen +1. `flomni.umvr_fsamy_tracked(0.01)`, attention: unit <mm>, move the sample stage relative up (positive) or down (negative) until the sample is approximately vertically centered in xray eye screen 1. `flomni.xrayeye_update_frame()` will update the current image on the xray eye screen #### Fine alignment After the xrayeyealign, a fine alignment needs to be performed using ptychography. -_To bypass the fine alignment: `flomni.feye_out`_ +_To bypass the fine alignment: `flomni.feye_out()`_ 1. `flomni.tomo_parameters()` Adjust the ptychographic scan parameters for performing an alignment scan. Typically FOVX = FOVX(Xrayeye)+20 mu, shell step = beamsize/2.5, number of projections and tomo mode are ignored in the alignment scans. @@ -74,13 +75,15 @@ _To bypass the fine alignment: `flomni.feye_out`_ ### Tomographic Measurement Now that the sample is aligned, the tomographic measurement can be performed. -1. `flomni.tomo_parameters()` adjust the scan parameters for the tomographic scan. This includes the parameters for ptychographic scans of projections plus the strategy for angular sampling. The vertical shift adjusts the field of view, up (positive) or down (negative). After adjusting the numbers, type again `flomni.tomo_parameters()` and verify that they are correct. For the "8 sub-tomograms" mode, an optional 0-deg reference shot can be enabled at the start of every odd sub-tomogram and once more at the end, useful for tracking radiation damage over the course of a long measurement. -1. `flomni.tomo_scan_projection(angle)` perform a ptychographic scan at the rotation angle . Launch the tomographic measurement by `flomni.tomo_scan()`. During the scan a live progress report is printed, including an estimated time of completion. Unusually long gaps (e.g. a beamline interruption) are detected automatically and excluded from the estimate, and the total time lost to such gaps is reported once the scan finishes. + +1. `flomni.tomo_parameters()` opens the tomo parameter window (GUI) to adjust the scan parameters for the tomographic scan (`flomni.tomo_parameters_cli()` is the command line version, which prompts for each value in the terminal). This includes the parameters for ptychographic scans of projections plus the strategy for angular sampling. The vertical shift adjusts the field of view, up (positive) or down (negative). After adjusting the numbers, type again `flomni.tomo_parameters()` and verify that they are correct. Besides the computed angular sampling modes, `tomo_type` 4 reads the angles from a text file, see [Angles from a file](flomni.md#angles-from-a-file-tomo_type-4) for the file requirements. For the "8 sub-tomograms" mode, an optional 0-deg reference shot can be enabled at the start of every odd sub-tomogram and once more at the end, useful for tracking radiation damage over the course of a long measurement. +1. `flomni.tomo_scan_projection(angle)` perform a ptychographic scan at the rotation angle <angle>. Launch the tomographic measurement by `flomni.tomo_scan()`. During the scan a live progress report is printed, including an estimated time of completion. Unusually long gaps (e.g. a beamline interruption) are detected automatically and excluded from the estimate, and the total time lost to such gaps is reported once the scan finishes. 1. Before changing sample, verify that all subtomograms were completely acquired using the tomo_reconstruction matlab script. #### Queueing multiple scans Several parameter sets can be queued and run one after another automatically, e.g. a fast overview scan followed by a higher resolution scan on the same sample, without having to wait around to start the next one by hand. + 1. `flomni.tomo_parameters()` set up the first parameter set, then `flomni.tomo_queue_add("label")` to add it to the queue. 1. Repeat for further parameter sets. 1. `flomni.tomo_queue_show()` review the queue before starting. @@ -88,7 +91,7 @@ Several parameter sets can be queued and run one after another automatically, e. If a queued scan is interrupted, the next call to `flomni.tomo_queue_execute()` resumes it automatically rather than starting over, then continues with the remaining entries. -The same queue can also hold **command jobs** that reconfigure the beamline between scans (e.g. change energy, then re-peak the undulator gap) instead of running a tomogram, so "scan A, reconfigure, scan B" runs unattended as one queue. A queued tomogram can also run custom code at each projection angle (e.g. moving a polarizer in and out between two exposures) via a registered **at_each_angle hook**. See [Tomography](user.ptychography.flomni.tomography) below for the full command reference, including command jobs and hooks. +The same queue can also hold **command jobs** that reconfigure the beamline between scans (e.g. change energy, then re-peak the undulator gap) instead of running a tomogram, so "scan A, reconfigure, scan B" runs unattended as one queue. A queued tomogram can also run custom code at each projection angle (e.g. moving a polarizer in and out between two exposures) via a registered **at_each_angle hook**. See [Tomography](flomni.md#tomography) below for the full command reference, including command jobs and hooks. #### If something went wrong… @@ -101,6 +104,7 @@ To continue an __interrupted tomography scan__, run It picks up automatically from wherever the scan was interrupted, no need to look up the exact subtomogram/angle by hand. Do not use this command when running from the tomo queuing system. Instead use `flomni.tomo_queue_execute()`. Alternatively, the same kind of parameters used internally by `tomo_scan_resume()` can be given to `flomni.tomo_scan()` directly, e.g. to resume from a different point than where it actually stopped: + | tomo type | parameters and their defaults | | --- | --- | | 8 sub-tomograms | subtomo_start=1, start_angle=None | @@ -109,7 +113,7 @@ Alternatively, the same kind of parameters used internally by `tomo_scan_resume( ### GUI tools -During operation the BEC GUI will show the relevant cameras or progress information. To manually switch view TAB completion on 'flomni.flomnigui_' will show all options to control the GUI. Most useful +During operation the BEC GUI will show the relevant cameras or progress information. To manually switch view ++tab++ completion on 'flomni.flomnigui_' will show all options to control the GUI. Most useful 'flomni.flomnigui_show_cameras()' will show the cameras for sample transfer and interior overview 'flomni.flomnigui_show_progress()' will show the measurement progress GUI 'flomnigui_show_xeyealign()' will show the XrayEye alignment GUI @@ -171,9 +175,10 @@ Example: `flomni.ffzp_info(6.2)` Documents about availabe optics can be accessed by `flomni.flomnigui_docs` -The [laser feedback](user.ptychography.flomni.laser_feedback) will be disabled and fine alignment lost if foptx/y are moved! +The [laser feedback](flomni.md#interferometer-feedback-commands) will be disabled and fine alignment lost if foptx/y are moved! Following functions exist to move the optics in and out, with self-explaining naming. + - `flomni.ffzp_in()` - `flomni.foptics_in()` - `flomni.foptics_out()` @@ -185,6 +190,7 @@ Following functions exist to move the optics in and out, with self-explaining na The position feedback in flOMNI is controlled in closed loop to an interferometric position measurement. To show the signal of the interferometers: `flomni.show_signal_strength_interferometer()` Typical values with proper alignment, sample stage at the measurement position and laser tracker running are in the range of + | Axis | Value | | --- | --- | | 0 | 13681.0 | @@ -201,12 +207,14 @@ The horizontal interferometer is built according to the [tracking interferometer - `flomni.laser_tracker_on()` - `flomni.laser_tracker_off()` -(user.ptychography.flomni.laser_feedback)= + #### Interferometer feedback commands The closed loop control of the Piezo stages can be controlled by + - `flomni.feedback_feedback_enable_with_reset()`. _There is also an enable without reset, which is used during tomography scans, when using coarse stages to increase the scan range. It should not be required to use manually._ + - `flomni.feedback_disable()` - `flomni.feedback_status()` @@ -222,19 +230,53 @@ The basic scan function can be called by `scans.flomni_fermat_scan()` and offers | fovy (float) | Fov in the piezo plane (i.e. piezo range). Max 100 um | | cenx (float) | center position in x | | ceny (float) | center position in y | -| exp_time (float) | exposure time per frame | -| frames_per_trigger(int) | Number of burst frames per position | | step (float) | stepsize | +| fermat_asymmetry (float) | ratio of x spacing to y spacing (x/y). 1 = symmetric (default). x spacing stays fixed at `step`; y spacing scales by this ratio — e.g. `2.0` doubles x spacing relative to y (denser sampling in y), `0.5` does the opposite. The requested FOV is preserved, so the total point count changes with the ratio. Soft-clamped to `[0.1, 10]`. Useful for samples/features elongated in one direction. | | zshift (float) | shift in z | | angle (float) | rotation angle (will rotate first) | -| corridor_size (float) | corridor size for the corridor optimization. Default 3 um | +| corridor_size (float) | corridor size for the corridor optimization. Default 3 um (auto-estimated if not provided) | +| randomize_offset (bool) | randomize the sub-step offset of the Fermat lattice on every scan. Default `True`. Set `False` for the unshifted lattice (identical positions on every call). See below. | +| exp_time (float) | exposure time per frame | +| frames_per_trigger (int) | Number of burst frames per position | +| burst_at_each_point (int) | Number of triggers and readouts at each point | Example: -`scans.flomni_fermat_scan(fovx=20, fovy=25, cenx=0.02, ceny=0, zshift=0, angle=0, step=0.5, exp_time=0.01, frames_per_trigger=1)` +`scans.flomni_fermat_scan(fovx=10, fovy=10, cenx=0, ceny=0, step=1, fermat_asymmetry=1.0, zshift=0, angle=0, exp_time=0.1, frames_per_trigger=1, burst_at_each_point=1)` + +**Effective spacing with `fermat_asymmetry`:** the x spacing always stays at `step`; +the y spacing becomes `step / fermat_asymmetry`. So with `step=1 um`: + +| fermat_asymmetry | x spacing | y spacing | points (fovx=fovy=10 um example) | +| --- | --- | --- | --- | +| 0.7 | 1.0 um | ~1.43 um (sparser) | ~70 | +| 1.0 (default) | 1.0 um | 1.0 um | ~100 | +| 1.3 | 1.0 um | ~0.77 um (denser) | ~130 | + +The requested FOV stays exactly as specified either way -- only the point density +(and therefore the point count and scan duration) changes. + +By default the Fermat spiral's sub-step phase is randomized on every projection +(drawn fresh per scan, uniform in `[0, step)`) to decorrelate position-tied +reconstruction errors that would otherwise accumulate into ring artifacts across a +tomographic series. Pass `randomize_offset=False` to switch this off and get the +unshifted lattice, e.g. for reproducible positions when comparing scans: +`scans.flomni_fermat_scan(fovx=10, fovy=10, cenx=0, ceny=0, step=1, randomize_offset=False, ...)`. +The tomo scans (`tomo_scan`, `tomo_scan_projection`) do not pass this parameter and +therefore always randomize. + +`frames_per_trigger` controls burst acquisition for detectors generally. +Separately, the OMNY PandABox is configured as one of two distinct devices in +`csaxs_bec/device_configs/ptycho_flomni.yaml` — `omny_panda` (per-gate +averaged acquisition, currently kept as a commented reference) or +`omny_panda_continuous` (continuous, high-rate raw-sample acquisition) — +because the two modes require different PandA hardware layouts and can't run +simultaneously. See `docs/developer/panda_box_free_running_setup.md` for what +each mode captures and how to switch between them. #### Overview of the alignment steps There are several corrections applied to maintain the sample in the FOV: + 1. Mirror calibration 1. X-ray eye alignment 1. Ptychography fine alignment (improvement of the X-ray eye alignment step) @@ -272,6 +314,22 @@ The loading routine uses default values for the vertical alignment. This behavio At each projection, the angular dependent is computed by `flomni.get_alignment_offset(angle)`, with _angle_ in degrees. +To manually nudge the alignment by a few microns without recording a new fit +(e.g. to compensate for a small shift introduced by moving `foptz`), edit the +constant offset term of the currently loaded fit directly: +```python +fit = flomni.client.get_global_var("tomo_alignment_fit") +fit[0][2] += 5.0 # x offset, microns +fit[1][2] += 3.0 # y offset, microns +flomni.client.set_global_var("tomo_alignment_fit", fit) +``` +`tomo_alignment_fit` is a 2x5 list (row 0 = x, row 1 = y); column 2 in each +row is the constant offset of the sinusoidal fit +(`correction = A*sin(angle + phase) + offset [+ 3rd-order term for y]`), so +changing it shifts the correction by that amount at every angle without +touching the fitted amplitude/phase. Check the result with +`flomni.get_alignment_offset(angle)`. + The alignment can be cleared by `flomni.reset_tomo_alignment_fit()` @@ -308,7 +366,7 @@ Define the scan parameters by `flomni.tomo_parameters()` Run a scan at _angle_ (in degrees) by `flomni.tomo_scan_projection(angle)` -(user.ptychography.flomni.tomography)= + ### Tomography The tomo parameters have to be set by @@ -319,19 +377,93 @@ Once satisfied with the alignment, the tomography scan can be started by During the scan, a live progress report is printed (subtomogram/projection counters and an estimated time of completion). Gaps significantly longer than a normal acquisition cycle (e.g. a beamline-down interruption, or a crash followed by a restart) are detected automatically from the time between consecutive projections and excluded from the time estimate; the total time lost to such gaps is printed once the scan finishes. -Three modes for angular sampling are implemented and they have different optional parameters for the tomo_scan method: -| tomography mode | parameters and defaults | -| --- | --- | -| 8 sub-tomograms | subtomo_start=1, start_angle=None | -| Golden ratio tomography (sorted in bunches) | projection_number=None | -| Equally spaced with golden starting angle | projection_number=None | +Four modes for angular sampling are implemented (selected by `tomo_type` 1-4) and they have different optional parameters for the tomo_scan method: + +| tomography mode | tomo_type | parameters and defaults | +| --- | --- | --- | +| 8 sub-tomograms | 1 | subtomo_start=1, start_angle=None | +| Golden ratio tomography (sorted in bunches) | 2 | projection_number=None | +| Equally spaced with golden starting angle | 3 | projection_number=None | +| Angles read from a file | 4 | projection_number=None | + +See [Angles from a file](flomni.md#angles-from-a-file-tomo_type-4) below for tomo_type 4. For the "8 sub-tomograms" mode, `flomni.tomo_parameters()` also offers a `zero_deg_reference_at_each_subtomo` option: when enabled, an additional reference projection at exactly 0 degrees is acquired at the start of every odd (forward) sub-tomogram and once more after the final sub-tomogram, useful for tracking radiation damage across the full measurement. +By default odd sub-tomograms sweep 0→180° and even ones 180→0°, so the reference is taken before every second sub-tomogram. With the `tomo_type1_always_forward` option ("Run all sub-tomos forward", 180° range only), every sub-tomogram sweeps 0→180°; the rotation returns to 0° between sub-tomograms and a 0° reference is taken before each of the 8 (plus one at the end). The angle set of every sub-tomogram, and thus the combined angular grid, is unchanged. + +`flomni.tomo_parameters()` also offers `stitch_x`/`stitch_y` (integers, default 0, up to 50 each) for a field of view larger than a single Fermat scan can cover: when nonzero, every projection angle is acquired as a `(2*stitch_x+1) × (2*stitch_y+1)` grid of separate Fermat scans instead of a single one, tiled around the normal alignment-corrected center and spaced by `fovx`/`fovy` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(2*stitch_x+1) × fovx` by `(2*stitch_y+1) × fovy`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. Stitching is forced to 0 (disabled) whenever `single_point_instead_of_fermat_scan` is enabled. + The parameters above can be used to __restart an interrupted acquisition__ manually, or - more conveniently - by running `flomni.tomo_scan_resume()` which reads the last recorded progress and resumes automatically at the exact point (subtomogram/angle, or projection for the golden ratio modes) the scan was interrupted at, without needing to look up the values by hand. When running from the tomo scan queuing system use `flomni.tomo_queue_execute()` instead! + +#### Angles from a file (tomo_type 4) + +With `tomo_type` 4 the projection angles are not computed but read from a plain-text file you provide, e.g. for a non-uniform sampling, a missing-wedge-aware set of angles, or to repeat a previously used list. Set the file in `flomni.tomo_parameters()` (choose type 4, then **Browse...** to pick the file; the GUI shows a live preview of the file content and blocks Submit / Add to queue while the file is invalid), or in `flomni.tomo_parameters_cli()` which prompts for the path until it parses. Only the path is stored (`flomni.tomo_angle_file`), so use a path that is readable from the BEC client machine. + +**File requirements** + +- Plain text, one projection per line. +- Column 1: the angle in degrees, `0 <= angle <= 360`. Angles above 180 are allowed; `tomo_angle_range` is not applied. +- Column 2 (optional): the sub-tomogram number, an integer `>= 1`. If omitted, it is 1. It is only a label used for progress display and the saved metadata; it does not change the acquisition. +- Columns are separated by whitespace and/or commas. Everything after `#` is a comment; blank lines are ignored. +- Projections are acquired in file order, so the order in the file is the order of the measurement. +- Further, optional `key=value` tokens after the angle/subtomo may override acquisition parameters for that projection -- see [Per-line acquisition overrides](flomni.md#per-line-acquisition-overrides) below. +- At least one angle is required. Any malformed line (not a number, angle outside 0-360, sub-tomogram number below 1, an unrecognized or malformed `key=value` token) makes the whole file invalid and names the offending line. +- A line that is `END` (case-insensitive, nothing but whitespace/comment around it) marks the file as **complete** -- no more angles will ever be appended. A commented-out `# END` does not count, so the marker can be staged ahead of time and uncommented when the file is truly finished. Anything other than a comment or blank line after `END` is an error. + +Example: +```text +# angle subtomo +0 1 +45 1 +190.5 2 +270, 2 +END +``` + +**Behavior** + +- The file is read once at the start of `flomni.tomo_scan()` (and again at `flomni.tomo_scan_resume()` or when a queued job runs), before any sample registration or motion. A missing or invalid file fails immediately (in the queue the job is marked `incomplete` with the error message). +- **While the scan is running, the file is re-read and polled (about every 5 s) whenever the scan catches up to the current end of file.** This means you can grow the file by appending lines while a scan is running -- newly appended angles are picked up automatically, with no need to call `tomo_scan_resume()`. +- A file without an `END` marker is **incomplete**: once the scan reaches the end of it, it waits indefinitely for more lines rather than finishing, printing a reminder each time it starts waiting. Add an `END` line when you want the scan to finish on its own -- a file prepared in advance with the full list of angles should have `END` as its last line, or a scan against it will hang at the end rather than completing. Pressing ++ctrl+c++ while waiting raises a clean, catchable error (safe to use to abort a queued job in this state) rather than a bare interrupt. +- Only append new lines after the last one the scan has already acquired. Editing or removing a line that has already been acquired aborts the scan with an error on the next poll; editing a line that has been written to the file but not yet acquired is not guaranteed to be picked up, so avoid relying on it. +- At scan start the number of angles known so far, their range and the number of sub-tomograms are printed (noting if the file is still incomplete), and the parsed angle list is written to scilog. The summary is printed again, together with the 180-deg-pair warning below, once the file becomes complete. +- A warning is printed if the file contains angles that are identical or 180 deg apart (within 0.05 deg), which are redundant in parallel-beam geometry. The scan is still run. +- No extra 0-deg damage-estimation shots are inserted (`golden_projections_at_0_deg_for_damage_estimation` and the sub-tomogram 0-deg reference are ignored); put 0-deg lines into the file if you want them. +- `flomni.tomo_scan_resume()` is for recovering from a crash or an explicit abort (e.g. ++ctrl+c++) -- it is not needed just to pick up appended lines, since a running scan already waits for and picks those up on its own. When used, it continues at the interrupted projection index of the file, using the file's *current* content from that index on. If the file has become shorter than the resume index, the resume is refused. +- In the tomo queue the file path is snapshotted per job when it is added, and `flomni.tomo_queue_reacquire(job_index, projection_number)` uses the projection index in the file (0-indexed, in file order). + + +##### Per-line acquisition overrides + +Besides the angle and sub-tomogram number, each line may add any number of `key=value` tokens that override acquisition parameters otherwise taken from the live `flomni.tomo_parameters()` configuration, for that projection and every one after it: + +- `step=` -- the Fermat scan step size (otherwise `flomni.tomo_shellstep`), range `0.025-20.0`. +- `exptime=` -- the exposure/counting time per frame (otherwise `flomni.tomo_countingtime`), range `0.001-100.0`. +- `frames=` -- the burst frame count (otherwise `flomni.frames_per_trigger`), integer, range `1-100`. + +These three are **sticky**: once a line sets one, it stays in effect -- for that line and every line after it -- until a later line changes it again. They are independent of each other (setting `step=` doesn't touch `exptime=`/`frames=`) and independent of the sub-tomogram number, which is never sticky. A field that is never set on any line simply uses whatever the live configuration is at the moment that projection is actually acquired, so a mix of overridden and non-overridden lines behaves exactly as if the non-overridden ones weren't in a tomo_type-4 file at all. + +Example -- coarse-and-fast for the first half, then fine-and-slow: +```text +0 1 +45 1 +90 1 step=0.3 exptime=2 frames=3 +135 1 +180 2 +225 2 step=0.05 exptime=10 +270 2 +END +``` +Here 0/45 use whatever `tomo_shellstep`/`tomo_countingtime`/`frames_per_trigger` are configured; 90/135/180 use `step=0.3 exptime=2 frames=3` (135 and 180 inherit it from 90); 225/270 use `step=0.05 exptime=10` with `frames=3` still carried forward from before (not reset, since `frames=` was never changed again). + +A scan summary note ("N with per-line overrides") is printed whenever any entry carries an override, so a typo'd `key=value` token that silently parsed as something else would stand out as an unexpected count. + +If a custom `at_each_angle_hook` or `flomni_at_each_angle` is active, it owns the whole per-projection acquisition and never sees these overrides -- a warning is printed once if the file has any overrides while such a hook is active. + In case of eight equally spaced sub-tomograms, an individual sub tomogram can be scanned by flomni.sub_tomo_scan(subtomo_number, start_angle). If the start angle is not specified, it will be computed depending on the subtomo_number, which is ranging from 1 to 8. #### Queueing multiple scans @@ -349,13 +481,15 @@ Several tomo parameter sets can be queued and run sequentially on the same sampl The queue is persisted (it survives a BEC client restart). Each job's status is one of `pending`, `running`, `incomplete`, or `done`. A job that did not run to completion (an exception was caught, or the BEC client itself crashed mid-scan) is automatically resumed - rather than restarted - the next time `flomni.tomo_queue_execute()` is called. -If the process running the queue dies hard enough that it never gets to write a status update at all (a kernel restart, a Ctrl-C during a blocking prompt) a job can be left stuck showing `running` with nothing actually running. `flomni.tomo_queue_delete(index)` has no status guard and works on a stuck row regardless; you can also fix the status directly with `flomni._tomo_queue_proxy.update_by_id(job_id, status="incomplete")` (to make it resumable) or `status="done"` (to skip it). The GUI's Delete/Clear normally refuse to touch a `running` row, but detect a stale one (no recent scan heartbeat) and offer to proceed anyway with an explicit confirmation instead of blocking forever. +If the process running the queue dies hard enough that it never gets to write a status update at all (a kernel restart, a ++ctrl+c++ during a blocking prompt) a job can be left stuck showing `running` with nothing actually running. `flomni.tomo_queue_delete(index)` has no status guard and works on a stuck row regardless; you can also fix the status directly with `flomni._tomo_queue_proxy.update_by_id(job_id, status="incomplete")` (to make it resumable) or `status="done"` (to skip it). The GUI's Delete/Clear normally refuse to touch a `running` row, but detect a stale one (no recent scan heartbeat) and offer to proceed anyway with an explicit confirmation instead of blocking forever. + +In the examples, `flomni.tomo_parameters_cli()` is used because it waits until the values are entered before the next command runs. With the GUI window, adjust the values there and add the job with its queue button, or with `flomni.tomo_queue_add()`. Example: -``` -flomni.tomo_parameters() # set up parameter set #1 +```python +flomni.tomo_parameters_cli() # set up parameter set #1 flomni.tomo_queue_add("fast overview") -flomni.tomo_parameters() # set up parameter set #2 +flomni.tomo_parameters_cli() # set up parameter set #2 flomni.tomo_queue_add("hires scan") flomni.tomo_queue_show() flomni.tomo_queue_execute() # runs both, in order, on this sample @@ -397,7 +531,7 @@ anything itself. Example — job 0 already finished, but its last few projections (and the jobs queued after it) need redoing: -``` +```python flomni.tomo_queue_show() # find the job index and the projection number to restart at flomni.tomo_queue_reacquire(0, 130) # reopen job 0 at projection 130; jobs 1+ reset to pending flomni.tomo_queue_resume() # actually re-run, in order @@ -419,10 +553,11 @@ two tomograms on the same sample. `flomni.tomo_queue_add_command(steps, label=None, idempotent=None)` - `steps`: a single `{"action": ..., "kwargs": {...}}` dict, or a list of them run in - sequence within that one job. + sequence within that one job. + - `label`: optional name shown by `tomo_queue_show()`, same as for `tomo_queue_add()`. - `idempotent`: normally inferred (safe to blindly re-run after a crash only if every - step is); override explicitly if needed. + step is); override explicitly if needed. Only actions from a fixed, reviewed registry can be queued — not arbitrary code: @@ -436,7 +571,7 @@ Devices allowed for `move`: `mokev` (energy, keV), `idgap` (undulator gap, mm). it actually runs. Example — change energy, then re-peak idgap, before the next tomogram: -``` +```python flomni.tomo_queue_add_command( [{"action": "move", "kwargs": {"positions": {"mokev": 6.2}}}, {"action": "optimize_idgap", "kwargs": {"search_range": 0.5}}], @@ -445,16 +580,18 @@ flomni.tomo_queue_add_command( ``` `tomo_queue_show()` lists command jobs alongside tomogram jobs, e.g.: -``` +```text [2] pending reconfigure to 6.2 keV CMD move{'positions': {'mokev': 6.2}} > optimize_idgap{'search_range': 0.5} [idem] ``` If a command job is interrupted by a crash, there is no per-step resume point — the whole job is either safe to redo from the top or it isn't: + - If every step is idempotent (the usual case — an absolute move is harmless to - repeat), `flomni.tomo_queue_execute()` silently re-runs the whole job from the top. + repeat), `flomni.tomo_queue_execute()` silently re-runs the whole job from the top. + - If any step is not idempotent, you are asked whether to re-run the job from the top - or mark it done as-is. + or mark it done as-is. #### Custom behavior at each projection angle @@ -491,7 +628,7 @@ Example — queue a polarizer-modulated tomogram, then a normal one: ```python flomni.register_at_each_angle_hook("polarizer_mod", polarizer_modulation) -flomni.tomo_parameters() # set up the scan parameters as usual +flomni.tomo_parameters_cli() # set up the scan parameters as usual flomni.at_each_angle_hook = "polarizer_mod" # activate the hook flomni.tomo_queue_add("polarizer run") @@ -560,7 +697,7 @@ Load and use it: import my_hooks flomni.register_at_each_angle_hook("high_res_every_5th", my_hooks.high_res_every_5th) -flomni.tomo_parameters() # set up the scan parameters as usual +flomni.tomo_parameters_cli() # set up the scan parameters as usual flomni.at_each_angle_hook = "high_res_every_5th" # activate the hook flomni.tomo_queue_add("subtomo 2 high-res every 5th") @@ -570,7 +707,7 @@ flomni.tomo_queue_execute() `progress["subtomo"]`/`progress["subtomo_projection"]` are populated the same way for tomo_types 2/3, so the same pattern works there too — just note that unlike type 1's fixed 8 sub-tomograms, those types have no fixed sub-tomogram count (see -[Tomography](user.ptychography.flomni.tomography) above), so "sub-tomogram 2" means +[Tomography](flomni.md#tomography) above), so "sub-tomogram 2" means something different (and open-ended) for them. **`tomo_scan_projection()` vs `tomo_acquire_at_angle()`:** these are not @@ -584,7 +721,7 @@ confirmation now fails the job** (raises, rather than silently skipping that projection) — inside a custom hook or the normal tomo flow this correctly marks the queue job `incomplete` and pauses the queue, instead of quietly acquiring fewer projections than expected with no trace, or (if you get asked at every angle and -eventually give up and hit Ctrl-C) leaving a job stuck at status `running` forever +eventually give up and hit ++ctrl+c++) leaving a job stuck at status `running` forever with nothing actually running. If you hit this, use `tomo_acquire_at_angle(angle)` in your hook instead, or fix the queue entry via `flomni.tomo_queue_delete(index)` (no status guard, works even on a stuck `running` row) or by marking it done/ @@ -621,7 +758,7 @@ removed later. ### Sample storage and transfer -[See short version](user.ptychography.flomni.transfer) +[See short version](flomni.md#sample-storage-and-transfer) ### Heater @@ -631,4 +768,4 @@ flomni.move_fheater_up() The functions are safe in the sense that no collisions should occur. E.g. the OSA will be moved back before a movement of the heater. -__The heater still needs commissioning in BEC!!!__ \ No newline at end of file +__The heater still needs commissioning in BEC!!!__ diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md index c5d08758..38559001 100644 --- a/docs/user/ptychography/lamni.md +++ b/docs/user/ptychography/lamni.md @@ -1,5 +1,3 @@ -(user.ptychography.lamni)= - # LamNI LamNI is an instrument for 3D ptychography via ptychographic X-ray computed laminography (PyXL). The instrument is described in detail [here](https://www.dora.lib4ri.ch/psi/islandora/object/psi:33067). @@ -8,13 +6,50 @@ LamNI is an instrument for 3D ptychography via ptychographic X-ray computed lami … a step-by-step guide for *beamline staff and expert users*. +### Quick guide + +Only the commands, in the order they are used. Details are in the linked sections below. + +**0. Sample change** ([details](lamni.md#sample-change-and-alignment)) + +- `lamni.leye_in()` moves the flight tube and detector table out and the X-ray eye in, opens slits sl1 and sl6 by 1 mm and reduces the filter transmission to 5 % +- mount the sample + +**1. Find the rotation center** ([details](lamni.md#coarse-axis-alignment)) + +- `lamni.xrayeye_rotation_center_calibration_isolated()` for an isolated particle, or +- `lamni.xrayeye_rotation_center_calibration_extended()` for an extended/textured sample +- experimental: `lamni.xrayeye_rotation_center_calibration_smear_experimental()` + +Applying a new center erases an existing X-ray eye alignment and fine-alignment correction. You are warned before that happens. + +**2. X-ray eye alignment** ([details](lamni.md#x-ray-eye-alignment)) + +- `lamni.xrayeye_alignment_start()`, or `lamni.xrayeye_alignment_start(keep_shutter_open=True)` if the sample is hard to find between angles +- `lamni.xrayeye_update_frame()` to take a single new frame + +If an alignment already exists, you are asked before it is erased. + +**3. Alignment scan** ([details](lamni.md#fine-alignment)) + +- `lamni.tomo_parameters()` to set FOV, step size and counting time +- `lamni.tomo_alignment_scan()`. This moves the X-ray eye out and the optics in by itself (it calls `lamni.leye_out()`), so no manual step is needed before it. +- run `BEC_ptycho_align` in Matlab with the printed scan numbers +- `lamni.read_additional_correction('')` +- optional second iteration: `lamni.read_additional_correction_2('')` + +**4. Laminography scan** ([details](lamni.md#test-projections-and-laminography-scan)) + +- `lamni.tomo_parameters()` +- test: `lamni.tomo_scan_projection(angle)` and `lamni.tomo_reconstruct()` +- `lamni.tomo_scan()`. This does not move the eye or the optics; it only checks that the eye is out and the optics are in, and asks before continuing otherwise. + ### Sample change and alignment The access to the sample region is blocked by the flight tube during measurement. To get the flight tube out of the way use `lamni.leye_in()`. Mount the new sample. The X-ray eye is already in, but the X-ray optics needs to be moved out of the beam path by -`lamni.loptics_out()`. Potentially a larger area needs to be illuminated to properly see the sample, which can be done by -`open slits`. +`lamni.loptics_out()`. `lamni.leye_in()` also opens slits sl1 and sl6 by 1 mm to illuminate a larger area, and `lamni.leye_out()` restores them. The rotation-center and X-ray eye alignment routines below move the FZP and optics as they need, so `loptics_out()` is only needed for looking at the sample by hand. #### Coarse axis alignment @@ -29,11 +64,23 @@ This opens the X-ray eye widget and walks through the calibration interactively. - `lamni.xrayeye_rotation_center_calibration_isolated()` — for a sparse/isolated particle. You submit its centre position once at 0° and once at 180°; the midpoint of the two is the rotation axis' projected position. Pass `keep_shutter_open=True` if it's hard to relocate the sample between steps, matching `xrayeye_alignment_start()` below. - `lamni.xrayeye_rotation_center_calibration_extended()` — for a non-isolated/textured sample where the rotation centre can be identified visually. The sample sweeps 0° → 180° → 0° with the live view left open so you can watch for the point that doesn't move, then you submit the rotation centre. The correction is applied, a short verification sweep (0° → 45° → 0°) runs, and you're asked *"Alignment acceptable — stop here?"* — answer `n` to run again. +In `..._extended()` and `..._smear_experimental()` the X-ray eye GUI also offers an elliptical selection, with its angle fixed at -76°. The isolated-particle mode keeps the rectangle/circle selection only. + At the end you're shown the computed `lsamx_center`/`lsamy_center` and asked to confirm before they're written via `dev.lsamx.update_user_parameter(...)` / `dev.lsamy.update_user_parameter(...)`. **Experimental: continuous-rotation "smear" aid** -`lamni.xrayeye_rotation_center_calibration_smear_experimental(sweep_deg=360.0)` is an **experimental** alternative to `..._extended()` for the same non-isolated-sample case. Instead of judging the rotation centre from a single instant of live rotation, it rotates `lsamrot` continuously through `sweep_deg` while accumulating a max-projection ("star-trail") composite from the camera: off-axis features smear into circular arcs, and the common center of curvature of those arcs is the rotation axis — usually an easier target to click than one live frame. The composite builds up live on screen as the sweep progresses and stays frozen once the sweep ends, until you submit your click. `sweep_deg` can be reduced below 360 if a shorter arc already shows enough curvature. There is no automatic circle fitting — you still submit the centre by eye, same click mechanism as `..._extended()`. +`lamni.xrayeye_rotation_center_calibration_smear_experimental(sweep_deg=360.0, projection="min", opaque_level=0.25)` is an **experimental** alternative to `..._extended()` for the same non-isolated-sample case. Instead of judging the rotation centre from a single instant of live rotation, it rotates `lsamrot` continuously through `sweep_deg` while accumulating a "star-trail" composite from the camera: off-axis features smear into circular arcs, and the common center of curvature of those arcs is the rotation axis — usually an easier target to click than one live frame. The composite builds up live on screen as the sweep progresses and stays frozen once the sweep ends, until you submit your click. `sweep_deg` can be reduced below 360 if a shorter arc already shows enough curvature. There is no automatic circle fitting — you still submit the centre by eye, same click mechanism as `..._extended()`. + +`projection` selects how the frames are combined: + +- `"min"` (default) keeps the darkest value seen at each pixel. Use it for absorption contrast, i.e. dark sample features on a bright background — the usual case on the X-ray eye. Small absorbing features leave dark arcs. A large sample fills in as a dark region covering everything it occupied during the rotation; its outline is symmetric about the projected rotation axis (an ellipse rather than a circle, because of the tilted axis), so click the centre of that outline. + + Opaque parts such as the sample holder frame would otherwise black out every pixel they pass at any angle and shrink the usable field of view. To avoid that, a pixel is ignored in any frame where it is darker than `opaque_level` (default `0.25`) times that frame's bright level (its 99th-percentile value), and the minimum is taken over the remaining frames only. A pixel that was opaque at every angle stays dark. If the holder still blacks out too much, raise `opaque_level` (e.g. `0.4`). If faint sample features disappear, lower it. `opaque_level=0` gives the plain minimum. + +- `"max"` keeps the brightest value at each pixel, for bright features on a dark background. Do not use it for absorption: every pixel sees the bright background at some point during the rotation, so the dark features are washed out. + +Each run is archived to `~/data/raw/logs/xrayeye_smear_calibration/` (composites, clicked centres, result, and the `sweep_deg`/`projection`/`opaque_level` used). **Manual fallback** @@ -45,8 +92,9 @@ To observe the axis of rotation obtain the position of the Fresnel zone plate on 4. `umv(dev.lsamrot,90)` to rotate the sample. One might observe the center of rotation at 0 and 180 degrees. 5. `umvr(dev.lsamx,0.01)` to move lsamx and lsamy such that the center of rotation is at the center of the X-ray beam 6. `dev.lsamx` and `dev.lsamy` will print current position and the center value. Update the center value by - `dev.lsamx.update_user_parameter({'center':8.69})` - `dev.lsamy.update_user_parameter({'center':8.69})` + `dev.lsamx.update_user_parameter({'center':8.69})`
+ `dev.lsamy.update_user_parameter({'center':8.69})` + 7. close the shutter: `dev.omnyfsh.fshclose()` #### X-ray eye alignment @@ -56,6 +104,8 @@ The BEC GUI is used to obtain a coarse sample alignment. Start the alignment pro This opens the X-ray eye widget automatically. The procedure collects the sample centre position at 8 angles (0°–315° in 45° steps, full 360° rotation). At each angle the user clicks the sample centre in the image and presses **Submit**. After all 8 angles the data is sent to the **Fit** tab of the GUI where a sinusoidal fit runs automatically in both x and y directions. The fit parameters are loaded automatically at the end of the procedure. +For the sample clicks (not the FZP click) the elliptical selection is also available, with a freely adjustable angle. + With LamNI it can be difficult to relocate the sample between rotations. To keep the shutter open throughout, pass: `lamni.xrayeye_alignment_start(keep_shutter_open=True)` @@ -67,43 +117,91 @@ To capture a single fresh frame without running the full alignment: `lamni.xrayeye_update_frame()` or with the shutter left open: `lamni.xrayeye_update_frame(keep_shutter_open=True)` -* If slits were opened during alignment, close the slits: `slits` to around 0.3 -* `lamni.leye_out()` remove the X-ray eye and move the flight tube in +##### Checking the x-ray eye alignment with the eye still in + +While the X-ray eye is still in, you can move the interferometer to the scan centre of any angle and look at where the sample ends up, without starting a scan: +`lamni.lamni_move_to_scan_center(shift_x, shift_y, angle)` + +This is the same move that the alignment procedure and every scan use to place the sample. `shift_x` and `shift_y` are the base shift in mm, i.e. the centre found with the first click at 0° (`lamni.tomo_fovx_offset`, `lamni.tomo_fovy_offset`), `angle` is the rotation angle in degrees. The shift is rotated with the angle using the LamNI geometry. The command does not rotate the sample itself and it returns a scan report, so add `.wait()` to block until the move is done. + +Example, checking the correction at 90°: + +1. `lamni.feedback_disable()` and rotate: `umv(dev.lsamrot, 90)` +2. `lamni.lamni_move_to_scan_center(lamni.tomo_fovx_offset, lamni.tomo_fovy_offset, 90).wait()` (this re-enables the feedback) +3. `lamni.xrayeye_update_frame(keep_shutter_open=True)` to look at the sample +4. `lamni.lamni_compute_additional_correction_xeye_mu(90)` prints the correction (x and y, in mm) that the scan will add on top of this position. The sample should appear displaced from the FZP centre by the size of this correction, and the correction compensates for that displacement. A clearly different displacement means the fit is off, e.g. a bad click, and the alignment should be repeated. + +##### Moving to the scan centre including the X-ray eye fit + +`lamni_move_to_scan_center()` above places the sample at the base shift only. To go where a projection at the current angle would go, including the sine fit from the X-ray eye alignment (`tomo_fit_xray_eye`), rotate first and then run: + +```python +angle = dev.lsamrot.user_setpoint.get() +cx, cy = lamni.lamni_compute_additional_correction_xeye_mu(angle) # mm, from the X-ray eye fit +from csaxs_bec.scans.lamni_components import LamNIComponents +sx, sy = LamNIComponents.lamni_compute_scan_center(lamni.tomo_fovx_offset, lamni.tomo_fovy_offset, angle) +lamni.lamni_move_to_scan_center(sx + cx, sy + cy, 0).wait() +``` + +The base shift is rotated to the angle first, so the move itself is called with angle 0 to pass the target through unchanged. This matches a projection without stitching, manual shifts (`lamni.manual_shift_x/y`, add them) and the correction tables from fine alignment (`lamni.compute_additional_correction(angle)` and `..._2(angle)`, subtract them). The positions are relative to the `center` user parameters of `lsamx`/`lsamy`, which must be set. Check the result with `lamni.show_interferometer_positions()`. + +If the drift between coarse stages and interferometer found at the start of the move is larger than 150 µm, it is considered unreliable and ignored; the move then relies on the second, interferometer-based iteration. + +* `lamni.leye_out()` removes the X-ray eye, moves the optics and the flight tube in and restores slits sl1 and sl6 to their size before `leye_in()` (it asks first if they were changed by hand in between). `lamni.tomo_alignment_scan()` calls it by itself. * *possibly check slit1, idgap* #### Fine alignment The sample fine alignment can be obtained using ptychography. For this a short laminogram has to be recorded. +**How the shift at each projection angle is computed.** `lamni.tomo_scan_projection(angle)`, which is used by the alignment scan and by the laminography scan, combines several contributions. All are evaluated for the requested angle: + +| Contribution | Source | Applied as | +|---|---|---| +| Base shift | first click (0°) of the x-ray eye alignment, stored as `lamni.tomo_fovx_offset` / `tomo_fovy_offset` | scan centre (`center_x`, `center_y`), rotated with the angle | +| X-ray eye correction | sinusoidal fit `A·sin(angle + B) + C` of the 8 clicked positions, `lamni.lamni_compute_additional_correction_xeye_mu(angle)` | added to the shift | +| Manual shift | `lamni.manual_shift_x` / `manual_shift_y`, constant for all angles | added to the shift | +| Additional correction 1 | lookup table from the fine alignment, `lamni.read_additional_correction(file)` | subtracted from the shift | +| Additional correction 2 | lookup table of the second iteration, `lamni.read_additional_correction_2(file)` | subtracted from the shift | + +The shift is therefore `manual + x-ray eye − correction 1 − correction 2`, on top of the rotated base shift. The x-ray eye fit was measured after the base shift was applied, so the two do not count twice. + +The correction tables are read from the files written by `BEC_ptycho_align`, with one shift per alignment angle. There is no interpolation: for each projection the entry with the closest angle is used. If a table has no data, it is skipped and `Not applying additional correction` is printed. Without an x-ray eye fit, `Not applying any X-ray eye correction` is printed. Each correction can be removed again with `lamni.reset_correction()`, `lamni.reset_correction_2()` and `lamni.reset_xray_eye_correction()`. + +The alignment scan itself already uses the x-ray eye correction and any correction tables that are loaded. So the ptychographic alignment measures what is left over after those corrections, which is why a second iteration with `read_additional_correction_2()` is useful. + * `lamni.tomo_parameters()` adjust the ptychographic scan parameters for the alignment scan (FOV/step size/counting time) — `tomo_type` and number of projections are ignored, since the alignment scan always runs its own fixed 12 points spread evenly across the full 360 degrees, independent of the main tomogram's settings. * `lamni.tomo_alignment_scan()` perform the alignment scan. Requires x-ray-eye alignment to have already been done — it will abort with a message otherwise. Scan numbers, angles and the existing x-ray-eye-fit offset at each angle are written to `~/data/raw/logs/ptychotomoalign_scannum.txt` and also printed at the end. * Run `BEC_ptycho_align` (in Matlab, **force ptycho=1**, and **correct scan numbers**) using the printed/logged scan numbers. * Click the sample position in the Matlab GUI and then load the generated file by, for example - `lamni.read_additional_correction('/sls/X12SA/data/e20632/Data10/cxs_software/ptycho/correction_lamni_um_S05389_lamni_fit.txt')` + `lamni.read_additional_correction('/sls/X12SA/data/e20632/Data10/cxs_software/ptycho/correction_lamni_um_S05389_lamni_fit.txt')` + * With this alignment a second iteration could be performed. To read the second correction file use `lamni.read_additional_correction_2()` -(user.ptychography.lamni.laminography)= + ### Test projections and Laminography scan Start the laminography scan by -1. `lamni.tomo_parameters()` adjust the parameters to the desired settings. +1. `lamni.tomo_parameters()` opens the tomo parameter window (GUI) to adjust the parameters to the desired settings. The command line version, which prompts for each value in the terminal, is `lamni.tomo_parameters_cli()`. 2. for test scans run - `lamni.tomo_scan_projection(angle)` - `lamni.tomo_reconstruct()` + `lamni.tomo_scan_projection(angle)`
+ `lamni.tomo_reconstruct()` + 3. `lamni.tomo_scan()` to start the laminography scan During the scan, a live progress report is printed (subtomogram/projection counters and an estimated time of completion). Gaps significantly longer than a normal acquisition cycle (e.g. a beamline-down interruption, or a crash followed by a restart) are detected automatically from the time between consecutive projections and excluded from the time estimate; the total time lost to such gaps is printed once the scan finishes. -Three angular sampling modes are implemented: +Four angular sampling modes are implemented (the fourth reads the angles from a file, see [Angles from a file](lamni.md#angles-from-a-file-tomo_type-4) below): | tomography mode | parameters and defaults | | --- | --- | | 8 sub-tomograms | subtomo_start=1, start_angle=None | | Golden ratio tomography (sorted in bunches) | projection_number=None | | Equally spaced with golden starting angle | projection_number=None | +| Angles from a file (tomo_type 4) | projection_number=None | The parameters above can be used to __restart an interrupted acquisition__ manually, or - more conveniently - by running `lamni.tomo_scan_resume()` @@ -111,6 +209,41 @@ which reads the last recorded progress and resumes automatically at the exact po In case of eight equally spaced sub-tomograms, an individual sub-tomogram can be scanned by `lamni.sub_tomo_scan(subtomo_number, start_angle)`. If the start angle is not specified, it is computed depending on `subtomo_number`, ranging from 1 to 8. +`lamni.tomo_parameters()` also offers `lamni_stitch_x`/`lamni_stitch_y` (integers, default 0, up to 50 each) for a field of view larger than a single scan can cover: when nonzero, every projection angle is acquired as a `(2*lamni_stitch_x+1) × (2*lamni_stitch_y+1)` grid of separate Fermat scans instead of a single one, tiled around the normal alignment-corrected center and spaced by `lamni_piezo_range_x`/`lamni_piezo_range_y` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(2*lamni_stitch_x+1) × lamni_piezo_range_x` by `(2*lamni_stitch_y+1) × lamni_piezo_range_y`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. Same mechanism as flomni's `stitch_x`/`stitch_y`, just under lamni's own parameter names (see `lamni.tomo_scan_projection()`, which passes `stitch_x`/`stitch_y`/`stitch_overlap` straight through to `scans.lamni_fermat_scan()`). + + +#### Angles from a file (tomo_type 4) + +With `tomo_type` 4 the projection angles are not computed but read from a plain-text file you provide, e.g. for a non-uniform sampling or to repeat a previously used list. Set the file in `lamni.tomo_parameters()` (choose type 4, then **Browse...**; the GUI shows a live preview of the file and blocks Submit / Add to queue while the file is invalid), or in `lamni.tomo_parameters_cli()`, which prompts for the path until it parses. Only the path is stored (`lamni.tomo_angle_file`), so use a path readable from the BEC client machine. The file format, the `END` marker and the per-line overrides are identical to flomni, see [Angles from a file](flomni.md#angles-from-a-file-tomo_type-4) there for the full description. + +**File requirements** + +- Plain text, one projection per line. +- Column 1: the angle in degrees, `0 <= angle <= 360`. +- Column 2 (optional): the sub-tomogram number, an integer `>= 1` (default 1). It is only a label for the progress display and saved metadata. +- Whitespace and/or commas separate columns; everything after `#` is a comment. +- Projections are acquired in file order. +- Optional `key=value` tokens per line override `step=` (`0.025-20.0`), `exptime=` (`0.001-100.0`) and `frames=` (`1-100`) for that projection and all following ones (sticky). They replace `lamni.tomo_shellstep`, `lamni.tomo_countingtime` and `lamni.frames_per_trigger`; an active `at_each_angle_hook` never sees them (a warning is printed). +- A final `END` line marks the file complete. Without it, the scan waits (polling about every 5 s) for more lines to be appended once it reaches the end of the file; ++ctrl+c++ while waiting gives a clean, catchable error. + +Example: +```text +# angle subtomo +0 1 +45 1 +190.5 2 step=0.5 +270 2 +END +``` + +**Behavior** + +- The file is read at the start of `lamni.tomo_scan()`, at `lamni.tomo_scan_resume()` and when a queued job runs, before any sample registration or motion. A missing or invalid file fails immediately (in the queue the job is marked `incomplete`). +- Only append new lines after the last acquired one; editing or removing an already acquired line aborts the scan on the next poll. +- Unlike flomni, no warning is given for angles that are identical or 180 deg apart: in laminography they are not redundant. +- As for every new tomogram, an unconditional 0-deg reference projection is taken first. No further 0-deg damage-estimation shots are inserted (`golden_projections_at_0_deg_for_damage_estimation` and `zero_deg_reference_at_each_subtomo` are ignored); put 0-deg lines in the file if you want them. +- `lamni.tomo_scan_resume()` continues at the interrupted projection index, using the file's current content from that index on. In the queue, `lamni.tomo_queue_reacquire(job_index, projection_number)` uses the projection index in the file (0-indexed). + #### Queueing multiple scans Several tomo parameter sets can be queued and run sequentially on the same sample, without having to start each one by hand. Easiest is to use the tomo parameters GUI for preparing the scans. But it is also possible on the command line interface. @@ -126,13 +259,15 @@ Several tomo parameter sets can be queued and run sequentially on the same sampl The queue is persisted (it survives a BEC client restart). Each job's status is one of `pending`, `running`, `incomplete`, or `done`. A job that did not run to completion (an exception was caught, or the BEC client itself crashed mid-scan) is automatically resumed - rather than restarted - the next time `lamni.tomo_queue_execute()` is called. -If the process running the queue dies hard enough that it never gets to write a status update at all (a kernel restart, a Ctrl-C during a blocking prompt) a job can be left stuck showing `running` with nothing actually running. `lamni.tomo_queue_delete(index)` has no status guard and works on a stuck row regardless; you can also fix the status directly with `lamni._tomo_queue_proxy.update_by_id(job_id, status="incomplete")` (to make it resumable) or `status="done"` (to skip it). The GUI's Delete/Clear normally refuse to touch a `running` row, but detect a stale one (no recent scan heartbeat) and offer to proceed anyway with an explicit confirmation instead of blocking forever. +If the process running the queue dies hard enough that it never gets to write a status update at all (a kernel restart, a ++ctrl+c++ during a blocking prompt) a job can be left stuck showing `running` with nothing actually running. `lamni.tomo_queue_delete(index)` has no status guard and works on a stuck row regardless; you can also fix the status directly with `lamni._tomo_queue_proxy.update_by_id(job_id, status="incomplete")` (to make it resumable) or `status="done"` (to skip it). The GUI's Delete/Clear normally refuse to touch a `running` row, but detect a stale one (no recent scan heartbeat) and offer to proceed anyway with an explicit confirmation instead of blocking forever. + +In the examples, `lamni.tomo_parameters_cli()` is used because it waits until the values are entered before the next command runs. With the GUI window, adjust the values there and add the job with its queue button, or with `lamni.tomo_queue_add()`. Example: -``` -lamni.tomo_parameters() # set up parameter set #1 +```python +lamni.tomo_parameters_cli() # set up parameter set #1 lamni.tomo_queue_add("fast overview") -lamni.tomo_parameters() # set up parameter set #2 +lamni.tomo_parameters_cli() # set up parameter set #2 lamni.tomo_queue_add("hires scan") lamni.tomo_queue_show() lamni.tomo_queue_execute() # runs both, in order, on this sample @@ -165,7 +300,7 @@ from there forward, in order. It does not run anything itself. Example — job 0 already finished, but its last few projections (and the jobs queued after it) need redoing: -``` +```python lamni.tomo_queue_show() # find the job index and the projection number to restart at lamni.tomo_queue_reacquire(0, 130) # reopen job 0 at projection 130; jobs 1+ reset to pending lamni.tomo_queue_resume() # actually re-run, in order @@ -187,10 +322,11 @@ two tomograms on the same sample. `lamni.tomo_queue_add_command(steps, label=None, idempotent=None)` - `steps`: a single `{"action": ..., "kwargs": {...}}` dict, or a list of them run in - sequence within that one job. + sequence within that one job. + - `label`: optional name shown by `tomo_queue_show()`, same as for `tomo_queue_add()`. - `idempotent`: normally inferred (safe to blindly re-run after a crash only if every - step is); override explicitly if needed. + step is); override explicitly if needed. Only actions from a fixed, reviewed registry can be queued — not arbitrary code: @@ -204,7 +340,7 @@ same beamline-wide devices as flOMNI's queue. A `move` naming any other device i rejected, both when the job is added and again when it actually runs. Example — change energy, then re-peak idgap, before the next laminogram: -``` +```python lamni.tomo_queue_add_command( [{"action": "move", "kwargs": {"positions": {"mokev": 6.2}}}, {"action": "optimize_idgap", "kwargs": {"search_range": 0.5}}], @@ -213,16 +349,18 @@ lamni.tomo_queue_add_command( ``` `tomo_queue_show()` lists command jobs alongside tomogram jobs, e.g.: -``` +```text [2] pending reconfigure to 6.2 keV CMD move{'positions': {'mokev': 6.2}} > optimize_idgap{'search_range': 0.5} [idem] ``` If a command job is interrupted by a crash, there is no per-step resume point — the whole job is either safe to redo from the top or it isn't: + - If every step is idempotent (the usual case — an absolute move is harmless to - repeat), `lamni.tomo_queue_execute()` silently re-runs the whole job from the top. + repeat), `lamni.tomo_queue_execute()` silently re-runs the whole job from the top. + - If any step is not idempotent, you are asked whether to re-run the job from the top - or mark it done as-is. + or mark it done as-is. #### Custom behavior at each projection angle @@ -255,7 +393,7 @@ Example — queue a polarizer-modulated laminogram, then a normal one: ```python lamni.register_at_each_angle_hook("polarizer_mod", polarizer_modulation) -lamni.tomo_parameters() # set up the scan parameters as usual +lamni.tomo_parameters_cli() # set up the scan parameters as usual lamni.at_each_angle_hook = "polarizer_mod" # activate the hook lamni.tomo_queue_add("polarizer run") @@ -324,7 +462,7 @@ Load and use it: import my_hooks lamni.register_at_each_angle_hook("high_res_every_5th", my_hooks.high_res_every_5th) -lamni.tomo_parameters() # set up the scan parameters as usual +lamni.tomo_parameters_cli() # set up the scan parameters as usual lamni.at_each_angle_hook = "high_res_every_5th" # activate the hook lamni.tomo_queue_add("subtomo 2 high-res every 5th") @@ -334,7 +472,7 @@ lamni.tomo_queue_execute() `progress["subtomo"]`/`progress["subtomo_projection"]` are populated the same way for tomo_types 2/3, so the same pattern works there too — just note that unlike type 1's fixed 8 sub-tomograms, those types have no fixed sub-tomogram count (see -[Laminography scan](user.ptychography.lamni.laminography) above), so "sub-tomogram 2" +[Laminography scan](lamni.md#test-projections-and-laminography-scan) above), so "sub-tomogram 2" means something different (and open-ended) for them. **GUI:** the tomo parameters panel has an "At-each-angle hook" dropdown, listing @@ -429,7 +567,8 @@ Typical values with proper alignment are * `lamni.feedback_disable()` * `lamni.feedback_enable_without_reset()` \*is only used internally by lamni methods * if reset of angle interferometer is required - `lamni.feedback_disable_and_even_reset_lamni_angle_interferometer()` + `lamni.feedback_disable_and_even_reset_lamni_angle_interferometer()` + * `lamni.feedback_enable_with_reset()` ### Scanning in 2D and sample alignment @@ -441,7 +580,7 @@ Use `scans.lamni_fermat_scan?` for detailed information. A prerequisite for scan ### GUI tools -During operation the BEC GUI will show the relevant cameras or progress information. TAB completion on `lamni.lamnigui_` will show all options to control the GUI. Most useful: +During operation the BEC GUI will show the relevant cameras or progress information. ++tab++ completion on `lamni.lamnigui_` will show all options to control the GUI. Most useful: * `lamni.lamnigui_show_progress()` — shows the measurement progress with ring progress bar * `lamni.lamnigui_show_xeyealign()` — opens the X-ray eye alignment GUI on the Alignment tab @@ -460,11 +599,23 @@ Example: `lamni.lfzp_info(6.2)` The laser feedback will be disabled and fine alignment lost if foptx/y are moved! -Following functions exist to move the optics in and out, the naming is self-explaining. +Following functions exist to move the optics in and out. The positions are the `in`/`out` user parameters of the devices; a move is skipped if the axes are already within 3 µm of the target. +* `lamni.loptics_in()` moves FZP, OSA and central stop in, in that order +* `lamni.loptics_out()` moves central stop, OSA and FZP out, in that order, with the interferometer feedback disabled, and re-enables the feedback with reset afterwards * `lamni.lfzp_in()` -* `lamni.loptics_in()` -* `lamni.loptics_out()` * `lamni.losa_in()` * `lamni.losa_out()` -* `lamni.lfzp_in()` +* `lamni.lcs_in()` moves `lcsx` and `lcsy` to their `in` positions +* `lamni.lcs_out()` moves `lcsy` to its `out` position; `lcsx` has no `out` position and stays where it is + + +#### Interferometer feedback commands + +* `lamni.feedback_enable_with_reset()` enables the closed-loop control and resets the interferometers +* `lamni.feedback_disable()` +* `lamni.feedback_status()` prints whether the loop is running +* `lamni.show_interferometer_positions()` prints the current interferometer x/y positions in µm and whether the loop is running, and returns them as a dict +* `lamni.show_signal_strength()` and `lamni.show_analog_signals()` print the interferometer signal strengths and the piezo/capacitive sensor readings + +With the feedback disabled, the interferometer follows the coarse stages: moving `lsamx` by +0.1 mm changes x by about +50 µm (`x = 0.5 · lsamx` in the LamNI geometry). This is a quick check that the interferometer is working before moving to a scan centre. diff --git a/docs/user/ptychography/omny.md b/docs/user/ptychography/omny.md index 855a8819..95ac8743 100644 --- a/docs/user/ptychography/omny.md +++ b/docs/user/ptychography/omny.md @@ -1,4 +1,3 @@ -(user.ptychography.omny)= # OMNY OMNY is a microscope setup for 3D mesurements via ptychographic X-ray computed tomography. The sample enviroment is in ultra-high vacuum and at a sample temperature of 90 K. The instrument is equipped with a load-lock system and allows loading and unloading of samples under cryogenic conditions. The setup is described in detail [here](https://www.dora.lib4ri.ch/psi/islandora/object/psi:4776). Samples have to be mounted on [OMNY pins](https://www.dora.lib4ri.ch/psi/islandora/object/psi:4528). @@ -9,7 +8,10 @@ OMNY is a microscope setup for 3D mesurements via ptychographic X-ray computed t ### Change to a new sample -The sample storage, shuttles and parking positions are described in detail (here)[user.ptychography.omny.samples]. +The sample storage, shuttles and parking positions are described in detail [here](omny.md#sample-storage-and-transfer). + +1. `omny.otransfer_get_sample(0)` remove current sample from sample stage. Watch gripper action and be ready to ++ctrl+c++ in case something is wrong.
+ _If in doubt check that the correct sample shutte is in the active position by calling `omny.otransfer_storage()`. The slot can be selected by `omny.otransfer_park_slot(slot)`._ 1. `omny.otransfer_get_sample(0)` remove current sample from sample stage. Watch gripper action and be ready to ctrl+c in case something is wrong. _If in doubt check that the correct sample shutte is in the active position by calling `omny.otransfer_show_all()`. The slot can be selected by `omny.otransfer_park_slot(slot)`._ @@ -24,7 +26,7 @@ _If in doubt check that the correct sample shutte is in the active position by c After mounting a new sample, the Xray eye will automatically be at the correct position to collect X-ray data. It can also be manually moved to the correct position by `omny.oeye_xray_in()`. 1. `omny.xrayeye_update_frame()` obtain a new frame that will be displayed on the Windows computer, OMNY software. If you see your sample already at the approximately correct height, you can skip step 2. Otherwise adjust the height: -1. `umvr(dev.osamy, 0.01)`, attention: unit , move the sample stage relative up (positive) or down (negative) until the sample is approximately vertically centered in xray eye screen. After a move get a new frame by `omny.xrayeye_update_frame()`. +1. `umvr(dev.osamy, 0.01)`, attention: unit <mm>, move the sample stage relative up (positive) or down (negative) until the sample is approximately vertically centered in xray eye screen. After a move get a new frame by `omny.xrayeye_update_frame()`. 1. `omny.xrayeye_alignment_start()` start the coarse alignment of the sample by measuring (clicking in the X-ray eye software) the sample position at 0, 45, 90, 135, 180 degrees. Then use the matlab routine `SPEC_ptycho_align.m` to fit this data. 1. `omny.read_alignment_offset()` read the generated alignment data. @@ -42,40 +44,41 @@ _To bypass the fine alignment skip steps_. ### Tomographic Measurement Now that the sample is aligned, the tomographic measurement can be performed. + 1. `omny.tomo_parameters()` adjust the scan parameters for the tomographic scan. This includes the parameters for ptychographic scans of projections plus the strategy for angular sampling. The vertical shift adjusts the field of view, up (positive) or down (negative). After adjusting the numbers, type again `omny.tomo_parameters()` and verify that they are correct. -1. `omny.tomo_scan_projection(angle)` perform a ptychographic scan at the rotation angle , e.g. at zero degrees. When happy with the scan parameters launch the tomographic measurement by `omny.tomo_scan()`. +1. `omny.tomo_scan_projection(angle)` perform a ptychographic scan at the rotation angle <angle>, e.g. at zero degrees. When happy with the scan parameters launch the tomographic measurement by `omny.tomo_scan()`. 1. Before changing to the next sample sample, verify that all subtomograms were completely acquired using the `tomo_recons matlab` script. ### If something went wrong… Special cases: -If the gripper or another stage got stuck during transfer. Ctrl+c and then +If the gripper or another stage got stuck during transfer. ++ctrl+c++ and then `umv(dev.otransy, -1.5)` followed by `omny._otransfer_gripper_to_park_z()`, which will move the gripper to its parking position. -If the above fails for the vertical movement of the gripper _Ctrl+C_. -Try moving up and down a bit `umvr(dev.otransy,0.5)`, `umvr(dev.otransy,-0.5)`, potentially requires a _ctrl+C_ again if stuck. +If the above fails for the vertical movement of the gripper ++ctrl+c++. +Try moving up and down a bit `umvr(dev.otransy,0.5)`, `umvr(dev.otransy,-0.5)`, potentially requires a ++ctrl+c++ again if stuck. Then, `umv(dev.ootransy,-1.5)` followed by `omny._otransfer_gripper_to_park_z()`, which will move the gripper to its parking position. If this error happens after a sample was mounted or unmounted, it is important to chech that the sample storage is correct `omny.otransfer_show_all()`. -(user.ptychography.omny.samples)= + ### Sample storage and transfer The sample holders used are [OMNY pins](https://www.dora.lib4ri.ch/psi/islandora/object/psi:4528). These are installed in sample transfer shuttles, which can load up to six pins. These shuttles are transferred via a vacuum load lock to the vacuum chamber. The loading procedure can be handled at room temperature or under cryogenic conditions. The three __shuttles__ that exist are named __“A”, “B”, “C”__. The __pin positions__ within a shuttle are numbered __1 to 6__ according to the following sketch: -```{figure} omny_shuttle.png -OMNY sample shuttle -``` +![OMNY sample shuttle](omny_shuttle.png) + +*OMNY sample shuttle* In addition to the shuttle positions, there are __fixed positions in OMNY__ which have numbers __larger than 10__. Such fixed positions are treated as system __“O”__. Each shuttle can be placed in a __parking slot__ in OMNY. The parking slots are numbered as displayed below (oparkz slot). Slots 1 and 2 are at room temperature. Slots 3 to 6 are at cryogenic temperature. -```{figure} omny_parking.png -OMNY parking station -``` +![OMNY parking station](omny_parking.png) + +*OMNY parking station* The status of the sample storage has to be correct in BEC. This means that the status of OMNY pins within OMNY ("O") as well as the shuttles has to be correct, the pin status within the shuttles ("A", "B", "C"), as well as the status of the shuttles within the OMNY parking. @@ -102,14 +105,15 @@ Physically moving samples still goes through the CLI — the widget only edits t | Command | Explanation | | --- | --- | | `omny.otransfer_help()` | print a brief help | -| `omny.otransfer_park_slot(slot)` | drive the __parking station__ to place for sample transfer from __ | -| `omny.otransfer_park_loadlock_slot(slot)` | drive the __parking station__ to load __ with the loadlock | -| `omny.otransfer_get_sample(position)` | pick with the gripper from __ | -| `omny.otransfer_put_sample(position)` | put with the gripper to __ | +| `omny.otransfer_park_slot(slot)` | drive the __parking station__ to place for sample transfer from _<slot>_ | +| `omny.otransfer_park_loadlock_slot(slot)` | drive the __parking station__ to load _<slot>_ with the loadlock | +| `omny.otransfer_get_sample(position)` | pick with the gripper from _<position>_ | +| `omny.otransfer_put_sample(position)` | put with the gripper to _<position>_ | For transfer the __sample stage__ is refered to as _position_ 0. Advanced commands ... in case something goes wrong + | Command | Explanation | | --- | --- | | `omny._otransfer_gripper_up()` | move gripper up | @@ -148,11 +152,11 @@ Measured status/account (normally set automatically during a tomographic scan) c ### Status of OMNY -To see the status of the insrument, following commands can be used. Most of the components mentioned below are controlled via devices with naming starting with omny. TAB completion on dev.omny can be a quick way to find the commands. +To see the status of the insrument, following commands can be used. Most of the components mentioned below are controlled via devices with naming starting with omny. ++tab++ completion on dev.omny can be a quick way to find the commands. #### Cameras -During operation the BEC GUI will show the relevant cameras or progress information. To manually switch view TAB completion on `omny.omnygui_` will show all options to control the GUI. Most useful +During operation the BEC GUI will show the relevant cameras or progress information. To manually switch view ++tab++ completion on `omny.omnygui_` will show all options to control the GUI. Most useful - `omny.omnygui_show_omnycam_parking()` - `omny.omnygui_show_omnycam_samplestage()` @@ -269,6 +273,7 @@ If the realtime system is restarted, BEC will lose communication. To restart: To show the signal of the interferometers: `omny.show_signal_strength_interferometer()` Typical values with proper alignment, sample stage at the measurement position and laser tracker running are + | Channel | Name | Value | |---------|-----------|----------| | 1 | OSA FZP Y | 5500 | @@ -299,8 +304,10 @@ Some mirrors are regularly automatically aligned, such as the horizontal mirror #### Interferometer feedback commands The closed loop control of the Piezo stages can be controlled by + - `omny.feedback_enable_with_reset()` _There is also an enable without reset, which is used during tomography scans, when using coarse stages to increase the scan range. It should not be required to use manually._ + - `omny.feedback_disable()` - `omny.feedback_status()` @@ -324,6 +331,7 @@ Example: `omny.ofzp_info(6.2)` __Laser feedback will be disabled and thus fine alignment lost if commands are used that move optics stages!__ Following functions exist to move the optics in and out, with self-explaining naming. + - `omny.optics_in()` - `omny.ofzp_in()` - `omny.ofzp_out()` @@ -341,19 +349,30 @@ The basic scan function can be called by `scans.omny_fermat_scan()` and offers a | fovy (float) | Fov in the piezo plane (i.e. piezo range). Max 100 um | | cenx (float) | center position in x | | ceny (float) | center position in y | -| exp_time (float) | exposure time per frame | -| frames_per_trigger(int) | Number of burst frames per position | | step (float) | stepsize | | zshift (float) | shift in z | | angle (float) | rotation angle (will rotate first) | | corridor_size (float) | corridor size for the corridor optimization. Default 3 um | +| exp_time (float) | exposure time per frame | +| frames_per_trigger (int) | Number of burst frames per position | +| readout_time (float) | configurable readout time for devices that support it | Example: -`scans.omny_fermat_scan(fovx=20, fovy=25, cenx=0.02, ceny=0, zshift=0, angle=0, step=0.5, exp_time=0.01, frames_per_trigger=1)` +`scans.omny_fermat_scan(fovx=20, fovy=25, cenx=0.02, ceny=0, zshift=0, angle=0, step=0.5, exp_time=0.01, frames_per_trigger=1, readout_time=0)` + +`frames_per_trigger` controls burst acquisition for detectors generally. +Separately, the OMNY PandABox is configured as one of two distinct devices in +`csaxs_bec/device_configs/ptycho_flomni.yaml` — `omny_panda` (per-gate +averaged acquisition, currently kept as a commented reference) or +`omny_panda_continuous` (continuous, high-rate raw-sample acquisition) — +because the two modes require different PandA hardware layouts and can't run +simultaneously. See `docs/developer/panda_box_free_running_setup.md` for what +each mode captures and how to switch between them. #### Overview of the alignment steps There are several corrections applied to maintain the sample in the FOV: + 1. Mirror calibration 1. X-ray eye alignment 1. Ptychography fine alignment (improvement of the X-ray eye alignment step) @@ -362,6 +381,7 @@ There are several corrections applied to maintain the sample in the FOV: #### XrayEye and sample alignment Within a usual work-flow the movement of the X-ray eye is mostly moved automatically to the correct position. For manual movements use + | Command | Explanation | | --- | --- | | omny.oeye_xray_in() | move to the fluorescense microscope in | @@ -392,6 +412,21 @@ The loading routine uses default values for the vertical alignment for setup. Th At each projection, the angular dependent is computed by `omny.get_alignment_offset(angle)`, with _angle_ in degrees. +To manually nudge the alignment by a few microns without recording a new fit, +edit the constant offset term of the currently loaded fit directly: +```python +fit = omny.client.get_global_var("tomo_alignment_fit") +fit[0][2] += 5.0 # x offset, microns +fit[1][2] += 3.0 # y offset, microns +omny.client.set_global_var("tomo_alignment_fit", fit) +``` +`tomo_alignment_fit` is a 2x5 list (row 0 = x, row 1 = y); column 2 in each +row is the constant offset of the sinusoidal fit +(`correction = A*sin(angle + phase) + offset [+ 3rd-order term for y]`), so +changing it shifts the correction by that amount at every angle without +touching the fitted amplitude/phase. Check the result with +`omny.get_alignment_offset(angle)`. + The alignment can be cleared by `omny.reset_tomo_alignment_fit()` @@ -437,6 +472,7 @@ Once satisfied with the alignment, the tomography scan can be started by `omny.tomo_scan()` Three modes for angular sampling are implemented and they have different optional parameters for the tomo_scan method: + | tomography mode | parameters and defaults | | --- | --- | | 2 sub-tomograms | subtomo_start=1, start_angle=None | diff --git a/docs/user/ptychography/ptychography.md b/docs/user/ptychography/ptychography.md index b70892b6..045b6810 100644 --- a/docs/user/ptychography/ptychography.md +++ b/docs/user/ptychography/ptychography.md @@ -1,72 +1,26 @@ -(user.ptychography)= # Ptychography Welcome to the ptychography section of the cSAXS beamline. -```{toctree} ---- -maxdepth: 2 -hidden: true ---- +
-flomni/ -lamni/ -omny/ -reconstructions/ +- ![](../../assets/biotech.svg){ width="32" height="32" .middle .card-icon } **[flOMNI](flomni.md)** + { .card-heading } -``` + All about the flOMNI instrument +- ![](../../assets/biotech.svg){ width="32" height="32" .middle .card-icon } **[LamNI](lamni.md)** + { .card-heading } -*** + All about the LamNI instrument -````{grid} 2 -:gutter: 5 +- ![](../../assets/biotech.svg){ width="32" height="32" .middle .card-icon } **[OMNY](omny.md)** + { .card-heading } -```{grid-item-card} -:link: user.ptychography.flomni -:link-type: ref -:img-top: /assets/biotech.svg -:text-align: center -:class-item: index-card + All about the OMNY instrument -## flOMNI +- ![](../../assets/receipt_long.svg){ width="32" height="32" .middle .card-icon } **[Ptychoshelves](reconstructions.md)** + { .card-heading } -All about the flOMNI instrument -``` + All about ptychography reconstructions -```{grid-item-card} -:link: user.ptychography.lamni -:link-type: ref -:img-top: /assets/biotech.svg -:text-align: center -:class-item: index-card - -## LamNI - -All about the LamNI instrument -``` - -```{grid-item-card} -:link: user.ptychography.omny -:link-type: ref -:img-top: /assets/biotech.svg -:text-align: center -:class-item: index-card - -## OMNY - -All about the OMNY instrument -``` - -```{grid-item-card} -:link: user.ptychography.reconstructions -:link-type: ref -:img-top: /assets/receipt_long.svg -:text-align: center -:class-item: index-card - -## Ptychoshelves - -All about ptychography reconstructions -``` - -```` \ No newline at end of file +
diff --git a/docs/user/ptychography/reconstructions.md b/docs/user/ptychography/reconstructions.md index 3c126e22..f17cb23e 100644 --- a/docs/user/ptychography/reconstructions.md +++ b/docs/user/ptychography/reconstructions.md @@ -1,4 +1,3 @@ -(user.ptychography.reconstructions)= # Ptychoshelves Check it out [here](https://www.dora.lib4ri.ch/psi/islandora/object/psi%3A31264). diff --git a/docs/user/saxs/online_integration.md b/docs/user/saxs/online_integration.md index 6ff984f1..6d0ece04 100644 --- a/docs/user/saxs/online_integration.md +++ b/docs/user/saxs/online_integration.md @@ -1,37 +1,35 @@ -(user.saxs.online_integration)= # Setup online radial integration on Jungfraujoch 1. **Calibrate the detector using pyFAI.** - Analyze a calibration scan carefully with `pyFAI` and save the resulting **PONI** file and **pixel mask**. Also determine the beam center position. + Analyze a calibration scan carefully with `pyFAI` and save the resulting **PONI** file and **pixel mask**. Also determine the beam center position. - :::{note} - As of 2026-06-18, Jungfraujoch only accepts `uint32` TIFF mask files. If the mask generated by `pyFAI` is stored as `uint8`, it needs to be converted to the required format before uploading. - To do this (from the home directory `/sls/x12sa/data/[p-group]/raw/`): + !!! note + As of 2026-06-18, Jungfraujoch only accepts `uint32` TIFF mask files. If the mask generated by `pyFAI` is stored as `uint8`, it needs to be converted to the required format before uploading. + To do this (from the home directory `/sls/x12sa/data/[p-group]/raw/`): + + - Activate the python environment: `source mask/.mask/bin/activate` + - Run `python convert_mask_format.py -i -o ` - - Activate the python environment: `source mask/.mask/bin/activate` - - Run `python convert_mask_format.py -i -o ` - ::: 2. **Update the BEC configuration.** - Update the detector distance and beam center coordinates in the BEC configuration files using the calibration results. + Update the detector distance and beam center coordinates in the BEC configuration files using the calibration results. 3. **Configure Jungfraujoch.** - Open the **Jungfraujoch Expert Configuration** panel in the frontend interface, for example: + Open the **Jungfraujoch Expert Configuration** panel in the frontend interface, for example: - ```text - http://sls-jfjoch-001:8080/frontend - ``` + ```text + http://sls-jfjoch-001:8080/frontend + ``` - - Upload the pixel mask under **Pixel Mask**. - - Fill in the required parameters under **Azimuthal Integration Settings**. - - Make sure to enable **Force CPU calculation in FPGA workflow**. - - After uploading a new configuration, do not press "initialize", as this will remove all the previously uploaded configuration. + - Upload the pixel mask under **Pixel Mask**. + - Fill in the required parameters under **Azimuthal Integration Settings**. + - Make sure to enable **Force CPU calculation in FPGA workflow**. + - After uploading a new configuration, do not press "initialize", as this will remove all the previously uploaded configuration. 4. **Detector rotation (if required).** - :::{note} - Adding rotation of the detector still needs to be documented (likely configured in the BEC configuration). - ::: + !!! note + Adding rotation of the detector still needs to be documented (likely configured in the BEC configuration). diff --git a/docs/user/saxs/saxs.md b/docs/user/saxs/saxs.md index abaa235b..727d8d19 100644 --- a/docs/user/saxs/saxs.md +++ b/docs/user/saxs/saxs.md @@ -1,39 +1,15 @@ -(user.saxs)= # SAXS Welcome to the SAXS section of the cSAXS beamline. -```{toctree} ---- -maxdepth: 2 -hidden: true ---- +
-online_integration +- ![](../../assets/biotech.svg){ width="32" height="32" .middle .card-icon } **[Online integration](online_integration.md)** + { .card-heading } -``` - - -*** - -````{grid} 2 -:gutter: 5 - -```{grid-item-card} -:link: user.saxs.online_integration -:link-type: ref -:img-top: /assets/biotech.svg -:text-align: center -:class-item: index-card - -## Online integration - -Setup of online radial integration on Jungfraujoch. -``` - -```` + Setup of online radial integration on Jungfraujoch. +
## Reference documents -- {download}`SAXS standards plate (PDF) ` - +- [SAXS standards plate (PDF)](SAXS_standards_plate.pdf) diff --git a/docs/user/user.md b/docs/user/user.md index 763d47d2..b93c7da0 100644 --- a/docs/user/user.md +++ b/docs/user/user.md @@ -1,72 +1,26 @@ -(user)= # User Welcome to the User section of the cSAXS beamline. -```{toctree} ---- -maxdepth: 2 -hidden: true ---- +
-before_you_arrive/before_you_arrive.md -ptychography/ptychography.md -saxs/saxs.md -data_analysis/data_analysis.md +- ![](../assets/receipt_long.svg){ width="32" height="32" .middle .card-icon } **[Before you arrive](before_you_arrive/before_you_arrive.md)** + { .card-heading } -``` + Learn what to do before you arrive to the beamline. +- ![](../assets/biotech.svg){ width="32" height="32" .middle .card-icon } **[Ptychography](ptychography/ptychography.md)** + { .card-heading } -*** + All about ptychography measurements and reconstructions. -````{grid} 2 -:gutter: 5 +- ![](../assets/biotech.svg){ width="32" height="32" .middle .card-icon } **[SAXS](saxs/saxs.md)** + { .card-heading } -```{grid-item-card} -:link: user.before_you_arrive -:link-type: ref -:img-top: /assets/receipt_long.svg -:text-align: center -:class-item: index-card + All about SAXS measurements and analysis. -## Before you arrive +- ![](../assets/programming.svg){ width="32" height="32" .middle .card-icon } **[Data analysis](data_analysis/data_analysis.md)** + { .card-heading } -Learn what to do before you arrive to the beamline. -``` + All about data analysis during the beamtime. -```{grid-item-card} -:link: user.ptychography -:link-type: ref -:img-top: /assets/biotech.svg -:text-align: center -:class-item: index-card - -## Ptychography - -All about ptychography measurements and reconstructions. -``` - -```{grid-item-card} -:link: user.saxs -:link-type: ref -:img-top: /assets/biotech.svg -:text-align: center -:class-item: index-card - -## SAXS - -All about SAXS measurements and analysis. -``` -```{grid-item-card} -:link: user.data_analysis -:link-type: ref -:img-top: /assets/programming.svg -:text-align: center -:class-item: index-card - -## Data analysis - -All about data analysis during the beamtime. -``` - - -```` \ No newline at end of file +
diff --git a/omny_e2e_tests/_bootstrap.py b/omny_e2e_tests/_bootstrap.py index 40161d81..133d9b4e 100644 --- a/omny_e2e_tests/_bootstrap.py +++ b/omny_e2e_tests/_bootstrap.py @@ -25,7 +25,7 @@ from csaxs_bec.bec_ipython_client.plugins.flomni import flomni as flomni_module from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_webpage_generator import ( FlomniWebpageGenerator, ) -from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import OMNYTools +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import OMNYTools class _NoOpSciLogMessage: diff --git a/omny_e2e_tests/sim_flomni_harness.py b/omny_e2e_tests/sim_flomni_harness.py index 47793ab4..709d5149 100644 --- a/omny_e2e_tests/sim_flomni_harness.py +++ b/omny_e2e_tests/sim_flomni_harness.py @@ -425,36 +425,47 @@ def main(): # noqa: C901 "slot 1 -> gripper bookkeeping", ) - # --- 9b. sample owner (packed into the same DESC field as name) ------------------ + # --- 9b. sample record: name, owner, measurement status (separate IOC fields) ---- samples.set_sample_slot(2, "owner_test_sample", owner="mholler") check( - samples.get_sample_name(2) == "owner_test_sample", "get_sample_name returns plain name only" + samples.get_sample_name(2) == "owner_test_sample", "get_sample_name returns the name" ) - check(samples.get_sample_owner(2) == "mholler", "get_sample_owner returns the packed owner") + check(samples.get_sample_owner(2) == "mholler", "get_sample_owner returns the owner") check( samples.get_sample_name_and_owner(2) == ("owner_test_sample", "mholler"), "get_sample_name_and_owner returns (name, owner)", ) + check(samples.get_sample_status(2) == "none", "a new sample starts with status 'none'") samples.unset_sample_slot(2) check( - str(getattr(samples.sample_names, "sample2").get()) == "-", - "unset_sample_slot writes bare '-', not '- | '", + str(getattr(samples.sample_names, "sample2").get()) == "-" + and samples.get_sample_owner(2) == "", + "unset_sample_slot clears name ('-') and owner", ) - # gripper move (slot -> gripper) must preserve owner, not just name. The - # gripper is a separate Cpt (sample_in_gripper_name), not part of the - # sample_names slot dict, so unpack it directly rather than via - # get_sample_name_and_owner (which only covers slots 0-20). - from csaxs_bec.devices.omny.sample_desc_codec import unpack_desc - + # transfer slot -> gripper -> slot must carry the whole record (name, owner, + # measurement status and date), slot 100 = gripper samples.set_sample_slot(4, "transfer_test_sample", owner="e12345") - name, owner = samples.get_sample_name_and_owner(4) - samples.set_sample_in_gripper(name, owner=owner) - samples.unset_sample_slot(4) + samples.set_sample_measured(4, "completed", "2026-10") + samples.transfer_sample(4, 100) check( - unpack_desc(str(samples.sample_in_gripper_name.get())) == ("transfer_test_sample", "e12345"), - "owner survives a slot -> gripper move", + samples.is_sample_in_gripper() + and not samples.is_sample_slot_used(4) + and samples.get_sample_name_and_owner(100) == ("transfer_test_sample", "e12345") + and samples.get_sample_status(100) == "completed" + and samples.get_sample_meas_date(100) == "2026-10", + "slot -> gripper move carries name, owner, status and date", ) - samples.unset_sample_in_gripper() + samples.transfer_sample(100, 5) + check( + not samples.is_sample_in_gripper() and samples.get_sample_status(5) == "completed", + "gripper -> slot move carries the status", + ) + samples.unset_sample_slot(5) + # set_sample_measured fills an empty owner (label printing uses the owner as account) + samples.set_sample_slot(6, "no_owner_sample") + samples.set_sample_measured(6, "started", "2026-10", owner_if_empty="e99999") + check(samples.get_sample_owner(6) == "e99999", "owner_if_empty fills an empty owner") + samples.unset_sample_slot(6) frame_a = ids_cam.cam.get_image_data() frame_b = ids_cam.cam.get_image_data() diff --git a/omny_e2e_tests/test_tomo_queue.py b/omny_e2e_tests/test_tomo_queue.py index 561791f5..5b2a67ed 100644 --- a/omny_e2e_tests/test_tomo_queue.py +++ b/omny_e2e_tests/test_tomo_queue.py @@ -65,7 +65,7 @@ def test_empty_and_all_done_queue_are_noops(flomni_sim, monkeypatch, capsys): flomni = flomni_sim prompt_calls = [] - from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import OMNYTools + from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import OMNYTools monkeypatch.setattr( OMNYTools, "yesno", lambda self, *a, **k: prompt_calls.append((a, k)) or True diff --git a/omny_e2e_tests/test_tomo_queue_command_jobs.py b/omny_e2e_tests/test_tomo_queue_command_jobs.py index dfb3d890..ea2e8554 100644 --- a/omny_e2e_tests/test_tomo_queue_command_jobs.py +++ b/omny_e2e_tests/test_tomo_queue_command_jobs.py @@ -1,10 +1,13 @@ """Step 2 -- command jobs. See AI_docs/TOMO_QUEUE_COMMAND_JOBS_PLAN.md. -mokev/idgap (the real curated move targets) aren't in the flomni sim config -(simulated_flomni.yaml) -- only on the real front end (bl_frontend.yaml), so -the real device-moving path here is exercised against `ftray` (sample -transfer tray), added to _TOMO_QUEUE_MOVE_DEVICES specifically for this -sim-testability, per TOMO_QUEUE_TESTING.md. +ccm_energy/idgap (the plain "move" allow-list) aren't in the flomni sim +config (simulated_flomni.yaml) -- only on the real front end +(bl_frontend.yaml/bl_optics_hutch.yaml). Tests that only exercise add-time +validation/rendering (no tomo_queue_execute()) reference idgap for that +reason, while tests that actually execute the queue use the dedicated +"move_foptz_with_shift" action against foptz, which IS in the sim config +and is the only queue-movable path for foptz (it is deliberately excluded +from the plain "move" allow-list, see flomni.py's _TOMO_QUEUE_MOVE_DEVICES). """ import datetime @@ -13,8 +16,8 @@ import uuid import pytest from _queue_helpers import FAST_STEPSIZE, add_short_job -FTRAY_A = -50.0 -FTRAY_B = -10.0 +FOPTZ_A = 15.0 +FOPTZ_B = 5.0 def test_add_command_validation_errors(flomni_sim): @@ -28,12 +31,18 @@ def test_add_command_validation_errors(flomni_sim): with pytest.raises(ValueError, match="above the maximum"): flomni.tomo_queue_add_command( - {"action": "optimize_idgap", "kwargs": {"search_range": 99.0}} + { + "action": "move_foptz_with_shift", + "kwargs": {"foptz_target": 99.0, "manual_shift_x": 0.0, "manual_shift_y": 0.0}, + } ) with pytest.raises(ValueError, match="must be a number"): flomni.tomo_queue_add_command( - {"action": "optimize_idgap", "kwargs": {"search_range": "wide"}} + { + "action": "move_foptz_with_shift", + "kwargs": {"foptz_target": "far", "manual_shift_x": 0.0, "manual_shift_y": 0.0}, + } ) with pytest.raises(ValueError, match="has no parameter"): @@ -74,23 +83,25 @@ def test_move_execution_time_allowlist_recheck(flomni_sim): def test_tomo_queue_actions_global_var_matches_registry(flomni_sim): flomni = flomni_sim published = flomni.client.get_global_var("tomo_queue_actions") - assert set(published["actions"]) == {"move", "optimize_idgap"} - assert published["move_devices"]["ftray"]["unit"] == "mm" + assert set(published["actions"]) == {"move", "move_foptz_with_shift"} + assert published["move_devices"]["idgap"]["unit"] == "mm" assert published["actions"]["move"]["idempotent"] is True - assert published["actions"]["optimize_idgap"]["params"]["search_range"]["default"] == 0.5 + assert ( + published["actions"]["move_foptz_with_shift"]["params"]["foptz_target"]["default"] == 17.0 + ) def test_show_and_webpage_survive_command_job(flomni_sim, capsys): flomni = flomni_sim flomni.tomo_queue_add_command( - {"action": "move", "kwargs": {"positions": {"ftray": FTRAY_A}}}, - label="move ftray", + {"action": "move", "kwargs": {"positions": {"idgap": 6.0}}}, + label="move idgap", idempotent=True, ) capsys.readouterr() jobs = flomni.tomo_queue_show() out = capsys.readouterr().out - assert "CMD move{'positions': {'ftray': -50.0}}" in out + assert "CMD move{'positions': {'idgap': 6.0}}" in out assert "[idem]" in out assert jobs[0]["kind"] == "command" @@ -100,7 +111,7 @@ def test_show_and_webpage_survive_command_job(flomni_sim, capsys): assert "isCommand" in html # the rendering branch exists; full JS isn't executed here -def test_legacy_job_no_kind_defaults_to_tomo_and_command_moves_ftray(flomni_sim): +def test_legacy_job_no_kind_defaults_to_tomo_and_command_moves_foptz(flomni_sim): """B-style back-compat + the real command-job execution path, combined into one mixed-queue run to keep the (only) real scan in this file to one job. @@ -109,12 +120,12 @@ def test_legacy_job_no_kind_defaults_to_tomo_and_command_moves_ftray(flomni_sim) bec = flomni.client # Start from a known position, not whatever a previous test run happened - # to leave ftray at -- ftray's position is real device-server state that + # to leave foptz at -- foptz's position is real device-server state that # persists across separate pytest invocations, unlike the tomo_queue/ # tomo_progress global vars the flomni_sim fixture snapshots/restores. - flomni._queue_action_move({"ftray": FTRAY_B}) - ftray_before = bec.device_manager.devices.ftray.readback.get() - assert ftray_before == pytest.approx(FTRAY_B) + flomni._queue_action_move_foptz_with_shift(FOPTZ_B, 0.0, 0.0) + foptz_before = bec.device_manager.devices.foptz.readback.get() + assert foptz_before == pytest.approx(FOPTZ_B) add_short_job(flomni, "legacy_tomo", FAST_STEPSIZE) # Simulate a queue entry written before the "kind" field existed. @@ -123,41 +134,48 @@ def test_legacy_job_no_kind_defaults_to_tomo_and_command_moves_ftray(flomni_sim) bec.set_global_var("tomo_queue", jobs) flomni.tomo_queue_add_command( - {"action": "move", "kwargs": {"positions": {"ftray": FTRAY_A}}}, label="move ftray" - ) - flomni.tomo_queue_add_command( - {"action": "optimize_idgap", "kwargs": {"search_range": 0.5}}, label="idgap stub" + { + "action": "move_foptz_with_shift", + "kwargs": {"foptz_target": FOPTZ_A, "manual_shift_x": 1.0, "manual_shift_y": -2.0}, + }, + label="move foptz", ) flomni.tomo_queue_execute() jobs = flomni._tomo_queue_proxy.as_list() - assert [j["status"] for j in jobs] == ["done", "done", "done"] + assert [j["status"] for j in jobs] == ["done", "done"] # Still no "kind" key -- proves it ran (and got marked done) via the # back-compat default, not that execute() silently added one. assert "kind" not in jobs[0] assert "params" in jobs[0] assert jobs[1]["kind"] == "command" - ftray_after = bec.device_manager.devices.ftray.readback.get() - assert ftray_after == pytest.approx(FTRAY_A) - assert ftray_after != pytest.approx(ftray_before) + foptz_after = bec.device_manager.devices.foptz.readback.get() + assert foptz_after == pytest.approx(FOPTZ_A) + assert foptz_after != pytest.approx(foptz_before) + assert flomni.manual_shift_x == pytest.approx(1.0) + assert flomni.manual_shift_y == pytest.approx(-2.0) def test_non_idempotent_command_resume_prompts(flomni_sim, monkeypatch): flomni = flomni_sim bec = flomni.client - ftray = bec.device_manager.devices.ftray + foptz = bec.device_manager.devices.foptz - # Use the same real move path the "move" action itself uses, rather than - # the device's underlying ophyd object directly (ftray's BEC wrapper is - # a Positioner here, unlike fsamx's DeviceBase -- no .obj to reach for). - flomni._queue_action_move({"ftray": FTRAY_B}) - start = ftray.readback.get() - assert start == pytest.approx(FTRAY_B) + # Use the same real move path the "move_foptz_with_shift" action itself + # uses, rather than the device's underlying ophyd object directly + # (foptz's BEC wrapper is a Positioner here, unlike fsamx's DeviceBase -- + # no .obj to reach for). + flomni._queue_action_move_foptz_with_shift(FOPTZ_B, 0.0, 0.0) + start = foptz.readback.get() + assert start == pytest.approx(FOPTZ_B) index = flomni.tomo_queue_add_command( - {"action": "move", "kwargs": {"positions": {"ftray": FTRAY_A}}}, + { + "action": "move_foptz_with_shift", + "kwargs": {"foptz_target": FOPTZ_A, "manual_shift_x": 0.0, "manual_shift_y": 0.0}, + }, label="non-idempotent move", idempotent=False, ) @@ -165,7 +183,7 @@ def test_non_idempotent_command_resume_prompts(flomni_sim, monkeypatch): flomni._tomo_queue_proxy.update_by_id(job_id, status="incomplete") prompts = [] - from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import OMNYTools + from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import OMNYTools def _yesno(resume_answer): # tomo_queue_execute()'s own top-level "OK to start?" confirmation @@ -180,11 +198,11 @@ def test_non_idempotent_command_resume_prompts(flomni_sim, monkeypatch): return _fn - # "no" -> must not re-run: ftray stays put, job still ends up "done". + # "no" -> must not re-run: foptz stays put, job still ends up "done". monkeypatch.setattr(OMNYTools, "yesno", _yesno(False)) flomni.tomo_queue_execute() assert len(prompts) == 1 - assert ftray.readback.get() == pytest.approx(start) + assert foptz.readback.get() == pytest.approx(start) assert flomni._tomo_queue_proxy.as_list()[0]["status"] == "done" # Re-arm as incomplete and try again with "yes" -> must re-run. @@ -192,5 +210,5 @@ def test_non_idempotent_command_resume_prompts(flomni_sim, monkeypatch): monkeypatch.setattr(OMNYTools, "yesno", _yesno(True)) flomni.tomo_queue_execute() assert len(prompts) == 2 - assert ftray.readback.get() == pytest.approx(FTRAY_A) + assert foptz.readback.get() == pytest.approx(FOPTZ_A) assert flomni._tomo_queue_proxy.as_list()[0]["status"] == "done" diff --git a/pyproject.toml b/pyproject.toml index ba3b703a..4110f2b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ ] [project.optional-dependencies] +docs = ["zensical", "pymdown-extensions"] allied_vision = [ "pygobject<3.52", # for the AlliedVision Alvium camera (native Aravis/GenICam, no EPICS IOC) # pinned below 3.52: that version switched to requiring diff --git a/tests/tests_bec_ipython_client/test_angle_file.py b/tests/tests_bec_ipython_client/test_angle_file.py new file mode 100644 index 00000000..c7ddcf88 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_angle_file.py @@ -0,0 +1,260 @@ +"""Tests for the tomo_type-4 angle file parser (OMNY_shared/angle_file.py).""" + +import pytest + +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.angle_file import ( + AngleEntry, + AngleFileError, + describe_opposite_pairs, + describe_summary, + read_angle_file, + summarize_angles, +) + + +def _e(angle, subtomo=1, step=None, exp_time=None, frames_per_trigger=None): + """Shorthand for building the expected AngleEntry list in assertions.""" + return AngleEntry( + angle=angle, subtomo=subtomo, step=step, exp_time=exp_time, + frames_per_trigger=frames_per_trigger, + ) + + +def _write(tmp_path, text, name="angles.txt"): + path = tmp_path / name + path.write_text(text) + return str(path) + + +def test_one_column_defaults_to_subtomo_1(tmp_path): + path = _write(tmp_path, "0\n45.5\n360\n") + result = read_angle_file(path) + assert result.angles == [_e(0.0), _e(45.5), _e(360.0)] + assert result.is_complete is False + + +def test_two_columns_whitespace_and_comma_separators(tmp_path): + path = _write(tmp_path, "0 1\n10\t1\n190.5, 2\n270,3\n") + assert read_angle_file(path).angles == [_e(0.0), _e(10.0), _e(190.5, 2), _e(270.0, 3)] + + +def test_comments_and_blank_lines_ignored_and_order_kept(tmp_path): + path = _write(tmp_path, "# header\n\n90 2 # trailing comment\n \n30 1\n") + assert read_angle_file(path).angles == [_e(90.0, 2), _e(30.0, 1)] + + +def test_tilde_expansion(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + _write(tmp_path, "12\n") + assert read_angle_file("~/angles.txt").angles == [_e(12.0)] + + +def test_angle_range_matches_rotation_stage_overtravel(tmp_path): + """-2 and 362 (the stage's physical overtravel limits) must parse, not + just the nominal 0-360 sweep.""" + path = _write(tmp_path, "-2\n-0.1\n0\n360.01\n362\n") + assert read_angle_file(path).angles == [ + _e(-2.0), + _e(-0.1), + _e(0.0), + _e(360.01), + _e(362.0), + ] + + +def test_end_marks_complete(tmp_path): + path = _write(tmp_path, "0\n10\nEND\n") + result = read_angle_file(path) + assert result.angles == [_e(0.0), _e(10.0)] + assert result.is_complete is True + + +def test_missing_end_is_incomplete(tmp_path): + path = _write(tmp_path, "0\n10\n") + assert read_angle_file(path).is_complete is False + + +@pytest.mark.parametrize("marker", ["END", "end", "EnD"]) +def test_end_case_insensitive(tmp_path, marker): + path = _write(tmp_path, f"0\n{marker}\n") + assert read_angle_file(path).is_complete is True + + +def test_commented_end_is_not_sentinel(tmp_path): + path = _write(tmp_path, "0\n# END\n10\n") + result = read_angle_file(path) + assert result.angles == [_e(0.0), _e(10.0)] + assert result.is_complete is False + + +def test_end_with_trailing_comment(tmp_path): + path = _write(tmp_path, "0\nEND # done\n") + assert read_angle_file(path).is_complete is True + + +def test_content_after_end_raises(tmp_path): + path = _write(tmp_path, "0\nEND\n10\n") + with pytest.raises(AngleFileError, match="line 3.*line 2"): + read_angle_file(path) + + +def test_blank_and_comment_after_end_allowed(tmp_path): + path = _write(tmp_path, "0\nEND\n\n# trailing note\n") + result = read_angle_file(path) + assert result.angles == [_e(0.0)] + assert result.is_complete is True + + +@pytest.mark.parametrize( + "text, match", + [ + ("-2.1\n", "outside"), + ("362.01\n", "outside"), + ("abc\n", "invalid angle"), + ("10 x\n", "invalid sub-tomogram"), + ("10 0\n", ">= 1"), + ("10 1 5\n", "expected"), + ("# only comments\n\n", "no angles"), + ], +) +def test_invalid_content_raises_with_reason(tmp_path, text, match): + path = _write(tmp_path, text) + with pytest.raises(AngleFileError, match=match): + read_angle_file(path) + + +def test_error_reports_line_number(tmp_path): + path = _write(tmp_path, "# c\n10\nbad\n") + with pytest.raises(AngleFileError, match="line 3"): + read_angle_file(path) + + +def test_missing_file_and_empty_path_raise(tmp_path): + with pytest.raises(AngleFileError, match="Cannot read"): + read_angle_file(str(tmp_path / "nope.txt")) + with pytest.raises(AngleFileError, match="No angle file"): + read_angle_file("") + + +def test_angle_file_error_is_a_value_error(): + assert issubclass(AngleFileError, ValueError) + + +def test_summary_counts(): + summary = summarize_angles([_e(0.0), _e(45.0), _e(100.0, 3)]) + assert summary["n"] == 3 + assert summary["min"] == 0.0 + assert summary["max"] == 100.0 + assert summary["n_subtomos"] == 2 + assert summary["n_overridden"] == 0 + assert summary["opposite_pairs"] == [] + assert describe_summary(summary) == "3 angles, 0.00-100.00 deg, 2 sub-tomograms" + assert describe_opposite_pairs(summary) is None + + +def test_summary_detects_180_opposite_and_exact_duplicates(): + entries = [_e(10.0), _e(190.0), _e(50.0), _e(50.0, 2), _e(120.0)] + pairs = summarize_angles(entries)["opposite_pairs"] + assert [(a, b) for a, b, _, _ in pairs] == [(0, 1), (2, 3)] + + +def test_summary_detects_pair_across_the_mod_180_wrap(): + # 179.99 and 0.0 are 0.01 apart mod 180; so are 359.99 and 0.0 + pairs = summarize_angles([_e(0.0), _e(90.0), _e(359.99)])["opposite_pairs"] + assert [(a, b) for a, b, _, _ in pairs] == [(0, 2)] + + +def test_summary_no_false_positives_for_equally_spaced_360(): + # 0, 7, 14, ... 353: 360/7 is not an integer, so no 180-degree pairs + entries = [_e(float(a)) for a in range(0, 360, 7)] + assert summarize_angles(entries)["opposite_pairs"] == [] + + +def test_summary_flags_every_pair_for_equally_spaced_360_with_even_count(): + entries = [_e(float(a)) for a in range(0, 360, 10)] + assert len(summarize_angles(entries)["opposite_pairs"]) == 18 + + +# ── per-line acquisition overrides (step=/exptime=/frames=) ──────────────── + + +def test_overrides_parsed_and_sticky_across_lines(tmp_path): + path = _write( + tmp_path, + "0\n" + "10 1\n" + "20 1 step=0.5\n" + "30 1\n" + "40 2 step=2 exptime=5 frames=3\n" + "50 2\n", + ) + result = read_angle_file(path) + assert result.angles == [ + _e(0.0), + _e(10.0), + _e(20.0, step=0.5), + _e(30.0, step=0.5), + _e(40.0, 2, step=2.0, exp_time=5.0, frames_per_trigger=3), + _e(50.0, 2, step=2.0, exp_time=5.0, frames_per_trigger=3), + ] + + +def test_overrides_independent_sticky_state(tmp_path): + """Each of step/exptime/frames carries forward independently -- setting + one doesn't reset or require the others.""" + path = _write(tmp_path, "0 1 step=1\n10 1 exptime=2\n20 1 frames=4\n") + result = read_angle_file(path) + assert result.angles == [ + _e(0.0, step=1.0), + _e(10.0, step=1.0, exp_time=2.0), + _e(20.0, step=1.0, exp_time=2.0, frames_per_trigger=4), + ] + + +def test_overrides_compose_with_subtomo_and_comma_separators(tmp_path): + path = _write(tmp_path, "10, 2, step=0.3\n") + result = read_angle_file(path) + assert result.angles == [_e(10.0, 2, step=0.3)] + + +def test_overrides_compose_with_end_sentinel(tmp_path): + path = _write(tmp_path, "0 1 step=0.5\n10\nEND\n") + result = read_angle_file(path) + assert result.angles == [_e(0.0, step=0.5), _e(10.0, step=0.5)] + assert result.is_complete is True + + +@pytest.mark.parametrize( + "text, match", + [ + ("10 foo=1\n", "unknown key 'foo'"), + ("10 step=1 step=2\n", "duplicate key 'step'"), + ("10 step=\n", "malformed"), + ("10 =5\n", "malformed"), + ("10 step=abc\n", "invalid step"), + ("10 frames=abc\n", "invalid frames"), + ("10 step=0.01\n", r"outside \[0.025, 20\]"), + ("10 step=21\n", r"outside \[0.025, 20\]"), + ("10 exptime=0.0001\n", r"outside \[0.001, 100\]"), + ("10 exptime=200\n", r"outside \[0.001, 100\]"), + ("10 frames=0\n", r"outside \[1, 100\]"), + ("10 frames=101\n", r"outside \[1, 100\]"), + ], +) +def test_override_errors(tmp_path, text, match): + path = _write(tmp_path, text) + with pytest.raises(AngleFileError, match=match): + read_angle_file(path) + + +def test_summary_reports_override_count(): + entries = [_e(0.0), _e(10.0, step=0.5), _e(20.0, exp_time=2.0)] + summary = summarize_angles(entries) + assert summary["n_overridden"] == 2 + assert "2 with per-line overrides" in describe_summary(summary) + + +def test_summary_omits_override_note_when_none_overridden(): + summary = summarize_angles([_e(0.0), _e(10.0)]) + assert summary["n_overridden"] == 0 + assert "overrides" not in describe_summary(summary) diff --git a/tests/tests_bec_ipython_client/test_cSAXS_filter_transmission.py b/tests/tests_bec_ipython_client/test_cSAXS_filter_transmission.py new file mode 100644 index 00000000..b7072db7 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_cSAXS_filter_transmission.py @@ -0,0 +1,53 @@ +from unittest import mock + +import pytest + +import csaxs_bec.bec_ipython_client.plugins.cSAXS.filter_transmission as ft_module +from csaxs_bec.bec_ipython_client.plugins.cSAXS.filter_transmission import ( + cSAXSFilterTransmission, +) + + +def _make_filter(): + filt = cSAXSFilterTransmission() + filt.OMNYTools = mock.MagicMock() + return filt + + +def _make_dev(readbacks: dict): + """Mock `dev` where each filter axis' readback.get returns the given value.""" + dev = mock.MagicMock() + for axis in cSAXSFilterTransmission._AXES: + axis_obj = mock.MagicMock() + axis_obj.readback.get.return_value = readbacks[axis] + setattr(dev, axis, axis_obj) + return dev + + +def test_fil_trans_report_reads_positions_cached(capsys): + filt = _make_filter() + # Sit exactly on the first defined nominal position of every unit + readbacks = {} + for axis, positions in zip(filt._AXES, filt._POSITIONS_USER): + readbacks[axis] = next(p for p in positions if p is not None) + dev = _make_dev(readbacks) + + with mock.patch.object(ft_module, "dev", dev, create=True): + filt._fil_trans_report(energy_kev=7.0) + + for axis in filt._AXES: + getattr(dev, axis).readback.get.assert_called_once_with(cached=True) + assert "Matched filter code" in capsys.readouterr().out + + +def test_fil_trans_report_without_cached_value_reports_error(capsys): + filt = _make_filter() + readbacks = {axis: None for axis in filt._AXES} # nothing published to redis yet + dev = _make_dev(readbacks) + + with mock.patch.object(ft_module, "dev", dev, create=True): + filt._fil_trans_report(energy_kev=7.0) + + out = capsys.readouterr().out + assert f"readback unavailable for axis {filt._AXES[0]}" in out + assert "Matched filter code" not in out diff --git a/tests/tests_bec_ipython_client/test_cSAXS_slits.py b/tests/tests_bec_ipython_client/test_cSAXS_slits.py new file mode 100644 index 00000000..b381bfbd --- /dev/null +++ b/tests/tests_bec_ipython_client/test_cSAXS_slits.py @@ -0,0 +1,45 @@ +from unittest import mock + +from csaxs_bec.bec_ipython_client.plugins.cSAXS.slits import cSAXSSlits + + +def _make_slits(devices: dict): + client = mock.MagicMock() + client.device_manager.devices = devices + return cSAXSSlits(client) + + +def test_sl_get_position_uses_cached_read(): + dev = mock.MagicMock() + dev.read.return_value = {"sl4xc": {"value": 1.25}} + slits = _make_slits({"sl4xc": dev}) + + assert slits._sl_get_position("sl4xc") == 1.25 + dev.read.assert_called_once_with(cached=True) + + +def test_sl_get_position_returns_none_without_cached_value(): + dev = mock.MagicMock() + dev.read.return_value = None # nothing published to redis yet + slits = _make_slits({"sl4xc": dev}) + + assert slits._sl_get_position("sl4xc") is None + assert slits._sl_get_position("unknown_device") is None + + +def test_slits_show_all_reads_every_slit_device_cached(capsys): + devices = {} + for prefix in cSAXSSlits._SLIT_LABELS: + for suffix in ("xc", "xs", "yc", "ys"): + name = f"{prefix}{suffix}" + dev = mock.MagicMock() + dev.read.return_value = {name: {"value": 0.5}} + devices[name] = dev + slits = _make_slits(devices) + + slits.slits_show_all() + + assert len(devices) == 24 + for dev in devices.values(): + dev.read.assert_called_once_with(cached=True) + assert "cSAXS Slits Overview" in capsys.readouterr().out diff --git a/tests/tests_bec_ipython_client/test_eps_history_shrink.py b/tests/tests_bec_ipython_client/test_eps_history_shrink.py new file mode 100644 index 00000000..ee163635 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_eps_history_shrink.py @@ -0,0 +1,104 @@ +"""Tests for HistoryTracker's upload-size guard on eps_alarm_history.json. + +A long-running/flapping EPS alarm could push the uploaded file past the +upload server's request-size limit (bare HTTP 413, no size check in +upload.php itself -- almost certainly nginx's client_max_body_size, default +1m) even with the count-based caps in eps_status_generator.py, since those +bound event/text *counts* but not individual alarm-text length, and a +previously-oversized event's texts list is never re-capped on load. See +x12sa report 2026-09-18: eps_alarm_history.json observed at 1,209,271 bytes. +""" + +import json + +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.eps.eps_status_generator import ( + _MAX_UPLOAD_BYTES, + HistoryTracker, + _payload_size, + _shrink_to_fit, +) + + +def _bloated_payload(n_events=50, texts_per_event=100, text_len=250): + eps_events = [ + { + "first_seen": i, + "cleared_at": None, + "count": 3, + "texts": [f"alarm text variant {i}-{j} " + "x" * text_len for j in range(texts_per_event)], + } + for i in range(n_events) + ] + machine_events = [ + {"pv": f"PV{i}", "label": "x", "from": "a" * 20, "to": "b" * 20, "at": i} + for i in range(n_events) + ] + ring_current_trace = [{"t": i, "v": 400.0 + i * 0.01} for i in range(288)] + return { + "eps_events": eps_events, + "machine_events": machine_events, + "ring_current_trace": ring_current_trace, + } + + +def test_shrink_to_fit_is_noop_when_already_under_budget(): + payload = _bloated_payload(n_events=2, texts_per_event=1, text_len=10) + assert _payload_size(payload) <= _MAX_UPLOAD_BYTES + + shrunk = _shrink_to_fit(payload) + + assert shrunk == payload + assert shrunk is not payload # returns a copy, doesn't mutate the caller's data + + +def test_shrink_to_fit_brings_bloated_payload_under_budget(): + payload = _bloated_payload() + assert _payload_size(payload) > _MAX_UPLOAD_BYTES # reproduces the reported 1.2 MB case + + shrunk = _shrink_to_fit(payload) + + assert _payload_size(shrunk) <= _MAX_UPLOAD_BYTES + # trimming per-event alarm texts alone is enough here -- full event/trace + # history (the actually useful part) is preserved + assert len(shrunk["eps_events"]) == 50 + assert len(shrunk["machine_events"]) == 50 + assert len(shrunk["ring_current_trace"]) == 288 + + +def test_shrink_to_fit_does_not_mutate_input(): + payload = _bloated_payload() + original_texts_len = len(payload["eps_events"][0]["texts"]) + + _shrink_to_fit(payload) + + assert len(payload["eps_events"][0]["texts"]) == original_texts_len + + +def test_shrink_to_fit_drops_oldest_events_when_a_tiny_budget_forces_it(): + payload = _bloated_payload(n_events=10, texts_per_event=5, text_len=50) + + shrunk = _shrink_to_fit(payload, max_bytes=500) + + assert _payload_size(shrunk) <= 500 or len(shrunk["eps_events"]) == 1 + # oldest dropped first: whatever's left is the most recent + if len(shrunk["eps_events"]) < 10: + kept_ids = [ev["first_seen"] for ev in shrunk["eps_events"]] + assert kept_ids == sorted(kept_ids) + assert kept_ids[-1] == 9 + + +def test_history_tracker_save_writes_file_under_budget(tmp_path): + path = tmp_path / "eps_alarm_history.json" + tracker = HistoryTracker(path) + bloated = _bloated_payload() + tracker.eps_events = bloated["eps_events"] + tracker.machine_events = bloated["machine_events"] + tracker.ring_trace = bloated["ring_current_trace"] + + tracker.save() + + written_size = path.stat().st_size + assert written_size <= _MAX_UPLOAD_BYTES + # written file is still valid JSON with the expected top-level shape + data = json.loads(path.read_text()) + assert set(data.keys()) == {"eps_events", "machine_events", "ring_current_trace"} diff --git a/tests/tests_bec_ipython_client/test_fermat_position_warning.py b/tests/tests_bec_ipython_client/test_fermat_position_warning.py index 36ac41ac..3c367a24 100644 --- a/tests/tests_bec_ipython_client/test_fermat_position_warning.py +++ b/tests/tests_bec_ipython_client/test_fermat_position_warning.py @@ -76,8 +76,10 @@ def test_flomni_expected_position_count_below_threshold(): count = flomni._expected_fermat_position_count() assert count < flomni._fermat_min_positions() - # matches calling the real scan-class algorithm directly - expected = FlomniFermatScan.get_flomni_fermat_spiral_pos(-2.5, 2.5, -2.5, 2.5, step=2.0) + # matches calling the real scan-class algorithm directly. _expected_fermat_position_count() + # pins seed=0 for its own reproducibility -- match it here rather than drawing an + # independent random offset. + expected = FlomniFermatScan.get_flomni_fermat_spiral_pos(-2.5, 2.5, -2.5, 2.5, step=2.0, seed=0) assert count == len(expected) @@ -104,7 +106,9 @@ def test_lamni_expected_position_count_below_threshold(): count = lamni._expected_fermat_position_count() assert count < lamni._fermat_min_positions() - # matches calling the real scan-class algorithm directly, center tile, angle 0 + # matches calling the real scan-class algorithm directly, center tile, angle 0. + # _expected_fermat_position_count() pins seed=0 for its own reproducibility -- match + # it here rather than drawing an independent random offset. expected = LamniFermatScan.get_lamni_fermat_spiral_pos( -2.5, 2.5, @@ -118,6 +122,7 @@ def test_lamni_expected_position_count_below_threshold(): fovx=5.0, fovy=5.0, fov_circular=0.0, + seed=0, ) assert count == len(expected) diff --git a/tests/tests_bec_ipython_client/test_filter_check.py b/tests/tests_bec_ipython_client/test_filter_check.py new file mode 100644 index 00000000..a45d6894 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_filter_check.py @@ -0,0 +1,81 @@ +"""Tests for filters_out_of_beam() (csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py). + +Regression coverage for a bug surfaced on a simulated flomni session: +tomo_scan_projection() -> filters_out_of_beam() crashed with +DeviceConfigError: Device filter_array_1_x does not exist, because the sim +config (unlike the real beamline's bl_endstation.yaml) doesn't define the +filter_array_*_x devices at all. The lookup used `getattr(dev, axis_name, +None)`, but BEC's device container raises DeviceConfigError (a plain +Exception, not AttributeError) for a missing device, so getattr's default +never applied. +""" + +from unittest import mock + +from bec_lib.bec_errors import DeviceConfigError + +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.filter_check import filters_out_of_beam + + +class _FakeDeviceContainer(dict): + """Stands in for BEC's real device-manager container: attribute access + for a present device returns it, for a missing one raises + DeviceConfigError (not AttributeError) -- see devicemanager.py's + __getattr__.""" + + def __getattr__(self, attr): + dev = self.get(attr) + if not dev: + raise DeviceConfigError(f"Device {attr} does not exist.") + return dev + + +def _axis_at(position: float): + axis = mock.MagicMock() + axis.readback.get.return_value = position + return axis + + +def test_filters_out_of_beam_dev_is_none(): + # The usual state of the module-level `dev` idiom before a real BEC + # session has set it (`dev = builtins.__dict__.get("dev")`) -- several + # LamNI/Flomni call sites pass this straight through. Must not raise. + all_out, offending = filters_out_of_beam(None) + + assert all_out is True + assert offending == [] + + +def test_filters_out_of_beam_all_devices_missing_from_container(): + # Mirrors a simulated deployment: none of the filter_array_*_x devices + # exist at all. Must not raise, and must report "all out" (nothing to + # check -> vacuously true), matching the existing axis_obj-is-None skip. + dev = _FakeDeviceContainer() + + all_out, offending = filters_out_of_beam(dev) + + assert all_out is True + assert offending == [] + + +def test_filters_out_of_beam_some_devices_missing_from_container(): + # A mixed deployment: filter_array_1_x exists and is out of the beam, + # the other three axes don't exist at all. + dev = _FakeDeviceContainer(filter_array_1_x=_axis_at(25.0)) + + all_out, offending = filters_out_of_beam(dev) + + assert all_out is True + assert offending == [] + + +def test_filters_out_of_beam_flags_axis_in_beam(): + dev = _FakeDeviceContainer( + filter_array_1_x=_axis_at(25.0), # out + filter_array_2_x=_axis_at(17.6), # NOT out (out position is 25.5) + ) + + all_out, offending = filters_out_of_beam(dev) + + assert all_out is False + assert offending == ["filter_array_2_x"] diff --git a/tests/tests_bec_ipython_client/test_flomni_get_val_range.py b/tests/tests_bec_ipython_client/test_flomni_get_val_range.py new file mode 100644 index 00000000..f1b2704e --- /dev/null +++ b/tests/tests_bec_ipython_client/test_flomni_get_val_range.py @@ -0,0 +1,61 @@ +"""Tests for Flomni._get_val()'s min_/max_ range validation. + +tomo_parameters() (the CLI wizard) used to accept any value of the right +type with no range check at all, even though the Tomo Parameters GUI +enforces spinbox ranges for the same fields (tomo_params.py) -- this let the +CLI silently set e.g. manual_shift_y or fovx far outside their GUI-intended +bounds. _get_val() now optionally re-prompts until the typed value is in +range, so tomo_parameters() enforces the same limits as the GUI. +""" + +import builtins + +import pytest + +from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni + + +def _fake_inputs(monkeypatch, values): + it = iter(values) + monkeypatch.setattr(builtins, "input", lambda prompt="": next(it)) + + +def test_get_val_without_bounds_accepts_anything(monkeypatch): + _fake_inputs(monkeypatch, ["12345"]) + assert Flomni._get_val("x", 0.0, float) == 12345.0 + + +def test_get_val_accepts_in_range_value_first_try(monkeypatch): + _fake_inputs(monkeypatch, ["25"]) + assert Flomni._get_val("x", 0.0, float, min_=0.1, max_=220.0) == 25.0 + + +def test_get_val_reprompts_when_above_max(monkeypatch, capsys): + _fake_inputs(monkeypatch, ["500", "50"]) + result = Flomni._get_val("FOV X", 0.0, float, min_=0.1, max_=220.0) + assert result == 50.0 + assert "must be <= 220.0" in capsys.readouterr().out + + +def test_get_val_reprompts_when_below_min(monkeypatch, capsys): + _fake_inputs(monkeypatch, ["-999", "-30"]) + result = Flomni._get_val("manual_shift_y", 0.0, float, min_=-50.0, max_=50.0) + assert result == -30.0 + assert "must be >= -50.0" in capsys.readouterr().out + + +def test_get_val_reprompts_until_valid_across_multiple_bad_attempts(monkeypatch): + _fake_inputs(monkeypatch, ["-5", "999", "30"]) + assert Flomni._get_val("stitch X", 0, int, min_=0, max_=50) == 30 + + +def test_get_val_empty_input_uses_default_and_still_checked(monkeypatch): + # empty input -> falls back to default_value, which is already in range + _fake_inputs(monkeypatch, [""]) + assert Flomni._get_val("x", 25, int, min_=0, max_=50) == 25 + + +def test_get_val_boundary_values_are_accepted(monkeypatch): + _fake_inputs(monkeypatch, ["0.1", "220.0"]) + assert Flomni._get_val("a", 0.0, float, min_=0.1, max_=220.0) == 0.1 + assert Flomni._get_val("b", 0.0, float, min_=0.1, max_=220.0) == 220.0 diff --git a/tests/tests_bec_ipython_client/test_flomni_special_angles.py b/tests/tests_bec_ipython_client/test_flomni_special_angles.py new file mode 100644 index 00000000..7cfab251 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_flomni_special_angles.py @@ -0,0 +1,88 @@ +"""Special-angle handling in Flomni._tomo_scan_at_angle(). + +num_repeats used to be unbound whenever ``special_angles`` was set but the +current angle wasn't near the next special angle (UnboundLocalError, retried +10x by @scan_repeat and then aborting the tomogram), and the special-angle +list was consumed before the projection ran, so a retried attempt no longer +saw its special angle. (Same fix as LamNI's, see test_lamni_golive_fixes.py.) +""" + +import builtins +import types + +import csaxs_bec.bec_ipython_client.plugins.flomni.flomni as flomni_module +from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni, _ProgressProxy + + +class _FakeClient: + def __init__(self): + self._vars = {} + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + +def _make_flomni(monkeypatch): + obj = object.__new__(Flomni) + obj.client = _FakeClient() + obj._progress_proxy = _ProgressProxy(obj.client) + obj.tomo_angle_range = 180 + obj.special_angles = [] + obj.special_angle_repeats = 3 + obj._current_special_angles = [] + obj._print_progress = lambda: None + obj._format_duration = lambda seconds: "0s" + obj._write_tomo_scan_number = lambda *a, **k: None + obj._log_projection_timing = lambda **k: None + fake_bec = types.SimpleNamespace(queue=types.SimpleNamespace(next_scan_number=1)) + monkeypatch.setitem(builtins.__dict__, "bec", fake_bec) + monkeypatch.setattr(flomni_module, "bec", fake_bec, raising=False) + return obj + + +def test_non_special_angle_with_special_list_does_not_crash(monkeypatch): + flomni = _make_flomni(monkeypatch) + flomni.special_angles = [90.0] + flomni._current_special_angles = [90.0] + calls = [] + flomni._at_each_angle = lambda angle: calls.append(angle) + + flomni._tomo_scan_at_angle(0.0, 1) + + assert calls == [0.0] + assert flomni._current_special_angles == [90.0] + + +def test_special_angle_repeats_and_is_consumed_once(monkeypatch): + flomni = _make_flomni(monkeypatch) + flomni.special_angles = [90.0] + flomni._current_special_angles = [90.0] + calls = [] + flomni._at_each_angle = lambda angle: calls.append(angle) + + flomni._tomo_scan_at_angle(90.0, 1) + + assert calls == [90.0] * 3 + assert flomni._current_special_angles == [] + + +def test_special_angle_repeats_survive_a_retry(monkeypatch): + flomni = _make_flomni(monkeypatch) + flomni.special_angles = [90.0] + flomni._current_special_angles = [90.0] + calls = [] + + def _at_each_angle(angle): + calls.append(angle) + if len(calls) == 1: + raise RuntimeError("transient failure") + + flomni._at_each_angle = _at_each_angle + + flomni._tomo_scan_at_angle(90.0, 1) # @scan_repeat retries once + + assert len(calls) == 1 + 3, "the retry must still repeat the special angle" + assert flomni._current_special_angles == [] diff --git a/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py b/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py index 7f0a1edb..71e6932c 100644 --- a/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py +++ b/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py @@ -4,6 +4,7 @@ import types import numpy as np import pytest +import csaxs_bec.bec_ipython_client.plugins.flomni.flomni as flomni_module from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni, _ProgressProxy plan = Flomni._subtomo_angle_plan @@ -11,6 +12,23 @@ plan = Flomni._subtomo_angle_plan STEPSIZES = [10.0, 7.0, 25.0, 12.5] +@pytest.fixture(autouse=True) +def _fail_fast_on_unexpected_sleep(monkeypatch): + """Guard against a tomo_type-4 fixture missing its 'END' marker turning + a test into a real wait-and-poll hang: any call to time.sleep() from + flomni.py (only reached by the angle-file wait loop) fails the test + immediately instead of blocking. Tests that intentionally exercise the + wait loop override this with their own monkeypatch.setattr.""" + + def _unexpected_sleep(seconds): + pytest.fail( + f"tomo_scan() tried to sleep({seconds}) -- likely an angle file " + "fixture missing its 'END' marker." + ) + + monkeypatch.setattr(flomni_module.time, "sleep", _unexpected_sleep) + + @pytest.mark.parametrize("stepsize", STEPSIZES) def test_180_mode_regression(stepsize): """180-degree mode must be byte-for-byte identical to the original @@ -133,6 +151,37 @@ def test_resume_round_trip(tomo_angle_range, stepsize): assert offset == i, f"n={n} i={i} range={tomo_angle_range} stepsize={stepsize}" +@pytest.mark.parametrize("stepsize", STEPSIZES) +def test_always_forward_180_mode(stepsize): + """always_forward: every sub-tomogram ascends, covers exactly the same + angle set as the default alternating scheme, and resume round-trips.""" + for n in range(1, 9): + default, _, N, _ = plan(n, 180, stepsize) + fwd, _, _, _ = plan(n, 180, stepsize, always_forward=True) + assert np.all(np.diff(fwd) > 0) + assert np.allclose(np.sort(default), np.sort(fwd), atol=1e-9) + for i in range(N): + _, offset, _, _ = plan(n, 180, stepsize, start_angle=fwd[i], always_forward=True) + assert offset == i + + +def test_always_forward_ignored_in_360_mode(): + for n in range(1, 9): + a, _, _, _ = plan(n, 360, 10.0) + b, _, _, _ = plan(n, 360, 10.0, always_forward=True) + assert np.array_equal(a, b) + + +@pytest.mark.parametrize("always_forward", [False, True]) +@pytest.mark.parametrize("n", range(1, 9)) +def test_subtomo_starts_near_zero_180(n, always_forward): + flomni = Flomni.__new__(Flomni) + flomni.client = types.SimpleNamespace( + get_global_var=lambda name: {"tomo_angle_range": 180, "tomo_type1_always_forward": always_forward}.get(name) + ) + assert flomni._subtomo_starts_near_zero(n) is (always_forward or bool(n % 2)) + + def _actual_grid(stepsize): N = int(180.0 / stepsize) step = 180.0 / N @@ -194,8 +243,6 @@ def make_flomni_for_golden_resume(monkeypatch, tomo_type: int) -> Flomni: obj.golden_max_number_of_projections = 21 obj.tomo_id = -1 obj.sample_get_name = lambda position: "test" # backs the read-only sample_name property - obj.sample_get_measured_log_key = lambda position=0: "test|" - obj.measured_log = types.SimpleNamespace(mark_measured=lambda *a, **k: None) obj.special_angles = [] obj._golden = _fake_golden obj._golden_equally_spaced = _fake_golden @@ -221,6 +268,11 @@ def make_flomni_for_golden_resume(monkeypatch, tomo_type: int) -> Flomni: ) monkeypatch.setitem(builtins.__dict__, "bec", fake_bec) monkeypatch.setitem(builtins.__dict__, "scans", _FakeScans()) + # tomo_scan() records the measurement status on the sample record (stage slot 0) + fake_dev = types.SimpleNamespace( + flomni_samples=types.SimpleNamespace(set_sample_measured=lambda *a, **k: None) + ) + monkeypatch.setitem(builtins.__dict__, "dev", fake_dev) return obj @@ -257,3 +309,459 @@ def test_golden_fresh_start_still_fires_zero_deg_shot_at_natural_transition(monk flomni.tomo_scan() # projection_number=None -- a fresh, non-resumed run assert (0, 3) in recorded, "a fresh (non-resume) run must still fire the shot at the natural transition" + + +# ── tomo_type 4: angles read from file ────────────────────────────────────── + + +def make_flomni_for_angle_file(monkeypatch, tmp_path, content: str) -> Flomni: + flomni = make_flomni_for_golden_resume(monkeypatch, tomo_type=4) + flomni.golden_projections_at_0_deg_for_damage_estimation = 0 + flomni.OMNYTools = types.SimpleNamespace( + printgreenbold=lambda msg: None, printredbold=lambda msg: None + ) + path = tmp_path / "angles.txt" + path.write_text(content) + flomni.tomo_angle_file = str(path) + return flomni + + +def test_tomo_type_4_accepted_and_5_rejected(): + flomni = object.__new__(Flomni) + flomni.client = _FakeClient() + flomni.tomo_type = 4 + assert flomni.tomo_type == 4 + with pytest.raises(ValueError): + flomni.tomo_type = 5 + + +def test_tomo_angle_file_setter_normalizes_without_validating(tmp_path, monkeypatch): + flomni = object.__new__(Flomni) + flomni.client = _FakeClient() + monkeypatch.setenv("HOME", str(tmp_path)) + # nonexistent file must be accepted: tomo_queue_execute() setattr()s + # every param, also for jobs that don't use tomo_type 4 + flomni.tomo_angle_file = "~/does_not_exist.txt" + assert flomni.tomo_angle_file == str(tmp_path / "does_not_exist.txt") + flomni.tomo_angle_file = "" + assert flomni.tomo_angle_file == "" + + +def test_angle_file_scan_follows_file_order_and_subtomos(monkeypatch, tmp_path): + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "10 1\n170 1\n270 2\n5.5 3\nEND\n") + flomni.tomo_angle_range = 180 # must not restrict type-4 angles + recorded = [] + flomni._tomo_scan_at_angle = lambda angle, subtomo: recorded.append((angle, subtomo)) + + flomni.tomo_scan() + + # (0, 1): unconditional 0-deg reference shot at the start of a new tomogram + assert recorded == [(0, 1), (10.0, 1), (170.0, 1), (270.0, 2), (5.5, 3)] + assert flomni.progress["total_projections"] == 4 + assert flomni.progress["tomo_type"] == "Angles from file" + + +def test_angle_file_resume_starts_at_projection_index(monkeypatch, tmp_path): + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "10\n20\n30\n40\nEND\n") + recorded = [] + flomni._tomo_scan_at_angle = lambda angle, subtomo: recorded.append((angle, subtomo)) + + flomni.tomo_scan(projection_number=2) + + assert recorded == [(30.0, 1), (40.0, 1)] + + +def test_angle_file_resume_rereads_edited_file(monkeypatch, tmp_path): + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "10\n20\n30\nEND\n") + (tmp_path / "angles.txt").write_text("10\n20\n33\n44\nEND\n") + recorded = [] + flomni._tomo_scan_at_angle = lambda angle, subtomo: recorded.append((angle, subtomo)) + + flomni.tomo_scan(projection_number=2) + + assert recorded == [(33.0, 1), (44.0, 1)] + + +def test_angle_file_resume_past_end_of_file_raises(monkeypatch, tmp_path): + from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import FlomniError + + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "10\n20\n") + flomni._tomo_scan_at_angle = lambda angle, subtomo: pytest.fail("must not acquire") + with pytest.raises(FlomniError, match="only 2 angles"): + flomni.tomo_scan(projection_number=2) + + +def test_angle_file_missing_raises_before_any_acquisition(monkeypatch, tmp_path): + from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import FlomniError + + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "10\n") + flomni.tomo_angle_file = str(tmp_path / "gone.txt") + calls = [] + flomni.add_sample_database = lambda *a, **k: calls.append("db") + flomni.collect_empty_frames = lambda: calls.append("empty") + flomni._tomo_scan_at_angle = lambda angle, subtomo: calls.append("scan") + + with pytest.raises(FlomniError, match="Cannot read"): + flomni.tomo_scan() + assert calls == [] + + +def test_angle_file_ignores_golden_zero_deg_damage_flag(monkeypatch, tmp_path): + """No extra 0-deg shots at sub-tomogram transitions for type 4 -- only + the unconditional reference shot at the start of a new tomogram.""" + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "10 1\n20 2\n30 3\n40 5\nEND\n") + flomni.golden_projections_at_0_deg_for_damage_estimation = 1 + recorded = [] + flomni._tomo_scan_at_angle = lambda angle, subtomo: recorded.append((angle, subtomo)) + + flomni.tomo_scan() + + assert recorded == [(0, 1), (10.0, 1), (20.0, 2), (30.0, 3), (40.0, 5)] + + +def test_max_scan_angle_is_362_only_for_type_4(): + flomni = object.__new__(Flomni) + flomni.client = _FakeClient() + flomni.tomo_angle_range = 180 + flomni.tomo_type = 4 + assert flomni._max_scan_angle() == 362 + flomni.tomo_type = 1 + assert flomni._max_scan_angle() == 180 + + +def test_min_scan_angle_is_minus_2_only_for_type_4(): + flomni = object.__new__(Flomni) + flomni.client = _FakeClient() + flomni.tomo_type = 4 + assert flomni._min_scan_angle() == -2 + flomni.tomo_type = 1 + assert flomni._min_scan_angle() == 0 + + +def test_angle_file_accepts_physical_overtravel_range(monkeypatch, tmp_path): + """Angles down to -2 and up to 362 (the rotation stage's physical + overtravel range) must actually reach acquisition for tomo_type 4, not + just pass angle-file parsing -- _tomo_scan_at_angle()'s own gate has to + be widened to match, or they'd parse fine but then be silently skipped + with no acquisition and no error.""" + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "-2\n-0.1\n361\n362\nEND\n") + flomni._write_tomo_scan_number = lambda *a, **k: None + flomni._log_projection_timing = lambda **k: None + recorded = [] + flomni._at_each_angle = lambda angle, **overrides: recorded.append(angle) + + flomni.tomo_scan() # exercises the real _tomo_scan_at_angle(), not a stub + + # 0: unconditional 0-deg reference shot at the start of a new tomogram + assert recorded == [0, -2.0, -0.1, 361.0, 362.0] + + +# ── tomo_type 4: live append -- wait at EOF without 'END', poll, pick up ──── + + +def test_angle_file_waits_then_picks_up_appended_lines(monkeypatch, tmp_path): + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "5\n10\n") + recorded = [] + flomni._tomo_scan_at_angle = lambda angle, subtomo: recorded.append((angle, subtomo)) + + angle_path = tmp_path / "angles.txt" + sleep_calls = [] + + def _sleep(seconds): + sleep_calls.append(seconds) + angle_path.write_text("5\n10\n20\nEND\n") + + monkeypatch.setattr(flomni_module.time, "sleep", _sleep) + + flomni.tomo_scan() + + # (0, 1): unconditional 0-deg reference shot at the start of a new tomogram + assert recorded == [(0, 1), (5.0, 1), (10.0, 1), (20.0, 1)] + assert len(sleep_calls) == 1 + assert flomni.progress["total_projections"] == 3 + + +def test_angle_file_edit_of_already_acquired_line_while_waiting_raises(monkeypatch, tmp_path): + from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import FlomniError + + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "5\n10\n") + flomni._tomo_scan_at_angle = lambda angle, subtomo: None + + angle_path = tmp_path / "angles.txt" + + def _sleep(seconds): + angle_path.write_text("5\n99\n") # already-acquired index 1 (10) changed + + monkeypatch.setattr(flomni_module.time, "sleep", _sleep) + + with pytest.raises(FlomniError, match="changed"): + flomni.tomo_scan() + + +def test_angle_file_keyboard_interrupt_while_waiting_raises_flomnierror(monkeypatch, tmp_path): + from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import FlomniError + + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "5\n10\n") + flomni._tomo_scan_at_angle = lambda angle, subtomo: None + + def _sleep(seconds): + raise KeyboardInterrupt + + monkeypatch.setattr(flomni_module.time, "sleep", _sleep) + + with pytest.raises(FlomniError, match="aborted while waiting"): + flomni.tomo_scan() + + +def test_angle_file_heartbeat_refreshed_while_waiting(monkeypatch, tmp_path): + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "5\n") + recorded_heartbeats = [] + flomni._tomo_scan_at_angle = lambda angle, subtomo: recorded_heartbeats.append( + flomni.progress.get("heartbeat") + ) + + angle_path = tmp_path / "angles.txt" + + def _sleep(seconds): + angle_path.write_text("5\n10\nEND\n") + + monkeypatch.setattr(flomni_module.time, "sleep", _sleep) + + flomni.tomo_scan() + + # (0, 1) reference shot, then (5.0, 1) -- both before any wait tick, so no + # heartbeat has been written yet (the stub above never sets one itself). + assert recorded_heartbeats[0] is None + assert recorded_heartbeats[1] is None + # (10.0, 1) is only reached after the wait tick refreshed the heartbeat. + assert recorded_heartbeats[2] is not None + + +# ── tomo_type 4: per-line acquisition overrides (step=/exptime=/frames=) ─── + + +def test_angle_file_overrides_reach_tomo_scan_at_angle_and_are_sticky(monkeypatch, tmp_path): + flomni = make_flomni_for_angle_file( + monkeypatch, + tmp_path, + "10\n20 1 step=0.5\n30 1\n40 1 step=2 exptime=5 frames=3\nEND\n", + ) + recorded = [] + flomni._tomo_scan_at_angle = lambda angle, subtomo, **overrides: recorded.append( + (angle, subtomo, overrides) + ) + + flomni.tomo_scan() + + assert recorded == [ + (0, 1, {}), # unconditional 0-deg reference shot at tomogram start + (10.0, 1, {}), + (20.0, 1, {"step": 0.5}), + (30.0, 1, {"step": 0.5}), # sticky: step carries forward + (40.0, 1, {"step": 2.0, "exp_time": 5.0, "frames_per_trigger": 3}), + ] + + +def test_tomo_scan_at_angle_forwards_overrides_to_at_each_angle(monkeypatch, tmp_path): + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "10\nEND\n") + recorded = [] + flomni._at_each_angle = lambda angle, **overrides: recorded.append((angle, overrides)) + + flomni._tomo_scan_at_angle(10.0, 1) + flomni._tomo_scan_at_angle(10.0, 1, step=0.5, exp_time=2.0, frames_per_trigger=4) + + assert recorded == [ + (10.0, {}), + (10.0, {"step": 0.5, "exp_time": 2.0, "frames_per_trigger": 4}), + ] + + +def test_at_each_angle_forwards_overrides_to_tomo_scan_projection(monkeypatch, tmp_path): + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "10\nEND\n") + flomni.single_point_instead_of_fermat_scan = False + recorded = [] + flomni.tomo_scan_projection = lambda angle, _internal=False, **overrides: recorded.append( + (angle, overrides) + ) + + flomni._at_each_angle(10.0, step=0.5, frames_per_trigger=4) + + assert recorded == [(10.0, {"step": 0.5, "exp_time": None, "frames_per_trigger": 4})] + + +def test_at_each_angle_forwards_overrides_to_tomo_acquire_at_angle_in_single_point_mode( + monkeypatch, tmp_path +): + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "10\nEND\n") + flomni.single_point_instead_of_fermat_scan = True + recorded = [] + flomni.tomo_acquire_at_angle = ( + lambda angle, frames_per_trigger=None, exp_time=None: recorded.append( + (angle, frames_per_trigger, exp_time) + ) + ) + + # step is accepted but silently has no effect in single-point mode (no + # Fermat scan to apply a step size to). + flomni._at_each_angle(10.0, step=0.5, exp_time=2.0, frames_per_trigger=4) + + assert recorded == [(10.0, 4, 2.0)] + + +def test_hook_active_with_overrides_warns_once_at_scan_start(monkeypatch, tmp_path): + flomni = make_flomni_for_angle_file( + monkeypatch, tmp_path, "10 1 step=0.5\n20\nEND\n" + ) + flomni.at_each_angle_hook = "some_hook" + flomni._tomo_scan_at_angle = lambda angle, subtomo, **overrides: None + warnings = [] + flomni.OMNYTools.printredbold = lambda msg: warnings.append(msg) + + flomni.tomo_scan() + + assert len(warnings) == 1 + assert "hook is active" in warnings[0] + + +def test_hook_active_without_overrides_does_not_warn(monkeypatch, tmp_path): + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "10\n20\nEND\n") + flomni.at_each_angle_hook = "some_hook" + flomni._tomo_scan_at_angle = lambda angle, subtomo, **overrides: None + warnings = [] + flomni.OMNYTools.printredbold = lambda msg: warnings.append(msg) + + flomni.tomo_scan() + + assert warnings == [] + + +def test_hook_warning_fires_again_only_for_newly_appended_overridden_lines(monkeypatch, tmp_path): + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "10\n") + flomni.at_each_angle_hook = "some_hook" + flomni._tomo_scan_at_angle = lambda angle, subtomo, **overrides: None + warnings = [] + flomni.OMNYTools.printredbold = lambda msg: warnings.append(msg) + + angle_path = tmp_path / "angles.txt" + + def _sleep(seconds): + angle_path.write_text("10\n20 step=0.5\nEND\n") + + monkeypatch.setattr(flomni_module.time, "sleep", _sleep) + + flomni.tomo_scan() + + # No overrides at scan start -- no warning yet; exactly one once the + # overridden line is picked up mid-scan. + assert len(warnings) == 1 + assert "hook is active" in warnings[0] + + +# ── progress is reset before any pre-flight gate can abort a new scan ────── + + +def test_new_scan_resets_progress_before_eye_optics_gate_declines(monkeypatch, tmp_path): + """A stale progress dict from a long-finished scan must not survive a + declined *new* scan attempt (e.g. the eye/optics-in prompt, which + disabling rt feedback can trigger) -- regression for a report where the + progress widget kept showing a days-old scan after a failed start.""" + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "10\nEND\n") + flomni.progress.update( + projection=196, + total_projections=288, + angle=196.2, + subtomo=6, + tomo_type="Equally spaced sub-tomograms", + tomo_start_time="2026-09-25T14:57:28", + heartbeat="2026-09-25T14:57:30", + ) + flomni._check_eye_out_and_optics_in = lambda: False + flomni.OMNYTools.yesno = lambda *a, **k: False # decline "Shall I continue?" + flomni._tomo_scan_at_angle = lambda angle, subtomo, **overrides: pytest.fail( + "must not acquire -- the gate declined" + ) + + flomni.tomo_scan() # returns early, no exception + + assert flomni.progress["projection"] == 0 + assert flomni.progress["total_projections"] == 1 + assert flomni.progress["angle"] == 0 + assert flomni.progress["subtomo"] == 0 + assert flomni.progress["tomo_type"] == 0 + assert flomni.progress["tomo_start_time"] is None + assert flomni.progress["heartbeat"] is None + + +def test_new_scan_resets_progress_before_angle_file_error(monkeypatch, tmp_path): + """Same as above, but the abort comes from the tomo_type-4 upfront angle- + file validation, which runs even earlier than the eye/optics gate.""" + from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import FlomniError + + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "10\nEND\n") + flomni.tomo_angle_file = str(tmp_path / "gone.txt") + flomni.progress.update(projection=42, tomo_start_time="2026-09-25T14:57:28") + + with pytest.raises(FlomniError, match="Cannot read"): + flomni.tomo_scan() + + assert flomni.progress["projection"] == 0 + assert flomni.progress["tomo_start_time"] is None + + +def test_resume_does_not_reset_progress(monkeypatch, tmp_path): + """A *resume* (projection_number given) must not wipe progress -- only a + genuinely new scan start does.""" + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "10\n20\n30\nEND\n") + flomni.progress.update(estimated_remaining_time=42.0) + reset_calls = [] + monkeypatch.setattr(flomni._progress_proxy, "reset", lambda: reset_calls.append(1)) + flomni._tomo_scan_at_angle = lambda angle, subtomo, **overrides: None + + flomni.tomo_scan(projection_number=1) + + assert reset_calls == [] + assert flomni.progress["estimated_remaining_time"] == 42.0 + + +def test_angle_file_progress_shows_type_and_total_before_the_zero_deg_shot(monkeypatch, tmp_path): + flomni = make_flomni_for_angle_file(monkeypatch, tmp_path, "10\n20\nEND\n") + seen = [] + flomni._tomo_scan_at_angle = lambda angle, subtomo, **kw: seen.append( + (angle, flomni.progress["tomo_type"], flomni.progress["total_projections"]) + ) + + flomni.tomo_scan() + + assert seen[0] == (0, "Angles from file", 2) # the initial 0-deg shot + + +def test_flomni_gui_progress_survives_zero_total(monkeypatch): + """total_projections == 0 (type 4 file without 'END' yet, or endless + golden ratio) must not make the GUI update raise (it was swallowed, + leaving the progress window frozen on the reset defaults).""" + from csaxs_bec.bec_ipython_client.plugins.flomni.gui_tools import flomniGuiTools + + labels, ring_values = [], [] + ring = types.SimpleNamespace(set_value=ring_values.append) + gui = object.__new__(flomniGuiTools) + gui.progressbar = types.SimpleNamespace( + rings=[ring, ring], set_center_label=labels.append + ) + gui.progress = { + "projection": 3, + "total_projections": 0, + "subtomo_projection": 1, + "subtomo_total_projections": 2, + "tomo_type": "Angles from file", + "angle": 10.0, + "subtomo": 1, + "tomo_start_time": None, + "estimated_remaining_time": None, + "estimated_finish_time": None, + } + gui._describe_active_hook = lambda: None + + gui._flomnigui_update_progress() + + assert ring_values == [0.0, 50.0] + assert "Angles from file" in labels[0] diff --git a/tests/tests_bec_ipython_client/test_flomni_xray_eye_skip.py b/tests/tests_bec_ipython_client/test_flomni_xray_eye_skip.py new file mode 100644 index 00000000..dee5ab2f --- /dev/null +++ b/tests/tests_bec_ipython_client/test_flomni_xray_eye_skip.py @@ -0,0 +1,219 @@ +"""Tests for the "Skip" button of the flomni X-ray-eye alignment (see +docs/plans/flomni-xrayeye-skip-button.md): the GUI signals a skip with +``omny_xray_gui.submit == -1`` and XrayEyeAlign._align_impl leaves that angle out of the +fit, as long as at least 4 of the 5 angles (0/45/90/135/180 deg) remain. + +The real _align_impl loop is driven with a scripted sequence of ``submit`` values; the +hardware (dev, umv, flomni, gui) is mocked, no BEC service is contacted. +""" + +from unittest import mock + +import numpy as np +import pytest + +from csaxs_bec.bec_ipython_client.plugins.flomni.x_ray_eye_align import XrayEyeAlign + +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name + +MODULE = "csaxs_bec.bec_ipython_client.plugins.flomni.x_ray_eye_align" + + +class _Signal: + def __init__(self, value=0.0): + self.value = value + + def get(self): + return self.value + + def set(self, value): + self.value = value + return mock.MagicMock() + + +class _ScriptedSubmit(_Signal): + """submit signal: get() plays back the script (one entry per loop iteration), then + fails loudly instead of letting a broken loop spin forever.""" + + def __init__(self, script): + super().__init__(0) + self.script = list(script) + self.sets = [] + + def get(self): + if not self.script: + raise AssertionError("client loop polled submit more often than scripted") + return self.script.pop(0) + + def set(self, value): + self.sets.append(value) + return super().set(value) + + +def _make_dev(submit_script): + """dev mock with a real-number omny_xray_gui: step k was clicked at x = 100 + 10 k px + (pixel_calibration 0.001 mm/px -> 0.1 + 0.01 k mm).""" + gui = mock.MagicMock() + gui.submit = _ScriptedSubmit(submit_script) + gui.mvx = _Signal(0) + gui.mvy = _Signal(0) + for k in range(6): + setattr(gui, f"xval_x_{k}", _Signal(100.0 + 10 * k)) + setattr(gui, f"yval_y_{k}", _Signal(50.0)) + setattr(gui, f"width_x_{k}", _Signal(20.0 + k)) + setattr(gui, f"width_y_{k}", _Signal(30.0 + k)) + dev = mock.MagicMock() + dev.omny_xray_gui = gui + dev.cam_xeye.user_parameter.get.return_value = 0.001 + dev.rtx.readback.get.return_value = 0.0 + return dev + + +def _run(submit_script): + """Run the whole _align_impl loop; returns (align, dev, messages, write_output data).""" + align = XrayEyeAlign(mock.MagicMock(), mock.MagicMock()) + align.test_wo_movements = True + messages = [] + align.send_message = messages.append + align.update_frame = mock.MagicMock() + align._save_alignment_data = mock.MagicMock() + dev = _make_dev(submit_script) + with mock.patch(f"{MODULE}.dev", dev), mock.patch(f"{MODULE}.umv"), mock.patch( + f"{MODULE}.time.sleep" + ): + align._align_impl() + return align, dev, messages + + +def _fit_data(align): + return align.gui.submit_fit_array.call_args.args[0] + + +# FZP center (k=0), height marking (first submit at k=1), then the five angle submissions +# 0/45/90/135/180 deg = steps k=1..5. 0 entries are idle polls. +FZP, HEIGHT = 1, 1 + + +def test_default_run_uses_all_five_angles(): + align, _, _ = _run([FZP, HEIGHT, 1, 1, 1, 1, 1]) + + assert align.skipped_steps == [] + assert list(_fit_data(align)[0]) == [0, 45, 90, 135, 180] + assert len(_fit_data(align)[1]) == 5 + # every non-skipped angle contributed to the field of view + assert align._xray_fov_xy == [25.0, 35.0] + + +def test_skip_90_deg_leaves_four_points(): + align, _, _ = _run([FZP, HEIGHT, 1, 1, -1, 1, 1]) + + assert align.skipped_steps == [3] + data = _fit_data(align) + assert list(data[0]) == [0, 45, 135, 180] + # offset = value[0] - value[k] (um): steps 1, 2, 4, 5 -> clicked at 110, 120, 140, 150 px + np.testing.assert_allclose(data[1], [-10.0, -20.0, -40.0, -50.0]) + assert data.shape == (3, 4) + kwargs = align._save_alignment_data.call_args.kwargs + assert kwargs["skipped_angles"] == [90] + + +def test_skip_writes_no_values_and_a_nan_row_for_the_step(): + align, _, _ = _run([FZP, HEIGHT, 1, 1, -1, 1, 1]) + + assert sorted(align.alignment_values) == [0, 1, 2, 4, 5] + rows = {int(r[0]): r for r in align.roi_pixel_data} + assert np.isnan(rows[3][1:5]).all() + assert not np.isnan(rows[4][1:5]).any() + + +def test_skipped_step_does_not_read_its_stale_widths_for_the_fov(): + # step 5 would be the widest ROI (25 px) but is skipped; stale widths must not be read + align, _, _ = _run([FZP, HEIGHT, 1, 1, 1, 1, -1]) + + assert align.skipped_steps == [5] + assert align._xray_fov_xy == [24.0, 34.0] + assert list(_fit_data(align)[0]) == [0, 45, 90, 135] + + +def test_skip_at_last_step_finishes_cleanly(): + align, dev, messages = _run([FZP, HEIGHT, 1, 1, 1, 1, -1]) + + assert "done..." in messages + align.gui.hide_crosshair.assert_called() + assert dev.omny_xray_gui.submit.value == 0 + + +def test_second_skip_is_refused_and_run_continues(): + # the second -1 (at k=4) is refused, the same step is then submitted normally + align, dev, messages = _run([FZP, HEIGHT, 1, 1, -1, -1, 1, 1]) + + assert align.skipped_steps == [3] + assert list(_fit_data(align)[0]) == [0, 45, 135, 180] + refusals = [m for m in messages if m.startswith("Cannot skip")] + assert len(refusals) == 1 + assert "at least 4 positions" in refusals[0] + # the message keeps telling the user which step is pending + assert "Step 4/5" in refusals[0] + # a refused skip is reset, so it cannot leak into the next step + assert dev.omny_xray_gui.submit.value == 0 + align.gui.enable_skip_button.assert_called_with(False) + + +def test_skip_of_fzp_center_is_refused(): + align, _, messages = _run([-1, FZP, HEIGHT, 1, 1, 1, 1, 1]) + + assert align.skipped_steps == [] + assert list(_fit_data(align)[0]) == [0, 45, 90, 135, 180] + assert "Cannot skip: the FZP center cannot be skipped. Submit center value of FZP." in messages + + +def test_skip_of_height_marking_is_refused(): + align, _, messages = _run([FZP, -1, HEIGHT, 1, 1, 1, 1, 1]) + + assert align.skipped_steps == [] + assert list(_fit_data(align)[0]) == [0, 45, 90, 135, 180] + assert any(m.startswith("Cannot skip: the sample height marking") for m in messages) + + +def test_skip_button_is_enabled_only_for_fit_steps(): + align, _, _ = _run([FZP, HEIGHT, 1, -1, 1, 1, 1]) + + calls = [c.args[0] for c in align.gui.enable_skip_button.call_args_list] + # start of the run: off; height marked: on; after the one allowed skip the budget is used + # up: off; last step: off + assert calls == [False, True, False, False] + + +def test_second_skip_would_leave_three_points_write_output_guard(): + align = XrayEyeAlign(mock.MagicMock(), mock.MagicMock()) + align._reset_init_values() + align.alignment_values = {k: 0.1 * k for k in range(6)} + align.skipped_steps = [2, 3] + align.gui.submit_fit_array = mock.MagicMock() + align._save_alignment_data = mock.MagicMock() + + with pytest.raises(RuntimeError, match="at least 4"): + align.write_output() + + align._save_alignment_data.assert_not_called() + align.gui.submit_fit_array.assert_not_called() + + +def test_skip_end_point_prints_warning(capsys): + _run([FZP, HEIGHT, 1, 1, 1, 1, -1]) + + assert "skipping an end point" in capsys.readouterr().out + + +def test_skip_refusal_reason_table(): + align = XrayEyeAlign(mock.MagicMock(), mock.MagicMock()) + align._reset_init_values() + + assert align._skip_refusal_reason(0) is not None + assert align._skip_refusal_reason(1) is not None # height marking + align._height_centered = True + assert align._skip_refusal_reason(1) is None # angle 0 deg + assert align._skip_refusal_reason(5) is None + align.skipped_steps = [3] + assert "at least 4" in align._skip_refusal_reason(4) diff --git a/tests/tests_bec_ipython_client/test_fov_bounds_match_scan_args.py b/tests/tests_bec_ipython_client/test_fov_bounds_match_scan_args.py new file mode 100644 index 00000000..fcc3140f --- /dev/null +++ b/tests/tests_bec_ipython_client/test_fov_bounds_match_scan_args.py @@ -0,0 +1,93 @@ +"""Regression test for a real hardware bug (2026-09-19): flomni.fovy could be +set to exactly 100.0 via the property/GUI/CLI (all capped at an inclusive +100.0), but FlomniFermatScan's own ScanArgument declares fovy as gt=0, lt=100 +(strictly less than) -- so fovy=100.0 passed every check right up until the +scan itself rejected it with ScanInputValidationError on real hardware. + +lamni_piezo_range_x/y (which become LamniFermatScan's fovx/fovy) had the same +class of bug at both ends: GUI/CLI min was 0.0 but LamniFermatScan requires +gt=0, and max was an inclusive 80.0 but LamniFermatScan requires lt=80. + +This test introspects the actual ScanArgument bounds from the scan classes +themselves and checks our configured GUI/CLI ranges (tomo_params.py's +SETUP_PROFILES, and flomni.py/lamni.py's tomo_parameters() prompts) are +strictly inside them -- not just checked once by hand -- so this class of +"looks fine until you actually run the scan" bug can't silently return. +""" + +import re +from typing import get_type_hints + +import csaxs_bec.bec_ipython_client.plugins.flomni.flomni as _flomni_cli_module +import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as _lamni_cli_module +from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import SETUP_PROFILES +from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan +from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan + + +def _scan_arg_bounds(scan_cls, param_name: str): + hints = get_type_hints(scan_cls.__init__, include_extras=True) + metadata = hints[param_name].__metadata__ + (scan_argument,) = [m for m in metadata if hasattr(m, "lt")] + return scan_argument.gt, scan_argument.ge, scan_argument.lt, scan_argument.le + + +def _assert_range_inside_bounds(our_min, our_max, gt, ge, lt, le): + if gt is not None: + assert our_min > gt, f"configured min {our_min} must be > scan's gt={gt}" + if ge is not None: + assert our_min >= ge, f"configured min {our_min} must be >= scan's ge={ge}" + if lt is not None: + assert our_max < lt, f"configured max {our_max} must be < scan's lt={lt}" + if le is not None: + assert our_max <= le, f"configured max {our_max} must be <= scan's le={le}" + + +def _gui_fields(profile_key: str) -> dict: + """Flatten a profile's fov_fields + piezo_range_fields into {name: (min, max)}.""" + profile = SETUP_PROFILES[profile_key] + fields = {} + for name, _label, min_, max_, *_ in profile["fov_fields"]: + fields[name] = (min_, max_) + for name, _label, min_, max_, *_ in profile.get("piezo_range_fields", []): + fields[name] = (min_, max_) + return fields + + +def test_flomni_gui_fovx_fovy_within_scan_bounds(): + fields = _gui_fields("flomni") + our_min, our_max = fields["fovx"] + _assert_range_inside_bounds(our_min, our_max, *_scan_arg_bounds(FlomniFermatScan, "fovx")) + + our_min, our_max = fields["fovy"] + _assert_range_inside_bounds(our_min, our_max, *_scan_arg_bounds(FlomniFermatScan, "fovy")) + + +def test_flomni_cli_fovy_bounds_within_scan_bounds(): + src = open(_flomni_cli_module.__file__, encoding="utf-8").read() + m = re.search(r'" um".*?min_=([\d.]+), max_=([\d.]+)', src, re.DOTALL) + assert m, "could not find the fovy _get_val() call in flomni.py -- did it move?" + cli_min, cli_max = float(m.group(1)), float(m.group(2)) + _assert_range_inside_bounds(cli_min, cli_max, *_scan_arg_bounds(FlomniFermatScan, "fovy")) + + +def test_lamni_gui_piezo_range_within_scan_bounds(): + fields = _gui_fields("lamni") + for name, scan_param in (("lamni_piezo_range_x", "fovx"), ("lamni_piezo_range_y", "fovy")): + our_min, our_max = fields[name] + _assert_range_inside_bounds( + our_min, our_max, *_scan_arg_bounds(LamniFermatScan, scan_param) + ) + + +def test_lamni_cli_piezo_range_bounds_within_scan_bounds(): + src = open(_lamni_cli_module.__file__, encoding="utf-8").read() + for axis, scan_param in (("X", "fovx"), ("Y", "fovy")): + m = re.search( + rf'" um".*?min_=([\d.]+), max_=([\d.]+)', src, re.DOTALL + ) + assert m, f"could not find the piezo range {axis} _get_val() call in lamni.py -- did it move?" + cli_min, cli_max = float(m.group(1)), float(m.group(2)) + _assert_range_inside_bounds( + cli_min, cli_max, *_scan_arg_bounds(LamniFermatScan, scan_param) + ) diff --git a/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py b/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py index fe2ce65d..85a6dc42 100644 --- a/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py +++ b/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py @@ -11,6 +11,7 @@ import types import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import _TomoQueueProxy class FakeClient: @@ -55,6 +56,7 @@ def make_lamni(answer=True): obj = object.__new__(LamNI) obj.client = FakeClient() obj._progress_proxy = _ProgressProxy(obj.client) + obj._tomo_queue_proxy = _TomoQueueProxy(obj.client) obj.OMNYTools = FakeOMNYTools(answer=answer) obj.reconstructor = types.SimpleNamespace(folder_name=None) return obj @@ -87,6 +89,8 @@ def test_prompts_and_resets_on_new_account_when_confirmed(monkeypatch): lamni.client.set_global_var("defaults_applied_for_account", "e11111") lamni.tomo_shellstep = 99.0 lamni.tomo_circfov = 123.0 + lamni._tomo_queue_proxy.append({"label": "leftover job", "params": {}, "status": "pending"}) + lamni._progress_proxy.update(projection=42, angle=90.0) monkeypatch.setitem(builtins.__dict__, "bec", type("Bec", (), {"active_account": "e22222"})()) lamni._maybe_reset_params_on_account_change() @@ -97,18 +101,24 @@ def test_prompts_and_resets_on_new_account_when_confirmed(monkeypatch): assert lamni.tomo_shellstep == 1 assert lamni.tomo_circfov == 0.0 assert lamni.client.get_global_var("defaults_applied_for_account") == "e22222" + assert lamni._tomo_queue_proxy.as_list() == [] + assert lamni._progress_proxy.as_dict() == _ProgressProxy._DEFAULTS def test_records_account_without_resetting_when_declined(monkeypatch): lamni = make_lamni(answer=False) lamni.client.set_global_var("defaults_applied_for_account", "e11111") lamni.tomo_shellstep = 99.0 + lamni._tomo_queue_proxy.append({"label": "leftover job", "params": {}, "status": "pending"}) + lamni._progress_proxy.update(projection=42, angle=90.0) monkeypatch.setitem(builtins.__dict__, "bec", type("Bec", (), {"active_account": "e22222"})()) lamni._maybe_reset_params_on_account_change() assert lamni.tomo_shellstep == 99.0 assert lamni.client.get_global_var("defaults_applied_for_account") == "e22222" + assert len(lamni._tomo_queue_proxy.as_list()) == 1 + assert lamni._progress_proxy["projection"] == 42 def test_set_default_tomo_params_matches_getter_fallbacks(): diff --git a/tests/tests_bec_ipython_client/test_lamni_get_val_range.py b/tests/tests_bec_ipython_client/test_lamni_get_val_range.py new file mode 100644 index 00000000..2227eb63 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_lamni_get_val_range.py @@ -0,0 +1,168 @@ +"""Tests for LamNI._get_val()'s min_/max_ range validation. + +Mirrors test_flomni_get_val_range.py -- LamNI's tomo_parameters() CLI wizard +previously had no range validation at all (unlike the GUI's spinbox +ranges); _get_val() now optionally re-prompts until the typed value is in +range, same mechanism as Flomni's. +""" + +import builtins + +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI + + +def _fake_inputs(monkeypatch, values): + it = iter(values) + monkeypatch.setattr(builtins, "input", lambda prompt="": next(it)) + + +def test_get_val_without_bounds_accepts_anything(monkeypatch): + _fake_inputs(monkeypatch, ["12345"]) + assert LamNI._get_val("x", 0.0, float) == 12345.0 + + +def test_get_val_accepts_in_range_value_first_try(monkeypatch): + _fake_inputs(monkeypatch, ["10"]) + assert LamNI._get_val("shellstep", 0.0, float, min_=0.025, max_=20.0) == 10.0 + + +def test_get_val_reprompts_when_above_max(monkeypatch, capsys): + _fake_inputs(monkeypatch, ["999", "50"]) + result = LamNI._get_val("circfov", 0.0, float, min_=0.0, max_=500.0) + assert result == 50.0 + assert "must be <= 500.0" in capsys.readouterr().out + + +def test_get_val_reprompts_when_below_min(monkeypatch, capsys): + _fake_inputs(monkeypatch, ["-5", "10"]) + result = LamNI._get_val("piezo range x", 20.0, float, min_=0.0, max_=80.0) + assert result == 10.0 + assert "must be >= 0.0" in capsys.readouterr().out + + +def test_get_val_boundary_values_are_accepted(monkeypatch): + _fake_inputs(monkeypatch, ["0.025", "20.0"]) + assert LamNI._get_val("a", 0.0, float, min_=0.025, max_=20.0) == 0.025 + assert LamNI._get_val("b", 0.0, float, min_=0.025, max_=20.0) == 20.0 + + +# ── tomo_parameters_cli(): the prompts must enforce sane ranges ───────────── +# +# It only enforced ranges on some prompts: a negative stitch made +# range(-n, n + 1) empty (a projection silently ran zero scans), fewer than 8 +# projections gave N = 0 sub-tomogram angles (ZeroDivisionError later), 0 +# projections per type-3 sub-tomogram divided by zero, and frames per trigger / +# the golden-ratio stop count were unbounded. Ranges mirror flomni's (158dc80, +# 1dd03d1) and the tomo-params GUI (stitch 0-50 is lamni's own GUI range). + + +class _CliClient: + def __init__(self): + self._vars = {} + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + +def _make_cli_lamni(monkeypatch, answers: dict): + """A bare LamNI whose tomo_parameters_cli() is answered by prompt keyword: + answers maps a substring of the prompt to the list of replies given for + it, in order; anything not listed accepts the default (empty reply).""" + import types + + from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import _ProgressProxy + + lamni = object.__new__(LamNI) + lamni.client = _CliClient() + lamni._progress_proxy = _ProgressProxy(lamni.client) + lamni._expected_fermat_position_count = lambda: 1000 + lamni._fermat_min_positions = lambda: 10 + lamni._describe_active_hook = lambda: "" + lamni.at_each_angle_hook = None + lamni.reconstructor = types.SimpleNamespace(folder_name=None) + lamni.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: False) # "not correct" -> edit + + queues = {key: list(vals) for key, vals in answers.items()} + + def _input(prompt=""): + for key, vals in queues.items(): + if key in prompt and vals: + return vals.pop(0) + return "" + + monkeypatch.setattr(builtins, "input", _input) + # the piezo-range setters consult dev.rtx.user_parameter + import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module + + fake_dev = types.SimpleNamespace(rtx=types.SimpleNamespace(user_parameter={})) + monkeypatch.setattr(lamni_module, "dev", fake_dev, raising=False) + return lamni + + +def test_cli_rejects_negative_stitch(monkeypatch, capsys): + lamni = _make_cli_lamni(monkeypatch, {"": ["-1", "2"], "": ["-3", "0"]}) + + lamni.tomo_parameters_cli() + + assert (lamni.lamni_stitch_x, lamni.lamni_stitch_y) == (2, 0) + assert "must be >= 0" in capsys.readouterr().out + + +def test_cli_rejects_out_of_range_frames_per_trigger(monkeypatch): + lamni = _make_cli_lamni(monkeypatch, {"Frames per trigger": ["0", "500", "5"]}) + + lamni.tomo_parameters_cli() + + assert lamni.frames_per_trigger == 5 + + +def test_cli_type1_rejects_fewer_than_8_projections(monkeypatch): + lamni = _make_cli_lamni( + monkeypatch, {"Tomography type": ["1"], "Number of projections": ["0", "4", "144"]} + ) + + lamni.tomo_parameters_cli() + + assert lamni.tomo_angle_stepsize == 20.0 # 360 / 144 * 8 + assert lamni._tomo_type1_actual_grid()[2] == 144 + + +def test_cli_type2_enforces_bunch_size_of_at_least_20(monkeypatch, capsys): + lamni = _make_cli_lamni( + monkeypatch, + { + "Tomography type": ["2"], + "sorted per bunch": ["5", "19", "150", "25"], + "Stop after": ["-1", "2000000", "800"], + }, + ) + + lamni.tomo_parameters_cli() + + assert lamni.golden_ratio_bunch_size == 25 + assert lamni.golden_max_number_of_projections == 800 + out = capsys.readouterr().out + assert "must be >= 20" in out + assert "must be <= 100" in out + assert "must be <= 1000000" in out + + +def test_cli_type2_zero_is_an_allowed_endless_stop_count(monkeypatch): + lamni = _make_cli_lamni(monkeypatch, {"Tomography type": ["2"], "Stop after": ["0"]}) + + lamni.tomo_parameters_cli() + + assert lamni.golden_max_number_of_projections == 0 + + +def test_cli_type3_rejects_zero_projections_per_subtomogram(monkeypatch): + lamni = _make_cli_lamni( + monkeypatch, {"Tomography type": ["3"], "per sub-tomogram": ["0", "-4", "36"]} + ) + + lamni.tomo_parameters_cli() + + assert lamni.tomo_angle_stepsize == 10.0 # 360 / 36 diff --git a/tests/tests_bec_ipython_client/test_lamni_golive_fixes.py b/tests/tests_bec_ipython_client/test_lamni_golive_fixes.py new file mode 100644 index 00000000..936a0dca --- /dev/null +++ b/tests/tests_bec_ipython_client/test_lamni_golive_fixes.py @@ -0,0 +1,582 @@ +"""Regression tests for the LamNI go-live audit (bugs found by comparing +LamNI against the battle-tested flomni). + +Each test pins one fixed bug: + +* a TimeoutError alarm used to be swallowed in ``_tomo_scan_at_angle`` -- + the projection was silently dropped (never retried, never reconstructed) + while its failed scan numbers were logged as if it had succeeded; +* ``leye_out()`` opened the fast shutter instead of closing it; +* the progress GUI divided by ``total_projections == 0`` (endless golden-ratio + scan) and any GUI failure propagated into the scan itself; +* ``write_to_scilog`` used the removed ``bec.logbook`` API and could raise. + +Uses bare LamNI instances (bypassing __init__'s heavy side effects), same +pattern as test_lamni_tomo_angles.py. +""" + +import builtins +import types + +import pytest +from bec_lib.alarm_handler import AlarmBase + +import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module +import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_optics_mixin as optics_mixin_module +from csaxs_bec.bec_ipython_client.plugins.LamNI.gui_tools import LamniGuiTools +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_optics_mixin import LamNIOpticsMixin + + +class FakeClient: + def __init__(self): + self._vars = {} + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + +def _timeout_alarm() -> AlarmBase: + """A TimeoutError alarm, without needing a full AlarmMessage.""" + alarm = AlarmBase.__new__(AlarmBase) + alarm.alarm_type = "TimeoutError" + return alarm + + +class FakeQueue: + def __init__(self, start=100): + self.next_scan_number = start + self.resets = 0 + + def request_queue_reset(self): + self.resets += 1 + + +@pytest.fixture +def fake_bec(monkeypatch): + queue = FakeQueue() + fake = types.SimpleNamespace(queue=queue) + monkeypatch.setitem(builtins.__dict__, "bec", fake) + # lamni.py also binds a module-level `bec` at import time when one exists + monkeypatch.setattr(lamni_module, "bec", fake, raising=False) + monkeypatch.setattr(lamni_module.time, "sleep", lambda s: None) + return fake + + +def make_lamni_for_angle_scan(): + obj = object.__new__(LamNI) + obj.client = FakeClient() + obj._progress_proxy = _ProgressProxy(obj.client) + obj.special_angles = [] + obj.special_angle_repeats = 3 + obj._current_special_angles = [] + obj._print_progress = lambda: None + obj._format_duration = lambda seconds: "0s" + obj.written_scans = [] + obj._write_tomo_scan_number = lambda scan_nr, angle, subtomo: obj.written_scans.append(scan_nr) + obj.timing_logged = [] + obj._log_projection_timing = lambda **kw: obj.timing_logged.append(kw) + return obj + + +# ── TimeoutError alarm must retry the projection, not drop it ─────────────── + + +def test_timeout_alarm_retries_projection_and_logs_only_successful_attempt(fake_bec): + lamni = make_lamni_for_angle_scan() + attempts = [] + + def _at_each_angle(angle): + attempts.append(angle) + fake_bec.queue.next_scan_number += 1 # every attempt consumes a scan number + if len(attempts) == 1: + raise _timeout_alarm() + + lamni._at_each_angle = _at_each_angle + + lamni._tomo_scan_at_angle(10.0, 1) + + assert attempts == [10.0, 10.0], "the projection must be re-acquired after a timeout" + assert fake_bec.queue.resets == 1 + # scan 100 belonged to the failed attempt; only the successful one (101) + # may be recorded for reconstruction + assert lamni.written_scans == [101] + assert len(lamni.timing_logged) == 1 + assert lamni.timing_logged[0]["start_scan_number"] == 101 + + +def test_timeout_alarm_that_never_clears_is_not_swallowed(fake_bec): + """A persistently timing-out projection must eventually fail loudly + (TooManyScanRestarts from @scan_repeat) instead of being marked done.""" + from bec_lib.scan_repeat import TooManyScanRestarts + + lamni = make_lamni_for_angle_scan() + + def _always_timeout(angle): + raise _timeout_alarm() + + lamni._at_each_angle = _always_timeout + + with pytest.raises(TooManyScanRestarts): + lamni._tomo_scan_at_angle(10.0, 1) + assert lamni.written_scans == [] + assert lamni.timing_logged == [] + + +# ── special angles ────────────────────────────────────────────────────────── + + +def test_non_special_angle_with_special_list_does_not_crash(fake_bec): + """num_repeats used to be unbound when special_angles was set but the + current angle wasn't near the next special angle.""" + lamni = make_lamni_for_angle_scan() + lamni.special_angles = [90.0] + lamni._current_special_angles = [90.0] + calls = [] + lamni._at_each_angle = lambda angle: calls.append(angle) + + lamni._tomo_scan_at_angle(0.0, 1) + + assert calls == [0.0] + assert lamni._current_special_angles == [90.0], "must not be consumed by another angle" + + +def test_special_angle_repeats_survive_a_retry(fake_bec): + """The special-angle list is only consumed after the projection + succeeded, so a retried attempt still repeats the projection.""" + lamni = make_lamni_for_angle_scan() + lamni.special_angles = [90.0] + lamni._current_special_angles = [90.0] + calls = [] + + def _at_each_angle(angle): + calls.append(angle) + if len(calls) == 1: + raise _timeout_alarm() + + lamni._at_each_angle = _at_each_angle + + lamni._tomo_scan_at_angle(90.0, 1) + + # 1 failed call, then a full retry with special_angle_repeats == 3 repeats + assert len(calls) == 1 + 3 + assert lamni._current_special_angles == [] + + +# ── leye_out closes the shutter ───────────────────────────────────────────── + + +def test_leye_out_closes_the_fast_shutter(monkeypatch): + events = [] + + class FakeShutter: + def fshopen(self): + events.append("open") + + def fshclose(self): + events.append("close") + + class FakeDev: + fsh = FakeShutter() + leyey = object() + lsamrot = object() + fttrz = object() + dttrz = object() + + def __contains__(self, name): + return hasattr(self, name) + + monkeypatch.setattr(optics_mixin_module, "dev", FakeDev(), raising=False) + monkeypatch.setattr(optics_mixin_module, "umv", lambda *a: None, raising=False) + + class FakeClient: + # no slit position stored before the eye went in -> nothing to restore + @staticmethod + def get_global_var(name): + return None + + class Optics(LamNIOpticsMixin): + csaxs = None + client = FakeClient() + + def loptics_in(self): + pass + + @staticmethod + def _get_user_param_safe(device, var): + return 0.0 + + Optics().leye_out() + + assert "close" in events + assert "open" not in events + + +# ── progress GUI must never abort the scan ────────────────────────────────── + + +class FakeRing: + def __init__(self): + self.value = None + + def set_value(self, v): + self.value = v + + +class FakeProgressBar: + def __init__(self): + self.rings = [FakeRing(), FakeRing(), FakeRing()] + self.label = None + + def set_center_label(self, text): + self.label = text + + +def make_gui_lamni(total_projections): + obj = object.__new__(LamNI) + obj.client = FakeClient() + obj._progress_proxy = _ProgressProxy(obj.client) + obj.progress.update( + {"projection": 5, "total_projections": total_projections, "subtomo_total_projections": 20} + ) + obj.progressbar = FakeProgressBar() + obj._describe_active_hook = lambda: "" + return obj + + +def test_progress_gui_endless_scan_does_not_divide_by_zero(): + """golden_max_number_of_projections == 0 ("endless") gives + total_projections == 0: the GUI update must cope instead of raising into + the scan (which @scan_repeat would retry 10 times, then abort).""" + lamni = make_gui_lamni(total_projections=0) + + lamni._lamnigui_update_progress() + + assert lamni.progressbar.rings[0].value == 0.0 + assert "endless" in lamni.progressbar.label + + +def test_progress_gui_normal_scan_still_reports_percentages(): + lamni = make_gui_lamni(total_projections=20) + + lamni._lamnigui_update_progress() + + assert lamni.progressbar.rings[0].value == pytest.approx(25.0) + assert "Total projections expected: 20" in lamni.progressbar.label + + +def test_progress_gui_failure_never_propagates(): + lamni = make_gui_lamni(total_projections=20) + + class Broken: + @property + def rings(self): + raise RuntimeError("GUI closed / RPC timeout") + + lamni.progressbar = Broken() + + lamni._lamnigui_update_progress() # must not raise + + +def test_lamnigui_show_progress_failure_never_propagates(): + lamni = make_gui_lamni(total_projections=20) + + def _boom(): + raise RuntimeError("no GUI") + + lamni.lamnigui_show_gui = _boom + + lamni.lamnigui_show_progress() # must not raise + + +def test_show_gui_does_not_raise_window(): + """raise_window() can hide the window (bec_widgets RPC bug) -- same + workaround as flomni's flomnigui_show_gui().""" + raised = [] + gui = LamniGuiTools() + gui.gui = types.SimpleNamespace( + windows={"lamni": object()}, + lamni=types.SimpleNamespace(raise_window=lambda: raised.append(1)), + ) + + gui.lamnigui_show_gui() + gui.lamnigui_raise() + + assert raised == [] + + +# ── scilog ────────────────────────────────────────────────────────────────── + + +class FakeSciLogMessage: + def __init__(self, sink): + self._sink = sink + self._sink["entry"] = {} + + def add_text(self, text): + self._sink["entry"]["text"] = text + return self + + def add_tags(self, tags): + self._sink["entry"]["tags"] = tags + return self + + def send(self): + self._sink["sent"] = True + + +class FakeSciLog: + def __init__(self, enabled=True): + self._enabled = enabled + self.sink = {} + + def new(self): + return FakeSciLogMessage(self.sink) + + +def make_scilog_lamni(monkeypatch, scilog): + obj = object.__new__(LamNI) + obj.client = FakeClient() + monkeypatch.setitem( + builtins.__dict__, "bec", types.SimpleNamespace(messaging=types.SimpleNamespace(scilog=scilog)) + ) + return obj + + +def test_write_to_scilog_uses_messaging_service(monkeypatch): + scilog = FakeSciLog() + lamni = make_scilog_lamni(monkeypatch, scilog) + + tags = ["tomoscan"] + assert lamni.write_to_scilog("line1\nline2", tags) is True + + assert scilog.sink["sent"] + assert scilog.sink["entry"]["text"] == "line1
line2" + assert scilog.sink["entry"]["tags"] == ["tomoscan", "BEC"] + assert tags == ["tomoscan"], "the caller's list must not be mutated" + + +def test_write_to_scilog_disabled_is_a_noop(monkeypatch): + lamni = make_scilog_lamni(monkeypatch, FakeSciLog(enabled=False)) + assert lamni.write_to_scilog("x", ["t"]) is False + + +def test_write_to_scilog_never_raises(monkeypatch): + class Exploding(FakeSciLog): + def new(self): + raise RuntimeError("scilog unreachable") + + lamni = make_scilog_lamni(monkeypatch, Exploding()) + assert lamni.write_to_scilog("x", ["t"]) is False + + +def test_write_to_scilog_without_messaging_service(monkeypatch): + obj = object.__new__(LamNI) + obj.client = FakeClient() + monkeypatch.setitem(builtins.__dict__, "bec", types.SimpleNamespace()) + assert obj.write_to_scilog("x", ["t"]) is False + + +# ── tomo_scan_projection adds the projection to the reconstruction queue ──── +# +# Like flomni, reconstruction is done by tomo_scan_projection() itself, so +# custom at_each_angle hooks (which call tomo_scan_projection(_internal=True)) +# and direct calls are reconstructed too. It used to live in _at_each_angle(), +# so hooks silently produced no reconstruction entries. + + +def make_lamni_for_projection(fake_bec): + obj = object.__new__(LamNI) + obj.client = FakeClient() + obj._progress_proxy = _ProgressProxy(obj.client) + obj.compute_additional_correction = lambda angle: (0, 0) + obj.compute_additional_correction_2 = lambda angle: (0, 0) + obj.lamni_compute_additional_correction_xeye_mu = lambda angle, verbose=True: (0, 0) + obj.reconstruct_calls = [] + obj.tomo_reconstruct = lambda: obj.reconstruct_calls.append(1) + obj.fermat_calls = [] + fake_scans = types.SimpleNamespace( + lamni_fermat_scan=lambda **kw: obj.fermat_calls.append(kw) + ) + return obj, fake_scans + + +@pytest.fixture +def projection_env(monkeypatch, fake_bec): + obj, fake_scans = make_lamni_for_projection(fake_bec) + monkeypatch.setitem(builtins.__dict__, "scans", fake_scans) + return obj + + +def test_tomo_scan_projection_reconstructs_exactly_once(projection_env): + projection_env.tomo_scan_projection(30.0, _internal=True) + + assert len(projection_env.fermat_calls) == 1 + assert projection_env.reconstruct_calls == [1] + + +def test_tomo_scan_projection_reconstructs_once_per_projection_not_per_tile(projection_env): + """With stitching several Fermat scans make up one projection; the + reconstruction entry is written once, after all tiles.""" + projection_env.lamni_stitch_x = 1 # 3 x 1 tiles + projection_env.tomo_scan_projection(30.0, _internal=True) + + assert len(projection_env.fermat_calls) == 3 + assert projection_env.reconstruct_calls == [1] + + +def test_tomo_scan_projection_can_skip_reconstruction(projection_env): + projection_env.tomo_scan_projection(30.0, _internal=True, _reconstruct=False) + + assert len(projection_env.fermat_calls) == 1 + assert projection_env.reconstruct_calls == [] + + +def test_custom_hook_using_tomo_scan_projection_gets_reconstructed(projection_env): + """The documented hook pattern (tomo_queue_mixin's example) is just + setup.tomo_scan_projection(angle, _internal=True).""" + + def hook(setup, angle): + setup.tomo_scan_projection(angle, _internal=True) + + projection_env.at_each_angle_hook = "my_hook" + projection_env._resolve_at_each_angle_hook = lambda name, err: hook + + projection_env._at_each_angle(45.0) + + assert projection_env.reconstruct_calls == [1] + + +def test_default_at_each_angle_reconstructs_only_once(projection_env): + projection_env.at_each_angle_hook = None + + projection_env._at_each_angle(45.0) + + assert projection_env.reconstruct_calls == [1], "must not be reconstructed twice" + + +# ── tomo_type 3: sub-tomogram boundaries must be exact ────────────────────── + + +@pytest.mark.parametrize("n_per_subtomo", range(1, 401)) +def test_type3_subtomo_numbering_is_exact(n_per_subtomo): + """subtomo_number was int((ii * step) / 360) + 1, which truncates one + below the integer for ii == k * n at ~35 values of n < 400 (e.g. 19, 33, + 38, 39, 66): the first projection of a sub-tomogram got the previous + sub-tomogram's number and a duplicate of its closing angle.""" + lamni = object.__new__(LamNI) + for ii in range(0, 6 * n_per_subtomo + 1): + angle, subtomo = lamni._golden_equally_spaced(ii, n_per_subtomo, maxangle=360, reverse=True) + assert subtomo == ii // n_per_subtomo + 1, (n_per_subtomo, ii) + assert 0 <= angle < 720 + + +@pytest.mark.parametrize("n_per_subtomo", [19, 33, 38, 39, 66, 76, 78, 103, 161]) +def test_type3_each_subtomo_has_n_distinct_angles(n_per_subtomo): + lamni = object.__new__(LamNI) + for subtomo in range(1, 5): + angles = [ + lamni._golden_equally_spaced( + (subtomo - 1) * n_per_subtomo + p, n_per_subtomo, maxangle=360, reverse=True + )[0] + % 360 + for p in range(n_per_subtomo) + ] + assert len({round(a, 6) for a in angles}) == n_per_subtomo, (n_per_subtomo, subtomo) + + +# ── xrayeye_update_frame warns when the beamline states are not valid ─────── + + +class _FakeStates: + def __init__(self, statuses): + self._states = dict(statuses) + + def get_status_by_name(self, name): + status = self._states[name] + if isinstance(status, Exception): + raise status + return status + + +def _update_frame_lamni(monkeypatch, states, answer_yes): + obj = object.__new__(LamNI) + obj.client = FakeClient() + fake_bec = ( + types.SimpleNamespace(beamline_states=states) + if states is not None + else types.SimpleNamespace() + ) + monkeypatch.setitem(builtins.__dict__, "bec", fake_bec) + asked = [] + obj.OMNYTools = types.SimpleNamespace( + yesno=lambda question, default: asked.append((question, default)) or answer_yes + ) + frames = [] + + class FakeAligner: + def __init__(self, client, lamni): + pass + + def update_frame(self, keep_shutter_open=False): + frames.append(keep_shutter_open) + + monkeypatch.setattr(lamni_module, "XrayEyeAlignGUI", FakeAligner) + return obj, frames, asked + + +def test_update_frame_takes_a_frame_when_all_states_are_valid(monkeypatch): + lamni, frames, asked = _update_frame_lamni( + monkeypatch, _FakeStates({"beam": "valid", "shutter": "valid"}), answer_yes=False + ) + + lamni.xrayeye_update_frame(keep_shutter_open=True) + + assert frames == [True] + assert asked == [] + + +def test_update_frame_warns_and_stops_when_a_state_is_invalid(monkeypatch, capsys): + lamni, frames, asked = _update_frame_lamni( + monkeypatch, _FakeStates({"beam": "invalid", "shutter": "valid"}), answer_yes=False + ) + + lamni.xrayeye_update_frame() + + assert frames == [] + assert asked == [("Take a frame anyway?", "n")] + out = capsys.readouterr().out + assert "beam: invalid" in out + assert "shutter" not in out.split("Attention")[1] + + +def test_update_frame_proceeds_if_the_user_insists(monkeypatch): + lamni, frames, _ = _update_frame_lamni( + monkeypatch, _FakeStates({"beam": "warning"}), answer_yes=True + ) + + lamni.xrayeye_update_frame() + + assert frames == [False] + + +def test_update_frame_never_blocks_without_beamline_states(monkeypatch): + lamni, frames, asked = _update_frame_lamni(monkeypatch, None, answer_yes=False) + + lamni.xrayeye_update_frame() + + assert frames == [False] + assert asked == [] + + +def test_update_frame_ignores_states_that_cannot_be_read(monkeypatch): + lamni, frames, _ = _update_frame_lamni( + monkeypatch, _FakeStates({"flaky": RuntimeError("unreadable"), "beam": "valid"}), True + ) + + lamni.xrayeye_update_frame() + + assert frames == [False] diff --git a/tests/tests_bec_ipython_client/test_lamni_osa_collision_info.py b/tests/tests_bec_ipython_client/test_lamni_osa_collision_info.py index 1a441213..88afbc8d 100644 --- a/tests/tests_bec_ipython_client/test_lamni_osa_collision_info.py +++ b/tests/tests_bec_ipython_client/test_lamni_osa_collision_info.py @@ -144,3 +144,37 @@ def test_lfzp_info_notes_parked_out(fake_dev, capsys): out = capsys.readouterr().out assert "likely parked OUT" in out assert "Remaining space if OSA is moved to its IN position" in out + + +class FakeCcmEnergy: + """ccm_energy is a PSISimplePositioner: read(cached=True) -> {name: {"value": ...}}.""" + + name = "ccm_energy" + + def read(self, cached=False): + return {"ccm_energy": {"value": 6.2}} + + +def test_lfzp_info_reads_the_energy_from_ccm_energy(fake_dev, capsys): + """It used to read dev.mokev, which only exists in device_configs/legacy/, + so lfzp_info() without an argument always bailed out.""" + dev = fake_dev(collision_offset=113.0) + del dev.mokev # not in the current configs + dev.ccm_energy = FakeCcmEnergy() + + make_optics().lfzp_info() + + out = capsys.readouterr().out + assert "6.2000 keV" in out + assert "Could not read" not in out + + +def test_lfzp_info_without_energy_device_explains_and_returns(fake_dev, capsys): + dev = fake_dev(collision_offset=113.0) + del dev.mokev + + make_optics().lfzp_info() # no ccm_energy either: must not raise + + out = capsys.readouterr().out + assert "ccm_energy" in out + assert "keV" in out # tells the user to pass the energy explicitly diff --git a/tests/tests_bec_ipython_client/test_lamni_pdf_report.py b/tests/tests_bec_ipython_client/test_lamni_pdf_report.py new file mode 100644 index 00000000..f790a818 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_lamni_pdf_report.py @@ -0,0 +1,172 @@ +"""Tests for LamNI.write_pdf_report()'s report content. + +It always used the tomo_type-1 grid (int(360/tomo_angle_stepsize) * 8), so a +golden-ratio tomogram (tomo_type 2/3) reported a stale type-1 projection count +left over in the persistent tomo_angle_stepsize global var, and a hardcoded +"8 sub-tomograms" -- fixed like flomni's 5a22651. It also read the +e-account from client.username (flomni: bec.active_account, c1ad2fc), and a +missing detector_distance userParameter crashed the report after the +tomogram had already been registered. + +Drives the real method with a fake PDFWriter that captures the report text. +""" + +import builtins +import types + +import pytest + +import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy + + +class FakeClient: + def __init__(self): + self._vars = {} + self.queue = types.SimpleNamespace(next_dataset_number=7, next_scan_number=1000) + self.username = "unix_user" + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + +class FakePDFWriter: + written: list[str] = [] + + def __init__(self, path): + self._pdf = types.SimpleNamespace(image=lambda *a, **k: None, ln=lambda *a, **k: None) + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def write(self, text): + FakePDFWriter.written.append(text) + + +@pytest.fixture +def report_lamni(monkeypatch, tmp_path): + FakePDFWriter.written = [] + monkeypatch.setattr(lamni_module, "PDFWriter", FakePDFWriter) + monkeypatch.setattr( + "os.path.expanduser", + lambda p: p.replace("~", str(tmp_path)) if p.startswith("~") else p, + ) + monkeypatch.setitem( + builtins.__dict__, + "bec", + types.SimpleNamespace(active_account="e12345", messaging=types.SimpleNamespace()), + ) + monkeypatch.setitem(builtins.__dict__, "dev", types.SimpleNamespace()) + + obj = object.__new__(LamNI) + obj.client = FakeClient() + obj._progress_proxy = _ProgressProxy(obj.client) + obj.tomo_id = 42 + obj.sample_name = "sample1" + obj._add_psi_footer = lambda file: None + obj._upload_pdf_report_to_samples = lambda path: None + obj._describe_active_hook = lambda: "" + obj._active_hook_source = lambda: None + obj._get_user_param_safe = lambda device, var: {"detector_distance": 1500.0}.get(var, "x") + return obj + + +def _report(lamni) -> str: + lamni.write_pdf_report() + assert FakePDFWriter.written, "no report was written" + return FakePDFWriter.written[0] + + +def _line(report: str, label: str) -> str: + for line in report.splitlines(): + if line.startswith(label): + return line[len(label) :].strip() + raise AssertionError(f"{label!r} not in report:\n{report}") + + +def test_type1_report_uses_the_grid(report_lamni): + report_lamni.tomo_type = 1 + report_lamni.tomo_angle_stepsize = 10.0 + + report = _report(report_lamni) + + assert _line(report, "Number of projections:") == "288" + assert _line(report, "Number of individual sub-tomograms:") == "8" + assert _line(report, "Angular step within sub-tomogram:") == "10.00" + assert _line(report, "Last scan number approx.:") == "1298" # 1000 + 288 + 10 + + +def test_type1_report_shows_the_achievable_step_not_the_configured_one(report_lamni): + """7 deg does not divide 360: N = 51, achievable step 360/51 = 7.06.""" + report_lamni.tomo_type = 1 + report_lamni.tomo_angle_stepsize = 7.0 + + report = _report(report_lamni) + + assert _line(report, "Number of projections:") == str(51 * 8) + assert _line(report, "Angular step within sub-tomogram:") == "7.06" + + +def test_golden_ratio_report_does_not_use_the_stale_type1_grid(report_lamni): + report_lamni.tomo_type = 2 + report_lamni.tomo_angle_stepsize = 10.0 # stale type-1 leftover: would give 288 + report_lamni.golden_max_number_of_projections = 500 + report_lamni.golden_ratio_bunch_size = 30 + + report = _report(report_lamni) + + assert _line(report, "Number of projections:") == "500" + assert _line(report, "Sorted in bunches of:") == "30" + assert _line(report, "Angular step within sub-tomogram:") == "N/A (golden ratio)" + assert "Number of individual sub-tomograms" not in report + assert _line(report, "Last scan number approx.:") == "1510" + + +def test_golden_ratio_endless_scan_reports_manual_stop(report_lamni): + report_lamni.tomo_type = 2 + report_lamni.golden_max_number_of_projections = 0 + report_lamni.golden_ratio_bunch_size = 20 + + report = _report(report_lamni) + + assert _line(report, "Number of projections:") == "N/A (manual stop)" + assert _line(report, "Last scan number approx.:") == "N/A" + + +def test_type3_report_uses_projections_per_full_circle_subtomo(report_lamni): + report_lamni.tomo_type = 3 + report_lamni.tomo_angle_stepsize = 10.0 + report_lamni.golden_max_number_of_projections = 400 + + report = _report(report_lamni) + + assert _line(report, "Number of projections:") == "400" + # lamni sub-tomograms span 360 degrees (flomni: 180) + assert _line(report, "Number of projections per sub-tomogram:") == "36.0" + assert _line(report, "Angular step within sub-tomogram:") == "10.00" + + +def test_report_uses_the_active_account(report_lamni): + report_lamni.tomo_type = 1 + report = _report(report_lamni) + assert _line(report, "e-account:") == "e12345" + + +def test_report_survives_a_missing_detector_distance(report_lamni): + def _get(device, var): + if var == "detector_distance": + raise ValueError("no user parameter definition") + return "x" + + report_lamni._get_user_param_safe = _get + report_lamni.tomo_type = 1 + + report = _report(report_lamni) + + assert _line(report, "Sample-to-detector distance:") == "N/A mm" diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py b/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py index 258dc920..a3d45ab8 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py @@ -85,7 +85,7 @@ def test_tomo_alignment_scan_aborts_without_xray_eye_fit(monkeypatch): lamni = make_lamni(monkeypatch, xray_eye_fit=None) lamni._confirm_sequence_override = lambda *a, **k: False calls = [] - lamni.tomo_scan_projection = lambda angle: calls.append(angle) + lamni.tomo_scan_projection = lambda angle, **kw: calls.append(angle) lamni.tomo_alignment_scan() @@ -97,7 +97,7 @@ def test_tomo_alignment_scan_runs_12_projections_across_360(monkeypatch): lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) calls = [] - def _fake_projection(angle): + def _fake_projection(angle, _internal=False, **kw): calls.append(angle) builtins.__dict__["bec"].queue.next_scan_number += 1 @@ -115,7 +115,7 @@ def test_tomo_alignment_scan_runs_12_projections_across_360(monkeypatch): def test_tomo_alignment_scan_rotates_back_to_zero(monkeypatch): lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) - lamni.tomo_scan_projection = lambda angle: None + lamni.tomo_scan_projection = lambda angle, _internal=False, **kw: None umv_calls = [] monkeypatch.setattr(lamni_module, "umv", lambda *a: umv_calls.append(a), raising=False) @@ -142,3 +142,64 @@ def test_write_alignment_scan_numbers_format(tmp_path, monkeypatch): assert angles == list(np.linspace(0, 360, num=12, endpoint=False)) assert len(lines[2].split()) == 12 assert len(lines[3].split()) == 12 + + +def _timeout_alarm(): + from bec_lib.alarm_handler import AlarmBase + + alarm = AlarmBase.__new__(AlarmBase) + alarm.alarm_type = "TimeoutError" + return alarm + + +def test_tomo_alignment_scan_retries_timed_out_angle_and_records_real_scan_numbers( + tmp_path, monkeypatch +): + """A TimeoutError alarm used to be swallowed: the angle was silently lost + while the failed attempt's scan numbers were logged as if it succeeded, and + the pre-written scan-number file (first_scan + k per angle) went stale.""" + lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) + monkeypatch.setattr( + "os.path.expanduser", + lambda p: p.replace("~", str(tmp_path)) if p.startswith("~") else p, + ) + monkeypatch.setattr(lamni_module.time, "sleep", lambda s: None) + calls = [] + + def _projection(angle, _internal=False, **kw): + calls.append(angle) + builtins.__dict__["bec"].queue.next_scan_number += 1 # each attempt uses a scan number + if len(calls) == 3: # 3rd call = first attempt at the 3rd angle + raise _timeout_alarm() + + lamni.tomo_scan_projection = _projection + + lamni.tomo_alignment_scan() + + expected_angles = list(np.linspace(0, 360, num=12, endpoint=False)) + assert len(calls) == 13, "the timed-out angle must be re-acquired" + assert calls[2] == calls[3] == expected_angles[2] + + log_file = tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt" + lines = log_file.read_text().splitlines() + scans = [int(s) for s in lines[0].split()] + # scans 100, 101 = angles 0, 1; 102 = the failed attempt at angle 2 (must be + # skipped); 103.. = the retry and the remaining angles + assert scans == [100, 101] + list(range(103, 113)) + assert [float(a) for a in lines[1].split()] == expected_angles + content, _tags = lamni._scilog_calls[0] + assert "Number of alignment scans: 12" in content + + +def test_tomo_alignment_scan_projections_are_not_queued_for_reconstruction(monkeypatch): + """Alignment projections were never added to the reconstruction queue on + lamni; moving tomo_reconstruct() into tomo_scan_projection() must not + change that.""" + lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) + kwargs_seen = [] + lamni.tomo_scan_projection = lambda angle, **kw: kwargs_seen.append(kw) + + lamni.tomo_alignment_scan() + + assert len(kwargs_seen) == 12 + assert all(kw.get("_reconstruct") is False for kw in kwargs_seen) diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_angle_file.py b/tests/tests_bec_ipython_client/test_lamni_tomo_angle_file.py new file mode 100644 index 00000000..a601c7a3 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_angle_file.py @@ -0,0 +1,271 @@ +"""tomo_type 4 (angles from a file) on LamNI -- the shared logic itself is +covered by test_flomni_tomo_angles.py / test_angle_file.py; these tests pin +the LamNI-specific wiring (setter, gates, overrides threading, no 180-deg +warning, progress reset, resume, PDF report, queue job line).""" + +import builtins +import types + +import pytest + +import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, LamNIError, _ProgressProxy + + +class _FakeClient: + def __init__(self): + self._vars = {} + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + +class _FakeContextManager: + def __enter__(self): + return self + + def __exit__(self, *exc_info): + return False + + +class _FakeScans: + dataset_id_on_hold = _FakeContextManager() + + +@pytest.fixture(autouse=True) +def _fail_fast_on_unexpected_sleep(monkeypatch): + """A fixture file without 'END' would otherwise hang in the wait loop.""" + + def _unexpected_sleep(seconds): + pytest.fail(f"tomo_scan() tried to sleep({seconds}) -- angle file missing 'END'?") + + monkeypatch.setattr(lamni_module.time, "sleep", _unexpected_sleep) + + +def make_lamni_for_angle_file(monkeypatch, tmp_path, content: str) -> LamNI: + obj = object.__new__(LamNI) + obj.client = _FakeClient() + obj._progress_proxy = _ProgressProxy(obj.client) + obj.tomo_type = 4 + obj.tomo_id = -1 + obj.special_angles = [] + obj._current_special_angles = [] + obj.at_each_angle_hook = None + obj.corr_pos_x = [1.0] # skip the fine-alignment gate + obj._check_eye_out_and_optics_in = lambda: True + obj.lamnigui_show_progress = lambda: None + obj._lamnigui_update_progress = lambda: None + obj.add_sample_database = lambda *a, **k: 0 + obj.write_pdf_report = lambda: None + obj._print_progress = lambda: None + obj._log_tomogram_timing = lambda: None + obj._build_timing_lines = lambda: ["a", "b", "c"] + obj._active_hook_source = lambda: None + obj.scilog = [] + obj.write_to_scilog = lambda content, tags=None: obj.scilog.append(content) + obj.printed_red = [] + obj.OMNYTools = types.SimpleNamespace( + printgreenbold=lambda msg: None, printredbold=lambda msg: obj.printed_red.append(msg) + ) + fake_bec = types.SimpleNamespace( + active_account="", + queue=types.SimpleNamespace(next_scan_number=1), + builtin_actors=types.SimpleNamespace( + scan_interlock=types.SimpleNamespace(trigger_setting=None, enabled=False) + ), + ) + monkeypatch.setitem(builtins.__dict__, "bec", fake_bec) + monkeypatch.setitem(builtins.__dict__, "scans", _FakeScans()) + monkeypatch.setitem(builtins.__dict__, "dev", types.SimpleNamespace()) + monkeypatch.setattr(lamni_module, "filters_out_of_beam", lambda dev: (True, [])) + path = tmp_path / "angles.txt" + path.write_text(content) + obj.tomo_angle_file = str(path) + return obj + + +def test_tomo_type_4_accepted_and_5_rejected(): + obj = object.__new__(LamNI) + obj.client = _FakeClient() + obj.tomo_type = 4 + assert obj.tomo_type == 4 + with pytest.raises(ValueError): + obj.tomo_type = 5 + + +def test_tomo_angle_file_is_a_queue_param(): + assert "tomo_angle_file" in LamNI._TOMO_SCAN_PARAM_NAMES + + +def test_scan_follows_file_order_and_reports_progress(monkeypatch, tmp_path): + lamni = make_lamni_for_angle_file(monkeypatch, tmp_path, "10 1\n170 1\n270 2\n362 3\nEND\n") + recorded = [] + lamni._tomo_scan_at_angle = lambda angle, subtomo: recorded.append((angle, subtomo)) + + lamni.tomo_scan(force=True) + + assert recorded == [(0, 1), (10.0, 1), (170.0, 1), (270.0, 2), (362.0, 3)] + assert lamni.progress["total_projections"] == 4 + assert lamni.progress["tomo_type"] == "Angles from file" + + +def test_no_180_deg_opposite_warning_for_laminography(monkeypatch, tmp_path): + lamni = make_lamni_for_angle_file(monkeypatch, tmp_path, "10\n190\n10\nEND\n") + lamni._tomo_scan_at_angle = lambda angle, subtomo: None + + lamni.tomo_scan(force=True) + + assert lamni.printed_red == [] + + +def test_resume_starts_at_projection_index(monkeypatch, tmp_path): + lamni = make_lamni_for_angle_file(monkeypatch, tmp_path, "10\n20\n30\n40\nEND\n") + recorded = [] + lamni._tomo_scan_at_angle = lambda angle, subtomo: recorded.append((angle, subtomo)) + + lamni.tomo_scan(projection_number=2, force=True) + + assert recorded == [(30.0, 1), (40.0, 1)] + + +def test_resume_past_end_of_file_raises(monkeypatch, tmp_path): + lamni = make_lamni_for_angle_file(monkeypatch, tmp_path, "10\n20\nEND\n") + lamni._tomo_scan_at_angle = lambda angle, subtomo: pytest.fail("must not acquire") + with pytest.raises(LamNIError, match="only 2 angles"): + lamni.tomo_scan(projection_number=2, force=True) + + +def test_missing_file_raises_before_gates_and_acquisition(monkeypatch, tmp_path): + lamni = make_lamni_for_angle_file(monkeypatch, tmp_path, "10\nEND\n") + lamni.tomo_angle_file = str(tmp_path / "gone.txt") + calls = [] + lamni._check_eye_out_and_optics_in = lambda: calls.append("gate") or True + lamni.add_sample_database = lambda *a, **k: calls.append("db") + lamni._tomo_scan_at_angle = lambda angle, subtomo: calls.append("scan") + + with pytest.raises(LamNIError, match="Cannot read"): + lamni.tomo_scan(force=True) + assert calls == [] + + +def test_progress_reset_even_if_gate_declines(monkeypatch, tmp_path): + lamni = make_lamni_for_angle_file(monkeypatch, tmp_path, "10\nEND\n") + lamni.progress["projection"] = 99 + lamni.progress["angle"] = 123 + lamni._check_eye_out_and_optics_in = lambda: False + lamni._confirm_sequence_override = lambda warning, force: False + lamni._tomo_scan_at_angle = lambda angle, subtomo: pytest.fail("must not acquire") + + lamni.tomo_scan() + + assert lamni.progress["projection"] == 0 + assert lamni.progress["angle"] == 0 + + +def test_per_line_overrides_are_sticky_and_passed_to_scan_at_angle(monkeypatch, tmp_path): + lamni = make_lamni_for_angle_file( + monkeypatch, tmp_path, "10 1\n20 1 step=0.5 exptime=2 frames=3\n30 1 step=0.7\nEND\n" + ) + calls = [] + lamni._tomo_scan_at_angle = lambda angle, subtomo, **kw: calls.append((angle, kw)) + + lamni.tomo_scan(force=True) + + assert calls == [ + (0, {}), + (10.0, {}), + (20.0, {"step": 0.5, "exp_time": 2.0, "frames_per_trigger": 3}), + (30.0, {"step": 0.7, "exp_time": 2.0, "frames_per_trigger": 3}), + ] + + +def test_scan_at_angle_forwards_overrides_to_at_each_angle(monkeypatch): + obj = object.__new__(LamNI) + obj.client = _FakeClient() + obj._progress_proxy = _ProgressProxy(obj.client) + obj.tomo_type = 4 + obj.special_angles = [] + obj._current_special_angles = [] + obj._print_progress = lambda: None + obj._write_tomo_scan_number = lambda *a: None + obj._log_projection_timing = lambda **kw: None + obj.lamni_stitch_x = 0 + obj.lamni_stitch_y = 0 + obj.tomo_countingtime = 0.1 + obj.frames_per_trigger = 1 + fake_bec = types.SimpleNamespace(queue=types.SimpleNamespace(next_scan_number=1)) + monkeypatch.setitem(builtins.__dict__, "bec", fake_bec) + monkeypatch.setattr(lamni_module, "bec", fake_bec, raising=False) + calls = [] + obj._at_each_angle = lambda angle, **kw: calls.append((angle, kw)) + + obj._tomo_scan_at_angle(-1.5, 1, step=0.3) # in range only for type 4 + obj._tomo_scan_at_angle(361.0, 1) + obj.tomo_type = 1 + obj._tomo_scan_at_angle(-1.5, 1) # outside 0..360 -> skipped + + assert calls == [(-1.5, {"step": 0.3}), (361.0, {})] + + +def test_scan_projection_uses_overrides_else_globals(monkeypatch): + obj = object.__new__(LamNI) + obj.client = _FakeClient() + obj.tomo_shellstep = 1.0 + obj.tomo_countingtime = 0.1 + obj.frames_per_trigger = 1 + obj.lamni_stitch_x = 0 + obj.lamni_stitch_y = 0 + obj.compute_additional_correction = lambda a: (0, 0) + obj.compute_additional_correction_2 = lambda a: (0, 0) + obj.lamni_compute_additional_correction_xeye_mu = lambda a: (0, 0) + obj.tomo_reconstruct = lambda: None + scan_calls = [] + fake_scans = types.SimpleNamespace(lamni_fermat_scan=lambda **kw: scan_calls.append(kw)) + fake_bec = types.SimpleNamespace(queue=types.SimpleNamespace(next_scan_number=1)) + monkeypatch.setitem(builtins.__dict__, "scans", fake_scans) + monkeypatch.setattr(lamni_module, "bec", fake_bec, raising=False) + monkeypatch.setitem(builtins.__dict__, "bec", fake_bec) + + obj.tomo_scan_projection(10.0, _internal=True) + obj.tomo_scan_projection( + 20.0, _internal=True, step=0.5, exp_time=2.0, frames_per_trigger=4 + ) + + assert (scan_calls[0]["step"], scan_calls[0]["exp_time"]) == (1.0, 0.1) + assert scan_calls[0]["frames_per_trigger"] == 1 + assert (scan_calls[1]["step"], scan_calls[1]["exp_time"]) == (0.5, 2.0) + assert scan_calls[1]["frames_per_trigger"] == 4 + + +def test_angle_gate_is_minus2_to_362_only_for_type_4(): + obj = object.__new__(LamNI) + obj.client = _FakeClient() + obj.tomo_type = 4 + assert (obj._min_scan_angle(), obj._max_scan_angle()) == (-2, 362) + obj.tomo_type = 1 + assert (obj._min_scan_angle(), obj._max_scan_angle()) == (0, 360) + + +def test_queue_job_line_shows_angle_file_for_type_4(): + obj = object.__new__(LamNI) + line = obj._describe_tomo_job_line( + {"params": {"tomo_type": 4, "tomo_angle_file": "/x/y/angles.txt", "tomo_circfov": 0}} + ) + assert "angles=angles.txt" in line + line = obj._describe_tomo_job_line({"params": {"tomo_type": 1}}) + assert "angles=" not in line + + +def test_progress_shows_type_and_total_before_the_zero_deg_shot(monkeypatch, tmp_path): + lamni = make_lamni_for_angle_file(monkeypatch, tmp_path, "10\n20\nEND\n") + seen = [] + lamni._tomo_scan_at_angle = lambda angle, subtomo: seen.append( + (angle, lamni.progress["tomo_type"], lamni.progress["total_projections"]) + ) + + lamni.tomo_scan(force=True) + + assert seen[0] == (0, "Angles from file", 2) # the initial 0-deg shot diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py index f9a63fe9..5363044b 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py @@ -129,6 +129,112 @@ def test_all_eight_subtomos_are_equally_spaced_when_combined(stepsize): _assert_equally_spaced(combined, "full (all 8)") +# ── progress reset at the start of a new scan ─────────────────────────────── +# +# A finished scan leaves projection == total (100 %), its last subtomo and +# angle in tomo_progress. The new scan's first _print_progress() (from the +# 0-degree reference shot) used to render those stale values in the progress +# GUI, because only the timing/ETA fields were reset. + + +def test_new_scan_resets_stale_progress_before_first_projection(monkeypatch): + lamni = make_lamni_for_tomo_scan(monkeypatch, 10.0, active_account="") + lamni.progress.update( + { + "tomo_type": "Golden ratio tomography", + "subtomo": 8, + "subtomo_projection": 36, + "subtomo_total_projections": 36, + "projection": 288, + "total_projections": 288, + "angle": 355.0, + "estimated_remaining_time": 0.0, + "estimated_finish_time": "2020-01-01T00:00:00", + "accumulated_idle_time": 99.0, + "heartbeat": "2020-01-01T00:00:00", + } + ) + lamni.add_sample_database = lambda *a, **k: 0 + seen = [] + lamni._tomo_scan_at_angle = lambda angle, subtomo_number: seen.append( + (angle, subtomo_number, dict(lamni.progress.as_dict())) + ) + + lamni.tomo_scan() + + angle, subtomo_number, prog = seen[0] + assert (angle, subtomo_number) == (0, 1) + assert prog["tomo_type"] == "Equally spaced sub-tomograms" + assert prog["subtomo"] == 1 + assert prog["projection"] == 0 + assert prog["subtomo_projection"] == 0 + assert prog["angle"] == 0 + assert prog["total_projections"] == 36 * 8 + assert prog["subtomo_total_projections"] == 36 + assert prog["estimated_remaining_time"] is None + assert prog["estimated_finish_time"] is None + assert prog["accumulated_idle_time"] == 0.0 + assert prog["heartbeat"] is None + assert prog["tomo_start_time"] is not None + + +def test_resume_does_not_reset_progress(monkeypatch): + lamni = make_lamni_for_tomo_scan(monkeypatch, 10.0, active_account="") + resets = [] + lamni._reset_progress_for_new_scan = lambda: resets.append(1) + + lamni.tomo_scan(subtomo_start=2, start_angle=85.0) + + assert resets == [] + + +# ── resuming mid-sub-tomogram (start_angle) ───────────────────────────────── +# +# start_angle used to be treated as the ORIGIN of a fresh 360-degree sweep, +# so resuming e.g. subtomo 2 at 85 deg generated 435..85 (flipped): the >360 +# artifacts were silently skipped, the resume started at 355 deg instead of +# 85, re-measured done angles and never reached the remaining ones. + + +def _resume(stepsize: float, subtomo_number: int, start_angle: float): + lamni = make_lamni(stepsize) + recorded = [] + + def _record(angle, subtomo): + recorded.append((float(angle), int(lamni.progress["subtomo_projection"]))) + + lamni._tomo_scan_at_angle = _record + lamni.sub_tomo_scan(subtomo_number, start_angle=start_angle) + return recorded + + +@pytest.mark.parametrize("stepsize", STEPSIZES) +@pytest.mark.parametrize("subtomo_number", range(1, 9)) +def test_resume_yields_tail_of_normal_sweep(stepsize, subtomo_number): + full = make_lamni(stepsize) + full_recorded = [] + full._tomo_scan_at_angle = lambda angle, subtomo: full_recorded.append(float(angle)) + full.sub_tomo_scan(subtomo_number) + + for k, start in enumerate(full_recorded): + resumed = _resume(stepsize, subtomo_number, start) + assert [a for a, _ in resumed] == pytest.approx(full_recorded[k:]) + assert [i for _, i in resumed] == list(range(k, len(full_recorded))) + assert all(0 <= a < 360.05 for a, _ in resumed) + + +def test_resume_reported_case_subtomo2_at_85_deg(): + resumed = _resume(10.0, 2, 85.0) + assert resumed[0] == (pytest.approx(85.0), 27) + assert resumed[-1][0] == pytest.approx(5.0) + assert len(resumed) == 36 - 27 + + +def test_resume_off_grid_start_angle_raises(): + with pytest.raises(ValueError): + _resume(10.0, 2, 80.0) + + # ── tomo_scan()'s new-scan account handling ───────────────────────────────── # # bec.active_account used to be passed through .decode() (as if it were @@ -164,10 +270,17 @@ def make_lamni_for_tomo_scan( obj.special_angles = [] obj.write_pdf_report = lambda: None obj.sub_tomo_scan = lambda subtomo_number, start_angle=None: None + # Default no-op for the unconditional 0-deg reference projection tomo_scan() + # now fires at the start of every new scan (see lamni.py) -- tests that + # care about it override this after calling make_lamni_for_tomo_scan(). + obj._tomo_scan_at_angle = lambda angle, subtomo_number: None obj._print_progress = lambda: None obj._format_duration = lambda seconds: "0s" obj.lamnigui_show_progress = lambda: None + obj._lamnigui_update_progress = lambda: None obj.at_each_angle_hook = None + # the real __init__ sets up the hook registry _active_hook_source() reads + obj._active_hook_source = lambda: None obj.OMNYTools = types.SimpleNamespace(printgreenbold=lambda msg: None) # These tests exercise tomo_scan()'s account-handling/heartbeat/progress-GUI # logic, not the fine-alignment confirmation gate or the measurement- @@ -301,7 +414,9 @@ def test_subtomo_starts_near_zero(subtomo_number, expected): def test_zero_deg_reference_disabled_by_default(monkeypatch): """zero_deg_reference_at_each_subtomo defaults to False -- a fresh - tomo_scan() must not fire any extra 0-deg shots.""" + tomo_scan() must not fire any *flag-gated* extra 0-deg shots, but the + unconditional single 0-deg reference projection at scan start (see + lamni.py) always fires regardless of the flag.""" lamni = make_lamni_for_tomo_scan(monkeypatch, 45.0, active_account="") lamni.add_sample_database = lambda *a, **k: 0 recorded = [] @@ -312,13 +427,16 @@ def test_zero_deg_reference_disabled_by_default(monkeypatch): lamni.tomo_scan() - assert recorded == [] + assert recorded == [(0, 1)] def test_zero_deg_reference_fires_for_odd_subtomos_and_final_shot(monkeypatch): """With the flag on, a fresh tomo_scan() must fire an extra angle-0 shot before each odd sub-tomogram (1, 3, 5, 7) plus one final shot after - sub-tomogram 8 completes -- mirrors Flomni.tomo_scan()'s equivalent.""" + sub-tomogram 8 completes -- mirrors Flomni.tomo_scan()'s equivalent. The + leading (0, 1) is the unconditional scan-start reference projection + (fires regardless of the flag), followed by the flag's own (0, 1) for + sub-tomogram 1 itself -- the two are independent and both fire here.""" lamni = make_lamni_for_tomo_scan(monkeypatch, 45.0, active_account="") lamni.add_sample_database = lambda *a, **k: 0 lamni.zero_deg_reference_at_each_subtomo = True @@ -330,7 +448,7 @@ def test_zero_deg_reference_fires_for_odd_subtomos_and_final_shot(monkeypatch): lamni.tomo_scan() - assert recorded == [(0, 1), (0, 3), (0, 5), (0, 7), (0, 8)] + assert recorded == [(0, 1), (0, 1), (0, 3), (0, 5), (0, 7), (0, 8)] def test_zero_deg_reference_skipped_when_resuming_mid_subtomo(monkeypatch): diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py index c424f754..8f777712 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py @@ -137,10 +137,12 @@ def test_lamni_offset_fields_excluded_from_param_names(): def test_flomni_field_order_matches_pre_reorder_layout(): """Regression guard: the field_order dispatch must reproduce flomni's - exact, unchanged field build order (this task only reordered lamni).""" + build order (this task only reordered lamni). Updated to include + fermat_asymmetry, added right after tomo_shellstep.""" assert SETUP_PROFILES["flomni"]["field_order"] == [ "tomo_countingtime", "tomo_shellstep", + "fermat_asymmetry", "fov", "stitch", "tomo_stitch_overlap", @@ -182,6 +184,8 @@ def test_compute_fermat_positions_lamni_matches_scan_class_below_threshold(): count, min_positions = _compute_fermat_positions_lamni(params) assert min_positions == LamniFermatScan.MIN_POSITIONS assert count < min_positions + # _compute_fermat_positions_lamni() pins seed=0 for its own reproducibility -- match + # it here rather than drawing an independent random offset. expected = LamniFermatScan.get_lamni_fermat_spiral_pos( -2.5, 2.5, @@ -195,6 +199,7 @@ def test_compute_fermat_positions_lamni_matches_scan_class_below_threshold(): fovx=5.0, fovy=5.0, fov_circular=0.0, + seed=0, ) assert count == len(expected) @@ -244,3 +249,80 @@ def test_compute_fermat_positions_lamni_zero_step_is_safe(): ) assert count == 0 assert min_positions == LamniFermatScan.MIN_POSITIONS + + +# ── golden bunch-size floor + manual-shift units ──────────────────────────── +# +# lamni's golden_ratio_bunch_size setter/CLI reject < 20, but the shared GUI +# spinbox allowed 1..100: a GUI-queued job with a smaller value made +# tomo_queue_execute()'s setattr() raise ValueError and pause the queue. +# The manual-shift fields were labelled um (+-1000) but lamni's manual shift is +# mm end to end (added to the mm scan centre, applied to lsamx/lsamy in mm). + + +def _bare_widget_for(setup: str): + from qtpy.QtWidgets import QApplication + + from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import OMNY_TomoParams + + QApplication.instance() or QApplication([]) + widget = OMNY_TomoParams.__new__(OMNY_TomoParams) + widget._profile = SETUP_PROFILES[setup] + widget._pw = {} + return widget + + +def test_lamni_bunch_size_spinbox_floor_is_20(): + widget = _bare_widget_for("lamni") + box = widget._build_type23_section() # keep a reference: it owns the spinboxes + assert box is not None + assert widget._pw["golden_ratio_bunch_size"].minimum() == 20 + assert widget._pw["golden_ratio_bunch_size"].maximum() == 100 + + +def test_flomni_bunch_size_spinbox_is_unchanged(): + widget = _bare_widget_for("flomni") + box = widget._build_type23_section() # keep a reference: it owns the spinboxes + assert box is not None + assert widget._pw["golden_ratio_bunch_size"].minimum() == 1 + + +def test_lamni_gui_bunch_size_floor_matches_the_setter(): + """Every value the GUI can produce must be accepted by lamni's setter.""" + + class FakeClient: + def __init__(self): + self._vars = {} + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + lamni = object.__new__(LamNI) + lamni.client = FakeClient() + floor = SETUP_PROFILES["lamni"]["golden_bunch_size_min"] + + lamni.golden_ratio_bunch_size = floor # must not raise + with pytest.raises(ValueError): + lamni.golden_ratio_bunch_size = floor - 1 + + +def test_lamni_manual_shift_fields_are_in_mm(): + fields = SETUP_PROFILES["lamni"]["manual_shift_fields"] + assert [f[0] for f in fields] == ["manual_shift_x", "manual_shift_y"] + for _key, label, min_, max_, _decimals in fields: + assert "(mm)" in label and "µm" not in label + assert (min_, max_) == (-1.0, 1.0) + + +def test_flomni_manual_shift_fields_are_in_um(): + """flomni's own fields really are um -- must not be touched.""" + fields = SETUP_PROFILES["flomni"]["manual_shift_fields"] + assert [f[0] for f in fields] == ["manual_shift_x", "manual_shift_y"] + by_key = {key: (min_, max_) for key, _label, min_, max_, _dec in fields} + for _key, label, _min_, _max_, _dec in fields: + assert "µm" in label + assert by_key["manual_shift_x"] == (-10.0, 10.0) + assert by_key["manual_shift_y"] == (-50.0, 50.0) diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py b/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py index 6ceca9ec..fdc43c3e 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py @@ -11,7 +11,7 @@ import types import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module import csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin as tomo_queue_mixin -from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, LamNIError +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, LamNIError, _ProgressProxy class _FakeRtx: @@ -159,24 +159,26 @@ def test_tomo_queue_execute_resumes_incomplete_job(): lamni.tomo_queue_execute() - assert calls == [("resume", (), {})] + assert calls == [("resume", (), {"from_queue": True})] job = lamni._tomo_queue_proxy.as_list()[0] assert job["status"] == "done" def test_tomo_queue_add_command_move_action(monkeypatch): lamni = make_lamni() - fake_dev = {"mokev": "mokev-device"} + # lamni's energy device is ccm_energy (the legacy "mokev" is not in the + # current configs) + fake_dev = {"ccm_energy": "ccm_energy-device"} moved = [] monkeypatch.setattr(tomo_queue_mixin, "dev", fake_dev, raising=False) monkeypatch.setattr(tomo_queue_mixin, "umv", lambda *a: moved.append(a), raising=False) lamni.tomo_queue_add_command( - {"action": "move", "kwargs": {"positions": {"mokev": 6.2}}}, label="reconfigure" + {"action": "move", "kwargs": {"positions": {"ccm_energy": 6.2}}}, label="reconfigure" ) lamni.tomo_queue_execute() - assert moved == [("mokev-device", 6.2)] + assert moved == [("ccm_energy-device", 6.2)] job = lamni._tomo_queue_proxy.as_list()[0] assert job["kind"] == "command" assert job["status"] == "done" @@ -204,16 +206,19 @@ def test_register_at_each_angle_hook_dispatches_from_at_each_angle(): def test_at_each_angle_default_path_unchanged_without_hook(): - """No hook registered -- must fall through to the exact same default - behaviour lamni had before this port: tomo_scan_projection + tomo_reconstruct.""" + """No hook registered -- falls through to tomo_scan_projection(), which + itself adds the projection to the reconstruction queue (like flomni), so + _at_each_angle() must NOT call tomo_reconstruct() a second time.""" lamni = make_lamni() calls = [] - lamni.tomo_scan_projection = lambda angle: calls.append(("projection", angle)) + lamni.tomo_scan_projection = lambda angle, _internal=False, **kw: calls.append( + ("projection", angle, _internal) + ) lamni.tomo_reconstruct = lambda: calls.append(("reconstruct",)) lamni._at_each_angle(30.0) - assert calls == [("projection", 30.0), ("reconstruct",)] + assert calls == [("projection", 30.0, True)] def test_at_each_angle_raises_for_unregistered_hook_name(): @@ -297,3 +302,44 @@ def test_at_each_angle_hook_refreshed_after_module_reload(tmp_path, capsys): finally: sys.path.remove(str(tmp_path)) sys.modules.pop(module_name, None) + + +def _resumable_lamni(status=None, answer=True): + lamni = make_lamni() + calls = [] + lamni.tomo_scan = lambda *a, **k: calls.append(("scan", a, k)) + lamni.tomo_type = 2 + lamni._progress_proxy = _ProgressProxy(lamni.client) + lamni.progress["tomo_start_time"] = "2026-01-01T00:00:00" + lamni.progress["projection"] = 5 + lamni.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: answer) + if status: + index = lamni.tomo_queue_add(label="job1") + lamni._tomo_queue_proxy.update(index, status=status) + return lamni, calls + + +def test_tomo_scan_resume_no_unfinished_job_does_not_prompt(): + lamni, calls = _resumable_lamni(answer=False) # would abort if prompted + lamni.tomo_scan_resume() + assert calls == [("scan", (), {"projection_number": 5})] + + +def test_tomo_scan_resume_warns_and_aborts_on_no(capsys): + lamni, calls = _resumable_lamni(status="running", answer=False) + lamni.tomo_scan_resume() + assert calls == [] + out = capsys.readouterr().out + assert "unfinished job" in out and "tomo_queue_resume()" in out + + +def test_tomo_scan_resume_proceeds_on_yes(): + lamni, calls = _resumable_lamni(status="incomplete", answer=True) + lamni.tomo_scan_resume() + assert calls == [("scan", (), {"projection_number": 5})] + + +def test_tomo_scan_resume_from_queue_never_prompts(): + lamni, calls = _resumable_lamni(status="running", answer=False) + lamni.tomo_scan_resume(from_queue=True) + assert calls == [("scan", (), {"projection_number": 5})] diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_reconstruct.py b/tests/tests_bec_ipython_client/test_lamni_tomo_reconstruct.py new file mode 100644 index 00000000..163da14d --- /dev/null +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_reconstruct.py @@ -0,0 +1,56 @@ +"""tomo_reconstruct() must write the right scan list and queue folder, also when called +directly (without tomo_scan_projection() having just set _current_scan_list).""" + +import builtins +import types + +import pytest + +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI + + +class FakeReconstructor: + def __init__(self): + self.folder_name = "stale_folder" + self.calls = [] + + def write(self, **kwargs): + self.calls.append(dict(kwargs, folder_name=self.folder_name)) + + +class FakeClient: + def get_global_var(self, key): + return {"ptycho_reconstruct_foldername": "current_folder"}.get(key) + + +@pytest.fixture +def lamni(monkeypatch): + obj = object.__new__(LamNI) + obj.client = FakeClient() + obj.reconstructor = FakeReconstructor() + fake_bec = types.SimpleNamespace(queue=types.SimpleNamespace(next_scan_number=101)) + monkeypatch.setitem(builtins.__dict__, "bec", fake_bec) + return obj + + +def test_uses_cached_scan_list_when_current(lamni): + lamni._current_scan_list = [99, 100] + lamni.tomo_reconstruct() + assert lamni.reconstructor.calls[0]["scan_list"] == [99, 100] + assert lamni.reconstructor.calls[0]["next_scan_number"] == 101 + + +def test_falls_back_when_scan_list_stale(lamni): + lamni._current_scan_list = [40, 41] + lamni.tomo_reconstruct() + assert lamni.reconstructor.calls[0]["scan_list"] == [100] + + +def test_falls_back_when_scan_list_missing(lamni): + lamni.tomo_reconstruct() + assert lamni.reconstructor.calls[0]["scan_list"] == [100] + + +def test_folder_name_resynced_from_global_var(lamni): + lamni.tomo_reconstruct() + assert lamni.reconstructor.calls[0]["folder_name"] == "current_folder" diff --git a/tests/tests_bec_ipython_client/test_lamni_xrayeye_refit.py b/tests/tests_bec_ipython_client/test_lamni_xrayeye_refit.py new file mode 100644 index 00000000..8cd08eab --- /dev/null +++ b/tests/tests_bec_ipython_client/test_lamni_xrayeye_refit.py @@ -0,0 +1,165 @@ +"""lamni.xrayeye_refit(): redo the X-ray eye sine fit without bad clicks.""" + +import h5py +import numpy as np +import pytest + +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_alignment_mixin import ( + XEYE_FIT_DATA_VAR, + fit_xray_eye_sine, +) + +ANGLES = np.arange(8) * 45.0 +TRUE_X = (12.0, 0.7, -3.0) +TRUE_Y = (5.0, -2.1, 8.0) + + +def _model(params, angles=ANGLES): + amp, shift, const = params + return amp * np.sin(np.radians(angles) + shift) + const + + +class _FakeClient: + def __init__(self): + self._vars = {} + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + def delete_global_var(self, key): + self._vars.pop(key, None) + + +class _FakeTools: + def __init__(self, answer=True): + self.answer = answer + self.asked = 0 + + def yesno(self, *_args, **_kwargs): + self.asked += 1 + return self.answer + + +class _FakeXeyeGui: + def __init__(self): + self.submitted = None + + def submit_fit_array(self, data): + self.submitted = data + + +def make_lamni(data=None, answer=True) -> LamNI: + obj = object.__new__(LamNI) + obj.client = _FakeClient() + obj.OMNYTools = _FakeTools(answer) + obj.xeyegui = _FakeXeyeGui() + obj.lamnigui_show_xeyealign_fittab = lambda: None + if data is not None: + obj.client.set_global_var(XEYE_FIT_DATA_VAR, data) + return obj + + +def _data_with_outlier(angle_idx=3, dx=40.0, dy=-25.0): + x = _model(TRUE_X) + y = _model(TRUE_Y) + x[angle_idx] += dx + y[angle_idx] += dy + return [ANGLES.tolist(), x.tolist(), y.tolist()] + + +def test_fit_recovers_parameters(): + assert np.allclose(fit_xray_eye_sine(ANGLES, _model(TRUE_X)), TRUE_X) + + +def test_fit_negative_amplitude_is_equivalent(): + # A DAP fit may report -A with shift+pi; the function must be the same. + amp, shift, const = fit_xray_eye_sine(ANGLES, _model((-12.0, 0.7, -3.0))) + assert amp > 0 + assert np.allclose(_model((amp, shift, const)), _model((-12.0, 0.7, -3.0))) + + +def test_exclude_outlier_recovers_true_fit(): + lamni = make_lamni(_data_with_outlier()) + lamni.xrayeye_refit([135]) + fit = lamni.client.get_global_var("tomo_fit_xray_eye") + assert np.allclose(fit[0], TRUE_X) + assert np.allclose(fit[1], TRUE_Y) + # Only the kept points go to the GUI. + assert lamni.xeyegui.submitted.shape == (3, 7) + assert 135 not in lamni.xeyegui.submitted[0] + + +def test_scalar_and_wrapped_angle_accepted(): + lamni = make_lamni(_data_with_outlier(angle_idx=0)) + lamni.xrayeye_refit(360) + assert np.allclose(lamni.client.get_global_var("tomo_fit_xray_eye")[0], TRUE_X) + + +def test_no_exclusion_fits_all_points(): + lamni = make_lamni(_data_with_outlier()) + lamni.xrayeye_refit() + fit = lamni.client.get_global_var("tomo_fit_xray_eye") + assert not np.allclose(fit[0], TRUE_X) + assert lamni.xeyegui.submitted.shape == (3, 8) + + +def test_declined_leaves_stored_fit(): + lamni = make_lamni(_data_with_outlier(), answer=False) + lamni.client.set_global_var("tomo_fit_xray_eye", [[1, 2, 3], [4, 5, 6]]) + lamni.xrayeye_refit([135]) + assert lamni.OMNYTools.asked == 1 + assert lamni.client.get_global_var("tomo_fit_xray_eye") == [[1, 2, 3], [4, 5, 6]] + + +def test_confirm_false_does_not_ask(): + lamni = make_lamni(_data_with_outlier()) + lamni.xrayeye_refit([135], confirm=False) + assert lamni.OMNYTools.asked == 0 + assert lamni.client.get_global_var("tomo_fit_xray_eye") is not None + + +def test_unknown_angle_rejected(): + lamni = make_lamni(_data_with_outlier()) + with pytest.raises(ValueError, match="No alignment point at 100"): + lamni.xrayeye_refit([100]) + + +def test_too_few_points_rejected(): + lamni = make_lamni(_data_with_outlier()) + with pytest.raises(ValueError, match="at least 4"): + lamni.xrayeye_refit([0, 45, 90, 135, 180]) + + +def test_no_data_gives_clear_error(): + with pytest.raises(RuntimeError, match="xrayeye_alignment_start"): + make_lamni().xrayeye_refit() + + +def test_reset_clears_points(): + lamni = make_lamni(_data_with_outlier()) + lamni.reset_xray_eye_correction() + assert lamni.client.get_global_var(XEYE_FIT_DATA_VAR) is None + + +def test_refit_from_archive_file(tmp_path): + path = tmp_path / "xrayeye_alignmentvalues_20261013_120000.h5" + with h5py.File(path, "w") as f: + f.create_dataset("alignment_fit", data=np.array(_data_with_outlier())) + lamni = make_lamni() + lamni.xrayeye_refit([135], file=str(path)) + assert np.allclose(lamni.client.get_global_var("tomo_fit_xray_eye")[1], TRUE_Y) + + +def test_gui_failure_does_not_block_refit(): + lamni = make_lamni(_data_with_outlier()) + + def _boom(): + raise RuntimeError("GUI closed") + + lamni.lamnigui_show_xeyealign_fittab = _boom + lamni.xrayeye_refit([135]) + assert np.allclose(lamni.client.get_global_var("tomo_fit_xray_eye")[0], TRUE_X) diff --git a/tests/tests_bec_ipython_client/test_tomo_parameters_gui.py b/tests/tests_bec_ipython_client/test_tomo_parameters_gui.py new file mode 100644 index 00000000..18540b11 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_tomo_parameters_gui.py @@ -0,0 +1,96 @@ +"""tomo_parameters() opens the OMNY_TomoParams widget in its own window (shared TomoQueueMixin +implementation, used by LamNI and Flomni); tomo_parameters_cli() is the command line version.""" + +import pytest + +import csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin as mixin_module +from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni, FlomniError +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, LamNIError + + +class FakeWindow: + def __init__(self): + self.new_calls = [] + + def new(self, widget, **kwargs): + self.new_calls.append((widget, kwargs)) + self.tomo_params = object() + + +class FakeGui: + def __init__(self, fail_new=False): + self.windows = {} + self.new_calls = [] + self.fail_new = fail_new + + def new(self, name, **kwargs): + if self.fail_new: + raise TimeoutError("GUI server did not answer") + self.new_calls.append((name, kwargs)) + self.windows[name] = window = FakeWindow() + setattr(self, name, window) + return window + + +class FakeClient: + def __init__(self, gui): + if gui is not None: + self.gui = gui + + +@pytest.fixture(autouse=True) +def no_sleep(monkeypatch): + monkeypatch.setattr(mixin_module.time, "sleep", lambda *_: None) + + +def make(cls, gui): + obj = object.__new__(cls) + obj.client = FakeClient(gui) + return obj + + +@pytest.mark.parametrize( + "cls, window_name, error", + [(LamNI, "lamni_tomo_params", LamNIError), (Flomni, "flomni_tomo_params", FlomniError)], +) +class TestTomoParametersWindow: + def test_opens_widget_in_own_window(self, cls, window_name, error): + gui = FakeGui() + make(cls, gui).tomo_parameters() + assert [name for name, _ in gui.new_calls] == [window_name] + widget, kwargs = gui.windows[window_name].new_calls[0] + assert widget == "OMNY_TomoParams" + assert kwargs["object_name"] == "tomo_params" + + def test_second_call_does_not_open_another(self, cls, window_name, error, capsys): + gui = FakeGui() + obj = make(cls, gui) + obj.tomo_parameters() + obj.tomo_parameters() + assert len(gui.new_calls) == 1 + assert len(gui.windows[window_name].new_calls) == 1 + assert "already open" in capsys.readouterr().out + + def test_window_without_widget_gets_the_widget(self, cls, window_name, error): + gui = FakeGui() + gui.new(window_name) # window exists, but holds no tomo_params widget + make(cls, gui).tomo_parameters() + assert len(gui.new_calls) == 1 # window reused, not created again + assert gui.windows[window_name].new_calls[0][0] == "OMNY_TomoParams" + + def test_raises_without_gui_and_names_cli(self, cls, window_name, error): + with pytest.raises(error, match="tomo_parameters_cli"): + make(cls, None).tomo_parameters() + + def test_raises_when_gui_fails_and_does_not_fall_back(self, cls, window_name, error): + obj = make(cls, FakeGui(fail_new=True)) + obj.tomo_parameters_cli = lambda: pytest.fail("must not fall back to the CLI") + with pytest.raises(error, match="tomo_parameters_cli") as exc_info: + obj.tomo_parameters() + assert isinstance(exc_info.value.__cause__, TimeoutError) + + +def test_cli_version_exists_and_gui_version_is_the_mixin_one(): + for cls in (LamNI, Flomni): + assert "tomo_parameters_cli" in vars(cls) + assert "tomo_parameters" not in vars(cls) diff --git a/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py index 2f4f77ac..bd1af2ec 100644 --- a/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py +++ b/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py @@ -56,7 +56,9 @@ def test_compute_fermat_positions_flomni_matches_scan_class_below_threshold(): count, min_positions = _compute_fermat_positions_flomni(params) assert min_positions == FlomniFermatScan.MIN_POSITIONS assert count < min_positions - expected = FlomniFermatScan.get_flomni_fermat_spiral_pos(-2.5, 2.5, -2.5, 2.5, step=2.0) + # _compute_fermat_positions_flomni() pins seed=0 for its own reproducibility -- + # match it here rather than drawing an independent random offset. + expected = FlomniFermatScan.get_flomni_fermat_spiral_pos(-2.5, 2.5, -2.5, 2.5, step=2.0, seed=0) assert count == len(expected) @@ -64,7 +66,9 @@ def test_compute_fermat_positions_flomni_matches_scan_class_above_threshold(): params = {"fovx": 100.0, "fovy": 80.0, "tomo_shellstep": 1.0} count, min_positions = _compute_fermat_positions_flomni(params) assert count >= min_positions - expected = FlomniFermatScan.get_flomni_fermat_spiral_pos(-50.0, 50.0, -40.0, 40.0, step=1.0) + expected = FlomniFermatScan.get_flomni_fermat_spiral_pos( + -50.0, 50.0, -40.0, 40.0, step=1.0, seed=0 + ) assert count == len(expected) diff --git a/tests/tests_bec_ipython_client/test_tomo_queue_reacquire.py b/tests/tests_bec_ipython_client/test_tomo_queue_reacquire.py index 2d1bf83c..679a35f7 100644 --- a/tests/tests_bec_ipython_client/test_tomo_queue_reacquire.py +++ b/tests/tests_bec_ipython_client/test_tomo_queue_reacquire.py @@ -191,7 +191,7 @@ def test_tomo_queue_reacquire_resets_later_jobs_to_pending_tomo_and_command(): i0 = lamni.tomo_queue_add(label="job0") i1 = lamni.tomo_queue_add(label="job1") - lamni.tomo_queue_add_command({"action": "move", "kwargs": {"positions": {"mokev": 6.2}}}) + lamni.tomo_queue_add_command({"action": "move", "kwargs": {"positions": {"ccm_energy": 6.2}}}) i3 = lamni.tomo_queue_add(label="job3") for i in (i0, i1, i3): @@ -213,7 +213,7 @@ def test_tomo_queue_reacquire_rejects_out_of_range_job_index(): def test_tomo_queue_reacquire_rejects_command_job(): lamni = make_lamni() - lamni.tomo_queue_add_command({"action": "move", "kwargs": {"positions": {"mokev": 6.2}}}) + lamni.tomo_queue_add_command({"action": "move", "kwargs": {"positions": {"ccm_energy": 6.2}}}) with pytest.raises(TomoQueueError): lamni.tomo_queue_reacquire(0, projection_number=0) @@ -258,3 +258,64 @@ def test_tomo_queue_reacquire_allows_targeting_the_incomplete_job_itself(): job = lamni._tomo_queue_proxy.as_list()[index] assert job["status"] == "incomplete" + + +# ── flomni tomo_type 4 (angles from file) ──────────────────────────────────── + + +def make_flomni_for_queue(): + """make_flomni() plus what tomo_queue_add()/tomo_queue_execute() touch + (ptycho_reconstruct_foldername setter, sample_name in the confirm prompt).""" + obj = make_flomni() + obj.reconstructor = types.SimpleNamespace(folder_name=None) + obj.sample_get_name = lambda position=0: "test" + return obj + + +def test_flomni_tomo_queue_reacquire_type4_bounds_by_file(tmp_path): + flomni = make_flomni_for_queue() + angle_file = tmp_path / "angles.txt" + angle_file.write_text("0\n10\n20\n") + flomni.tomo_type = 4 + flomni.tomo_angle_file = str(angle_file) + index = flomni.tomo_queue_add(label="job0") + flomni._tomo_queue_proxy.update(index, status="done") + + with pytest.raises(ValueError, match="< 3"): + flomni.tomo_queue_reacquire(index, projection_number=3) + + flomni.tomo_queue_reacquire(index, projection_number=2) + assert flomni.progress["projection"] == 2 + assert flomni._tomo_queue_proxy.as_list()[index]["status"] == "incomplete" + + +def test_flomni_queue_type1_job_with_stale_angle_file_still_runs(tmp_path): + """tomo_queue_execute() setattr()s every snapshotted param -- a dangling + tomo_angle_file must not break a job that doesn't use tomo_type 4.""" + flomni = make_flomni_for_queue() + flomni.tomo_type = 1 + flomni.tomo_angle_file = str(tmp_path / "deleted_later.txt") + flomni.tomo_queue_add(label="job0") + ran = [] + flomni.tomo_scan = lambda **kwargs: ran.append(flomni.tomo_type) + + flomni.tomo_queue_execute() + + assert ran == [1] + assert flomni._tomo_queue_proxy.as_list()[0]["status"] == "done" + + +def test_flomni_queue_jobs_keep_their_own_angle_file(tmp_path): + flomni = make_flomni_for_queue() + flomni.tomo_type = 4 + for name in ("a.txt", "b.txt"): + (tmp_path / name).write_text("0\n") + flomni.tomo_angle_file = str(tmp_path / name) + flomni.tomo_queue_add(label=name) + used = [] + flomni.tomo_scan = lambda **kwargs: used.append(flomni.tomo_angle_file) + + flomni.tomo_queue_execute() + + assert used == [str(tmp_path / "a.txt"), str(tmp_path / "b.txt")] + assert "angles=a.txt" in flomni._describe_tomo_job_line(flomni._tomo_queue_proxy.as_list()[0]) diff --git a/tests/tests_bec_ipython_client/test_webpage_password_hint.py b/tests/tests_bec_ipython_client/test_webpage_password_hint.py new file mode 100644 index 00000000..7d46da88 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_webpage_password_hint.py @@ -0,0 +1,35 @@ +"""The 'no web authentication' hint names the setup's own client object.""" + +import types + +import pytest + +from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_webpage_generator import ( + FlomniWebpageGenerator, +) +from csaxs_bec.bec_ipython_client.plugins.LamNI.LamNI_webpage_generator import ( + LamniWebpageGenerator, +) +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.webpage_generator_base import ( + WebpageGeneratorBase, +) + + +@pytest.mark.parametrize( + "cls, expected", + [ + (LamniWebpageGenerator, 'lamni.set_web_password("your_password")'), + (FlomniWebpageGenerator, 'flomni.set_web_password("your_password")'), + (WebpageGeneratorBase, 'run: set_web_password("your_password")'), + ], +) +def test_password_hint_uses_client_name(cls, expected, capsys): + gen = object.__new__(cls) + gen._session_query_url = "http://example.invalid/session.php" + gen._bec = types.SimpleNamespace(active_account="p12345") + gen._get_remote_session_user = lambda: None + gen._check_session_user() + out = capsys.readouterr().out + assert expected in out + if cls is not FlomniWebpageGenerator: + assert "flomni." not in out diff --git a/tests/tests_bec_ipython_client/test_webpage_projection_count.py b/tests/tests_bec_ipython_client/test_webpage_projection_count.py new file mode 100644 index 00000000..c74dc307 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_webpage_projection_count.py @@ -0,0 +1,108 @@ +"""The status webpage computes a job's total projection count in JS +(calcProjections) from a per-setup TOMO_TYPES table. + +For lamni's equally-spaced tomo_type 1 it used to hardcode flomni's +floor(180/step) * 8, so the page showed half of the real count (144 instead of +288 at a 10 degree step): lamni sweeps the full 360 degrees in every +sub-tomogram (N = int(360/step), see LamNI._tomo_type1_actual_grid()). + +There is no JS engine in the test environment, so this checks the data the JS +consumes (the TOMO_TYPES JSON rendered into the page) and that the JS uses +it, and cross-checks the formula against the real LamNI grid. +""" + +import json +import math +import re + +import pytest + +from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_webpage_generator import ( + FlomniWebpageGenerator, +) +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy +from csaxs_bec.bec_ipython_client.plugins.LamNI.LamNI_webpage_generator import ( + LamniWebpageGenerator, +) +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.webpage_generator_base import _render_html + + +class FakeClient: + """Minimal in-memory stand-in for BEC's global-var store.""" + + def __init__(self): + self._vars = {} + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + +def _rendered_tomo_types(generator_cls) -> dict: + html = _render_html( + [], generator_cls.HAS_TOMO_QUEUE, generator_cls.TOMO_TYPES, generator_cls.TQ_PARAM_DISPLAY + ) + match = re.search(r"const TOMO_TYPES = (\{.*?\});", html, re.S) + assert match, "TOMO_TYPES not found in the rendered page" + # JSON object keys are strings once serialized + return json.loads(match.group(1)) + + +def _js_expression_for_grid(generator_cls) -> str: + html = _render_html( + [], generator_cls.HAS_TOMO_QUEUE, generator_cls.TOMO_TYPES, generator_cls.TQ_PARAM_DISPLAY + ) + match = re.search(r"const N=Math\.floor\((.*?)/step\);", html) + assert match, "grid projection formula not found in the rendered page" + return match.group(1) + + +def test_page_js_uses_the_per_setup_base_angle(): + assert _js_expression_for_grid(LamniWebpageGenerator) == "(def.base_angle||180)" + + +def test_lamni_grid_type_declares_360_degree_sub_tomograms(): + types = _rendered_tomo_types(LamniWebpageGenerator) + assert types["1"] == {"kind": "equally_spaced_grid", "n_subtomos": 8, "base_angle": 360} + + +def test_flomni_grid_type_is_unchanged(): + """flomni's page must keep computing floor(180/step) * 8.""" + types = _rendered_tomo_types(FlomniWebpageGenerator) + assert types["1"] == {"kind": "equally_spaced_grid", "n_subtomos": 8} + assert "base_angle" not in types["1"] + + +@pytest.mark.parametrize("stepsize", [10.0, 7.0, 25.0, 12.5, 5.0]) +def test_page_projection_count_matches_the_real_lamni_grid(stepsize): + grid = LamniWebpageGenerator.TOMO_TYPES[1] + page_total = math.floor(grid.get("base_angle", 180) / stepsize) * grid["n_subtomos"] + + lamni = object.__new__(LamNI) + lamni.client = FakeClient() + lamni._progress_proxy = _ProgressProxy(lamni.client) + lamni.tomo_angle_stepsize = stepsize + _n, _step, real_total = lamni._tomo_type1_actual_grid() + + assert page_total == real_total + + +def test_lamni_10_degree_step_shows_288_not_144(): + grid = LamniWebpageGenerator.TOMO_TYPES[1] + assert math.floor(grid["base_angle"] / 10.0) * grid["n_subtomos"] == 288 + + +def test_lamni_param_table_only_shows_real_lamni_parameters(): + """Every row of the page's parameter table must be a parameter the + tomo queue actually stores for lamni (or the special hook row).""" + known = set(LamNI._TOMO_SCAN_PARAM_NAMES) | {"tomo_type"} + for key, _label in LamniWebpageGenerator.TQ_PARAM_DISPLAY: + assert key in known, f"{key!r} is not a lamni tomo-queue parameter" + + +def test_lamni_param_table_shows_the_scan_fov_and_manual_shift(): + keys = [key for key, _label in LamniWebpageGenerator.TQ_PARAM_DISPLAY] + for wanted in ("lamni_piezo_range_x", "lamni_piezo_range_y", "manual_shift_x", "manual_shift_y"): + assert wanted in keys diff --git a/tests/tests_bec_ipython_client/test_x_ray_eye_align.py b/tests/tests_bec_ipython_client/test_x_ray_eye_align.py index 9987ebe4..725088f5 100644 --- a/tests/tests_bec_ipython_client/test_x_ray_eye_align.py +++ b/tests/tests_bec_ipython_client/test_x_ray_eye_align.py @@ -5,7 +5,10 @@ import pytest from bec_lib.device import DeviceBase from csaxs_bec.bec_ipython_client.plugins.LamNI import LamNI -from csaxs_bec.bec_ipython_client.plugins.LamNI.x_ray_eye_align import XrayEyeAlign +from csaxs_bec.bec_ipython_client.plugins.LamNI.x_ray_eye_align import ( + SmearComposite, + XrayEyeAlign, +) # pylint: disable=unused-import @@ -47,6 +50,7 @@ def test_update_frame(bec_client_mock): client = bec_client_mock align = _make_align(client) align.lamni.xeyegui = mock.MagicMock() + align.lamni.xeyegui._is_deleted.return_value = False # GUI open dev_mock = mock.MagicMock() dev_mock.cam_xeye.live_mode_enabled.get.return_value = False @@ -67,6 +71,7 @@ def test_update_frame_keeps_shutter_open(bec_client_mock): client = bec_client_mock align = _make_align(client) align.lamni.xeyegui = mock.MagicMock() + align.lamni.xeyegui._is_deleted.return_value = False # GUI open dev_mock = mock.MagicMock() dev_mock.cam_xeye.live_mode_enabled.get.return_value = True @@ -158,6 +163,7 @@ def _make_calibration_align(client): """ align = _make_align(client) align.lamni.xeyegui = mock.MagicMock() + align.lamni.xeyegui._is_deleted.return_value = False # GUI open align.lamni.lfzp_in = mock.MagicMock() align.lamni.loptics_out = mock.MagicMock() align.lamni.losa_out = mock.MagicMock() @@ -190,6 +196,9 @@ def _make_calibration_align(client): # FZP reference click (step 0): (0, 0) px -> (0.0, 0.0) mm dev_mock.omny_xray_gui.xval_x_0.get.return_value = 0.0 dev_mock.omny_xray_gui.yval_y_0.get.return_value = 0.0 + # no arrow-button press pending + dev_mock.omny_xray_gui.mvx.get.return_value = 0 + dev_mock.omny_xray_gui.mvy.get.return_value = 0 return align, dev_mock @@ -329,6 +338,40 @@ def test_find_rotation_center_isolated_computes_midpoint(bec_client_mock): dev_mock.lsamy.update_user_parameter.assert_called_once_with({"center": 10.05}) +def test_find_rotation_center_isolated_subtracts_arrow_shifts(bec_client_mock): + """Arrow moves during the isolated calibration move lsamx/lsamy via + lamni_move_to_scan_center(angle=0.0); the shift active at each click is + mapped back to the configured-center frame before taking the midpoint.""" + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + + # same clicks as test_find_rotation_center_isolated_computes_midpoint + dev_mock.omny_xray_gui.xval_x_1.get.return_value = 300.0 + dev_mock.omny_xray_gui.yval_y_1.get.return_value = 300.0 + dev_mock.omny_xray_gui.xval_x_2.get.return_value = 500.0 + dev_mock.omny_xray_gui.yval_y_2.get.return_value = 700.0 + # one arrow press per step: +100 um in x at 0 deg, +50 um in y at 180 deg + dev_mock.omny_xray_gui.mvx.get.side_effect = [100, 0] + dev_mock.omny_xray_gui.mvy.get.side_effect = [0, 50] + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.umv"): + with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): + with mock.patch(f"{OMNY_GENERAL_TOOLS}.input", return_value="n"): + align.find_rotation_center(sample_type="isolated") + + calls = align.scans.lamni_move_to_scan_center.call_args_list + assert len(calls) == 4 # 2 arrow moves + 2 from _apply_rotation_center_shift + assert [c.kwargs["angle"] for c in calls] == [0.0] * 4 + assert (calls[0].kwargs["shift_x"], calls[0].kwargs["shift_y"]) == pytest.approx((0.1, 0.0)) + assert (calls[1].kwargs["shift_x"], calls[1].kwargs["shift_y"]) == pytest.approx((0.1, 0.05)) + # p0 (0.3,0.3) @ shift (0.1,0) -> ref (0.2,0.3); p180 (0.5,0.7) @ shift + # (0.1,0.05) -> ref (0.4,0.75); midpoint (0.3,0.525) -> target (-0.3,0.525) + for call in calls[2:]: + assert call.kwargs["shift_x"] == pytest.approx(-0.3) + assert call.kwargs["shift_y"] == pytest.approx(0.525) + + def test_find_rotation_center_isolated_declines_apply(bec_client_mock): client = bec_client_mock align, dev_mock = _make_calibration_align(client) @@ -377,6 +420,69 @@ def test_find_rotation_center_extended_iterates_until_happy(bec_client_mock): dev_mock.lsamy.update_user_parameter.assert_called_once() +def test_find_rotation_center_extended_locks_ellipse_angle(bec_client_mock): + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + dev_mock.omny_xray_gui.xval_x_1.get.return_value = 100.0 + dev_mock.omny_xray_gui.yval_y_1.get.return_value = 0.0 + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.umv"): + with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): + with mock.patch(f"{OMNY_GENERAL_TOOLS}.input", side_effect=["y", "y"]): + align.find_rotation_center(sample_type="extended") + + gui = align.lamni.xeyegui + gui.lock_roi_angle.assert_called_once_with(-76.0) + assert gui.enable_ellipse_roi.call_args_list == [mock.call(True), mock.call(False)] + + +def test_find_rotation_center_isolated_offers_no_ellipse(bec_client_mock): + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + for k in (1, 2): + getattr(dev_mock.omny_xray_gui, f"xval_x_{k}").get.return_value = 100.0 * k + getattr(dev_mock.omny_xray_gui, f"yval_y_{k}").get.return_value = 100.0 * k + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.umv"): + with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): + with mock.patch(f"{OMNY_GENERAL_TOOLS}.input", return_value="y"): + align.find_rotation_center(sample_type="isolated") + + gui = align.lamni.xeyegui + gui.enable_ellipse_roi.assert_not_called() + gui.lock_roi_angle.assert_not_called() + + +def test_align_offers_free_ellipse_after_fzp_click_and_removes_it_on_abort(bec_client_mock): + """The 8-angle alignment enables the ellipse (angle not locked) only after + the FZP click, and switches it off again even when the run is aborted.""" + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + gui = align.lamni.xeyegui + sent = [] + align.send_message = sent.append + for k in (0, 1): + for sig in ("xval_x", "yval_y", "width_x", "width_y"): + getattr(dev_mock.omny_xray_gui, f"{sig}_{k}").get.return_value = 10.0 + # abort while handling the first sample click (k=1) + align.scans.lamni_move_to_scan_center.side_effect = KeyboardInterrupt + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.umv"): + with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): + with pytest.raises(KeyboardInterrupt): + align._align_impl() + + assert gui.enable_ellipse_roi.call_args_list == [mock.call(True), mock.call(False)] + gui.lock_roi_angle.assert_not_called() + # not yet offered while the FZP centre was requested + assert sent.index("Submit centre of FZP.") < sent.index( + "Find the sample and submit its centre." + ) + + # ---------------------------------------------------------------------- # EXPERIMENTAL: rotation-center smear mode (_smear_sweep) # ---------------------------------------------------------------------- @@ -415,7 +521,7 @@ def test_smear_sweep_max_projection(bec_client_mock): with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): with mock.patch(f"{XRAY_EYE_ALIGN}.mv", return_value=report): with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): - composite = align._smear_sweep() + composite = align._smear_sweep(projection="max") expected = np.maximum(np.maximum(frame_a, frame_b), frame_c) assert np.array_equal(composite, expected) @@ -432,6 +538,83 @@ def test_smear_sweep_max_projection(bec_client_mock): assert [c.args[0] for c in align.gui.set_smear_active.call_args_list] == [True, False] +def test_smear_sweep_defaults_to_min_projection(bec_client_mock): + # absorption contrast: dark features on a bright background must survive + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + + frame_a = np.array([[1, 5], [3, 2]], dtype=np.uint8) + frame_b = np.array([[4, 2], [3, 6]], dtype=np.uint8) + frame_c = np.array([[0, 1], [9, 0]], dtype=np.uint8) + dev_mock.cam_xeye.get_last_image.side_effect = [frame_a, frame_b, frame_c] + report = _report_with_status_sequence("RUNNING", "RUNNING", "RUNNING", "COMPLETED") + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.mv", return_value=report): + with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): + composite = align._smear_sweep(opaque_level=0) + + expected = np.minimum(np.minimum(frame_a, frame_b), frame_c) + assert np.array_equal(composite, expected) + + +def test_smear_sweep_rejects_unknown_projection(bec_client_mock): + client = bec_client_mock + align, dev_mock = _make_calibration_align(client) + + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): + with mock.patch(f"{XRAY_EYE_ALIGN}.mv") as mv_mock: + with pytest.raises(ValueError): + align._smear_sweep(projection="mean") + + # rejected before anything moved + mv_mock.assert_not_called() + + +def test_smear_composite_min_skips_opaque_holder(): + # bright background 200, absorbing particle 100, opaque holder 10 + frame_a = np.array([[200, 100], [200, 200]], dtype=np.uint8) # particle at (0, 1) + frame_b = np.array([[10, 200], [200, 200]], dtype=np.uint8) # holder at (0, 0) + frame_c = np.array([[200, 200], [10, 10]], dtype=np.uint8) # holder on bottom row + + comp = SmearComposite("min") + for frame in (frame_a, frame_b, frame_c): + comp.add(frame) + + # holder ignored where the pixel was open at another angle; particle kept + assert np.array_equal(comp.image(), np.array([[200, 100], [200, 200]], dtype=np.uint8)) + assert comp.image().dtype == np.uint8 + + +def test_smear_composite_min_always_opaque_pixel_stays_dark(): + frame_a = np.array([[10, 200], [200, 200]], dtype=np.uint8) + frame_b = np.array([[5, 200], [200, 200]], dtype=np.uint8) + + comp = SmearComposite("min") + comp.add(frame_a) + comp.add(frame_b) + + # never open -> plain minimum + assert comp.image()[0, 0] == 5 + + +def test_smear_composite_opaque_level_zero_is_plain_min(): + frames = [ + np.array([[200, 100], [200, 200]], dtype=np.uint8), + np.array([[10, 200], [200, 200]], dtype=np.uint8), + ] + comp = SmearComposite("min", opaque_level=0) + for frame in frames: + comp.add(frame) + + assert np.array_equal(comp.image(), np.minimum(*frames)) + + +def test_smear_composite_rejects_bad_opaque_level(): + with pytest.raises(ValueError): + SmearComposite("min", opaque_level=1.0) + + def test_smear_sweep_computes_capture_fps(bec_client_mock): client = bec_client_mock align, dev_mock = _make_calibration_align(client) @@ -453,11 +636,16 @@ def test_smear_sweep_computes_capture_fps(bec_client_mock): clock["t"] += 0.05 return clock["t"] + # Replace the module's `time` rather than patching time.time globally: + # background threads left running by other tests (e.g. EPICS CA + # callbacks) would otherwise also advance the fake clock. + fake_time_module = mock.MagicMock() + fake_time_module.time.side_effect = fake_time + with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock): with mock.patch(f"{XRAY_EYE_ALIGN}.mv", return_value=report): - with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"): - with mock.patch(f"{XRAY_EYE_ALIGN}.time.time", side_effect=fake_time): - align._smear_sweep() + with mock.patch(f"{XRAY_EYE_ALIGN}.time", fake_time_module): + align._smear_sweep(opaque_level=0) assert align.last_smear_fps == pytest.approx(10.0) @@ -586,4 +774,60 @@ def test_find_rotation_center_smear_experimental_saves_composites_and_clicks(bec save_mock.assert_called_once() _, kwargs = save_mock.call_args assert kwargs["fzp_xy"] == (0.0, 0.0) - assert kwargs["sweep_deg"] == 45.0 \ No newline at end of file + assert kwargs["sweep_deg"] == 45.0 + +# ── align() must always leave the GUI clean ───────────────────────────────── +# +# Only KeyboardInterrupt (handled by the caller) used to reset the GUI; any +# other exception (a GUI RPC error, a missing GUI output key, ...) left DAP +# parameter forwarding on and the alignment crosshair drawn. Mirrors flomni's +# XrayEyeAlign.align() try/finally. + + +def _bare_aligner(): + aligner = object.__new__(XrayEyeAlign) + # .gui is a read-only property returning self.lamni.xeyegui + aligner.lamni = mock.MagicMock() + return aligner + + +def test_align_cleans_up_gui_after_success(): + aligner = _bare_aligner() + aligner._align_impl = mock.MagicMock() + + aligner.align(keep_shutter_open=True) + + aligner._align_impl.assert_called_once_with(True) + aligner.lamni.xeyegui.set_dap_params_forwarding.assert_called_with(False) + aligner.lamni.xeyegui.hide_crosshair.assert_called_once() + + +def test_align_cleans_up_gui_after_any_exception_and_reraises(): + aligner = _bare_aligner() + aligner._align_impl = mock.MagicMock(side_effect=KeyError("gui output key missing")) + + with pytest.raises(KeyError): + aligner.align() + + aligner.lamni.xeyegui.set_dap_params_forwarding.assert_called_with(False) + aligner.lamni.xeyegui.hide_crosshair.assert_called_once() + + +def test_align_cleanup_failures_do_not_mask_the_original_error(): + aligner = _bare_aligner() + aligner._align_impl = mock.MagicMock(side_effect=ValueError("real problem")) + aligner.lamni.xeyegui.set_dap_params_forwarding.side_effect = RuntimeError("GUI gone") + aligner.lamni.xeyegui.hide_crosshair.side_effect = RuntimeError("GUI gone") + + with pytest.raises(ValueError, match="real problem"): + aligner.align() + + +def test_align_survives_a_gui_without_reset_zoom(): + aligner = _bare_aligner() + aligner.lamni.xeyegui.reset_zoom.side_effect = AttributeError("no reset_zoom") + aligner._align_impl = mock.MagicMock() + + aligner.align() + + aligner._align_impl.assert_called_once_with(False) diff --git a/tests/tests_bec_widgets/test_console_buttons_hard_stop.py b/tests/tests_bec_widgets/test_console_buttons_hard_stop.py new file mode 100644 index 00000000..16cd74e3 --- /dev/null +++ b/tests/tests_bec_widgets/test_console_buttons_hard_stop.py @@ -0,0 +1,214 @@ +"""Tests for the hard-stop logic of z_ConsoleButtonsWidget: every configured +Galil controller gets a stop (one thread each, configured order in the +result), a device that can't be resolved or a controller that fails is +reported instead of silently skipped, and one failure never prevents the +other stops. + +The stop helpers are plain methods (no Qt slots), so they are exercised on a +minimal stub instead of constructing the full widget, which needs a live BEC +client context via get_bec_shortcuts() -- the same approach as +test_x_ray_eye_vertical_lock.py. No hardware or BEC service is contacted. +""" + +import threading +from unittest import mock + +import pytest + +from csaxs_bec.bec_widgets.widgets.console_buttons.console_buttons import z_ConsoleButtonsWidget + +Widget = z_ConsoleButtonsWidget + + +class _StubConsole: + """Minimal stand-in for z_ConsoleButtonsWidget, isolated to the + attributes the hard-stop helpers touch.""" + + _RESOLVE_ATTEMPTS = 3 + _RESOLVE_DELAY_S = 0.0 + _STOP_JOIN_TIMEOUT_S = 5.0 + + _resolve_controller = Widget._resolve_controller + _stop_controller = Widget._stop_controller + _stop_all_controllers = Widget._stop_all_controllers + _restore_positioning_mode = Widget._restore_positioning_mode + + +def _make_stub(devices: dict, names: list[str]): + stub = _StubConsole() + stub._hard_stop_device_names = names + stub.client = mock.Mock() + stub.client.device_manager.devices = devices + return stub + + +def _device(controller): + return mock.Mock(controller=controller) + + +def _controller(stop_reply=":"): + ctrl = mock.Mock() + ctrl.stop_all_axes.return_value = stop_reply + ctrl.socket_put_and_receive.return_value = ":" + return ctrl + + +# ---------------------------------------------------------------- name list + + +def test_collect_names_prefers_new_list_and_drops_duplicates(): + assert Widget._collect_hard_stop_names(["a", "b", "a", None, ""], "x", "y") == ["a", "b"] + + +def test_collect_names_falls_back_to_legacy_pair(): + assert Widget._collect_hard_stop_names(None, "ftransy", "foptx") == ["ftransy", "foptx"] + assert Widget._collect_hard_stop_names(None, "ftransy", None) == ["ftransy"] + assert Widget._collect_hard_stop_names(None, None, None) == [] + + +def test_collect_names_accepts_a_single_string(): + assert Widget._collect_hard_stop_names("ftransy") == ["ftransy"] + + +# ---------------------------------------------------------------- resolving + + +def test_resolve_returns_controller(): + ctrl = _controller() + stub = _make_stub({"ftransy": _device(ctrl)}, ["ftransy"]) + assert stub._resolve_controller("ftransy") is ctrl + + +def test_resolve_reports_missing_device_after_retries(): + devices = mock.MagicMock() + devices.get.return_value = None + stub = _make_stub(devices, ["ftransy"]) + + with pytest.raises(RuntimeError, match="not found"): + stub._resolve_controller("ftransy") + assert devices.get.call_count == _StubConsole._RESOLVE_ATTEMPTS + + +def test_resolve_retries_after_transient_lookup_error(): + ctrl = _controller() + devices = mock.MagicMock() + devices.get.side_effect = [Exception("container being reloaded"), _device(ctrl)] + stub = _make_stub(devices, ["ftransy"]) + + assert stub._resolve_controller("ftransy") is ctrl + + +# ---------------------------------------------------------------- one stop + + +def test_stop_controller_uses_stop_all_axes(): + ctrl = _controller() + stub = _make_stub({"ftransy": _device(ctrl)}, ["ftransy"]) + + assert stub._stop_controller("ftransy") == (True, ":") + ctrl.stop_all_axes.assert_called_once_with() + ctrl.socket_put_and_receive.assert_not_called() + + +def test_stop_controller_falls_back_to_raw_command_when_not_exposed(): + ctrl = mock.Mock(spec=["socket_put_and_receive"]) # no stop_all_axes attribute + ctrl.socket_put_and_receive.return_value = ":" + stub = _make_stub({"ftransy": _device(ctrl)}, ["ftransy"]) + + assert stub._stop_controller("ftransy") == (True, ":") + ctrl.socket_put_and_receive.assert_called_once_with("XQ#STOP,1") + + +def test_stop_controller_falls_back_to_raw_command_when_rpc_call_fails(): + ctrl = _controller() + ctrl.stop_all_axes.side_effect = TimeoutError("rpc timeout") + stub = _make_stub({"ftransy": _device(ctrl)}, ["ftransy"]) + + assert stub._stop_controller("ftransy") == (True, ":") + ctrl.socket_put_and_receive.assert_called_once_with("XQ#STOP,1") + + +def test_stop_controller_reports_unresolved_device(): + devices = mock.MagicMock() + devices.get.return_value = None + stub = _make_stub(devices, ["fsamroy"]) + + ok, detail = stub._stop_controller("fsamroy") + assert not ok + assert "not resolved" in detail + + +def test_stop_controller_reports_total_failure(): + ctrl = _controller() + ctrl.stop_all_axes.side_effect = RuntimeError("boom") + ctrl.socket_put_and_receive.side_effect = RuntimeError("socket dead") + stub = _make_stub({"ftransy": _device(ctrl)}, ["ftransy"]) + + ok, detail = stub._stop_controller("ftransy") + assert not ok + assert "socket dead" in detail + + +# ---------------------------------------------------------------- all stops + + +def test_stop_all_controllers_stops_each_and_keeps_configured_order(): + ctrls = {name: _controller() for name in ("ftransy", "foptx", "fsamroy")} + devices = {name: _device(ctrl) for name, ctrl in ctrls.items()} + stub = _make_stub(devices, ["ftransy", "foptx", "fsamroy"]) + + results = stub._stop_all_controllers() + + assert [name for name, _, _ in results] == ["ftransy", "foptx", "fsamroy"] + assert all(ok for _, ok, _ in results) + for ctrl in ctrls.values(): + ctrl.stop_all_axes.assert_called_once_with() + + +def test_one_unresolved_device_does_not_prevent_the_other_stops(): + ctrls = {name: _controller() for name in ("ftransy", "fsamroy")} + devices = mock.MagicMock() + devices.get.side_effect = lambda name: _device(ctrls[name]) if name in ctrls else None + stub = _make_stub(devices, ["ftransy", "foptx", "fsamroy"]) + + results = {name: ok for name, ok, _ in stub._stop_all_controllers()} + + assert results == {"ftransy": True, "foptx": False, "fsamroy": True} + ctrls["ftransy"].stop_all_axes.assert_called_once_with() + ctrls["fsamroy"].stop_all_axes.assert_called_once_with() + + +def test_hung_controller_does_not_delay_the_others(): + release = threading.Event() + hung = _controller() + hung.stop_all_axes.side_effect = lambda: release.wait(5) and ":" + fast = {name: _controller() for name in ("foptx", "fsamroy")} + devices = {"ftransy": _device(hung), **{n: _device(c) for n, c in fast.items()}} + stub = _make_stub(devices, ["ftransy", "foptx", "fsamroy"]) + stub._STOP_JOIN_TIMEOUT_S = 0.5 + + try: + results = {name: (ok, detail) for name, ok, detail in stub._stop_all_controllers()} + finally: + release.set() + + assert results["foptx"][0] and results["fsamroy"][0] + assert not results["ftransy"][0] + assert "no answer" in results["ftransy"][1] + for ctrl in fast.values(): + ctrl.stop_all_axes.assert_called_once_with() + + +# ---------------------------------------------------------------- restore + + +def test_restore_positioning_mode_success_and_failure(): + ctrl = _controller() + stub = _make_stub({"ftransy": _device(ctrl)}, ["ftransy"]) + assert stub._restore_positioning_mode("ftransy") == (True, "") + ctrl.hard_abort_and_restore_positioning_mode.assert_called_once_with() + + ctrl.hard_abort_and_restore_positioning_mode.side_effect = RuntimeError("still in mount mode") + ok, detail = stub._restore_positioning_mode("ftransy") + assert not ok + assert "still in mount mode" in detail diff --git a/tests/tests_bec_widgets/test_tomo_params_angle_file.py b/tests/tests_bec_widgets/test_tomo_params_angle_file.py new file mode 100644 index 00000000..1a8771ba --- /dev/null +++ b/tests/tests_bec_widgets/test_tomo_params_angle_file.py @@ -0,0 +1,190 @@ +"""Tests for the TomoParams widget's tomo_type 4 (angles from file) support: +per-setup type availability, validation, tooltip gating, and the type-4 +section's live file preview. + +The full OMNY_TomoParams widget needs a live BEC client, so these exercise +its methods on minimal stubs, same approach as test_x_ray_eye_vertical_lock.py. +""" + +import os +import types + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from qtpy.QtWidgets import QApplication, QLineEdit # noqa: E402 + +from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import ( # noqa: E402 + DEFAULTS, + SETUP_PROFILES, + OMNY_TomoParams, + _irrelevant_params_for_type, +) + + +@pytest.fixture(scope="module") +def qapp(): + return QApplication.instance() or QApplication([]) + + +def _valid_flomni_params(**overrides): + params = dict(DEFAULTS) + params.update(overrides) + return params + + +def _validate(setup, params): + stub = types.SimpleNamespace(_profile=SETUP_PROFILES[setup]) + return OMNY_TomoParams._validate(stub, params) + + +def test_type_4_available_for_flomni_and_lamni(): + assert 4 in SETUP_PROFILES["flomni"]["tomo_types"] + assert 4 in SETUP_PROFILES["lamni"]["tomo_types"] + assert "tomo_angle_file" in SETUP_PROFILES["lamni"]["param_names"] + assert "tomo_angle_file" in SETUP_PROFILES["lamni"]["defaults"] + + +def test_validate_accepts_type_4_with_good_file(tmp_path): + angle_file = tmp_path / "angles.txt" + angle_file.write_text("0\n90 2\n") + params = _valid_flomni_params(tomo_type=4, tomo_angle_file=str(angle_file), fovx=40.0) + assert _validate("flomni", params) is None + + +@pytest.mark.parametrize("content", [None, "400\n", "# nothing\n"]) +def test_validate_rejects_type_4_with_bad_file(tmp_path, content): + angle_file = tmp_path / "angles.txt" + if content is not None: + angle_file.write_text(content) + params = _valid_flomni_params(tomo_type=4, tomo_angle_file=str(angle_file), fovx=40.0) + error = _validate("flomni", params) + assert error is not None and error.startswith("Angle file:") + + +def test_validate_ignores_bad_angle_file_for_other_types(tmp_path): + params = _valid_flomni_params( + tomo_type=1, tomo_angle_file=str(tmp_path / "gone.txt"), fovx=40.0 + ) + assert _validate("flomni", params) is None + + +def test_validate_type_4_on_lamni(tmp_path): + params = dict(SETUP_PROFILES["lamni"]["defaults"]) + params["tomo_type"] = 4 + params["tomo_angle_file"] = str(tmp_path / "gone.txt") + assert _validate("lamni", params).startswith("Angle file:") + angle_file = tmp_path / "angles.txt" + angle_file.write_text("0\n180\n") + params["tomo_angle_file"] = str(angle_file) + assert _validate("lamni", params) is None + params["tomo_type"] = 5 + assert "tomo_type must be one of 1, 2, 3, 4" in _validate("lamni", params) + + +def test_tooltip_gating_for_type_4(): + irrelevant = _irrelevant_params_for_type(4) + assert "tomo_angle_file" not in irrelevant + assert "golden_projections_at_0_deg_for_damage_estimation" in irrelevant + assert "golden_max_number_of_projections" in irrelevant + assert "golden_ratio_bunch_size" in irrelevant + assert "tomo_angle_range" in irrelevant + for other in (1, 2, 3): + assert "tomo_angle_file" in _irrelevant_params_for_type(other) + + +class _StubTomoParams: + """Just enough of OMNY_TomoParams to build and drive the type-4 section.""" + + _build_type4_section = OMNY_TomoParams._build_type4_section + _update_angle_file_preview = OMNY_TomoParams._update_angle_file_preview + _set_angle_file_summary = OMNY_TomoParams._set_angle_file_summary + _browse_angle_file = OMNY_TomoParams._browse_angle_file + + def __init__(self, setup="flomni"): + self._profile = SETUP_PROFILES[setup] + self._pw = {} + self._sec_type4 = self._build_type4_section() + + +def test_type4_section_preview_tracks_file_content(qapp, tmp_path): + stub = _StubTomoParams() + assert isinstance(stub._pw["tomo_angle_file"], QLineEdit) + + angle_file = tmp_path / "angles.txt" + angle_file.write_text("10 1\n100 2\nEND\n") + stub._pw["tomo_angle_file"].setText(str(angle_file)) + assert stub._lbl_angle_file_summary.text() == "2 angles, 10.00-100.00 deg, 2 sub-tomograms" + assert "red" not in stub._lbl_angle_file_summary.styleSheet() + + angle_file.write_text("10\n190\n") + stub._update_angle_file_preview() + assert "180 deg apart" in stub._lbl_angle_file_summary.text() + assert "orange" in stub._lbl_angle_file_summary.styleSheet() + + stub._pw["tomo_angle_file"].setText(str(tmp_path / "missing.txt")) + assert "Cannot read" in stub._lbl_angle_file_summary.text() + assert "red" in stub._lbl_angle_file_summary.styleSheet() + + +def test_type4_preview_has_no_180_deg_warning_on_lamni(qapp, tmp_path): + stub = _StubTomoParams("lamni") + angle_file = tmp_path / "angles.txt" + angle_file.write_text("10\n190\nEND\n") + stub._pw["tomo_angle_file"].setText(str(angle_file)) + assert "180 deg apart" not in stub._lbl_angle_file_summary.text() + assert "orange" not in stub._lbl_angle_file_summary.styleSheet() + + +def test_type4_section_preview_grows_minimum_height_for_wrapped_content(qapp, tmp_path): + """QFormLayout sizes the Content row from the label's sizeHint() at + construction time (still holding the short '---' placeholder) and + doesn't reliably recompute it later when setText() swaps in longer, + word-wrapped text -- a known Qt quirk. _set_angle_file_summary() must + grow the label's minimum height to compensate, or multi-line content + (e.g. a 180-deg-pair or missing-END warning) gets visually clipped.""" + stub = _StubTomoParams() + initial_height = stub._lbl_angle_file_summary.minimumHeight() + + angle_file = tmp_path / "angles.txt" + angle_file.write_text("10\n190\n") # triggers the 180-deg-pair warning line + stub._pw["tomo_angle_file"].setText(str(angle_file)) + + assert stub._lbl_angle_file_summary.minimumHeight() > initial_height + assert stub._lbl_angle_file_summary.minimumHeight() >= ( + stub._lbl_angle_file_summary.heightForWidth(stub._lbl_angle_file_summary.width()) + ) + + +def test_type4_section_preview_flags_missing_end_marker(qapp, tmp_path): + stub = _StubTomoParams() + + angle_file = tmp_path / "angles.txt" + angle_file.write_text("10 1\n100 2\n") + stub._pw["tomo_angle_file"].setText(str(angle_file)) + assert "No 'END' marker" in stub._lbl_angle_file_summary.text() + assert "orange" in stub._lbl_angle_file_summary.styleSheet() + + angle_file.write_text("10 1\n100 2\nEND\n") + stub._update_angle_file_preview() + assert "No 'END' marker" not in stub._lbl_angle_file_summary.text() + + +def test_browse_sets_selected_path(qapp, tmp_path, monkeypatch): + stub = _StubTomoParams() + chosen = str(tmp_path / "picked.txt") + seen = {} + + def fake_dialog(parent, caption, start_dir, filters): + seen["start_dir"] = start_dir + return chosen, filters + + monkeypatch.setattr( + "csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params.QFileDialog.getOpenFileName", + fake_dialog, + ) + stub._pw["tomo_angle_file"].setText(str(tmp_path / "old.txt")) + stub._browse_angle_file() + assert seen["start_dir"] == str(tmp_path) + assert stub._pw["tomo_angle_file"].text() == chosen diff --git a/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py b/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py new file mode 100644 index 00000000..e07ece3b --- /dev/null +++ b/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py @@ -0,0 +1,130 @@ +"""Tests for OMNY_XRayEye's elliptical ROI (LamNI rotation-centre calibration): +rotation-aware geometry, angle lock, vertical snap, and the submit payload. + +Like test_x_ray_eye_vertical_lock.py, the widget methods are borrowed onto a +minimal stub (the full widget needs a live BEC client context). The ROIs +themselves are pyqtgraph ellipses; ``EllipticalROI`` is only needed for the +``isinstance`` dispatch, so a bare subclass of ``pg.EllipseROI`` that is +registered as one is used instead of the BECConnector-based real class. +""" + +import math +from unittest import mock + +import pyqtgraph as pg +import pytest + +from csaxs_bec.bec_widgets.widgets.xray_eye import x_ray_eye +from csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye import ( + OMNY_XRayEye, + _normalize_angle, + ellipse_geometry, +) + + +@pytest.fixture(autouse=True, scope="module") +def _qapp(): + return pg.mkQApp() + + +class _FakeEllipticalROI(pg.EllipseROI): + """pg ellipse standing in for bec_widgets' EllipticalROI in isinstance checks.""" + + +@pytest.fixture(autouse=True) +def _patch_roi_class(): + with mock.patch.object(x_ray_eye, "EllipticalROI", _FakeEllipticalROI): + yield + + +def _roi(pos=(10.0, 20.0), size=(40.0, 20.0), angle=0.0): + roi = _FakeEllipticalROI(pos, size, angle=angle) + return roi + + +class _Stub: + _apply_locked_angle = OMNY_XRayEye._apply_locked_angle + _set_rotate_handle_visible = staticmethod(OMNY_XRayEye._set_rotate_handle_visible) + _snap_roi_vertical = OMNY_XRayEye._snap_roi_vertical + + +def _stub(locked=None, lock_vertical=False, fzp_y=100.0): + stub = _Stub() + stub._roi_angle_locked = locked + stub._vertical_lock_enabled = lock_vertical + stub.target_crosshair = mock.Mock() + stub.target_crosshair.is_visible.return_value = True + stub.crosshair_position = mock.Mock(return_value=(0.0, fzp_y)) + return stub + + +@pytest.mark.parametrize("raw, expected", [(0, 0), (90, 90), (-90, 90), (100, -80), (270, 90), (45, 45)]) +def test_normalize_angle(raw, expected): + assert _normalize_angle(raw) == pytest.approx(expected) + + +def test_geometry_unrotated(): + geo = ellipse_geometry(_roi()) + assert (geo["center_x"], geo["center_y"]) == pytest.approx((30.0, 30.0)) + assert (geo["width"], geo["height"], geo["angle"]) == pytest.approx((40.0, 20.0, 0.0)) + + +def test_geometry_centre_is_independent_of_rotation_about_centre(): + roi = _roi() + roi.setAngle(30.0, center=(0.5, 0.5)) + geo = ellipse_geometry(roi) + assert (geo["center_x"], geo["center_y"]) == pytest.approx((30.0, 30.0)) + assert geo["angle"] == pytest.approx(30.0) + # axes are not sorted by size: width stays the local-x axis + assert (geo["width"], geo["height"]) == pytest.approx((40.0, 20.0)) + + +def test_geometry_handles_negative_size_from_up_left_drag(): + geo = ellipse_geometry(_roi(pos=(50.0, 50.0), size=(-40.0, -20.0))) + assert (geo["center_x"], geo["center_y"]) == pytest.approx((30.0, 40.0)) + assert (geo["width"], geo["height"]) == pytest.approx((40.0, 20.0)) + + +def test_apply_locked_angle_rotates_about_centre(): + roi = _roi() + _stub(locked=40.0)._apply_locked_angle(roi) + geo = ellipse_geometry(roi) + assert geo["angle"] == pytest.approx(40.0) + assert (geo["center_x"], geo["center_y"]) == pytest.approx((30.0, 30.0)) + + +def test_apply_locked_angle_noop_when_free_or_already_at_angle(): + roi = _roi(angle=25.0) + _stub(locked=None)._apply_locked_angle(roi) + assert roi.angle() == pytest.approx(25.0) + with mock.patch.object(roi, "setAngle") as set_angle: + _stub(locked=25.0)._apply_locked_angle(roi) + # equivalent angle mod 180 deg is also "already there" + _stub(locked=-155.0)._apply_locked_angle(roi) + set_angle.assert_not_called() + + +def test_rotate_handle_visibility(): + roi = _roi() + rotate = [h["item"] for h in roi.handles if h["type"] == "r"] + assert len(rotate) == 1 + _Stub._set_rotate_handle_visible(roi, False) + assert rotate[0].isVisible() is False + _Stub._set_rotate_handle_visible(roi, True) + assert rotate[0].isVisible() is True + + +def test_snap_vertical_moves_ellipse_centre_to_crosshair_when_rotated(): + roi = _roi() + roi.setAngle(30.0, center=(0.5, 0.5)) + _stub(lock_vertical=True, fzp_y=100.0)._snap_roi_vertical(roi) + geo = ellipse_geometry(roi) + assert geo["center_y"] == pytest.approx(100.0) + assert geo["center_x"] == pytest.approx(30.0) + assert geo["angle"] == pytest.approx(30.0) + + +def test_snap_vertical_noop_when_lock_off(): + roi = _roi() + _stub(lock_vertical=False)._snap_roi_vertical(roi) + assert ellipse_geometry(roi)["center_y"] == pytest.approx(30.0) diff --git a/tests/tests_bec_widgets/test_x_ray_eye_skip_button.py b/tests/tests_bec_widgets/test_x_ray_eye_skip_button.py new file mode 100644 index 00000000..14b6fda2 --- /dev/null +++ b/tests/tests_bec_widgets/test_x_ray_eye_skip_button.py @@ -0,0 +1,105 @@ +"""Tests for the Skip button of OMNY_XRayEye (see docs/plans/flomni-xrayeye-skip-button.md): +it is disabled by default (LamNI/omny never enable it), only clickable while the client +allows a skip *and* the submit button is enabled, and a click signals ``submit = -1`` +without needing an ROI. + +The slots are exercised on a minimal stub instead of the full widget, which needs a live BEC +client context via get_bec_shortcuts() -- same approach as test_x_ray_eye_vertical_lock.py. +""" + +from unittest import mock + +from csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye import OMNY_XRayEye + + +class _Button: + def __init__(self, enabled=False): + self._enabled = enabled + self._visible = False + + def setVisible(self, visible): + self._visible = bool(visible) + + def isVisible(self): + return self._visible + + def setEnabled(self, enabled): + self._enabled = bool(enabled) + + def isEnabled(self): + return self._enabled + + +class _StubXRayEye: + enable_submit_button = OMNY_XRayEye.enable_submit_button + enable_skip_button = OMNY_XRayEye.enable_skip_button + _refresh_skip_button = OMNY_XRayEye._refresh_skip_button + skip = OMNY_XRayEye.skip + + +def _make_stub(): + stub = _StubXRayEye() + stub._skip_enabled = False + stub.submit_button = _Button(enabled=False) + stub.skip_button = _Button(enabled=False) + stub.dev = mock.Mock() + return stub + + +def test_skip_button_stays_disabled_unless_the_client_enables_it(): + stub = _make_stub() + + stub.enable_submit_button(True) # what LamNI/omny do + + assert stub.submit_button.isEnabled() + assert not stub.skip_button.isEnabled() + assert not stub.skip_button.isVisible() + + +def test_skip_button_appears_on_the_first_enable_call(): + stub = _make_stub() + + stub.enable_skip_button(False) # flomni does this at the start of a run + + assert stub.skip_button.isVisible() + assert not stub.skip_button.isEnabled() + + +def test_skip_button_follows_submit_button_once_enabled(): + stub = _make_stub() + stub.enable_skip_button(True) + assert not stub.skip_button.isEnabled() # submit still disabled ("please wait ...") + + stub.enable_submit_button(True) + assert stub.skip_button.isEnabled() + + stub.enable_submit_button(False) + assert not stub.skip_button.isEnabled() + + stub.enable_submit_button(True) # next step is ready again + assert stub.skip_button.isEnabled() + + +def test_skip_button_can_be_withdrawn_while_submit_stays_enabled(): + stub = _make_stub() + stub.enable_skip_button(True) + stub.enable_submit_button(True) + + stub.enable_skip_button(False) + + assert stub.submit_button.isEnabled() + assert not stub.skip_button.isEnabled() + + +def test_skip_click_signals_minus_one_and_blocks_double_click(): + stub = _make_stub() + stub.enable_skip_button(True) + stub.enable_submit_button(True) + + stub.skip() + + stub.dev.omny_xray_gui.submit.set.assert_called_once_with(-1) + assert not stub.skip_button.isEnabled() + # the client re-enables the buttons for the next step + stub.enable_submit_button(True) + assert stub.skip_button.isEnabled() diff --git a/tests/tests_bec_widgets/test_x_ray_eye_vertical_lock.py b/tests/tests_bec_widgets/test_x_ray_eye_vertical_lock.py new file mode 100644 index 00000000..49925018 --- /dev/null +++ b/tests/tests_bec_widgets/test_x_ray_eye_vertical_lock.py @@ -0,0 +1,102 @@ +"""Tests for OMNY_XRayEye's ROI-vertical-lock feature (see +docs/plans/xrayeye-vertical-lock.md): _snap_roi_vertical() snaps a selection +box's vertical center to the crosshair's fixed y position while leaving its +x position and height/diameter untouched, but only while the lock is enabled +and the crosshair is visible. + +_snap_roi_vertical is a plain (non-Qt-slot-decorated) method, so it can be +exercised directly on a minimal stub without constructing the full +OMNY_XRayEye widget (which needs a live BEC client context via +get_bec_shortcuts()). +""" + +from unittest import mock + +from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI + +from csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye import OMNY_XRayEye + + +class _StubXRayEye: + """Minimal stand-in for OMNY_XRayEye, isolated to just the attributes + _snap_roi_vertical touches.""" + + _snap_roi_vertical = OMNY_XRayEye._snap_roi_vertical + + +def _make_stub(*, lock_enabled: bool, crosshair_visible: bool, fzp_y: float = 42.0): + stub = _StubXRayEye() + stub._vertical_lock_enabled = lock_enabled + stub.target_crosshair = mock.Mock() + stub.target_crosshair.is_visible.return_value = crosshair_visible + stub.crosshair_position = mock.Mock(return_value=(0.0, fzp_y)) + return stub + + +def _make_rect_roi(x: float, y: float, height: float): + roi = mock.Mock(spec=RectangularROI) + roi.get_coordinates.return_value = {"height": height} + roi.pos.return_value = mock.Mock(x=lambda: x, y=lambda: y) + return roi + + +def test_snap_roi_vertical_snaps_to_crosshair_when_locked(): + stub = _make_stub(lock_enabled=True, crosshair_visible=True, fzp_y=42.0) + roi = _make_rect_roi(x=5.0, y=0.0, height=10.0) + + stub._snap_roi_vertical(roi) + + # new_origin_y = fzp_y - height/2 = 42 - 5 = 37; x is untouched + roi.set_position.assert_called_once_with(5.0, 37.0) + + +def test_snap_roi_vertical_noop_when_lock_disabled(): + stub = _make_stub(lock_enabled=False, crosshair_visible=True) + roi = _make_rect_roi(x=5.0, y=0.0, height=10.0) + + stub._snap_roi_vertical(roi) + + roi.set_position.assert_not_called() + + +def test_snap_roi_vertical_noop_when_crosshair_hidden(): + stub = _make_stub(lock_enabled=True, crosshair_visible=False) + roi = _make_rect_roi(x=5.0, y=0.0, height=10.0) + + stub._snap_roi_vertical(roi) + + roi.set_position.assert_not_called() + + +def test_snap_roi_vertical_noop_when_already_snapped(): + # Regression guard: sigRegionChangeFinished is connected to this method, + # so a call that finds the ROI already at the target position must not + # call set_position() again -- otherwise a re-triggered signal could loop. + stub = _make_stub(lock_enabled=True, crosshair_visible=True, fzp_y=42.0) + roi = _make_rect_roi(x=5.0, y=37.0, height=10.0) # already at target y + + stub._snap_roi_vertical(roi) + + roi.set_position.assert_not_called() + + +def test_snap_roi_vertical_preserves_x_and_height(): + stub = _make_stub(lock_enabled=True, crosshair_visible=True, fzp_y=-10.0) + roi = _make_rect_roi(x=-3.5, y=100.0, height=4.0) + + stub._snap_roi_vertical(roi) + + # new_origin_y = -10 - 2 = -12; x (-3.5) is preserved + roi.set_position.assert_called_once_with(-3.5, -12.0) + + +def test_snap_roi_vertical_handles_circular_roi(): + stub = _make_stub(lock_enabled=True, crosshair_visible=True, fzp_y=20.0) + roi = mock.Mock(spec=CircularROI) + roi.get_coordinates.return_value = {"diameter": 8.0} + roi.pos.return_value = mock.Mock(x=lambda: 1.0, y=lambda: 0.0) + + stub._snap_roi_vertical(roi) + + # new_origin_y = fzp_y - diameter/2 = 20 - 4 = 16 + roi.set_position.assert_called_once_with(1.0, 16.0) diff --git a/tests/tests_devices/test_allied_vision_aravis_camera.py b/tests/tests_devices/test_allied_vision_aravis_camera.py index b3134d06..d5951284 100644 --- a/tests/tests_devices/test_allied_vision_aravis_camera.py +++ b/tests/tests_devices/test_allied_vision_aravis_camera.py @@ -23,6 +23,7 @@ def allied_vision_camera(): camera.cam.width = 2 camera.cam.height = 2 yield camera + camera.stop_live_mode() def test_mask_setter_getter(allied_vision_camera): diff --git a/tests/tests_devices/test_delay_generator_csaxs.py b/tests/tests_devices/test_delay_generator_csaxs.py index a484dae3..f06f290f 100644 --- a/tests/tests_devices/test_delay_generator_csaxs.py +++ b/tests/tests_devices/test_delay_generator_csaxs.py @@ -22,6 +22,12 @@ from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_1 import ( from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_1 import ( DEFAULT_TRIGGER_SOURCE as DDG1_DEFAULT_TRIGGER_SOURCE, ) +from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_1 import ( + DEFAULT_MOVE_START_LATENCY, + DEFAULT_SHUTTER_TO_OPEN_DELAY, + MAX_MOVE_START_LATENCY, + MAX_SHUTTER_TO_OPEN_DELAY, +) from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_1 import PROC_EVENT_MODE from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_2 import ( DEFAULT_IO_CONFIG as DDG2_DEFAULT_IO_CONFIG, @@ -143,6 +149,28 @@ def test_ddg_set_io_values(mock_ddg: DelayGeneratorCSAXS): assert attr.nim_mode.get() == 1 +def test_ddg_show_all(mock_ddg: DelayGeneratorCSAXS, capsys): + """Smoke test: show_all() must run against a fresh device and print both tables.""" + mock_ddg.set_delay_pairs(channel="ab", delay=0.1, width=0.2) + mock_ddg.set_channel_reference("A", CHANNELREFERENCE.T0) + mock_ddg.show_all() + out = capsys.readouterr().out + assert "delay pair settings" in out + assert "trigger/burst settings" in out + assert "AB" in out + assert "T0" in out + + +def test_ddg1_show_all_is_reachable_from_the_client(): + """USER_ACCESS is how BEC exposes a method over RPC as dev.ddg1.show_all().""" + assert "show_all" in DDG1.USER_ACCESS + + +def test_ddg2_show_all_is_reachable_from_the_client(): + """USER_ACCESS is how BEC exposes a method over RPC as dev.ddg2.show_all().""" + assert "show_all" in DDG2.USER_ACCESS + + def test_ddg_set_delay_pairs(mock_ddg: DelayGeneratorCSAXS): """Test setting delay pairs.""" mock_ddg.set_delay_pairs(channel="ab", delay=0.1, width=0.2) @@ -272,6 +300,90 @@ def test_ddg1_prepare_mcs(mock_ddg1: DDG1, mock_mcs_csaxs: MCSCardCSAXS): assert st.success is True +def test_ddg1_move_start_latency_default(mock_ddg1: DDG1): + assert mock_ddg1.get_move_start_latency() == DEFAULT_MOVE_START_LATENCY + + +def test_ddg1_move_start_latency_round_trips(mock_ddg1: DDG1): + mock_ddg1.set_move_start_latency(0.25) + assert mock_ddg1.get_move_start_latency() == 0.25 + # zero is the escape hatch back to the timing that preceded the parameter + mock_ddg1.set_move_start_latency(0) + assert mock_ddg1.get_move_start_latency() == 0 + + +@pytest.mark.parametrize("bad", [-0.1, MAX_MOVE_START_LATENCY + 0.1]) +def test_ddg1_move_start_latency_is_bounded(mock_ddg1: DDG1, bad: float): + with pytest.raises(ValueError): + mock_ddg1.set_move_start_latency(bad) + assert mock_ddg1.get_move_start_latency() == DEFAULT_MOVE_START_LATENCY + + +def test_ddg1_move_start_latency_is_reachable_from_the_client(mock_ddg1: DDG1): + """cont_grid reads it over RPC and users tune it over RPC; without these it is + unreachable from either.""" + assert "get_move_start_latency" in DDG1.USER_ACCESS + assert "set_move_start_latency" in DDG1.USER_ACCESS + + +def test_ddg1_move_start_latency_is_independent_of_the_shutter_delay(mock_ddg1: DDG1): + """Two timing constants on the same device with different natures -- one is + hardware (how long the shutter takes to open), one is software (how long a move + takes to reach the motor). Neither may disturb the other.""" + mock_ddg1.set_move_start_latency(0.25) + mock_ddg1.set_shutter_to_open_delay(0.009) + assert mock_ddg1.get_move_start_latency() == 0.25 + mock_ddg1.keep_shutter_open_during_scan(True) + assert mock_ddg1.get_move_start_latency() == 0.25 + + +def test_ddg1_shutter_delay_default(mock_ddg1: DDG1): + assert mock_ddg1.get_shutter_to_open_delay() == DEFAULT_SHUTTER_TO_OPEN_DELAY + + +def test_ddg1_shutter_delay_reaches_the_trigger_channel(mock_ddg1: DDG1): + """The point of the setter: the value must land on `ab`, which gates the + acquisition, while the shutter on `cd` keeps firing at t0.""" + exp_time, frames = 0.1, 10 + mock_ddg1.scan_info.msg.info["exp_time"] = exp_time + mock_ddg1.scan_info.msg.info["frames_per_trigger"] = frames + mock_ddg1.fast_shutter_control._read_pv.mock_data = 0 + + mock_ddg1.set_shutter_to_open_delay(9e-3) + mock_ddg1.stage() + + assert np.isclose(mock_ddg1.ab.delay.get(), 9e-3) + assert np.isclose(mock_ddg1.cd.delay.get(), 0) + assert np.isclose(mock_ddg1.cd.width.get(), 9e-3 + exp_time * frames) + assert np.isclose(mock_ddg1.burst_period.get(), 2 * 9e-3 + exp_time * frames + 3e-6) + mock_ddg1.unstage() + + +@pytest.mark.parametrize("bad", [-1e-3, MAX_SHUTTER_TO_OPEN_DELAY + 1e-3]) +def test_ddg1_shutter_delay_is_bounded(mock_ddg1: DDG1, bad: float): + """A fat-fingered value would stretch every line rather than fail, so it is + refused instead of applied.""" + with pytest.raises(ValueError): + mock_ddg1.set_shutter_to_open_delay(bad) + assert mock_ddg1.get_shutter_to_open_delay() == DEFAULT_SHUTTER_TO_OPEN_DELAY + + +def test_ddg1_shutter_delay_is_reachable_from_the_client(mock_ddg1: DDG1): + """Without this the setter cannot be called over RPC, which is the whole + reason it exists -- the value needs tuning without a redeploy.""" + assert "set_shutter_to_open_delay" in DDG1.USER_ACCESS + + +def test_ddg1_keeping_the_shutter_open_discards_a_tuned_delay(mock_ddg1: DDG1): + """Documents a sharp edge: the two methods write the same attribute, so + toggling this reverts a tuned value to the default rather than restoring it.""" + mock_ddg1.set_shutter_to_open_delay(9e-3) + mock_ddg1.keep_shutter_open_during_scan(True) + assert mock_ddg1.get_shutter_to_open_delay() == 0 + mock_ddg1.keep_shutter_open_during_scan(False) + assert mock_ddg1.get_shutter_to_open_delay() == DEFAULT_SHUTTER_TO_OPEN_DELAY + + def test_ddg1_stage(mock_ddg1: DDG1): """Test the on_stage method of DDG1.""" exp_time = 0.1 @@ -295,7 +407,7 @@ def test_ddg1_stage(mock_ddg1: DDG1): assert np.isclose(mock_ddg1.burst_period.get(), total_exposure) # Trigger DDG2 through EXT/EN - assert np.isclose(mock_ddg1.ab.delay.get(), 2e-3) + assert np.isclose(mock_ddg1.ab.delay.get(), DEFAULT_SHUTTER_TO_OPEN_DELAY) assert np.isclose(mock_ddg1.ab.width.get(), shutter_width) # Shutter channel cd assert np.isclose(mock_ddg1.cd.delay.get(), 0) @@ -613,3 +725,95 @@ def test_ddg2_on_stop(mock_ddg2: DDG2): # mock_ddg2.burst_mode.put(1) # Enable burst mode # mock_ddg2.stop() # assert mock_ddg2.burst_mode.get() == 0 # Burst mode is disabled + + +def test_ddg2_readout_times_default(mock_ddg2: DDG2): + """Without an override the module defaults are in force.""" + assert mock_ddg2.get_readout_times() == DDG2_DEFAULT_READOUT_TIMES + # A copy, so a caller cannot mutate the device's state by accident. + mock_ddg2.get_readout_times()["ab"] = 1.0 + assert mock_ddg2.get_readout_times()["ab"] == DDG2_DEFAULT_READOUT_TIMES["ab"] + + +def test_ddg2_set_readout_times_validates(mock_ddg2: DDG2): + """Unknown pairs and non-positive values are rejected before they reach a scan.""" + with pytest.raises(ValueError): + mock_ddg2.set_readout_times(xy=1e-3) + with pytest.raises(ValueError): + mock_ddg2.set_readout_times(ab=0) + with pytest.raises(ValueError): + mock_ddg2.set_readout_times(ab=-1e-3) + assert mock_ddg2.get_readout_times() == DDG2_DEFAULT_READOUT_TIMES + + +def test_ddg2_set_readout_times_widens_the_gap(mock_ddg2: DDG2): + """A raised readout time shortens the pulse; the burst period is unchanged.""" + ddg = mock_ddg2 + exp_time = 0.1 + frames_per_trigger = 10 + ddg.on_connected() + ddg.scan_info.msg.info["exp_time"] = exp_time + ddg.scan_info.msg.info["frames_per_trigger"] = frames_per_trigger + + ddg.set_readout_times(ab=3e-3) + assert ddg.get_readout_times()["ab"] == 3e-3 + # Other pairs are untouched. + assert ddg.get_readout_times()["cd"] == DDG2_DEFAULT_READOUT_TIMES["cd"] + + ddg.stage() + assert np.isclose(ddg.ab.width.get(), exp_time - 3e-3) + assert ddg.burst_period.get() == exp_time # frame rate unaffected + ddg.unstage() + + +def test_ddg2_readout_times_from_device_config(): + """deviceConfig can set it per deployment, merged over the defaults.""" + with patched_device( + DDG2, name="ddg2", prefix="test_ddg2:", readout_times={"ab": 3e-3}, _mock_pv_initial_value=0 + ) as ddg: + try: + assert ddg.get_readout_times()["ab"] == 3e-3 + assert ddg.get_readout_times()["gh"] == DDG2_DEFAULT_READOUT_TIMES["gh"] + finally: + ddg.destroy() + + +def test_ddg2_too_long_readout_time_is_caught_at_stage(mock_ddg2: DDG2): + """A readout time exceeding the exposure must fail loudly, not emit a negative pulse.""" + ddg = mock_ddg2 + ddg.on_connected() + ddg.scan_info.msg.info["exp_time"] = 1e-3 + ddg.scan_info.msg.info["frames_per_trigger"] = 1 + ddg.set_readout_times(ab=3e-3) + with pytest.raises(ValueError): + ddg.stage() + + +def test_ddg2_effective_readout_uses_the_scan_value_when_larger(mock_ddg2: DDG2): + """A scan-level readout_time widens the gap; the configured value is the floor.""" + ddg = mock_ddg2 + exp_time = 0.1 + ddg.on_connected() + ddg.scan_info.msg.info["exp_time"] = exp_time + ddg.scan_info.msg.info["frames_per_trigger"] = 1 + ddg.scan_info.msg.info["readout_time"] = 3e-3 + + ddg.stage() + assert ddg.effective_readout_times()["ab"] == 3e-3 + assert np.isclose(ddg.ab.width.get(), exp_time - 3e-3) + ddg.unstage() + + +def test_ddg2_effective_readout_falls_back_to_the_configured_value(mock_ddg2: DDG2): + """Scans default readout_time to 0, so the device value must still apply.""" + ddg = mock_ddg2 + exp_time = 0.1 + ddg.on_connected() + ddg.scan_info.msg.info["exp_time"] = exp_time + ddg.scan_info.msg.info["frames_per_trigger"] = 1 + ddg.scan_info.msg.info["readout_time"] = 0.0 + + ddg.stage() + assert ddg.effective_readout_times()["ab"] == DDG2_DEFAULT_READOUT_TIMES["ab"] + assert np.isclose(ddg.ab.width.get(), exp_time - DDG2_DEFAULT_READOUT_TIMES["ab"]) + ddg.unstage() diff --git a/tests/tests_devices/test_eiger.py b/tests/tests_devices/test_eiger.py index 3e94b424..06469cf4 100644 --- a/tests/tests_devices/test_eiger.py +++ b/tests/tests_devices/test_eiger.py @@ -1,4 +1,5 @@ # pylint: skip-file +import inspect import os import threading import time @@ -9,6 +10,7 @@ import numpy as np import pytest from bec_lib.messages import FileMessage, ScanStatusMessage from bec_server.scan_server.scans.scan_base import ScanInfo as ScanServerScanInfo +from jfjoch_client.exceptions import ServiceException from jfjoch_client.models.broker_status import BrokerStatus from jfjoch_client.models.dataset_settings import DatasetSettings from jfjoch_client.models.detector_list import DetectorList @@ -22,8 +24,23 @@ from ophyd import Staged from ophyd_devices.utils.psi_device_base_utils import DeviceStatus from csaxs_bec.devices.jungfraujoch.eiger import EigerError -from csaxs_bec.devices.jungfraujoch.eiger_1_5m import Eiger1_5M -from csaxs_bec.devices.jungfraujoch.eiger_9m import Eiger9M +from csaxs_bec.devices.jungfraujoch.eiger import ( + EIGER_READOUT_TIME, + MIN_EXP_TIME, + MISSING_PACKETS_MESSAGE, + RAISE_ON_MISSING_PACKETS, +) +from csaxs_bec.devices.jungfraujoch.jungfrau_joch_client import ( + DetectorState, + JungfrauJochClient, + JungfrauJochClientError, +) +from csaxs_bec.devices.jungfraujoch.eiger_1_5m import EIGER1_5M_READOUT_TIME, Eiger1_5M +from csaxs_bec.devices.jungfraujoch.eiger_9m import EIGER9M_READOUT_TIME, Eiger9M +from csaxs_bec.devices.jungfraujoch.eiger2_si_500k import ( + EIGER2_SI_500K_READOUT_TIME, + Eiger2Si500K, +) if TYPE_CHECKING: # pragma no cover from bec_lib.messages import FileMessage @@ -66,7 +83,7 @@ def mock_scan_info(request, tmpdir): yield scan_info -@pytest.fixture(scope="function", params=[(1,), (2,)]) +@pytest.fixture(scope="function", params=[(1,), (2,), (3,)]) def detector_list(request) -> Generator[DetectorList, None, None]: """Fixture for the detector list.""" current_id = request.param[0] @@ -102,6 +119,21 @@ def detector_list(request) -> Generator[DetectorList, None, None]: min_count_time_ns=100000, type="EIGER", ), + DetectorListElement( + id=3, + description="Dectris EIGER2 Si 500K", + serial_number="123456", + base_ipv4_addr="192.168.0.1", + udp_interface_count=1, + nmodules=1, + width=512, + height=512, + pixel_size_mm=0.1, + readout_time_ns=100000, + min_frame_time_ns=1000000, + min_count_time_ns=100000, + type="EIGER", + ), ], current_id=current_id, ) @@ -135,9 +167,41 @@ def eiger_9m(mock_scan_info) -> Generator[Eiger9M, None, None]: dev.destroy() -def test_eiger_wait_for_connection(eiger_1_5m, eiger_9m): +@pytest.fixture(autouse=True) +def no_live_jfjoch_cancel(): + """ + The devices point to the production JungfrauJoch host, and destroy() -> on_stop() sends a + cancel from a background thread. Block it so tests never cancel a live acquisition, nor read + the Dectris detector server afterwards. Tests patching the client instance still override this. + """ + with mock.patch( + "jfjoch_client.api.default_api.DefaultApi.cancel_post_with_http_info", + side_effect=ConnectionError("tests must not reach JungfrauJoch"), + ): + yield + # Stop threads started by device teardown must finish while the patch is active. + for thread in threading.enumerate(): + if thread.name == "stop_jungfraujoch_thread": + thread.join(timeout=5) + + +@pytest.fixture(scope="function") +def eiger2_si_500k(mock_scan_info) -> Generator[Eiger2Si500K, None, None]: + """Fixture for the Eiger 2 Si 500K device. + Currently only on_connected is different for both devices, all other methods are the same.""" + name = "eiger2_si_500k" + dev = Eiger2Si500K(name=name) + dev.scan_info.msg = mock_scan_info + try: + yield dev + finally: + if dev._destroyed is False: + dev.destroy() + + +def test_eiger_wait_for_connection(eiger_1_5m, eiger_9m, eiger2_si_500k): """Test the wait_for_connection metho is calling status_get on the JFJ API client.""" - for eiger in (eiger_1_5m, eiger_9m): + for eiger in (eiger_1_5m, eiger_9m, eiger2_si_500k): with mock.patch.object(eiger.jfj_client.api, "status_get") as mock_status_get: eiger.wait_for_connection(timeout=1) mock_status_get.assert_called_once_with(_request_timeout=1) @@ -181,6 +245,44 @@ def test_eiger_1_5m_on_connected(eiger_1_5m, detector_list, detector_state): assert mock_jfj_preview_client.start.call_count == 1 +@pytest.mark.parametrize("detector_state", ["Idle", "Inactive"]) +def test_eiger2_si_500k_on_connected(eiger2_si_500k, detector_list, detector_state): + """Test the on_connected logic of the Eiger detector.""" + eiger = eiger2_si_500k + detector_id = 3 + with ( + mock.patch.object(eiger.jfj_client, "stop") as mock_jfj_client_stop, + mock.patch.object( + eiger.jfj_client.api, "config_select_detector_get", return_value=detector_list + ), + mock.patch.object( + eiger.jfj_client.api, "status_get", return_value=BrokerStatus(state=detector_state) + ), + mock.patch.object(eiger.jfj_client, "set_detector_settings") as mock_set_det, + mock.patch.object(eiger.jfj_client.api, "config_file_writer_put") as mock_file_writer, + mock.patch.object(eiger, "jfj_preview_client") as mock_jfj_preview_client, + ): + if detector_state != "Idle" or detector_list.current_id != detector_id: + with pytest.raises(RuntimeError): + eiger.on_connected() + mock_jfj_client_stop.assert_called_once() + assert mock_jfj_preview_client.call_count == 0 + else: + eiger.on_connected() + assert mock_set_det.call_args == mock.call( + DetectorSettings(frame_time_us=500, timing=DetectorTiming.TRIGGER), timeout=5 + ) + assert mock_file_writer.call_args == mock.call( + file_writer_settings=FileWriterSettings( + overwrite=True, format=FileWriterFormat.NXMXVDS + ), + _request_timeout=10, + ) + mock_jfj_client_stop.assert_called_once() + assert mock_jfj_preview_client.connect.call_count == 1 + assert mock_jfj_preview_client.start.call_count == 1 + + @pytest.mark.skip( reason="This test is currently disabled because the Eiger 9M config is replaced by the 8_5M config." ) @@ -327,11 +429,12 @@ def test_eiger_on_complete(eiger_1_5m, raise_timeout): assert status.success == False if raise_timeout else True -@pytest.mark.skip( - reason="This test currently fails because the error should be skipped for now due to HW issues." -) def test_eiger_on_complete_error_message(eiger_1_5m): - """Test that on_complete raises if the message severity is error.""" + """Test that on_complete raises if the message severity is error. + + Unrelated to the missing-packet tolerance: this message is a different error, so it + raises whatever raise_on_missing_packets is set to. + """ # Test that on_complete raises if message_severity is error eiger = eiger_1_5m unblock_wait_for_idle = threading.Event() @@ -416,7 +519,8 @@ def test_eiger_on_stage(eiger_1_5m): assert file_msg.hinted_h5_entries == {"data": "entry/data/data"} data_settings = DatasetSettings( - image_time_us=int(scan_msg.scan_parameters["exp_time"] * 1e6), + # exp_time is the trigger period; the detector acquires for the gated window + image_time_us=int((scan_msg.scan_parameters["exp_time"] - EIGER_READOUT_TIME) * 1e6), ntrigger=int(scan_msg.num_points * scan_msg.scan_parameters["frames_per_trigger"]), file_prefix=os.path.relpath(eiger._full_path, start="/sls/x12sa/data").removesuffix( "_master.h5" @@ -460,3 +564,544 @@ def test_eiger_preview_callback(eiger_1_5m): mock_preview_put.assert_called_once_with(msg["data"]["default"]) else: mock_preview_put.assert_not_called() + + +def test_eiger_preview_callback_falls_back_to_non_default_channel(eiger2_si_500k): + """The Eiger2 Si 500K's multi-threshold preview data has no "default" key (it's + named after the threshold, e.g. "threshold_1" -- confirmed against a live capture + of the JFJ preview stream); the callback must still forward that single channel. + """ + eiger = eiger2_si_500k + frame = np.array([[1, 2], [3, 4]]) + msg = {"type": "image", "data": {"threshold_1": frame}} + with mock.patch.object(eiger.preview_image, "put") as mock_preview_put: + eiger._preview_callback(msg) + mock_preview_put.assert_called_once_with(frame) + + +def test_eiger_preview_callback_logs_when_data_is_truly_empty(eiger_1_5m): + """No channel at all (empty or missing data dict) must still be treated as missing, + not silently pass through some other unrelated dict key.""" + eiger = eiger_1_5m + for msg in [{"type": "image", "data": {}}, {"type": "image"}]: + with mock.patch.object(eiger.preview_image, "put") as mock_preview_put: + eiger._preview_callback(msg) + mock_preview_put.assert_not_called() + + +def test_eiger_acquisition_matches_the_gated_window(eiger_1_5m): + """The detector must acquire for exp_time - readout, not for the whole period. + + exp_time is the trigger period. The delay generator gates for exp_time - readout, so + sending the full period leaves the detector still acquiring when the next trigger + arrives -- 20 us of margin, which is what JungfrauJoch subtracts internally. + """ + eiger = eiger_1_5m + exp_time = eiger.scan_info.msg.scan_parameters["exp_time"] + + with ( + mock.patch.object(eiger.jfj_client, "wait_for_idle", return_value=True), + mock.patch.object(eiger.jfj_client, "start") as mock_start, + mock.patch.object( + eiger.jfj_client.api, "status_get", return_value=BrokerStatus(state="Idle") + ), + ): + eiger.stage() + + sent = mock_start.call_args.kwargs["settings"].image_time_us + assert sent == int((exp_time - EIGER_READOUT_TIME) * 1e6) + assert sent < int(exp_time * 1e6) + + +def test_eiger_scan_readout_time_wins_when_larger(eiger_1_5m): + """A scan asking for a wider gap must widen the acquisition window too.""" + eiger = eiger_1_5m + exp_time = eiger.scan_info.msg.scan_parameters["exp_time"] + eiger.scan_info.msg.info["readout_time"] = 3e-3 # > EIGER_READOUT_TIME + + with ( + mock.patch.object(eiger.jfj_client, "wait_for_idle", return_value=True), + mock.patch.object(eiger.jfj_client, "start") as mock_start, + mock.patch.object( + eiger.jfj_client.api, "status_get", return_value=BrokerStatus(state="Idle") + ), + ): + eiger.stage() + + assert mock_start.call_args.kwargs["settings"].image_time_us == int((exp_time - 3e-3) * 1e6) + + +def test_eiger_rejects_a_readout_that_fills_the_period(eiger_1_5m): + """A gap wider than the period leaves nothing to acquire; fail rather than send <= 0.""" + eiger = eiger_1_5m + exp_time = eiger.scan_info.msg.scan_parameters["exp_time"] + eiger.scan_info.msg.info["readout_time"] = exp_time + 1e-3 + + with ( + mock.patch.object(eiger.jfj_client, "wait_for_idle", return_value=True), + mock.patch.object(eiger.jfj_client, "start"), + mock.patch.object( + eiger.jfj_client.api, "status_get", return_value=BrokerStatus(state="Idle") + ), + ): + with pytest.raises(ValueError): + eiger.stage() + + +def test_eiger_minimum_exposure_check_is_unchanged(eiger_1_5m): + """Splitting readout from the validation floor must not relax the minimum exposure.""" + eiger = eiger_1_5m + eiger.scan_info.msg.info["exp_time"] = MIN_EXP_TIME / 2 + + with ( + mock.patch.object(eiger.jfj_client, "wait_for_idle", return_value=True), + mock.patch.object(eiger.jfj_client, "start"), + mock.patch.object( + eiger.jfj_client.api, "status_get", return_value=BrokerStatus(state="Idle") + ), + ): + with pytest.raises(ValueError): + eiger.stage() + + +def test_eiger_models_may_declare_their_own_readout_time(eiger_1_5m, eiger_9m, eiger2_si_500k): + """Readout is a detector property, so each model states its own value. + + They coincide today, but nothing should force them to: a 9M has more modules + than a 1.5M, and the Falcon needs 3 ms. + """ + assert eiger_1_5m._readout_time == EIGER1_5M_READOUT_TIME + assert eiger_9m._readout_time == EIGER9M_READOUT_TIME + assert eiger2_si_500k._readout_time == EIGER2_SI_500K_READOUT_TIME + + +def test_eiger_readout_time_is_overridable_per_deployment(mock_scan_info): + """deviceConfig's readout_time wins over the model's default.""" + dev = Eiger1_5M( + name="eiger_1_5m", beam_center=(256, 256), detector_distance=100.0, readout_time=1.5e-3 + ) + try: + assert dev._readout_time == 1.5e-3 + finally: + dev.destroy() + + +def test_eiger_subclasses_name_readout_time_for_the_device_server(): + """deviceConfig keys reach a device only if the class signature names them. + + bec_server intersects the config keys with the named parameters of the class + (device_server/devices/devicemanager.py), so a subclass that only forwards + **kwargs silently drops readout_time -- and passing it explicitly used to raise + 'got multiple values for keyword argument'. + """ + for cls in (Eiger1_5M, Eiger9M, Eiger2Si500K): + assert "readout_time" in inspect.signature(cls).parameters + + +def _run_on_complete(eiger, broker_status, images_collected=0, timeout=5): + """Drive on_complete to a verdict against a given broker status. + + Returns the status once it resolves; raises whatever on_complete raised. + """ + eiger._wait_for_on_complete = 1 # keep the loop short + unblock_wait_for_idle = threading.Event() + + def mock_wait_for_idle(timeout: float, raise_on_timeout: bool) -> bool: + return unblock_wait_for_idle.wait(timeout) + + with ( + mock.patch.object(eiger.jfj_client.api, "status_get", return_value=broker_status), + mock.patch.object(eiger.jfj_client, "wait_for_idle", side_effect=mock_wait_for_idle), + mock.patch.object( + eiger.jfj_client.api, + "statistics_data_collection_get", + return_value=MeasurementStatistics(run_number=1, images_collected=images_collected), + ), + ): + status = eiger.complete() + unblock_wait_for_idle.set() + status.wait(timeout=timeout) + return status + + +def _missing_packets_status(): + """A broker status carrying the packet-loss error JungfrauJoch actually emits.""" + return BrokerStatus( + state="Idle", message_severity="error", message=f"{MISSING_PACKETS_MESSAGE} (run 42)" + ) + + +def test_eiger_tolerates_missing_packets_when_configured_to(eiger_1_5m): + """With the tolerance on, a missing-packet error completes the scan and is counted.""" + eiger = eiger_1_5m + eiger.set_raise_on_missing_packets(False) + + status = _run_on_complete(eiger, _missing_packets_status()) + + assert status.done is True + assert status.success is True + assert eiger.get_missing_packet_events() == 1 + + +def test_eiger_raises_on_missing_packets_when_asked_to(eiger_1_5m): + """With the tolerance off, the same error fails the scan and is not counted.""" + eiger = eiger_1_5m + eiger.set_raise_on_missing_packets(True) + + with pytest.raises(EigerError): + _run_on_complete(eiger, _missing_packets_status()) + + assert eiger.get_missing_packet_events() == 0 + + +def test_eiger_tolerance_does_not_extend_to_other_broker_errors(eiger_1_5m): + """Tolerating packet loss must not tolerate every error the broker reports.""" + eiger = eiger_1_5m + eiger.set_raise_on_missing_packets(False) + + with pytest.raises(EigerError): + _run_on_complete( + eiger, BrokerStatus(state="Idle", message_severity="error", message="Detector on fire") + ) + + assert eiger.get_missing_packet_events() == 0 + + +def test_eiger_tolerance_does_not_hide_a_short_acquisition(eiger_1_5m): + """The frame-count check still fires: only flagged-but-complete data is let through. + + This is what makes the tolerance defensible -- a scan that actually lost frames still + fails, whatever raise_on_missing_packets says. + """ + eiger = eiger_1_5m + eiger.set_raise_on_missing_packets(False) + eiger._num_triggers = 10 + + with pytest.raises(EigerError, match="acquisition incomplete"): + _run_on_complete(eiger, _missing_packets_status(), images_collected=9) + + +def test_eiger_missing_packet_tolerance_is_switchable_at_runtime(eiger_1_5m): + """The flag is reachable from the client, so a beamtime can change its mind.""" + eiger = eiger_1_5m + assert eiger.get_raise_on_missing_packets() is RAISE_ON_MISSING_PACKETS + + eiger.set_raise_on_missing_packets(True) + assert eiger.get_raise_on_missing_packets() is True + eiger.set_raise_on_missing_packets(False) + assert eiger.get_raise_on_missing_packets() is False + + for name in ( + "get_raise_on_missing_packets", + "set_raise_on_missing_packets", + "get_missing_packet_events", + ): + assert name in type(eiger).USER_ACCESS + + +def test_eiger_missing_packet_tolerance_is_overridable_per_deployment(mock_scan_info): + """deviceConfig's raise_on_missing_packets wins over the default, per detector.""" + dev = Eiger1_5M( + name="eiger_1_5m", + beam_center=(256, 256), + detector_distance=100.0, + raise_on_missing_packets=True, + ) + try: + assert dev.get_raise_on_missing_packets() is True + finally: + dev.destroy() + + +def test_eiger_subclasses_name_raise_on_missing_packets_for_the_device_server(): + """Same rule as readout_time: a deviceConfig key only reaches a device if the + class signature names it, so **kwargs alone would silently drop it.""" + for cls in (Eiger1_5M, Eiger9M, Eiger2Si500K): + assert "raise_on_missing_packets" in inspect.signature(cls).parameters + + +def test_eiger_on_stage_auto_reinitialize(eiger_1_5m): + """With auto_reinitialize the detector is recovered on staging, otherwise it is not.""" + eiger = eiger_1_5m + for flag in (True, False): + eiger._auto_reinitialize = flag + with ( + mock.patch.object(eiger.jfj_client, "wait_for_idle", return_value=True), + mock.patch.object(eiger.jfj_client, "start"), + mock.patch.object(eiger.jfj_client, "stop"), + mock.patch.object(eiger.jfj_client, "recover_from_error") as mock_recover, + mock.patch.object( + eiger.jfj_client.api, "status_get", return_value=BrokerStatus(state="Idle") + ), + ): + eiger.stage() + assert mock_recover.call_count == (1 if flag else 0) + eiger.unstage() + + +def test_eiger_on_stage_auto_reinitialize_failure(eiger_1_5m): + """A failed recovery raises a single EigerError pointing to the WEB UI.""" + eiger = eiger_1_5m + eiger._auto_reinitialize = True + with mock.patch.object( + eiger.jfj_client, "recover_from_error", side_effect=JungfrauJochClientError("still bad") + ): + with pytest.raises(EigerError, match="could not be re-initialised"): + eiger.on_stage() + + +def test_eiger_on_complete_fails_fast_in_error_state(eiger_1_5m): + """on_complete raises right away if the detector is in ERROR state, not after the timeout.""" + eiger = eiger_1_5m + with mock.patch.object( + eiger.jfj_client.api, + "status_get", + return_value=BrokerStatus(state="Error", message_severity="error", message="boom"), + ): + start = time.time() + status = eiger.complete() + with pytest.raises(EigerError): + status.wait(timeout=5) + assert time.time() - start < 5 + + +def _jfj_client() -> JungfrauJochClient: + return JungfrauJochClient(host="http://localhost:1") + + +@pytest.mark.parametrize( + "states, expected, n_init", + [ + (["Idle"], False, 0), + (["Error", "Idle"], True, 1), + ], +) +def test_jfj_client_recover_from_error(states, expected, n_init): + client = _jfj_client() + with ( + mock.patch.object( + JungfrauJochClient, + "detector_state", + new_callable=mock.PropertyMock, + side_effect=[DetectorState(s) for s in states], + ), + mock.patch.object(client.api, "initialize_post") as mock_init, + mock.patch.object(client, "wait_for_idle", return_value=True) as mock_wait, + ): + assert client.recover_from_error() is expected + assert mock_init.call_count == n_init + assert mock_wait.call_count == n_init + + +def test_jfj_client_recover_from_error_still_in_error(): + client = _jfj_client() + with ( + mock.patch.object( + JungfrauJochClient, + "detector_state", + new_callable=mock.PropertyMock, + return_value=DetectorState.ERROR, + ), + mock.patch.object(client.api, "initialize_post"), + mock.patch.object(client, "wait_for_idle", return_value=True), + ): + with pytest.raises(JungfrauJochClientError, match="after re-initialisation"): + client.recover_from_error() + + +def test_jfj_client_wait_for_idle_reports_broker_message(): + """The HTTP 500 body is reported instead of the pydantic validation traceback.""" + client = _jfj_client() + body = ( + '{"msg":"DAQ state error (Detector error (Error in detector operation))",' + '"reason":"WrongDAQState"}' + ) + exc = ServiceException(status=500, reason="Internal Server Error", body=body) + with mock.patch.object(client.api, "wait_till_done_post", side_effect=exc): + with pytest.raises(JungfrauJochClientError, match="WrongDAQState"): + client.wait_for_idle(timeout=1) + assert client.wait_for_idle(timeout=1, raise_on_timeout=False) is False + + +def test_eiger_auto_reinitialize_is_a_config_attribute(eiger_1_5m): + """The device server rejects deviceConfig keys that are not attributes of the device.""" + eiger = eiger_1_5m + assert hasattr(eiger, "auto_reinitialize") + assert eiger.auto_reinitialize is False + eiger.auto_reinitialize = True + assert eiger._auto_reinitialize is True + + +def test_dectris_url_only_set_for_eiger2_si_500k(eiger_1_5m, eiger_9m, eiger2_si_500k): + """Only the Eiger2 Si 500K logs the Dectris detector server status, the others are unchanged.""" + assert eiger_1_5m.jfj_client._dectris_url is None + assert eiger_9m.jfj_client._dectris_url is None + assert eiger2_si_500k.jfj_client._dectris_url == "http://129.129.122.159" + + +def test_jfj_client_log_dectris_status_disabled(): + """Without dectris_url no request is made and nothing is logged.""" + client = _jfj_client() + with ( + mock.patch("csaxs_bec.devices.jungfraujoch.jungfrau_joch_client.requests.get") as mock_get, + mock.patch("csaxs_bec.devices.jungfraujoch.jungfrau_joch_client.logger") as mock_logger, + ): + client.log_dectris_status("test") + mock_get.assert_not_called() + mock_logger.warning.assert_not_called() + + +def test_jfj_client_log_dectris_status(): + """All endpoints are read and logged in a single line, failed requests do not raise.""" + client = JungfrauJochClient(host="http://localhost:1", dectris_url="http://dcu/") + + def fake_get(url, timeout): + response = mock.Mock() + if url.endswith("/status/link_2"): + raise TimeoutError("too slow") + if url.endswith("/status/state"): + response.json.return_value = {"value": "error", "value_type": "string"} + elif "/monitor/" in url: + response.json.return_value = {"state": {"value": "overflow"}, "dropped": {"value": 5}} + else: + response.json.return_value = {"value": 1} + return response + + with ( + mock.patch( + "csaxs_bec.devices.jungfraujoch.jungfrau_joch_client.requests.get", side_effect=fake_get + ) as mock_get, + mock.patch("csaxs_bec.devices.jungfraujoch.jungfrau_joch_client.logger") as mock_logger, + ): + client.log_dectris_status("test reason") + assert mock_get.call_count == 15 + mock_logger.warning.assert_called_once() + line = mock_logger.warning.call_args[0][0] + assert "test reason" in line + assert '"detector/state":"error"' in line + assert '"monitor":{"state":"overflow","dropped":5}' in line + assert "TimeoutError" in line # link_2 failed, the rest is still logged + + +def test_jfj_client_recover_from_error_logs_dectris_status_before_init(): + client = JungfrauJochClient(host="http://localhost:1", dectris_url="http://dcu") + calls = mock.Mock() + with ( + mock.patch.object( + JungfrauJochClient, + "detector_state", + new_callable=mock.PropertyMock, + side_effect=[DetectorState.ERROR, DetectorState.IDLE], + ), + mock.patch.object(client, "log_dectris_status", calls.log), + mock.patch.object(client.api, "initialize_post", calls.init), + mock.patch.object(client, "wait_for_idle", return_value=True), + ): + assert client.recover_from_error() is True + assert [c[0] for c in calls.mock_calls] == ["log", "init"] + + +def test_eiger_on_complete_logs_dectris_status_in_error_state(eiger2_si_500k): + eiger = eiger2_si_500k + with ( + mock.patch.object( + eiger.jfj_client.api, + "status_get", + return_value=BrokerStatus(state="Error", message_severity="error", message="boom"), + ), + mock.patch.object(eiger.jfj_client, "log_dectris_status") as mock_log, + ): + status = eiger.complete() + with pytest.raises(EigerError): + status.wait(timeout=5) + mock_log.assert_called_once() + + +def test_jfj_client_log_dectris_status_returns_results(): + disabled = _jfj_client() + assert disabled.log_dectris_status("test") is None + + client = JungfrauJochClient(host="http://localhost:1", dectris_url="http://dcu") + response = mock.Mock() + response.json.return_value = {"value": "idle"} + with ( + mock.patch( + "csaxs_bec.devices.jungfraujoch.jungfrau_joch_client.requests.get", + return_value=response, + ), + mock.patch("csaxs_bec.devices.jungfraujoch.jungfrau_joch_client.logger"), + ): + results = client.log_dectris_status("test") + assert results["detector/state"] == "idle" + + +@pytest.mark.parametrize( + "results, expected", + [ + ({"detector/state": "ready", "detector/series_unique_id": "S1"}, "S1"), + ({"detector/state": "idle", "stream": {"state": "acquire"}}, "unknown"), + ({"detector/state": "ready", "detector/series_unique_id": ""}, "unknown"), + ({"detector/state": "idle", "stream": {"state": "ready"}}, None), + ({"detector/state": "", "stream": ""}, None), + ({}, None), + (None, None), + ], +) +def test_jfj_client_dectris_still_armed(results, expected): + assert JungfrauJochClient._dectris_still_armed(results) == expected + + +@pytest.mark.parametrize( + "dectris_status, hint_expected", + [ + ( + { + "detector/state": "ready", + "detector/series_unique_id": "01M4J4FPRPZ2J7M6MW2YSA27VG", + "stream": {"state": "acquire"}, + }, + True, + ), + ({"detector/state": "idle", "stream": {"state": "ready"}}, False), + (None, False), + ], +) +def test_jfj_client_recover_from_error_hints_armed_dectris(dectris_status, hint_expected): + """A failed re-init tells the operator when the Dectris server is still armed.""" + client = JungfrauJochClient(host="http://localhost:1", dectris_url="http://dcu") + with ( + mock.patch.object( + JungfrauJochClient, + "detector_state", + new_callable=mock.PropertyMock, + return_value=DetectorState.ERROR, + ), + mock.patch.object(client, "log_dectris_status", return_value=dectris_status), + mock.patch.object(client.api, "initialize_post"), + mock.patch.object( + client, "wait_for_idle", side_effect=JungfrauJochClientError("Detector busy") + ), + ): + with pytest.raises(JungfrauJochClientError, match="Detector busy") as exc_info: + client.recover_from_error() + message = str(exc_info.value) + assert ("still armed" in message) is hint_expected + assert ("01M4J4FPRPZ2J7M6MW2YSA27VG" in message) is hint_expected + + +def test_jfj_client_stop_logs_cancel_and_dectris_status(): + client = JungfrauJochClient(host="http://localhost:1", dectris_url="http://dcu") + calls = mock.Mock() + with ( + mock.patch.object(client.api, "cancel_post_with_http_info", calls.cancel), + mock.patch.object(client, "log_dectris_status", calls.log), + mock.patch("csaxs_bec.devices.jungfraujoch.jungfrau_joch_client.logger") as mock_logger, + mock.patch( + "csaxs_bec.devices.jungfraujoch.jungfrau_joch_client.threading.Thread", + side_effect=lambda target, args, **kwargs: mock.Mock(start=lambda: target(*args)), + ), + ): + client.stop(reason="on_stop") + assert [c[0] for c in calls.mock_calls] == ["cancel", "log"] + calls.log.assert_called_once_with("after cancel (on_stop)") + assert "on_stop" in mock_logger.info.call_args[0][0] diff --git a/tests/tests_devices/test_falcon_csaxs.py b/tests/tests_devices/test_falcon_csaxs.py index 4069ba6f..156544f9 100644 --- a/tests/tests_devices/test_falcon_csaxs.py +++ b/tests/tests_devices/test_falcon_csaxs.py @@ -1,4 +1,5 @@ # pylint: skip-file +import inspect import os import threading from typing import Generator @@ -15,7 +16,9 @@ from ophyd_devices.tests.utils import patched_device from csaxs_bec.devices.epics.falcon_csaxs import ( ACQUIRESTATUS, + DEFAULT_XML_FILE_NAME, FalconcSAXS, + FalconError, MappingSource, TriggerSource, ) @@ -83,7 +86,7 @@ def test_falcon_on_connected(mock_det: FalconcSAXS): # Backend default PV values assert falcon.hdf5.enable.get() == "1" # Enabled - assert falcon.hdf5.xml_file_name.get() == "layout.xml" + assert falcon.hdf5.xml_file_name.get() == DEFAULT_XML_FILE_NAME assert falcon.hdf5.lazy_open.get() == "1" # Enabled assert falcon.hdf5.temp_suffix.get() == "" assert falcon.hdf5.queue_size.get() == falcon._queue_size @@ -230,3 +233,160 @@ def test_falcon_complete(mock_det: FalconcSAXS): hinted_h5_entries=None, metadata={}, ) + + +def test_falcon_is_primed(mock_det: FalconcSAXS): + """The plugin counts as primed as soon as any array dimension is non-zero.""" + falcon = mock_det + + with mock.patch.object(falcon.hdf5.array_size, "get", return_value=(0, 0, 0)): + assert falcon.is_primed() is False + + with mock.patch.object(falcon.hdf5.array_size, "get", return_value=(0, 1, 3000)): + assert falcon.is_primed() is True + + +def test_falcon_prime_skips_when_already_primed(mock_det: FalconcSAXS): + """Priming an already primed plugin must not touch the detector.""" + falcon = mock_det + + with ( + mock.patch.object(falcon, "is_primed", return_value=True), + mock.patch.object(falcon, "set_trigger") as mock_set_trigger, + mock.patch.object(falcon, "on_stop") as mock_on_stop, + ): + falcon.prime() + mock_set_trigger.assert_not_called() + mock_on_stop.assert_not_called() + + +def test_falcon_prime(mock_det: FalconcSAXS): + """Priming advances a pixel by hand and restores the trigger configuration.""" + falcon = mock_det + + falcon.set_trigger( + mapping_mode=MappingSource.MAPPING, trigger_source=TriggerSource.GATE, ignore_gate=0 + ) + falcon.pixels_per_buffer.put(20) + + with ( + mock.patch.object(falcon, "is_primed", side_effect=[False, True]), + mock.patch.object(falcon.next_pixel, "put") as mock_next_pixel, + ): + falcon.prime() + + # A gate signal must not be required, so the pixel is advanced from software. + assert mock_next_pixel.call_count > 0 + assert falcon.hdf5.capture.get() == 0 + + # Trigger configuration restored, otherwise the next scan silently starves. + assert falcon.collect_mode.get() == MappingSource.MAPPING + assert falcon.pixel_advance_mode.get() == TriggerSource.GATE + assert falcon.ignore_gate.get() == 0 + assert falcon.pixels_per_buffer.get() == 20 + + +def test_falcon_prime_restores_config_on_failure(mock_det: FalconcSAXS): + """A failed priming must still leave the detector in its original trigger config.""" + falcon = mock_det + + falcon.set_trigger( + mapping_mode=MappingSource.MAPPING, trigger_source=TriggerSource.GATE, ignore_gate=0 + ) + falcon.pixels_per_buffer.put(20) + + with ( + mock.patch.object(falcon, "is_primed", return_value=False), + mock.patch.object(falcon, "_wait_for_primed", return_value=False), + ): + with pytest.raises(FalconError): + falcon.prime() + + assert falcon.pixel_advance_mode.get() == TriggerSource.GATE + assert falcon.ignore_gate.get() == 0 + assert falcon.pixels_per_buffer.get() == 20 + + +def test_falcon_sets_the_ioc_resolvable_layout(mock_det: FalconcSAXS): + """A bare 'layout.xml' does not resolve for the IOC; cfg/layout.xml does.""" + falcon = mock_det + falcon.hdf5.xml_file_name.put("something-else") + + with mock.patch.object(falcon, "_check_xml_layout", return_value=True): + falcon._initialize_detector_backend() + + assert falcon.hdf5.xml_file_name.get() == DEFAULT_XML_FILE_NAME + assert DEFAULT_XML_FILE_NAME == "cfg/layout.xml" + + +def test_falcon_xml_file_name_from_device_config(): + """deviceConfig can point at a different layout, or disable it with ''.""" + with patched_device( + FalconcSAXS, + name="falcon", + prefix="X12SA-SITORO:", + device_manager=DMMock(), + xml_file_name="", + _mock_pv_initial_value=1, + ) as dev: + try: + assert dev._xml_file_name == "" + # Nothing to validate when the built-in layout is requested. + assert dev._check_xml_layout(timeout=0.01) is True + finally: + dev.destroy() + + +def test_falcon_warns_when_the_ioc_rejects_the_layout(mock_det: FalconcSAXS): + """An unreadable layout is reported at connect, not as a write error much later.""" + falcon = mock_det + + with ( + mock.patch.object(falcon.hdf5.xml_valid, "get", return_value="No"), + mock.patch.object(falcon.hdf5.xml_error_msg, "get", return_value="cannot be opened"), + mock.patch("csaxs_bec.devices.epics.falcon_csaxs.logger") as mock_logger, + ): + assert falcon._check_xml_layout(timeout=0.05) is False + assert mock_logger.error.call_count == 1 + assert "layout" in mock_logger.error.call_args[0][0].lower() + + +def test_falcon_accepts_a_valid_layout_silently(mock_det: FalconcSAXS): + """A layout the IOC accepts produces no error.""" + falcon = mock_det + + with ( + mock.patch.object(falcon.hdf5.xml_valid, "get", return_value="Yes"), + mock.patch("csaxs_bec.devices.epics.falcon_csaxs.logger") as mock_logger, + ): + assert falcon._check_xml_layout(timeout=0.05) is True + mock_logger.error.assert_not_called() + + +def test_falcon_init_names_every_device_config_key(): + """The device server drops config keys that are not named parameters. + + bec_server's devicemanager builds the init kwargs by intersecting the deviceConfig + keys with the class signature's named parameters. A *args/**kwargs signature + therefore silently discards 'prefix', and every PV is built without it -- which + presents as 'failed to connect to all signals' rather than as a config error. + """ + params = inspect.signature(FalconcSAXS).parameters + for key in ("name", "prefix", "scan_info", "device_manager", "xml_file_name"): + assert key in params, f"{key} must be a named parameter or the device server drops it" + + +def test_falcon_keeps_the_configured_prefix(): + """A prefix passed as the device server passes it must reach the signals.""" + with patched_device( + FalconcSAXS, + name="falcon", + prefix="X12SA-SITORO:", + device_manager=DMMock(), + _mock_pv_initial_value=1, + ) as dev: + try: + assert dev.prefix == "X12SA-SITORO:" + assert dev.hdf5.file_path.pvname.startswith("X12SA-SITORO:") + finally: + dev.destroy() diff --git a/tests/tests_devices/test_ids_camera.py b/tests/tests_devices/test_ids_camera.py index 1c639b4b..704763df 100644 --- a/tests/tests_devices/test_ids_camera.py +++ b/tests/tests_devices/test_ids_camera.py @@ -27,7 +27,16 @@ def ids_camera(): camera.cam.cam = mock.Mock() camera.cam.cam.width.value = 2 camera.cam.cam.height.value = 2 + camera.cam.get_exposure_range = mock.Mock(return_value=(0.1, 1000.0, 0.1)) + camera.cam.get_pixel_clock = mock.Mock(return_value=20) + camera.cam.get_pixel_clock_range = mock.Mock(return_value=(5, 40, 1)) + camera.cam.get_pixel_clock_list = mock.Mock(return_value=[5, 10, 20, 40]) + # on_connected() pulses auto_gain_enabled on then off with a real sleep + # in between (see IDSCamera._AUTO_GAIN_SETTLE_S) -- skip the wait in + # tests, only hardware needs the settle time. + camera._AUTO_GAIN_SETTLE_S = 0 yield camera + camera.stop_live_mode() def test_mask_setter_getter(ids_camera): @@ -147,6 +156,106 @@ def test_push_preview_image_compensates_rotation_and_transpose(): assert np.array_equal(result, display_oriented) +def test_get_set_exposure_time(ids_camera): + ids_camera.set_exposure_time(1234.5) + assert ids_camera.cam.exposure_time == 1234.5 + assert ids_camera.get_exposure_time() == 1234.5 + + +def test_set_auto_exposure_enabled(ids_camera): + ids_camera.set_auto_exposure_enabled(False) + ids_camera.cam.set_auto_shutter.assert_called_once_with(False) + + +def test_set_auto_gain_enabled(ids_camera): + ids_camera.set_auto_gain_enabled(False) + ids_camera.cam.set_auto_gain.assert_called_once_with(False) + + +def test_on_connected_seeds_exposure_time(ids_camera): + ids_camera.cam.on_connect = mock.Mock() + ids_camera.cam.exposure_time = 4200.0 + ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.1, 1000.0, 0.1)) + ids_camera.on_connected() + assert ids_camera.get_exposure_time() == 4200.0 + + +def test_on_connected_seeds_exposure_time_range(ids_camera): + ids_camera.cam.on_connect = mock.Mock() + ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.05, 500.0, 0.05)) + ids_camera.on_connected() + assert ids_camera.get_exposure_time_range() == (0.05, 500.0) + + +def test_auto_gain_hardware_failure_is_logged_not_raised(ids_camera): + """A driver-level failure inside the subscribe callback must not + propagate -- see _on_auto_gain_enabled_changed()'s docstring. This also + covers the case that motivated it: the old set_auto_gain() passed a + c_int where the SDK expects c_double, which the real driver rejected.""" + ids_camera.cam.set_auto_gain = mock.Mock(side_effect=RuntimeError("driver rejected value")) + ids_camera.set_auto_gain_enabled(False) # must not raise + ids_camera.cam.set_auto_gain.assert_called_once_with(False) + + +def test_auto_exposure_hardware_failure_is_logged_not_raised(ids_camera): + ids_camera.cam.set_auto_shutter = mock.Mock(side_effect=RuntimeError("driver rejected value")) + ids_camera.set_auto_exposure_enabled(False) # must not raise + ids_camera.cam.set_auto_shutter.assert_called_once_with(False) + + +def test_on_connected_applies_auto_exposure_default_to_hardware(ids_camera): + """auto_exposure_enabled defaults to True but, unlike + live_mode_enabled/exposure_time, was never actually applied to hardware + at connect -- meaning a fresh session left the camera in whatever + is_ResetToDefault() leaves it in (observed: auto off) regardless of the + Signal's declared default. on_connected() must now round-trip it through + its subscribe callback so the real SDK call is issued.""" + ids_camera.cam.on_connect = mock.Mock() + ids_camera.on_connected() + ids_camera.cam.set_auto_shutter.assert_called_once_with(True) + + +def test_on_connected_pulses_auto_gain_on_then_off(ids_camera): + """auto_gain_enabled gets pulsed on then off at connect (a one-time gain + correction), unlike auto_exposure_enabled which stays on -- HW testing + found continuous auto-gain has nothing useful left to do once correctly + exposed, and it should end up under the operator's manual control.""" + ids_camera.cam.on_connect = mock.Mock() + ids_camera.on_connected() + ids_camera.cam.set_auto_gain.assert_has_calls([mock.call(True), mock.call(False)]) + assert ids_camera.auto_gain_enabled.get() is False + + +def test_on_connected_seeds_pixel_clock(ids_camera): + ids_camera.cam.on_connect = mock.Mock() + ids_camera.cam.get_pixel_clock = mock.Mock(return_value=30) + ids_camera.cam.get_pixel_clock_range = mock.Mock(return_value=(5, 45, 1)) + ids_camera.on_connected() + assert ids_camera.get_pixel_clock() == 30 + assert ids_camera.get_pixel_clock_range() == (5, 45) + + +def test_set_pixel_clock_reseeds_exposure_range(ids_camera): + ids_camera.cam.set_pixel_clock = mock.Mock() + ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.2, 200.0, 0.1)) + ids_camera.set_pixel_clock(10) + ids_camera.cam.set_pixel_clock.assert_called_once_with(10) + assert ids_camera.get_exposure_time_range() == (0.2, 200.0) + + +def test_set_pixel_clock_snaps_to_nearest_supported_value(ids_camera): + """Confirmed on hardware: not every value between get_pixel_clock_range()'s + min/max is actually accepted (49, 60, 73 MHz were all rejected on camera + 41) -- set_pixel_clock() must snap to the nearest value in + get_pixel_clock_list() instead of passing the raw slider value through.""" + ids_camera.cam.get_pixel_clock_list = mock.Mock(return_value=[5, 10, 20, 40]) + ids_camera.cam.set_pixel_clock = mock.Mock() + ids_camera.cam.get_exposure_range = mock.Mock(return_value=(0.1, 100.0, 0.1)) + ids_camera.set_pixel_clock(37) # nearest supported value is 40 + ids_camera.cam.set_pixel_clock.assert_called_once_with(40) + assert ids_camera.get_pixel_clock() == 40 + + def test_push_smear_preview_no_rotation_compensation(ids_camera): """smear_preview has no rotation_90/transpose configured, so pushed data passes straight through unmodified -- unlike push_preview_image, no diff --git a/tests/tests_devices/test_mcs_card.py b/tests/tests_devices/test_mcs_card.py index 2f8f6b07..0bfa70aa 100644 --- a/tests/tests_devices/test_mcs_card.py +++ b/tests/tests_devices/test_mcs_card.py @@ -24,7 +24,7 @@ from csaxs_bec.devices.epics.mcs_card.mcs_card import ( READMODE, MCSCard, ) -from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import MCSCardCSAXS +from csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs import MCSCardCSAXS, suppress_mca_callbacks from csaxs_bec.devices.utils.utils import fetch_scan_info @@ -167,9 +167,10 @@ def test_mcs_card_csaxs_complete_and_stop(mock_mcs_csaxs: MCSCardCSAXS): """ Test complete method of MCSCarcCSAXS. - Two use cases: + Three use cases: I. Acquisition is stopped externally II. Acquisition completes normally + III. Acquisition emits more points than requested """ mcs = mock_mcs_csaxs mcs.scan_parameters = fetch_scan_info(mcs.scan_info) @@ -219,6 +220,38 @@ def test_mcs_card_csaxs_complete_and_stop(mock_mcs_csaxs: MCSCardCSAXS): mcs._start_monitor_async_data_emission.wait(2) assert not mcs._start_monitor_async_data_emission.is_set() + ############################## + # III. Extra data fails fast # + ############################## + mcs._current_data_index = 11 + st = mcs.complete() + with pytest.raises(RuntimeError, match="emitted 11 data points, but the scan requested 10"): + st.wait(timeout=1) + + +def test_suppress_mca_callbacks_discards_callbacks_without_blocking(mock_mcs_csaxs: MCSCardCSAXS): + """A complete MCA callback batch is discarded while suppression is active.""" + mcs = mock_mcs_csaxs + callback_finished = threading.Event() + + def emit_callbacks(): + for index in range(mcs.NUM_MCA_CHANNELS): + counter = getattr(mcs.counters, f"mca{index + 1}") + mcs._on_counter_update(index, obj=counter, timestamp=1.0) + callback_finished.set() + + worker = threading.Thread(target=emit_callbacks) + with mock.patch.object(mcs.mca, "put") as mca_put: + with suppress_mca_callbacks(mcs, restore_after_timeout=0): + worker.start() + assert callback_finished.wait(timeout=1) + + worker.join(timeout=1) + assert not worker.is_alive() + assert mcs._current_data == {} + assert mcs._current_data_index == 0 + mca_put.assert_not_called() + def test_mcs_on_stop(mock_mcs_csaxs: MCSCardCSAXS): """Test that on stop sets the omit_mca_callbacks flag. Also test that on stage clears the omit_mca_callbacks flag.""" diff --git a/tests/tests_devices/test_panda.py b/tests/tests_devices/test_panda.py index 57afc8d1..ff017212 100644 --- a/tests/tests_devices/test_panda.py +++ b/tests/tests_devices/test_panda.py @@ -3,16 +3,31 @@ # pylint: skip-file from __future__ import annotations +import threading from unittest import mock +import numpy as np import pytest from ophyd import Staged +from ophyd_devices.devices.panda_box.panda_box import PandaState +from ophyd_devices.interfaces.base_classes.psi_device_base import DeviceStoppedError +from pandablocks.responses import FrameData from csaxs_bec.devices.panda_box.panda_box import PandaBoxCSAXS from csaxs_bec.devices.panda_box.panda_box_omny import PandaBoxOMNY from csaxs_bec.devices.utils.utils import fetch_scan_info +def _make_frame_data(n_rows: int, start: int = 0) -> FrameData: + """Build a synthetic FrameData batch with `n_rows` rows for the frame_counter / + gate_detector_active signals used by the raw-stream tests below.""" + data = np.array( + [(np.float64(i), np.float64(i % 2)) for i in range(start, start + n_rows)], + dtype=[("COUNTER1.OUT.Value", "= 5, flush triggered + + out = mock_put.call_args.args[0] + assert [v for v in out["frame_counter"]["value"]] == [0.0, 1.0, 2.0, 3.0, 4.0, 5.0] + assert [v for v in out["gate_detector_active"]["value"]] == [0.0, 1.0, 0.0, 1.0, 0.0, 1.0] + + +def test_panda_omny_raw_stream_gate_bit_is_masked_from_shared_word(panda_omny_raw_stream): + """PCAP.BITS0 is a 32-bit word shared with other captured bit_out signals; only bit 0 + (TTLIN1.VAL, confirmed via CAPTURE_WORD?/OFFSET? against the omny-panda hardware) is the + detector-trigger gate. gate_detector_active must be masked down to that bit rather than + passed through as the raw word.""" + dev = panda_omny_raw_stream + data = np.array( + # bit 0 set (gate active) plus other bits from something else sharing BITS0 + [(0.0, 5.0), (1.0, 4.0), (2.0, 3.0)], + dtype=[("COUNTER1.OUT.Value", " threading.Timer: + """Simulate the PandA data thread delivering the READY event `delay` seconds from now.""" + timer = threading.Timer(delay, dev._run_status_callbacks, args=(PandaState.READY,)) + timer.daemon = True + timer.start() + return timer + + +def test_panda_omny_pre_scan_arms_when_ready(panda_omny, capsys): + dev = panda_omny + dev.pre_scan_timeout = 2 + with ( + mock.patch.object(dev, "_arm") as mock_arm, + mock.patch.object(dev, "_reset_panda") as mock_reset, + ): + _deliver_ready_after(dev, 0.05) + status = dev.on_pre_scan() + status.wait(timeout=4) + assert status.success is True + mock_arm.assert_called_once() + mock_reset.assert_not_called() + assert "Forcing abort/reset" not in capsys.readouterr().out + + +def test_panda_omny_pre_scan_retries_once_then_succeeds(panda_omny, capsys): + """No READY on the first attempt: the box is force-reset and arming retried. The retry is + printed and logged.""" + dev = panda_omny + dev.pre_scan_timeout = 0.2 + + def _reset_then_ready(): + _deliver_ready_after(dev, 0.05) + + with ( + mock.patch.object(dev, "_arm") as mock_arm, + mock.patch.object(dev, "_reset_panda", side_effect=_reset_then_ready) as mock_reset, + mock.patch("csaxs_bec.devices.panda_box.panda_box_omny.logger") as mock_logger, + ): + status = dev.on_pre_scan() + status.wait(timeout=4) + assert status.success is True + mock_reset.assert_called_once() + mock_arm.assert_called_once() + assert any("Forcing abort/reset" in str(c) for c in mock_logger.warning.call_args_list) + assert any("armed successfully on attempt 2" in str(c) for c in mock_logger.info.call_args_list) + assert "Forcing abort/reset" in capsys.readouterr().out + + +def test_panda_omny_pre_scan_raises_after_retry_fails(panda_omny, capsys): + dev = panda_omny + dev.pre_scan_timeout = 0.1 + with ( + mock.patch.object(dev, "_arm") as mock_arm, + mock.patch.object(dev, "_reset_panda") as mock_reset, + mock.patch("csaxs_bec.devices.panda_box.panda_box_omny.logger") as mock_logger, + ): + status = dev.on_pre_scan() + with pytest.raises(RuntimeError, match="could not be armed after 2 attempts"): + status.wait(timeout=4) + mock_arm.assert_not_called() + # once for the forced reset before the retry, once to leave the box clean at the end + assert mock_reset.call_count == 2 + mock_logger.error.assert_called_once() + assert capsys.readouterr().out.count("Forcing abort/reset") == 1 + # stale status callbacks of the failed attempts are removed again + assert dev._status_callbacks == {} + + +def test_panda_omny_pre_scan_stop_is_not_retried(panda_omny, capsys): + dev = panda_omny + dev.pre_scan_timeout = 0.3 + with ( + mock.patch.object(dev, "_arm") as mock_arm, + mock.patch.object(dev, "_reset_panda") as mock_reset, + ): + status = dev.on_pre_scan() + threading.Event().wait(0.05) + dev._stop_stoppable_status_objects() + with pytest.raises(DeviceStoppedError): + status.wait(timeout=4) + # give a wrongly retrying task thread the time to show up + threading.Event().wait(0.5) + mock_arm.assert_not_called() + mock_reset.assert_not_called() + assert "Forcing abort/reset" not in capsys.readouterr().out + + +def test_panda_omny_raw_stream_drops_stale_leading_frame_counter_rows(panda_omny_raw_stream): + """PandA's COUNTER1.OUT is not cleared by Arm(), so the first raw samples of a new + acquisition can briefly carry a stale frame_counter value left over from the previous + acquisition's tail (confirmed 2026-09-21 against a real scan, S06313). Rows before the + first observed frame_counter == 0 must be dropped, and every channel must be truncated + at the same row so they stay aligned.""" + dev = panda_omny_raw_stream + # Two stale rows carrying the previous acquisition's terminal counter value (167), then + # the real acquisition starting at frame_counter 0. + data = np.array( + [(167.0, 1.0), (167.0, 0.0), (0.0, 1.0), (0.0, 0.0), (1.0, 1.0)], + dtype=[("COUNTER1.OUT.Value", " sgx is now movable + assert sgx.controller.is_ready() + st = sgx.move(0.5, wait=True) + assert st.done and st.success + assert sgx.position == pytest.approx(0.5) + + +def test_set_is_nonblocking(): + coord = _coordinator() + sgchi = _motor("sgchi", "CHI") + coord.reference() + st = sgchi.set(12.0) + st.wait(timeout=5) + assert st.success and sgchi.position == pytest.approx(12.0) + + +def test_soft_limits_enforced(): + coord = _coordinator() + sgx = _motor("sgx", "SHX", limits=[-1, 1]) + coord.reference() + with pytest.raises(LimitError): + sgx.move(2.0) + + +def test_zero_zero_limits_mean_unbounded(): + coord = _coordinator() + sgo = _motor("sgo", "OMEGA") # no limits configured -> (0, 0) + coord.reference() + assert sgo.limits == (0.0, 0.0) + sgo.move(123.0, wait=True) # not rejected + assert sgo.position == pytest.approx(123.0) + + +# -- coordinator ------------------------------------------------------------ + + +def test_coordinator_reference_and_mode(): + coord = _coordinator() + assert coord.get_mode() == "UNINITIALIZED" + coord.reference() + assert coord.is_ready() and coord.get_mode() == "READY" + + +def test_move_scs_coordinated(): + coord = _coordinator() + sgx = _motor("sgx", "SHX", limits=[-2, 2]) + sgz = _motor("sgz", "SHZ", limits=[10, 22]) + coord.reference() + coord.move_scs(SHX=0.3, SHZ=19.0, wait=True) + assert sgx.position == pytest.approx(0.3) + assert sgz.position == pytest.approx(19.0) + + +def test_move_scs_respects_limits(): + coord = _coordinator() + _motor("sgz", "SHZ", limits=[10, 22]) + coord.reference() + with pytest.raises(LimitError): + coord.move_scs(SHZ=0.0) # below the SHZ range + + +def test_motor_positions_and_transport_info(): + coord = _coordinator() + assert set(coord.motor_positions()) == {"q1", "q2", "q3", "q4", "q5", "q6"} + info = coord.transport_info() + assert info["simulation"] is True and info["connected"] is True + + +def test_mode_signal_is_readonly(): + coord = _coordinator() + assert coord.mode.get() == int(Mode.UNINITIALIZED) + with pytest.raises(ReadOnlyError): + coord.mode.put(2) + + +def test_controller_reachable_from_axis(): + coord = _coordinator() + sgx = _motor("sgx", "SHX") + # dev..controller.reference() path + sgx.controller.reference() + assert coord.is_ready() + + +# -- fault handling --------------------------------------------------------- + + +def test_move_fails_when_target_rejected_up_front(): + coord = _coordinator() + sgx = _motor("sgx", "SHX", limits=[-2, 2]) + coord.reference() + coord.controller.transport.fail_next["move_absolute"] = SmargopoloCommandError("nope") + st = sgx.set(0.5) + with pytest.raises(SmargopoloCommandError): + st.wait(timeout=5) + assert not st.success + + +def test_move_aborts_when_controller_faults_mid_move(): + coord = _coordinator(gradual_motion=True) + sgx = _motor("sgx", "SHX", gradual_motion=True) # shares the gradual controller + coord.reference() + st = sgx.set(1e6) # far target: gradual motion keeps it in-flight + coord.controller.transport._mode = Mode.ERROR + with pytest.raises(SmargopoloModeError): + st.wait(timeout=5) + assert not st.success and sgx.motor_is_moving.get() == 0 + + +def test_move_scs_aborts_on_controller_fault(): + coord = _coordinator(gradual_motion=True) + _motor("sgx", "SHX", gradual_motion=True) + coord.reference() + coord.controller.transport._mode = Mode.ERROR + with pytest.raises(SmargopoloModeError): + coord.move_scs(SHX=1e6, wait=True) diff --git a/tests/tests_devices/test_smargon_transport.py b/tests/tests_devices/test_smargon_transport.py new file mode 100644 index 00000000..8aba62c6 --- /dev/null +++ b/tests/tests_devices/test_smargon_transport.py @@ -0,0 +1,82 @@ +"""Tests for the ophyd-free smargopolo transport layer (FakeTransport).""" + +import pytest + +from csaxs_bec.devices.smargon.transport import Axis, BeamlineAxis, FakeTransport, Mode +from csaxs_bec.devices.smargon.transport.errors import SmargopoloModeError + + +@pytest.fixture +def transport(): + t = FakeTransport() + t.connect() + return t + + +def test_starts_uninitialized_and_refuses_motion(transport): + assert transport.get_mode() is Mode.UNINITIALIZED + with pytest.raises(SmargopoloModeError): + transport.move_absolute({Axis.SHX: 0.5}) + + +def test_referencing_transitions_to_ready(transport): + transport.set_mode(Mode.REFERENCING) + assert transport.get_mode() is Mode.READY + + +def test_absolute_move_updates_readback(transport): + transport.set_mode(Mode.REFERENCING) + transport.move_absolute({Axis.SHX: 0.5, Axis.CHI: 10.0}) + assert transport.get_position(Axis.SHX) == pytest.approx(0.5) + assert transport.get_position(Axis.CHI) == pytest.approx(10.0) + + +def test_relative_move(transport): + transport.set_mode(Mode.REFERENCING) + start = transport.get_position(Axis.SHY) + transport.move_relative({Axis.SHY: 0.2}) + assert transport.get_position(Axis.SHY) == pytest.approx(start + 0.2) + + +def test_get_positions_covers_all_scs_axes(transport): + positions = transport.get_positions() + assert set(positions) == set(Axis) + + +def test_stop_retargets_to_readback(transport): + transport.set_mode(Mode.REFERENCING) + transport.move_absolute({Axis.SHZ: 20.0}) + transport.stop([Axis.SHZ]) + assert transport.get_position(Axis.SHZ) == pytest.approx(20.0) + + +def test_info_reports_serial_and_motors(transport): + info = transport.get_info() + assert info.serial_number == "MCS2-FAKE0001" + assert set(info.motor_positions) == {"q1", "q2", "q3", "q4", "q5", "q6"} + + +def test_seq_advances_on_commands(transport): + first = transport.get_info().seq + transport.set_mode(Mode.REFERENCING) + transport.move_absolute({Axis.SHX: 0.1}) + assert transport.get_info().seq > first + + +def test_nudge_requires_ready(transport): + with pytest.raises(SmargopoloModeError): + transport.nudge_beamline({BeamlineAxis.BX: 0.01}) + transport.set_mode(Mode.REFERENCING) + transport.nudge_beamline({BeamlineAxis.BX: 0.01}) # no raise once READY + + +def test_gradual_motion_advances_over_time(): + clock = {"t": 0.0} + t = FakeTransport(gradual_motion=True, time_func=lambda: clock["t"]) + t.connect() + t.set_mode(Mode.REFERENCING) + t.move_absolute({Axis.SHX: 100.0}) # 50 units/s default speed + clock["t"] = 1.0 + assert t.get_position(Axis.SHX) == pytest.approx(50.0) + clock["t"] = 5.0 + assert t.get_position(Axis.SHX) == pytest.approx(100.0) diff --git a/tests/tests_macros/test_macro_device_access.py b/tests/tests_macros/test_macro_device_access.py new file mode 100644 index 00000000..0daa2c00 --- /dev/null +++ b/tests/tests_macros/test_macro_device_access.py @@ -0,0 +1,62 @@ +"""Static checks on the user macros. + +Macros run in the BEC client namespace and are not covered by unit tests, so mistakes in +them surface only at the beamline -- and, because several macros wrap their body in a +try/except that reports to SciLog, often only as a generic message with the traceback +discarded. These checks catch the failure modes that are decidable statically. +""" + +import ast +import pathlib + +import pytest +from bec_lib.macro_update_handler import has_executable_code + +MACRO_DIR = pathlib.Path(__file__).resolve().parents[2] / "csaxs_bec" / "macros" +MACROS = sorted(p for p in MACRO_DIR.glob("*.py") if p.name != "__init__.py") + + +def test_macro_directory_is_found(): + """Guard against the glob silently matching nothing if the layout changes.""" + assert MACROS, f"no macros found under {MACRO_DIR}" + + +@pytest.mark.parametrize("macro", MACROS, ids=lambda p: p.name) +def test_macros_do_not_go_through_dev_devices(macro: pathlib.Path): + """`dev` is already the device container, so `dev.devices` cannot resolve. + + bec_lib binds `dev = device_manager.devices` in the client namespace, and + DeviceContainer.__getattr__ raises DeviceConfigError for an unknown attribute. So + `dev.devices[name]` asks for a device literally called "devices" and fails before + doing anything useful. Use `dev[name]`. + """ + tree = ast.parse(macro.read_text(), filename=str(macro)) + offenders = [ + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.Attribute) + and node.attr == "devices" + and isinstance(node.value, ast.Name) + and node.value.id == "dev" + ] + assert not offenders, ( + f"{macro.name}: line(s) {offenders} use dev.devices -- `dev` is the device " + "container itself, so this raises DeviceConfigError at runtime. Use dev[]." + ) + + +@pytest.mark.parametrize("macro", MACROS, ids=lambda p: p.name) +def test_macros_are_loadable_by_the_macro_loader(macro: pathlib.Path): + """A module-level statement makes the loader refuse the whole file, silently. + + bec_lib.macro_update_handler.has_executable_code rejects any module-level node that + is not an import, a def, a class, an annotated assignment or a docstring -- a plain + `logger = bec_logger.logger` is enough. The macro is then simply not loaded, and the + only trace is a WARNING in the log stream, so a broken macro looks like a macro that + was never there. The real checker is used here rather than a re-implementation. + """ + blocked, lineno = has_executable_code(macro.read_text()) + assert not blocked, ( + f"{macro.name}: module-level executable code at line {lineno}; the macro loader " + "will refuse the file. Move it inside a function, or use an annotated assignment." + ) diff --git a/tests/tests_macros/test_run_cont_grid_scan_for_table_row.py b/tests/tests_macros/test_run_cont_grid_scan_for_table_row.py new file mode 100644 index 00000000..9c19be78 --- /dev/null +++ b/tests/tests_macros/test_run_cont_grid_scan_for_table_row.py @@ -0,0 +1,99 @@ +"""Tests for the failure reporting of the cont_grid table-row macro. + +The macro runs in the BEC client namespace, where `bec`, `dev` and `scans` are globals +injected by the client. The tests inject a mock `bec` into the module namespace so the +reporting path can be exercised without a session. +""" + +from unittest import mock + +import pytest + +from csaxs_bec.macros import run_cont_grid_scan_for_table_row as macro + +ROW = { + "sample_name": "test40ms", + "sample_id": "3c6e18db10b547aeb86804f524a49489", + "template": "tensor", + "fast_axis": "samx", + "fast_start": -1, + "fast_stop": 1, + "fast_step": 0.01, + "stepper_axis": "samy", + "stepper_start": 0, + "stepper_stop": 1, + "stepper_step": 0.1, + "exposure_time": 0.04, + "rotx_axis": "sgchi", + "rotx": 12.0, + "roty_axis": "sgphi", + "roty": 34.0, +} + + +@pytest.fixture +def mock_bec(): + """Inject a mock `bec` global, as the client would.""" + with mock.patch.object(macro, "bec", mock.MagicMock(), create=True) as m: + yield m + + +def _raise_and_report(mock_row, phone_numbers=None): + """Call the reporter from inside a real except block, so format_exc() has a traceback.""" + try: + raise RuntimeError("the original failure") + except RuntimeError as exc: + macro._report_failure("Something went wrong.", exc, mock_row, phone_numbers) + return exc + + +def test_row_context_names_the_rotation_axes_for_tensor_rows(): + """A tensor failure report must identify which axes and angles were involved.""" + context = macro._row_context(ROW) + assert "sgchi=12.0" in context + assert "sgphi=34.0" in context + assert "test40ms" in context + + +def test_row_context_omits_rotations_for_scanning_rows(): + row = dict(ROW, template="scanning") + assert "sgchi" not in macro._row_context(row) + + +def test_report_prints_the_traceback(mock_bec, capsys): + """The detail must reach the console, not only SciLog.""" + _raise_and_report(ROW) + out = capsys.readouterr().out + assert "the original failure" in out + assert "RuntimeError" in out + assert "Traceback" in out + + +def test_scilog_failure_does_not_mask_the_original_error(mock_bec, capsys): + """A broken SciLog must not become the reported error. + + This is the defect that let a one-line DeviceConfigError survive three retries and + several hours: the unguarded post raised, replacing the exception it was reporting. + """ + mock_bec.messaging.scilog.new.side_effect = ConnectionError("scilog is down") + + _raise_and_report(ROW) # must not raise + + out = capsys.readouterr().out + assert "the original failure" in out # original still reported + assert "SciLog" in out # and the reporting failure is noted + + +def test_sms_failure_does_not_mask_the_original_error(mock_bec, capsys): + mock_bec.messaging.signal.new.side_effect = ConnectionError("no gateway") + + _raise_and_report(ROW, phone_numbers=["+41000000000"]) # must not raise + + out = capsys.readouterr().out + assert "the original failure" in out + assert "SMS" in out + + +def test_no_sms_is_attempted_without_phone_numbers(mock_bec): + _raise_and_report(ROW, phone_numbers=None) + mock_bec.messaging.signal.new.assert_not_called() diff --git a/tests/tests_scans/test_cont_grid_scan.py b/tests/tests_scans/test_cont_grid_scan.py index efeec8d2..e9f04ab9 100644 --- a/tests/tests_scans/test_cont_grid_scan.py +++ b/tests/tests_scans/test_cont_grid_scan.py @@ -65,6 +65,9 @@ def _assemble_cont_grid_scan(v4_scan_assembler, device_manager): signal_read_values={"ddg1": 0.0}, ) custom_ddg1.get_shutter_to_open_delay = mock.MagicMock(return_value=2e-3) + # 0.0 so the hook tests do not spend a real 0.6 s per line; the value itself + # is exercised explicitly further down. + custom_ddg1.get_move_start_latency = mock.MagicMock(return_value=0.0) custom_mcs = MockCustomDevice( "mcs", device_info={ @@ -110,6 +113,58 @@ def test_cont_grid_post_scan_waits_for_completion_and_moves_back_when_relative( assert completion_status.wait_calls == 1 +def test_cont_grid_waits_for_the_move_to_reach_the_motor_before_triggering( + v4_scan_assembler, device_manager, nth_done_status_mock +): + """The burst must not start before the stage does. + + at_each_point issues the line move and then sleeps before firing the trigger. + That sleep used to be acc_time alone, which covers the acceleration ramp and + nothing else -- while the move still has to cross the scan server -> device + server -> EPICS -> controller path first. acc_time shrinks with the scan + velocity; that path does not, so on a slow scan the burst began about 0.6 s + before the stage moved and the first points of every line piled up at the + line start. + """ + scan = _assemble_cont_grid_scan(v4_scan_assembler, device_manager) + scan.device_manager.devices["ddg1"].get_move_start_latency = mock.MagicMock( + return_value=0.35 + ) + scan.prepare_scan() + + scan.actions.set = mock.MagicMock(return_value=nth_done_status_mock(resolve_after=1)) + scan.ddg1 = mock.MagicMock() + # after prepare_scan, so acc_time is still the value the real mock device gave + scan.fast_axis = mock.MagicMock() + scan.actions.read_monitored_devices = mock.MagicMock() + + with mock.patch("csaxs_bec.scans.scans_v4.cont_grid.time.sleep") as sleep: + scan.at_each_point(scan.motors, scan.positions[0], nth_done_status_mock(resolve_after=1)) + + assert scan._cont_motor_params["move_start_latency"] == 0.35 + sleep.assert_called_once_with(0.35 + scan._cont_motor_params["acc_time"]) + + +def test_cont_grid_move_start_latency_of_zero_restores_the_previous_timing( + v4_scan_assembler, device_manager, nth_done_status_mock +): + """The escape hatch: setting the latency to 0 on the device reproduces exactly + what the scan did before this parameter existed, with no redeploy.""" + scan = _assemble_cont_grid_scan(v4_scan_assembler, device_manager) + scan.prepare_scan() + + scan.actions.set = mock.MagicMock(return_value=nth_done_status_mock(resolve_after=1)) + scan.ddg1 = mock.MagicMock() + # after prepare_scan, so acc_time is still the value the real mock device gave + scan.fast_axis = mock.MagicMock() + scan.actions.read_monitored_devices = mock.MagicMock() + + with mock.patch("csaxs_bec.scans.scans_v4.cont_grid.time.sleep") as sleep: + scan.at_each_point(scan.motors, scan.positions[0], nth_done_status_mock(resolve_after=1)) + + sleep.assert_called_once_with(scan._cont_motor_params["acc_time"]) + + def test_cont_grid_prepare_scan_keeps_generated_positions_stable(v4_scan_assembler, device_manager): scan = _assemble_cont_grid_scan(v4_scan_assembler, device_manager) diff --git a/tests/tests_scans/test_fermat_spiral_utils.py b/tests/tests_scans/test_fermat_spiral_utils.py new file mode 100644 index 00000000..249c457f --- /dev/null +++ b/tests/tests_scans/test_fermat_spiral_utils.py @@ -0,0 +1,14 @@ +from csaxs_bec.scans.fermat_spiral_utils import compute_fermat_spiral_n_max + + +def test_n_max_unchanged_for_square_box(): + # the old area-based term already dominates for a square/near-square box, + # so the corner-radius-based term must not change the result there + assert compute_fermat_spiral_n_max(50, 50, 1) == int(50 * 50 * 3.2) + + +def test_n_max_boosted_for_elongated_box(): + # for a long, narrow box the old area-based term under-estimates the + # iterations needed to reach the far corners, so the fixed result must + # exceed it + assert compute_fermat_spiral_n_max(220, 30, 1) > int(220 * 30 * 3.2) diff --git a/tests/tests_scans/test_flomni_fermat_scan.py b/tests/tests_scans/test_flomni_fermat_scan.py index a0aacb80..2e2e4c0e 100644 --- a/tests/tests_scans/test_flomni_fermat_scan.py +++ b/tests/tests_scans/test_flomni_fermat_scan.py @@ -1,5 +1,7 @@ from unittest import mock +import numpy as np + from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan @@ -43,3 +45,110 @@ def test_flomni_rotation_moves_when_setpoint_matches_but_readback_far_off(): FlomniFermatScan.flomni_rotation(scan, 10.0) scan.actions.set.assert_called_once() + + +def test_flomni_fermat_spiral_reaches_elongated_fov_corners(): + # Regression test: for an elongated (non-square) FOV, the spiral's iteration + # cap used to be sized from FOV area alone, which under-estimated the + # iterations needed and left the loop-generated points well short of the + # long axis's outer region (only the two explicit corner points reached it). + fovx, fovy = 220.0, 30.0 + pos = FlomniFermatScan.get_flomni_fermat_spiral_pos( + -fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=1 + ) + loop_points = pos[:-2] # exclude the two corner points appended after the loop + assert np.max(np.abs(loop_points[:, 0])) > 0.95 * (fovx / 2) + assert len(pos) > FlomniFermatScan.MIN_POSITIONS + + +def test_fermat_asymmetry_default_matches_symmetric_spiral(): + # fermat_asymmetry=1.0 (or omitted) must reproduce exactly today's positions -- + # no-op / backward-compatibility check. Same seed on both calls isolates this + # from the (unrelated) per-call random sub-step offset. + fovx, fovy = 10.0, 10.0 + default_call = FlomniFermatScan.get_flomni_fermat_spiral_pos( + -fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=1, seed=0 + ) + explicit_symmetric = FlomniFermatScan.get_flomni_fermat_spiral_pos( + -fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=1, seed=0, fermat_asymmetry=1.0 + ) + np.testing.assert_array_equal(default_call, explicit_symmetric) + + +def test_fermat_asymmetry_biases_spacing_while_preserving_fov(): + # Directly verifies the documented "generate against an inflated fovy, then + # compress y back down" design: get_flomni_fermat_spiral_pos(..., fovy, + # fermat_asymmetry=ratio) must select exactly the same raw lattice points as + # get_flomni_fermat_spiral_pos(..., fovy*ratio, fermat_asymmetry=1.0) (same + # seed => same random sub-step offset), just with y compressed by `ratio`. + fovx, fovy, step = 40.0, 40.0, 1.0 + for ratio in (2.0, 0.5): + asym = FlomniFermatScan.get_flomni_fermat_spiral_pos( + -fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=step, seed=42, fermat_asymmetry=ratio + ) + inflated_symmetric = FlomniFermatScan.get_flomni_fermat_spiral_pos( + -fovx / 2, fovx / 2, -(fovy * ratio) / 2, (fovy * ratio) / 2, step=step, seed=42 + ) + asym_loop = asym[:-2] + inflated_loop = inflated_symmetric[:-2] + + assert len(asym_loop) == len(inflated_loop) + np.testing.assert_allclose(asym_loop[:, 0], inflated_loop[:, 0]) + np.testing.assert_allclose(asym_loop[:, 1] * ratio, inflated_loop[:, 1]) + + # FOV preserved + assert np.max(np.abs(asym_loop[:, 0])) <= fovx / 2 + assert np.max(np.abs(asym_loop[:, 1])) <= fovy / 2 + + +def test_fermat_asymmetry_out_of_range_is_clamped_not_raised(): + fovx, fovy, step = 10.0, 10.0, 1.0 + pos_too_large = FlomniFermatScan.get_flomni_fermat_spiral_pos( + -fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=step, seed=0, fermat_asymmetry=100.0 + ) + pos_clamped = FlomniFermatScan.get_flomni_fermat_spiral_pos( + -fovx / 2, + fovx / 2, + -fovy / 2, + fovy / 2, + step=step, + seed=0, + fermat_asymmetry=FlomniFermatScan.MAX_FERMAT_ASYMMETRY, + ) + np.testing.assert_array_equal(pos_too_large, pos_clamped) + loop_points = pos_too_large[:-2] + assert np.max(np.abs(loop_points[:, 0])) <= fovx / 2 + assert np.max(np.abs(loop_points[:, 1])) <= fovy / 2 + + +def test_randomize_offset_default_is_active(): + # Default keeps the per-call random sub-step offset: different seeds give different + # lattices, and omitting randomize_offset equals randomize_offset=True. + fovx, fovy = 10.0, 10.0 + args = (-fovx / 2, fovx / 2, -fovy / 2, fovy / 2) + a = FlomniFermatScan.get_flomni_fermat_spiral_pos(*args, step=1, seed=1) + b = FlomniFermatScan.get_flomni_fermat_spiral_pos(*args, step=1, seed=2) + c = FlomniFermatScan.get_flomni_fermat_spiral_pos( + *args, step=1, seed=1, randomize_offset=True + ) + assert a.shape != b.shape or not np.allclose(a, b) + np.testing.assert_array_equal(a, c) + + +def test_randomize_offset_false_gives_unshifted_deterministic_lattice(): + # randomize_offset=False ignores the seed and is identical from call to call. + fovx, fovy = 10.0, 10.0 + args = (-fovx / 2, fovx / 2, -fovy / 2, fovy / 2) + a = FlomniFermatScan.get_flomni_fermat_spiral_pos( + *args, step=1, seed=1, randomize_offset=False + ) + b = FlomniFermatScan.get_flomni_fermat_spiral_pos( + *args, step=1, seed=2, randomize_offset=False + ) + d = FlomniFermatScan.get_flomni_fermat_spiral_pos(*args, step=1, randomize_offset=False) + np.testing.assert_array_equal(a, b) + np.testing.assert_array_equal(a, d) + # unshifted lattice: first loop point (ii=1) is at radius step*0.57, no offset + phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2.0) + r = 0.57 + np.testing.assert_allclose(a[0, :2], [r * np.sin(phi), r * np.cos(phi)]) diff --git a/tests/tests_scans/test_lamni_fermat_scan.py b/tests/tests_scans/test_lamni_fermat_scan.py index c013275e..cf4fd4c0 100644 --- a/tests/tests_scans/test_lamni_fermat_scan.py +++ b/tests/tests_scans/test_lamni_fermat_scan.py @@ -1,5 +1,7 @@ from unittest import mock +import numpy as np + from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan @@ -44,3 +46,77 @@ def test_lamni_rotation_moves_when_setpoint_matches_but_not_settled(): scan.dev.lsamrot.set.assert_called_once_with(10.0) scan.dev.lsamrot.set.return_value.wait.assert_called_once_with() + + +def test_lamni_fermat_spiral_reaches_elongated_fov_corners(): + # Regression test: for an elongated (non-square) FOV, the spiral's iteration + # cap used to be sized from FOV area alone, which under-estimated the + # iterations needed and left coverage well short of the long axis's outer + # region. lamni's returned x/y are post-transform-shift, so assert on point + # count growth (46 -> 73 for this case) rather than raw coordinate magnitude. + fovx, fovy = 79.0, 2.0 + pos = LamniFermatScan.get_lamni_fermat_spiral_pos( + -fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=1, fovx=fovx, fovy=fovy + ) + assert len(pos) >= 60 + + +def _spiral(**overrides): + kwargs = dict( + m1_start=-40.0, + m1_stop=40.0, + m2_start=-40.0, + m2_stop=40.0, + step=10.0, + angle=0.0, + fovx=80.0, + fovy=80.0, + seed=0, + ) + kwargs.update(overrides) + return LamniFermatScan.get_lamni_fermat_spiral_pos(**kwargs) + + +def test_shift_and_center_are_mm_and_positions_are_um(): + """Unit contract of the LamNI scan: shift_x/shift_y (LamNI's manual shift) + and center_x/center_y (the FOV offsets) are in mm, the generated scan + positions handed to the RT controller are in um. A shift of 0.01 mm must + therefore move every position by exactly 10 um. + + (The tomo-parameters GUI used to label the manual shift "um" with a + +-1000 range -- copied from flomni -- although it is mm end to end.) + """ + base = _spiral() + shifted_x = _spiral(shift_x=0.01) + shifted_y = _spiral(shift_y=0.005) + centered_x = _spiral(center_x=0.01) # at angle 0, stitch 0: same physical shift + + assert base.shape == shifted_x.shape == shifted_y.shape == centered_x.shape + np.testing.assert_allclose(shifted_x - base, [[10.0, 0.0]] * len(base), atol=1e-9) + np.testing.assert_allclose(shifted_y - base, [[0.0, 5.0]] * len(base), atol=1e-9) + np.testing.assert_allclose(centered_x - base, [[10.0, 0.0]] * len(base), atol=1e-9) + + +def test_ddg1_armed_in_pre_scan_and_reset_after_scan(): + """ddg1 must be switched to external triggering before the devices are pre-scanned, + and back to single shot after the scan and on exceptions.""" + from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import TRIGGERSOURCE + + scan = mock.MagicMock() + manager = mock.MagicMock() + manager.attach_mock(scan.dev.ddg1.set_trigger, "set_trigger") + manager.attach_mock(scan.actions.pre_scan_all_devices, "pre_scan_all_devices") + + LamniFermatScan.pre_scan(scan) + assert manager.mock_calls == [ + mock.call.set_trigger(TRIGGERSOURCE.EXT_RISING_EDGE.value), + mock.call.pre_scan_all_devices(), + ] + + scan.dev.ddg1.set_trigger.reset_mock() + LamniFermatScan.post_scan(scan) + scan.dev.ddg1.set_trigger.assert_called_once_with(TRIGGERSOURCE.SINGLE_SHOT.value) + + scan.dev.ddg1.set_trigger.reset_mock() + LamniFermatScan.on_exception(scan, RuntimeError("boom")) + scan.dev.ddg1.set_trigger.assert_called_once_with(TRIGGERSOURCE.SINGLE_SHOT.value) diff --git a/tests/tests_scans/test_omny_fermat_scan.py b/tests/tests_scans/test_omny_fermat_scan.py index f17c7498..7080fe7b 100644 --- a/tests/tests_scans/test_omny_fermat_scan.py +++ b/tests/tests_scans/test_omny_fermat_scan.py @@ -99,9 +99,10 @@ def test_get_omny_fermat_spiral_pos_count_ignores_cenx_ceny_zshift(): """cenx/ceny/zshift only shift the final position (applied after the FOV keep-or-discard check), so they must never change the resulting position count -- same invariant flomni's version relies on.""" - baseline = OmnyFermatScan.get_omny_fermat_spiral_pos(-5, 5, -5, 5, step=1, spiral_type=0) + # same seed: both calls get the same random sub-step offset (see seed in the function) + baseline = OmnyFermatScan.get_omny_fermat_spiral_pos(-5, 5, -5, 5, step=1, spiral_type=0, seed=1) shifted = OmnyFermatScan.get_omny_fermat_spiral_pos( - -5, 5, -5, 5, step=1, spiral_type=0, cenx=100.0, ceny=-50.0, zshift=7.0 + -5, 5, -5, 5, step=1, spiral_type=0, cenx=100.0, ceny=-50.0, zshift=7.0, seed=1 ) assert len(shifted) == len(baseline) @@ -133,3 +134,14 @@ def test_prepare_scan_aborts_below_min_positions(): with pytest.raises(ScanAbortion): OmnyFermatScan.prepare_scan(scan) + + +def test_omny_fermat_spiral_reaches_elongated_fov_corners(): + # Regression test: for an elongated (non-square) FOV, the spiral's iteration + # cap used to be sized from FOV area alone, which under-estimated the + # iterations needed and left the loop-generated points well short of the + # long axis's outer region (only the two explicit corner points reached it). + fovx, fovy = 190.0, 20.0 # stay under omny's fovx<200 / fovy<100 argument bounds + pos = OmnyFermatScan.get_omny_fermat_spiral_pos(-fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=1) + loop_points = pos[:-2] # exclude the two corner points appended after the loop + assert np.max(np.abs(loop_points[:, 0])) > 0.95 * (fovx / 2) diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 00000000..37fe217e --- /dev/null +++ b/zensical.toml @@ -0,0 +1,88 @@ +[project] +site_name = "SLS cSAXS" +site_url = "https://sls-csaxs.readthedocs.io/en/latest/" +site_description = "User and developer documentation for the cSAXS beamline" +site_author = "Paul Scherrer Institute" +repo_url = "https://gitea.psi.ch/bec/csaxs_bec" +repo_name = "bec/csaxs_bec" +copyright = "Copyright © Paul Scherrer Institute" +docs_dir = "docs" +site_dir = "site" +extra_css = ["stylesheets/extra.css"] +# Preserve Sphinx's existing page URLs. +use_directory_urls = false +exclude_docs = """ +/plans/ +/_build/ +/api_reference/ +*.log +/objects.inv +/developer/ptouch_label_printer_plan.md +""" +nav = [ + { "Home" = "index.md" }, + { "Introduction" = "introduction/introduction.md" }, + { "Safety" = "safety/safety.md" }, + { "User guide" = [ + { "Overview" = "user/user.md" }, + { "Before you arrive" = "user/before_you_arrive/before_you_arrive.md" }, + { "Ptychography" = [ + { "Overview" = "user/ptychography/ptychography.md" }, + { "flOMNI" = "user/ptychography/flomni.md" }, + { "LamNI" = "user/ptychography/lamni.md" }, + { "OMNY" = "user/ptychography/omny.md" }, + { "Ptychoshelves" = "user/ptychography/reconstructions.md" }, + ] }, + { "SAXS" = [ + { "Overview" = "user/saxs/saxs.md" }, + { "Online integration" = "user/saxs/online_integration.md" }, + ] }, + { "Data analysis" = [ + { "Overview" = "user/data_analysis/data_analysis.md" }, + { "Computing" = "user/data_analysis/computing.md" }, + ] }, + ] }, + { "Developer guide" = [ + { "Overview" = "developer/developer.md" }, + { "Editing the documentation" = "developer/editing_docs.md" }, + { "LamNI smear aid architecture" = "developer/lamni_smear_architecture.md" }, + { "PandaBox free-running setup" = "developer/panda_box_free_running_setup.md" }, + { "PandaBox data schema" = "developer/panda_box_free_running_data_schema.md" }, + ] }, +] + +[project.theme] +language = "en" +features = ["navigation.tabs", "navigation.sections", "navigation.top", "content.code.copy", "search.highlight"] + +[[project.theme.palette]] +media = "(prefers-color-scheme)" +toggle.icon = "lucide/sun-moon" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +toggle.icon = "lucide/moon" +toggle.name = "Switch to system preference" + +[project.markdown_extensions.admonition] +[project.markdown_extensions.attr_list] +[project.markdown_extensions.md_in_html] +[project.markdown_extensions.pymdownx.betterem] +smart_enable = "all" +[project.markdown_extensions.pymdownx.highlight] +[project.markdown_extensions.pymdownx.keys] +[project.markdown_extensions.pymdownx.superfences] +[project.markdown_extensions.pymdownx.tabbed] +alternate_style = true +[project.markdown_extensions.pymdownx.tasklist] +custom_checkbox = true +[project.markdown_extensions.toc] +permalink = true