Merge origin/main into feat/omny-sample-ioc (based on update-omny)

Conflicts resolved:
- omny_fermat_scan: static get_omny_fermat_spiral_pos keeps cenx/ceny/zshift
  and main's seeded random offset; prepare_scan passes the offsets again
  (update-omny had dropped them); corridor cap kept, exp_time 0.1.
- tomo_params: per-profile tomo_types with an angle_file flag (flomni/lamni
  type 4 'Angles from file'), no global TOMO_TYPES.
- sample_storage widget: _MeasuredSampleLog removed (main), shuttle status
  labels kept (update-omny).
- tomo_queue_mixin, omny.py, omny.md: both sides kept.
- test_omny_fermat_scan: main's elongated-FOV test adapted to the static
  signature; cenx test uses a fixed seed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNtKEk4jAyjebMDQFwRHJQ
This commit is contained in:
Mirko HollerandClaude Opus 5.5 committed 2026-10-18 19:19:04 +02:00
commit f53a5ffb13
213 files changed
+23546 -3290

No files matched your search

+4 -1
View File
@@ -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/
+7 -20
View File
@@ -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/"
+62
View File
@@ -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.
+26
View File
@@ -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.
@@ -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
@@ -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"),
]
@@ -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:
@@ -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"
File diff suppressed because it is too large. Load diff
@@ -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_<timestamp>.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 = []
@@ -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")
File diff suppressed because it is too large. Load diff
@@ -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.
@@ -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=<um> Fermat scan step size, 0.025-20.0
- exptime=<s> exposure/counting time per frame, 0.001-100.0
- frames=<n> 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}"
)
@@ -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).
@@ -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():
@@ -107,9 +107,6 @@
<h2>Operator Messages</h2>
<div class="panel"><div id="messages"></div></div>
<h2>Alarm Status</h2>
<div id="hero" class="hero unknown"><div class="hero-title">&mdash;</div></div>
<h2>Mono Cryocooler</h2>
<div class="panel"><div class="cryo" id="cryo"></div></div>
@@ -127,6 +124,9 @@
<h2>Zone Detail</h2>
<div class="zones" id="zones"></div>
<h2>Alarm Status</h2>
<div id="hero" class="hero unknown"><div class="hero-title">&mdash;</div></div>
<h2>Recent History</h2>
<div class="panel" id="history"></div>
@@ -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,
}
@@ -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
@@ -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
@@ -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/
<setup>_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
@@ -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 <signal not yet decided>.
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(<job 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.")
@@ -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);
}}
}}
@@ -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.<axis>.readback.get()
Determines stage positions via dev.<axis>.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
@@ -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:
@@ -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_<field>/value`, where `<field>` 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/<device>/<device>_data_<field>/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<id>/` 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.
@@ -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.<device>.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.<name>.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": "<device>"`
(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.<name>.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
@@ -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
@@ -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
File diff suppressed because it is too large. Load diff
@@ -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")
@@ -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 = {
@@ -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)
@@ -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}")
@@ -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_<id>.pdf` | `~/data/raw/documentation/tomo_scan_ID_<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.
@@ -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).
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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".
@@ -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.
@@ -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.
@@ -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.
@@ -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):
@@ -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"""
██████╗ ███████╗ ██████╗ ███████╗██╗ ██████╗ ███╗ ███╗███╗ ██╗██╗
+96
View File
@@ -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":
@@ -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):
@@ -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",
@@ -0,0 +1,3 @@
from csaxs_bec.bec_widgets.widgets.ids_camera_settings.ids_camera_settings import IDSCameraSettings
__all__ = ["IDSCameraSettings"]
@@ -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_())
@@ -0,0 +1 @@
{'files': ['ids_camera_settings.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 = """
<ui language='c++'>
<widget class='IDSCameraSettings' name='ids_camera_settings'>
</widget>
</ui>
"""
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()
@@ -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()
@@ -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:<slot>:USED/NAME/OWNER/STATUS/MEAS_DATE); the measurement
status travels with the sample and is no longer kept in a BEC global var.
OMNY, stage/gripper/fixed-O/shuttle slots -- see ``_write_slot_omny()``.
OMNY, parking : parking_placed.parking{N}.set(used)
parking_names.parking{N}.set(letter or "none")
@@ -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:<slot>:STATUS)
or None if this slot's current occupant has no measured record.
OMNY cells always pass measured_status=None (no measured-sample log
for OMNY -- see module docstring), which the measured label below
@@ -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")
File diff suppressed because it is too large. Load diff
@@ -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()
+37 -10
View File
@@ -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
+6 -1
View File
@@ -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
@@ -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:
+29
View File
@@ -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
+6 -6
View File
@@ -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
+128 -43
View File
@@ -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
+79 -18
View File
@@ -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 ###########################
@@ -104,6 +104,9 @@ fheater:
readOnly: false
readoutPriority: baseline
connectionTimeout: 20
userParameter:
in: -0.5
enabled: 1
deviceTags:
- simulated_flomni
@@ -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
+86
View File
@@ -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
@@ -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
@@ -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
@@ -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
+16 -16
View File
@@ -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)
@@ -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()
@@ -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(
@@ -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.<name>()
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)
@@ -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:")
+185 -2
View File
@@ -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.<name>()
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.
@@ -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:
@@ -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",
)
+222
View File
@@ -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()
+190 -26
View File
@@ -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):
@@ -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,
)
+10 -3
View File
@@ -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,
+10 -3
View File
@@ -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,
@@ -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/<key>
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
+183 -78
View File
@@ -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:<slot>:USED 0 = free, 1 = sample present
FLOMNI-SAMPLE:<slot>:NAME sample name (long string, up to 256 chars)
FLOMNI-SAMPLE:<slot>:OWNER e-account of the owner
FLOMNI-SAMPLE:<slot>:STATUS none / started / completed (measurement status)
FLOMNI-SAMPLE:<slot>:MEAS_DATE date of the measurement
<slot> = 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<n>: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")
@@ -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:
+10 -1
View File
@@ -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:
"""
+2 -1
View File
@@ -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")
+2 -1
View File
@@ -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])
+135 -1
View File
@@ -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))
+6
View File
@@ -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)
@@ -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<B
SEQ2.TABLE<B
PGEN1.TABLE<B
PGEN2.TABLE<B
*METADATA.LAYOUT<
{"PCAP": {"x": -2.0, "y": 132.41562128067017},
"TTLIN1": {"x": -350.0, "y": -127.0},
"INENC1": {"x": 355.0, "y": -441.0},
"INENC2": {"x": 358.0, "y": -240.0},
"INENC3": {"x": 593.0, "y": -440.0},
"INENC4": {"x": 595.0, "y": -232.0},
"COUNTER1": {"x": 69.0, "y": -100.0}}
*METADATA.EXPORTS<
@@ -0,0 +1,877 @@
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=Value
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=Value
INENC2.VAL.CAPTURE=Value
INENC3.VAL.CAPTURE=Value
INENC4.VAL.CAPTURE=Value
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=Value
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=Value
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=Value
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=CLOCK1.OUT
PCAP.ENABLE=ONE
PCAP.SHIFT_SUM=0
PCAP.TRIG_EDGE=Falling
PCAP.TRIG=CLOCK1.OUT
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=ONE
CLOCK2.ENABLE=ZERO
CLOCK1.PERIOD=0.0001
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<B
SEQ2.TABLE<B
PGEN1.TABLE<B
PGEN2.TABLE<B
*METADATA.LAYOUT<
{"PCAP": {"x": -2.0, "y": 132.41562128067017},
"TTLIN1": {"x": -350.0, "y": -127.0},
"INENC1": {"x": 355.0, "y": -441.0},
"INENC2": {"x": 358.0, "y": -240.0},
"INENC3": {"x": 593.0, "y": -440.0},
"INENC4": {"x": 595.0, "y": -232.0},
"COUNTER1": {"x": 69.0, "y": -100.0},
"CLOCK1": {"x": -388.7071887784922, "y": 140.75832846288722}}
*METADATA.EXPORTS<
+411 -3
View File
@@ -1,11 +1,15 @@
"""Module to integrate the PandaBox for cSAXS measurements."""
import threading
import time
from typing import Any
from bec_lib.logger import bec_logger
from bec_server.scan_server.scans.scan_base import ScanInfo as ScanServerScanInfo
from ophyd_devices import StatusBase
from ophyd_devices.devices.panda_box.panda_box import PandaBox, PandaState
from ophyd_devices.interfaces.base_classes.psi_device_base import DeviceStoppedError
from pandablocks.responses import FrameData
from csaxs_bec.devices.utils.utils import fetch_scan_info
@@ -13,21 +17,149 @@ logger = bec_logger.logger
class PandaBoxOMNY(PandaBox):
"""PandaBox integration for OMNY. This class implements OMNY specific logic for the PandaBox integration."""
"""PandaBox integration for OMNY. This class implements OMNY specific logic for the PandaBox integration.
Two operation modes are supported, selected via ``raw_stream_mode``:
- Legacy mode (``raw_stream_mode=False``, default): unchanged behavior. Each PCAP gate
(one per scan point / burst sub-frame) produces one aggregate statistic
(Mean/Min/Max/Sum/Diff) that is published immediately, and completion is checked
against the exact expected number of gates (``num_points * frames_per_trigger``).
- Free-running mode (``raw_stream_mode=True``): the PCAP module is expected to be
configured on the PandaBox itself to capture raw ``Value`` samples continuously at a
much higher rate for the duration of a burst window, alongside a digital gate-input bit
and a monotonic frame counter (see docs/developer/panda_box_free_running_setup.md).
Incoming samples are coalesced client-side (by row count or time, whichever comes
first) before being published, to keep the message rate to the BEC message bus
bounded regardless of how the PandaBox itself batches network frames. Completion is
checked against the frame counter (COUNTER1, aliased to ``frame_counter``), which
increments on the detector-trigger's falling edge, reaching the expected
``num_points * frames_per_trigger`` count once the last exposure finishes.
``*PCAP.CAPTURED?`` can't be used for this: on this layout PCAP's gate/trig are driven
continuously by an internal clock, so it never naturally stops incrementing while
armed.
"""
# PCAP.BITS0 is a shared 32-bit capture word; other captured bit_out signals (e.g. other
# TTLIN/LVDSIN/encoder bits also flagged for capture) may share it at different bit
# offsets, so the raw word is not itself a clean 0/1. TTLIN1.VAL (the detector-trigger
# gate signal, aliased to _GATE_SIGNAL_NAME below) was confirmed at bit offset 0 of
# PCAP.BITS0 on the omny-panda hardware on 2026-09-15 via
# `TTLIN1.VAL.CAPTURE_WORD?`/`TTLIN1.VAL.OFFSET?` (see
# docs/developer/panda_box_free_running_setup.md). Re-verify with the same query if the
# PandA layout is ever rebuilt.
_GATE_SIGNAL_NAME = "gate_detector_active"
_GATE_BIT_OFFSET = 0
# Arming in on_pre_scan is tried this many times (one forced abort/reset in between) before
# the scan is failed. READY normally arrives within milliseconds of the readout thread
# connecting, so a missing READY means the readout thread/box is in a stale state.
_PRE_SCAN_ARM_ATTEMPTS = 2
# PandA's COUNTER1.OUT (the block behind frame_counter) is not cleared by Arm() --
# COUNTER1.ENABLE=PCAP.ACTIVE only gates counting, not the persisted output value, and
# COUNTER1.SET is wired to a constant (never pulsed) in the saved layout (see
# layouts/omny_freerun.ini). So the first raw samples of a new acquisition can briefly
# carry a stale frame_counter value left over from the previous acquisition's tail --
# confirmed 2026-09-21 against a real scan (S06313): 36 samples reading a stale value
# before dropping to 0 and counting correctly from there. If that stale value collides
# with a real point index later in the same scan, grouping raw samples by frame_counter
# silently mixes those stale rows into that point's average (see
# docs/developer/panda_box_free_running_setup.md, "Known data-quality caveat"). Rows are
# dropped client-side until the first observed frame_counter == 0 per acquisition to
# guard against this; see _reset_raw_stream_state/_drop_stale_frame_counter_rows below.
_FRAME_COUNTER_SIGNAL_NAME = "frame_counter"
def __init__(
self,
*,
name: str,
host: str,
signal_alias: dict[str, str] | None = None,
scan_info: ScanServerScanInfo | None = None,
device_manager: Any | None = None,
raw_stream_mode: bool = False,
raw_stream_flush_interval: float = 0.05,
raw_stream_flush_row_count: int = 500,
pre_scan_timeout: float = 5.0,
arm_scan_allowlist: list[str] | None = None,
**kwargs,
) -> 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
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff. Show more