feat(flomni): commission heater down/up sequencing and OSA safety checks
CI for csaxs_bec / test (push) Failing after 2m55s

- Add fheater userParameter (in: -11.5, enabled) and drive it to that
  position at the end of ftransfer_flomni_stage_in(), gated on "enabled"
- fosa_in()/fosa_out() now refuse to move the OSA unless fheater is
  verified fully up or fully down, instead of fosa_out() silently
  forcing the heater up
- ensure_fheater_up() reuses the new _fheater_is_up() helper
- Reorder ftransfer_flomni_stage_out() to bring the eye in and optics
  out before disabling feedback
- Tighten fosaz "out" clearance (6 -> 4mm)
- Re-enable omny_panda_continuous device config
- Update PandA position-validation doc with post-fix scan confirmation
  and updated venv path notes

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
x12sa
2026-09-22 15:41:08 +02:00
co-authored by Claude Sonnet 5
parent 9cdc7dcc3f
commit c3c1b10d7e
5 changed files with 193 additions and 85 deletions
@@ -157,8 +157,11 @@ suspecting the conversion factors.
## 5. Practical notes
- Use `/opt/bec_deployments/production/bec_venv/bin/python3` for any
h5py-based analysis — the system `python3` doesn't have `h5py` installed.
- Use a BEC production venv `python3` for any h5py-based analysis — the
system `python3` doesn't have `h5py` installed. Path is host-dependent:
`/opt/bec_deployments/production/bec_venv/bin/python3` on some hosts,
`/sls/x12sa/config/bec/production/bec_venv/bin/python3` on others (e.g.
`x12sa-bec-001`) — check both if one is missing.
- `S06313`'s files were at `/tmp/S06313/` (`S06313_master.h5` has both
`omny_panda_continuous` and `rt_positions`; `S06313_eiger_*` are detector
data, not needed for this). `/tmp` is not persistent — get a fresh scan's
@@ -184,3 +187,85 @@ suspecting the conversion factors.
2. **Reduced data mode**: not yet defined in this doc — get the actual
definition/scope from Mirko at the start of that session rather than
assuming; don't extend this validation methodology to it until then.
## 7. Post-fix confirmation against live scans (2026-09-22)
Checked two scans taken after `3eb6e03` landed (both at `/tmp/S<id>/` on
`x12sa-bec-001`, files from 2026-09-22 ~12:4512:58):
- **`S00386`** (174 points, ~348k raw rows): `frame_counter` starts at `0` on
the very first raw row and has zero backward steps across the whole raw
stream — no leading stale-value prefix at all, unlike pre-fix `S06313`.
Full grouping+conversion validation against `rt_positions`: R² =
0.99999997 (X) / 0.99999998 (Y) after removing the expected constant
per-axis offset (1.23 µm X, +1.08 µm Y, consistent with §4's `S06313`
values); residual std 0.30 nm (X) / 0.25 nm (Y); worst single-point
residual 1.02 nm (X) / 0.51 nm (Y) — no outlier anywhere near the ~116 nm
the pre-fix bug produced. Clean confirmation of the fix.
- **`S00388`** (785 points, ~1.4M raw rows): one backward step, at raw row
36 (`frame_counter` reads `28` for rows 036, then drops to `0` from row
37 on) — structurally the same leading-stale-prefix signature the fix is
meant to strip, still present in data captured after the fix. However,
every one of those 37 stale rows has `gate_detector_active == 0`, so
they'd never enter a point's average regardless (§3.2 already filters to
`gate_detector_active == 1` before grouping) — this scan happened not to
be exposed to the bug's actual failure mode. Full validation still comes
out clean: R² = 0.99999999 (X) / 0.99999998 (Y), residual std 0.27 nm (X)
/ 0.26 nm (Y), max residual 0.85 nm (X) / 0.56 nm (Y).
**Root-cause investigation (updated 2026-09-22, still not fully resolved):**
Ruled out first: the leading `frame_counter` column can't be missing from
`S00388`'s first post-arm chunk the way an earlier version of this note
speculated. `pandablocks.connections.DataConnection`'s captured field set is
fixed once per TCP data connection (established once per `on_stage`, before
any `FrameData` is produced), so every `FrameData` chunk within one
acquisition carries the same columns — the "layout doesn't capture
`frame_counter`" fallback in `_drop_stale_frame_counter_rows`
(`counter_entry is None`) isn't reachable here.
What actually happened, confirmed from `ScanServer.log`/`DeviceServer.log`
for 2026-09-22: the scan immediately preceding `S00388` — scan **#387**
(786 points, `eb7cfd11-3610-4511-94a0-75f49b403e62`) — was opened at
`12:50:10` and force-**aborted** by the user only ~7 seconds later
(`12:50:16``17`, `action: 'abort'` in the scan queue). At `exp_time=0.1`s/point
that's on the right order to land around point ~2030, consistent with the
leftover `frame_counter == 28` that then showed up at the start of `#388`'s
raw stream (`#388` itself opened cleanly at `12:50:39`, no arm retries
logged — matches the "one `on_stage` per scan number" mapping confirmed
against the log). This is exactly the hardware mechanism `3eb6e03`'s commit
message describes (`COUNTER1.OUT` not cleared by `Arm()`) — just triggered
by an **abort mid-scan** rather than a normal scan-to-scan transition, which
is what `S06313` (the original bug report) and `S00386` (clean, §7 above)
both were.
Why the client-side filter didn't strip it is not fully pinned down.
`PandaBoxOMNY.stop()` (inherited, not overridden) does not call
`_reset_raw_stream_state()` — only `on_stage()` (once per scan) and
`_try_arm()` (right before every `Arm()`) do, and both ran normally for
`#388` per the logs. So by the code's own logic the state should have been
freshly reset well before `#388`'s `Arm()` went out. One structural
suspect, not yet confirmed as the actual cause: `_raw_stream_buffer`/
`_raw_stream_buffer_row_count`/`_raw_stream_last_flush` are all mutated only
under `_raw_stream_buffer_lock`, but `_raw_stream_seen_reset` is set outside
that lock in `_reset_raw_stream_state()` (after the `with` block) and read
outside the lock at the top of `_drop_stale_frame_counter_rows()` — the one
piece of this acquisition's reset state that isn't synchronized the same
way as everything else it's reset alongside. Whether that gap is actually
reachable in a plain abort → restage sequence (as opposed to needing an
`on_pre_scan` retry, which `#388` didn't have) isn't shown by the logs
available here; would need instrumentation (e.g. temporarily logging
`_raw_stream_seen_reset` transitions) against a reproduced abort-then-rescan
to confirm.
Practically: harmless in both scans checked so far, since the stale rows'
`gate_detector_active` was `0` throughout — they never entered a point's
average regardless of the row-level filter. But an abort mid-scan is a
routine, user-triggered event (unlike an `on_pre_scan` retry), so this path
is worth closing rather than relying on the gate happening to be low during
the stale window. If revisited: consider (a) locking `_raw_stream_seen_reset`
reads/writes the same as the buffer fields, and (b) also calling
`_reset_raw_stream_state()` from `on_stop`/`stop()`, not only `on_stage`/
`_try_arm`, so an aborted acquisition can't leave stale state for the next
one to inherit. Re-check against a scan where the stale run's rows show
`gate_detector_active == 1` before considering this fully closed.
@@ -386,10 +386,10 @@ class FlomniSampleTransferMixin:
def ensure_fheater_up(self):
axis_id = dev.fheater._config["deviceConfig"].get("axis_Id")
axis_id_numeric = self.axis_id_to_numeric(axis_id)
low, high = dev.fheater.controller.get_motor_limit_switch(axis_id)
_low, high = dev.fheater.controller.get_motor_limit_switch(axis_id)
if high:
raise FlomniError("fheater in high limit. How did we get here?? Aborting.")
if not low:
if not self._fheater_is_up():
self.ensure_osa_back()
if dev.fheater.readback.get() < -0.2:
umv(dev.fheater, -0.2)
@@ -481,6 +481,8 @@ class FlomniSampleTransferMixin:
self.feye_in()
print("Moving X-ray optics out.")
self.foptics_out()
if dev.fheater.user_parameter.get("enabled"):
self.move_fheater_down()
self.xrayeye_update_frame()
def laser_tracker_show_all(self):
@@ -718,6 +720,9 @@ class FlomniSampleTransferMixin:
umv(dev.fsamroy, 0)
self.feye_in()
self.foptics_out()
self.feedback_disable()
self.ensure_fheater_up()
@@ -737,7 +742,6 @@ class FlomniSampleTransferMixin:
self.laser_tracker_off()
time.sleep(0.05)
self.foptics_out()
umv(dev.fsamroy, 90)
@@ -23,37 +23,48 @@ class FlomniOpticsMixin:
foptz_val = dev.foptz.readback.get()
return -fosaz_target + (33 - foptz_val)
def _fheater_is_up(self) -> bool:
axis_id = dev.fheater._config["deviceConfig"].get("axis_Id")
low, _high = dev.fheater.controller.get_motor_limit_switch(axis_id)
return bool(low)
def _fheater_is_down(self) -> bool:
fheater_in = dev.fheater.user_parameter.get("in")
if fheater_in is None:
return False
return np.isclose(dev.fheater.readback.get(), fheater_in, atol=0.1)
def feye_out(self):
dev.fsh.fshclose()
if self.csaxs is not None:
self.csaxs.fil_trans(1, print_only=False)
sl1xs_before = self._sl1xs_before_eye
sl1ys_before = self._sl1ys_before_eye
if sl1xs_before is not None and sl1ys_before is not None:
# if the slit was manually re-adjusted while the eye was in,
# offer to keep the current size instead of restoring the
# pre-eye value
current_sl1xs = dev.sl1xs.user_readback.get()
current_sl1ys = dev.sl1ys.user_readback.get()
expected_sl1xs = sl1xs_before + 0.3
expected_sl1ys = sl1ys_before + 0.3
tol = 0.01 # mm (10 microns)
manually_adjusted = not np.isclose(
current_sl1xs, expected_sl1xs, atol=tol
) or not np.isclose(current_sl1ys, expected_sl1ys, atol=tol)
# sl1xs_before = self._sl1xs_before_eye
# sl1ys_before = self._sl1ys_before_eye
# if sl1xs_before is not None and sl1ys_before is not None:
# # if the slit was manually re-adjusted while the eye was in,
# # offer to keep the current size instead of restoring the
# # pre-eye value
# current_sl1xs = dev.sl1xs.user_readback.get()
# current_sl1ys = dev.sl1ys.user_readback.get()
# expected_sl1xs = sl1xs_before + 0.3
# expected_sl1ys = sl1ys_before + 0.3
# tol = 0.01 # mm (10 microns)
# manually_adjusted = not np.isclose(
# current_sl1xs, expected_sl1xs, atol=tol
# ) or not np.isclose(current_sl1ys, expected_sl1ys, atol=tol)
if manually_adjusted and self.OMNYTools.yesno(
"sl1xs/sl1ys appear to have been adjusted manually while the "
"eye was in. Keep the current slit size instead of "
"restoring the pre-eye value?"
):
print("Keeping current slit size, not restoring pre-eye value.")
else:
umv(dev.sl1xs, sl1xs_before, dev.sl1ys, sl1ys_before)
self._sl1xs_before_eye = None
self._sl1ys_before_eye = None
# if manually_adjusted and self.OMNYTools.yesno(
# "sl1xs/sl1ys appear to have been adjusted manually while the "
# "eye was in. Keep the current slit size instead of "
# "restoring the pre-eye value?"
# ):
# print("Keeping current slit size, not restoring pre-eye value.")
# else:
# umv(dev.sl1xs, sl1xs_before, dev.sl1ys, sl1ys_before)
# self._sl1xs_before_eye = None
# self._sl1ys_before_eye = None
self.foptics_in()
self.flomnigui_show_xeyealign()
@@ -104,21 +115,22 @@ class FlomniOpticsMixin:
current_feyex = dev.feyex.readback.get()
current_feyey = dev.feyey.readback.get()
# check if both are close enough (within 0.01)
if np.isclose(current_feyex, feyex_in, atol=0.01) and np.isclose(current_feyey, feyey_in, atol=0.01):
# both already in position → do nothing
pass
else:
if "sl1xs" in dev and "sl1ys" in dev:
self._sl1xs_before_eye = dev.sl1xs.user_readback.get()
self._sl1ys_before_eye = dev.sl1ys.user_readback.get()
scans.umv(dev.sl1xs, 0.3, dev.sl1ys, 0.3, relative=True)
# # check if both are close enough (within 0.01)
# if np.isclose(current_feyex, feyex_in, atol=0.01) and np.isclose(current_feyey, feyey_in, atol=0.01):
# # both already in position → do nothing
# pass
# else:
# if "sl1xs" in dev and "sl1ys" in dev:
# self._sl1xs_before_eye = dev.sl1xs.user_readback.get()
# self._sl1ys_before_eye = dev.sl1ys.user_readback.get()
# scans.umv(dev.sl1xs, 0.3, dev.sl1ys, 0.3, relative=True)
if self.csaxs is not None:
self.csaxs.fil_trans(0.05, print_only=False)
# if self.csaxs is not None:
# self.csaxs.fil_trans(0.05, print_only=False)
# move both axes to the desired "in" positions
umv(dev.feyex, feyex_in, dev.feyey, feyey_in)
# # move both axes to the desired "in" positions
# umv(dev.feyex, feyex_in, dev.feyey, feyey_in)
umv(dev.feyex, feyex_in, dev.feyey, feyey_in)
def _ffzp_in(self):
foptx_in = self._get_user_param_safe("foptx", "in")
@@ -206,13 +218,6 @@ class FlomniOpticsMixin:
dev.rtx.controller.feedback_enable_with_reset()
def fosa_in(self):
# TODO(commissioning): the OSA travels inside the heater's (fheater)
# envelope, but this function never checks fheater position before
# driving fosaz. fosaz must only move while fheater is fully "up" or
# fully "down" -- an intermediate heater position here risks a
# collision. Add a heater-state check (see ensure_osa_back()/
# ensure_fheater_up() in flomni.py for the pattern) once fheater's
# up/down limits are commissioned.
# 6.2 keV, 170 um FZP
# umv(dev.losax, -1.4450000, dev.losay, -0.1800)
# umv(dev.losaz, -1)
@@ -244,6 +249,13 @@ class FlomniOpticsMixin:
)
if need_move_osa:
if not (self._fheater_is_up() or self._fheater_is_down()):
raise FlomniOpticsError(
"Refusing to move OSA to its IN position: fheater is neither "
"at its 'up' (retracted) limit nor at its 'down' (heating) "
"position. An intermediate heater position risks a collision "
"with the OSA. Move the heater fully up or fully down first."
)
remaining = self._osa_remaining_space(fosaz_in)
if remaining <= 0:
foptz_val = dev.foptz.readback.get()
@@ -307,7 +319,13 @@ class FlomniOpticsMixin:
def fosa_out(self):
self.ensure_fheater_up()
if not (self._fheater_is_up() or self._fheater_is_down()):
raise FlomniOpticsError(
"Refusing to move OSA to its OUT position: fheater is neither "
"at its 'up' (retracted) limit nor at its 'down' (heating) "
"position. An intermediate heater position risks a collision "
"with the OSA. Move the heater fully up or fully down first."
)
curtain_is_triggered = dev.foptz.controller.fosaz_light_curtain_is_triggered()
if not curtain_is_triggered:
fosaz_out = self._get_user_param_safe("fosaz", "out")
+33 -35
View File
@@ -46,13 +46,7 @@ feyey:
in: -10.09
deviceTags:
- ptycho_flomni
# TODO(commissioning): needs userParameter (e.g. in/up, down) -- move_fheater_down()
# in flomni.py currently reads user_parameter.get("in") and gets None.
# KNOWN GAP: fosa_in()/foptics_in() (flomni_optics_mixin.py) never check fheater
# position before driving fosaz. The OSA travels inside the heater's envelope, so
# fosaz must only move while fheater is fully at its "up" or fully at its "down"
# limit -- an intermediate heater position during fosa_in() risks a collision.
# Verify/add that check once real up/down limits are known from hardware.
fheater:
description: Heater Y
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
@@ -69,6 +63,10 @@ fheater:
readOnly: false
readoutPriority: baseline
connectionTimeout: 20
userParameter:
#in: -0.5
in: -11.5
enabled: 1
deviceTags:
- ptycho_flomni
@@ -447,7 +445,7 @@ fosaz:
#out: 6
#170 micron, 60 nm, 7.9 kev, foptz 16.9, probe size 7.5 mu
in: 14.1
out: 6
out: 4
# micron, 30 nm, 7.9 kev, very close to the sample. make sure foptz is 32.02 or smaller //abe's fzp's
# in: 0.5
# out: -5
@@ -703,30 +701,30 @@ 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
# 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
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
@@ -104,6 +104,9 @@ fheater:
readOnly: false
readoutPriority: baseline
connectionTimeout: 20
userParameter:
in: -0.5
enabled: 1
deviceTags:
- simulated_flomni