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:
commit
f53a5ffb13
213 files changed
+23546
-3290
No files matched your search
+4
-1
@@ -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
@@ -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/"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+55
-6
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
File renamed without changes.
Binary file not shown.
@@ -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
+114
@@ -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">—</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">—</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
|
||||
|
||||
BIN
Binary file not shown.
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}")
|
||||
+46
@@ -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.
|
||||
+47
@@ -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.
|
||||
+62
@@ -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.
|
||||
+58
@@ -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.
|
||||
+68
@@ -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.
|
||||
+25
@@ -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.
|
||||
+40
@@ -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"""
|
||||
██████╗ ███████╗ ██████╗ ███████╗██╗ ██████╗ ███╗ ███╗███╗ ██╗██╗
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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<
|
||||
@@ -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
Reference in new issue
Block a user