Fzp move beyond #338

Merged
holler merged 20 commits from fzp-move-beyond into main 2026-10-26 13:19:41 +01:00
29 changed files with 1764 additions and 351 deletions

No files matched your search

@@ -35,6 +35,26 @@ def umvr(*args):
return scans.umv(*args, relative=True)
def read_escape_suffix(stream, timeout: float = 0.05) -> str | None:
"""The two characters after an ESC on a non-blocking stdin (arrow keys: "[A".."[D"),
or None if they do not arrive within `timeout` (ESC alone, or a slow terminal).
A plain stream.read(2) can return fewer characters, or None, before the rest of the
sequence has arrived; unpacking that raised and ended the tweak with a traceback."""
chars = ""
deadline = time.monotonic() + timeout
while len(chars) < 2 and time.monotonic() < deadline:
try:
got = stream.read(2 - len(chars))
except (IOError, TypeError):
got = None
if got:
chars += got
else:
time.sleep(0.005)
return chars if len(chars) == 2 else None
class OMNYTools:
HEADER = "\033[95m"
@@ -164,10 +184,10 @@ class OMNYTools:
self, dev1, step1: float, dev2="none", step2: float = "0", special_command="none"
):
if dev1 not in dev.enabled_devices:
print(f"Device 1 {dev} is not in enabled devices.")
print(f"Device 1 {dev1} is not in enabled devices.")
return
if dev2 not in dev.enabled_devices and dev2 != "none":
print(f"Device 2 {dev} is not in enabled devices.")
print(f"Device 2 {dev2} is not in enabled devices.")
return
# Save the current terminal settings
fd = sys.stdin.fileno()
@@ -187,7 +207,8 @@ class OMNYTools:
print("\n\rExiting tweak mode\r")
break
elif key == "\x1b": # Escape sequences for arrow keys
next1, next2 = sys.stdin.read(2)
suffix = read_escape_suffix(sys.stdin)
next1, next2 = suffix if suffix else ("", "")
if next1 == "[":
if next2 == "A":
if dev2 != "none":
@@ -1157,6 +1157,8 @@ class TomoQueueMixin:
self.progress["estimated_finish_time"] = None
self.progress["accumulated_idle_time"] = 0.0
self.progress["heartbeat"] = None
# flOMNI: a completed scan is not resumed (tomo_scan_resume); reopened here on purpose
self.progress["tomo_completed"] = False
job_id = job["id"]
self._tomo_queue_proxy.update_by_id(job_id, status="incomplete")
@@ -2,7 +2,7 @@
Source of `local_contact_only-howto_calibrate_the_fzp_scanner.pdf`. The original text is kept unchanged; the
section "Update 2026-10-20" at the end lists what the present flOMNI loop (PREEMPT_RT, 100 µs, branch `rt_preemt`)
needs in addition.
needs in addition (corrected 2026-10-23: "move-ahead" → **move-beyond**, see the section at the end).
```text
This is a description of how to calibrate the FZP scanner of flOMNI
@@ -72,6 +72,11 @@ Default values for the fzp pid, fast FZP parameter are "1,0.0001,0.1,-
4,0,0,0"
```
Naming: "move ahead" in the text above (sample PIDs `FAST_FZP_PID_LOOP(0)` < 0) is called **move-beyond** from
2026-10-23 on: the sample stage moves *beyond* the scan point towards the next one, the FZP compensates and so sits
beyond its centre during the exposure, on the side that gives it more stroke for the next step. It is not a
"pre-move" of the FZP to the next point (that is `TargetSelect` `use_final` 1, see below).
## Update 2026-10-20 (flOMNI loop on PREEMPT_RT, mpc2844)
The loop has new modules since the text above was written (flOMNI `FeedbackLoop/tuning_guide.md`,
@@ -79,8 +84,8 @@ The loop has new modules since the text above was written (flOMNI `FeedbackLoop/
Parameters) instead of OrchestraHMI. `i` = x or y, as above. Module names now: `PID_x`, `PID_y` (sample stage),
`PID_x_fzp`, `PID_y_fzp` (FZP scanner), `AnalogInput_6289` (was `AnalogInput_6259`).
**FAST_FZP_PID_LOOP has 7 elements** (`PID.cpp`). Sample stage PIDs: (0) move-ahead (negative, µm, point-by-point
scans only), (4) FZP error threshold (µm) and (5) FZP drive limit (V) for holding the sample stage while the FZP
**FAST_FZP_PID_LOOP has 7 elements** (`PID.cpp`). Sample stage PIDs: (0) move-beyond (negative, µm, point-by-point
scans only; section "Move-beyond" below), (4) FZP error threshold (µm) and (5) FZP drive limit (V) for holding the sample stage while the FZP
takes the step (5 = 0: never held), (6) 1 = hold the sample stage at 0. FZP PIDs: (0) mode (1 operation, 2 FZP
centred on its cap sensor = disabled, 3 not commissioned, 4 debug), (1)–(3) min / max / gain of the old velocity
correction (−4 before 2026-10-19, now 0: replaced by the feedforward).
@@ -97,14 +102,15 @@ correction (−4 before 2026-10-19, now 0: replaced by the feedforward).
- FZP range: `PID_x_fzp` / `PID_y_fzp` `Ub` / `Lb` are ±1.5 V in operation (the cap sensors are calibrated over
about ±2 V only). For the field of view above (2.8 × 1.8 µm) widen them, e.g. to ±10 V (FZP ≈ 0.13 µm/V in x,
≈ 0.05 µm/V in y). The feedforward stays off (`enable` 0,0) during this scan.
- The detector trigger (`OnTargetTrigger`) checks the relative error sample vs FZP (tolerance / hold time from the
ConstEmitter, BEC `interferometer_trigger_*`, default 10 nm / 3 ms) and waits `MinDelay_ms` (15 ms).
- The detector trigger (`OnTargetTrigger`) checks the relative error sample vs FZP (parameters
`ontarget_tolerance_um` / `ontarget_hold_ms`, BEC `flomni.realtime_system.trigger_*`, default 10 nm / 3 ms; until
2026-10-23 ConstEmitter lines) and waits `MinDelay_ms` (15 ms).
**Step 3, default operating values (replace the values in the text above):**
| Module | Parameter | Value |
|---|---|---|
| `PID_x`, `PID_y` | `FAST_FZP_PID_LOOP` | `0,0,0,0,0.1,0,0` (no move-ahead: −0.5 µm does not fit the ±1.5 V FZP range) |
| `PID_x`, `PID_y` | `FAST_FZP_PID_LOOP` | `0,0,0,0,0.1,0,0` (no move-beyond: −0.5 µm is larger than the ±1.5 V FZP stroke, x ≈ ±0.19 µm, y ≈ ±0.08 µm) |
| `PID_x_fzp` | `FAST_FZP_PID_LOOP`, `Ub` / `Lb` | `1,0.0001,0.1,0,0,0,0`, ±1.5 V |
| `PID_y_fzp` | `FAST_FZP_PID_LOOP`, `Ub` / `Lb` | `1,0.0005,0.02,0,0,0,0`, ±1.5 V |
| `Feedforward` | `enable`; `ff_limit`, `out_min` / `out_max` | 1,1; ±1.5 V (as the FZP `Ub` / `Lb`) |
@@ -122,7 +128,77 @@ over a wider range the FZP can take the small steps alone (flOMNI `feedback_repo
- `PID_x_fzp` / `PID_y_fzp` `Ub` / `Lb` and the `Feedforward` limits to the calibrated range (e.g. ±7 V);
- `TargetSelect_x` / `_y` `use_final` 1 (the FZP aims at the new point at once, the sample stage follows its
S-curve) and `OnTargetTrigger` `WaitForSlew` 0 (trigger on the relative error only); optionally `MinDelay_ms` 0;
- optionally move-ahead, sized to the FZP range: `PID_x` (0) e.g. −0.5, `PID_y` (0) e.g. −0.2.
- optionally move-beyond, sized to the FZP stroke (≈ half of it, see below): at ±7 V (x ≈ ±0.9 µm, y ≈ ±0.36 µm)
`PID_x` (0) e.g. −0.5, `PID_y` (0) e.g. −0.2 (the values tested in `feedback_report.md` 6q).
Measured with 101 ms exposures: 0.2–1 µm steps trigger after 4.5–5.8 ms instead of 17–32 ms; the error during the
exposure grows with the step (1 µm: ≈ 6 nm max instead of 2.5 nm), so the setting depends on the experiment.
## With the loop settings (since 2026-10-23)
The steps above are profiles of the loop settings (web HMI tab *Loop settings*, BEC `flomni.realtime_system`;
flOMNI `FeedbackLoop/loopsettings/`). The rules keep the modules consistent, so the individual parameters above do
not have to be set by hand:
| Step | Profile | BEC |
|---|---|---|
| 1, FZP disabled (alignment, affine matrix) | `fzp_off` | `flomni.realtime_system.apply_profile("fzp_off")` |
| 2, FZP-only calibration scan (clear `MatrixCorr` first: not part of the settings) | `fzp_calibration` | `apply_profile("fzp_calibration")` |
| 3, back to operation | `production` | `apply_profile("production")` |
| afterwards, fast scans with the FZP at ±9 V (scan modes, flOMNI `FeedbackLoop/scan_modes.md`) | `high_res`, `normal`, `low_res` | `apply_profile("high_res")` |
Single values: `flomni.realtime_system.set(fzp_range_V_x=7, fzp_range_V_y=7)`,
`set(move_beyond_um_x=0.3)`, `set(exposure_during_motion=1)`; refused with the reason if a rule objects
(`adjust=True` changes the dependent settings too). FZP settings only between scans. Live only until
`save_as_default()`.
## Move-beyond (`PID_x` / `PID_y` `FAST_FZP_PID_LOOP(0)` < 0), 2026-10-23
**What it does** (`PID.cpp`, unchanged since the original loop). Only in point-by-point scans (scan state 2). The
TrajectoryGenerator gives the next scan point (outputs 5 / 6, PID input 9). The sample PID then aims not at the
target T but at T + m · sign(next − T), m = min(|FAST_FZP_PID_LOOP(0)|, |next − T|): the sample stage moves *beyond*
the point towards the next one, by at most the step. The FZP loop keeps its own target T (it does not see m), so the
FZP compensates the offset: during the exposure it holds the relative position at T while sitting at +m off its
centre. At the next point the FZP then has stroke R + m in the step direction instead of R (R = stroke from the centre),
so larger steps are taken by the FZP alone. Steps smaller than |FAST_FZP_PID_LOOP(0)|: the sample stage is one point
ahead, the FZP moves between 0 and +step only. The offset is not shaped (InputSlew acts on T only); the sample stage
moves to it with its own (slow) loop, i.e. partly during the exposure, and the FZP compensates that motion.
**Is it still the same in the new loop?** Yes. The relative position is computed as before:
FZP error = Cap − (Chan − target) (mode 1), `Position sample vs FZP` = Chan − Cap (PID output 3, RT_sampler). The
sample PIDs still get the shaped target (InputSlew) + move-beyond and the raw interferometer. The FZP side now has
two additions that act on the FZP side only: `TargetSelect` (`use_final` 0 = shaped target as before, 1 = final
target) and `AbbeCorrection` (Chan corrected for the stage tilt, see below). The `Feedforward` uses the same FZP
target and Chan, so it supplies the +m offset of the FZP directly (static term m / K).
**Sizing.** During the exposure the FZP has to hold m + Abbe correction + noise / settling inside its stroke:
|m| ≤ R − reserve, as a rule ≈ R / 2. R per axis = `Ub` × K (K ≈ 0.13 µm/V x, ≈ 0.05 µm/V y). The loop settings
refuse more than 0.6 × R minus the Abbe limit (rule `move_beyond_*_fits`, constant `move_beyond_fraction`; the
tested ±7 V values are 0.56 × R):
| FZP `Ub`/`Lb` | R x / y | move-beyond x / y (≈ R/2) |
|---|---|---|
| ±1.5 V (production) | ±0.19 / ±0.08 µm | ≈ −0.1 / ≈ −0.04 µm (production: 0) |
| ±7 V (after calibration) | ±0.9 / ±0.36 µm | −0.5 / −0.2 µm (tested, 6q) |
The original default −0.5 µm in both axes dates from the ±9 V limits of the old loop (x ≈ ±1.2 µm, about R/2; in y
±9 V ≈ ±0.45 µm, i.e. about the full stroke). When the FZP saturates while holding the offset, the relative error grows and the trigger
waits.
**Abbe correction.** `AbbeCorrection` adds a new term to the measured sample x / y on the FZP side only:
x = Chan2_x + θz · offset_y_for_x, y = Chan1_y + θz · offset_x_for_y (θz = Chan3 / 13 mm). So yes: it is an error of
the sample x / y position (stage tilt about z, not seen by the x / y interferometer beams) that is corrected by the
FZP x / y, not by the sample stage (`PID_x` / `PID_y` keep the raw Chan, the rotZ piezo is too slow). The positions
recorded for the reconstruction (`Position sample vs FZP`) contain it as well. It uses FZP stroke (limited to
`correction_limit_um` 0.1 µm per axis; y at rest ≈ 2 nm, ≈ 30–37 nm during 20 µm x steps) — keep this in the reserve
of the move-beyond, in y it is a large part of the ±0.08 µm stroke. Keep it off (`enable` 0) during the FZP
calibration scan (step 2).
**Notes.**
- Hold of the sample stage, (4) / (5): input 8 of `PID_x` / `PID_y` was the FZP PID output *before* the
`Feedforward` (with the feedforward on only the remainder, not the real FZP drive). Since 2026-10-23 it is the
`Feedforward` output (FZP drive incl. feedforward), so (5) again compares the real FZP drive. No change with the
feedforward off or with (5) = 0 (production).
- On the last point of a scan the next point output is 0, so the move-beyond points towards 0 (as in the old loop).
- `TargetSelect` `use_final` 1 is a different thing: the FZP aims at the new point at once while the sample stage
follows the S-curve. Both can be combined (6q, E0).
@@ -2,7 +2,8 @@
Source of `local_contact_only-howto_limit_the_fzp_scanner_range.pdf`. The original text is kept unchanged; the
section "Update 2026-10-20" lists what the present flOMNI loop (PREEMPT_RT, 100 µs, branch `rt_preemt`) needs in
addition.
addition. "ahead movement" (element 0) is called **move-beyond** from 2026-10-23 on: the sample stage moves beyond the
scan point towards the next one, the FZP compensates and sits off-centre, which gives it stroke for the next step.
```text
to limit the fzp scanner ranges
@@ -20,7 +21,7 @@ limit the analog output LB and UB
The limited range is the present operating state: `PID_x_fzp` / `PID_y_fzp` `Ub` / `Lb` = ±1.5 V because the cap
sensors are calibrated over about ±2 V only (flOMNI `feedback_report.md` 6n). FZP ≈ 0.13 µm/V in x, ≈ 0.05 µm/V in y,
so ±1.5 V ≈ ±0.19 µm (x) / ±0.08 µm (y). In the sample stage PIDs (`PID_x`, `PID_y`) `FAST_FZP_PID_LOOP` is
`0,0,0,0,0.1,0,0`: no move-ahead, (5) = 0 (the sample stage always compensates fully).
`0,0,0,0,0.1,0,0`: no move-beyond, (5) = 0 (the sample stage always compensates fully).
Changing the FZP range now involves, in addition to the text above:
@@ -31,6 +32,15 @@ Changing the FZP range now involves, in addition to the text above:
- **`OnTargetTrigger`** `WaitForSlew` and **`TargetSelect_x` / `_y`** `use_final`: keep the defaults (1 / 0) with a
small range. `use_final` 1 + `WaitForSlew` 0 ("exposure during the motion") only makes sense when the FZP range
covers the scan step; steps larger than the range then still wait for the sample stage.
- Move-ahead (0) must fit into the range: at ±1.5 V at most ≈ −0.05 µm (x) — so in operation it stays 0.
- Move-beyond (0) must fit into the stroke: the FZP holds it during the exposure, together with the Abbe correction
(up to `correction_limit_um` 0.1 µm when enabled) and noise. Rule ≈ half the stroke (the loop settings refuse more than 0.6 × stroke minus the Abbe limit): at ±1.5 V ≈ −0.1 µm (x),
≈ −0.04 µm (y); at ±7 V −0.5 / −0.2 µm (tested). Production: 0. Details: calibration how-to, section "Move-beyond".
- (5) compares the real FZP drive incl. the feedforward (input 8 of `PID_x` / `PID_y` = `Feedforward` output since
2026-10-23; before it was the FZP PID output without the feedforward).
- After a change: web HMI tab **Loop check** level 1 (expected settings) and, with the beamline ready, the scan test
(level 4: positioning time, stability during the exposure per step size).
**With the loop settings (since 2026-10-23):** `flomni.realtime_system.set(fzp_range_V_x=..., fzp_range_V_y=...)`
(web HMI tab *Loop settings*) sets `PID_*_fzp` `Ub` / `Lb` and the `Feedforward` limits together; a move-beyond that
no longer fits is refused, or clamped with `adjust=True` (rule `move_beyond_*_fits`). `FreqGen` limits stay
manual (measurements only).
@@ -0,0 +1,81 @@
# flOMNI real-time loop settings (beamline scientists / local contact only)
**Not for users.** `flomni.realtime_system` changes how the FZP scanner, the detector trigger and the laser tracker
work, and `save_as_default()` makes a change the default of every following loop start. A wrong combination can spoil
the data of a beamtime. The user guide only says that these settings exist, are set by the local contact and are
stored with every scan; the details are here.
Background and reference (flOMNI control repository, `OMNY/flOMNI` on gitea):
- `FeedbackLoop/loopsettings/README.md` — every setting with the loop parameters it sets and its production value,
the rules, the profiles; `FeedbackLoop/loopsettings/flomni.json` — the definition (help texts, rules, profiles)
- `FeedbackLoop/tuning_guide.md`, `FeedbackLoop/feedback_report.md` — characterization, measurements, why the values
- RT-system `docs/loop-settings.md` — design, the web HMI side, installation
- the FZP how-tos next to this file (`local_contact_only-howto_calibrate_the_fzp_scanner.md`,
`local_contact_only-howto_limit_the_fzp_scanner_range.md`) — move-beyond, FZP range, calibration steps as profiles
## What can be set
| Group | Settings |
|---|---|
| FZP scanner (only between scans) | `fzp_enable` (both axes or none), `fzp_range_V_x` / `_y`, `feedforward_enable`, `move_beyond_um_x` / `_y`, `exposure_during_motion`, `abbe_enable`, `abbe_limit_um` |
| Detector trigger | `trigger_tolerance_nm`, `trigger_hold_ms`, `trigger_min_delay_ms`, `trigger_on_move` |
| Laser tracker and thresholds | `tracking_target_y` / `_z`, `psd_threshold_low`, `interferometer_threshold`, `interferometer_threshold_rot` |
| FZP calibration (expert, only between scans) | `sample_hold_fzp_V`, `sample_hold_err_um`, `sample_hold_zero` |
`flomni.realtime_system.help("all")` explains every setting, `help("<setting>")` one setting (what it does, range,
live and saved value, the loop parameters it sets, its rules).
## Commands
- `flomni.realtime_system.show()` — all settings with live and saved value, violated rules, scan state
(`show(expert=True)` also the FZP calibration group)
- `flomni.realtime_system.set(name=value, ...)` — change; refused with the reason if a rule objects (e.g. the
feedforward must be off while the FZP loop is off); `set(..., adjust=True)` changes the dependent settings the
rule names (never the ones given)
- `flomni.realtime_system.check(name=value)` — dry run
- `flomni.realtime_system.profiles()`, `apply_profile("production" | "fzp_off" | "fzp_calibration" | "high_res" | "high_res_1v5" | "normal" | "low_res")` (scan modes 2026-10-26: flOMNI `FeedbackLoop/scan_modes.md`)
- `flomni.realtime_system.unsaved()` — what differs from the loop's parameter files
- `flomni.realtime_system.save_as_default()` — writes the live values into the loop's parameter files: the next
loop start uses them. Only for a change that should stay (a backup of each file is kept on the loop machine).
- `flomni.realtime_system.help()`, `help("all")`, `help("<setting>")`
Changes are live only until the next start of the control loop, unless saved. FZP settings can only be changed
between scans (refused while a scan runs); trigger and tracker settings any time.
### Detector trigger
In step scans the detector is triggered when the relative position sample vs FZP (interferometer minus FZP cap
sensor) stays within the tolerance in x and in y (each averaged over 0.5 ms) for the hold time.
- `flomni.realtime_system.trigger_show()` — tolerance, hold time, minimum delay, trigger after a move
- `flomni.realtime_system.trigger_set_tolerance_nm(10)` — about 30 for a fast low-resolution scan, 10 standard,
2 for the highest resolution (a few ms more per point); 0 = the former criterion (on-target flags of the FZP loops)
- `flomni.realtime_system.trigger_set_hold_ms(3)` — hold time in ms
- `flomni.realtime_system.trigger_set_after_move(True)` — also one trigger after every position change of the
sample stage (e.g. `umv(dev.rtx, ...)`, `dev.rtx.controller.move_xyz(x, y, z)`) once on target; `False` (default):
scan points only
The former `flomni.interferometer_trigger_*` were removed 2026-10-23.
### Laser tracker
`flomni.laser_parameters_show_all()`, `laser_parameters_set_targety(v)` / `_targetz(v)`,
`laser_parameters_set_intensity_threshold_laser(v)`, `laser_parameters_set_psd_intensity_threshold_tracking_low(v)`
and the arrow-key tweak `laser_parameters_tweak_tracking()` use the same settings (`tracking_target_y` / `_z`,
`interferometer_threshold`, `psd_threshold_low`).
## Stored with every scan
The baseline device `flomni_loop_settings` is read at the start of every scan: one value per setting,
`available` (0 when the loop machine could not be reached — the scan continues), the number of settings not saved
as default or inconsistent, violated error rules, the definition with its sha1, and a JSON of all settings.
`dev.flomni_loop_settings.show()` prints what a scan would store now.
## Connection
`flomni.realtime_system` sends its requests through the device `flomni_loop_settings` (device server). The device
talks to the web HMI on the loop machine (`http://mpc2844:8080`, REST `/api/settings`; port 80 as fallback) with the
API token in its device config (`ptycho_flomni.yaml`, `token:`; the line of `~/loop-data/webhmi/api_token` on
mpc2844). Without that device (e.g. a simulated config) the client talks to the web HMI directly with
`~/.flomni_webhmi_token`. The web HMI tab *Loop settings* shows and changes the same settings with the same rules.
@@ -17,6 +17,7 @@ from typeguard import typechecked
from csaxs_bec.bec_ipython_client.plugins.cSAXS import cSAXSBeamlineChecks
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_optics_mixin import FlomniOpticsMixin
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_realtime_system import FlomniRealtimeSystem
from csaxs_bec.bec_ipython_client.plugins.flomni.gui_tools import flomniGuiTools
from csaxs_bec.bec_ipython_client.plugins.flomni.x_ray_eye_align import XrayEyeAlign
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.angle_file import (
@@ -39,6 +40,7 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import
OMNYTools,
PtychoReconstructor,
TomoIDManager,
read_escape_suffix,
)
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.ptouch_printer import PTouchLabelPrinter
from csaxs_bec.devices.omny.galil.galil_ophyd import GalilError
@@ -81,7 +83,8 @@ class FlomniInitStagesMixin:
sensor_voltage_target = dev.ftransy.user_parameter.get("sensor_voltage")
sensor_voltage = float(dev.ftransy.controller.socket_put_and_receive("MG@AN[1]").strip())
if not np.isclose(sensor_voltage, sensor_voltage_target, 0.25):
# absolute tolerances (V, mm): the third positional argument of np.isclose is rtol
if not np.isclose(sensor_voltage, sensor_voltage_target, rtol=0, atol=0.25):
print(
f"Sensor voltage of the gripper is {sensor_voltage}, while target from config is {sensor_voltage_target}"
)
@@ -409,7 +412,7 @@ class FlomniSampleTransferMixin:
self.ensure_osa_back()
fsamx_in = dev.fsamx.user_parameter.get("in")
if not np.isclose(dev.fsamx.readback.get(), fsamx_in, 0.2):
if not np.isclose(dev.fsamx.readback.get(), fsamx_in, rtol=0, atol=0.2):
raise FlomniError("fsamx not in position. Aborting.")
fheater_in = dev.fheater.user_parameter.get("in")
@@ -503,26 +506,28 @@ class FlomniSampleTransferMixin:
def laser_tracker_off(self):
dev.rtx.controller.laser_tracker_off()
# laser tracker targets and thresholds: settings of the real-time loop (flomni.realtime_system, group
# "tracking"; until 2026-10-23 ConstEmitter lines set through the CommunicationServer e-commands)
def laser_parameters_show_all(self):
dev.rtx.controller.emitter_show_all()
self.realtime_system.show(group="tracking")
def laser_parameters_set_targety(self, val: float):
dev.rtx.controller.emitter_set_tracking_target_y(val)
self.realtime_system.set(tracking_target_y=val)
def laser_parameters_set_targetz(self, val: float):
dev.rtx.controller.emitter_set_tracking_target_z(val)
self.realtime_system.set(tracking_target_z=val)
def laser_parameters_set_intensity_threshold_laser(self, val: float):
dev.rtx.controller.emitter_set_intensity_threshold_laser(val)
self.realtime_system.set(interferometer_threshold=val)
def laser_parameters_set_psd_intensity_threshold_tracking_low(self, val: float):
dev.rtx.controller.emitter_set_psd_intensity_threshold_tracking_low(val)
self.realtime_system.set(psd_threshold_low=val)
def laser_parameters_tweak_tracking(self):
"""Interactive tweak of the two laser tracking target positions.
Arrow keys nudge the ConstEmitter tracking targets by a fixed step of
0.01 (absolute set of current +/- step):
Arrow keys nudge the tracking targets (realtime_system tracking_target_y / _z) by a fixed step of
0.02 (absolute set of current +/- step):
left / right : target y -/+
up / down : target z +/-
Keys:
@@ -542,6 +547,7 @@ class FlomniSampleTransferMixin:
import tty
controller = dev.rtx.controller
rts = self.realtime_system
if not controller.laser_tracker_check_enabled():
print(
@@ -554,11 +560,7 @@ class FlomniSampleTransferMixin:
step = 0.02
def read_targets():
values = controller.emitter_get()
return (
values["targetpos_tracking_y"],
values["targetpos_tracking_z"],
)
return rts.tracking_targets()
def signal_fzpx():
# second channel (index 1) of show_signal_strength_interferometer,
@@ -597,25 +599,25 @@ class FlomniSampleTransferMixin:
print("\n\rExiting tweak mode\r")
break
elif key == "s":
controller.emitter_set_tracking_target_y(start_y)
controller.emitter_set_tracking_target_z(start_z)
rts.set(tracking_target_y=start_y, tracking_target_z=start_z, quiet=True)
print("\rReturned to start positions\r")
print_status()
elif key == "\x1b": # escape sequences for arrow keys
next1, next2 = sys.stdin.read(2)
suffix = read_escape_suffix(sys.stdin)
next1, next2 = suffix if suffix else ("", "")
if next1 == "[":
target_y, target_z = read_targets()
if next2 == "A": # up: target z +
controller.emitter_set_tracking_target_z(target_z + step)
rts.set(tracking_target_z=target_z + step, quiet=True)
print_status()
elif next2 == "B": # down: target z -
controller.emitter_set_tracking_target_z(target_z - step)
rts.set(tracking_target_z=target_z - step, quiet=True)
print_status()
elif next2 == "C": # right: target y +
controller.emitter_set_tracking_target_y(target_y + step)
rts.set(tracking_target_y=target_y + step, quiet=True)
print_status()
elif next2 == "D": # left: target y -
controller.emitter_set_tracking_target_y(target_y - step)
rts.set(tracking_target_y=target_y - step, quiet=True)
print_status()
except IOError:
pass
@@ -710,81 +712,8 @@ class FlomniSampleTransferMixin:
else:
print("The rt feedback is \x1b[91mNOT\x1b[0m running.")
def interferometer_trigger_show(self):
"""Show the detector-trigger stability criterion of the interferometer feedback.
A step-scan point is triggered when the relative position sample vs FZP (interferometer
minus FZP cap sensor) is within the tolerance in x and in y, each averaged over 0.5 ms,
for the hold time without interruption.
"""
values = dev.rtx.controller.emitter_get_ontarget()
tol_nm = values["ontarget_tolerance_um"] * 1000
if tol_nm > 0:
print(f"Detector trigger: |position error| x and y <= {tol_nm:.1f} nm "
f"for {values['ontarget_hold_ms']:.1f} ms.")
else:
print("Detector trigger: former criterion (on-target flags of the FZP PID loops).")
if values.get("trigger_on_move") is not None:
mode = "on" if values["trigger_on_move"] else "off"
print(f"Trigger after every position change (not only at scan points): {mode}.")
def interferometer_trigger_set_tolerance_nm(self, tolerance_nm: float):
"""Set the detector-trigger tolerance in nm (per axis, x and y).
Typical: 30 nm for a fast low-resolution scan, 10 nm standard, 2 nm for the highest
resolution (needs a few ms more per point). 0 selects the former criterion (on-target flags
of the FZP PID loops).
Args:
tolerance_nm (float): tolerance in nm, 0 or 1 ... 1000.
"""
if tolerance_nm != 0 and not 1 <= tolerance_nm <= 1000:
raise FlomniError(
"The trigger tolerance must be 0 (former criterion) or 1 ... 1000 nm."
)
dev.rtx.controller.emitter_set_ontarget_tolerance(tolerance_nm / 1000)
self.interferometer_trigger_show()
def interferometer_trigger_set_hold_ms(self, hold_ms: float):
"""Set how long (ms) the position must stay within the tolerance before the trigger.
Typical 3 ms. Longer = safer against a position that is only passing through the tolerance.
Args:
hold_ms (float): hold time in ms, 0 ... 100.
"""
if not 0 <= hold_ms <= 100:
raise FlomniError("The trigger hold time must be 0 ... 100 ms.")
dev.rtx.controller.emitter_set_ontarget_hold_ms(hold_ms)
self.interferometer_trigger_show()
def interferometer_trigger_set_after_move(self, enable: bool):
"""Send one detector trigger after every position change, not only at scan points.
With True every direct move of the sample stage (e.g. umv of rtx / rty / rtz, or
dev.rtx.controller.move_xyz) gives exactly one trigger, as soon as the position is stable
(same tolerance and hold time as for scan points). False: triggers at scan points only.
"""
dev.rtx.controller.emitter_set_trigger_on_move(1 if enable else 0)
self.interferometer_trigger_show()
def interferometer_trigger_help(self):
"""Print the detector-trigger commands with a short explanation."""
lines = [
"Detector trigger (step scans): a point is triggered when the relative position",
"sample vs FZP is within the tolerance in x and y (0.5 ms average) for the hold time.",
" flomni.interferometer_trigger_show() tolerance and hold time",
" flomni.interferometer_trigger_set_tolerance_nm(val) 30 coarse, 10 standard,",
" 2 finest; 0 = former",
" criterion (FZP PID flags)",
" flomni.interferometer_trigger_set_hold_ms(val) e.g. 3",
" flomni.interferometer_trigger_set_after_move(True) also one trigger after every",
" position change (False: off)",
" dev.rtx.controller.move_xyz(x, y, z) move all three axes at once",
"The values are live; after a restart of the control loop the start values",
"(10 nm, 3 ms) from the loop parameter file apply again.",
]
print("\n".join(lines))
# detector trigger: flomni.realtime_system.trigger_* (the former flomni.interferometer_trigger_* removed
# 2026-10-23, Mirko: "interferometer" was confusing next to flomni.laser_*)
def lights_off(self):
self.device_manager.devices.fsamx.controller.lights_off()
@@ -798,7 +727,7 @@ class FlomniSampleTransferMixin:
self.flomnigui_raise()
target_pos = -162
if np.isclose(dev.fsamx.readback.get(), target_pos, 0.01):
if np.isclose(dev.fsamx.readback.get(), target_pos, rtol=0, atol=0.5):
return
umv(dev.fsamroy, 0)
@@ -838,7 +767,7 @@ class FlomniSampleTransferMixin:
sensor_voltage_target = dev.ftransy.user_parameter.get("sensor_voltage")
sensor_voltage = float(dev.ftransy.controller.socket_put_and_receive("MG@AN[1]").strip())
if not np.isclose(sensor_voltage, sensor_voltage_target, 0.5):
if not np.isclose(sensor_voltage, sensor_voltage_target, rtol=0, atol=0.5):
raise FlomniError(f"Sensor voltage is {sensor_voltage}, indicates an error. Aborting.")
def ftransfer_get_sample(self, position: int):
@@ -1138,7 +1067,7 @@ class FlomniSampleTransferMixin:
if 0 <= position < 21:
return
raise FlomniError(
f"The given position number [{position}] is not in the valid range of 0-21. "
f"The given position number [{position}] is not in the valid range of 0-20. "
)
def ftransfer_controller_enable_mount_mode(self):
@@ -1717,6 +1646,8 @@ class _ProgressProxy:
"estimated_finish_time": None,
"heartbeat": None,
"accumulated_idle_time": 0.0,
# set when tomo_scan() reached its end: tomo_scan_resume() then has nothing to resume
"tomo_completed": False,
}
def __init__(self, client):
@@ -1792,7 +1723,7 @@ class Flomni(
self.device_manager = client.device_manager
self.special_angles = []
self.special_angle_repeats = 20
self.special_angle_tolerance = 20
self.special_angle_tolerance = 0.5
self._current_special_angles = []
self._beam_is_okay = True
self._progress_proxy = _ProgressProxy(self.client)
@@ -1819,6 +1750,9 @@ class Flomni(
self._webpage_gen.start()
self.OMNYTools = OMNYTools(self.client)
# expert functions of the real-time loop (settings with rules, via the web HMI on the loop machine;
# flomni_realtime_system.py): flomni.realtime_system.show() / .set(...) / .help()
self.realtime_system = FlomniRealtimeSystem(device_manager=self.device_manager)
self.reconstructor = PtychoReconstructor(self.ptycho_reconstruct_foldername)
self.tomo_id_manager = TomoIDManager()
self.align = XrayEyeAlign(self.client, self)
@@ -1920,8 +1854,8 @@ class Flomni(
try:
self.align.align(keep_shutter_open)
except KeyboardInterrupt as exc:
fsamx_in = self._get_user_param_safe(dev.fsamx, "in")
if np.isclose(fsamx_in, dev.fsamx.readback.get(), 0.5):
fsamx_in = self._get_user_param_safe("fsamx", "in") # name: dev[...] lookup (a device raised)
if np.isclose(fsamx_in, dev.fsamx.readback.get(), rtol=0, atol=0.5):
print("Stopping alignment. Returning to fsamx in position.")
self.feedback_disable()
umv(dev.fsamx, fsamx_in)
@@ -1982,10 +1916,14 @@ class Flomni(
def set_special_angles(self, angles: list, repeats: int = 20, tolerance: float = 0.5):
"""Set the special angles for a tomo
The first projection of the tomogram within `tolerance` of a special angle is acquired
`repeats` times; each special angle is used once per tomogram (in any order), also across
tomo_scan_resume() in the same session.
Args:
angles (list): List of special angles
repeats (int, optional): Number of repeats at a special angle. Defaults to 20.
tolerance (float, optional): Number of repeats at a special angle. Defaults to 0.5.
tolerance (float, optional): Angle tolerance in degrees. Defaults to 0.5.
"""
self.special_angles = angles
@@ -2299,6 +2237,8 @@ class Flomni(
self.write_alignment_scan_numbers(bec.queue.next_scan_number)
start_angle = 0
alignment_scan_numbers = []
# first scan number of the accepted attempt per angle (a retry shifts the scan numbers)
alignment_first_scans = []
angle_end = start_angle + 180
for angle in np.linspace(start_angle, angle_end, num=int(180 / 45) + 1, endpoint=True):
@@ -2316,16 +2256,24 @@ class Flomni(
bec.queue.request_queue_reset()
time.sleep(2)
error_caught = True
print(f"Timeout at angle {angle}, repeating it (Ctrl-C to stop).")
else:
raise exc
# todo here was if blchk success, then setting to success true
successful = True
# repeat the angle after a timeout (it was skipped silently before, 2026-10-24)
successful = not error_caught
end_scan_number = bec.queue.next_scan_number
alignment_first_scans.append(start_scan_number)
for scan_nr in range(start_scan_number, end_scan_number):
# self._write_tomo_scan_number(scan_nr, angle, 0)
alignment_scan_numbers.append(scan_nr)
consecutive = [alignment_first_scans[0] + k for k in range(len(alignment_first_scans))]
if alignment_first_scans != consecutive:
# a retry (or stitching) shifted the scan numbers: rewrite the file with the accepted scans
self.write_alignment_scan_numbers(alignment_first_scans[0], scans=alignment_first_scans)
umv(dev.fsamroy, 0)
self.OMNYTools.printgreenbold(
"\n\nAlignment scan finished. Please run BEC_ptycho_align and load the new fit by flomni.read_alignment_offset() ."
@@ -2345,13 +2293,14 @@ class Flomni(
print(scilog_content)
self._scilog_write(scilog_content, "alignmentscan")
def write_alignment_scan_numbers(self, first_scan):
def write_alignment_scan_numbers(self, first_scan, scans=None):
import os
file = os.path.expanduser("~/data/raw/logs/ptychotomoalign_scannum.txt")
os.makedirs(os.path.dirname(file), exist_ok=True)
scans = [first_scan + k for k in range(5)]
if scans is None:
scans = [first_scan + k for k in range(5)]
angles = [0, 45, 90, 135, 180]
x_vals = []
@@ -2643,10 +2592,16 @@ class Flomni(
# succeeded, so a retried attempt still sees the same special
# angle and repeat count.
num_repeats = 1
is_special_angle = bool(
self._current_special_angles
and np.isclose(angle, self._current_special_angles[0], atol=0.5)
# any remaining special angle within the tolerance (not only the next one in the list)
special_index = next(
(
k
for k, special in enumerate(self._current_special_angles)
if np.isclose(angle, special, rtol=0, atol=self.special_angle_tolerance)
),
None,
)
is_special_angle = special_index is not None
if is_special_angle:
num_repeats = self.special_angle_repeats
start_scan_number = bec.queue.next_scan_number
@@ -2656,7 +2611,7 @@ class Flomni(
self._at_each_angle(angle, **overrides)
projection_duration = time.perf_counter() - projection_start
if is_special_angle:
self._current_special_angles.pop(0)
self._current_special_angles.pop(special_index)
end_scan_number = bec.queue.next_scan_number
for scan_nr in range(start_scan_number, end_scan_number):
@@ -2673,8 +2628,8 @@ class Flomni(
)
def collect_empty_frames(self):
"""Acquire 10 empty-frame (flat-field) images at angle 0, with fsamx
shifted out of the beam by half the field of view (fovx/2), in the
"""Acquire 10 empty-frame (flat-field) images at angle 0, with the
sample moved out of the beam by half the field of view (fovx/2), in the
direction opposite the normal alignment x-offset at angle 0.
Called once at the start of a new tomo_scan() (not on resume).
@@ -2786,15 +2741,15 @@ class Flomni(
if self.OMNYTools.yesno("Shall I continue?", "n"):
print("OK")
else:
print("Stopping.")
return
# raise, not return: a return let tomo_queue_execute() mark the job "done" without data
raise FlomniError("Tomo scan stopped: the setup is not in measurement condition.")
all_out, offending = filters_out_of_beam(dev)
if not all_out:
warning = f"Not all filters are out of the beam: {', '.join(offending)}."
if not warn_and_confirm(self, warning, interactive=interactive, force=False):
print("Aborting tomo scan.")
return
# raise, not return (see above)
raise FlomniError(f"Tomo scan aborted: {warning}")
self.flomnigui_show_progress()
@@ -2804,7 +2759,9 @@ class Flomni(
bec.builtin_actors.scan_interlock.trigger_setting = "restart_scan"
bec.builtin_actors.scan_interlock.enabled = True
self._current_special_angles = self.special_angles.copy()
if is_new_scan or not self.special_angles:
# on a resume keep the special angles not yet acquired (they were taken again before)
self._current_special_angles = list(self.special_angles)
if is_new_scan:
# pylint: disable=undefined-variable
# Always attempt registration (even for an empty/test account)
@@ -2833,6 +2790,7 @@ class Flomni(
# self.progress.reset() above, before the gates -- only the actual
# start time is set here, once the scan is genuinely committed to.
self.progress["tomo_start_time"] = datetime.datetime.now().isoformat()
self.progress["tomo_completed"] = False
if file_angles is not None:
self._init_angle_file_progress(file_angles, projection_number)
self.collect_empty_frames()
@@ -2857,6 +2815,9 @@ class Flomni(
start_angle is None
and self._subtomo_starts_near_zero(ii)
and self.zero_deg_reference_at_each_subtomo
# new scan: the fixed 0-deg shot above and sub-tomogram 1's first
# projection (0 deg) are already the two at the start (Mirko 2026-10-24)
and not (is_new_scan and ii == 1)
):
# Dedicated reference shot at exactly 0 degrees, taken
# every time the rotation passes back through 0, for
@@ -3009,6 +2970,8 @@ class Flomni(
# (120s / 90s) after a tomogram that's actually already done.
self.progress["heartbeat"] = None
# all projections acquired: nothing left for tomo_scan_resume() (also if a step below fails)
self.progress["tomo_completed"] = True
self.progress["projection"] = self.progress["total_projections"]
self.progress["subtomo_projection"] = self.progress["subtomo_total_projections"]
self._print_progress()
@@ -3062,6 +3025,10 @@ class Flomni(
if self.progress.get("tomo_start_time") is None:
print("No tomo scan in progress to resume -- nothing to do.")
return
if self.progress.get("tomo_completed"):
# it re-acquired the last projection (and repeated the end-of-scan steps) before, 2026-10-24
print("The last tomo scan was completed -- nothing to resume.")
return
if not from_queue and not self._confirm_resume_outside_queue():
print("Aborted.")
@@ -4062,7 +4029,7 @@ class Flomni(
if self.tomo_type == 1:
self.tomo_angle_range = self._get_val(
"Angular range (180 or 360)", self.tomo_angle_range, int
"Angular range (180 or 360)", self.tomo_angle_range, int, choices=(180, 360)
)
# Default shown here must be the actual achievable total
# (int(range/stepsize)*8), not the raw algebraic inverse of
@@ -4179,14 +4146,23 @@ class Flomni(
self._prompt_angle_file()
@staticmethod
def _get_val(msg: str, default_value, data_type, min_=None, max_=None):
def _get_val(msg: str, default_value, data_type, min_=None, max_=None, choices=None):
"""Prompt for a value, casting to data_type. When min_/max_ are
given, re-prompts (rather than silently clamping) until the typed
value is in range, mirroring the Tomo Parameters GUI's spinbox
bounds (tomo_params.py) so tomo_parameters() enforces the same
limits instead of accepting anything of the right type."""
limits instead of accepting anything of the right type. Also
re-prompts on a value that cannot be cast, or that is not one of
`choices` (when given)."""
while True:
val = data_type(input(f"{msg} ({default_value}): ") or default_value)
try:
val = data_type(input(f"{msg} ({default_value}): ") or default_value)
except ValueError:
print(f"Not a valid {data_type.__name__}. Please try again.")
continue
if choices is not None and val not in choices:
print(f"Value must be one of {', '.join(str(c) for c in choices)}. Please try again.")
continue
if min_ is not None and val < min_:
print(f"Value must be >= {min_}. Please try again.")
continue
@@ -88,8 +88,10 @@ class FlomniOpticsMixin:
if self.OMNYTools.yesno("Did the direct beam on the xray eye disappear?"):
print("excellent.")
else:
print("Aborting. With visible parts of the direct beam on the xray eye, it cannot be removed.")
return
# raise, not return: callers (tomo_alignment_scan) went on measuring with the eye in
raise FlomniOpticsError(
"Aborting. With visible parts of the direct beam on the xray eye, it cannot be removed."
)
feyex_out = self._get_user_param_safe("feyex", "out")
umv(dev.feyex, feyex_out)
@@ -104,8 +106,7 @@ class FlomniOpticsMixin:
"this could cause collisions when operating on the real "
"beamline."
):
print("Aborting feye_out(): fttrx1 not moved.")
return
raise FlomniOpticsError("Aborting feye_out(): fttrx1 not moved.")
else:
fttrx_in = self._get_user_param_safe("feyex", "fttrx_in")
umv(dev.fttrx1, fttrx_in)
@@ -119,8 +120,7 @@ class FlomniOpticsMixin:
"this could cause collisions when operating on the real "
"beamline."
):
print("Aborting feye_in(): fttrx1 not moved.")
return
raise FlomniOpticsError("Aborting feye_in(): fttrx1 not moved.")
else:
fttrx_out = self._get_user_param_safe("feyex", "fttrx_out")
umv(dev.fttrx1, fttrx_out)
@@ -420,7 +420,8 @@ class FlomniOpticsMixin:
print("\nOSA Information:")
print(f" Current fosaz {fosaz_val:.1f}")
print(f" The OSA will collide with a normal OMNY pin at fosaz \033[1m{(33-fosaz_val):.1f}\033[0m")
# collision where compute_osa_remaining_space() = 33 - foptz - fosaz reaches 0 (was 33 - fosaz)
print(f" The OSA will collide with a normal OMNY pin at fosaz \033[1m{(33-foptz_val):.1f}\033[0m")
print(f" Remaining space (right now): \033[1m{remaining_current:.1f}\033[0m")
diff = fosaz_val - fosaz_in # >0: OSA is currently more "in" than nominal -> worse
@@ -0,0 +1,273 @@
"""flomni.realtime_system: the operating settings of the flOMNI real-time loop.
BEAMLINE SCIENTISTS / LOCAL CONTACT ONLY - not for users. These settings change how the FZP scanner, the detector
trigger and the laser tracker work; a wrong combination can spoil a beamtime's data. The rules refuse combinations
that do not work, but not ones that are only unsuitable for an experiment.
The settings, the parameters they map onto and the rules between them live in the flOMNI instrument repository
(FeedbackLoop/loopsettings/flomni.json) and are applied by the web HMI on the loop machine (RT-system
webhmi/loopsettings.py). This client only talks to its REST API (/api/settings); it holds no logic of its own, so the
web page (tab "Loop settings") and BEC always behave the same way.
flomni.realtime_system.show() all settings, rules, scan state
flomni.realtime_system.set(trigger_tolerance_nm=5) change (refused if a rule objects)
flomni.realtime_system.set(fzp_enable=0, adjust=True) ... and adjust the dependent settings
flomni.realtime_system.check(fzp_range_V_x=1.0) dry run
flomni.realtime_system.apply_profile("fzp_off") a named set of settings
flomni.realtime_system.save_as_default() write the live values into the .xmp files
Changes are live only: the next start of the control loop uses the .xmp files again (save_as_default keeps them).
Connection: through the BEC device flomni_loop_settings (RPC; the API token is in its device config,
ptycho_flomni.yaml) when it exists and is enabled, else directly to the web HMI (devices/omny/webhmi_client.py: URL
http://<rtx host>:8080 with port-80 fallback, token from ~/.flomni_webhmi_token or FLOMNI_WEBHMI_TOKEN_FILE).
"""
from csaxs_bec.devices.omny.webhmi_client import WebHmiClient
from csaxs_bec.devices.omny.webhmi_client import WebHmiError as RealtimeSystemError # name kept for BEC users
STAFF_ONLY = ("flomni.realtime_system: beamline scientists / local contact only - not for users "
"(plugins/flomni/docs/local_contact_only-realtime_system.md).")
def _fmt(v):
if v is None:
return "-"
if isinstance(v, float):
return f"{v:.6g}"
return str(v)
class FlomniRealtimeSystem(WebHmiClient):
"""Requests go through the BEC device flomni_loop_settings (RPC api_get / api_post; the token is in its device
config) when it exists and is enabled; otherwise directly to the web HMI (WebHmiClient,
devices/omny/webhmi_client.py; token from token_file / ~/.flomni_webhmi_token)."""
DEVICE = "flomni_loop_settings"
def __init__(self, url=None, token_file=None, device_manager=None, timeout=5.0, token=None):
super().__init__(url=url, token=token, token_file=token_file, device_manager=device_manager, timeout=timeout)
def _device(self):
try:
dev = getattr(self._device_manager.devices, self.DEVICE)
except Exception: # noqa: BLE001 (no device manager / no such device)
return None
return dev if getattr(dev, "enabled", True) else None
def _request(self, path, body=None):
dev = self._device()
if dev is None:
return super()._request(path, body) # direct HTTP
try:
return dev.api_get(path) if body is None else dev.api_post(path, body)
except Exception as exc: # noqa: BLE001 (RPC error with the device-side message)
raise RealtimeSystemError(f"via device {self.DEVICE}: {exc}") from None
@property
def route(self):
"""'device flomni_loop_settings' or the direct URL."""
return f"device {self.DEVICE}" if self._device() is not None else self.url
# ── reading ───────────────────────────────────────────────────────────────
def read(self) -> dict:
"""The full state as the web HMI reports it (settings, rules, scan state, profiles)."""
return self._request("/api/settings")
def get(self, name: str = None):
"""Live value of one setting, or a dict of all values."""
values = {s["name"]: s["value"] for s in self.read()["settings"]}
if name is None:
return values
if name not in values:
raise RealtimeSystemError(f"unknown setting '{name}'; known: {', '.join(values)}")
return values[name]
def show(self, group: str = None, expert: bool = False):
"""Print the settings (live value, .xmp value, markers), the violated rules and the scan state."""
snap = self.read()
groups = {g["name"]: g for g in snap["groups"]}
sc = snap.get("scan") or {}
print(STAFF_ONLY)
print(f"Loop settings {snap.get('instrument', '')} ({self.route}) - "
+ (f"scan running (mode {_fmt(sc.get('mode'))})" if sc.get("active") else "no scan"))
for gname, g in groups.items():
if group and gname != group:
continue
if g.get("expert") and not expert and not group:
continue
rows = [s for s in snap["settings"] if s["group"] == gname]
if not rows:
continue
print(f"\n{g.get('title', gname)}" + (" (changed only between scans)" if g.get("interlock") else ""))
for s in rows:
info = f" ({s['info']['label']} {s['info']['value']:.4g} {s['info']['unit']})" if s.get("info") else ""
mark = (" NOT SAVED (.xmp " + _fmt(s["saved"]) + ")" if s["unsaved"] else "") + \
(" INCONSISTENT: " + "; ".join(s["issues"]) if not s["consistent"] else "")
print(f" {s['name']:30s} {_fmt(s['value']):>10s} {s.get('unit', ''):3s}{info}{mark}")
bad = [r for r in snap["rules"] if not r["ok"]]
if bad:
print()
for r in bad:
print(f"{r['severity'].upper()}: {r['message']}")
if not expert and not group and any(g.get("expert") for g in snap["groups"]):
print("\n(expert groups hidden: show(expert=True))")
print("\n" + (snap.get("note") or ""))
def profiles(self):
"""Print the named profiles and their values."""
for p in self.read()["profiles"]:
print(f"{p['name']:18s} {p.get('title', '')}")
if p.get("help"):
print(f"{'':18s} {p['help']}")
print(f"{'':18s} " + ", ".join(f"{k}={_fmt(v)}" for k, v in p["values"].items()))
def unsaved(self):
"""Print the parameters whose live value differs from the .xmp (settings only)."""
un = self._request("/api/settings/unsaved")
if not un:
print("All settings are saved: the .xmp files hold the live values.")
for u in un:
print(f" {u['module']}.{u['param']}: .xmp {u['saved']} -> live {u['live']}")
return un
# ── changing ──────────────────────────────────────────────────────────────
@staticmethod
def _print_result(r, dry=False):
head = ("Check: " if dry else "") + ("applied" if r.get("applied") else ("ok" if r["ok"] else "REFUSED"))
if r["ok"] and not r["params"]:
head = "no change"
print(head + (f" (profile {r['profile']})" if r.get("profile") else ""))
for c in r["changes"]:
print(f" {c['name']}: {_fmt(c['old'])} -> {_fmt(c['new'])}"
+ (f" (adjusted by rule {c['adjusted']})" if c.get("adjusted") else ""))
for e in r["errors"]:
print(f" ERROR: {e}")
for w in r["warnings"]:
print(f" warning: {w}")
if r.get("applied"):
print(" " + r.get("note", "Live only: save_as_default() to keep it."))
def check(self, adjust: bool = False, **changes) -> dict:
"""Dry run: what would change, which rules object. Nothing is sent to the loop."""
r = self._request("/api/settings/plan", {"changes": changes, "profile": None, "adjust": adjust})
self._print_result(r, dry=True)
return r
def set(self, adjust: bool = False, quiet: bool = False, **changes) -> dict:
"""Change settings, e.g. set(trigger_tolerance_nm=5). All parameters change in one loop step.
A rule that objects refuses the request (RealtimeSystemError); with adjust=True the settings the rule names
are changed with it (never the ones given here). Live only until save_as_default(). quiet: print nothing
(interactive tools); a refusal still raises.
"""
if not changes:
raise RealtimeSystemError("nothing to set: e.g. set(trigger_tolerance_nm=5); show() lists the names")
r = self._request("/api/settings/apply", {"changes": changes, "profile": None, "adjust": adjust})
if not quiet:
self._print_result(r)
if not r["ok"]:
raise RealtimeSystemError("; ".join(r["errors"]))
return r
def apply_profile(self, name: str, adjust: bool = False, **changes) -> dict:
"""Apply a named profile (profiles() lists them); extra keyword values override the profile's."""
r = self._request("/api/settings/apply", {"changes": changes, "profile": name, "adjust": adjust})
self._print_result(r)
if not r["ok"]:
raise RealtimeSystemError("; ".join(r["errors"]))
return r
def save_as_default(self, *names: str, confirm: bool = True) -> dict:
"""Write the live values of the settings' parameters (all, or the named settings) into the .xmp files, so the
next loop start uses them (the web HMI keeps a backup of each file)."""
un = self._request("/api/settings/unsaved")
if not un:
print("Nothing to save: the .xmp files hold the live values.")
return {"saved": []}
if not names:
print("Save as default (written into the .xmp files of the loop):")
for u in un:
print(f" {u['module']}.{u['param']}: {u['saved']} -> {u['live']}")
if confirm and input("Save? [y/N] ").strip().lower() not in ("y", "yes"):
print("not saved")
return {"saved": []}
r = self._request("/api/settings/save", {"names": list(names) or None})
print(r.get("message", ""), "; ".join(r.get("saved", [])))
return r
# ── shortcuts (detector trigger, laser tracker) ────────────────────────────
# the former flomni.interferometer_trigger_* (removed 2026-10-23)
def trigger_show(self):
"""Detector trigger (step scans): tolerance, hold time, minimum delay, trigger after every move."""
self.show(group="trigger")
def trigger_set_tolerance_nm(self, tolerance_nm: float):
"""|relative position sample vs FZP| x and y within this (nm) for the hold time. 30 coarse, 10 standard, 2 finest;
0 = the former criterion (OnTarget_legacy flags of the FZP PIDs)."""
return self.set(trigger_tolerance_nm=tolerance_nm)
def trigger_set_hold_ms(self, hold_ms: float):
"""How long the position must stay within the tolerance before the trigger (ms), typical 3."""
return self.set(trigger_hold_ms=hold_ms)
def trigger_set_after_move(self, enable: bool):
"""True: one detector trigger after every direct position change (umv rtx / rty / rtz, move_xyz) once on target;
False: triggers at step-scan points only."""
return self.set(trigger_on_move=1 if enable else 0)
def tracking_targets(self):
"""-> (target y, target z) of the laser tracker on the PSD."""
v = self.get()
return v["tracking_target_y"], v["tracking_target_z"]
def help(self, name: str = None):
"""help(): the functions; help("all"): every setting with its explanation; help(name): one setting (what it
does, unit, range, the loop parameters it sets, live and saved value), from the definition on the loop
machine (the same text as the tooltips of the web HMI tab)."""
if name is None:
print(__doc__.split("\n\nConnection:")[0])
print("\nShortcuts: trigger_show(), trigger_set_tolerance_nm(v), trigger_set_hold_ms(v),"
" trigger_set_after_move(True/False), tracking_targets(); profiles(), unsaved(), get(name)")
print('Explanations: help("all") or help("<setting>"), e.g. help("move_beyond_um_x")')
return
snap = self.read()
settings = {s["name"]: s for s in snap["settings"]}
groups = {g["name"]: g for g in snap["groups"]}
if name == "all":
print(STAFF_ONLY)
for gname, g in groups.items():
rows = [s for s in snap["settings"] if s["group"] == gname]
if not rows:
continue
print(f"\n{g.get('title', gname)}" + (" (only between scans)" if g.get("interlock") else ""))
for s in rows:
unit = f" [{s['unit']}]" if s.get("unit") else ""
print(f" {s['name']}{unit}: {s.get('title', '')}")
if s.get("help"):
print(f" {s['help']}")
return
s = settings.get(name)
if s is None:
raise RealtimeSystemError(f"unknown setting '{name}'; help('all') lists them")
g = groups.get(s["group"], {})
print(STAFF_ONLY)
print(f"{s['name']} - {s.get('title', '')} (group {g.get('title', s['group'])}"
+ (", only between scans)" if g.get("interlock") else ")"))
if s.get("help"):
print(f" {s['help']}")
rng = "0 / 1" if s.get("type") == "bool" else f"{_fmt(s.get('min'))} ... {_fmt(s.get('max'))} {s.get('unit', '')}"
print(f" range: {rng}")
print(f" live: {_fmt(s.get('value'))} saved (.xmp): {_fmt(s.get('saved'))}"
+ (" NOT SAVED" if s.get("unsaved") else "") + ("" if s.get("consistent", True) else " INCONSISTENT"))
if s.get("info"):
print(f" {s['info'].get('label', '')} {s['info']['value']:.4g} {s['info'].get('unit', '')}")
print(" sets: " + ", ".join(f"{t['module']}.{t['param']}[{t['index']}] = {t.get('write', 'v')}"
for t in s.get("targets", [])))
rules = [r for r in snap.get("rules", []) if name in r.get("settings", [])]
if rules:
print(" rules: " + ", ".join(r["name"] + ("" if r.get("ok") else " (VIOLATED)") for r in rules))
@@ -0,0 +1,48 @@
# TODO: OMNY has bugs that were fixed in flomni (review 2026-10-24)
Found while reviewing `flomni.py` and its mixins (branch `fzp-move-beyond`, csaxs_bec commits
`1a5b53c`, `b939793` and the follow-up). OMNY was not changed; port these on the OMNY branch.
## 1. X-ray eye Ctrl-C handler cannot return osamx (`omny.py`, `start_x_ray_eye_alignment`)
```python
osamx_in = self._get_user_param_safe(dev.osamx, "in")
if np.isclose(osamx_in, dev.osamx.readback.get(), 0.5):
```
- `_get_user_param_safe()` looks the device up with `dev[device]` and needs the **name**
(`"osamx"`). With the device object the lookup raises (`KeyError`) inside the
`except KeyboardInterrupt` handler, so osamx is never returned to its in position and the
user sees a KeyError instead of the interrupt. flomni fix: `self._get_user_param_safe("fsamx", "in")`.
- The third positional argument of `np.isclose` is **rtol**, not atol: `0.5` means 50 % of the
readback. Use `np.isclose(a, b, rtol=0, atol=0.5)` (mm).
## 2. Special angles (`omny.py`, the per-angle loop of the tomo scan)
```python
if self._current_special_angles:
next_special_angle = self._current_special_angles[0]
if np.isclose(angle, next_special_angle, atol=0.5):
self._current_special_angles.pop(0)
num_repeats = self.special_angle_repeats
else:
num_repeats = 1
```
- `num_repeats` is unbound when special angles are set but the angle is not the next one ->
`UnboundLocalError` (flomni had the same bug, fixed earlier with `num_repeats = 1` up front).
- The angle is popped before the projection ran: a repeated attempt (timeout / beam check)
no longer sees it as special.
- `special_angle_tolerance` is never used (hard-coded 0.5); `__init__` sets it to 20 while
`set_special_angles()` defaults to 0.5; the `tolerance` docstring says "Number of repeats".
- Only the first list entry can match: a special angle that is never reached blocks all later ones.
flomni now: match any remaining special angle within `special_angle_tolerance`, pop it only after
the projection succeeded, keep the remaining list across a resume in the same session
(`Flomni._tomo_scan_at_angle`, tests `tests/tests_bec_ipython_client/test_flomni_special_angles.py`).
## Already shared (nothing to do for OMNY)
- `OMNYTools.tweak_cursor()` (OMNY_shared) no longer ends with a traceback when an arrow-key
escape sequence arrives in two parts or ESC is pressed alone (`read_escape_suffix()`), and its
"not in enabled devices" message names the device.
+56 -34
View File
@@ -616,6 +616,28 @@ flomni_temphum:
deviceTags:
- ptycho_flomni
# ############################################################
# ############ flOMNI real-time loop settings #################
# ############################################################
# The loop settings (FZP, trigger, tracker; flOMNI FeedbackLoop/loopsettings) stored with every scan: read from the
# web HMI on the loop machine at the start of each scan (baseline). Web HMI down: available = 0, the scan continues.
# The user commands flomni.realtime_system go through this device (RPC), so the token is needed only here.
# token: the web HMI API token (~/loop-data/webhmi/api_token on mpc2844; behind the beamline firewall, Mirko
# 2026-10-23). When empty, token_file (on the machine of the device server) is used.
flomni_loop_settings:
description: flOMNI real-time loop settings (web HMI loop settings, snapshot at scan start)
deviceClass: csaxs_bec.devices.omny.flomni_loop_settings.FlomniLoopSettings
deviceConfig:
host: mpc2844.psi.ch
token: "3CcGHMdz16G7zuC6iPHEoZPWvKDY1HkCZnTlP1msZbg"
token_file: ~/.flomni_webhmi_token
timeout: 2.0
enabled: true
onFailure: buffer
readOnly: true
readoutPriority: baseline
deviceTags:
- ptycho_flomni
# ############################################################
# ########## OMNY / flOMNI / LamNI fast shutter ##############
# REPLACED BY SIMULATED BEAMLINE DEVICE !!!!!!!!!!!!!!!!
# ############################################################
@@ -706,37 +728,37 @@ calculated_signal:
# 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.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
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.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
# deviceTags:
# - ptycho_flomni
@@ -24,5 +24,5 @@ fsh:
flomni:
- !include ../ptycho_flomni.yaml
machine:
- !include ../machine.yml
# machine:
# - !include ../machine.yml
@@ -0,0 +1,169 @@
"""flomni_loop_settings: the operating settings of the flOMNI real-time loop as a baseline device.
Read at the start of every scan (readoutPriority: baseline; the flOMNI Fermat scan calls read_baseline_devices in
prepare_scan), so each scan file holds the loop settings it ran with: FZP on/off, FZP range, feedforward,
move-beyond, exposure during the motion, Abbe correction, detector trigger, tracker targets and thresholds
(definition: flOMNI FeedbackLoop/loopsettings/flomni.json; served by the web HMI on the loop machine, GET
/api/settings). Settings only, not all loop parameters (decision Mirko 2026-10-23).
Signals: one per setting (float, NaN when not available; names = the setting names), and
available 1 = read from the web HMI, 0 = not available (the scan continues; message says why)
message "" or the reason it is not available
unsaved number of settings whose live value differs from the .xmp (not saved as default)
inconsistent number of settings whose parameters disagree (e.g. FZP x on, y off)
rules_violated number of violated error rules
definition path and sha1 of the definition file on the loop machine
snapshot_json JSON: every setting (value, saved, unsaved, consistent, unit), the violated rules, the
scan state, the definition path and sha1
deviceConfig: token (the API token itself), else token_file (path to a file with it, default
~/.flomni_webhmi_token), optional url / host / timeout (default 2 s for the scan snapshot). Connection details:
devices/omny/webhmi_client.py.
flomni.realtime_system (the user commands) calls api_get / api_post of this device by RPC, so the token is needed
only here.
"""
import json
import math
import time
from ophyd import Component as Cpt
from ophyd import Device, Kind, Signal
from csaxs_bec.devices.omny.webhmi_client import WebHmiClient, WebHmiError
# the settings of flomni.json (2026-10-23); a setting added to the definition later is in snapshot_json only until
# it is added here
SETTINGS = (
"fzp_enable", "fzp_range_V_x", "fzp_range_V_y", "feedforward_enable", "move_beyond_um_x", "move_beyond_um_y",
"exposure_during_motion", "abbe_enable", "abbe_limit_um",
"trigger_tolerance_nm", "trigger_hold_ms", "trigger_min_delay_ms", "trigger_on_move",
"tracking_target_y", "tracking_target_z", "psd_threshold_low", "interferometer_threshold",
"interferometer_threshold_rot",
"sample_hold_fzp_V", "sample_hold_err_um", "sample_hold_zero",
"target_shaper_scale", "jump_max_um_x", "jump_max_um_y", # group motion, scan-mode profiles (2026-10-26)
)
RETRY_AFTER_FAILURE_S = 60.0 # web HMI not reachable: no new attempt for this long (a tomogram does not wait each time)
class FlomniLoopSettings(Device):
USER_ACCESS = ["show", "snapshot", "api_get", "api_post"]
available = Cpt(Signal, value=0, kind=Kind.normal)
message = Cpt(Signal, value="", kind=Kind.normal)
unsaved = Cpt(Signal, value=-1, kind=Kind.normal)
inconsistent = Cpt(Signal, value=-1, kind=Kind.normal)
rules_violated = Cpt(Signal, value=-1, kind=Kind.normal)
definition = Cpt(Signal, value="", kind=Kind.normal)
snapshot_json = Cpt(Signal, value="", kind=Kind.normal)
# one signal per setting
locals().update({_n: Cpt(Signal, value=math.nan, kind=Kind.normal) for _n in SETTINGS})
def __init__(self, prefix="", *, name, token=None, token_file=None, url=None, host=None, timeout=2.0,
device_manager=None, **kwargs):
super().__init__(prefix, name=name, **kwargs)
self._client = WebHmiClient(url=url, token=token, token_file=token_file, host=host,
device_manager=device_manager, timeout=timeout, client_name="BEC baseline")
# requests of flomni.realtime_system (RPC from the client, below): longer timeout, no back-off
self._api = WebHmiClient(url=url, token=token, token_file=token_file, host=host,
device_manager=device_manager, timeout=max(timeout, 10.0), client_name="BEC")
self._failed_at = None
self._last = None
def _unavailable(self, reason):
self.available.put(0)
self.message.put(str(reason))
for n in ("unsaved", "inconsistent", "rules_violated"):
getattr(self, n).put(-1)
self.definition.put("")
self.snapshot_json.put("")
for n in SETTINGS:
getattr(self, n).put(math.nan)
def update(self):
"""Read the settings from the web HMI into the signals. Never raises: not available -> available 0."""
now = time.time()
if self._failed_at is not None and now - self._failed_at < RETRY_AFTER_FAILURE_S:
return # keep the 'not available' values of the last failure
try:
snap = self._client.settings()
except WebHmiError as exc:
self._failed_at = now
self._unavailable(f"{exc} (next attempt after {RETRY_AFTER_FAILURE_S:.0f} s)")
return
except Exception as exc: # noqa: BLE001 (a broken answer must not stop a scan)
self._failed_at = now
self._unavailable(f"unexpected answer from the web HMI: {exc}")
return
self._failed_at = None
self._last = snap
values = {s["name"]: s for s in snap.get("settings", [])}
for n in SETTINGS:
v = values.get(n, {}).get("value")
getattr(self, n).put(float(v) if isinstance(v, (int, float)) else math.nan) # bool is an int too
self.unsaved.put(sum(1 for s in values.values() if s.get("unsaved")))
self.inconsistent.put(sum(1 for s in values.values() if not s.get("consistent", True)))
self.rules_violated.put(sum(1 for r in snap.get("rules", []) if not r.get("ok") and r.get("severity") == "error"))
self.definition.put(f"{snap.get('definition', '')} sha1 {snap.get('definition_sha1', '?')}")
missing = [n for n in values if n not in SETTINGS]
self.message.put(f"settings not stored as own signals (in snapshot_json): {', '.join(missing)}" if missing else "")
# compact: values and flags only (help texts and parameter mappings are in the definition, see its sha1)
compact = {
"instrument": snap.get("instrument"), "definition": snap.get("definition"),
"definition_sha1": snap.get("definition_sha1"), "scan": snap.get("scan"),
"settings": {n: {"value": s.get("value"), "saved": s.get("saved"), "unsaved": s.get("unsaved"),
"consistent": s.get("consistent"), "unit": s.get("unit", "")}
for n, s in values.items()},
"rules_violated": [{"name": r.get("name"), "severity": r.get("severity"), "message": r.get("message")}
for r in snap.get("rules", []) if not r.get("ok")],
}
self.snapshot_json.put(json.dumps(compact, separators=(",", ":")))
self.available.put(1)
def read(self):
self.update()
return super().read()
# ── RPC for flomni.realtime_system: the user commands go through this device, so the API token is needed only
# in its device config (device server), not on the client. Not called set / put: the device server treats
# those names as ophyd writes (refused on a readOnly device).
_API_PATHS = ("/api/settings", "/api/status")
def _check_path(self, path):
if not any(path == p or path.startswith(p + "/") for p in self._API_PATHS):
raise WebHmiError(f"path {path!r} not allowed (only /api/settings*, /api/status)")
def api_get(self, path: str = "/api/settings") -> dict:
"""GET a web HMI loop-settings endpoint; returns its JSON. Errors raise (passed to the client)."""
self._check_path(path)
return self._api._request(path)
def api_post(self, path: str, body: dict = None) -> dict:
"""POST to a web HMI loop-settings endpoint (plan / apply / save); returns its JSON."""
self._check_path(path)
return self._api._request(path, body or {})
def snapshot(self) -> dict:
"""Read now and return {setting: value} (empty when not available)."""
self._failed_at = None # an explicit request always tries
self.update()
if not self.available.get():
return {}
return {n: getattr(self, n).get() for n in SETTINGS}
def show(self):
"""Print the settings as they would be stored with a scan now."""
snap = self.snapshot()
if not snap:
print(f"loop settings not available: {self.message.get()}")
return
for n, v in snap.items():
print(f" {n:30s} {v:g}")
print(f"unsaved {self.unsaved.get()}, inconsistent {self.inconsistent.get()}, "
f"rules violated {self.rules_violated.get()}; {self.definition.get()}")
if self.message.get():
print(self.message.get())
+44 -146
View File
@@ -13,6 +13,7 @@ from ophyd_devices.utils.socket import SocketIO, raise_if_disconnected
from prettytable import PrettyTable
from csaxs_bec.devices.omny.rt.rt_ophyd import (
format_scan_timing,
RtCommunicationError,
RtError,
RtReadbackSignal,
@@ -45,17 +46,6 @@ class RtFlomniController(Controller):
"laser_tracker_check_signalstrength",
"laser_tracker_check_enabled",
"is_axis_moving",
"emitter_show_all",
"emitter_get",
"emitter_set_tracking_target_y",
"emitter_set_tracking_target_z",
"emitter_set_intensity_threshold_laser",
"emitter_set_psd_intensity_threshold_tracking_low",
"emitter_get_ontarget",
"emitter_set_ontarget_tolerance",
"emitter_set_ontarget_hold_ms",
"emitter_help",
"emitter_set_trigger_on_move",
"move_xyz",
]
@@ -404,84 +394,9 @@ class RtFlomniController(Controller):
return True
return False
def emitter_get(self) -> dict:
"""Read the runtime constants served by the ConstEmitter Orchestra module.
These are values that used to live in individual module parameter files
(tracking targets and intensity thresholds) and are now emitted centrally
so they can be tweaked live. The rotation-loop laser threshold
(intensity_threshold_rot_laser) is a pure startup constant and is
intentionally not reported here.
Returns:
dict: current emitter values.
"""
ret = self.socket_put_and_receive("es")
# remove trailing \n
ret = ret.split("\n")[0]
values = [float(val) for val in ret.split(",")]
if len(values) != 4:
raise RtCommunicationError(
f"Expected to receive 4 emitter values. Instead received {values}"
)
return {
"targetpos_tracking_y": values[0],
"targetpos_tracking_z": values[1],
"intensity_threshold_laser": values[2],
"PSD_intensity_threshold_tracking_low": values[3],
}
def emitter_show_all(self):
"""Print all ConstEmitter runtime constants as a table."""
t = PrettyTable()
t.title = "ConstEmitter Runtime Constants"
t.field_names = ["Name", "Value"]
for key, val in self.emitter_get().items():
t.add_row([key, val])
for key, val in self.emitter_get_ontarget().items():
t.add_row([key, val])
print(t)
def emitter_get_ontarget(self) -> dict:
"""Read the detector-trigger stability criterion (OnTargetTrigger in the control loop).
A step-scan point is triggered when the relative position sample vs FZP is within
``ontarget_tolerance_um`` in x and in y (each averaged over 0.5 ms) for ``ontarget_hold_ms``
without interruption.
Returns:
dict: ontarget_tolerance_um, ontarget_hold_ms, trigger_on_move (None from a server
that does not report it yet).
"""
ret = self.socket_put_and_receive("eg")
ret = ret.split("\n")[0]
values = [float(val) for val in ret.split(",")]
if len(values) not in (2, 3):
raise RtCommunicationError(
f"Expected to receive 2 or 3 on-target values. Instead received {values}"
)
return {
"ontarget_tolerance_um": values[0],
"ontarget_hold_ms": values[1],
"trigger_on_move": values[2] if len(values) == 3 else None,
}
def emitter_set_ontarget_tolerance(self, val_um: float) -> None:
"""Set the detector-trigger tolerance in um, per axis.
E.g. 0.030 for a low-resolution scan, 0.002 for the highest resolution. A value <= 0 selects
the former criterion (on-target flags of the FZP PID loops, set in their parameter files).
"""
self.socket_put(f"eo{val_um:.6f}")
def emitter_set_ontarget_hold_ms(self, val_ms: float) -> None:
"""Set the time in ms the position must stay within the tolerance before the trigger."""
self.socket_put(f"eh{val_ms:.3f}")
def emitter_set_trigger_on_move(self, val: int) -> None:
"""1: send one detector trigger after every direct position change (pa / p), once on target
(same criterion as a step-scan point); 0: triggers at step-scan points only."""
self.socket_put(f"em{1 if val else 0}")
# emitter_* (ConstEmitter get / set through the CommunicationServer e-commands) removed 2026-10-23: the
# ConstEmitter is no longer in the loop; its values are loop settings, set through the web HMI
# (flomni.realtime_system, csaxs_bec/bec_ipython_client/plugins/flomni/flomni_realtime_system.py).
def move_xyz(self, x: float, y: float, z: float) -> None:
"""Move rtx, rty and rtz with one command (CommunicationServer p<x>,<y>,<z>).
@@ -502,59 +417,6 @@ class RtFlomniController(Controller):
targets.append(target_val)
self.socket_put(f"p{targets[0]:.4f},{targets[1]:.4f},{targets[2]:.4f}")
def emitter_help(self) -> None:
"""Print the ConstEmitter commands (loop runtime constants) with a short explanation."""
t = PrettyTable()
t.title = "ConstEmitter commands (rt controller)"
t.field_names = ["Method", "Wire", "Explanation"]
t.align = "l"
rows = [
("emitter_show_all()", "es, eg", "print all runtime constants"),
("emitter_get()", "es", "dict: tracking targets y/z, laser and PSD thresholds"),
("emitter_get_ontarget()", "eg", "dict: detector-trigger tolerance (um), hold (ms)"),
("emitter_set_tracking_target_y(val)", "ey", "target of the tracking loop y (PSD)"),
("emitter_set_tracking_target_z(val)", "ez", "target of the tracking loop z (PSD)"),
("emitter_set_intensity_threshold_laser(val)", "et",
"interferometer signal below this: slew limiters and x/y/z, FZP PIDs stop"),
("emitter_set_psd_intensity_threshold_tracking_low(val)", "el",
"PSD intensity below this: tracking PIDs y/z stop"),
("emitter_set_ontarget_tolerance(val_um)", "eo",
"detector trigger: |relative error| x and y <= val um (e.g. 0.030 coarse, 0.002 fine);"
" <= 0: FZP PID flags"),
("emitter_set_ontarget_hold_ms(val_ms)", "eh",
"detector trigger: ... held for val ms without interruption"),
("emitter_set_trigger_on_move(val)", "em",
"1: one detector trigger after every direct move (pa / p) once on target; 0: off"),
]
for row in rows:
t.add_row(row)
print(t)
print(
"Values are live in the control loop; the start values after a loop restart come from"
" ConstEmitter.xmp."
)
def emitter_set_tracking_target_y(self, val: float) -> None:
"""Set the y tracking target position (PID_tracking_y)."""
self.socket_put(f"ey{val:.4f}")
def emitter_set_tracking_target_z(self, val: float) -> None:
"""Set the z tracking target position (PID_tracking_z)."""
self.socket_put(f"ez{val:.4f}")
def emitter_set_intensity_threshold_laser(self, val: float) -> None:
"""Set the shared laser intensity threshold.
Wired into the slew-rate limiters (x/y/z) and the PID loops
(x/y/z and the fast FZP x/y loops). Does not affect the
rotation loops, which use a separate fixed threshold.
"""
self.socket_put(f"et{val:.4f}")
def emitter_set_psd_intensity_threshold_tracking_low(self, val: float) -> None:
"""Set the low PSD intensity threshold used by the tracking PIDs (y and z)."""
self.socket_put(f"el{val:.4f}")
def pid_y(self) -> float:
ret = float(self.socket_put_and_receive("G").strip())
return ret
@@ -955,6 +817,11 @@ class RtFlomniFlyer(Device):
)
self.average_stdeviations_x_st_fzp = 0
self.average_stdeviations_y_st_fzp = 0
# end-of-scan statistics: kickoff parameters from the scan, start / end of the scan
self._exp_time = None
self._frames_per_trigger = 1
self._t_kickoff = None
self._t_scan_end = None
self.readout_thread = None
self.scan_done_event = threading.Event()
self.scan_done_event.set()
@@ -995,6 +862,8 @@ class RtFlomniFlyer(Device):
signals = self._get_signals_from_table(return_table)
self.data.set(signals)
self._t_scan_end = time.monotonic() # scan finished in the RT loop (statistics)
if self.shutdown_event.wait(0.05):
logger.info("Shutdown event set, stopping readout.")
# if we are here, the shutdown_event is set. We can exit the readout loop.
@@ -1016,11 +885,30 @@ class RtFlomniFlyer(Device):
status.set_finished()
self.progress.put(value=read_counter, max_value=number_of_positions_planned, done=True)
logger.info(
"Flomni statistics: Average of all standard deviations: x"
f" {self.average_stdeviations_x_st_fzp/read_counter*1000:.1f}, y"
f" {self.average_stdeviations_y_st_fzp/read_counter*1000:.1f}"
self._report_statistics(read_counter)
def _report_statistics(self, read_counter: int):
"""At the end of a scan: mean of the per-point standard deviations (sample vs FZP during each exposure,
RT_sampler) to the device-server log and to the BEC command line (Mirko 2026-10-26)."""
if read_counter == 0:
logger.info("Flomni statistics: no scan points read, no average of the standard deviations")
return
msg = (
"flOMNI scan statistics: average of the standard deviations during the exposures"
f" ({read_counter} points, sample vs FZP): x"
f" {self.average_stdeviations_x_st_fzp/read_counter*1000:.1f} nm, y"
f" {self.average_stdeviations_y_st_fzp/read_counter*1000:.1f} nm"
+ format_scan_timing(read_counter, self._t_kickoff, self._t_scan_end, self._exp_time,
self._frames_per_trigger)
)
logger.info(msg)
try:
self.controller.device_manager.connector.send_client_info(
msg, scope="flomni_scan_statistics", show_asap=True
)
except Exception as exc: # pylint: disable=broad-except
# the statistics are information only: never fail the readout for them
logger.warning(f"Flomni statistics not sent to the client: {exc}")
def _get_signals_from_table(self, return_table) -> dict:
self.average_stdeviations_x_st_fzp += float(return_table[4])
@@ -1054,12 +942,22 @@ class RtFlomniFlyer(Device):
)
self.readout_thread.start()
def configure(self, d: dict) -> tuple:
"""The device server calls this with the kickoff parameters of the scan before kickoff():
exp_time / frames_per_trigger for the end-of-scan statistics (not passed: no exposure part)."""
d = dict(d or {})
self._exp_time = d.pop("exp_time", None)
self._frames_per_trigger = d.pop("frames_per_trigger", 1) or 1
return super().configure(d) if d else ({}, {})
def kickoff(self) -> DeviceStatus:
self.shutdown_event.clear()
self.scan_done_event.clear()
while not self.controller._min_scan_buffer_reached and not self.shutdown_event.wait(0.001):
...
self.controller.start_scan()
self._t_kickoff = time.monotonic()
self._t_scan_end = None
self.shutdown_event.wait(0.1)
status = DeviceStatus(self)
status.set_finished()
+43 -3
View File
@@ -13,7 +13,7 @@ from ophyd_devices.utils.controller import Controller, retry_once, threadlocked
from ophyd_devices.utils.socket import SocketIO, SocketSignal, raise_if_disconnected
from prettytable import PrettyTable
from csaxs_bec.devices.omny.rt.rt_ophyd import RtCommunicationError, RtError
from csaxs_bec.devices.omny.rt.rt_ophyd import RtCommunicationError, RtError, format_scan_timing
logger = bec_logger.logger
@@ -752,6 +752,11 @@ class RtLamniFlyer(Device):
self.average_stdeviations_x_st_fzp = 0
self.average_stdeviations_y_st_fzp = 0
self.average_lamni_angle = 0
# end-of-scan statistics: kickoff parameters from the scan, start / end of the scan
self._exp_time = None
self._frames_per_trigger = 1
self._t_kickoff = None
self._t_scan_end = None
self.readout_thread = None
self.scan_done_event = threading.Event()
self.scan_done_event.set()
@@ -767,12 +772,22 @@ class RtLamniFlyer(Device):
)
self.readout_thread.start()
def configure(self, d: dict) -> tuple:
"""The device server calls this with the kickoff parameters of the scan before kickoff():
exp_time / frames_per_trigger for the end-of-scan statistics (not passed: no exposure part)."""
d = dict(d or {})
self._exp_time = d.pop("exp_time", None)
self._frames_per_trigger = d.pop("frames_per_trigger", 1) or 1
return super().configure(d) if d else ({}, {})
def kickoff(self) -> DeviceStatus:
self.shutdown_event.clear()
self.scan_done_event.clear()
while not self.controller._min_scan_buffer_reached and not self.shutdown_event.wait(0.001):
...
self.controller.start_scan()
self._t_kickoff = time.monotonic()
self._t_scan_end = None
self.shutdown_event.wait(0.1)
status = DeviceStatus(self)
status.set_finished()
@@ -834,6 +849,8 @@ class RtLamniFlyer(Device):
signals = self._get_signals_from_table(return_table)
self.data.set(signals)
self._t_scan_end = time.monotonic() # scan finished in the RT loop (statistics)
if self.shutdown_event.wait(0.05):
logger.info("Shutdown event set, stopping readout.")
# if we are here, the shutdown_event is set. We can exit the readout loop.
@@ -855,9 +872,32 @@ class RtLamniFlyer(Device):
status.set_finished()
self.progress.put(value=read_counter, max_value=number_of_positions_planned, done=True)
logger.info(
f"LamNI statistics: Average of all standard deviations: x {self.average_stdeviations_x_st_fzp}, y {self.average_stdeviations_y_st_fzp}, angle {self.average_lamni_angle}."
self._report_statistics(read_counter)
def _report_statistics(self, read_counter: int):
"""At the end of a scan: mean of the per-point standard deviations (sample vs FZP during each exposure,
RT_sampler) and the mean angle to the device-server log and to the BEC command line (Mirko 2026-10-26,
as flOMNI). Before, the log line printed the sums, not the averages."""
if read_counter == 0:
logger.info("LamNI statistics: no scan points read, no average of the standard deviations")
return
msg = (
"LamNI scan statistics: average of the standard deviations during the exposures"
f" ({read_counter} points, sample vs FZP): x"
f" {self.average_stdeviations_x_st_fzp/read_counter*1000:.1f} nm, y"
f" {self.average_stdeviations_y_st_fzp/read_counter*1000:.1f} nm;"
f" average angle {self.average_lamni_angle/read_counter:.4f}"
+ format_scan_timing(read_counter, self._t_kickoff, self._t_scan_end, self._exp_time,
self._frames_per_trigger)
)
logger.info(msg)
try:
self.controller.device_manager.connector.send_client_info(
msg, scope="lamni_scan_statistics", show_asap=True
)
except Exception as exc: # pylint: disable=broad-except
# the statistics are information only: never fail the readout for them
logger.warning(f"LamNI statistics not sent to the client: {exc}")
def _get_signals_from_table(self, return_table) -> dict:
self.average_stdeviations_x_st_fzp += float(return_table[5])
+20
View File
@@ -14,6 +14,26 @@ from ophyd_devices.utils.socket import SocketSignal
logger = bec_logger.logger
def format_scan_timing(n_points: int, t_start, t_end, exp_time=None, frames_per_trigger=1) -> str:
"""Time split of a flyer scan for the end-of-scan statistics (Mirko 2026-10-26, BEC side only): scan time from
the kickoff to the end of the scan in the RT loop (wall clock of the device server), exposure = points x
requested exp_time x frames_per_trigger (not measured: the detector may expose longer), overhead = the rest
(positioning, trigger, detector readout). '' when the times are missing."""
if not n_points or t_start is None or t_end is None or t_end <= t_start:
return ""
total = t_end - t_start
txt = f"; scan time {total:.1f} s ({total / n_points * 1000:.0f} ms/point)"
if exp_time:
expo = n_points * float(exp_time) * int(frames_per_trigger or 1)
over = total - expo
txt += (
f": exposure {expo:.1f} s ({expo / total * 100:.0f} %, requested {float(exp_time) * 1000:g} ms"
f" x {int(frames_per_trigger or 1)}), overhead {over:.1f} s ({over / n_points * 1000:.0f} ms/point:"
" positioning, trigger, detector)"
)
return txt
class RtCommunicationError(ControllerCommunicationError):
pass
+116
View File
@@ -0,0 +1,116 @@
"""Client of the web HMI on an OMNY-family loop machine (RT-system webhmi): REST requests with the API token.
Shared by the BEC client plugin (flomni.realtime_system, bec_ipython_client/plugins/flomni/flomni_realtime_system.py)
and the baseline device FlomniLoopSettings (devices/omny/flomni_loop_settings.py), so both talk to the web HMI the
same way. Standard library only (runs in the IPython client and in the device server).
URL: explicit `url`, else env FLOMNI_WEBHMI_URL, else http://<host>:8080 with <host> = `host`, else the host of the
rtx device config, else mpc2844.psi.ch. With the default URL a request that cannot reach port 8080 is repeated on
port 80 of the same host (nftables redirect 80 -> 8080 on the loop machine, the port the PSI gateway passes); that URL
is then kept. An explicit URL gets no fallback.
Token: `token` (the value, e.g. from a device config that is not committed), else `token_file`, else env
FLOMNI_WEBHMI_TOKEN_FILE, else ~/.flomni_webhmi_token. The same line as the web HMI's api_token_file on the loop
machine (flOMNI FeedbackLoop/loopsettings/README.md). Never commit a token.
"""
import json
import os
import urllib.error
import urllib.request
DEFAULT_TOKEN_FILE = "~/.flomni_webhmi_token"
DEFAULT_PORT = 8080
DEFAULT_HOST = "mpc2844.psi.ch"
class WebHmiError(Exception):
"""A request was refused (rules, scan running) or the web HMI could not be reached (.unreachable = True)."""
unreachable = False
class WebHmiClient:
def __init__(self, url=None, token=None, token_file=None, host=None, device_manager=None, timeout=5.0,
client_name="BEC"):
self._url = url
self._fallback = None # port-80 URL that answered when 8080 did not (kept for the session)
self._token_value = token
self._token_file = token_file
self._host = host
self._device_manager = device_manager
self.timeout = timeout
self.client_name = client_name
@property
def url(self):
if self._fallback:
return self._fallback
if self._url:
return self._url.rstrip("/")
env = os.environ.get("FLOMNI_WEBHMI_URL")
if env:
return env.rstrip("/")
host = self._host or DEFAULT_HOST
if not self._host:
try: # the host of the rt controller (device config of rtx)
host = self._device_manager.devices.rtx._config["deviceConfig"]["host"] or host
except Exception: # noqa: BLE001
pass
return f"http://{host}:{DEFAULT_PORT}"
def _explicit_url(self):
return bool(self._url or os.environ.get("FLOMNI_WEBHMI_URL"))
def _token(self):
if self._token_value:
return str(self._token_value).strip()
path = os.path.expanduser(self._token_file or os.environ.get("FLOMNI_WEBHMI_TOKEN_FILE") or DEFAULT_TOKEN_FILE)
try:
with open(path) as f:
tok = f.readline().strip()
except OSError:
raise WebHmiError(
f"No API token for the web HMI: {path} not readable. Copy the token from the loop machine "
"(~/loop-data/webhmi/api_token of the user control) into this file (mode 600), or set "
"FLOMNI_WEBHMI_TOKEN_FILE.") from None
if not tok:
raise WebHmiError(f"API token file {path} is empty")
return tok
def _request(self, path, body=None):
try:
return self._request_once(self.url, path, body)
except WebHmiError as exc:
# default URL on port 8080 not reachable: the loop machine also serves the web HMI on port 80
if exc.unreachable and not self._explicit_url() and not self._fallback:
alt = self.url.replace(f":{DEFAULT_PORT}", "")
r = self._request_once(alt, path, body)
self._fallback = alt
return r
raise
def _request_once(self, url, path, body=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(url + path, data=data, method="GET" if body is None else "POST",
headers={"Authorization": "Bearer " + self._token(),
"X-Client": self.client_name, "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=self.timeout) as r:
return json.load(r)
except urllib.error.HTTPError as exc:
try:
detail = json.load(exc).get("detail", exc.reason)
except Exception: # noqa: BLE001
detail = exc.reason
if exc.code == 401:
detail = f"{detail} (API token not accepted by the web HMI: compare with its api_token_file)"
raise WebHmiError(f"web HMI {url}{path}: {exc.code} {detail}") from None
except (urllib.error.URLError, OSError) as exc:
err = WebHmiError(f"web HMI {url} not reachable: {exc}")
err.unreachable = True
raise err from None
def settings(self) -> dict:
"""GET /api/settings: settings, rules, scan state, profiles."""
return self._request("/api/settings")
+5 -1
View File
@@ -238,7 +238,11 @@ class FlomniFermatScan(ScanBase):
"""
# send off the flyer
self.actions.kickoff(device=self.dev.rt_positions)
self.actions.kickoff(
device=self.dev.rt_positions,
# for the end-of-scan statistics of the flyer (exposure vs overhead)
parameters={"exp_time": self.exp_time, "frames_per_trigger": self.frames_per_trigger},
)
# start the readout loop of the flyer
status = self.actions.complete(device=self.dev.rt_positions, wait=False)
+5 -1
View File
@@ -218,7 +218,11 @@ class LamniFermatScan(ScanBase):
This is where the main scan logic should be implemented.
"""
self.actions.kickoff(device="rt_positions")
self.actions.kickoff(
device="rt_positions",
# for the end-of-scan statistics of the flyer (exposure vs overhead)
parameters={"exp_time": self.exp_time, "frames_per_trigger": self.frames_per_trigger},
)
status = self.actions.complete(device="rt_positions", wait=False)
+11 -22
View File
@@ -221,23 +221,12 @@ _There is also an enable without reset, which is used during tomography scans, w
#### Detector trigger: position stability
In step scans the detector is triggered when the sample is stable at the scan point: the relative position
sample vs FZP (interferometer minus FZP cap sensor) must stay within a tolerance in x and in y (each averaged
over 0.5 ms) for a hold time. Both values are live and can be changed between scans:
sample vs FZP must stay within a tolerance in x and in y for a short hold time (standard 10 nm, 3 ms). The local
contact can choose a coarser tolerance for fast low-resolution scans or a finer one for the highest resolution.
Measured with 3 µm steps (2026-10-20): the trigger comes about 36 ms (x) / 48 ms (y) after the step at 10 nm,
40 / 55 ms at 2 nm; after the trigger the position stays within about 4 nm even at a 30 nm tolerance.
- `flomni.interferometer_trigger_show()` — current tolerance and hold time
- `flomni.interferometer_trigger_set_tolerance_nm(10)` — tolerance per axis in nm: about 30 for a fast
low-resolution scan, 10 standard, 2 for the highest resolution (a few ms more per point);
0 selects the former criterion (on-target flags of the FZP loops)
- `flomni.interferometer_trigger_set_hold_ms(3)` — hold time in ms
- `flomni.interferometer_trigger_set_after_move(True)` — additionally one trigger after every position change
of the sample stage (e.g. `umv(dev.rtx, ...)`), as soon as the position is stable with the same tolerance and
hold time; `False` (default): triggers at scan points only
- `dev.rtx.controller.move_xyz(x, y, z)` — moves rtx, rty and rtz with one command (one trigger for the move)
- `flomni.interferometer_trigger_help()` — this list
After a restart of the control loop the start values (10 nm, 3 ms) apply again. Measured with 3 µm steps
(2026-10-20): the trigger comes about 36 ms (x) / 48 ms (y) after the step at 10 nm, 40 / 55 ms at 2 nm;
after the trigger the position stays within about 4 nm even at a 30 nm tolerance.
- `dev.rtx.controller.move_xyz(x, y, z)` — moves rtx, rty and rtz with one command
### Scanning in 2D and sample alignment
@@ -781,13 +770,13 @@ removed later.
[See short version](flomni.md#sample-storage-and-transfer)
### Detector trigger and interferometer tuning
### Real-time loop settings
The trigger tolerance and hold time: [Detector trigger: position stability](#detector-trigger-position-stability)
(`flomni.interferometer_trigger_*`). The control loops of the sample stage, the FZP scanner and the laser
tracker were characterized and retuned in October 2026; results, the present parameters and how to retune them
are documented in the flOMNI control repository (`FeedbackLoop/feedback_report.md` with PDF,
`FeedbackLoop/tuning_guide.md`).
The operating settings of the real-time control loop (FZP scanner, detector trigger, laser tracker) are set by the
local contact; please do not change them. Every scan stores the settings it ran with (baseline device
`flomni_loop_settings`).
Details for beamline scientists: `csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-realtime_system.md`.
### Heater
@@ -0,0 +1,214 @@
"""Tests for flomni.realtime_system (flomni_realtime_system.py): the client of the web HMI loop-settings API on the
flOMNI loop machine. A small local HTTP server stands in for the web HMI (RT-system webhmi, /api/settings*); it checks
the token header and answers like the real server. No BEC service, no loop."""
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_realtime_system import (
FlomniRealtimeSystem,
RealtimeSystemError,
)
TOKEN = "test-token-0123456789abcdef"
SNAP = {
"instrument": "flOMNI", "note": "Live only.", "scan": {"mode": 0, "active": False, "text": ""},
"groups": [{"name": "trigger", "title": "Detector trigger"}, {"name": "tracking", "title": "Tracker"}],
"settings": [
{"name": "trigger_tolerance_nm", "group": "trigger", "unit": "nm", "value": 10.0, "saved": 10.0,
"unsaved": False, "consistent": True, "issues": [], "info": None},
{"name": "tracking_target_y", "group": "tracking", "unit": "", "value": 0.13, "saved": 0.13,
"unsaved": False, "consistent": True, "issues": [], "info": None},
{"name": "tracking_target_z", "group": "tracking", "unit": "", "value": 0.2, "saved": 0.2,
"unsaved": False, "consistent": True, "issues": [], "info": None},
],
"rules": [], "profiles": [{"name": "production", "title": "Production", "help": "", "values": {}}],
}
class _Handler(BaseHTTPRequestHandler):
requests = []
def log_message(self, *args):
pass
def _reply(self, code, body):
data = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _auth(self):
if self.headers.get("Authorization") != "Bearer " + TOKEN:
self._reply(401, {"detail": "login required"})
return False
return True
def do_GET(self):
if not self._auth():
return
if self.path == "/api/settings":
self._reply(200, SNAP)
elif self.path == "/api/settings/unsaved":
self._reply(200, [])
else:
self._reply(404, {"detail": "not found"})
def do_POST(self):
if not self._auth():
return
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
_Handler.requests.append((self.path, body, self.headers.get("X-Client")))
changes = body.get("changes", {})
if "move_beyond_um_x" in changes:
self._reply(200, {"ok": False, "errors": ["Move-beyond x does not fit"], "warnings": [], "changes": [],
"params": [], "profile": None, "applied": False})
return
ch = [{"name": k, "old": 0, "new": v, "adjusted": None} for k, v in changes.items()]
self._reply(200, {"ok": True, "errors": [], "warnings": [], "changes": ch,
"params": [{"module": "M", "param": "p", "old": [0], "new": [1]}] if ch or body.get("profile") else [],
"profile": body.get("profile"), "applied": self.path.endswith("apply"),
"note": "Live only: save as default to keep them."})
@pytest.fixture
def server(tmp_path):
_Handler.requests = []
srv = HTTPServer(("127.0.0.1", 0), _Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
tok = tmp_path / "token"
tok.write_text(TOKEN + "\n")
yield f"http://127.0.0.1:{srv.server_address[1]}", str(tok), tmp_path
srv.shutdown()
def test_read_and_shortcuts(server, capsys):
url, tok, _ = server
rts = FlomniRealtimeSystem(url=url, token_file=tok)
assert rts.get("trigger_tolerance_nm") == 10.0
assert rts.tracking_targets() == (0.13, 0.2)
rts.show()
out = capsys.readouterr().out
assert "trigger_tolerance_nm" in out and "Live only." in out
with pytest.raises(RealtimeSystemError):
rts.get("nosuch")
def test_set_sends_changes_and_token(server, capsys):
url, tok, _ = server
rts = FlomniRealtimeSystem(url=url, token_file=tok)
r = rts.trigger_set_tolerance_nm(5)
assert r["applied"]
path, body, client = _Handler.requests[-1]
assert path == "/api/settings/apply" and body == {"changes": {"trigger_tolerance_nm": 5}, "profile": None,
"adjust": False} and client == "BEC"
assert "Live only" in capsys.readouterr().out
rts.set(tracking_target_y=0.2, quiet=True)
assert capsys.readouterr().out == ""
rts.apply_profile("production", adjust=True)
assert _Handler.requests[-1][1] == {"changes": {}, "profile": "production", "adjust": True}
rts.check(fzp_enable=0, adjust=True)
assert _Handler.requests[-1][0] == "/api/settings/plan"
def test_refused_raises(server):
url, tok, _ = server
with pytest.raises(RealtimeSystemError, match="does not fit"):
FlomniRealtimeSystem(url=url, token_file=tok).set(move_beyond_um_x=0.5)
with pytest.raises(RealtimeSystemError, match="nothing to set"):
FlomniRealtimeSystem(url=url, token_file=tok).set()
def test_token_errors(server):
url, _, tmp = server
with pytest.raises(RealtimeSystemError, match="No API token"):
FlomniRealtimeSystem(url=url, token_file=str(tmp / "missing")).get()
bad = tmp / "bad"
bad.write_text("wrong-token-xxxxxxxxxxxx")
with pytest.raises(RealtimeSystemError, match="401"):
FlomniRealtimeSystem(url=url, token_file=str(bad)).get()
def test_unreachable():
with pytest.raises(RealtimeSystemError, match="not reachable"):
FlomniRealtimeSystem(url="http://127.0.0.1:9", token_file=__file__, timeout=1).get()
def test_port_80_fallback(monkeypatch):
"""Default URL (rtx host, port 8080) not reachable: the same request goes to port 80 (nftables redirect on the
loop machine) and that URL is kept; an explicit URL gets no fallback."""
monkeypatch.delenv("FLOMNI_WEBHMI_URL", raising=False)
dm = type("DM", (), {})()
dm.devices = type("D", (), {})()
dm.devices.rtx = type("R", (), {"_config": {"deviceConfig": {"host": "loophost"}}})()
rts = FlomniRealtimeSystem(device_manager=dm, token_file=__file__)
calls = []
def once(url, path, body=None):
calls.append(url)
if url.endswith(":8080"):
err = RealtimeSystemError("not reachable")
err.unreachable = True
raise err
return {"ok": True}
monkeypatch.setattr(rts, "_request_once", once)
assert rts._request("/api/settings") == {"ok": True}
assert calls == ["http://loophost:8080", "http://loophost"] and rts.url == "http://loophost"
explicit = FlomniRealtimeSystem(url="http://other:8080", token_file=__file__)
monkeypatch.setattr(explicit, "_request_once", once)
with pytest.raises(RealtimeSystemError):
explicit._request("/api/settings")
def test_routes_through_the_device():
"""With the BEC device flomni_loop_settings present and enabled, requests go to it by RPC (api_get / api_post),
no token on the client; errors of the device come back as RealtimeSystemError."""
calls = []
class Dev:
enabled = True
def api_get(self, path):
calls.append(("get", path))
return {"settings": [{"name": "trigger_tolerance_nm", "value": 10.0}]}
def api_post(self, path, body):
calls.append(("post", path, body))
if "move_beyond_um_x" in body.get("changes", {}):
raise RuntimeError("web HMI not reachable")
return {"ok": True, "errors": [], "warnings": [], "changes": [], "params": [], "applied": False}
dm = type("DM", (), {})()
dm.devices = type("D", (), {})()
dm.devices.flomni_loop_settings = Dev()
rts = FlomniRealtimeSystem(device_manager=dm, token_file="/nonexistent") # token never read
assert rts.get("trigger_tolerance_nm") == 10.0 and rts.route == "device flomni_loop_settings"
rts.check(fzp_enable=0)
assert calls[-1] == ("post", "/api/settings/plan", {"changes": {"fzp_enable": 0}, "profile": None, "adjust": False})
with pytest.raises(RealtimeSystemError, match="via device flomni_loop_settings: web HMI not reachable"):
rts.set(move_beyond_um_x=0.5)
dm.devices.flomni_loop_settings.enabled = False # disabled: direct HTTP
with pytest.raises(RealtimeSystemError, match="No API token"):
rts.get()
def test_help(server, capsys):
"""help(name) / help("all"): explanations from the definition, marked as beamline-scientist only."""
url, tok, _ = server
rts = FlomniRealtimeSystem(url=url, token_file=tok)
rts.help("trigger_tolerance_nm")
out = capsys.readouterr().out
assert "local contact only" in out and "trigger_tolerance_nm" in out and "live: 10" in out
rts.help("all")
assert "Detector trigger" in capsys.readouterr().out
rts.help()
assert "BEAMLINE SCIENTISTS" in capsys.readouterr().out
with pytest.raises(RealtimeSystemError, match="unknown setting"):
rts.help("nosuch")
@@ -0,0 +1,229 @@
"""Fixes from the flomni.py review 2026-10-24: tomo_scan_resume() after a completed scan, special angles kept
on resume, the alignment scan repeats an angle after a timeout, absolute np.isclose tolerances."""
import builtins
import types
import pytest
import csaxs_bec.bec_ipython_client.plugins.flomni.flomni as flomni_module
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni, FlomniError, _ProgressProxy
class _FakeClient:
def __init__(self):
self._vars = {}
def get_global_var(self, key):
return self._vars.get(key)
def set_global_var(self, key, value):
self._vars[key] = value
def _make_flomni():
obj = object.__new__(Flomni)
obj.client = _FakeClient()
obj._progress_proxy = _ProgressProxy(obj.client)
return obj
def test_resume_after_completed_scan_does_nothing(monkeypatch):
flomni = _make_flomni()
flomni.progress["tomo_start_time"] = "2026-10-24T10:00:00"
flomni.progress["tomo_completed"] = True
monkeypatch.setattr(Flomni, "tomo_type", 1, raising=False)
flomni.tomo_scan = lambda **kw: pytest.fail("tomo_scan must not be called")
flomni._confirm_resume_outside_queue = lambda: True
flomni.tomo_scan_resume()
def test_resume_of_interrupted_scan_still_resumes(monkeypatch):
flomni = _make_flomni()
flomni.progress["tomo_start_time"] = "2026-10-24T10:00:00"
flomni.progress["subtomo"] = 3
flomni.progress["angle"] = 45.0
monkeypatch.setattr(Flomni, "tomo_type", 1, raising=False)
calls = []
flomni.tomo_scan = lambda **kw: calls.append(kw)
flomni._confirm_resume_outside_queue = lambda: True
flomni.tomo_scan_resume()
assert calls == [{"subtomo_start": 3, "start_angle": 45.0}]
def test_progress_reset_clears_completed():
flomni = _make_flomni()
flomni.progress["tomo_completed"] = True
flomni.progress.reset()
assert flomni.progress.get("tomo_completed") is False
class _Timeout(flomni_module.AlarmBase):
def __init__(self):
Exception.__init__(self, "timeout")
self.alarm_type = "TimeoutError"
class _Queue:
def __init__(self):
self.next_scan_number = 100
self.resets = 0
def request_queue_reset(self):
self.resets += 1
def test_alignment_scan_repeats_an_angle_after_timeout(monkeypatch, tmp_path):
flomni = _make_flomni()
queue = _Queue()
fake_dev = types.SimpleNamespace(
rtx=types.SimpleNamespace(
controller=types.SimpleNamespace(feedback_is_running=lambda: True)
),
fsamroy=object(),
)
monkeypatch.setitem(builtins.__dict__, "bec", types.SimpleNamespace(queue=queue))
monkeypatch.setitem(builtins.__dict__, "dev", fake_dev)
monkeypatch.setattr(flomni_module, "umv", lambda *a: None, raising=False)
monkeypatch.setattr(flomni_module, "filters_out_of_beam", lambda dev: (True, []))
monkeypatch.setattr(flomni_module.time, "sleep", lambda s: None)
monkeypatch.setattr(Flomni, "sample_name", "test", raising=False)
monkeypatch.setenv("HOME", str(tmp_path))
flomni.get_alignment_offset = lambda angle: (1.0, 0.0, 0.0)
flomni._check_eye_out_and_optics_in = lambda: True
flomni.OMNYTools = types.SimpleNamespace(printgreenbold=lambda msg: None)
flomni._scilog_write = lambda *a: True
angles = []
def projection(angle, _internal=False):
angles.append(angle)
queue.next_scan_number += 1 # one scan per attempt (the timed-out one included)
if angle == 90 and angles.count(90) == 1:
raise _Timeout()
flomni.tomo_scan_projection = projection
flomni.tomo_alignment_scan()
assert angles == [0, 45, 90, 90, 135, 180]
assert queue.resets == 1
lines = (tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt").read_text().splitlines()
assert lines[0] == "100 101 103 104 105" # the accepted scan per angle, not 100..104
@pytest.mark.parametrize(
"readback, ok", [(-1.14, True), (-1.30, True), (-1.40, False), (-0.80, False)]
)
def test_heater_down_needs_fsamx_in_within_0_2_mm(monkeypatch, readback, ok):
flomni = _make_flomni()
fsamx = types.SimpleNamespace(
user_parameter={"in": -1.14}, readback=types.SimpleNamespace(get=lambda: readback)
)
fheater = types.SimpleNamespace(
_config={"deviceConfig": {"axis_Id": "H"}}, user_parameter={"in": 5}
)
monkeypatch.setitem(
builtins.__dict__, "dev", types.SimpleNamespace(fsamx=fsamx, fheater=fheater)
)
monkeypatch.setattr(flomni_module, "dev", builtins.__dict__["dev"], raising=False)
moved = []
monkeypatch.setattr(flomni_module, "umv", lambda *a: moved.append(a), raising=False)
flomni.ensure_osa_back = lambda: None
if ok:
flomni.move_fheater_down()
assert moved
else:
with pytest.raises(FlomniError):
flomni.move_fheater_down()
assert not moved
class _DevContainer(dict):
"""dict keyed by device name, like bec_lib's DeviceContainer (attribute access too)."""
def __getattr__(self, name):
return self[name]
def test_xray_eye_ctrl_c_returns_fsamx_in(monkeypatch):
"""The KeyboardInterrupt handler passed the device instead of its name: dev[device] raised."""
import csaxs_bec.bec_ipython_client.plugins.flomni.flomni_optics_mixin as optics_module
flomni = _make_flomni()
fsamx = types.SimpleNamespace(
user_parameter={"in": -1.14}, readback=types.SimpleNamespace(get=lambda: -1.3)
)
fake_dev = _DevContainer(fsamx=fsamx)
monkeypatch.setitem(builtins.__dict__, "dev", fake_dev)
monkeypatch.setattr(flomni_module, "dev", fake_dev, raising=False)
monkeypatch.setattr(optics_module, "dev", fake_dev, raising=False)
moved = []
monkeypatch.setattr(flomni_module, "umv", lambda *a: moved.append(a), raising=False)
flomni.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: True)
flomni.feedback_disable = lambda: None
class _Align:
def __init__(self, *a):
pass
def align(self, keep_shutter_open):
raise KeyboardInterrupt
monkeypatch.setattr(flomni_module, "XrayEyeAlign", _Align)
with pytest.raises(KeyboardInterrupt):
flomni.start_x_ray_eye_alignment()
assert moved == [(fsamx, -1.14)]
def test_tray_position_message_says_0_to_20():
flomni = _make_flomni()
flomni.check_position_is_valid(20)
with pytest.raises(FlomniError, match="0-20"):
flomni.check_position_is_valid(21)
def test_get_val_reasks_angle_range_and_bad_numbers(monkeypatch, capsys):
answers = iter(["270", "abc", "360"])
monkeypatch.setattr(builtins, "input", lambda prompt="": next(answers))
assert Flomni._get_val("Angular range", 180, int, choices=(180, 360)) == 360
out = capsys.readouterr().out
assert "one of 180, 360" in out and "Not a valid int" in out
def test_feye_out_raises_when_direct_beam_still_visible(monkeypatch):
import csaxs_bec.bec_ipython_client.plugins.flomni.flomni_optics_mixin as optics_module
flomni = _make_flomni()
flomni.csaxs = None
flomni.foptics_in = lambda: None
flomni.flomnigui_show_xeyealign = lambda: None
flomni.xrayeye_update_frame = lambda: None
flomni.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: False)
fake_dev = types.SimpleNamespace(fsh=types.SimpleNamespace(fshclose=lambda: None))
monkeypatch.setattr(optics_module, "dev", fake_dev, raising=False)
monkeypatch.setitem(builtins.__dict__, "dev", fake_dev)
with pytest.raises(optics_module.FlomniOpticsError, match="direct beam"):
flomni.feye_out()
class _SlowStream:
"""Non-blocking stdin stand-in: returns the queued chunks one per read, None in between."""
def __init__(self, chunks):
self.chunks = list(chunks)
def read(self, n):
return self.chunks.pop(0) if self.chunks else None
def test_read_escape_suffix_waits_for_split_sequence():
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import (
read_escape_suffix,
)
assert read_escape_suffix(_SlowStream(["[", None, "A"])) == "[A"
assert read_escape_suffix(_SlowStream([])) is None # ESC alone
@@ -32,6 +32,7 @@ def _make_flomni(monkeypatch):
obj.tomo_angle_range = 180
obj.special_angles = []
obj.special_angle_repeats = 3
obj.special_angle_tolerance = 0.5
obj._current_special_angles = []
obj._print_progress = lambda: None
obj._format_duration = lambda seconds: "0s"
@@ -86,3 +87,34 @@ def test_special_angle_repeats_survive_a_retry(monkeypatch):
assert len(calls) == 1 + 3, "the retry must still repeat the special angle"
assert flomni._current_special_angles == []
def test_special_angle_tolerance_is_used(monkeypatch):
"""special_angle_tolerance was ignored (hard-coded 0.5) before 2026-10-24."""
flomni = _make_flomni(monkeypatch)
flomni.special_angles = [90.0]
flomni._current_special_angles = [90.0]
flomni.special_angle_tolerance = 2.0
calls = []
flomni._at_each_angle = lambda angle: calls.append(angle)
flomni._tomo_scan_at_angle(91.5, 1)
assert calls == [91.5] * 3
assert flomni._current_special_angles == []
def test_special_angles_match_in_any_order(monkeypatch):
"""An angle not reached first (e.g. a reverse sub-tomogram) no longer blocks the later ones."""
flomni = _make_flomni(monkeypatch)
flomni.special_angles = [30.0, 120.0]
flomni._current_special_angles = [30.0, 120.0]
calls = []
flomni._at_each_angle = lambda angle: calls.append(angle)
flomni._tomo_scan_at_angle(120.0, 1)
flomni._tomo_scan_at_angle(120.0, 1) # used once only
flomni._tomo_scan_at_angle(30.0, 1)
assert calls == [120.0] * 3 + [120.0] + [30.0] * 3
assert flomni._current_special_angles == []
@@ -681,7 +681,11 @@ def test_new_scan_resets_progress_before_eye_optics_gate_declines(monkeypatch, t
"must not acquire -- the gate declined"
)
flomni.tomo_scan() # returns early, no exception
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import FlomniError
# raises since 2026-10-24 (a plain return let the tomo queue mark the job "done")
with pytest.raises(FlomniError, match="not in measurement condition"):
flomni.tomo_scan()
assert flomni.progress["projection"] == 0
assert flomni.progress["total_projections"] == 1
@@ -765,3 +769,24 @@ def test_flomni_gui_progress_survives_zero_total(monkeypatch):
assert ring_values == [0.0, 50.0]
assert "Angles from file" in labels[0]
@pytest.mark.parametrize("zero_deg_reference", [False, True])
def test_type1_new_scan_takes_two_zero_deg_projections_at_the_start(monkeypatch, zero_deg_reference):
"""The fixed 0-deg shot + sub-tomogram 1's first projection (0 deg); the damage-check reference
before sub-tomogram 1 would have been a third one in a row (removed 2026-10-24)."""
flomni = make_flomni_for_golden_resume(monkeypatch, tomo_type=1)
flomni.tomo_angle_range = 180
flomni.tomo_angle_stepsize = 45.0
flomni.tomo_type1_always_forward = False
flomni.zero_deg_reference_at_each_subtomo = zero_deg_reference
calls = []
flomni._tomo_scan_at_angle = lambda angle, subtomo, **kw: calls.append((float(angle), subtomo))
flomni.tomo_scan()
assert calls[:2] == [(0.0, 1), (0.0, 1)]
assert calls[2][0] != 0.0
# sub-tomogram 3 (forward, starts near 0) still gets its reference shot when enabled
sub3 = [c for c in calls if c[1] == 3]
assert (sub3[0][0] == 0.0) == zero_deg_reference
@@ -0,0 +1,108 @@
"""Tests for the baseline device flomni_loop_settings (devices/omny/flomni_loop_settings.py): the loop settings of
the flOMNI web HMI stored with every scan. A small local HTTP server stands in for the web HMI (/api/settings)."""
import json
import math
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from csaxs_bec.devices.omny import flomni_loop_settings as fls
from csaxs_bec.devices.omny.flomni_loop_settings import SETTINGS, FlomniLoopSettings
TOKEN = "device-token-0123456789abcdef"
SNAP = {
"instrument": "flOMNI", "definition": "/home/control/OMNY/flOMNI/FeedbackLoop/loopsettings/flomni.json",
"definition_sha1": "a" * 40, "scan": {"mode": 0, "active": False, "text": ""},
"settings": [
{"name": "fzp_enable", "value": 1, "unsaved": False, "consistent": True},
{"name": "fzp_range_V_x", "value": 1.5, "unsaved": True, "consistent": True},
{"name": "trigger_tolerance_nm", "value": 10.0, "unsaved": False, "consistent": False},
{"name": "new_setting_later", "value": 3.0, "unsaved": False, "consistent": True},
],
"rules": [{"name": "r1", "severity": "error", "ok": False}, {"name": "r2", "severity": "warning", "ok": False}],
}
class _Handler(BaseHTTPRequestHandler):
hits = 0
def log_message(self, *args):
pass
def do_GET(self):
_Handler.hits += 1
code, body = (200, SNAP) if self.headers.get("Authorization") == "Bearer " + TOKEN else (401, {"detail": "no"})
data = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
@pytest.fixture
def url():
_Handler.hits = 0
srv = HTTPServer(("127.0.0.1", 0), _Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{srv.server_address[1]}"
srv.shutdown()
def test_read_stores_settings(url):
dev = FlomniLoopSettings(name="flomni_loop_settings", url=url, token=TOKEN)
r = dev.read()
assert r["flomni_loop_settings_available"]["value"] == 1
assert r["flomni_loop_settings_fzp_enable"]["value"] == 1.0
assert r["flomni_loop_settings_fzp_range_V_x"]["value"] == 1.5
assert math.isnan(r["flomni_loop_settings_move_beyond_um_x"]["value"]) # not in the answer
assert r["flomni_loop_settings_unsaved"]["value"] == 1
assert r["flomni_loop_settings_inconsistent"]["value"] == 1
assert r["flomni_loop_settings_rules_violated"]["value"] == 1 # error rules only
assert r["flomni_loop_settings_definition"]["value"].endswith("sha1 " + "a" * 40)
assert "new_setting_later" in r["flomni_loop_settings_message"]["value"]
snap = json.loads(r["flomni_loop_settings_snapshot_json"]["value"])
assert snap["settings"]["fzp_enable"]["value"] == 1 and "profiles" not in snap
assert snap["settings"]["new_setting_later"]["value"] == 3.0 # later settings: in the JSON
assert [r["name"] for r in snap["rules_violated"]] == ["r1", "r2"]
assert set(SETTINGS) <= {k.replace("flomni_loop_settings_", "") for k in r}
def test_unavailable_does_not_raise_and_backs_off(url, monkeypatch):
dev = FlomniLoopSettings(name="ls", url=url, token="wrong-token-xxxxxxxxxxxx")
r = dev.read()
assert r["ls_available"]["value"] == 0 and "401" in r["ls_message"]["value"]
assert math.isnan(r["ls_fzp_enable"]["value"]) and r["ls_unsaved"]["value"] == -1
hits = _Handler.hits
dev.read() # within the back-off: no new request
assert _Handler.hits == hits
monkeypatch.setattr(fls, "RETRY_AFTER_FAILURE_S", 0.0)
dev.read()
assert _Handler.hits == hits + 1
def test_unreachable():
dev = FlomniLoopSettings(name="ls", url="http://127.0.0.1:9", token=TOKEN, timeout=0.5)
r = dev.read()
assert r["ls_available"]["value"] == 0 and "not reachable" in r["ls_message"]["value"]
def test_token_file_and_snapshot(url, tmp_path):
tok = tmp_path / "token"
tok.write_text(TOKEN + "\n")
dev = FlomniLoopSettings(name="ls", url=url, token_file=str(tok))
assert dev.snapshot()["fzp_range_V_x"] == 1.5
def test_api_methods_for_realtime_system(url):
"""api_get / api_post: the RPC used by flomni.realtime_system (token only in the device config)."""
dev = FlomniLoopSettings(name="ls", url=url, token=TOKEN)
assert dev.api_get("/api/settings")["instrument"] == "flOMNI"
with pytest.raises(Exception, match="not allowed"):
dev.api_get("/api/params") # only the loop-settings endpoints
with pytest.raises(Exception, match="not allowed"):
dev.api_post("/api/loop/stop", {})
assert "api_get" in FlomniLoopSettings.USER_ACCESS and "set" not in FlomniLoopSettings.USER_ACCESS
+29
View File
@@ -4,6 +4,7 @@ import pytest
from ophyd_devices.tests.utils import SocketMock
from csaxs_bec.devices.omny.rt import RtFlomniController, RtFlomniMotor
from csaxs_bec.devices.omny.rt.rt_flomni_ophyd import RtFlomniFlyer
from csaxs_bec.devices.omny.rt.rt_ophyd import RtError
@@ -98,3 +99,31 @@ def test_feedback_enable_without_reset_raises(rt_flomni):
rt_flomni.feedback_enable_without_reset()
laser_tracker_on.assert_called_once()
assert mock.call(b"l3\n") in rt_flomni.sock.put.mock_calls
def test_flyer_reports_average_stdev_to_the_client(rt_flomni):
flyer = RtFlomniFlyer(name="rt_positions", socket_cls=SocketMock, device_manager=rt_flomni.device_manager)
flyer.controller = rt_flomni
for row in (["0", "0", "1", "1", "0.002", "2", "2", "0.001", "0", "0"],
["1", "0", "1", "1", "0.004", "2", "2", "0.003", "0", "0"]):
flyer._get_signals_from_table(row)
flyer.configure({"exp_time": 0.1, "frames_per_trigger": 2}) # kickoff parameters of the scan
flyer._t_kickoff, flyer._t_scan_end = 100.0, 101.0
flyer._report_statistics(2)
send = rt_flomni.device_manager.connector.send_client_info
msg = send.call_args.args[0]
assert "x 3.0 nm, y 2.0 nm" in msg and "2 points" in msg
assert "scan time 1.0 s (500 ms/point)" in msg and "exposure 0.4 s (40 %" in msg and "overhead 0.6 s (300 ms/point" in msg
assert send.call_args.kwargs["show_asap"] is True
send.reset_mock()
flyer._report_statistics(0) # no points: nothing sent, no ZeroDivisionError
send.assert_not_called()
def test_format_scan_timing():
from csaxs_bec.devices.omny.rt.rt_ophyd import format_scan_timing
assert format_scan_timing(0, 0, 1) == "" and format_scan_timing(5, None, 1) == ""
assert format_scan_timing(10, 0, 2.0) == "; scan time 2.0 s (200 ms/point)" # no exp_time: no split
txt = format_scan_timing(10, 0, 2.0, 0.1, 1)
assert "exposure 1.0 s (50 %" in txt and "overhead 1.0 s (100 ms/point" in txt
@@ -0,0 +1,26 @@
from unittest import mock
from ophyd_devices.tests.utils import SocketMock
from csaxs_bec.devices.omny.rt.rt_lamni_ophyd import RtLamniController, RtLamniFlyer
def test_lamni_flyer_reports_average_stdev_to_the_client():
RtLamniController._reset_controller()
flyer = RtLamniFlyer(name="rt_positions", socket_cls=SocketMock, device_manager=mock.MagicMock())
flyer.controller = mock.MagicMock()
for i, (sx, sy) in enumerate(((0.002, 0.001), (0.004, 0.003))):
row = ["0"] * 21
row[0], row[5], row[8], row[19] = str(i), str(sx), str(sy), "0.5"
flyer._get_signals_from_table(row)
flyer.configure({"exp_time": 0.05}) # frames_per_trigger default 1
flyer._t_kickoff, flyer._t_scan_end = 10.0, 10.5
flyer._report_statistics(2)
send = flyer.controller.device_manager.connector.send_client_info
msg = send.call_args.args[0]
assert "x 3.0 nm, y 2.0 nm" in msg and "2 points" in msg and "average angle 0.5000" in msg
assert "scan time 0.5 s (250 ms/point)" in msg and "exposure 0.1 s (20 %" in msg
send.reset_mock()
flyer._report_statistics(0) # no points: nothing sent, no ZeroDivisionError
send.assert_not_called()
RtLamniController._reset_controller()