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
+- { 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.
+- { 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.
-```
-
-````
+