From 2fc724d32dcfd06554891fd1ee0d5ea7ecbb4fca Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 15 Sep 2026 16:52:06 +0200 Subject: [PATCH 01/78] docs(flomni): fix stale example call in flomni_fermat_scan doc The Example: line was left out of sync with the parameter table fix in 5141854 - argument order didn't match, and burst_at_each_point was missing. Now mirrors the code docstring's example. Co-Authored-By: Claude Sonnet 5 --- docs/user/ptychography/flomni.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index 12369515..9c3ebab7 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -231,7 +231,7 @@ The basic scan function can be called by `scans.flomni_fermat_scan()` and offers | burst_at_each_point (int) | Number of triggers and readouts at each point | Example: -`scans.flomni_fermat_scan(fovx=20, fovy=25, cenx=0.02, ceny=0, zshift=0, angle=0, step=0.5, exp_time=0.01, frames_per_trigger=1)` +`scans.flomni_fermat_scan(fovx=10, fovy=10, cenx=0, ceny=0, step=1, zshift=0, angle=0, exp_time=0.1, frames_per_trigger=1, burst_at_each_point=1)` `frames_per_trigger` controls burst acquisition for detectors generally. Separately, the OMNY PandABox is configured as one of two distinct devices in -- 2.54.0 From 0d44f9f87d2ded982fec6fac40e752f1c2fd8589 Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 15 Sep 2026 19:55:35 +0200 Subject: [PATCH 02/78] fix(scans): supply ScanArgument example= for fermat scans' generated docs The generated `Examples:` section in scans.? always replaces any hand-written docstring example with placeholder values, using a generic 1.0 for required float args with no example hint. Set example= on the required fovx/fovy/cenx/ceny/step/zshift/angle arguments of flomni_fermat_scan, omny_fermat_scan, and lamni_fermat_scan so the generated Minimum/Full calls show sensible values instead. Also drop the now-fully-dead hand-written Examples: blocks in flomni/omny, since they were always discarded before reaching users. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0197yD3hTxwvrgmYr7aaYiCD --- csaxs_bec/scans/flomni_fermat_scan.py | 17 +++++++---------- csaxs_bec/scans/lamni_fermat_scan.py | 6 +++--- csaxs_bec/scans/omny_fermat_scan.py | 15 ++++++--------- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/csaxs_bec/scans/flomni_fermat_scan.py b/csaxs_bec/scans/flomni_fermat_scan.py index 8aa1ec64..fff274d1 100644 --- a/csaxs_bec/scans/flomni_fermat_scan.py +++ b/csaxs_bec/scans/flomni_fermat_scan.py @@ -69,13 +69,13 @@ class FlomniFermatScan(ScanBase): def __init__( # fmt: off self, - fovx: Annotated[float, ScanArgument(display_name="Fovx", description="FOV in the piezo plane (i.e. piezo range). Max 200 um.", units=Units.µm, gt=0, lt=200)], - fovy: Annotated[float, ScanArgument(display_name="Fovy", description="FOV in the piezo plane (i.e. piezo range). Max 100 um.", units=Units.µm, gt=0, lt=100)], - cenx: Annotated[float, ScanArgument(display_name="Cenx", description="Center position in x.", units=Units.µm)], - ceny: Annotated[float, ScanArgument(display_name="Ceny", description="Center position in y.", units=Units.µm)], - step: Annotated[float, ScanArgument(display_name="Step", description="Step size.", units=Units.µm)], - zshift: Annotated[float, ScanArgument(display_name="Zshift", description="Shift in z. ", units=Units.µm)], - angle: Annotated[float, ScanArgument(display_name="Angle", description="Rotation angle (will rotate first)", units=Units.deg)], + fovx: Annotated[float, ScanArgument(display_name="Fovx", description="FOV in the piezo plane (i.e. piezo range). Max 200 um.", units=Units.µm, gt=0, lt=200, example=10)], + fovy: Annotated[float, ScanArgument(display_name="Fovy", description="FOV in the piezo plane (i.e. piezo range). Max 100 um.", units=Units.µm, gt=0, lt=100, example=10)], + cenx: Annotated[float, ScanArgument(display_name="Cenx", description="Center position in x.", units=Units.µm, example=0)], + ceny: Annotated[float, ScanArgument(display_name="Ceny", description="Center position in y.", units=Units.µm, example=0)], + step: Annotated[float, ScanArgument(display_name="Step", description="Step size.", units=Units.µm, example=1)], + zshift: Annotated[float, ScanArgument(display_name="Zshift", description="Shift in z. ", units=Units.µm, example=0)], + angle: Annotated[float, ScanArgument(display_name="Angle", description="Rotation angle (will rotate first)", units=Units.deg, example=0)], corridor_size: Annotated[float | None, ScanArgument(display_name="Corridor Size", description="Corridor size for the corridor optimization.", units=Units.µm)] = None, exp_time: DefaultArgType.ExposureTime = 0, frames_per_trigger: DefaultArgType.FramesPerTrigger = 1, @@ -101,9 +101,6 @@ class FlomniFermatScan(ScanBase): Returns: ScanReport - - Examples: - >>> scans.flomni_fermat_scan(10, 10, 0, 0, 1, 0, 0, exp_time=0.1, frames_per_trigger=1, burst_at_each_point=1) """ super().__init__(**kwargs) self._baseline_readout_status = None diff --git a/csaxs_bec/scans/lamni_fermat_scan.py b/csaxs_bec/scans/lamni_fermat_scan.py index f152ea82..993c4c51 100644 --- a/csaxs_bec/scans/lamni_fermat_scan.py +++ b/csaxs_bec/scans/lamni_fermat_scan.py @@ -67,9 +67,9 @@ class LamniFermatScan(ScanBase): def __init__( # fmt: off self, - fovx: Annotated[float, ScanArgument(display_name="FOV x", description="FOV in the piezo plane (i.e. piezo range). Max 80 um", units=Units.µm, gt=0, lt=80)], - fovy: Annotated[float, ScanArgument(display_name="FOV y", description="FOV in the piezo plane (i.e. piezo range). Max 80 um", units=Units.µm, gt=0, lt=80)], - step: Annotated[float, ScanArgument(display_name="Step", description="Step size", units=Units.µm)], + fovx: Annotated[float, ScanArgument(display_name="FOV x", description="FOV in the piezo plane (i.e. piezo range). Max 80 um", units=Units.µm, gt=0, lt=80, example=10)], + fovy: Annotated[float, ScanArgument(display_name="FOV y", description="FOV in the piezo plane (i.e. piezo range). Max 80 um", units=Units.µm, gt=0, lt=80, example=10)], + step: Annotated[float, ScanArgument(display_name="Step", description="Step size", units=Units.µm, example=1)], shift_x: Annotated[float, ScanArgument(display_name="Shift X", description="Extra shift in x. The shift is directly applied to the scan. It will not be auto-rotated.", units=Units.mm)] = 0, shift_y: Annotated[float, ScanArgument(display_name="Shift Y", description="Extra shift in y. The shift is directly applied to the scan. It will not be auto-rotated.", units=Units.mm)] = 0, center_x: Annotated[float, ScanArgument(display_name="Center X", description="Center position in x at 0 deg. This shift is rotated using the geometry of LamNI. It is determined by the first 'click' in the x-ray eye alignment procedure.", units=Units.mm)] = 0, diff --git a/csaxs_bec/scans/omny_fermat_scan.py b/csaxs_bec/scans/omny_fermat_scan.py index 6c336f8b..d1a1689b 100644 --- a/csaxs_bec/scans/omny_fermat_scan.py +++ b/csaxs_bec/scans/omny_fermat_scan.py @@ -61,12 +61,12 @@ class OmnyFermatScan(ScanBase): def __init__( # fmt: off self, - fovx: Annotated[float, ScanArgument(display_name="Fovx", description="FOV in the piezo plane (i.e. piezo range).", units=Units.µm, gt=0, lt=200)], - fovy: Annotated[float, ScanArgument(display_name="Fovy", description="FOV in the piezo plane (i.e. piezo range).", units=Units.µm, gt=0, lt=100)], - cenx: Annotated[float, ScanArgument(display_name="Cenx", description="Center position in x.", units=Units.µm)], - ceny: Annotated[float, ScanArgument(display_name="Ceny", description="Center position in y.", units=Units.µm)], - step: Annotated[float, ScanArgument(display_name="Step", description="Step size.", units=Units.µm)], - zshift: Annotated[float, ScanArgument(display_name="Zshift", description="Shift in z.", units=Units.µm)], + fovx: Annotated[float, ScanArgument(display_name="Fovx", description="FOV in the piezo plane (i.e. piezo range).", units=Units.µm, gt=0, lt=200, example=10)], + fovy: Annotated[float, ScanArgument(display_name="Fovy", description="FOV in the piezo plane (i.e. piezo range).", units=Units.µm, gt=0, lt=100, example=10)], + cenx: Annotated[float, ScanArgument(display_name="Cenx", description="Center position in x.", units=Units.µm, example=0)], + ceny: Annotated[float, ScanArgument(display_name="Ceny", description="Center position in y.", units=Units.µm, example=0)], + step: Annotated[float, ScanArgument(display_name="Step", description="Step size.", units=Units.µm, example=1)], + zshift: Annotated[float, ScanArgument(display_name="Zshift", description="Shift in z.", units=Units.µm, example=0)], angle: Annotated[float | None, ScanArgument(display_name="Angle", description="Rotation angle (will rotate first)", units=Units.deg)] = None, corridor_size: Annotated[float, ScanArgument(display_name="Corridor Size", description="Corridor size for the corridor optimization. ", units=Units.µm)] = 3, exp_time: DefaultArgType.ExposureTime = 0, @@ -93,9 +93,6 @@ class OmnyFermatScan(ScanBase): Returns: ScanReport - - Examples: - >>> scans.omny_fermat_scan(10, 10, 0, 0, 1, 0, 0, exp_time=0.1, frames_per_trigger=1, readout_time=0) """ super().__init__(**kwargs) self._baseline_readout_status = None -- 2.54.0 From 0eb8bb7590f6f62b07a9e0c9cf10d1c1379a5e50 Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 16 Sep 2026 09:13:13 +0200 Subject: [PATCH 03/78] fix(sample_storage): drop non-latin1 markers from printed report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PDF report's measured/started status markers used the ✓/◐ unicode symbols, which the core Helvetica font in fpdf2 only supports up to latin-1, so printing the report crashed with "Report failed: Character outside the range of characters supported by the font". The on-screen Qt label is unaffected, since it renders those symbols fine -- only the PDF-only marker text needed to change. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0197yD3hTxwvrgmYr7aaYiCD --- .../bec_widgets/widgets/sample_storage/sample_storage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py index ac1c1304..9348f07a 100644 --- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py +++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py @@ -797,9 +797,9 @@ class OMNY_SampleStorage(BECWidget, QWidget): marker = "" if measured_status == "completed": - marker = "✓ measured" + marker = "measured" elif measured_status == "started": - marker = "◐ started" + marker = "started" pdf.set_xy(x + 1, y + h - 6) pdf.set_font("Helvetica", "", 7) pdf.cell(w - 2, 4, marker, align="C") -- 2.54.0 From 857ffe041dfa0a32f776a5cdc689926e7a4c12a5 Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 16 Sep 2026 09:37:58 +0200 Subject: [PATCH 04/78] fix(flomni): clarify x-ray eye alignment height-step instruction The height-marking step's GUI message just said "Submit height. Use arrows if far off.", which didn't tell users they first need to mark the position with the image's selection (ROI) tool before submitting. Spell that out explicitly. Verified against message_line_edit's actual 90px-tall QTextEdit box (across a 140-300px width range) that the longer text still wraps within the fixed height, no scrollbar needed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0197yD3hTxwvrgmYr7aaYiCD --- csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py index 2a8e23c1..87e0fbfc 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py @@ -299,7 +299,7 @@ class XrayEyeAlign: self.gui.show_crosshair() self.send_message( - "Submit height. Use arrows if far off." + "Mark the sample height with the selection tool, then submit. Use arrows if far off." ) self.gui.enable_submit_button(True) self.movement_buttons_enabled(True, True) -- 2.54.0 From 8856f4c084209839016fdf7ac3401c58ddd7159d Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 16 Sep 2026 09:41:40 +0200 Subject: [PATCH 05/78] docs(flomni): fix missing parentheses in feye_out call Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0197yD3hTxwvrgmYr7aaYiCD --- docs/user/ptychography/flomni.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index 9c3ebab7..c078b3e5 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -64,7 +64,7 @@ Otherwise adjust the height manually: #### Fine alignment After the xrayeyealign, a fine alignment needs to be performed using ptychography. -_To bypass the fine alignment: `flomni.feye_out`_ +_To bypass the fine alignment: `flomni.feye_out()`_ 1. `flomni.tomo_parameters()` Adjust the ptychographic scan parameters for performing an alignment scan. Typically FOVX = FOVX(Xrayeye)+20 mu, shell step = beamsize/2.5, number of projections and tomo mode are ignored in the alignment scans. -- 2.54.0 From 1ef73f31e43a8fe24eac4fbdbc45a9b62112002a Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 16 Sep 2026 10:08:41 +0200 Subject: [PATCH 06/78] feat(flomni): raise fovx limit from 200 to 220 um Raise the flOMNI fovx cap in the flomni.fovx property setter, the tomo_parameters() summary/prompt text, and the flomni_fermat_scan ScanArgument bound + docstring, so all four are consistent. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0197yD3hTxwvrgmYr7aaYiCD --- csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py | 8 ++++---- csaxs_bec/scans/flomni_fermat_scan.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index d013a68f..834f495a 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -2036,8 +2036,8 @@ class Flomni( @fovx.setter def fovx(self, val: float): - if val > 200: - raise ValueError("FOV cannot be larger than 200 um.") + if val > 220: + raise ValueError("FOV cannot be larger than 220 um.") self.client.set_global_var("fovx", val) @property @@ -3651,7 +3651,7 @@ class Flomni( print("Current settings:") print(f"Counting time = {self.tomo_countingtime} s") print(f"Stepsize microns = {self.tomo_shellstep}") - print(f"FOV (200/100) = {self.fovx}, {self.fovy}") + print(f"FOV (220/100) = {self.fovx}, {self.fovy}") print(f"Stitching number x,y = {self.stitch_x}, {self.stitch_y}") print(f"Stitching overlap = {self.tomo_stitch_overlap}") if not self.single_point_instead_of_fermat_scan: @@ -3739,7 +3739,7 @@ class Flomni( else: self.tomo_countingtime = self._get_val(" s", self.tomo_countingtime, float) self.tomo_shellstep = self._get_val(" um", self.tomo_shellstep, float) - self.fovx = self._get_val(" um", self.fovx, float) + self.fovx = self._get_val(" um", self.fovx, float) self.fovy = self._get_val(" um", self.fovy, float) if self.single_point_instead_of_fermat_scan: print( diff --git a/csaxs_bec/scans/flomni_fermat_scan.py b/csaxs_bec/scans/flomni_fermat_scan.py index fff274d1..6ca6ae00 100644 --- a/csaxs_bec/scans/flomni_fermat_scan.py +++ b/csaxs_bec/scans/flomni_fermat_scan.py @@ -69,7 +69,7 @@ class FlomniFermatScan(ScanBase): def __init__( # fmt: off self, - fovx: Annotated[float, ScanArgument(display_name="Fovx", description="FOV in the piezo plane (i.e. piezo range). Max 200 um.", units=Units.µm, gt=0, lt=200, example=10)], + fovx: Annotated[float, ScanArgument(display_name="Fovx", description="FOV in the piezo plane (i.e. piezo range). Max 220 um.", units=Units.µm, gt=0, lt=220, example=10)], fovy: Annotated[float, ScanArgument(display_name="Fovy", description="FOV in the piezo plane (i.e. piezo range). Max 100 um.", units=Units.µm, gt=0, lt=100, example=10)], cenx: Annotated[float, ScanArgument(display_name="Cenx", description="Center position in x.", units=Units.µm, example=0)], ceny: Annotated[float, ScanArgument(display_name="Ceny", description="Center position in y.", units=Units.µm, example=0)], @@ -87,7 +87,7 @@ class FlomniFermatScan(ScanBase): flOMNI Fermat Scan Args: - fovx (float): FOV in the piezo plane (i.e. piezo range). Max 200 um. + fovx (float): FOV in the piezo plane (i.e. piezo range). Max 220 um. fovy (float): FOV in the piezo plane (i.e. piezo range). Max 100 um. cenx (float): Center position in x. ceny (float): Center position in y. -- 2.54.0 From 82e9e8bee1963dcccdf534037db5138ea283f882 Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 16 Sep 2026 10:55:22 +0200 Subject: [PATCH 07/78] feat(flomni): open frontend slit + drop filters on feye_in/feye_out feye_in now widens sl1xs/sl1ys by +0.3 mm and sets filter transmission to 5% before moving the eye in, so there is enough flux/visibility on the camera. feye_out restores the pre-open slit position (persisted via a BEC global var so it survives a kernel restart) and fully removes filters (fil_trans(1)) before checking whether the direct beam disappeared. If the slit was manually re-adjusted while the eye was in, feye_out now asks whether to keep the current size instead of restoring the pre-eye value. csaxs (for fil_trans) is threaded into Flomni via constructor injection, gated on the existing "fttrx1 in dev" real-beamline check, rather than exposing it as a global. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CpPtD7tCRwRZZnREhWcJi8 --- .../plugins/flomni/flomni.py | 8 +++- .../plugins/flomni/flomni_optics_mixin.py | 39 +++++++++++++++++++ .../startup/post_startup.py | 5 ++- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 834f495a..3322c82c 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -1760,9 +1760,15 @@ class Flomni( cSAXSBeamlineChecks, flomniGuiTools, ): - def __init__(self, client): + # Backed by a BEC global var (Redis) so the pre-open slit position + # survives a kernel restart while the eye is still in. + _sl1xs_before_eye = _GlobalVarParam(None) + _sl1ys_before_eye = _GlobalVarParam(None) + + def __init__(self, client, csaxs=None): super().__init__() self.client = client + self.csaxs = csaxs self.device_manager = client.device_manager self.special_angles = [] self.special_angle_repeats = 20 diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py index 0757ad20..5fabf040 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py @@ -25,6 +25,36 @@ class FlomniOpticsMixin: def feye_out(self): dev.fsh.fshclose() + + if self.csaxs is not None: + self.csaxs.fil_trans(1, print_only=False) + + sl1xs_before = self._sl1xs_before_eye + sl1ys_before = self._sl1ys_before_eye + if sl1xs_before is not None and sl1ys_before is not None: + # if the slit was manually re-adjusted while the eye was in, + # offer to keep the current size instead of restoring the + # pre-eye value + current_sl1xs = dev.sl1xs.user_readback.get() + current_sl1ys = dev.sl1ys.user_readback.get() + expected_sl1xs = sl1xs_before + 0.3 + expected_sl1ys = sl1ys_before + 0.3 + tol = 0.01 # mm (10 microns) + manually_adjusted = not np.isclose( + current_sl1xs, expected_sl1xs, atol=tol + ) or not np.isclose(current_sl1ys, expected_sl1ys, atol=tol) + + if manually_adjusted and self.OMNYTools.yesno( + "sl1xs/sl1ys appear to have been adjusted manually while the " + "eye was in. Keep the current slit size instead of " + "restoring the pre-eye value?" + ): + print("Keeping current slit size, not restoring pre-eye value.") + else: + umv(dev.sl1xs, sl1xs_before, dev.sl1ys, sl1ys_before) + self._sl1xs_before_eye = None + self._sl1ys_before_eye = None + self.foptics_in() self.flomnigui_show_xeyealign() self.xrayeye_update_frame() @@ -34,6 +64,7 @@ class FlomniOpticsMixin: print("Aborting. With visible parts of the direct beam on the xray eye, it cannot be removed.") return feyex_out = self._get_user_param_safe("feyex", "out") + umv(dev.feyex, feyex_out) # move rotation stage to zero to avoid problems with wires @@ -78,6 +109,14 @@ class FlomniOpticsMixin: # both already in position → do nothing pass else: + if "sl1xs" in dev and "sl1ys" in dev: + self._sl1xs_before_eye = dev.sl1xs.user_readback.get() + self._sl1ys_before_eye = dev.sl1ys.user_readback.get() + scans.umv(dev.sl1xs, 0.3, dev.sl1ys, 0.3, relative=True) + + if self.csaxs is not None: + self.csaxs.fil_trans(0.05, print_only=False) + # move both axes to the desired "in" positions umv(dev.feyex, feyex_in, dev.feyey, feyey_in) diff --git a/csaxs_bec/bec_ipython_client/startup/post_startup.py b/csaxs_bec/bec_ipython_client/startup/post_startup.py index d560a94c..e1981652 100644 --- a/csaxs_bec/bec_ipython_client/startup/post_startup.py +++ b/csaxs_bec/bec_ipython_client/startup/post_startup.py @@ -85,7 +85,10 @@ elif _args.session.lower() == "flomni": from csaxs_bec.bec_ipython_client.plugins.flomni import Flomni _session_name = "flomni" - flomni = Flomni(bec) + if "fttrx1" in dev: + flomni = Flomni(bec, csaxs=csaxs) + else: + flomni = Flomni(bec) logger.success("flomni session loaded.") print(r""" ██████╗ ███████╗ ██████╗ ███████╗██╗ ██████╗ ███╗ ███╗███╗ ██╗██╗ -- 2.54.0 From 1fc702b854d306f9f55955e0db926d4bd910f2da Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 16 Sep 2026 11:30:46 +0200 Subject: [PATCH 08/78] fix(scans): correct undersized fermat spiral iteration cap for elongated FOVs get_flomni_fermat_spiral_pos/get_omny_fermat_spiral_pos/get_lamni_fermat_spiral_pos sized their spiral loop's iteration cap (n_max) from the FOV's area alone, but the spiral radius only grows as step*0.57*sqrt(ii). For an elongated (non-square) FOV -- e.g. flomni's fovx up to 220 vs fovy capped at 100 -- this under-estimated the iterations needed, so the loop ran out before reaching the long axis's outer region. The two explicitly-appended bounding-box corner points masked this: they showed up regardless, while genuine spiral fill points near the true edge silently went missing (observed as the pattern being "cut" well short of the requested FOV). Add a shared compute_fermat_spiral_n_max() helper that takes the max of the old area-based estimate and a corner-radius-based estimate, so square/near-square FOVs are numerically unaffected (verified byte-for-byte identical) while elongated ones now reach the requested edge (verified: flomni fovx=220,fovy=30 now reaches 109.98um vs the 82.78um it reached before, against a 110.0um target). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0197yD3hTxwvrgmYr7aaYiCD --- csaxs_bec/scans/fermat_spiral_utils.py | 35 +++++++++++++++++++ csaxs_bec/scans/flomni_fermat_scan.py | 3 +- csaxs_bec/scans/lamni_fermat_scan.py | 3 +- csaxs_bec/scans/omny_fermat_scan.py | 4 ++- tests/tests_scans/test_fermat_spiral_utils.py | 14 ++++++++ tests/tests_scans/test_flomni_fermat_scan.py | 16 +++++++++ tests/tests_scans/test_lamni_fermat_scan.py | 13 +++++++ tests/tests_scans/test_omny_fermat_scan.py | 21 +++++++++++ 8 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 csaxs_bec/scans/fermat_spiral_utils.py create mode 100644 tests/tests_scans/test_fermat_spiral_utils.py diff --git a/csaxs_bec/scans/fermat_spiral_utils.py b/csaxs_bec/scans/fermat_spiral_utils.py new file mode 100644 index 00000000..b42272b7 --- /dev/null +++ b/csaxs_bec/scans/fermat_spiral_utils.py @@ -0,0 +1,35 @@ +"""Shared helpers for Fermat-spiral scan position generation (flomni/omny/lamni).""" + +from __future__ import annotations + +import numpy as np + + +def compute_fermat_spiral_n_max( + length_axis1: float, length_axis2: float, step: float, safety_factor: float = 1.1 +) -> int: + """ + Size the Fermat-spiral loop's iteration cap. + + The spiral radius at iteration ``ii`` grows as ``step * 0.57 * sqrt(ii)``, so sizing + the cap from the box's area alone (``length_axis1 * length_axis2 * 3.2 / step**2``) + under-estimates the iterations needed for elongated (non-square) boxes: the maximum + radius that formula reaches is a function of area, not of the box's actual corner + distance, so the spiral can exhaust its iterations before reaching the far corners of + a long, narrow box. Taking the max with a cap derived from the corner distance itself + fixes that while leaving square/near-square boxes unaffected (the area-based term + already dominates there). + + Args: + length_axis1 (float): Full extent of the scan box along the first axis. + length_axis2 (float): Full extent of the scan box along the second axis. + step (float): Spiral step size. + safety_factor (float): Margin applied to the corner-distance-based term. + + Returns: + int: Iteration cap for the Fermat-spiral generation loop. + """ + n_max_area = int(length_axis1 * length_axis2 * 3.2 / step / step) + corner_radius = np.sqrt((length_axis1 / 2) ** 2 + (length_axis2 / 2) ** 2) + n_max_corner = int((corner_radius / (0.57 * step)) ** 2 * safety_factor) + return max(n_max_area, n_max_corner) diff --git a/csaxs_bec/scans/flomni_fermat_scan.py b/csaxs_bec/scans/flomni_fermat_scan.py index 6ca6ae00..0fd80dde 100644 --- a/csaxs_bec/scans/flomni_fermat_scan.py +++ b/csaxs_bec/scans/flomni_fermat_scan.py @@ -32,6 +32,7 @@ from bec_server.scan_server.scans.scan_base import ScanBase, ScanType from bec_server.scan_server.scans.scan_modifier import scan_hook from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import TRIGGERSOURCE +from csaxs_bec.scans.fermat_spiral_utils import compute_fermat_spiral_n_max logger = bec_logger.logger @@ -441,7 +442,7 @@ class FlomniFermatScan(ScanBase): length_axis1 = np.abs(m1_stop - m1_start) length_axis2 = np.abs(m2_stop - m2_start) - n_max = int(length_axis1 * length_axis2 * 3.2 / step / step) + n_max = compute_fermat_spiral_n_max(length_axis1, length_axis2, step) z_pos = zshift diff --git a/csaxs_bec/scans/lamni_fermat_scan.py b/csaxs_bec/scans/lamni_fermat_scan.py index 993c4c51..862c407e 100644 --- a/csaxs_bec/scans/lamni_fermat_scan.py +++ b/csaxs_bec/scans/lamni_fermat_scan.py @@ -26,6 +26,7 @@ from bec_server.scan_server.errors import ScanAbortion from bec_server.scan_server.scans.scan_base import ScanBase, ScanType from bec_server.scan_server.scans.scan_modifier import scan_hook +from csaxs_bec.scans.fermat_spiral_utils import compute_fermat_spiral_n_max from csaxs_bec.scans.lamni_components import LamNIComponents logger = bec_logger.logger @@ -309,7 +310,7 @@ class LamniFermatScan(ScanBase): length_axis1 = np.abs(m1_stop - m1_start) length_axis2 = np.abs(m2_stop - m2_start) - n_max = int(length_axis1 * length_axis2 * 3.2 / step / step) + n_max = compute_fermat_spiral_n_max(length_axis1, length_axis2, step) total_shift_x, total_shift_y = LamniFermatScan._compute_total_shift( center_x=center_x, diff --git a/csaxs_bec/scans/omny_fermat_scan.py b/csaxs_bec/scans/omny_fermat_scan.py index d1a1689b..34b2c5f6 100644 --- a/csaxs_bec/scans/omny_fermat_scan.py +++ b/csaxs_bec/scans/omny_fermat_scan.py @@ -28,6 +28,8 @@ from bec_server.scan_server.scans import MessageEndpoints from bec_server.scan_server.scans.scan_base import ScanBase, ScanType from bec_server.scan_server.scans.scan_modifier import scan_hook +from csaxs_bec.scans.fermat_spiral_utils import compute_fermat_spiral_n_max + logger = bec_logger.logger @@ -302,7 +304,7 @@ class OmnyFermatScan(ScanBase): length_axis1 = np.abs(m1_stop - m1_start) length_axis2 = np.abs(m2_stop - m2_start) - n_max = int(length_axis1 * length_axis2 * 3.2 / step / step) + n_max = compute_fermat_spiral_n_max(length_axis1, length_axis2, step) z_pos = self.zshift diff --git a/tests/tests_scans/test_fermat_spiral_utils.py b/tests/tests_scans/test_fermat_spiral_utils.py new file mode 100644 index 00000000..249c457f --- /dev/null +++ b/tests/tests_scans/test_fermat_spiral_utils.py @@ -0,0 +1,14 @@ +from csaxs_bec.scans.fermat_spiral_utils import compute_fermat_spiral_n_max + + +def test_n_max_unchanged_for_square_box(): + # the old area-based term already dominates for a square/near-square box, + # so the corner-radius-based term must not change the result there + assert compute_fermat_spiral_n_max(50, 50, 1) == int(50 * 50 * 3.2) + + +def test_n_max_boosted_for_elongated_box(): + # for a long, narrow box the old area-based term under-estimates the + # iterations needed to reach the far corners, so the fixed result must + # exceed it + assert compute_fermat_spiral_n_max(220, 30, 1) > int(220 * 30 * 3.2) diff --git a/tests/tests_scans/test_flomni_fermat_scan.py b/tests/tests_scans/test_flomni_fermat_scan.py index a0aacb80..83da7b9b 100644 --- a/tests/tests_scans/test_flomni_fermat_scan.py +++ b/tests/tests_scans/test_flomni_fermat_scan.py @@ -1,5 +1,7 @@ from unittest import mock +import numpy as np + from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan @@ -43,3 +45,17 @@ def test_flomni_rotation_moves_when_setpoint_matches_but_readback_far_off(): FlomniFermatScan.flomni_rotation(scan, 10.0) scan.actions.set.assert_called_once() + + +def test_flomni_fermat_spiral_reaches_elongated_fov_corners(): + # Regression test: for an elongated (non-square) FOV, the spiral's iteration + # cap used to be sized from FOV area alone, which under-estimated the + # iterations needed and left the loop-generated points well short of the + # long axis's outer region (only the two explicit corner points reached it). + fovx, fovy = 220.0, 30.0 + pos = FlomniFermatScan.get_flomni_fermat_spiral_pos( + -fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=1 + ) + loop_points = pos[:-2] # exclude the two corner points appended after the loop + assert np.max(np.abs(loop_points[:, 0])) > 0.95 * (fovx / 2) + assert len(pos) > FlomniFermatScan.MIN_POSITIONS diff --git a/tests/tests_scans/test_lamni_fermat_scan.py b/tests/tests_scans/test_lamni_fermat_scan.py index c013275e..d12595eb 100644 --- a/tests/tests_scans/test_lamni_fermat_scan.py +++ b/tests/tests_scans/test_lamni_fermat_scan.py @@ -44,3 +44,16 @@ def test_lamni_rotation_moves_when_setpoint_matches_but_not_settled(): scan.dev.lsamrot.set.assert_called_once_with(10.0) scan.dev.lsamrot.set.return_value.wait.assert_called_once_with() + + +def test_lamni_fermat_spiral_reaches_elongated_fov_corners(): + # Regression test: for an elongated (non-square) FOV, the spiral's iteration + # cap used to be sized from FOV area alone, which under-estimated the + # iterations needed and left coverage well short of the long axis's outer + # region. lamni's returned x/y are post-transform-shift, so assert on point + # count growth (46 -> 73 for this case) rather than raw coordinate magnitude. + fovx, fovy = 79.0, 2.0 + pos = LamniFermatScan.get_lamni_fermat_spiral_pos( + -fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=1, fovx=fovx, fovy=fovy + ) + assert len(pos) >= 60 diff --git a/tests/tests_scans/test_omny_fermat_scan.py b/tests/tests_scans/test_omny_fermat_scan.py index 089afd71..1ac42bc3 100644 --- a/tests/tests_scans/test_omny_fermat_scan.py +++ b/tests/tests_scans/test_omny_fermat_scan.py @@ -1,5 +1,7 @@ from unittest import mock +import numpy as np + from csaxs_bec.scans.omny_fermat_scan import OmnyFermatScan @@ -42,3 +44,22 @@ def test_omny_rotation_moves_when_setpoint_matches_but_readback_far_off(): OmnyFermatScan.omny_rotation(scan, 10.0) scan.actions.set.assert_called_once() + + +class _PosStub: + cenx = 0.0 + ceny = 0.0 + zshift = 0.0 + + +def test_omny_fermat_spiral_reaches_elongated_fov_corners(): + # Regression test: for an elongated (non-square) FOV, the spiral's iteration + # cap used to be sized from FOV area alone, which under-estimated the + # iterations needed and left the loop-generated points well short of the + # long axis's outer region (only the two explicit corner points reached it). + fovx, fovy = 190.0, 20.0 # stay under omny's fovx<200 / fovy<100 argument bounds + pos = OmnyFermatScan.get_omny_fermat_spiral_pos( + _PosStub(), -fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=1 + ) + loop_points = pos[:-2] # exclude the two corner points appended after the loop + assert np.max(np.abs(loop_points[:, 0])) > 0.95 * (fovx / 2) -- 2.54.0 From ce913cfc8711af3faaf33607a9dddaf57ea4a012 Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 16 Sep 2026 11:31:01 +0200 Subject: [PATCH 09/78] fix(flomni): make fovx=220 inclusive everywhere, default exp_time example to 0.1 - flomni_fermat_scan.py's fovx bound was lt=220 (exclusive), so 220 itself was rejected; change to le=220 to match flomni.py's fovx setter, which was already inclusive (only raises for val > 220). - tomo_params.py's flomni fov_fields spinbox max was still 200.0, an independent limit that wasn't updated when fovx's cap was raised elsewhere -- bring it to 220.0 for consistency. - exp_time's real default is now 0.1 instead of 0, so the generated scan doc's example (scans.flomni_fermat_scan?) shows a realistic exposure time instead of 0. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0197yD3hTxwvrgmYr7aaYiCD --- csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py | 2 +- csaxs_bec/scans/flomni_fermat_scan.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index 0c6341c6..5b2d1cce 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -2267,7 +2267,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { "has_single_point": True, "has_zero_deg_reference": True, "fov_fields": [ - ("fovx", "FOV x (µm)", 0.1, 200.0, 2), + ("fovx", "FOV x (µm)", 0.1, 220.0, 2), ("fovy", "FOV y (µm)", 0.1, 100.0, 2), ], "stitch_fields": [("stitch_x", "Stitch x", 0, 50), ("stitch_y", "Stitch y", 0, 50)], diff --git a/csaxs_bec/scans/flomni_fermat_scan.py b/csaxs_bec/scans/flomni_fermat_scan.py index 0fd80dde..df31722f 100644 --- a/csaxs_bec/scans/flomni_fermat_scan.py +++ b/csaxs_bec/scans/flomni_fermat_scan.py @@ -70,7 +70,7 @@ class FlomniFermatScan(ScanBase): def __init__( # fmt: off self, - fovx: Annotated[float, ScanArgument(display_name="Fovx", description="FOV in the piezo plane (i.e. piezo range). Max 220 um.", units=Units.µm, gt=0, lt=220, example=10)], + fovx: Annotated[float, ScanArgument(display_name="Fovx", description="FOV in the piezo plane (i.e. piezo range). Max 220 um.", units=Units.µm, gt=0, le=220, example=10)], fovy: Annotated[float, ScanArgument(display_name="Fovy", description="FOV in the piezo plane (i.e. piezo range). Max 100 um.", units=Units.µm, gt=0, lt=100, example=10)], cenx: Annotated[float, ScanArgument(display_name="Cenx", description="Center position in x.", units=Units.µm, example=0)], ceny: Annotated[float, ScanArgument(display_name="Ceny", description="Center position in y.", units=Units.µm, example=0)], @@ -78,7 +78,7 @@ class FlomniFermatScan(ScanBase): zshift: Annotated[float, ScanArgument(display_name="Zshift", description="Shift in z. ", units=Units.µm, example=0)], angle: Annotated[float, ScanArgument(display_name="Angle", description="Rotation angle (will rotate first)", units=Units.deg, example=0)], corridor_size: Annotated[float | None, ScanArgument(display_name="Corridor Size", description="Corridor size for the corridor optimization.", units=Units.µm)] = None, - exp_time: DefaultArgType.ExposureTime = 0, + exp_time: DefaultArgType.ExposureTime = 0.1, frames_per_trigger: DefaultArgType.FramesPerTrigger = 1, burst_at_each_point: DefaultArgType.BurstAtEachPoint = 1, **kwargs, -- 2.54.0 From 3b332f923941d0e62cd64d4cf18322f1b8597de0 Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 16 Sep 2026 11:33:18 +0200 Subject: [PATCH 10/78] feat(flomni): add step size to scilog_last_ptycho_scans summary line tomo_shellstep was already captured in every projection timing record but never surfaced in the per-scan SciLog message. Add it next to FOV, matching the "step=...um" convention already used in _describe_tomo_job_line. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CpPtD7tCRwRZZnREhWcJi8 --- csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 3322c82c..6311fbf0 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -3348,12 +3348,14 @@ class Flomni( fovx = rec.get("fovx") fovy = rec.get("fovy") fov_str = f"{fovx} x {fovy} um" if fovx is not None and fovy is not None else "?" + step = rec.get("tomo_shellstep") + step_str = f"{step} um" if step is not None else "?" exposure = rec.get("tomo_countingtime") exp_str = f"{exposure} s" if exposure is not None else "?" duration = rec.get("duration_s") dur_str = self._format_duration(duration) if duration is not None else "?" lines.append( - f"scan {scan_str}: FOV {fov_str}, exposure {exp_str}, duration {dur_str}" + f"scan {scan_str}: FOV {fov_str}, step {step_str}, exposure {exp_str}, duration {dur_str}" ) content = "\n".join(lines) -- 2.54.0 From 066d93f6cf1851ff4bd14e64ef2a0c85f08d2c29 Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 16 Sep 2026 11:39:42 +0200 Subject: [PATCH 11/78] feat(lamni): add step size to scilog_last_ptycho_scans summary line Mirrors the same fix just applied to Flomni: tomo_shellstep was already captured in every projection timing record but never surfaced in the per-scan SciLog message. Add it next to FOV. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CpPtD7tCRwRZZnREhWcJi8 --- csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index d620cbf8..cc9155f5 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -1134,12 +1134,14 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools fovx = rec.get("lamni_piezo_range_x") fovy = rec.get("lamni_piezo_range_y") fov_str = f"{fovx} x {fovy} um" if fovx is not None and fovy is not None else "?" + step = rec.get("tomo_shellstep") + step_str = f"{step} um" if step is not None else "?" exposure = rec.get("tomo_countingtime") exp_str = f"{exposure} s" if exposure is not None else "?" duration = rec.get("duration_s") dur_str = self._format_duration(duration) if duration is not None else "?" lines.append( - f"scan {scan_str}: FOV {fov_str}, exposure {exp_str}, duration {dur_str}" + f"scan {scan_str}: FOV {fov_str}, step {step_str}, exposure {exp_str}, duration {dur_str}" ) content = "\n".join(lines) -- 2.54.0 From 43aa84ac0e181a09b124fbd42ff2e115b426c3fa Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 16 Sep 2026 11:40:29 +0200 Subject: [PATCH 12/78] docs(omny): note that scilog_last_ptycho_scans still needs porting flomni and LamNI both have scilog_last_ptycho_scans plus its projection-timing-log infrastructure; OMNY has neither, only the older per-subtomo write_to_scilog/_write_subtomo_to_scilog. Flag it as a TODO (same style as the existing filter/eye-check and tomo_reconstruct TODOs in this folder) so it isn't lost, and note that the step-size field just added to flomni/lamni's version should be included from the start when this gets ported. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CpPtD7tCRwRZZnREhWcJi8 --- .../AI_docs/TODO_scilog_last_ptycho_scans.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_scilog_last_ptycho_scans.md diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_scilog_last_ptycho_scans.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_scilog_last_ptycho_scans.md new file mode 100644 index 00000000..abf0ad76 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_scilog_last_ptycho_scans.md @@ -0,0 +1,44 @@ +# TODO: OMNY is missing the scilog_last_ptycho_scans command (and its timing-log infrastructure) that flomni/LamNI have + +Found while adding a step-size field to flomni's and LamNI's +`scilog_last_ptycho_scans` per-scan summary line (branch +`fixes/flomni_beamtime_2`). OMNY has no equivalent at all, not even the +older/pre-step-size version — porting it is a bigger job than the two-line +fixes just made to flomni/lamni, so it's flagged here instead of attempted +opportunistically. + +## 1. scilog_last_ptycho_scans + timing-log infrastructure (new feature, not yet on OMNY at all) + +flomni (`flomni.py`) and LamNI (`lamni.py`) both have a full +timing-instrumented pipeline behind `scilog_last_ptycho_scans(number_of_scans=1)`: + +- `_TIMING_LOG_DIR`/`_PROJECTION_TIMING_LOG` — an append-only JSONL log of + per-projection timing records, one written per completed projection via + `_log_projection_timing(...)` (flomni.py:3180, lamni.py:966), called from + each setup's per-angle tomo-scan loop (flomni.py:2578/3502, lamni.py:1611). +- `_read_last_timing_records(number_of_scans)` (flomni.py:3265, lamni.py:1051) + — reads back the last N records from that log. +- `scilog_last_ptycho_scans(number_of_scans=1)` (flomni.py:3291, lamni.py:1077) + — prompts for a free-text comment, builds one summary line per record (scan + number(s), FOV, step size, exposure, duration), and sends it via each + setup's tolerant scilog-write helper (`Flomni._scilog_write`, flomni.py:2993; + `LamNI.write_to_scilog`, lamni.py:928). + +OMNY (`omny.py`) only has the much older `write_to_scilog(content, tags=None)` +(omny.py:855) and `_write_subtomo_to_scilog(subtomo_number)` (omny.py:916) — a +per-subtomo one-liner, not a "last N scans" summary command — and no timing +log, no duration measurement, and no `scilog_last_ptycho_scans` at all. +`OMNY.tomo_scan()`/`_tomo_scan_at_angle()` (omny.py:1005/966) would be the +natural hook point for a `_log_projection_timing`-style call, mirroring where +flomni/lamni call theirs. + +Porting this means: adding the timing-log write call to OMNY's per-angle +loop, then copying `_read_last_timing_records`/`scilog_last_ptycho_scans` +across (with OMNY-specific field names in place of `fovx`/`fovy` or +`lamni_piezo_range_x`/`y` — whatever OMNY's equivalent scan-size parameter is +called). Remember to include the `step {value} um` field in the per-scan +line from the start (already added to flomni's and lamni's versions, keyed +off `tomo_shellstep`) rather than needing a second follow-up fix like this +one. + +Not scoped/designed further here — just flagging it so it isn't lost. -- 2.54.0 From 9f922d869fa0ea1a7d481f98170cd5cc7d4d4670 Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 16 Sep 2026 12:28:05 +0200 Subject: [PATCH 13/78] docs(flomni): add plan for auto-locking xrayeye selection box vertical center Once height-centering fixes the sample's vertical position during the flomni alignment procedure, the selection box for the 0-180 deg rotation steps should auto-snap to the FZP height instead of requiring manual vertical placement each time. Plan only, not implemented. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CpPtD7tCRwRZZnREhWcJi8 --- docs/plans/xrayeye-vertical-lock.md | 145 ++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/plans/xrayeye-vertical-lock.md diff --git a/docs/plans/xrayeye-vertical-lock.md b/docs/plans/xrayeye-vertical-lock.md new file mode 100644 index 00000000..a0cfd008 --- /dev/null +++ b/docs/plans/xrayeye-vertical-lock.md @@ -0,0 +1,145 @@ +# Plan: Auto-lock vertical center of the sample selection box to the FZP height (flomni X-ray eye alignment) + +Status: **not implemented** (written during live beamtime operation on request; no +code changes made — see conversation this plan came from). + +## Context + +The flomni X-ray eye alignment procedure (`XrayEyeAlign._align_impl` in +`csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py`) works in two phases: + +1. **Step 0** — the user draws a selection box around the FZP and submits it + (`xval_x_0`/`yval_y_0`). This position is stored and a fixed crosshair + (`TargetCrosshair`, drawn via `self.gui.set_crosshair_position()` / + `show_crosshair()`) is placed on the live view at that FZP center — the + horizontal line of that crosshair marks the FZP's vertical (`y`) position. +2. **Height centering** — the user draws one more box around the sample and + submits it (still logically "step 1"). The script computes + `delta_y_mm = HEIGHT_CENTERING_SIGN * (fzp_center_y - mark_y) * pixel_calibration` + and moves `fsamy` so the sample lines up with the FZP height on camera + (`x_ray_eye_align.py:307-340`). `self._height_centered` flips to `True` + right after this move. +3. **Steps 1-5 (angles 0°/45°/90°/135°/180°)** — for each rotation angle, the + user draws a new box around the sample and submits it; only the box's `x` + center is used (`alignment_values[k]`, used later to compute per-angle + fovx offsets). The vertical (`y`) position of these boxes is not used for + anything, but the user currently still has to eyeball/re-draw it near the + right height each time. + +Once `_height_centered` becomes `True`, the sample's vertical position is +physically fixed for the rest of the run — it should equal the FZP's `y` +(the same value the crosshair's horizontal line is drawn at). The goal is +for the selection box in steps 1-5 to no longer require manual vertical +placement: draw the box anywhere, and its vertical center snaps to the FZP +height automatically, staying correct even if the user drags or resizes it +further, right up until they hit submit. + +This must **not** affect the box drawn during the height-centering step +itself (the one submission where `k == 1 and not self._height_centered`) — +that box's vertical position is the actual measurement the height +correction is computed from, so it must remain fully free. + +## Design + +Add an explicit widget-side "vertical lock" flag, toggled by the flomni +script via a new RPC method pair on `OMNY_XRayEye`, mirroring the existing +`show_crosshair()`/`hide_crosshair()` pattern (`x_ray_eye.py:238-261` for the +USER_ACCESS declarations, `x_ray_eye.py:833-874` for implementations). This +avoids adding a new EPICS/ophyd `Signal` and avoids ambiguity from +overloading the existing `step` counter (confirmed by reading +`x_ray_eye_align.py`: `step` reads `1` for *both* the height-centering box +and the first real angle-0° box, so `step` alone can't distinguish "lock" +from "don't lock"). + +- New widget state: `self._vertical_lock_enabled: bool = False`, initialized + alongside `self.target_crosshair` (`x_ray_eye.py:299`). +- New RPC methods on `OMNY_XRayEye` (USER_ACCESS, same section as + `show_crosshair`/`hide_crosshair`): `lock_vertical_center()` / + `unlock_vertical_center()`. They just set `self._vertical_lock_enabled`. +- New helper `_snap_roi_vertical(self, roi)`: + - No-op if `not self._vertical_lock_enabled` or the crosshair isn't + visible. + - Otherwise reads the crosshair's fixed `y` (the same value used by + `crosshair_position()`, `x_ray_eye.py:868-874`), reads the roi's current + `x` origin and height via `roi.pos()` / `roi.get_coordinates(typed=True)`, + computes `new_origin_y = fzp_y - height / 2` (ROIs are positioned by + bottom-left origin + size, not by center — confirmed via + `bec_widgets/widgets/plots/roi/image_roi.py`), and calls + `roi.set_position(current_x, new_origin_y)`. Guard against signal + re-entrancy (block signals or a re-entrancy flag) while calling + `set_position`, since this will itself be triggered from a + region-changed signal. +- Wire this helper into two places: + - `_style_new_roi` (`x_ray_eye.py:630-636`, connected to + `self.roi_manager.controller.roiAdded`) — call it right after the ROI is + styled, so a freshly-drawn box snaps immediately (`roiAdded` already + fires only after the user finishes drawing/releases the mouse, so this + naturally happens post-draw, not mid-drag). + - Each new ROI's `sigRegionChangeFinished` (pyqtgraph's inherited + "drag/resize finished" signal) — connect it in the same place `roiAdded` + is handled, so any subsequent manual drag or resize snaps back to the + correct vertical center as soon as the user releases the mouse, without + fighting the live drag. (No extra visual affordance beyond this — the + box stays draggable/resizable in both axes, it's just always corrected + back to the right vertical center on release.) + +### Script-side wiring (`x_ray_eye_align.py`) + +- Call `self.gui.lock_vertical_center()` right after + `self._height_centered = True` is set (line 334), before the "Step 1/5" + prompt — so every box from here through step 5 is auto-centered + vertically. The height-centering box itself (processed just before this + line) is unaffected since the lock isn't enabled yet at that point. +- Call `self.gui.unlock_vertical_center()`: + - At the start of a fresh run, alongside `self._reset_init_values()` + (`x_ray_eye_align.py:196`) or where the crosshair is implicitly reset, + so a new alignment run doesn't inherit a stale lock. + - In `align()`'s `finally` block (`x_ray_eye_align.py:172`), alongside the + existing `self.gui.hide_crosshair()` cleanup call, so the lock never + outlives a run (including on error/interruption). + +LamNI and OMNY have their own near-identical `x_ray_eye_align.py` scripts +but different height/rotation procedures — out of scope here. The widget +change is inert by default (`_vertical_lock_enabled` starts `False` and is +only ever set by a script that explicitly opts in), so it's safe to leave +their scripts untouched. + +## Files to change + +- `csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py` + - Add `self._vertical_lock_enabled` state near `self.target_crosshair` + init (~line 299). + - Add `lock_vertical_center()` / `unlock_vertical_center()` USER_ACCESS + RPC methods (declare near ~238-261, implement near ~833-874, next to the + crosshair show/hide methods). + - Add `_snap_roi_vertical(self, roi)` helper. + - In `_style_new_roi` (~630-636): call `_snap_roi_vertical(roi)` after + existing styling, and connect `roi.sigRegionChangeFinished` to + `lambda: self._snap_roi_vertical(roi)`. +- `csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py` + - Add `self.gui.lock_vertical_center()` call after line 334 + (`self._height_centered = True`). + - Add `self.gui.unlock_vertical_center()` calls at run start (near line + 196) and in `align()`'s `finally` block (near line 172). + +## Verification + +Since this is real beamtime hardware/procedure code, verify without +disrupting operation: +1. Read back the diff carefully against the exact step sequence traced above + (k=0 FZP, k=1 first pass height-centering, k=1 second pass angle-0°, + k=2..5 angles 45°-180°) to confirm the lock is enabled exactly once, + right after `_height_centered` flips, and disabled on both run-start and + run-teardown paths. +2. If a test/offline instance of the GUI is available (`test_wo_movements` + flag on `XrayEyeAlign`, or a non-production BEC session), run through a + full alignment: confirm the FZP box and height-centering box are + completely unaffected (free vertical placement, `delta_y_mm` computed + correctly), then confirm boxes drawn for steps 1-5 immediately snap their + vertical center to the crosshair's horizontal line, and stay snapped + after manual drag/resize. +3. Confirm no infinite signal loop / visual jitter when dragging or resizing + a locked box (guard against `_snap_roi_vertical`'s own `set_position` + call re-triggering `sigRegionChangeFinished`). +4. Only test live on the actual flomni beamline once available and outside + critical beamtime, per standing practice for this codebase. -- 2.54.0 From df433c88c4fc56b7ddcf5a921f1cbf2363a8f560 Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 16 Sep 2026 13:09:45 +0200 Subject: [PATCH 14/78] fix(omny): accept p-accounts as valid in TomoIDManager bec.active_account is normally a p##### proposal account in production, but _is_valid_eaccount() only matched e#####, causing every real beamtime session to be silently rejected and fall back to tomo ID 0 (skipping OMNY registration and PDF upload). Widen the regex to accept both e- and p-accounts while still rejecting test/gac-* accounts. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CpPtD7tCRwRZZnREhWcJi8 --- .../plugins/OMNY_shared/omny_general_tools.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py index 3959efb5..0a75cb8f 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py @@ -327,6 +327,8 @@ class TomoIDManager: """Registers a tomography measurement in the OMNY sample database and returns its assigned tomo ID. + Real e-accounts (personal, e.g. "e12345") and p-accounts (proposal + accounts, e.g. "p23092") are both registered against OMNY_URL. Non-production accounts (e.g. test accounts like "gac-x01dc") are not registered against OMNY_URL at all -- there is no longer a separate test server to redirect them to, and registering test runs against @@ -351,8 +353,8 @@ class TomoIDManager: @staticmethod def _is_valid_eaccount(eaccount: str) -> bool: - """True for real e-accounts (e.g. "e12345"), False for test accounts.""" - return bool(re.fullmatch(r"e\d{5}", eaccount.strip())) + """True for real e- or p-accounts (e.g. "e12345", "p23092"), False for test accounts.""" + return bool(re.fullmatch(r"[ep]\d{5}", eaccount.strip())) def register( self, @@ -366,7 +368,7 @@ class TomoIDManager: ) -> int: """Register a new measurement and return the assigned tomo ID. - Registers against OMNY_URL for a real e-account. For any other + Registers against OMNY_URL for a real e- or p-account. For any other account (e.g. a test/"gac-*" account), skips registration entirely and returns FALLBACK_TOMO_ID (0) -- there is no separate test server to register against, and registering test runs against @@ -376,7 +378,7 @@ class TomoIDManager: """ if not self._is_valid_eaccount(eaccount): logger.warning( - f"Account '{eaccount}' is not a valid e-account; skipping OMNY " + f"Account '{eaccount}' is not a valid e- or p-account; skipping OMNY " f"registration (no separate test server to register against) " f"and falling back to tomo ID {self.FALLBACK_TOMO_ID}." ) -- 2.54.0 From 861d2f2ff0925b2cbc335f075c608cb1e547b309 Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 16 Sep 2026 20:08:47 +0200 Subject: [PATCH 15/78] docs(flomni): add plan for Fermat scan x/y spacing asymmetry Introduces fermat_asymmetry, a ratio of x spacing to y spacing for the Flomni Fermat-spiral scan: x spacing stays fixed at step while y spacing scales by the ratio, with the requested field of view preserved (so the total position count changes with the ratio). Covers the scan class, global var/CLI, queuing, and the tomo_params Qt widget. Plan only, not implemented -- written during a live beamtime experiment. Also flags this for a future OMNY port (same Fermat-spiral algorithm), explicitly excluding LamNI. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WW873PFzsY4N1r65aQgfFn --- .../AI_docs/TODO_fermat_asymmetry_port.md | 40 ++++ docs/plans/flomni-fermat-asymmetry.md | 225 ++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md create mode 100644 docs/plans/flomni-fermat-asymmetry.md diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md new file mode 100644 index 00000000..502aa887 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md @@ -0,0 +1,40 @@ +# TODO: port `fermat_asymmetry` (x/y spacing ratio) to OMNY once it lands on Flomni + +Flomni is getting a new Fermat-spiral scan parameter, `fermat_asymmetry` +(ratio of x spacing to y spacing; default 1.0 = symmetric, keeps x spacing +fixed and scales y spacing by the ratio while preserving the requested FOV). +See `docs/plans/flomni-fermat-asymmetry.md` for the full design and the +exact math. + +OMNY's `OmnyFermatScan.get_omny_fermat_spiral_pos` +(`csaxs_bec/scans/omny_fermat_scan.py:275-333`) uses the byte-for-byte same +spiral algorithm as Flomni's `get_flomni_fermat_spiral_pos` (`radius = +step * 0.57 * sqrt(ii)`, same FOV-window filter), so the same change ports +directly: add `fermat_asymmetry` to `OmnyFermatScan.__init__` +(`omny_fermat_scan.py:63-79`), thread it through +`get_omny_fermat_spiral_pos`, and mirror the CLI-side plumbing on `OMNY` +(`csaxs_bec/bec_ipython_client/plugins/omny/omny.py`): a +`tomo_shellstep`-style property pair (OMNY hand-writes +`get_global_var`/`set_global_var` property pairs, e.g. `omny.py:671-679`, +rather than using the `_GlobalVarParam` descriptor Flomni uses), the +`scan_kwargs` dict in the per-angle scan call (~`omny.py:1282-1284`), and +`tomo_parameters()` (~`omny.py:1293-1341`). + +## Difference from Flomni to account for when porting + +OMNY has no tomo-queue system — `OMNY` does not mix in `TomoQueueMixin` and +has no `_TOMO_SCAN_PARAM_NAMES` (unlike `Flomni`/`LamNI`). So there is no +queue-snapshot list to add the new parameter to on OMNY's side, and no +`tomo_params` Qt widget profile for OMNY today either (`SETUP_PROFILES` in +`tomo_params.py` only has `"flomni"` and `"lamni"` entries) — the widget +part of the Flomni plan doesn't apply until/unless OMNY gets its own queue ++ widget support. Scope the OMNY port to: the scan class parameter, the +global var, and `tomo_parameters()` display/prompt only. + +## Explicitly not for LamNI + +This feature was scoped to Flomni (and, via this TODO, OMNY) only; do not +add it to `LamniFermatScan` / `LamNI` as part of this port. + +Not scoped/designed further here beyond the pointers above — flagging it so +it isn't lost, same as the other `TODO_*.md` files in this folder. diff --git a/docs/plans/flomni-fermat-asymmetry.md b/docs/plans/flomni-fermat-asymmetry.md new file mode 100644 index 00000000..0ea0e338 --- /dev/null +++ b/docs/plans/flomni-fermat-asymmetry.md @@ -0,0 +1,225 @@ +# Plan: `fermat_asymmetry` (x/y spacing ratio) for the Flomni Fermat scan + +Status: **not implemented** (written during live beamtime operation on request; no +code changes made — see conversation this plan came from). + +## Context + +The Flomni ptycho Fermat-spiral scan (`FlomniFermatScan`) currently generates +an isotropic spiral: the local point spacing is the same in x and y, +controlled by a single `step` parameter, and points are kept only if they +fall inside the requested field of view (`fovx` × `fovy`). For some samples +it's desirable to bias the spacing — e.g. because the sample or an expected +feature is elongated in one direction — so that the effective spacing in x +differs from y while the requested field of view stays exactly as specified. + +This plan introduces `fermat_asymmetry` (ratio = x_spacing / y_spacing, +default `1.0` = symmetric). `>1` means x spacing is larger than y spacing (y +is denser); `<1` is the reverse. Because spacing changes while the FOV area +stays fixed, the total number of positions will change with the ratio. + +Scope: the scan argument, the BEC global variable, the queuing path (client +→ queue → scan-server validation → scan execution), the Flomni CLI's +`tomo_parameters()`, and the `tomo_params` Qt widget's Flomni profile. OMNY +and LamNI are out of scope for the code change itself — OMNY is flagged +separately for a future port (see +`csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md`); +LamNI is excluded on purpose. + +## The math + +Current core loop, `FlomniFermatScan.get_flomni_fermat_spiral_pos` (static +method), `csaxs_bec/scans/flomni_fermat_scan.py:410-463`: + +```python +length_axis1 = np.abs(m1_stop - m1_start) +length_axis2 = np.abs(m2_stop - m2_start) +n_max = compute_fermat_spiral_n_max(length_axis1, length_axis2, step) +... +for ii in range(start, n_max): + radius = step * 0.57 * np.sqrt(ii) + if abs(radius * np.sin(ii * phi)) > length_axis1 / 2: + continue + if abs(radius * np.cos(ii * phi)) > length_axis2 / 2: + continue + x = radius * np.sin(ii * phi) + y = radius * np.cos(ii * phi) + positions.append([x + cenx, y + ceny, z_pos]) +``` + +This is an isotropic Fermat spiral: `step` controls spacing equally in both +directions. To bias it while keeping x spacing pinned at `step` and the +requested FOV fixed: + +- Generate the spiral exactly as today (unchanged x0/y0). Filter x against + the true `fovx` half-width as before. For y, generate/filter against an + **inflated** half-width `length_axis2 * fermat_asymmetry`, then **compress + the accepted y-coordinate back down** by dividing by `fermat_asymmetry` + before storing the final position. +- This keeps x spacing at `step` (untouched), while y spacing becomes + `step / fermat_asymmetry` — "keep x spacing constant, scale y spacing by + the ratio". When `fermat_asymmetry == 1.0` the math reduces exactly to + today's formula (no behavior change for existing callers/tests). +- The FOV corner points appended after the loop (lines 459-462) stay + untouched — they represent the real, requested physical FOV box and must + not be skewed by the asymmetry. +- `compute_fermat_spiral_n_max` (`csaxs_bec/scans/fermat_spiral_utils.py:8`) + must be called with the *inflated* y half-width (`length_axis2 * + fermat_asymmetry`), since that's the actual unscaled extent the loop needs + to reach before compression. + +Range: `fermat_asymmetry` must be `> 0` (zero/negative makes the +y-compression nonsensical) — reject that outright via the scan argument's +`gt=0` bound (validated by BEC's `ScanInputValidator` at queue-submit time, +same mechanism as every other `Annotated[..., ScanArgument(...)]` +parameter). Additionally, clamp the practical working range to `[0.1, 10]` +with a warning, the same soft-clamp style already used for `zshift` and +`corridor_size` in this file (`flomni_fermat_scan.py:121-134`). Put this +clamp **inside `get_flomni_fermat_spiral_pos` itself** (not in `__init__`), +because that static method is also called directly — bypassing `__init__` — +by the point-count preview helpers (`Flomni._expected_fermat_position_count()` +and the widget's `_compute_fermat_positions_flomni()`, see below). Clamping +at that one shared call site keeps the real scan and every preview in sync +automatically. + +## Files to change + +### 1. `csaxs_bec/scans/flomni_fermat_scan.py` + +- Add class constants next to `MIN_POSITIONS`/`MAX_CORRIDOR_SIZE` (line + 67-68): `MIN_FERMAT_ASYMMETRY = 0.1`, `MAX_FERMAT_ASYMMETRY = 10.0`. +- Add `"fermat_asymmetry"` to `gui_config["Scan Parameters"]` (line 54-63). +- Add a new `__init__` parameter (near `step`, line 77): + ```python + fermat_asymmetry: Annotated[float, ScanArgument(display_name="Fermat Asymmetry", description="Ratio of x spacing to y spacing (x/y). 1 = symmetric. >1 widens x spacing / tightens y spacing; <1 the opposite. FOV is preserved; point count changes with the ratio.", gt=0, example=1)] = 1.0, + ``` + Store as `self.fermat_asymmetry = fermat_asymmetry` and document it in the + docstring `Args:` block. +- Pass it through in `prepare_scan()`'s call to `get_flomni_fermat_spiral_pos` + (line 150-161): add `fermat_asymmetry=self.fermat_asymmetry`. +- Update `get_flomni_fermat_spiral_pos` (line 410-463): add + `fermat_asymmetry: float = 1.0` parameter, implement the clamp (with + `logger.warning` when out of `[MIN_FERMAT_ASYMMETRY, MAX_FERMAT_ASYMMETRY]`) + and the x/y-scaling math described above, replacing the direct + `radius*sin`/`radius*cos` filter+store with the generate-against-inflated- + window / compress-on-accept logic. + +### 2. `csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py` + +- Add a new global-var-backed property next to `tomo_shellstep` + (line 2005): `fermat_asymmetry = _GlobalVarParam(1.0)`. +- Add `"fermat_asymmetry"` to `_TOMO_SCAN_PARAM_NAMES` (line 3900-3914) so + it's snapshotted/restored by `tomo_queue_add()`/`tomo_queue_execute()` + like every other tomo scan parameter (`tomo_queue_mixin.py:625`, `899`). + No migration needed for already-queued jobs: `tomo_queue_execute()` only + sets attributes present in a job's saved `params` dict + (`tomo_queue_mixin.py:898-899`), so older queued jobs simply leave the + live `fermat_asymmetry` value untouched — the same behavior every + previously-added parameter already relies on. +- Thread it into the per-projection scan call, `scan_kwargs` dict + (line 3478-3488): add `fermat_asymmetry=self.fermat_asymmetry`, and into + the accompanying `logger.info(...)` repro string (line 3467-3472) for + consistency with the other logged kwargs. +- Pass it into the preview helper, `_expected_fermat_position_count()` + (line 3638-3646): add `fermat_asymmetry=self.fermat_asymmetry` to the + `get_flomni_fermat_spiral_pos(...)` call. +- Surface it in `tomo_parameters()`: + - print current value near line 3661-3662 (alongside step/FOV). + - add a prompt line near line 3749-3751, following the existing + `self.tomo_shellstep = self._get_val(...)` pattern. + +### 3. `csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py` (Qt widget) + +The widget is largely data-driven off a few module-level tables plus a +per-setup `SETUP_PROFILES["flomni"]` dict, so most of the wiring is +declarative: + +- Add `"fermat_asymmetry"` to `QUEUE_PARAM_NAMES` (line 127-149, the tuple + mirroring `Flomni._TOMO_SCAN_PARAM_NAMES`) and to `DEFAULTS` + (line 151-173): `"fermat_asymmetry": 1.0`. **Do not** touch + `LAMNI_QUEUE_PARAM_NAMES` (line 184) — LamNI is explicitly excluded. +- Add a token builder to the `token_builders` dict in `_build_params_panel` + (~line 463-470), matching the existing `tomo_shellstep` entry's style: + ```python + "fermat_asymmetry": lambda: self._add_double( + common_form, + "fermat_asymmetry", + "Fermat asymmetry (x/y)", + min_=0.1, + max_=10.0, + decimals=2, + ), + ``` +- Add `"fermat_asymmetry"` to `SETUP_PROFILES["flomni"]["field_order"]` + (~line 2280-2291), right after `"tomo_shellstep"`. +- Add `"fermat_asymmetry"` to `SETUP_PROFILES["flomni"]["fermat_position_fields"]` + (line 2296) so the existing live point-count preview wiring (the loop at + line 505-508 that connects each listed field's `valueChanged` signal to + `_update_fermat_position_preview`) picks it up automatically — no new + signal-wiring code needed. +- Update `_compute_fermat_positions_flomni()` (line 2162-2189): read + `fermat_asymmetry = params.get("fermat_asymmetry", 1.0)` and pass + `fermat_asymmetry=fermat_asymmetry` into the + `FlomniFermatScan.get_flomni_fermat_spiral_pos(...)` call, so the GUI's + "Estimated Fermat scan points" preview never drifts from the real + algorithm (matches this function's own stated purpose in its docstring). + +No other widget code needs to change: `_load_params()` (line 1411-1416) and +`add_edited_to_queue()`'s snapshot (line 1266) both iterate generically over +`self._profile["param_names"]`, and `_job_tooltip()` (line 2432-2458) iterates +generically over `sorted(job["params"])` — all three automatically pick up +`fermat_asymmetry` once it's in `QUEUE_PARAM_NAMES`/`DEFAULTS` and has a +widget in `self._pw`. + +**Deliberately left out**: the fixed-column queue table (`_build_ui`, +`QTableWidget(0, 8)`, headers `["#", "Label", "Status", "Type", +"Projections", "Exp (s)", "Step (µm)", "Details"]`, populated around line +1522-1533) is not extended with a new column for `fermat_asymmetry`. Adding +a column means touching header setup, column-width logic, and the +alignment-index checks (`col in (1, 7)`) in the same function — more +surface area for a cosmetic addition. The value is still fully visible via +the row tooltip (`_job_tooltip`, which is generic). Revisit only if this +turns out to be commonly-varied-enough to want at-a-glance visibility. + +### 4. Tests — `tests/tests_scans/test_flomni_fermat_scan.py` + +Add cases alongside the existing +`test_flomni_fermat_spiral_reaches_elongated_fov_corners`: + +- `fermat_asymmetry=1.0` (or omitted) reproduces exactly today's positions + (no-op / backward-compatibility check). +- For `fermat_asymmetry > 1` and `< 1`, the ratio of median nearest-neighbor + spacing along x vs. along y (via `scipy.spatial.cKDTree`, same technique + already used in `_estimate_corridor_size`) approximately matches the + requested ratio, while `max(abs(x)) <= fovx/2` and `max(abs(y)) <= fovy/2` + still hold. +- A value outside `[0.1, 10]` (e.g. `100`) is silently clamped rather than + raising, and does not change the FOV bounds. + +## Verification + +1. `cd /sls/x12sa/config/bec/production/csaxs_bec && python -m pytest tests/tests_scans/test_flomni_fermat_scan.py -v` +2. Sanity-check the position generator directly for a few ratios: + ```python + from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan + for r in (0.5, 1.0, 2.0): + pos = FlomniFermatScan.get_flomni_fermat_spiral_pos(-10, 10, -5, 5, step=1, fermat_asymmetry=r) + print(r, len(pos), pos[:, 0].max(), pos[:, 1].max()) + ``` +3. In a BEC client session against the Flomni deployment: confirm + `flomni.fermat_asymmetry` reads/writes via the global var (persists + across a client restart), shows up in `flomni.tomo_parameters()`, and + that `scans.flomni_fermat_scan(..., fermat_asymmetry=2.0)` is accepted by + the queue (client-side `ScanInputValidator` and server-side + `ScanAssembler` both validate against the new `ScanArgument`). +4. Open the `tomo_params` widget for the flomni profile: confirm the new + "Fermat asymmetry (x/y)" field appears in the parameter panel in the + right position, edit it, confirm the "Estimated Fermat scan points" + preview updates live, and confirm that queuing/loading a job round-trips + the value correctly. + +## Out of scope + +- `OmnyFermatScan` / `OMNY` — flagged for a future port, see + `csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md`. +- `LamniFermatScan` / `LamNI` — explicitly excluded, not planned. -- 2.54.0 From a73eded4093a98da091a57e49e35fdabdcaf661b Mon Sep 17 00:00:00 2001 From: x12sa Date: Thu, 17 Sep 2026 12:02:37 +0200 Subject: [PATCH 16/78] config(flomni): switch FZP optics config to 120 micron diameter Update foptx/fopty/fosax/fosay in/out positions and detector_distance for the 120 micron FZP (60 nm outermost zone), commenting out the previous 150 micron settings for reference. --- csaxs_bec/device_configs/ptycho_flomni.yaml | 24 ++++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index ab3643df..b010526e 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -89,9 +89,12 @@ foptx: readoutPriority: baseline connectionTimeout: 20 userParameter: + #120 micros, 60 + in: -13.890 + out: -14 #150 micros, 60 - in: -14.191 - out: -13.831 + # in: -14.191 + # out: -13.831 #170 micron, 60 nm #in: -13.8909 #out: -13.831 @@ -101,9 +104,9 @@ foptx: #250 micron, 30 nm, Tomas structures # in: -14.5490625 # out: -14.1809 - fzp_diameter: 150 # microns + fzp_diameter: 120 # microns fzp_outermost_zone_width: 60 # nm - detector_distance: -1 # mm, sample-to-detector; unknown for now + detector_distance: 7196 # mm, sample-to-detector; unknown for now fzp_details: "manufacturing notes here" # free-text FZP manufacturing notes deviceTags: - ptycho_flomni @@ -127,7 +130,7 @@ fopty: userParameter: #150 micron, 60 in: 1.02 - out: 0.57 + out: 0.869 #170 micron, 60 nm #in: 0.42 #out: 0.57 @@ -367,9 +370,12 @@ fosax: readoutPriority: baseline connectionTimeout: 20 userParameter: - #150micron, 60 nm, 7.9 kev - in: 9.0393 + #120micron, 60 nm, 7.9 kev + in: 8.74251 out: 5.1 + #150micron, 60 nm, 7.9 kev + # in: 9.0393 + # out: 5.1 #170 micron, 60 nm, 7.6 kev #in: 8.7568 #out: 5.1 @@ -402,8 +408,10 @@ fosay: readoutPriority: baseline connectionTimeout: 20 userParameter: + #120 micron, 60 nm, 7.9 kev + in: -0.645276 #150 micron, 60 nm, 7.9 kev - in: -0.6422 + #in: -0.6422 #170 micron, 60 nm, 7.6 kev #in: -0.0235 #170 micron, 60 nm, 7.9 kev -- 2.54.0 From ab1fdd9a196a35163f41278c5345df9ed1ac64a2 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 12:19:37 +0200 Subject: [PATCH 17/78] docs(flomni): add plan for Fermat scan sub-step randomization fix Legacy spec code drew a fresh random offset per projection to shift the Fermat lattice phase before FOV cropping (mitigates ptycho-tomo ring artifacts). None of the current BEC Fermat spiral generators (flOMNI, OMNY, LamNI) draw this offset -- all three are fully deterministic. Plan only, not implemented; applies identically across all three setups. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- docs/plans/fermat-scan-randomization.md | 80 +++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/plans/fermat-scan-randomization.md diff --git a/docs/plans/fermat-scan-randomization.md b/docs/plans/fermat-scan-randomization.md new file mode 100644 index 00000000..48c4a15b --- /dev/null +++ b/docs/plans/fermat-scan-randomization.md @@ -0,0 +1,80 @@ +# Fix needed: restore random sub-step offset in Fermat spiral scans (OMNY, flOMNI, LamNI) + +**Status:** Not started — investigation complete, fix not yet implemented. +**Applies to:** all three setups — **OMNY, flOMNI, and LamNI**. The fix must be applied consistently across all three, not just one. + +## Background + +Ptychographic tomography ring artifacts arise from any error that is systematically fixed relative to a common coordinate frame across all projection angles (e.g. a local reconstruction inconsistency always occurring at the same scan-grid position). Randomizing the Fermat scan grid's sub-step phase from projection to projection is a known mitigation: it decorrelates such position-tied errors so they don't accumulate into a ring during backprojection. + +## What the old spec code did + +In the legacy spec macros (`spec/scan_handling.mac`, `_rtscan_point_by_point`, fermat branch), a fresh random offset was drawn **on every call** (i.e. every projection) and used to shift the sampling lattice before cropping to the field of view: + +```c +_fermat_offset = step*rand(100)/100 // uniform random in [0, step) + +for (ind0=1; ind0<=_arb_num1; ind0++) { + _radius = step/sqrt(PI) * sqrt(ind0) + _angle = 8*PI/(1+sqrt(5))/(1+sqrt(5)) * ind0 + tmp_x = radius*cos(angle) + tmp_y = radius*sin(angle) + if (tmp_x in [-(fovx/2)+offset, (fovx/2)+offset] and + tmp_y in [-(fovy/2)+offset, (fovy/2)+offset]) keep it +} +final_x = tmp_x + cenx - _fermat_offset +final_y = tmp_y + ceny - _fermat_offset +``` + +Net effect (worked out by algebra): the entire Fermat lattice is shifted by `-offset` before cropping to the FOV window, with `offset` redrawn uniformly from `[0, step)` on every scan. So the exact sub-step "phase" of the sampling lattice relative to the sample/FOV origin varies from projection to projection, rather than being bit-for-bit identical every time. + +## Current state in BEC (the problem) + +None of the three current Fermat spiral generators in `csaxs_bec` draw any random number. All three are fully deterministic given `(cenx, ceny, step, fovx, fovy, ...)`: + +| Setup | File | Function | +|--------|------|----------| +| flOMNI | `csaxs_bec/csaxs_bec/scans/flomni_fermat_scan.py` | `get_flomni_fermat_spiral_pos` (staticmethod) | +| OMNY | `csaxs_bec/csaxs_bec/scans/omny_fermat_scan.py` | `get_omny_fermat_spiral_pos` (instance method) | +| LamNI | `csaxs_bec/csaxs_bec/scans/lamni_fermat_scan.py` | `get_lamni_fermat_spiral_pos` (staticmethod) | + +All three share the same core loop shape: + +```python +for ii in range(start, n_max): + radius = step * 0.57 * np.sqrt(ii) + x = radius * np.sin(ii * phi) + y = radius * np.cos(ii * phi) + # ... FOV/circular-FOV crop check ... + positions.append([x + cenx, y + ceny, z_pos]) +``` + +Since `cenx`/`ceny` come from the per-angle alignment/offset computation (not randomized), every projection at a given nominal center currently samples the **exact same** lattice phase relative to the sample. This is a plausible, currently-unmitigated contributor to ring artifacts in reconstructions from all three instruments. + +Note: there is an existing `random.uniform` call in `csaxs_bec/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py` (`single_point_random_shift_max`, used in `tomo_acquire_at_angle`). That is a **separate, unrelated** mechanism for single-point burst acquisitions — it does not touch the Fermat spiral generators and does not address this issue. + +## What needs to happen + +1. Add a per-call random sub-step offset to all three spiral generators (`get_flomni_fermat_spiral_pos`, `get_omny_fermat_spiral_pos`, `get_lamni_fermat_spiral_pos`), reproducing the intent of the old spec `_fermat_offset` (uniform draw in `[0, step)`, applied as a lattice-phase shift before FOV cropping). +2. Apply the fix **identically in spirit** across OMNY, flOMNI, and LamNI — the three functions currently share near-identical structure and should stay in sync on this behavior. Watch for the small existing differences between them (flOMNI/LamNI take `cenx`/`ceny` as static args; OMNY reads `self.cenx`/`self.ceny`; LamNI has additional stitching/shift/circular-FOV parameters that the offset must compose correctly with). +3. Decide and document (open questions below) rather than guessing silently. + +## Open questions for the implementing session to resolve (and record the decision) + +- **Reproducibility/logging:** should the drawn offset be logged into scan metadata (e.g. via `update_scan_info`) so a given projection's exact grid phase can be recovered/audited later, the way `_arb_num1`/positions already are? +- **Opt-out for testing/repeatability:** should there be a parameter (e.g. `randomize_offset: bool = True`, or an explicit `seed`) so existing/new unit tests and commissioning scans can request deterministic output? +- **X/Y coupling:** the original spec used the *same* scalar `_fermat_offset` for both the x- and y-shift. Decide whether to preserve that (simpler, matches historical behavior) or draw independent offsets per axis (stronger decorrelation, but diverges from the reference implementation) — pick one and note the reasoning. +- **Interaction with existing FOV/circular-FOV cropping logic**, especially in LamNI's `_lamni_check_pos_in_fov_range_and_circ_fov` and any stitching offsets — the offset must shift the lattice consistently with those checks, not just the final translation. + +## Tests likely needing updates + +- `csaxs_bec/csaxs_bec/tests/tests_scans/test_flomni_fermat_scan.py` +- `csaxs_bec/csaxs_bec/tests/tests_scans/test_omny_fermat_scan.py` +- `csaxs_bec/csaxs_bec/tests/tests_scans/test_lamni_fermat_scan.py` + +Any test currently asserting exact/deterministic position arrays will need either a fixed seed, the opt-out parameter above, or an update to assert distributional/structural properties instead of exact values. + +## Non-goals + +- Do not change the underlying spiral density/index-to-radius/angle formula (`radius = step * 0.57 * sqrt(ii)`, golden-angle `phi`) — only the lattice-phase (offset) behavior is in scope. +- Do not touch `single_point_random_shift_max` / `tomo_acquire_at_angle` in `flomni.py` — unrelated mechanism, out of scope here. -- 2.54.0 From f81c4ee5310309ade349c6c17cedf17722aceb87 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 12:30:13 +0200 Subject: [PATCH 18/78] docs(ids-cameras): mark manual exposure/auto-gain plan as done Confirmed merged into main (round-4 HW-testing commit 2f6f445 is an ancestor of main) -- update the plan's status from "pending real-hardware verification" to done. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- docs/plans/ids-camera-manual-exposure.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/plans/ids-camera-manual-exposure.md b/docs/plans/ids-camera-manual-exposure.md index ce299e54..a9ba4965 100644 --- a/docs/plans/ids-camera-manual-exposure.md +++ b/docs/plans/ids-camera-manual-exposure.md @@ -1,10 +1,11 @@ # Plan: Manual exposure / auto-gain control for IDSCamera + xrayeye widget knobs -Status: **implemented on `feat/ids-camera-manual-exposure`, pending real-hardware -verification** (`IDSCamera` and the `OMNY_XRayEye` GUI widget are both used in -production during beamtimes; this change should be reviewed and verified against -real hardware outside a live beamtime -- see `csaxs_bec/device_configs/test_ids_camera_41.yaml` -for a single-camera (ID 41, color) config for that purpose -- before merging). +Status: **done — implemented and merged into `main`**, after four rounds of +real-hardware verification (see the addenda below for the bugs found and fixed +each round: startup auto-exposure/gain never actually engaging, a zero-ms +exposure floor, auto-gain being more useful as a one-time startup pulse than a +persistent toggle, and the pixel-clock slider needing to be index-based over +the camera's real discrete list instead of a ranged value). ## Context -- 2.54.0 From 5e26b86d0343cad0c8053eec465f96cd0962b64c Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 12:30:13 +0200 Subject: [PATCH 19/78] feat(scans): randomize Fermat spiral sub-step offset (flOMNI/OMNY/LamNI) Restores the legacy spec behavior of drawing a fresh random offset (uniform in [0, step)) per call and shifting the sampling lattice by it before the FOV crop check -- decorrelates position-tied reconstruction errors that otherwise accumulate into ring artifacts across a ptycho-tomography series. Applied identically to all three generators (get_flomni_fermat_spiral_pos, get_omny_fermat_spiral_pos, get_lamni_fermat_spiral_pos), same scalar offset for x and y, matching the historical spec. New `seed` parameter (default None = fresh randomization) allows deterministic/reproducible calls. The four call sites that predict Fermat point counts ahead of a real scan (flomni.py/lamni.py's _expected_fermat_position_count(), tomo_params.py's _compute_fermat_positions_flomni/_lamni) now pin seed=0 so their own estimates stay reproducible across repeated calls; their docstrings note the real scan's count can differ by a point or two near the threshold due to the randomization. Updated the affected tests to compare against a matching seed=0 instead of an independent random draw. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- .../bec_ipython_client/plugins/LamNI/lamni.py | 7 +++++++ .../plugins/flomni/flomni.py | 8 ++++++++ .../widgets/tomo_params/tomo_params.py | 20 ++++++++++++++++++- csaxs_bec/scans/flomni_fermat_scan.py | 19 ++++++++++++++---- csaxs_bec/scans/lamni_fermat_scan.py | 15 ++++++++++++-- csaxs_bec/scans/omny_fermat_scan.py | 19 ++++++++++++++---- docs/plans/fermat-scan-randomization.md | 16 ++++++++++++++- .../test_fermat_position_warning.py | 11 +++++++--- .../test_lamni_tomo_params_widget_math.py | 3 +++ .../test_tomo_params_widget_math.py | 8 ++++++-- 10 files changed, 109 insertions(+), 17 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index cc9155f5..58a4658b 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -1994,6 +1994,12 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools the real per-projection count can vary a bit with angle/stitch tile, since the circular FOV crop (tomo_circfov) is checked against the rotated stage position. + + The real scan also draws a fresh random sub-step offset on every + call (see get_lamni_fermat_spiral_pos()'s `seed` argument), adding a + further small amount of per-projection count variation on top of + the angle/stitch dependence above. A fixed seed is used here only so + that this estimate itself is reproducible from one call to the next. """ from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan @@ -2012,6 +2018,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools fovx=self.lamni_piezo_range_x, fovy=self.lamni_piezo_range_y, fov_circular=self.tomo_circfov, + seed=0, ) return len(positions) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 6311fbf0..887238e6 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -3632,6 +3632,13 @@ class Flomni( cenx/ceny/zshift are deliberately left at their defaults: they only shift the final position, applied after the FOV keep-or-discard check, so they can never affect the resulting count. + + The real scan draws a fresh random sub-step offset on every call + (see get_flomni_fermat_spiral_pos()'s `seed` argument), which can + shift a point or two across the FOV edge -- so the actual scan-time + count can differ from this estimate by a small amount near the + threshold. A fixed seed is used here only so that this estimate + itself is reproducible from one call to the next. """ from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan @@ -3643,6 +3650,7 @@ class Flomni( step=self.tomo_shellstep, spiral_type=0, center=False, + seed=0, ) return len(positions) diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index 5b2d1cce..188c8fb3 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -2173,6 +2173,11 @@ def _compute_fermat_positions_flomni(params: dict[str, Any]) -> tuple[int, int]: doesn't affect flomni's count either -- every stitched tile is an identically-shaped Fermat scan, just centered differently. + The real scan also draws a fresh random sub-step offset on every call + (see get_flomni_fermat_spiral_pos()'s `seed` argument), which can shift + the actual scan-time count by a point or two near the threshold. A + fixed seed is used here only so this estimate is itself reproducible. + Returns: (estimated_count, minimum_required) """ @@ -2184,7 +2189,13 @@ def _compute_fermat_positions_flomni(params: dict[str, Any]) -> tuple[int, int]: if step <= 0: return 0, FlomniFermatScan.MIN_POSITIONS positions = FlomniFermatScan.get_flomni_fermat_spiral_pos( - -abs(fovx / 2), abs(fovx / 2), -abs(fovy / 2), abs(fovy / 2), step=step, spiral_type=0 + -abs(fovx / 2), + abs(fovx / 2), + -abs(fovy / 2), + abs(fovy / 2), + step=step, + spiral_type=0, + seed=0, ) return len(positions), FlomniFermatScan.MIN_POSITIONS @@ -2198,6 +2209,12 @@ def _compute_fermat_positions_lamni(params: dict[str, Any]) -> tuple[int, int]: no angle field in this widget -- the real per-projection count can vary a bit with angle, since the circular crop is angle-dependent). + The real scan also draws a fresh random sub-step offset on every call + (see get_lamni_fermat_spiral_pos()'s `seed` argument), adding a further + small amount of per-projection count variation on top of the + angle/stitch dependence above. A fixed seed is used here only so this + estimate is itself reproducible. + Returns: (estimated_count, minimum_required) """ @@ -2222,6 +2239,7 @@ def _compute_fermat_positions_lamni(params: dict[str, Any]) -> tuple[int, int]: fovx=piezo_x, fovy=piezo_y, fov_circular=params.get("tomo_circfov", 0.0), + seed=0, ) return len(positions), LamniFermatScan.MIN_POSITIONS diff --git a/csaxs_bec/scans/flomni_fermat_scan.py b/csaxs_bec/scans/flomni_fermat_scan.py index df31722f..3700bdf3 100644 --- a/csaxs_bec/scans/flomni_fermat_scan.py +++ b/csaxs_bec/scans/flomni_fermat_scan.py @@ -419,6 +419,7 @@ class FlomniFermatScan(ScanBase): cenx: float = 0.0, ceny: float = 0.0, zshift: float = 0.0, + seed: int | None = None, ): """ Calculate positions for a Fermat spiral scan. @@ -431,6 +432,9 @@ class FlomniFermatScan(ScanBase): step(float): stepsize spiral_type(int): 0 for traditional Fermat spiral center(bool): whether to include the center position + seed(int | None): seed for the per-call random sub-step offset (see below). + Pass a fixed value for reproducible output (e.g. in tests); leave as None + to draw a fresh offset every call. Returns: positions(array): positions @@ -446,15 +450,22 @@ class FlomniFermatScan(ScanBase): z_pos = zshift + # Randomize the sampling lattice's sub-step phase on every call (i.e. every + # projection), matching the legacy spec behaviour: this decorrelates position-tied + # reconstruction errors that would otherwise accumulate into ring artifacts across + # a tomographic series. + offset = np.random.default_rng(seed).uniform(0, step) + logger.info(f"Fermat spiral sub-step offset: {offset:.4f} um") + for ii in range(start, n_max): radius = step * 0.57 * np.sqrt(ii) + x = radius * np.sin(ii * phi) - offset + y = radius * np.cos(ii * phi) - offset # FOV is restructed below at check pos in range - if abs(radius * np.sin(ii * phi)) > length_axis1 / 2: + if abs(x) > length_axis1 / 2: continue - if abs(radius * np.cos(ii * phi)) > length_axis2 / 2: + if abs(y) > length_axis2 / 2: continue - x = radius * np.sin(ii * phi) - y = radius * np.cos(ii * phi) positions.append([x + cenx, y + ceny, z_pos]) left_lower_corner = [min(m1_start, m1_stop) + cenx, min(m2_start, m2_stop) + ceny, z_pos] right_upper_corner = [max(m1_start, m1_stop) + cenx, max(m2_start, m2_stop) + ceny, z_pos] diff --git a/csaxs_bec/scans/lamni_fermat_scan.py b/csaxs_bec/scans/lamni_fermat_scan.py index 862c407e..e2312ff1 100644 --- a/csaxs_bec/scans/lamni_fermat_scan.py +++ b/csaxs_bec/scans/lamni_fermat_scan.py @@ -287,6 +287,7 @@ class LamniFermatScan(ScanBase): fovx: float | None = None, fovy: float | None = None, fov_circular: float = 0.0, + seed: int | None = None, ) -> np.ndarray: """Generate positions for a LamNI Fermat spiral scan. @@ -299,6 +300,9 @@ class LamniFermatScan(ScanBase): spiral_type (float, optional): Angular offset in radians that determines the shape of the spiral. A spiral with spiral_type=2 is the same as spiral_type=0. Defaults to 0. center (bool, optional): Add a center point. Defaults to False. + seed (int | None, optional): seed for the per-call random sub-step offset (see + below). Pass a fixed value for reproducible output (e.g. in tests); leave as + None to draw a fresh offset every call. Returns: np.ndarray: Array of positions for the Fermat spiral scan. @@ -312,6 +316,13 @@ class LamniFermatScan(ScanBase): length_axis2 = np.abs(m2_stop - m2_start) n_max = compute_fermat_spiral_n_max(length_axis1, length_axis2, step) + # Randomize the sampling lattice's sub-step phase on every call (i.e. every + # projection), matching the legacy spec behaviour: this decorrelates position-tied + # reconstruction errors that would otherwise accumulate into ring artifacts across + # a tomographic series. + offset = np.random.default_rng(seed).uniform(0, step) + logger.info(f"Fermat spiral sub-step offset: {offset:.4f} um") + total_shift_x, total_shift_y = LamniFermatScan._compute_total_shift( center_x=center_x, center_y=center_y, @@ -327,8 +338,8 @@ class LamniFermatScan(ScanBase): for ii in range(start, n_max): radius = step * 0.57 * np.sqrt(ii) - x = radius * np.sin(ii * phi) - y = radius * np.cos(ii * phi) + x = radius * np.sin(ii * phi) - offset + y = radius * np.cos(ii * phi) - offset if LamniFermatScan._lamni_check_pos_in_fov_range_and_circ_fov( x=x, y=y, diff --git a/csaxs_bec/scans/omny_fermat_scan.py b/csaxs_bec/scans/omny_fermat_scan.py index 34b2c5f6..35b9ca58 100644 --- a/csaxs_bec/scans/omny_fermat_scan.py +++ b/csaxs_bec/scans/omny_fermat_scan.py @@ -281,6 +281,7 @@ class OmnyFermatScan(ScanBase): step: float = 1, spiral_type: int = 0, center: bool = False, + seed: int | None = None, ): """ Calculate positions for a Fermat spiral scan. @@ -293,6 +294,9 @@ class OmnyFermatScan(ScanBase): step(float): stepsize spiral_type(int): 0 for traditional Fermat spiral center(bool): whether to include the center position + seed(int | None): seed for the per-call random sub-step offset (see below). + Pass a fixed value for reproducible output (e.g. in tests); leave as None + to draw a fresh offset every call. Returns: positions(array): positions @@ -308,15 +312,22 @@ class OmnyFermatScan(ScanBase): z_pos = self.zshift + # Randomize the sampling lattice's sub-step phase on every call (i.e. every + # projection), matching the legacy spec behaviour: this decorrelates position-tied + # reconstruction errors that would otherwise accumulate into ring artifacts across + # a tomographic series. + offset = np.random.default_rng(seed).uniform(0, step) + logger.info(f"Fermat spiral sub-step offset: {offset:.4f} um") + for ii in range(start, n_max): radius = step * 0.57 * np.sqrt(ii) + x = radius * np.sin(ii * phi) - offset + y = radius * np.cos(ii * phi) - offset # FOV is restructed below at check pos in range - if abs(radius * np.sin(ii * phi)) > length_axis1 / 2: + if abs(x) > length_axis1 / 2: continue - if abs(radius * np.cos(ii * phi)) > length_axis2 / 2: + if abs(y) > length_axis2 / 2: continue - x = radius * np.sin(ii * phi) - y = radius * np.cos(ii * phi) positions.append([x + self.cenx, y + self.ceny, z_pos]) left_lower_corner = [ min(m1_start, m1_stop) + self.cenx, diff --git a/docs/plans/fermat-scan-randomization.md b/docs/plans/fermat-scan-randomization.md index 48c4a15b..9c89e5a6 100644 --- a/docs/plans/fermat-scan-randomization.md +++ b/docs/plans/fermat-scan-randomization.md @@ -1,6 +1,20 @@ # Fix needed: restore random sub-step offset in Fermat spiral scans (OMNY, flOMNI, LamNI) -**Status:** Not started — investigation complete, fix not yet implemented. +**Status:** Implemented on `fixes/flomni_beamtime_2`, pending real/simulated-hardware +verification. All three generators now draw a fresh `np.random.default_rng(seed).uniform(0, +step)` offset per call and subtract it from the raw lattice `x`/`y` before the FOV crop check +(same scalar offset for both axes, matching the legacy spec). Decisions made on the open +questions below: no scan-metadata logging (a `logger.info` line is emitted instead); the +opt-out is a `seed: int | None = None` parameter (default `None` = fresh randomization); +x/y share one scalar offset. The four "preview/estimate" call sites that predict Fermat +point counts before a real scan runs (`flomni.py`/`lamni.py`'s +`_expected_fermat_position_count()`, `tomo_params.py`'s `_compute_fermat_positions_flomni`/ +`_compute_fermat_positions_lamni`) now pin `seed=0` so their own repeated-call estimates stay +reproducible; their docstrings note the real scan's count can differ by a point or two near +the threshold because of the randomization. Tests updated to match (`seed=0` on the +comparison calls in `test_fermat_position_warning.py`, `test_tomo_params_widget_math.py`, +`test_lamni_tomo_params_widget_math.py`); full suite passes (725 passed). + **Applies to:** all three setups — **OMNY, flOMNI, and LamNI**. The fix must be applied consistently across all three, not just one. ## Background diff --git a/tests/tests_bec_ipython_client/test_fermat_position_warning.py b/tests/tests_bec_ipython_client/test_fermat_position_warning.py index 36ac41ac..3c367a24 100644 --- a/tests/tests_bec_ipython_client/test_fermat_position_warning.py +++ b/tests/tests_bec_ipython_client/test_fermat_position_warning.py @@ -76,8 +76,10 @@ def test_flomni_expected_position_count_below_threshold(): count = flomni._expected_fermat_position_count() assert count < flomni._fermat_min_positions() - # matches calling the real scan-class algorithm directly - expected = FlomniFermatScan.get_flomni_fermat_spiral_pos(-2.5, 2.5, -2.5, 2.5, step=2.0) + # matches calling the real scan-class algorithm directly. _expected_fermat_position_count() + # pins seed=0 for its own reproducibility -- match it here rather than drawing an + # independent random offset. + expected = FlomniFermatScan.get_flomni_fermat_spiral_pos(-2.5, 2.5, -2.5, 2.5, step=2.0, seed=0) assert count == len(expected) @@ -104,7 +106,9 @@ def test_lamni_expected_position_count_below_threshold(): count = lamni._expected_fermat_position_count() assert count < lamni._fermat_min_positions() - # matches calling the real scan-class algorithm directly, center tile, angle 0 + # matches calling the real scan-class algorithm directly, center tile, angle 0. + # _expected_fermat_position_count() pins seed=0 for its own reproducibility -- match + # it here rather than drawing an independent random offset. expected = LamniFermatScan.get_lamni_fermat_spiral_pos( -2.5, 2.5, @@ -118,6 +122,7 @@ def test_lamni_expected_position_count_below_threshold(): fovx=5.0, fovy=5.0, fov_circular=0.0, + seed=0, ) assert count == len(expected) diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py index c424f754..bb4ab18b 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py @@ -182,6 +182,8 @@ def test_compute_fermat_positions_lamni_matches_scan_class_below_threshold(): count, min_positions = _compute_fermat_positions_lamni(params) assert min_positions == LamniFermatScan.MIN_POSITIONS assert count < min_positions + # _compute_fermat_positions_lamni() pins seed=0 for its own reproducibility -- match + # it here rather than drawing an independent random offset. expected = LamniFermatScan.get_lamni_fermat_spiral_pos( -2.5, 2.5, @@ -195,6 +197,7 @@ def test_compute_fermat_positions_lamni_matches_scan_class_below_threshold(): fovx=5.0, fovy=5.0, fov_circular=0.0, + seed=0, ) assert count == len(expected) diff --git a/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py index 1f1b1f6f..249dfed5 100644 --- a/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py +++ b/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py @@ -55,7 +55,9 @@ def test_compute_fermat_positions_flomni_matches_scan_class_below_threshold(): count, min_positions = _compute_fermat_positions_flomni(params) assert min_positions == FlomniFermatScan.MIN_POSITIONS assert count < min_positions - expected = FlomniFermatScan.get_flomni_fermat_spiral_pos(-2.5, 2.5, -2.5, 2.5, step=2.0) + # _compute_fermat_positions_flomni() pins seed=0 for its own reproducibility -- + # match it here rather than drawing an independent random offset. + expected = FlomniFermatScan.get_flomni_fermat_spiral_pos(-2.5, 2.5, -2.5, 2.5, step=2.0, seed=0) assert count == len(expected) @@ -63,7 +65,9 @@ def test_compute_fermat_positions_flomni_matches_scan_class_above_threshold(): params = {"fovx": 100.0, "fovy": 80.0, "tomo_shellstep": 1.0} count, min_positions = _compute_fermat_positions_flomni(params) assert count >= min_positions - expected = FlomniFermatScan.get_flomni_fermat_spiral_pos(-50.0, 50.0, -40.0, 40.0, step=1.0) + expected = FlomniFermatScan.get_flomni_fermat_spiral_pos( + -50.0, 50.0, -40.0, 40.0, step=1.0, seed=0 + ) assert count == len(expected) -- 2.54.0 From 8cd9235e122e70e909f02317aadc9c4b15e213ea Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 13:07:44 +0200 Subject: [PATCH 20/78] feat(flomni): add fermat_asymmetry (x/y spacing ratio) to Fermat scan Implements docs/plans/flomni-fermat-asymmetry.md as written: generate the spiral against an inflated fovy half-width (length_axis2 * fermat_asymmetry), then compress the accepted y-coordinate back down by the ratio before storing -- keeps x spacing fixed at step, scales y spacing by the ratio, preserves the requested FOV. Composes with the already-landed sub-step randomization: the offset is subtracted at the raw (pre-compression) scale on both axes, before the inflated-window check, so the offset's phase-shift stays proportional to the local spacing on both axes. Wired through the scan class (FlomniFermatScan), Flomni's global var + queue/CLI plumbing (_TOMO_SCAN_PARAM_NAMES, scan_kwargs, tomo_parameters() print/prompt, _set_default_tomo_params()), and the tomo_params Qt widget (new field with a "?" help button, following the widget's existing offsets-section help-button pattern). Added tests verifying the inflate/compress math directly (against an equivalent call with an inflated FOV and fermat_asymmetry=1.0, same seed) rather than via an approximate spacing-ratio heuristic -- a first attempt at the latter proved too noisy on a Fermat spiral to assert reliably. Updated the flOMNI user docs and the OMNY port TODO (out of scope here, explicitly excludes LamNI per the plan). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- .../plugins/flomni/flomni.py | 13 ++- .../AI_docs/TODO_fermat_asymmetry_port.md | 103 +++++++++++++----- .../widgets/tomo_params/tomo_params.py | 41 ++++++- csaxs_bec/scans/flomni_fermat_scan.py | 40 ++++++- docs/plans/flomni-fermat-asymmetry.md | 16 ++- docs/user/ptychography/flomni.md | 8 +- .../test_lamni_tomo_params_widget_math.py | 4 +- tests/tests_scans/test_flomni_fermat_scan.py | 60 ++++++++++ 8 files changed, 250 insertions(+), 35 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 887238e6..c329ef14 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -1859,6 +1859,7 @@ class Flomni( not reset here, as it is overwritten by the next alignment anyway. """ self.tomo_shellstep = 1 + self.fermat_asymmetry = 1.0 self.tomo_countingtime = 0.1 self.manual_shift_y = 0.0 self.single_point_random_shift_max = 0.0 @@ -2004,6 +2005,8 @@ class Flomni( tomo_shellstep = _GlobalVarParam(1) + fermat_asymmetry = _GlobalVarParam(1.0) + tomo_countingtime = _GlobalVarParam(0.1) manual_shift_y = _GlobalVarParam(0.0) @@ -3466,7 +3469,8 @@ class Flomni( ceny = sum_offset_y + stitch_y * (self.fovy - self.tomo_stitch_overlap) logger.info( f"scans.flomni_fermat_scan(fovx={self.fovx}, fovy={self.fovy}," - f" step={self.tomo_shellstep}, cenx={cenx}, ceny={ceny}," + f" step={self.tomo_shellstep}, fermat_asymmetry={self.fermat_asymmetry}," + f" cenx={cenx}, ceny={ceny}," f" zshift={sum_offset_z}, angle={angle}," f" exp_time={self.tomo_countingtime}, corridor_size={corridor_size})" ) @@ -3479,6 +3483,7 @@ class Flomni( fovx=self.fovx, fovy=self.fovy, step=self.tomo_shellstep, + fermat_asymmetry=self.fermat_asymmetry, cenx=cenx, ceny=ceny, zshift=sum_offset_z, @@ -3651,6 +3656,7 @@ class Flomni( spiral_type=0, center=False, seed=0, + fermat_asymmetry=self.fermat_asymmetry, ) return len(positions) @@ -3755,6 +3761,9 @@ class Flomni( else: self.tomo_countingtime = self._get_val(" s", self.tomo_countingtime, float) self.tomo_shellstep = self._get_val(" um", self.tomo_shellstep, float) + self.fermat_asymmetry = self._get_val( + "", self.fermat_asymmetry, float + ) self.fovx = self._get_val(" um", self.fovx, float) self.fovy = self._get_val(" um", self.fovy, float) if self.single_point_instead_of_fermat_scan: @@ -3908,6 +3917,7 @@ class Flomni( _TOMO_SCAN_PARAM_NAMES = ( "tomo_countingtime", "tomo_shellstep", + "fermat_asymmetry", "fovx", "fovy", "stitch_x", @@ -4133,6 +4143,7 @@ class Flomni( f"{'Current photon energy:':<{padding}}{energy_str}\n", f"{'Exposure time:':<{padding}}{self.tomo_countingtime:.2f}\n", f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:.2f}\n", + f"{'Fermat asymmetry (x/y):':<{padding}}{self.fermat_asymmetry:.2f}\n", f"{'FOV:':<{padding}}{fovxy}\n", f"{'Stitching:':<{padding}}{stitching}\n", f"{subtomo_count_label:<{padding}}{subtomo_count_value}\n", diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md index 502aa887..dd5684e4 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md @@ -1,40 +1,91 @@ -# TODO: port `fermat_asymmetry` (x/y spacing ratio) to OMNY once it lands on Flomni +# TODO: port `fermat_asymmetry` (x/y spacing ratio) to OMNY now that it's landed on Flomni -Flomni is getting a new Fermat-spiral scan parameter, `fermat_asymmetry` -(ratio of x spacing to y spacing; default 1.0 = symmetric, keeps x spacing -fixed and scales y spacing by the ratio while preserving the requested FOV). -See `docs/plans/flomni-fermat-asymmetry.md` for the full design and the -exact math. +**Status: `fermat_asymmetry` is now implemented on Flomni** (branch +`fixes/flomni_beamtime_2`), not yet ported to OMNY. See +`docs/plans/flomni-fermat-asymmetry.md` for the original design (the +implementation follows it as written) and `csaxs_bec/scans/flomni_fermat_scan.py` +for the actual landed code (`get_flomni_fermat_spiral_pos`, line ~420 at +time of writing; `MIN_FERMAT_ASYMMETRY`/`MAX_FERMAT_ASYMMETRY` class +constants, line ~70). + +`fermat_asymmetry` is a ratio of x spacing to y spacing; default `1.0` = +symmetric, keeps x spacing fixed at `step` and scales y spacing by the ratio +while preserving the requested FOV. Soft-clamped to `[0.1, 10]` (with a +`logger.warning` when clamped), same style as `zshift`/`corridor_size`. + +## Important: compose with the already-ported sub-step randomization + +Before this, `fixes/flomni_beamtime_2` already ported a *different* fix to +all three setups including OMNY: a per-call random sub-step offset (see +`docs/plans/fermat-scan-randomization.md`). `OmnyFermatScan.get_omny_fermat_spiral_pos` +(`csaxs_bec/scans/omny_fermat_scan.py`) already has a `seed: int | None = None` +parameter and draws `offset = np.random.default_rng(seed).uniform(0, step)`, +subtracted from both `x` and `y` before the FOV crop check -- this landed +*before* fermat_asymmetry and is unrelated to it, but the two must compose +correctly when porting fermat_asymmetry to OMNY. + +Flomni's landed implementation composes them as: `x = radius*sin(...) - +offset` (unchanged); `y_raw = radius*cos(...) - offset` (offset subtracted +at the *raw*, pre-compression scale, same as x); check `abs(y_raw) > +(length_axis2 * fermat_asymmetry) / 2` (inflated window); on accept, `y = +y_raw / fermat_asymmetry` (compression happens *after* the offset and the +check, not before). This keeps the offset's phase-shift proportional to the +local spacing on both axes (x spacing = step, y spacing = step/fermat_asymmetry, +offset/fermat_asymmetry is the resulting y-phase-shift once compressed) -- +port this exact ordering (offset subtraction -> inflated check -> compression), +not just the offset or the asymmetry in isolation. + +## What needs to happen OMNY's `OmnyFermatScan.get_omny_fermat_spiral_pos` -(`csaxs_bec/scans/omny_fermat_scan.py:275-333`) uses the byte-for-byte same -spiral algorithm as Flomni's `get_flomni_fermat_spiral_pos` (`radius = -step * 0.57 * sqrt(ii)`, same FOV-window filter), so the same change ports -directly: add `fermat_asymmetry` to `OmnyFermatScan.__init__` -(`omny_fermat_scan.py:63-79`), thread it through -`get_omny_fermat_spiral_pos`, and mirror the CLI-side plumbing on `OMNY` -(`csaxs_bec/bec_ipython_client/plugins/omny/omny.py`): a -`tomo_shellstep`-style property pair (OMNY hand-writes -`get_global_var`/`set_global_var` property pairs, e.g. `omny.py:671-679`, -rather than using the `_GlobalVarParam` descriptor Flomni uses), the -`scan_kwargs` dict in the per-angle scan call (~`omny.py:1282-1284`), and -`tomo_parameters()` (~`omny.py:1293-1341`). +(`csaxs_bec/scans/omny_fermat_scan.py`) uses the byte-for-byte same spiral +algorithm as Flomni's `get_flomni_fermat_spiral_pos` (`radius = step * 0.57 +* sqrt(ii)`, same FOV-window filter, same offset-subtraction-before-check +shape already ported), so the same change ports directly: + +1. Add `MIN_FERMAT_ASYMMETRY = 0.1` / `MAX_FERMAT_ASYMMETRY = 10.0` class + constants to `OmnyFermatScan`, mirroring Flomni's. +2. Add `fermat_asymmetry` to `OmnyFermatScan.__init__` + (`omny_fermat_scan.py:63-79`) as a new `ScanArgument`, store it, pass it + through in `prepare_scan()`'s call to `get_omny_fermat_spiral_pos`. +3. Add the clamp + inflated-window-check + y-compression logic to + `get_omny_fermat_spiral_pos` itself (same reasoning as Flomni: it's also + called directly by preview/estimate code, so clamping there keeps every + caller in sync automatically) -- apply it on top of the existing offset + logic exactly as described above, not before it. +4. Mirror the CLI-side plumbing on `OMNY` + (`csaxs_bec/bec_ipython_client/plugins/omny/omny.py`): a + `tomo_shellstep`-style property pair (OMNY hand-writes + `get_global_var`/`set_global_var` property pairs, e.g. `omny.py:671-679`, + rather than using the `_GlobalVarParam` descriptor Flomni uses), the + `scan_kwargs` dict in the per-angle scan call (~`omny.py:1282-1284`), and + `tomo_parameters()` (~`omny.py:1293-1341`). ## Difference from Flomni to account for when porting -OMNY has no tomo-queue system — `OMNY` does not mix in `TomoQueueMixin` and +OMNY has no tomo-queue system -- `OMNY` does not mix in `TomoQueueMixin` and has no `_TOMO_SCAN_PARAM_NAMES` (unlike `Flomni`/`LamNI`). So there is no queue-snapshot list to add the new parameter to on OMNY's side, and no `tomo_params` Qt widget profile for OMNY today either (`SETUP_PROFILES` in -`tomo_params.py` only has `"flomni"` and `"lamni"` entries) — the widget -part of the Flomni plan doesn't apply until/unless OMNY gets its own queue -+ widget support. Scope the OMNY port to: the scan class parameter, the -global var, and `tomo_parameters()` display/prompt only. +`tomo_params.py` only has `"flomni"` and `"lamni"` entries) -- the widget +part of the Flomni implementation (the "Fermat asymmetry (x/y)" field with +its "?" help button in `_build_fermat_asymmetry_row`/ +`_show_fermat_asymmetry_help`) doesn't apply until/unless OMNY gets its own +queue + widget support. Scope the OMNY port to: the scan class parameter, +the global var, and `tomo_parameters()` display/prompt only. + +## Tests + +Mirror the three new cases added to `tests/tests_scans/test_flomni_fermat_scan.py` +(`test_fermat_asymmetry_default_matches_symmetric_spiral`, +`test_fermat_asymmetry_biases_spacing_while_preserving_fov`, +`test_fermat_asymmetry_out_of_range_is_clamped_not_raised`) in +`tests/tests_scans/test_omny_fermat_scan.py` -- same technique (compare +against an equivalent call with an inflated FOV and `fermat_asymmetry=1.0`, +same `seed`, to verify the inflate/compress math directly rather than via +an approximate spacing-ratio heuristic). ## Explicitly not for LamNI This feature was scoped to Flomni (and, via this TODO, OMNY) only; do not add it to `LamniFermatScan` / `LamNI` as part of this port. - -Not scoped/designed further here beyond the pointers above — flagging it so -it isn't lost, same as the other `TODO_*.md` files in this folder. diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index 188c8fb3..a23613d0 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -127,6 +127,7 @@ _QUEUE_IDLE_STATUSES = ("STOPPED", "COMPLETED", "IDLE") QUEUE_PARAM_NAMES = ( "tomo_countingtime", "tomo_shellstep", + "fermat_asymmetry", "fovx", "fovy", "stitch_x", @@ -151,6 +152,7 @@ QUEUE_PARAM_NAMES = ( DEFAULTS: dict[str, Any] = { "tomo_countingtime": 0.1, "tomo_shellstep": 1.0, + "fermat_asymmetry": 1.0, "fovx": 20.0, "fovy": 20.0, "stitch_x": 0, @@ -468,6 +470,7 @@ class OMNY_TomoParams(BECWidget, QWidget): max_=1000.0, decimals=3, ), + "fermat_asymmetry": lambda: self._build_fermat_asymmetry_row(common_form), "fov": lambda: self._build_double_group(common_form, self._profile["fov_fields"]), "stitch": lambda: self._build_int_group(common_form, self._profile["stitch_fields"]), "tomo_stitch_overlap": lambda: self._add_double( @@ -586,6 +589,39 @@ class OMNY_TomoParams(BECWidget, QWidget): "constant shift that will not rotate.", ) + def _build_fermat_asymmetry_row(self, form: QFormLayout) -> None: + """Fermat asymmetry (x/y spacing ratio) field, with a "?" help button + next to it -- same pattern as the offsets section header + (_build_offsets_section/_show_offsets_help above).""" + from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan + + row = QHBoxLayout() + w = QDoubleSpinBox() + w.setDecimals(2) + w.setRange(FlomniFermatScan.MIN_FERMAT_ASYMMETRY, FlomniFermatScan.MAX_FERMAT_ASYMMETRY) + w.setEnabled(False) + self._pw["fermat_asymmetry"] = w + row.addWidget(w, stretch=1) + btn_help = QPushButton("?") + btn_help.setFixedWidth(24) + btn_help.setToolTip("What does Fermat asymmetry do?") + btn_help.clicked.connect(self._show_fermat_asymmetry_help) + row.addWidget(btn_help) + form.addRow("Fermat asymmetry (x/y):", row) + + def _show_fermat_asymmetry_help(self) -> None: + QMessageBox.information( + self, + "Fermat asymmetry", + "Ratio of the Fermat spiral's x spacing to its y spacing (x/y). 1 = " + "symmetric (default). x spacing always stays fixed at the shell step; " + "y spacing scales by this ratio -- values above 1 widen x spacing " + "relative to y (denser sampling in y), values below 1 do the " + "opposite. The requested field of view is preserved either way, so " + "the total number of scan points changes with the ratio (see the " + "'Estimated Fermat scan points' preview below).", + ) + def _build_at_each_angle_hook_row(self, form: QFormLayout) -> None: self._pw["at_each_angle_hook"] = QComboBox() self._pw["at_each_angle_hook"].setEnabled(False) @@ -2186,6 +2222,7 @@ def _compute_fermat_positions_flomni(params: dict[str, Any]) -> tuple[int, int]: fovx = params.get("fovx", 0.0) fovy = params.get("fovy", 0.0) step = params.get("tomo_shellstep", 0.0) + fermat_asymmetry = params.get("fermat_asymmetry", 1.0) if step <= 0: return 0, FlomniFermatScan.MIN_POSITIONS positions = FlomniFermatScan.get_flomni_fermat_spiral_pos( @@ -2196,6 +2233,7 @@ def _compute_fermat_positions_flomni(params: dict[str, Any]) -> tuple[int, int]: step=step, spiral_type=0, seed=0, + fermat_asymmetry=fermat_asymmetry, ) return len(positions), FlomniFermatScan.MIN_POSITIONS @@ -2298,6 +2336,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { "field_order": [ "tomo_countingtime", "tomo_shellstep", + "fermat_asymmetry", "fov", "stitch", "tomo_stitch_overlap", @@ -2311,7 +2350,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { "compute_type1": _compute_type1, "requested_to_stepsize": _requested_to_stepsize, "compute_fermat_positions": _compute_fermat_positions_flomni, - "fermat_position_fields": ["tomo_shellstep", "fovx", "fovy"], + "fermat_position_fields": ["tomo_shellstep", "fermat_asymmetry", "fovx", "fovy"], "sample_name_getter": lambda w: w.dev.flomni_samples.sample_names.sample0.get(), "cli_hint_name": "flomni", }, diff --git a/csaxs_bec/scans/flomni_fermat_scan.py b/csaxs_bec/scans/flomni_fermat_scan.py index 3700bdf3..787cdda5 100644 --- a/csaxs_bec/scans/flomni_fermat_scan.py +++ b/csaxs_bec/scans/flomni_fermat_scan.py @@ -57,6 +57,7 @@ class FlomniFermatScan(ScanBase): "cenx", "ceny", "step", + "fermat_asymmetry", "zshift", "angle", "corridor_size", @@ -66,6 +67,8 @@ class FlomniFermatScan(ScanBase): MIN_POSITIONS = 20 # Minimum number of positions required for a valid scan MAX_CORRIDOR_SIZE = 3 # Corridor size is capped at this value (in um) for stability + MIN_FERMAT_ASYMMETRY = 0.1 # Practical working range for fermat_asymmetry (soft-clamped) + MAX_FERMAT_ASYMMETRY = 10.0 def __init__( # fmt: off @@ -78,6 +81,7 @@ class FlomniFermatScan(ScanBase): zshift: Annotated[float, ScanArgument(display_name="Zshift", description="Shift in z. ", units=Units.µm, example=0)], angle: Annotated[float, ScanArgument(display_name="Angle", description="Rotation angle (will rotate first)", units=Units.deg, example=0)], corridor_size: Annotated[float | None, ScanArgument(display_name="Corridor Size", description="Corridor size for the corridor optimization.", units=Units.µm)] = None, + fermat_asymmetry: Annotated[float, ScanArgument(display_name="Fermat Asymmetry", description="Ratio of x spacing to y spacing (x/y). 1 = symmetric. >1 widens x spacing / tightens y spacing; <1 the opposite. FOV is preserved; point count changes with the ratio.", gt=0, example=1)] = 1.0, exp_time: DefaultArgType.ExposureTime = 0.1, frames_per_trigger: DefaultArgType.FramesPerTrigger = 1, burst_at_each_point: DefaultArgType.BurstAtEachPoint = 1, @@ -96,6 +100,9 @@ class FlomniFermatScan(ScanBase): zshift (float): Shift in z. angle (float): Rotation angle (will rotate first) corridor_size (float | None): Corridor size for the corridor optimization. + fermat_asymmetry (float): Ratio of x spacing to y spacing (x/y). 1 = symmetric. + x spacing stays fixed at step, y spacing scales by this ratio. The FOV is + preserved, so the point count changes with the ratio. exp_time (float): Exposure time in seconds frames_per_trigger (int): Number of frames per trigger for devices that support configurable frame counts per trigger. burst_at_each_point (int): Number of triggers and readouts at each point. @@ -113,6 +120,7 @@ class FlomniFermatScan(ScanBase): self.zshift = zshift self.angle = angle self.corridor_size = corridor_size + self.fermat_asymmetry = fermat_asymmetry self.exp_time = exp_time self.frames_per_trigger = frames_per_trigger self.burst_at_each_point = burst_at_each_point @@ -158,6 +166,7 @@ class FlomniFermatScan(ScanBase): cenx=self.cenx, ceny=self.ceny, zshift=self.zshift, + fermat_asymmetry=self.fermat_asymmetry, ) if len(positions) < self.MIN_POSITIONS: @@ -420,6 +429,7 @@ class FlomniFermatScan(ScanBase): ceny: float = 0.0, zshift: float = 0.0, seed: int | None = None, + fermat_asymmetry: float = 1.0, ): """ Calculate positions for a Fermat spiral scan. @@ -435,10 +445,27 @@ class FlomniFermatScan(ScanBase): seed(int | None): seed for the per-call random sub-step offset (see below). Pass a fixed value for reproducible output (e.g. in tests); leave as None to draw a fresh offset every call. + fermat_asymmetry(float): ratio of x spacing to y spacing (x/y). 1 = symmetric + (default). x spacing stays fixed at step; y spacing scales by this ratio. + The requested FOV (m1/m2 range) is preserved, so the point count changes + with the ratio. Soft-clamped to [MIN_FERMAT_ASYMMETRY, MAX_FERMAT_ASYMMETRY]. Returns: positions(array): positions """ + if fermat_asymmetry < FlomniFermatScan.MIN_FERMAT_ASYMMETRY: + logger.warning( + f"fermat_asymmetry is smaller than {FlomniFermatScan.MIN_FERMAT_ASYMMETRY}. It" + f" will be limited to {FlomniFermatScan.MIN_FERMAT_ASYMMETRY}." + ) + fermat_asymmetry = FlomniFermatScan.MIN_FERMAT_ASYMMETRY + elif fermat_asymmetry > FlomniFermatScan.MAX_FERMAT_ASYMMETRY: + logger.warning( + f"fermat_asymmetry is larger than {FlomniFermatScan.MAX_FERMAT_ASYMMETRY}. It" + f" will be limited to {FlomniFermatScan.MAX_FERMAT_ASYMMETRY}." + ) + fermat_asymmetry = FlomniFermatScan.MAX_FERMAT_ASYMMETRY + positions = [] phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2.0) + spiral_type * np.pi @@ -446,7 +473,9 @@ class FlomniFermatScan(ScanBase): length_axis1 = np.abs(m1_stop - m1_start) length_axis2 = np.abs(m2_stop - m2_start) - n_max = compute_fermat_spiral_n_max(length_axis1, length_axis2, step) + # y is checked against an inflated half-width (see below), so the spiral must be + # grown far enough to still cover the real fovy after the y-compression. + n_max = compute_fermat_spiral_n_max(length_axis1, length_axis2 * fermat_asymmetry, step) z_pos = zshift @@ -460,12 +489,17 @@ class FlomniFermatScan(ScanBase): for ii in range(start, n_max): radius = step * 0.57 * np.sqrt(ii) x = radius * np.sin(ii * phi) - offset - y = radius * np.cos(ii * phi) - offset + # y_raw is the pre-compression lattice coordinate: kept at the isotropic + # spacing/phase so the crop check below (against the inflated fovy) sees the + # same sub-step randomization as x, then compressed by fermat_asymmetry + # afterwards -- x spacing stays fixed at step, y spacing becomes step/fermat_asymmetry. + y_raw = radius * np.cos(ii * phi) - offset # FOV is restructed below at check pos in range if abs(x) > length_axis1 / 2: continue - if abs(y) > length_axis2 / 2: + if abs(y_raw) > (length_axis2 * fermat_asymmetry) / 2: continue + y = y_raw / fermat_asymmetry positions.append([x + cenx, y + ceny, z_pos]) left_lower_corner = [min(m1_start, m1_stop) + cenx, min(m2_start, m2_stop) + ceny, z_pos] right_upper_corner = [max(m1_start, m1_stop) + cenx, max(m2_start, m2_stop) + ceny, z_pos] diff --git a/docs/plans/flomni-fermat-asymmetry.md b/docs/plans/flomni-fermat-asymmetry.md index 0ea0e338..940eab62 100644 --- a/docs/plans/flomni-fermat-asymmetry.md +++ b/docs/plans/flomni-fermat-asymmetry.md @@ -1,7 +1,19 @@ # Plan: `fermat_asymmetry` (x/y spacing ratio) for the Flomni Fermat scan -Status: **not implemented** (written during live beamtime operation on request; no -code changes made — see conversation this plan came from). +Status: **implemented on `fixes/flomni_beamtime_2`**, pending real/simulated-hardware +verification. Followed this plan as written: scan class (`FlomniFermatScan`), the +`fermat_asymmetry` global var on `Flomni`, the queue/CLI plumbing (`_TOMO_SCAN_PARAM_NAMES`, +`scan_kwargs`, `tomo_parameters()` print/prompt, `_set_default_tomo_params()`), and the +`tomo_params` Qt widget (field + a "?" help button next to it, following the widget's +existing offsets-section help-button pattern -- `_build_fermat_asymmetry_row`/ +`_show_fermat_asymmetry_help`). Tests added to `tests/tests_scans/test_flomni_fermat_scan.py` +verify the inflate/compress math directly (against an equivalent call with an inflated FOV +and `fermat_asymmetry=1.0`) rather than via an approximate spacing-ratio heuristic, which a +first attempt showed is too noisy on a Fermat spiral. Also updated flOMNI's user +documentation (`docs/user/ptychography/flomni.md`) and the OMNY port TODO +(`csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md`) to +describe how this composes with the already-landed sub-step randomization (see +`docs/plans/fermat-scan-randomization.md`). Full test suite passes (728 passed). ## Context diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index c078b3e5..4bc4fba7 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -223,6 +223,7 @@ The basic scan function can be called by `scans.flomni_fermat_scan()` and offers | cenx (float) | center position in x | | ceny (float) | center position in y | | step (float) | stepsize | +| fermat_asymmetry (float) | ratio of x spacing to y spacing (x/y). 1 = symmetric (default). x spacing stays fixed at `step`; y spacing scales by this ratio — e.g. `2.0` doubles x spacing relative to y (denser sampling in y), `0.5` does the opposite. The requested FOV is preserved, so the total point count changes with the ratio. Soft-clamped to `[0.1, 10]`. Useful for samples/features elongated in one direction. | | zshift (float) | shift in z | | angle (float) | rotation angle (will rotate first) | | corridor_size (float) | corridor size for the corridor optimization. Default 3 um (auto-estimated if not provided) | @@ -231,7 +232,12 @@ The basic scan function can be called by `scans.flomni_fermat_scan()` and offers | burst_at_each_point (int) | Number of triggers and readouts at each point | Example: -`scans.flomni_fermat_scan(fovx=10, fovy=10, cenx=0, ceny=0, step=1, zshift=0, angle=0, exp_time=0.1, frames_per_trigger=1, burst_at_each_point=1)` +`scans.flomni_fermat_scan(fovx=10, fovy=10, cenx=0, ceny=0, step=1, fermat_asymmetry=1.0, zshift=0, angle=0, exp_time=0.1, frames_per_trigger=1, burst_at_each_point=1)` + +The Fermat spiral's sub-step phase is also randomized on every projection (drawn +fresh per scan, not user-configurable) to decorrelate position-tied reconstruction +errors that would otherwise accumulate into ring artifacts across a tomographic +series. `frames_per_trigger` controls burst acquisition for detectors generally. Separately, the OMNY PandABox is configured as one of two distinct devices in diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py index bb4ab18b..7b7b06f4 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py @@ -137,10 +137,12 @@ def test_lamni_offset_fields_excluded_from_param_names(): def test_flomni_field_order_matches_pre_reorder_layout(): """Regression guard: the field_order dispatch must reproduce flomni's - exact, unchanged field build order (this task only reordered lamni).""" + build order (this task only reordered lamni). Updated to include + fermat_asymmetry, added right after tomo_shellstep.""" assert SETUP_PROFILES["flomni"]["field_order"] == [ "tomo_countingtime", "tomo_shellstep", + "fermat_asymmetry", "fov", "stitch", "tomo_stitch_overlap", diff --git a/tests/tests_scans/test_flomni_fermat_scan.py b/tests/tests_scans/test_flomni_fermat_scan.py index 83da7b9b..4bfd206d 100644 --- a/tests/tests_scans/test_flomni_fermat_scan.py +++ b/tests/tests_scans/test_flomni_fermat_scan.py @@ -59,3 +59,63 @@ def test_flomni_fermat_spiral_reaches_elongated_fov_corners(): loop_points = pos[:-2] # exclude the two corner points appended after the loop assert np.max(np.abs(loop_points[:, 0])) > 0.95 * (fovx / 2) assert len(pos) > FlomniFermatScan.MIN_POSITIONS + + +def test_fermat_asymmetry_default_matches_symmetric_spiral(): + # fermat_asymmetry=1.0 (or omitted) must reproduce exactly today's positions -- + # no-op / backward-compatibility check. Same seed on both calls isolates this + # from the (unrelated) per-call random sub-step offset. + fovx, fovy = 10.0, 10.0 + default_call = FlomniFermatScan.get_flomni_fermat_spiral_pos( + -fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=1, seed=0 + ) + explicit_symmetric = FlomniFermatScan.get_flomni_fermat_spiral_pos( + -fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=1, seed=0, fermat_asymmetry=1.0 + ) + np.testing.assert_array_equal(default_call, explicit_symmetric) + + +def test_fermat_asymmetry_biases_spacing_while_preserving_fov(): + # Directly verifies the documented "generate against an inflated fovy, then + # compress y back down" design: get_flomni_fermat_spiral_pos(..., fovy, + # fermat_asymmetry=ratio) must select exactly the same raw lattice points as + # get_flomni_fermat_spiral_pos(..., fovy*ratio, fermat_asymmetry=1.0) (same + # seed => same random sub-step offset), just with y compressed by `ratio`. + fovx, fovy, step = 40.0, 40.0, 1.0 + for ratio in (2.0, 0.5): + asym = FlomniFermatScan.get_flomni_fermat_spiral_pos( + -fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=step, seed=42, fermat_asymmetry=ratio + ) + inflated_symmetric = FlomniFermatScan.get_flomni_fermat_spiral_pos( + -fovx / 2, fovx / 2, -(fovy * ratio) / 2, (fovy * ratio) / 2, step=step, seed=42 + ) + asym_loop = asym[:-2] + inflated_loop = inflated_symmetric[:-2] + + assert len(asym_loop) == len(inflated_loop) + np.testing.assert_allclose(asym_loop[:, 0], inflated_loop[:, 0]) + np.testing.assert_allclose(asym_loop[:, 1] * ratio, inflated_loop[:, 1]) + + # FOV preserved + assert np.max(np.abs(asym_loop[:, 0])) <= fovx / 2 + assert np.max(np.abs(asym_loop[:, 1])) <= fovy / 2 + + +def test_fermat_asymmetry_out_of_range_is_clamped_not_raised(): + fovx, fovy, step = 10.0, 10.0, 1.0 + pos_too_large = FlomniFermatScan.get_flomni_fermat_spiral_pos( + -fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=step, seed=0, fermat_asymmetry=100.0 + ) + pos_clamped = FlomniFermatScan.get_flomni_fermat_spiral_pos( + -fovx / 2, + fovx / 2, + -fovy / 2, + fovy / 2, + step=step, + seed=0, + fermat_asymmetry=FlomniFermatScan.MAX_FERMAT_ASYMMETRY, + ) + np.testing.assert_array_equal(pos_too_large, pos_clamped) + loop_points = pos_too_large[:-2] + assert np.max(np.abs(loop_points[:, 0])) <= fovx / 2 + assert np.max(np.abs(loop_points[:, 1])) <= fovy / 2 -- 2.54.0 From 695eac3706459db65c2fb690d2c7e5f8fe77e3bc Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 13:46:27 +0200 Subject: [PATCH 21/78] docs(flomni): document fermat_asymmetry's effective x/y spacing Add an intuitive spacing table (x spacing stays at step, y spacing becomes step/fermat_asymmetry) with a worked example, derived from measuring actual generated positions (area-per-point vs. point count, averaged over 20 randomized draws per ratio) rather than just stating the formula. Mirrored into the OMNY port TODO so the same relationship gets documented there once ported. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- .../omny/AI_docs/TODO_fermat_asymmetry_port.md | 12 ++++++++++++ docs/user/ptychography/flomni.md | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md index dd5684e4..140f989b 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md @@ -13,6 +13,18 @@ symmetric, keeps x spacing fixed at `step` and scales y spacing by the ratio while preserving the requested FOV. Soft-clamped to `[0.1, 10]` (with a `logger.warning` when clamped), same style as `zshift`/`corridor_size`. +**Effective spacing (verified against generated data, see +`docs/plans/flomni-fermat-asymmetry.md`'s conversation for the measurement +method):** x spacing always stays at `step`; y spacing becomes `step / +fermat_asymmetry`. E.g. with `step=1 um`, `fovx=fovy=10 um`: `fermat_asymmetry=0.7` +-> x spacing 1.0 um, y spacing ~1.43 um (sparser), ~70 points; +`fermat_asymmetry=1.3` -> x spacing 1.0 um, y spacing ~0.77 um (denser), +~130 points. Point count scales with the ratio since the FOV is fixed but +the density isn't. Same relationship should be documented for OMNY once +ported (Flomni's user doc has this table -- see +`docs/user/ptychography/flomni.md`, "Effective spacing with +`fermat_asymmetry`" -- OMNY's equivalent doc, if any, should get the same). + ## Important: compose with the already-ported sub-step randomization Before this, `fixes/flomni_beamtime_2` already ported a *different* fix to diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index 4bc4fba7..932cf613 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -234,6 +234,18 @@ The basic scan function can be called by `scans.flomni_fermat_scan()` and offers Example: `scans.flomni_fermat_scan(fovx=10, fovy=10, cenx=0, ceny=0, step=1, fermat_asymmetry=1.0, zshift=0, angle=0, exp_time=0.1, frames_per_trigger=1, burst_at_each_point=1)` +**Effective spacing with `fermat_asymmetry`:** the x spacing always stays at `step`; +the y spacing becomes `step / fermat_asymmetry`. So with `step=1 um`: + +| fermat_asymmetry | x spacing | y spacing | points (fovx=fovy=10 um example) | +| --- | --- | --- | --- | +| 0.7 | 1.0 um | ~1.43 um (sparser) | ~70 | +| 1.0 (default) | 1.0 um | 1.0 um | ~100 | +| 1.3 | 1.0 um | ~0.77 um (denser) | ~130 | + +The requested FOV stays exactly as specified either way -- only the point density +(and therefore the point count and scan duration) changes. + The Fermat spiral's sub-step phase is also randomized on every projection (drawn fresh per scan, not user-configurable) to decorrelate position-tied reconstruction errors that would otherwise accumulate into ring artifacts across a tomographic -- 2.54.0 From e0cb504c900065b9301b8e273d28aca2e2329774 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 14:22:09 +0200 Subject: [PATCH 22/78] fix(flomni): print fermat_asymmetry in tomo_parameters() CLI display The earlier fermat_asymmetry commit added it to the wrong "Current settings" printout -- the scilog/permanent-record summary block further down in the file, not the interactive tomo_parameters() display operators actually look at before confirming/editing settings. Caught live: the prompt asked for it but the summary above never showed it. Add the missing print line, column-aligned with the existing ones (verified: '=' lands at the same column as every other line in the block). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py | 1 + 1 file changed, 1 insertion(+) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index c329ef14..5cfda171 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -3673,6 +3673,7 @@ class Flomni( print("Current settings:") print(f"Counting time = {self.tomo_countingtime} s") print(f"Stepsize microns = {self.tomo_shellstep}") + print(f"Fermat asymmetry (x/y) = {self.fermat_asymmetry}") print(f"FOV (220/100) = {self.fovx}, {self.fovy}") print(f"Stitching number x,y = {self.stitch_x}, {self.stitch_y}") print(f"Stitching overlap = {self.tomo_stitch_overlap}") -- 2.54.0 From 0690fbb391fc9767defc3e8563edd03eb48106c3 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 14:22:20 +0200 Subject: [PATCH 23/78] feat(flomni): auto-lock ROI vertical center to FZP height in x-ray eye alignment Implements docs/plans/xrayeye-vertical-lock.md: once the height-centering step fixes the sample's vertical position against the FZP crosshair, every selection box drawn for the remaining angle-alignment steps (1-5) no longer needs manual vertical placement -- it snaps to the crosshair's y position on draw and again on every drag/resize. - OMNY_XRayEye: new _vertical_lock_enabled state, lock_vertical_center()/ unlock_vertical_center() RPC methods, _snap_roi_vertical() helper wired into _style_new_roi() (both roiAdded and sigRegionChangeFinished). Snap is idempotent (no-op once already at the target y) so it can't loop back into itself via its own set_position() call. - Also added a "ROI vertical lock" toggle in the control panel so an operator can release/re-engage the lock manually mid-run -- e.g. to freely draw/measure an unrelated box without aborting the alignment. Both the toggle and the RPC methods stay in sync (blockSignals pattern already used elsewhere in this file for on_live_view_enabled). - x_ray_eye_align.py: lock_vertical_center() right after _height_centered flips to True; unlock_vertical_center() at the start of every fresh run (so a run never inherits a stale lock) and in align()'s finally block (so the lock never outlives a run, including on error/interruption). FZP box (step 0) and the height-centering box itself remain fully free -- the lock isn't engaged yet at that point. LamNI/OMNY have their own near-identical scripts but are explicitly out of scope (per the plan); the widget change is inert by default. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- .../plugins/flomni/x_ray_eye_align.py | 12 +++ .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 86 +++++++++++++++ .../test_x_ray_eye_vertical_lock.py | 102 ++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 tests/tests_bec_widgets/test_x_ray_eye_vertical_lock.py diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py index 87e0fbfc..e74598f1 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py @@ -172,6 +172,10 @@ class XrayEyeAlign: self.gui.hide_crosshair() except Exception as exc: # pylint: disable=broad-except logger.warning(f"Failed to hide XRayEye alignment crosshair: {exc}") + try: + self.gui.unlock_vertical_center() + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Failed to unlock XRayEye ROI vertical center: {exc}") def _align_impl(self, keep_shutter_open=False): if not keep_shutter_open: @@ -194,6 +198,9 @@ class XrayEyeAlign: # reset shift xy and fov params self._reset_init_values() + # a fresh run must not inherit a stale lock from a previous (possibly + # interrupted) run -- see lock_vertical_center()/unlock_vertical_center() + self.gui.unlock_vertical_center() # Moved here from __init__: this is the actual start of a fresh # alignment run (matching what start_x_ray_eye_alignment() already @@ -332,6 +339,11 @@ class XrayEyeAlign: self.flomni.feedback_enable_with_reset() self._height_centered = True + # From here through step 5, the sample's vertical position is + # physically fixed at the FZP height -- auto-snap every new/ + # dragged/resized selection box's vertical center to the + # crosshair instead of requiring manual placement each time. + self.gui.lock_vertical_center() self.update_frame(keep_shutter_open) self.send_message("Step 1/5: Submit sample center") diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index fe5f6a74..d4e8a924 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -251,6 +251,8 @@ class OMNY_XRayEye(BECWidget, QWidget): "submit_fit_array", "show_crosshair", "hide_crosshair", + "lock_vertical_center", + "unlock_vertical_center", "crosshair_visible", "crosshair_visible.setter", "set_crosshair_position", @@ -297,6 +299,8 @@ class OMNY_XRayEye(BECWidget, QWidget): self._init_ui() self.target_crosshair = TargetCrosshair(self.image.plot_item) + # See lock_vertical_center()/unlock_vertical_center()/_snap_roi_vertical() + self._vertical_lock_enabled = False self._make_connections() # Connection to redis endpoints @@ -406,6 +410,17 @@ class OMNY_XRayEye(BECWidget, QWidget): self.smear_active_toggle.checked = False self.smear_active_toggle.setEnabled(False) # read-only status, not operator-togglable + # Manual override for the ROI-vertical-lock feature (see + # lock_vertical_center()/unlock_vertical_center()): a script (e.g. + # x_ray_eye_align.py) engages the lock automatically during the + # angle-alignment steps, but an operator may still want to freely + # draw/measure an unrelated box mid-run -- this toggle lets them + # release it without aborting the run, and re-engage it after. + self.vertical_lock_label = QLabel("ROI vertical lock", parent=self) + self.vertical_lock_toggle = ToggleSwitch(parent=self) + self.vertical_lock_toggle.checked = False + self.vertical_lock_toggle.enabled.connect(self._on_vertical_lock_toggle_changed) + _right_vcenter = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter switch_grid.addWidget(self.shutter_label, 0, 1, _right_vcenter) switch_grid.addWidget(self.shutter_toggle, 0, 2, Qt.AlignmentFlag.AlignVCenter) @@ -415,6 +430,8 @@ class OMNY_XRayEye(BECWidget, QWidget): switch_grid.addWidget(self.smear_active_toggle, 1, 2, Qt.AlignmentFlag.AlignVCenter) switch_grid.addWidget(self.smear_preview_label, 1, 3, _right_vcenter) switch_grid.addWidget(self.smear_preview_toggle, 1, 4, Qt.AlignmentFlag.AlignVCenter) + switch_grid.addWidget(self.vertical_lock_label, 2, 1, _right_vcenter) + switch_grid.addWidget(self.vertical_lock_toggle, 2, 2, Qt.AlignmentFlag.AlignVCenter) self.control_panel_layout.addWidget(self.switch_grid_widget) @@ -634,6 +651,33 @@ class OMNY_XRayEye(BECWidget, QWidget): # Live-update the microns readout while this ROI is drawn/resized. roi.sigRegionChanged.connect(lambda r=roi: self._update_roi_size_readout(r)) self._update_roi_size_readout(roi) + # If the vertical lock is on (see lock_vertical_center()), snap this box's + # vertical center to the crosshair immediately, and again on every future + # drag/resize. + self._snap_roi_vertical(roi) + roi.sigRegionChangeFinished.connect(lambda r=roi: self._snap_roi_vertical(r)) + + def _snap_roi_vertical(self, roi): + """Snap `roi`'s vertical center to the crosshair's fixed y position, keeping + its x position and height/diameter unchanged. No-op unless the vertical lock + is enabled (``lock_vertical_center()``) and the crosshair is visible. Safe to + call from ``roi.sigRegionChangeFinished`` -- if the ROI is already at the + target position this is a no-op, so it cannot loop back into itself. + """ + if not self._vertical_lock_enabled or not self.target_crosshair.is_visible(): + return + _, fzp_y = self.crosshair_position() + if isinstance(roi, RectangularROI): + height = roi.get_coordinates(typed=True)["height"] + elif isinstance(roi, CircularROI): + height = roi.get_coordinates(typed=True)["diameter"] + else: + return + current_x = roi.pos().x() + new_origin_y = fzp_y - height / 2 + if math.isclose(roi.pos().y(), new_origin_y, abs_tol=1e-9): + return + roi.set_position(current_x, new_origin_y) def _microns_per_pixel(self): """Resolve microns/pixel and unit label from the camera user parameter. @@ -840,6 +884,48 @@ class OMNY_XRayEye(BECWidget, QWidget): """Hide the alignment target crosshair on the image view.""" self.target_crosshair.set_visible(False) + @SafeSlot() + @rpc_timeout(20) + def lock_vertical_center(self): + """Snap every ROI's vertical center to the crosshair's fixed y position + (see ``crosshair_position()``), immediately and again on every future + draw/drag/resize, until ``unlock_vertical_center()`` is called. Used by + ``x_ray_eye_align.py`` once the sample's height has been physically + centered on the FZP, so subsequent alignment boxes no longer need manual + vertical placement. See ``_snap_roi_vertical()``. + + Also settable by the operator directly via the "ROI vertical lock" + toggle in the control panel (``_on_vertical_lock_toggle_changed``) -- + e.g. to temporarily release a script-engaged lock mid-run in order to + freely draw/measure an unrelated box, then re-engage it. Both paths + keep the toggle's visual state in sync with ``_vertical_lock_enabled``. + """ + self._vertical_lock_enabled = True + self.vertical_lock_toggle.blockSignals(True) + self.vertical_lock_toggle.checked = True + self.vertical_lock_toggle.blockSignals(False) + for roi in self.roi_manager.controller.rois: + self._snap_roi_vertical(roi) + + @SafeSlot() + @rpc_timeout(20) + def unlock_vertical_center(self): + """Stop auto-snapping ROIs' vertical center -- see ``lock_vertical_center()``.""" + self._vertical_lock_enabled = False + self.vertical_lock_toggle.blockSignals(True) + self.vertical_lock_toggle.checked = False + self.vertical_lock_toggle.blockSignals(False) + + @SafeSlot(bool) + def _on_vertical_lock_toggle_changed(self, enabled: bool): + """Operator flipped the "ROI vertical lock" toggle directly -- delegate to + lock_vertical_center()/unlock_vertical_center() so both entry points + (this toggle, and script/RPC calls) stay in sync.""" + if enabled: + self.lock_vertical_center() + else: + self.unlock_vertical_center() + @SafeProperty(bool) def crosshair_visible(self) -> bool: """Whether the alignment target crosshair is currently shown.""" diff --git a/tests/tests_bec_widgets/test_x_ray_eye_vertical_lock.py b/tests/tests_bec_widgets/test_x_ray_eye_vertical_lock.py new file mode 100644 index 00000000..49925018 --- /dev/null +++ b/tests/tests_bec_widgets/test_x_ray_eye_vertical_lock.py @@ -0,0 +1,102 @@ +"""Tests for OMNY_XRayEye's ROI-vertical-lock feature (see +docs/plans/xrayeye-vertical-lock.md): _snap_roi_vertical() snaps a selection +box's vertical center to the crosshair's fixed y position while leaving its +x position and height/diameter untouched, but only while the lock is enabled +and the crosshair is visible. + +_snap_roi_vertical is a plain (non-Qt-slot-decorated) method, so it can be +exercised directly on a minimal stub without constructing the full +OMNY_XRayEye widget (which needs a live BEC client context via +get_bec_shortcuts()). +""" + +from unittest import mock + +from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI + +from csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye import OMNY_XRayEye + + +class _StubXRayEye: + """Minimal stand-in for OMNY_XRayEye, isolated to just the attributes + _snap_roi_vertical touches.""" + + _snap_roi_vertical = OMNY_XRayEye._snap_roi_vertical + + +def _make_stub(*, lock_enabled: bool, crosshair_visible: bool, fzp_y: float = 42.0): + stub = _StubXRayEye() + stub._vertical_lock_enabled = lock_enabled + stub.target_crosshair = mock.Mock() + stub.target_crosshair.is_visible.return_value = crosshair_visible + stub.crosshair_position = mock.Mock(return_value=(0.0, fzp_y)) + return stub + + +def _make_rect_roi(x: float, y: float, height: float): + roi = mock.Mock(spec=RectangularROI) + roi.get_coordinates.return_value = {"height": height} + roi.pos.return_value = mock.Mock(x=lambda: x, y=lambda: y) + return roi + + +def test_snap_roi_vertical_snaps_to_crosshair_when_locked(): + stub = _make_stub(lock_enabled=True, crosshair_visible=True, fzp_y=42.0) + roi = _make_rect_roi(x=5.0, y=0.0, height=10.0) + + stub._snap_roi_vertical(roi) + + # new_origin_y = fzp_y - height/2 = 42 - 5 = 37; x is untouched + roi.set_position.assert_called_once_with(5.0, 37.0) + + +def test_snap_roi_vertical_noop_when_lock_disabled(): + stub = _make_stub(lock_enabled=False, crosshair_visible=True) + roi = _make_rect_roi(x=5.0, y=0.0, height=10.0) + + stub._snap_roi_vertical(roi) + + roi.set_position.assert_not_called() + + +def test_snap_roi_vertical_noop_when_crosshair_hidden(): + stub = _make_stub(lock_enabled=True, crosshair_visible=False) + roi = _make_rect_roi(x=5.0, y=0.0, height=10.0) + + stub._snap_roi_vertical(roi) + + roi.set_position.assert_not_called() + + +def test_snap_roi_vertical_noop_when_already_snapped(): + # Regression guard: sigRegionChangeFinished is connected to this method, + # so a call that finds the ROI already at the target position must not + # call set_position() again -- otherwise a re-triggered signal could loop. + stub = _make_stub(lock_enabled=True, crosshair_visible=True, fzp_y=42.0) + roi = _make_rect_roi(x=5.0, y=37.0, height=10.0) # already at target y + + stub._snap_roi_vertical(roi) + + roi.set_position.assert_not_called() + + +def test_snap_roi_vertical_preserves_x_and_height(): + stub = _make_stub(lock_enabled=True, crosshair_visible=True, fzp_y=-10.0) + roi = _make_rect_roi(x=-3.5, y=100.0, height=4.0) + + stub._snap_roi_vertical(roi) + + # new_origin_y = -10 - 2 = -12; x (-3.5) is preserved + roi.set_position.assert_called_once_with(-3.5, -12.0) + + +def test_snap_roi_vertical_handles_circular_roi(): + stub = _make_stub(lock_enabled=True, crosshair_visible=True, fzp_y=20.0) + roi = mock.Mock(spec=CircularROI) + roi.get_coordinates.return_value = {"diameter": 8.0} + roi.pos.return_value = mock.Mock(x=lambda: 1.0, y=lambda: 0.0) + + stub._snap_roi_vertical(roi) + + # new_origin_y = fzp_y - diameter/2 = 20 - 4 = 16 + roi.set_position.assert_called_once_with(1.0, 16.0) -- 2.54.0 From d23735701227188240752ccdadcb938874597c7a Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 14:22:29 +0200 Subject: [PATCH 24/78] fix(omny-shared): filters_out_of_beam tolerates a missing/None dev Surfaced live on a simulated flomni session: tomo_scan_projection() crashed with "DeviceConfigError: Device filter_array_1_x does not exist" -- the real beamline's four filter_array_*_x devices (bl_endstation.yaml) aren't part of any endstation simulation config, by design (out of scope for an isolated endstation sim, same as frontend/optics-hutch/detector devices). filters_out_of_beam() already tried to skip a missing device via getattr(dev, axis_name, None), but BEC's device container raises DeviceConfigError -- a plain Exception, not AttributeError -- for a missing device, so getattr's default never applied. Wrap the lookup in try/except instead, which also preserves the pre-existing (and apparently relied-upon, per several LamNI tests) behavior of `dev` itself being None -- the usual state of this codebase's `dev = builtins.__dict__.get("dev")` idiom before a real session sets it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- .../plugins/OMNY_shared/filter_check.py | 14 +++- .../test_filter_check.py | 81 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 tests/tests_bec_ipython_client/test_filter_check.py diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py index 1503433a..6d5db9d7 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py @@ -9,12 +9,24 @@ def filters_out_of_beam(dev, tol: float = 0.1) -> tuple[bool, list[str]]: Reuses cSAXSFilterTransmission's own axis list/position table (index 0 of each row is the "out" position) so there is one source of truth for what "out" means, matching csaxs.fil_trans()/_fil_trans_report()'s own check. + + Tolerates a deployment missing one or more filter axes (e.g. a simulated + session without the real beamline's filter_array_* devices), and `dev` + itself being None (the usual state of the module-level `dev = + builtins.__dict__.get("dev")` idiom used throughout this codebase before + a real BEC session has set it). BEC's device container raises + DeviceConfigError -- not AttributeError -- for a missing device, so a + plain `getattr(dev, axis_name, None)` does NOT safely skip it; wrapping + the lookup itself in try/except does, for both cases. """ offending = [] for axis_name, positions in zip( cSAXSFilterTransmission._AXES, cSAXSFilterTransmission._POSITIONS_USER ): - axis_obj = getattr(dev, axis_name, None) + try: + axis_obj = getattr(dev, axis_name, None) + except Exception: + axis_obj = None out_position = positions[0] if axis_obj is None or out_position is None: continue diff --git a/tests/tests_bec_ipython_client/test_filter_check.py b/tests/tests_bec_ipython_client/test_filter_check.py new file mode 100644 index 00000000..a45d6894 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_filter_check.py @@ -0,0 +1,81 @@ +"""Tests for filters_out_of_beam() (csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py). + +Regression coverage for a bug surfaced on a simulated flomni session: +tomo_scan_projection() -> filters_out_of_beam() crashed with +DeviceConfigError: Device filter_array_1_x does not exist, because the sim +config (unlike the real beamline's bl_endstation.yaml) doesn't define the +filter_array_*_x devices at all. The lookup used `getattr(dev, axis_name, +None)`, but BEC's device container raises DeviceConfigError (a plain +Exception, not AttributeError) for a missing device, so getattr's default +never applied. +""" + +from unittest import mock + +from bec_lib.bec_errors import DeviceConfigError + +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.filter_check import filters_out_of_beam + + +class _FakeDeviceContainer(dict): + """Stands in for BEC's real device-manager container: attribute access + for a present device returns it, for a missing one raises + DeviceConfigError (not AttributeError) -- see devicemanager.py's + __getattr__.""" + + def __getattr__(self, attr): + dev = self.get(attr) + if not dev: + raise DeviceConfigError(f"Device {attr} does not exist.") + return dev + + +def _axis_at(position: float): + axis = mock.MagicMock() + axis.readback.get.return_value = position + return axis + + +def test_filters_out_of_beam_dev_is_none(): + # The usual state of the module-level `dev` idiom before a real BEC + # session has set it (`dev = builtins.__dict__.get("dev")`) -- several + # LamNI/Flomni call sites pass this straight through. Must not raise. + all_out, offending = filters_out_of_beam(None) + + assert all_out is True + assert offending == [] + + +def test_filters_out_of_beam_all_devices_missing_from_container(): + # Mirrors a simulated deployment: none of the filter_array_*_x devices + # exist at all. Must not raise, and must report "all out" (nothing to + # check -> vacuously true), matching the existing axis_obj-is-None skip. + dev = _FakeDeviceContainer() + + all_out, offending = filters_out_of_beam(dev) + + assert all_out is True + assert offending == [] + + +def test_filters_out_of_beam_some_devices_missing_from_container(): + # A mixed deployment: filter_array_1_x exists and is out of the beam, + # the other three axes don't exist at all. + dev = _FakeDeviceContainer(filter_array_1_x=_axis_at(25.0)) + + all_out, offending = filters_out_of_beam(dev) + + assert all_out is True + assert offending == [] + + +def test_filters_out_of_beam_flags_axis_in_beam(): + dev = _FakeDeviceContainer( + filter_array_1_x=_axis_at(25.0), # out + filter_array_2_x=_axis_at(17.6), # NOT out (out position is 25.5) + ) + + all_out, offending = filters_out_of_beam(dev) + + assert all_out is False + assert offending == ["filter_array_2_x"] -- 2.54.0 From 8bdd70eaf4d2d43780a9924f70b56b9eda7c84f2 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 15:08:29 +0200 Subject: [PATCH 25/78] feat(xrayeye): add separator between shutter/camera-running and smear switches Both rows share one QGridLayout (for column alignment across rows of different label widths) -- insert the separator as a full-width grid row between them rather than a separate widget, and shift the smear-integration and ROI-vertical-lock rows down accordingly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index d4e8a924..e170f304 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -377,12 +377,13 @@ class OMNY_XRayEye(BECWidget, QWidget): header_row.addWidget(self.live_preview_toggle, 0, Qt.AlignmentFlag.AlignVCenter) self.control_panel_layout.addLayout(header_row) - # Shutter/camera-running (row 0) and the smear aid's own status - # switches (row 1) share one grid so the toggle columns actually - # line up regardless of each row's label text width -- two - # independent QHBoxLayouts (the previous approach) each size - # themselves from their own labels' widths, so corresponding - # switches in different rows land at different x-positions. + # Shutter/camera-running (row 0), a separator (row 1), the smear + # aid's own status switches (row 2), and the ROI-vertical-lock + # toggle (row 3) share one grid so the toggle columns actually line + # up regardless of each row's label text width -- independent + # QHBoxLayouts per row (the previous approach) each size themselves + # from their own labels' widths, so corresponding switches in + # different rows would land at different x-positions. self.switch_grid_widget = QWidget(parent=self) switch_grid = QGridLayout(self.switch_grid_widget) switch_grid.setContentsMargins(0, 0, 0, 0) @@ -426,12 +427,14 @@ class OMNY_XRayEye(BECWidget, QWidget): switch_grid.addWidget(self.shutter_toggle, 0, 2, Qt.AlignmentFlag.AlignVCenter) switch_grid.addWidget(self.camera_running_label, 0, 3, _right_vcenter) switch_grid.addWidget(self.camera_running_toggle, 0, 4, Qt.AlignmentFlag.AlignVCenter) - switch_grid.addWidget(self.smear_active_label, 1, 1, _right_vcenter) - switch_grid.addWidget(self.smear_active_toggle, 1, 2, Qt.AlignmentFlag.AlignVCenter) - switch_grid.addWidget(self.smear_preview_label, 1, 3, _right_vcenter) - switch_grid.addWidget(self.smear_preview_toggle, 1, 4, Qt.AlignmentFlag.AlignVCenter) - switch_grid.addWidget(self.vertical_lock_label, 2, 1, _right_vcenter) - switch_grid.addWidget(self.vertical_lock_toggle, 2, 2, Qt.AlignmentFlag.AlignVCenter) + # separator: below shutter/camera-running, above the smear-integration switches + switch_grid.addWidget(self._create_separator(), 1, 0, 1, 5) + switch_grid.addWidget(self.smear_active_label, 2, 1, _right_vcenter) + switch_grid.addWidget(self.smear_active_toggle, 2, 2, Qt.AlignmentFlag.AlignVCenter) + switch_grid.addWidget(self.smear_preview_label, 2, 3, _right_vcenter) + switch_grid.addWidget(self.smear_preview_toggle, 2, 4, Qt.AlignmentFlag.AlignVCenter) + switch_grid.addWidget(self.vertical_lock_label, 3, 1, _right_vcenter) + switch_grid.addWidget(self.vertical_lock_toggle, 3, 2, Qt.AlignmentFlag.AlignVCenter) self.control_panel_layout.addWidget(self.switch_grid_widget) -- 2.54.0 From 5f6520214da758cea198ea5ac1d43a4c85841239 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 15:18:47 +0200 Subject: [PATCH 26/78] fix(flomni): use cached read for sample-name poll in tomo params widget The OMNY_TomoParams widget polled flomni_samples.sample_names.sample0 uncached every 2s to refresh a display label. Over a long interlock lock this produced thousands of RPC calls, overflowing the client's shared 100-entry request buffer (RequestStorage) and evicting a pending scan request, which stalled the queue with no error. The signal is auto-monitored and already live in Redis, so read it from there instead of round-tripping to the device server. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GCaaQQSYGCbkAsRnALWC3h --- csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index a23613d0..b7484d49 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -2351,7 +2351,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { "requested_to_stepsize": _requested_to_stepsize, "compute_fermat_positions": _compute_fermat_positions_flomni, "fermat_position_fields": ["tomo_shellstep", "fermat_asymmetry", "fovx", "fovy"], - "sample_name_getter": lambda w: w.dev.flomni_samples.sample_names.sample0.get(), + "sample_name_getter": lambda w: w.dev.flomni_samples.sample_names.sample0.get(cached=True), "cli_hint_name": "flomni", }, "lamni": { -- 2.54.0 From 80ccde83b21cc255e051d11027b288dcb5364340 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 15:20:36 +0200 Subject: [PATCH 27/78] chore(bec_widgets): regenerate client.py RPC stub for lock/unlock_vertical_center csaxs_bec/bec_widgets/widgets/client.py is a generated file (bw-generate-cli --target csaxs_bec) that the BEC client uses to build its dynamic RPC proxy classes -- it is the actual source of truth for what methods a client-side widget reference exposes, NOT the live widget class itself. Adding lock_vertical_center()/unlock_vertical_center() to OMNY_XRayEye without regenerating this file meant every BEC client session (even a freshly restarted one) built its xeyegui proxy without those two methods, causing "AttributeError: 'OMNY_XRayEye' object has no attribute 'unlock_vertical_center'" live on a real session -- traced via flomni.xeyegui._registry[...].__class__.__module__ == 'client', confirming the proxy class comes from this file, not csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye directly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- csaxs_bec/bec_widgets/widgets/client.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index 14541d6c..37122c73 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -257,6 +257,31 @@ class OMNY_XRayEye(RPCBase): Hide the alignment target crosshair on the image view. """ + @rpc_timeout(20) + @rpc_call + def lock_vertical_center(self): + """ + Snap every ROI's vertical center to the crosshair's fixed y position + (see ``crosshair_position()``), immediately and again on every future + draw/drag/resize, until ``unlock_vertical_center()`` is called. Used by + ``x_ray_eye_align.py`` once the sample's height has been physically + centered on the FZP, so subsequent alignment boxes no longer need manual + vertical placement. See ``_snap_roi_vertical()``. + + Also settable by the operator directly via the "ROI vertical lock" + toggle in the control panel (``_on_vertical_lock_toggle_changed``) -- + e.g. to temporarily release a script-engaged lock mid-run in order to + freely draw/measure an unrelated box, then re-engage it. Both paths + keep the toggle's visual state in sync with ``_vertical_lock_enabled``. + """ + + @rpc_timeout(20) + @rpc_call + def unlock_vertical_center(self): + """ + Stop auto-snapping ROIs' vertical center -- see ``lock_vertical_center()``. + """ + @property @rpc_call def crosshair_visible(self) -> "bool": -- 2.54.0 From 3d0686329a67a13ffee744a8f1b827031d8c2916 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 15:29:33 +0200 Subject: [PATCH 28/78] fix(sim-cameras): correct exposure_time units from ~10s default to 10ms _SimIDSBackend's default exposure_time (10000.0, commented "ms") and its get_exposure_range() ceiling formula (1_000_000.0 / pixel_clock_MHz) were both scaled as if the unit were microseconds, inconsistent with the real IDSCamera's documented exposure_time unit (ms; see its Cpt docstring and exposure_time_max's own 1000.0 ms placeholder default). Net effect: every fresh sim connect seeded exposure_time.put(10000), read by the real device layer as a literal 10 second exposure -- ~50x a 5 Hz live-mode frame period (200 ms), and get_exposure_range() reported a ~50 s ceiling at the default 20 MHz pixel clock. New default (10 ms) and range formula (20_000.0 / pixel_clock_MHz, giving ~1000 ms at 20 MHz) keep the same pixel-clock-dependent shape but land in a believable ms-scale range comfortably under a 5 Hz target. Noticed live: simulated camera's exposure/gain readout looked nonsensical after the manual-exposure work made this value load-bearing (previously just an unused placeholder). Sim frame *content* is independent of exposure/gain (no brightness modeling), so this alone doesn't explain a reported "cut" first image, but the seeded value being wildly unrealistic was worth fixing regardless. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- csaxs_bec/devices/sim/sim_cameras.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/csaxs_bec/devices/sim/sim_cameras.py b/csaxs_bec/devices/sim/sim_cameras.py index dba8b06a..eae4b735 100644 --- a/csaxs_bec/devices/sim/sim_cameras.py +++ b/csaxs_bec/devices/sim/sim_cameras.py @@ -238,7 +238,13 @@ class _SimIDSBackend: self._connected = False self._rgb = rgb self._noise_std = float(noise_std) - self._exposure_time = 10000.0 # ms + # Realistic default well under a 5 Hz (200 ms) live-mode frame period -- + # was 10000.0 (10 s), inconsistent with get_exposure_range()'s own + # microsecond-scaled formula below and with the real IDSCamera Cpt's + # documented units/placeholder (exposure_time is in ms, default max + # placeholder 1000.0 ms). A 10 s default meant every fresh sim connect + # seeded exposure_time.put(10000) -- ~50x a 5 Hz period. + self._exposure_time = 10.0 # ms self._auto_exposure = True self._auto_gain = True self._pixel_clock = 20 # MHz @@ -307,7 +313,12 @@ class _SimIDSBackend: def get_exposure_range(self) -> tuple[float, float, float]: # Loosely mimics the real driver's pixel-clock-dependent ceiling: a # higher pixel clock -> shorter max frame time -> lower max exposure. - max_exposure = 1_000_000.0 / max(self._pixel_clock, 1) + # Scaled in ms (the real IDSCamera's documented exposure_time unit): + # 20_000.0 / pixel_clock_MHz gives ~1000 ms at the default 20 MHz + # pixel clock, matching exposure_time_max's own real-device placeholder + # default. The previous 1_000_000.0 scale was microsecond-sized, + # producing a ~50 s ceiling here instead. + max_exposure = 20_000.0 / max(self._pixel_clock, 1) return 0.1, max_exposure, 0.1 def get_pixel_clock(self) -> int: -- 2.54.0 From 10b02ca2f14888c5c7d13abdfb7673dc89c10973 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 15:37:25 +0200 Subject: [PATCH 29/78] fix(xrayeye): defer control-panel width-fixing until after first show _init_ui() locked the control panel to a fixed width computed from sizeHint() called synchronously during construction -- before the widget had ever been shown/laid out, with no real screen geometry and fonts not fully resolved yet (worse over a remote desktop session). Reported live: the widget opens correctly sized for a moment, then the image area visibly collapses down to a sliver as the (wrong) fixed width gets applied, recoverable only by manually resizing the window (which doesn't fix the wrong width itself, just gives the image enough leftover space to look normal again despite it). Defer the actual sizeHint()/setFixedWidth() call via QTimer.singleShot(0, ...) so it runs on the next event-loop iteration, after the widget has actually been shown/laid out at least once -- same effect a manual resize was achieving, but automatic and correct the first time. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index e170f304..6ed7758d 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -580,11 +580,15 @@ class OMNY_XRayEye(BECWidget, QWidget): self._update_calibration_readout() self.control_panel_layout.addLayout(form) - # Fix panel width and allow vertical expansion - self.control_panel.adjustSize() - p_hint = self.control_panel.sizeHint() - self.control_panel.setFixedWidth(p_hint.width()) + # Fix panel width and allow vertical expansion. The actual width-fixing + # is deferred (see _fix_control_panel_width()) rather than computed + # here -- sizeHint() called before the widget has ever been shown/laid + # out is unreliable (no real screen geometry yet, fonts not fully + # resolved -- worse over a remote desktop session), which froze + # control_panel at a wrong width until the operator manually resized + # the window (forcing Qt to redo layout with correct geometry). self.control_panel.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + QTimer.singleShot(0, self._fix_control_panel_width) # Core Layout: image (expanding) | control panel (fixed) self.core_layout.addWidget(self.image) @@ -741,6 +745,14 @@ class OMNY_XRayEye(BECWidget, QWidget): sep.setLineWidth(1) return sep + def _fix_control_panel_width(self): + """Lock the control panel to its natural width. Scheduled via + QTimer.singleShot(0, ...) from _init_ui() to run on the next event-loop + iteration, after the widget has actually been shown/laid out at least + once -- sizeHint() is not reliable before that point.""" + self.control_panel.adjustSize() + self.control_panel.setFixedWidth(self.control_panel.sizeHint().width()) + def _init_gui_trigger(self): self.dev.omny_xray_gui.read() self.dev.fsh.read() -- 2.54.0 From a6dadafe46719813e96ff34fb82e6d7be4724688 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 15:44:23 +0200 Subject: [PATCH 30/78] fix(xrayeye): use showEvent() instead of a one-shot timer for control-panel width The previous fix (QTimer.singleShot(0, ...) from _init_ui()) was not reliably late enough in practice -- still reproduced live over a remote desktop session: window opens, image briefly visible, then the control panel's wrong fixed width squeezes it down, recoverable only by moving or resizing the window (which doesn't fix the wrong width itself, just gives the image enough leftover space to look normal despite it). showEvent() is the correct Qt idiom for "run once real geometry is available" -- it fires every time the widget is actually made visible, not just once at construction. Also more robust than a single deferred call: it self-corrects on any later re-show too (e.g. a saved-workspace restore re-applying stale geometry after the fix already ran once). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index 6ed7758d..4625c987 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -581,14 +581,19 @@ class OMNY_XRayEye(BECWidget, QWidget): self.control_panel_layout.addLayout(form) # Fix panel width and allow vertical expansion. The actual width-fixing - # is deferred (see _fix_control_panel_width()) rather than computed + # happens in showEvent() (see _fix_control_panel_width()) rather than # here -- sizeHint() called before the widget has ever been shown/laid # out is unreliable (no real screen geometry yet, fonts not fully # resolved -- worse over a remote desktop session), which froze - # control_panel at a wrong width until the operator manually resized - # the window (forcing Qt to redo layout with correct geometry). + # control_panel at a wrong width until the operator manually moved or + # resized the window (forcing Qt to redo layout with correct + # geometry). A one-shot QTimer.singleShot(0, ...) right here was tried + # first and was NOT reliably late enough (still observed live, over a + # remote desktop session, after that fix) -- showEvent() is the + # correct Qt idiom for "run once real geometry is available", and + # unlike a single deferred call it also self-corrects on any later + # re-show (e.g. a saved-workspace restore re-applying stale geometry). self.control_panel.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) - QTimer.singleShot(0, self._fix_control_panel_width) # Core Layout: image (expanding) | control panel (fixed) self.core_layout.addWidget(self.image) @@ -745,11 +750,24 @@ class OMNY_XRayEye(BECWidget, QWidget): sep.setLineWidth(1) return sep + def showEvent(self, event): + """Re-fix the control panel's width every time this widget actually + becomes visible -- see _fix_control_panel_width() for why this can't + just be computed once during __init__/_init_ui().""" + super().showEvent(event) + self._fix_control_panel_width() + def _fix_control_panel_width(self): - """Lock the control panel to its natural width. Scheduled via - QTimer.singleShot(0, ...) from _init_ui() to run on the next event-loop - iteration, after the widget has actually been shown/laid out at least - once -- sizeHint() is not reliable before that point.""" + """Lock the control panel to its natural width, using its current + (now-real) sizeHint() -- see showEvent(), which calls this every time + the widget is actually shown. sizeHint() computed before the widget + has ever been shown/laid out (e.g. synchronously during __init__) is + unreliable: no real screen geometry yet, fonts not fully resolved + (worse over a remote desktop session). A single deferred + QTimer.singleShot(0, ...) call was tried first and was not reliably + late enough in practice; showEvent() also self-corrects on any later + re-show (e.g. a saved-workspace restore re-applying stale geometry), + which a one-shot call cannot.""" self.control_panel.adjustSize() self.control_panel.setFixedWidth(self.control_panel.sizeHint().width()) -- 2.54.0 From 99243931acd3fa0b34ae314b89e17c1c0ed2850f Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 15:48:07 +0200 Subject: [PATCH 31/78] Revert "fix(xrayeye): use showEvent() instead of a one-shot timer for control-panel width" This reverts commit a6dadafe46719813e96ff34fb82e6d7be4724688. --- .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 34 +++++-------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index 4625c987..6ed7758d 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -581,19 +581,14 @@ class OMNY_XRayEye(BECWidget, QWidget): self.control_panel_layout.addLayout(form) # Fix panel width and allow vertical expansion. The actual width-fixing - # happens in showEvent() (see _fix_control_panel_width()) rather than + # is deferred (see _fix_control_panel_width()) rather than computed # here -- sizeHint() called before the widget has ever been shown/laid # out is unreliable (no real screen geometry yet, fonts not fully # resolved -- worse over a remote desktop session), which froze - # control_panel at a wrong width until the operator manually moved or - # resized the window (forcing Qt to redo layout with correct - # geometry). A one-shot QTimer.singleShot(0, ...) right here was tried - # first and was NOT reliably late enough (still observed live, over a - # remote desktop session, after that fix) -- showEvent() is the - # correct Qt idiom for "run once real geometry is available", and - # unlike a single deferred call it also self-corrects on any later - # re-show (e.g. a saved-workspace restore re-applying stale geometry). + # control_panel at a wrong width until the operator manually resized + # the window (forcing Qt to redo layout with correct geometry). self.control_panel.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + QTimer.singleShot(0, self._fix_control_panel_width) # Core Layout: image (expanding) | control panel (fixed) self.core_layout.addWidget(self.image) @@ -750,24 +745,11 @@ class OMNY_XRayEye(BECWidget, QWidget): sep.setLineWidth(1) return sep - def showEvent(self, event): - """Re-fix the control panel's width every time this widget actually - becomes visible -- see _fix_control_panel_width() for why this can't - just be computed once during __init__/_init_ui().""" - super().showEvent(event) - self._fix_control_panel_width() - def _fix_control_panel_width(self): - """Lock the control panel to its natural width, using its current - (now-real) sizeHint() -- see showEvent(), which calls this every time - the widget is actually shown. sizeHint() computed before the widget - has ever been shown/laid out (e.g. synchronously during __init__) is - unreliable: no real screen geometry yet, fonts not fully resolved - (worse over a remote desktop session). A single deferred - QTimer.singleShot(0, ...) call was tried first and was not reliably - late enough in practice; showEvent() also self-corrects on any later - re-show (e.g. a saved-workspace restore re-applying stale geometry), - which a one-shot call cannot.""" + """Lock the control panel to its natural width. Scheduled via + QTimer.singleShot(0, ...) from _init_ui() to run on the next event-loop + iteration, after the widget has actually been shown/laid out at least + once -- sizeHint() is not reliable before that point.""" self.control_panel.adjustSize() self.control_panel.setFixedWidth(self.control_panel.sizeHint().width()) -- 2.54.0 From b43240fbdd6dbccb72dd298f58d469f703e6d55f Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 15:48:07 +0200 Subject: [PATCH 32/78] Revert "fix(xrayeye): defer control-panel width-fixing until after first show" This reverts commit 10b02ca2f14888c5c7d13abdfb7673dc89c10973. --- .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index 6ed7758d..e170f304 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -580,15 +580,11 @@ class OMNY_XRayEye(BECWidget, QWidget): self._update_calibration_readout() self.control_panel_layout.addLayout(form) - # Fix panel width and allow vertical expansion. The actual width-fixing - # is deferred (see _fix_control_panel_width()) rather than computed - # here -- sizeHint() called before the widget has ever been shown/laid - # out is unreliable (no real screen geometry yet, fonts not fully - # resolved -- worse over a remote desktop session), which froze - # control_panel at a wrong width until the operator manually resized - # the window (forcing Qt to redo layout with correct geometry). + # Fix panel width and allow vertical expansion + self.control_panel.adjustSize() + p_hint = self.control_panel.sizeHint() + self.control_panel.setFixedWidth(p_hint.width()) self.control_panel.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) - QTimer.singleShot(0, self._fix_control_panel_width) # Core Layout: image (expanding) | control panel (fixed) self.core_layout.addWidget(self.image) @@ -745,14 +741,6 @@ class OMNY_XRayEye(BECWidget, QWidget): sep.setLineWidth(1) return sep - def _fix_control_panel_width(self): - """Lock the control panel to its natural width. Scheduled via - QTimer.singleShot(0, ...) from _init_ui() to run on the next event-loop - iteration, after the widget has actually been shown/laid out at least - once -- sizeHint() is not reliable before that point.""" - self.control_panel.adjustSize() - self.control_panel.setFixedWidth(self.control_panel.sizeHint().width()) - def _init_gui_trigger(self): self.dev.omny_xray_gui.read() self.dev.fsh.read() -- 2.54.0 From 3728a24c70ea726c8bcf9f67b36a332ca84351a4 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 15:48:39 +0200 Subject: [PATCH 33/78] docs: add personal Claude Code notes for mirko Personal, machine-syncable notes file (not referenced by any shared AGENTS.md/CLAUDE.md, so it has no effect on other contributors). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GCaaQQSYGCbkAsRnALWC3h --- AGENTS_mirko.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 AGENTS_mirko.md diff --git a/AGENTS_mirko.md b/AGENTS_mirko.md new file mode 100644 index 00000000..fd7a334a --- /dev/null +++ b/AGENTS_mirko.md @@ -0,0 +1,15 @@ +# Personal notes — mirko + +Personal working notes for Claude Code sessions in this repo, for my own use across machines. +Not project-wide guidance — nothing here applies to other contributors, and nothing else in +this repo references or enforces it. + +## Repo ownership boundaries + +Root causes traced into BEC core (`bec_lib`/`bec_server`/`bec_ipython_client`, the `bec` repo) +or into core `bec_widgets` are not mine to fix here — those are owned by their respective core +teams. When an investigation in `csaxs_bec` turns up a bug whose fix belongs in one of those +repos, write it up (a short report or plan) instead of implementing/committing it there. + +Fixes that land in `csaxs_bec` itself (beamline plugin code, widgets, device configs, scans) +are mine to implement, commit, and push directly. -- 2.54.0 From 631bdedb6c485d0e14c49f4ae4123c5d11d2d455 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 15:59:40 +0200 Subject: [PATCH 34/78] fix(flomni): increase flomni GUI window settle delay after creation (experiment) Root cause finally isolated via a live A/B test: closing the whole "flomni" window and reopening it (flomnigui_show_gui() takes the gui.new() branch, creating a fresh top-level window) reproduces the "camera image squeezed/ missing until the window is moved" symptom; closing just the xeyegui dock and reopening it (window already exists in self.gui.windows, reused as-is) does not. Same OMNY_XRayEye construction either way -- the only variable is whether the window itself is freshly created, which rules out the widget's own layout code (already tried and reverted two fixes there this session). The existing time.sleep(1) after gui.new() is itself evidence someone already found the RPC response alone doesn't mean the window has actually settled on screen. Bumping to 2s is a pragmatic, easily-reversible experiment, not a confirmed fix -- flagging in case it needs to become a poll-for-actual-geometry wait instead. LamNI's gui_tools.py has the identical gui.new() + time.sleep(1) pattern (line 46-47) and is presumably equally exposed, but untouched here -- not reported/tested there. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- .../bec_ipython_client/plugins/flomni/gui_tools.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py index 0a933ec9..c347b40c 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py @@ -59,7 +59,17 @@ class flomniGuiTools: pos_x = self._SCREEN_WIDTH - self._WINDOW_WIDTH geometry = (pos_x, self._WINDOW_TOP_MARGIN, self._WINDOW_WIDTH, self._WINDOW_HEIGHT) self.flomni_window = self.gui.new("flomni", geometry=geometry) - time.sleep(1) + # The RPC response for gui.new() confirms the server has created the + # window object, not that it has actually settled at the requested + # geometry on screen (window-manager-level resize can lag behind + # that, worse over a remote desktop session) -- the very first dock + # added right after this (e.g. flomnigui_show_xeyealign()'s + # OMNY_XRayEye) can otherwise get laid out against a still-settling + # window, squeezing it down until the operator manually moves or + # resizes the window (which forces a relayout, masking the real + # timing issue rather than fixing it). Was 1s; bumped to 2s -- + # this is a pragmatic guess, not a confirmed-sufficient bound. + time.sleep(2) def flomnigui_stop_gui(self): self.gui.flomni.hide() -- 2.54.0 From 903e4657018a5d7b38b7dbe2707544f87eec3b1f Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 16:02:38 +0200 Subject: [PATCH 35/78] Revert "fix(flomni): increase flomni GUI window settle delay after creation (experiment)" This reverts commit 631bdedb6c485d0e14c49f4ae4123c5d11d2d455. --- .../bec_ipython_client/plugins/flomni/gui_tools.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py index c347b40c..0a933ec9 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py @@ -59,17 +59,7 @@ class flomniGuiTools: pos_x = self._SCREEN_WIDTH - self._WINDOW_WIDTH geometry = (pos_x, self._WINDOW_TOP_MARGIN, self._WINDOW_WIDTH, self._WINDOW_HEIGHT) self.flomni_window = self.gui.new("flomni", geometry=geometry) - # The RPC response for gui.new() confirms the server has created the - # window object, not that it has actually settled at the requested - # geometry on screen (window-manager-level resize can lag behind - # that, worse over a remote desktop session) -- the very first dock - # added right after this (e.g. flomnigui_show_xeyealign()'s - # OMNY_XRayEye) can otherwise get laid out against a still-settling - # window, squeezing it down until the operator manually moves or - # resizes the window (which forces a relayout, masking the real - # timing issue rather than fixing it). Was 1s; bumped to 2s -- - # this is a pragmatic guess, not a confirmed-sufficient bound. - time.sleep(2) + time.sleep(1) def flomnigui_stop_gui(self): self.gui.flomni.hide() -- 2.54.0 From fd01806b2fd56ff604252951d691bacf320dfaa1 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 17:03:37 +0200 Subject: [PATCH 36/78] fix(xrayeye): remove self.resize(800, 600) from __init__ -- fights dock manager Root cause isolated via a live A/B test (Mirko): the "camera image cut/ collapsed until the window is manually moved" symptom is specific to OMNY_XRayEye -- flomnigui_show_cameras()'s Image/z_ConsoleButtonsWidget docks never show it, even swapped in and out of the *same* persistent window repeatedly (ruling out window-freshness/QtAds-first-dock theories tried and reverted earlier this session). Further isolated to screen size: reproduces reliably on a screen too small for the assumed geometry, not on a larger one. OMNY_XRayEye.__init__() was the only widget in this GUI calling self.resize(800, 600) on itself. This widget is normally embedded as a dock (gui_tools.py's flomnigui_show_xeyealign()), where the dock manager (Qt Advanced Docking System) owns its geometry -- an explicit self-resize call fights that, and on an undersized screen corrupts the dock's layout until a manual move/resize forces Qt to reflow within actual available space. The standalone `python x_ray_eye.py` test harness at the bottom of this file already does its own win.resize(1000, 800) after construction regardless, so this call was never actually needed even there -- pure dead weight that only caused harm once embedded. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index e170f304..f11a4a54 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -329,7 +329,20 @@ class OMNY_XRayEye(BECWidget, QWidget): ) self.connect_motors() - self.resize(800, 600) + # No self.resize() here: this widget is normally embedded as a dock + # (see gui_tools.py's flomnigui_show_xeyealign()), where the dock + # manager (Qt Advanced Docking System) owns its geometry -- an + # explicit self-resize call fights that, and on a screen too small + # for the assumed geometry (gui_tools.py's hardcoded 2560px-wide + # assumption) corrupts the dock's layout until the operator manually + # moves/resizes the window (forcing Qt to reflow within the actual + # available space) -- confirmed live: reproduces reliably on a + # smaller screen, not on a large one, and only for this widget (the + # only one in this GUI with a self-resize call), never for the + # camera/console docks shown via flomnigui_show_cameras(). The + # standalone `python x_ray_eye.py` harness below already does its + # own win.resize(1000, 800) after construction regardless, so this + # call was never actually needed even for that use case. QTimer.singleShot(0, self._init_queue_status) QTimer.singleShot(0, self._init_gui_trigger) QTimer.singleShot(0, self._init_pixel_clock_options) -- 2.54.0 From 74996f78e1481c6710f68d84caff4144c3dbac1e Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 18 Sep 2026 08:41:07 +0200 Subject: [PATCH 37/78] feat(flomni,lamni): add on-demand tomo timing report commands tomo_scan()'s elapsed/idle timing breakdown only printed on normal completion, so it was unreachable after a KeyboardInterrupt even though the underlying data (self.progress, a BEC global var) survives the interrupt. Factor the report construction into _build_timing_lines() and expose it via tomo_print_timing_report() (console only) and tomo_print_timing_report_and_scilog() on both Flomni and LamNI. Also flags the same pre-existing gap on OMNY in a TODO note. Co-Authored-By: Claude Sonnet 5 --- .../bec_ipython_client/plugins/LamNI/lamni.py | 86 ++++++++++++----- .../plugins/flomni/flomni.py | 93 +++++++++++++------ .../omny/AI_docs/TODO_tomo_timing_report.md | 38 ++++++++ 3 files changed, 166 insertions(+), 51 deletions(-) create mode 100644 csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_timing_report.md diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index 58a4658b..226cd42b 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -408,6 +408,66 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self._progress_proxy.reset() print("Tomo progress reset.") + def _build_timing_lines(self, header: str = "Tomoscan finished.") -> list: + """Build the timing-report lines (elapsed / idle / excl.-idle + breakdown) from the current ``self.progress`` state. + + Shared by the end-of-scan report in ``tomo_scan()`` and the + on-demand ``tomo_print_timing_report()`` / ``_and_scilog()`` + commands, so a scan interrupted mid-flight can still get this + breakdown -- ``self.progress`` is a BEC global var, not a local, + so it survives a KeyboardInterrupt raised inside ``tomo_scan()``. + """ + idle_s = self.progress.get("accumulated_idle_time", 0.0) + start_str = self.progress.get("tomo_start_time") + elapsed_s = None + if start_str is not None: + try: + elapsed_s = ( + datetime.datetime.now() - datetime.datetime.fromisoformat(start_str) + ).total_seconds() + except (ValueError, TypeError): + elapsed_s = None + timing_lines = [ + header, + f"Measurement ID: {self.tomo_id}", + f"Sample: {self.sample_name}", + ] + if self.at_each_angle_hook: + timing_lines.append(f"At-each-angle hook: {self._describe_active_hook()}") + if elapsed_s is not None: + timing_lines.append(f"Total measurement time: {self._format_duration(elapsed_s)}") + timing_lines.append( + f"Total measurement time excluding detected gaps: {self._format_duration(elapsed_s - idle_s)}" + ) + timing_lines.append( + f"Total measurement time lost to detected gaps: {self._format_duration(idle_s)}" + ) + return timing_lines + + def tomo_print_timing_report(self) -> None: + """Print the current tomo timing breakdown (elapsed / idle / excl. + idle) for whatever scan is or was last in progress. + + Callable at any time, including right after interrupting a scan + with Ctrl-C, since the underlying data (self.progress) survives + the interrupt. Console only -- does not write to scilog. + """ + timing_lines = self._build_timing_lines(header="Tomo timing report (current progress).") + for line in timing_lines: + print(line) + + def tomo_print_timing_report_and_scilog(self) -> None: + """Same as tomo_print_timing_report(), but also writes the report + to scilog -- useful for documenting the timing of a scan that was + interrupted rather than left undocumented. + """ + timing_lines = self._build_timing_lines(header="Tomo timing report (current progress).") + for line in timing_lines: + print(line) + timing_content = "\n".join(timing_lines) + self.write_to_scilog(timing_content, ["tomoscan"]) + @property def alignment_scan_progress(self) -> _AlignmentScanProgressProxy: """Proxy dict backed by the BEC global variable ``alignment_scan_progress``. @@ -1906,31 +1966,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self._log_tomogram_timing() self.OMNYTools.printgreenbold("Tomoscan finished") - idle_s = self.progress.get("accumulated_idle_time", 0.0) - start_str = self.progress.get("tomo_start_time") - elapsed_s = None - if start_str is not None: - try: - elapsed_s = ( - datetime.datetime.now() - datetime.datetime.fromisoformat(start_str) - ).total_seconds() - except (ValueError, TypeError): - elapsed_s = None - timing_lines = [ - "Tomoscan finished.", - f"Measurement ID: {self.tomo_id}", - f"Sample: {self.sample_name}", - ] - if self.at_each_angle_hook: - timing_lines.append(f"At-each-angle hook: {self._describe_active_hook()}") - if elapsed_s is not None: - timing_lines.append(f"Total measurement time: {self._format_duration(elapsed_s)}") - timing_lines.append( - f"Total measurement time excluding detected gaps: {self._format_duration(elapsed_s - idle_s)}" - ) - timing_lines.append( - f"Total measurement time lost to detected gaps: {self._format_duration(idle_s)}" - ) + timing_lines = self._build_timing_lines() for line in timing_lines[3:]: print(line) timing_content = "\n".join(timing_lines) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 5cfda171..933fbcb5 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -2896,32 +2896,7 @@ class Flomni( datetime.date.today().strftime("%Y-%m"), status="completed", ) - idle_s = self.progress.get("accumulated_idle_time", 0.0) - start_str = self.progress.get("tomo_start_time") - elapsed_s = None - if start_str is not None: - try: - elapsed_s = ( - datetime.datetime.now() - datetime.datetime.fromisoformat(start_str) - ).total_seconds() - except (ValueError, TypeError): - elapsed_s = None - timing_lines = [ - "Tomoscan finished.", - f"Measurement ID: {self.tomo_id}", - f"Sample: {self.sample_name}", - ] - hook_description = self._describe_active_hook() - if hook_description: - timing_lines.append(f"At-each-angle hook: {hook_description}") - if elapsed_s is not None: - timing_lines.append(f"Total measurement time: {self._format_duration(elapsed_s)}") - timing_lines.append( - f"Total measurement time excluding detected gaps: {self._format_duration(elapsed_s - idle_s)}" - ) - timing_lines.append( - f"Total measurement time lost to detected gaps: {self._format_duration(idle_s)}" - ) + timing_lines = self._build_timing_lines() for line in timing_lines[3:]: print(line) timing_content = "\n".join(timing_lines) @@ -2981,6 +2956,72 @@ class Flomni( self._progress_proxy.reset() print("Tomo progress reset.") + def _build_timing_lines(self, header: str = "Tomoscan finished.") -> list: + """Build the timing-report lines (elapsed / idle / excl.-idle + breakdown) from the current ``self.progress`` state. + + Shared by the end-of-scan report in ``tomo_scan()`` and the + on-demand ``tomo_print_timing_report()`` / ``_and_scilog()`` + commands, so a scan interrupted mid-flight can still get this + breakdown -- ``self.progress`` is a BEC global var, not a local, + so it survives a KeyboardInterrupt raised inside ``tomo_scan()``. + """ + idle_s = self.progress.get("accumulated_idle_time", 0.0) + start_str = self.progress.get("tomo_start_time") + elapsed_s = None + if start_str is not None: + try: + elapsed_s = ( + datetime.datetime.now() - datetime.datetime.fromisoformat(start_str) + ).total_seconds() + except (ValueError, TypeError): + elapsed_s = None + timing_lines = [ + header, + f"Measurement ID: {self.tomo_id}", + f"Sample: {self.sample_name}", + ] + hook_description = self._describe_active_hook() + if hook_description: + timing_lines.append(f"At-each-angle hook: {hook_description}") + if elapsed_s is not None: + timing_lines.append(f"Total measurement time: {self._format_duration(elapsed_s)}") + timing_lines.append( + f"Total measurement time excluding detected gaps: {self._format_duration(elapsed_s - idle_s)}" + ) + timing_lines.append( + f"Total measurement time lost to detected gaps: {self._format_duration(idle_s)}" + ) + return timing_lines + + def tomo_print_timing_report(self) -> None: + """Print the current tomo timing breakdown (elapsed / idle / excl. + idle) for whatever scan is or was last in progress. + + Callable at any time, including right after interrupting a scan + with Ctrl-C, since the underlying data (self.progress) survives + the interrupt. Console only -- does not write to scilog. + """ + timing_lines = self._build_timing_lines(header="Tomo timing report (current progress).") + for line in timing_lines: + print(line) + + def tomo_print_timing_report_and_scilog(self) -> None: + """Same as tomo_print_timing_report(), but also writes the report + to scilog -- useful for documenting the timing of a scan that was + interrupted rather than left undocumented. + """ + timing_lines = self._build_timing_lines(header="Tomo timing report (current progress).") + for line in timing_lines: + print(line) + timing_content = "\n".join(timing_lines) + hook_source = self._active_hook_source() + if hook_source: + timing_content += ( + f"\n\nAt-each-angle hook source ('{self.at_each_angle_hook}'):\n{hook_source}" + ) + self._scilog_write(timing_content, "tomoscan") + @staticmethod def _format_duration(seconds: float) -> str: """Format a duration in seconds as a human-readable string, e.g. '2h 03m 15s'.""" diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_timing_report.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_timing_report.md new file mode 100644 index 00000000..982947a6 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_timing_report.md @@ -0,0 +1,38 @@ +# TODO: OMNY has no end-of-scan timing summary at all, so it's also missing the new tomo_print_timing_report() commands + +Found while adding `tomo_print_timing_report()` / `tomo_print_timing_report_and_scilog()` +to flomni and LamNI (branch `fixes/flomni_beamtime_2`) so the elapsed/idle +timing breakdown that normally only prints at the end of a *completed* +`tomo_scan()` can also be pulled on demand -- in particular right after +interrupting a scan with Ctrl-C, since the underlying `self.progress` data +(a BEC global var) survives the interrupt even though the old inlined +end-of-scan print block did not run. + +## 1. No "Tomoscan finished" timing block on OMNY at all (pre-existing gap) + +flomni (`flomni.py`) and LamNI (`lamni.py`) both print an elapsed/idle/ +excl.-idle timing breakdown at the tail of `tomo_scan()`, built from +`self.progress` (`tomo_start_time`, `accumulated_idle_time`) via a shared +`_build_timing_lines()` helper (flomni.py, near line 2984; lamni.py, near +line 411). `OMNY.tomo_scan()` (omny.py:1005) has no equivalent block at +all -- it was added to flomni/lamni after the flomni-to-lamni migration and +was never back-ported to OMNY. + +## 2. New on-demand commands also missing + +Both setups now also expose: +- `tomo_print_timing_report()` -- prints the current timing breakdown to + console at any time (mid-scan, after an interrupt, or after completion), + no scilog write. +- `tomo_print_timing_report_and_scilog()` -- same, plus writes the report to + scilog. + +If OMNY is still in active use, porting this means: adding an +`_ProgressProxy`-equivalent `tomo_start_time`/`accumulated_idle_time` +tracking (OMNY may already track some of this differently -- check before +assuming it needs the same proxy), then copying +`_build_timing_lines()`/`tomo_print_timing_report()`/ +`tomo_print_timing_report_and_scilog()` across with OMNY's own +`write_to_scilog`/`_scilog_write` equivalent (omny.py:855). + +Not scoped/designed further here -- just flagging it so it isn't lost. -- 2.54.0 From bf6f89636a5b3823ee5ca688d382515ed3ce7b94 Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 18 Sep 2026 09:19:41 +0200 Subject: [PATCH 38/78] feat(flomni,lamni): clear tomo queue and reset progress on account change The account-change check already offered to reset scan parameters to defaults for a new experiment; extend it to also clear the tomo queue and reset acquisition progress, so a new account doesn't inherit leftover queued jobs or in-progress state from the previous account. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- .../bec_ipython_client/plugins/LamNI/lamni.py | 17 ++++++++++++----- .../bec_ipython_client/plugins/flomni/flomni.py | 15 +++++++++++---- .../test_lamni_account_change_reset.py | 10 ++++++++++ 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index 226cd42b..785c02e4 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -273,15 +273,20 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self._webpage_gen.start(force=True) def _maybe_reset_params_on_account_change(self) -> None: - """Offer a tomo-parameter reset when the active account has changed. + """Offer a tomo-parameter/queue/progress reset when the active + account has changed. Called at BEC session start. The account for which defaults were last applied is stored in the global var ``defaults_applied_for_account`` so it survives client restarts. Only a genuine change of account triggers the (interactive) reset; the same account is a silent no-op, so restarting a client within the same experiment never disturbs tuned - parameters. Mirrors Flomni._maybe_reset_params_on_account_change() - exactly. + parameters, the tomo queue, or in-progress acquisition state. Mirrors + Flomni._maybe_reset_params_on_account_change() exactly. + + On a confirmed reset, this also clears the tomo queue and resets tomo + acquisition progress (via tomo_queue_clear()/tomo_progress_reset()), + since both are leftover state from the previous account/experiment. """ bec = builtins.__dict__.get("bec") try: @@ -298,11 +303,13 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools if self.OMNYTools.yesno( f"New account '{account}' detected (previous: '{last_account}').\n" - "Reset tomo parameters to defaults for the new experiment?", + "Reset tomo parameters to defaults, clear the tomo queue, and " + "reset tomo acquisition progress for the new experiment?", "y", ): self._set_default_tomo_params() - print(f"Tomo parameters reset to defaults for account '{account}'.") + self.tomo_queue_clear() + self.tomo_progress_reset() self.client.set_global_var("defaults_applied_for_account", account) def _set_default_tomo_params(self) -> None: diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 933fbcb5..519d1bf7 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -1820,14 +1820,19 @@ class Flomni( self._webpage_gen.start(force=True) def _maybe_reset_params_on_account_change(self) -> None: - """Offer a tomo-parameter reset when the active account has changed. + """Offer a tomo-parameter/queue/progress reset when the active + account has changed. Called at BEC session start. The account for which defaults were last applied is stored in the global var ``defaults_applied_for_account`` so it survives client restarts. Only a genuine change of account triggers the (interactive) reset; the same account is a silent no-op, so restarting a client within the same experiment never disturbs tuned - parameters. + parameters, the tomo queue, or in-progress acquisition state. + + On a confirmed reset, this also clears the tomo queue and resets tomo + acquisition progress (via tomo_queue_clear()/tomo_progress_reset()), + since both are leftover state from the previous account/experiment. """ bec = builtins.__dict__.get("bec") try: @@ -1844,11 +1849,13 @@ class Flomni( if self.OMNYTools.yesno( f"New account '{account}' detected (previous: '{last_account}').\n" - "Reset tomo parameters to defaults for the new experiment?", + "Reset tomo parameters to defaults, clear the tomo queue, and " + "reset tomo acquisition progress for the new experiment?", "y", ): self._set_default_tomo_params() - print(f"Tomo parameters reset to defaults for account '{account}'.") + self.tomo_queue_clear() + self.tomo_progress_reset() self.client.set_global_var("defaults_applied_for_account", account) def _set_default_tomo_params(self) -> None: diff --git a/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py b/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py index fe2ce65d..85a6dc42 100644 --- a/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py +++ b/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py @@ -11,6 +11,7 @@ import types import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import _TomoQueueProxy class FakeClient: @@ -55,6 +56,7 @@ def make_lamni(answer=True): obj = object.__new__(LamNI) obj.client = FakeClient() obj._progress_proxy = _ProgressProxy(obj.client) + obj._tomo_queue_proxy = _TomoQueueProxy(obj.client) obj.OMNYTools = FakeOMNYTools(answer=answer) obj.reconstructor = types.SimpleNamespace(folder_name=None) return obj @@ -87,6 +89,8 @@ def test_prompts_and_resets_on_new_account_when_confirmed(monkeypatch): lamni.client.set_global_var("defaults_applied_for_account", "e11111") lamni.tomo_shellstep = 99.0 lamni.tomo_circfov = 123.0 + lamni._tomo_queue_proxy.append({"label": "leftover job", "params": {}, "status": "pending"}) + lamni._progress_proxy.update(projection=42, angle=90.0) monkeypatch.setitem(builtins.__dict__, "bec", type("Bec", (), {"active_account": "e22222"})()) lamni._maybe_reset_params_on_account_change() @@ -97,18 +101,24 @@ def test_prompts_and_resets_on_new_account_when_confirmed(monkeypatch): assert lamni.tomo_shellstep == 1 assert lamni.tomo_circfov == 0.0 assert lamni.client.get_global_var("defaults_applied_for_account") == "e22222" + assert lamni._tomo_queue_proxy.as_list() == [] + assert lamni._progress_proxy.as_dict() == _ProgressProxy._DEFAULTS def test_records_account_without_resetting_when_declined(monkeypatch): lamni = make_lamni(answer=False) lamni.client.set_global_var("defaults_applied_for_account", "e11111") lamni.tomo_shellstep = 99.0 + lamni._tomo_queue_proxy.append({"label": "leftover job", "params": {}, "status": "pending"}) + lamni._progress_proxy.update(projection=42, angle=90.0) monkeypatch.setitem(builtins.__dict__, "bec", type("Bec", (), {"active_account": "e22222"})()) lamni._maybe_reset_params_on_account_change() assert lamni.tomo_shellstep == 99.0 assert lamni.client.get_global_var("defaults_applied_for_account") == "e22222" + assert len(lamni._tomo_queue_proxy.as_list()) == 1 + assert lamni._progress_proxy["projection"] == 42 def test_set_default_tomo_params_matches_getter_fallbacks(): -- 2.54.0 From cd838e4f73b1707c988d2fab329955d507804693 Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 18 Sep 2026 13:46:12 +0200 Subject: [PATCH 39/78] fix(eps): enforce hard byte budget on eps_alarm_history.json uploads The 2026-09-11 fix capped alarm-text *count* per event (_ALARM_TEXTS_MAX), but not text length, and _load() never re-caps an already-oversized event's texts list -- only new appends are capped. Confirmed on x12sa: the file had grown to 1,209,271 bytes, still tripping HTTP 413 on every upload cycle (upload.php itself has no size check -- almost certainly nginx's default client_max_body_size=1m). HistoryTracker.save() now shrinks a copy of the payload to a ~250 KB budget (alarm texts, then ring trace, then oldest events, then field length as a last resort) before writing/uploading, so the file can no longer exceed the server's limit regardless of how the underlying data grows. In-memory history is untouched. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- .../OMNY_shared/eps/eps_status_generator.py | 67 ++++++++++- .../test_eps_history_shrink.py | 104 ++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 tests/tests_bec_ipython_client/test_eps_history_shrink.py diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status_generator.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status_generator.py index e886ac54..88530f65 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/eps/eps_status_generator.py @@ -96,6 +96,18 @@ _ALARM_TEXTS_MAX = 100 # distinct alarm texts kept on a single open eps_e # only one event is ever open at a time, so this bounds # total history growth during a long-running flapping alarm +_MAX_UPLOAD_BYTES = 250_000 # hard ceiling on eps_alarm_history.json's serialized size, + # enforced in HistoryTracker.save(). _HISTORY_MAX/ + # _ALARM_TEXTS_MAX/_TRACE_MAX bound the *shape* of the data + # but not each alarm text's length, and _load() never re-caps + # a stored event's texts list -- only new appends are capped + # -- so a sufficiently verbose/flapping EPS alarm can still + # push the file past the upload server's request-size limit + # (a bare HTTP 413, no size check in upload.php itself; almost + # certainly nginx's client_max_body_size, default 1m). This + # keeps comfortable headroom under that regardless of how the + # data grows. + _SEVERITY_NAMES = {0: "NO_ALARM", 1: "MINOR", 2: "MAJOR", 3: "INVALID"} _BEAMLINE_SUBNET_PREFIX = "129.129.122." # X12SA subnet, where EPS/machine PVs are reachable @@ -165,6 +177,56 @@ def _clean(value): return value +def _payload_size(payload: dict) -> int: + return len(json.dumps(payload, default=str)) + + +def _shrink_to_fit(payload: dict, max_bytes: int = _MAX_UPLOAD_BYTES) -> dict: + """Return a shrunk copy of a HistoryTracker.save() payload that + serializes to at most max_bytes. + + Trims progressively less-essential data -- alarm texts per event, then + the ring-current trace, then the oldest events, then (last resort, for a + single pathologically long string) individual field lengths -- stopping + as soon as the payload fits. + """ + payload = { + "eps_events": [dict(ev) for ev in payload["eps_events"]], + "machine_events": list(payload["machine_events"]), + "ring_current_trace": list(payload["ring_current_trace"]), + } + if _payload_size(payload) <= max_bytes: + return payload + + for texts_cap in (20, 5, 1): + for ev in payload["eps_events"]: + texts = ev.get("texts") + if isinstance(texts, list) and len(texts) > texts_cap: + ev["texts"] = texts[-texts_cap:] + if _payload_size(payload) <= max_bytes: + return payload + + while len(payload["ring_current_trace"]) > 12 and _payload_size(payload) > max_bytes: + payload["ring_current_trace"] = payload["ring_current_trace"][::2] + + while len(payload["eps_events"]) > 1 and _payload_size(payload) > max_bytes: + payload["eps_events"].pop(0) + + while len(payload["machine_events"]) > 1 and _payload_size(payload) > max_bytes: + payload["machine_events"].pop(0) + + if _payload_size(payload) > max_bytes: + for ev in payload["eps_events"]: + if isinstance(ev.get("texts"), list): + ev["texts"] = [t[:200] for t in ev["texts"]] + for mev in payload["machine_events"]: + for key in ("from", "to"): + if isinstance(mev.get(key), str): + mev[key] = mev[key][:200] + + return payload + + # --------------------------------------------------------------------------- # History tracking # --------------------------------------------------------------------------- @@ -210,9 +272,12 @@ class HistoryTracker: "machine_events": self.machine_events[-_HISTORY_MAX:], "ring_current_trace": self.ring_trace[-_TRACE_MAX:], } + # Shrink a copy for writing/uploading; the in-memory lists above keep + # their full (count-capped) history regardless of upload-size limits. + payload = _shrink_to_fit(payload) try: tmp = self._path.with_suffix(".tmp") - tmp.write_text(json.dumps(payload, indent=1, default=str)) + tmp.write_text(json.dumps(payload, default=str)) tmp.replace(self._path) except Exception as exc: logger.warning(f"HistoryTracker: cannot write {self._path}: {exc}") diff --git a/tests/tests_bec_ipython_client/test_eps_history_shrink.py b/tests/tests_bec_ipython_client/test_eps_history_shrink.py new file mode 100644 index 00000000..ee163635 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_eps_history_shrink.py @@ -0,0 +1,104 @@ +"""Tests for HistoryTracker's upload-size guard on eps_alarm_history.json. + +A long-running/flapping EPS alarm could push the uploaded file past the +upload server's request-size limit (bare HTTP 413, no size check in +upload.php itself -- almost certainly nginx's client_max_body_size, default +1m) even with the count-based caps in eps_status_generator.py, since those +bound event/text *counts* but not individual alarm-text length, and a +previously-oversized event's texts list is never re-capped on load. See +x12sa report 2026-09-18: eps_alarm_history.json observed at 1,209,271 bytes. +""" + +import json + +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.eps.eps_status_generator import ( + _MAX_UPLOAD_BYTES, + HistoryTracker, + _payload_size, + _shrink_to_fit, +) + + +def _bloated_payload(n_events=50, texts_per_event=100, text_len=250): + eps_events = [ + { + "first_seen": i, + "cleared_at": None, + "count": 3, + "texts": [f"alarm text variant {i}-{j} " + "x" * text_len for j in range(texts_per_event)], + } + for i in range(n_events) + ] + machine_events = [ + {"pv": f"PV{i}", "label": "x", "from": "a" * 20, "to": "b" * 20, "at": i} + for i in range(n_events) + ] + ring_current_trace = [{"t": i, "v": 400.0 + i * 0.01} for i in range(288)] + return { + "eps_events": eps_events, + "machine_events": machine_events, + "ring_current_trace": ring_current_trace, + } + + +def test_shrink_to_fit_is_noop_when_already_under_budget(): + payload = _bloated_payload(n_events=2, texts_per_event=1, text_len=10) + assert _payload_size(payload) <= _MAX_UPLOAD_BYTES + + shrunk = _shrink_to_fit(payload) + + assert shrunk == payload + assert shrunk is not payload # returns a copy, doesn't mutate the caller's data + + +def test_shrink_to_fit_brings_bloated_payload_under_budget(): + payload = _bloated_payload() + assert _payload_size(payload) > _MAX_UPLOAD_BYTES # reproduces the reported 1.2 MB case + + shrunk = _shrink_to_fit(payload) + + assert _payload_size(shrunk) <= _MAX_UPLOAD_BYTES + # trimming per-event alarm texts alone is enough here -- full event/trace + # history (the actually useful part) is preserved + assert len(shrunk["eps_events"]) == 50 + assert len(shrunk["machine_events"]) == 50 + assert len(shrunk["ring_current_trace"]) == 288 + + +def test_shrink_to_fit_does_not_mutate_input(): + payload = _bloated_payload() + original_texts_len = len(payload["eps_events"][0]["texts"]) + + _shrink_to_fit(payload) + + assert len(payload["eps_events"][0]["texts"]) == original_texts_len + + +def test_shrink_to_fit_drops_oldest_events_when_a_tiny_budget_forces_it(): + payload = _bloated_payload(n_events=10, texts_per_event=5, text_len=50) + + shrunk = _shrink_to_fit(payload, max_bytes=500) + + assert _payload_size(shrunk) <= 500 or len(shrunk["eps_events"]) == 1 + # oldest dropped first: whatever's left is the most recent + if len(shrunk["eps_events"]) < 10: + kept_ids = [ev["first_seen"] for ev in shrunk["eps_events"]] + assert kept_ids == sorted(kept_ids) + assert kept_ids[-1] == 9 + + +def test_history_tracker_save_writes_file_under_budget(tmp_path): + path = tmp_path / "eps_alarm_history.json" + tracker = HistoryTracker(path) + bloated = _bloated_payload() + tracker.eps_events = bloated["eps_events"] + tracker.machine_events = bloated["machine_events"] + tracker.ring_trace = bloated["ring_current_trace"] + + tracker.save() + + written_size = path.stat().st_size + assert written_size <= _MAX_UPLOAD_BYTES + # written file is still valid JSON with the expected top-level shape + data = json.loads(path.read_text()) + assert set(data.keys()) == {"eps_events", "machine_events", "ring_current_trace"} -- 2.54.0 From 7d21c0e43ebe43195b5021099283ff85109b9686 Mon Sep 17 00:00:00 2001 From: x01dc Date: Sat, 19 Sep 2026 09:11:31 +0200 Subject: [PATCH 40/78] docs(panda): note proposed gated-continuous PCAP capture variant Record the exposure-active-gated PCAP.gate variant discussed for reducing idle-time data volume in raw_stream_mode (currently PCAP free-runs via CLOCK1 regardless of detector state). Documentation only -- not implemented or tested against hardware. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- .../developer/panda_box_free_running_setup.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/developer/panda_box_free_running_setup.md b/docs/developer/panda_box_free_running_setup.md index 74764e10..a872010f 100644 --- a/docs/developer/panda_box_free_running_setup.md +++ b/docs/developer/panda_box_free_running_setup.md @@ -116,6 +116,54 @@ layout can be restored unambiguously (see `ptycho_flomni.yaml`): CLOCK1 (enabled by constant ONE) ──▶ PCAP.gate, PCAP.trig (continuous arm/capture) ``` +### Possible variant: gate continuous capture by exposure-active (proposed, not implemented) + +Discussed 2026-09-19, not yet implemented or tested against hardware. Today +`PCAP.gate` and `PCAP.trig` are both driven by `CLOCK1.OUT`, so PCAP +free-runs continuously at `CLOCK1`'s rate regardless of whether the detector +is actually exposing -- including idle/dead time between exposures. The +proposal is to split the two roles so they're driven by different sources: + +``` +CLOCK1 (enabled by constant ONE) ──▶ PCAP.trig (sample-rate clock, unchanged) +TTLIN1.VAL (detector-trigger / exposure-active) ──▶ PCAP.gate (only capture while exposing) +``` + +`PCAP.trig` keeps ticking at `CLOCK1`'s configured rate exactly as today; +`PCAP.gate` switches from `CLOCK1.OUT` to `TTLIN1.VAL`, so a `CLOCK1` tick +only produces a captured row while the detector is actively exposing -- +idle-time ticks are simply dropped, not written. + +This should be a pure PandA-layout change -- no `ptycho_flomni.yaml` or +`panda_box_omny.py` change would be needed, since it doesn't change which +BEC signal names exist or what they mean: + +- `TTLIN1.VAL` is already proven usable as a `PCAP.gate` source -- it's what + Mode A already uses (`PCAP.GATE`/`PCAP.TRIG` = `TTLIN1.VAL` there). +- `frame_counter` (`COUNTER1`, clocked directly off `TTLIN1.VAL`'s falling + edge, not off `PCAP`) is unaffected -- still counts every completed + exposure and still works as the completion/grouping signal exactly as + today. +- `gate_detector_active` (`PCAP.BITS0` bit 0, also `TTLIN1.VAL`) would become + trivially always `1` on every captured row, since a row is only ever + captured while that signal is high -- harmless, since `frame_counter` + already does the actual per-exposure grouping, not the gate bit. + +Motivation: at 20 kHz continuous (current, ungated behavior), the +free-running raw stream produces roughly 67-86 MB/minute (~4-5 GB/hour) of +raw payload across the 10 captured fields (see +`panda_box_free_running_data_schema.md` for the field list; the range +reflects uncertainty over whether the four `INENC` channels report as +`int32` or `float64`). Gating capture to only actual exposure windows would +cut that in proportion to the detector's duty cycle -- e.g. roughly 10x less +at a 10% duty cycle -- with no BEC-side software change required. + +If this is implemented: update this section (and the "Continuous gate/arm" +wiring diagram above) to confirmed status, record which `.ini` layout +captures it, and re-verify the `PCAP.BITS0` bit-0 assignment per the caveat +under "Gate-bit input" above (bit assignment is fixed by the FPGA build, not +guaranteed stable across a layout rebuild). + ## Saving / restoring layouts Use `ophyd_devices/devices/panda_box/utility_scripts.py` to save the current -- 2.54.0 From 22a882fafab422f9cc264afe58771de43a1f5ab6 Mon Sep 17 00:00:00 2001 From: x01dc Date: Sat, 19 Sep 2026 09:56:01 +0200 Subject: [PATCH 41/78] docs: note proposed interferometer signal-strength trend on webpage Design note only -- not implemented. Captures the idea (low-rate/~hourly trend, not per-projection), plus a 2026-09-19 survey finding that flomni/ lamni/omny each have incompatible interferometer-read primitives (different axis counts/semantics, no shared RT-controller base), and that omny has no working webpage generator to hook into yet (its existing file is dead code, never imported by the real factory). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- .../TODO_interferometer_signal_trend.md | 114 ++++++++++++++++++ .../plugins/flomni/AI_docs/Webpage.md | 7 ++ 2 files changed, 121 insertions(+) create mode 100644 csaxs_bec/bec_ipython_client/plugins/OMNY_shared/AI_docs/TODO_interferometer_signal_trend.md diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/AI_docs/TODO_interferometer_signal_trend.md b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/AI_docs/TODO_interferometer_signal_trend.md new file mode 100644 index 00000000..d4514bed --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/AI_docs/TODO_interferometer_signal_trend.md @@ -0,0 +1,114 @@ +# TODO: interferometer signal-strength trend on the webpage + +Status: **proposed, not implemented.** Discussed 2026-09-19. This is a design +note to capture the idea before it's lost -- no code changes yet. + +## Goal + +A new section on the flomni/lamni/omny status webpage showing the trend of +interferometer signal strength over time (a sparkline/chart, not a live +readout), so drift/degradation is visible at a glance across a session or +beamtime, not just from an interactive `show_signal_strength_interferometer()` +call in the moment. + +## Sampling design + +- **Very low rate on purpose**: roughly one data point per hour, or less -- + explicitly *not* per-projection. A tomo scan already calls a signal-strength + check regularly (flomni: every projection, see below), but sampling that + often would produce far too many points for a trend view (tens of points + per hour per axis) for no benefit -- this is about long-term drift, not + per-projection diagnostics. +- **Timestamped, so per-axis cadence can differ freely.** Each data point is + `(timestamp, value)`; there's no requirement that every axis be sampled at + the same time or the same rate. An axis that's cheap to read opportunistically + (e.g. already fetched as a side effect of something else) can be sampled + more often than one that costs a dedicated round-trip, and the trend for + each axis is still valid independently. Staleness/eventual-consistency + between axes, or between this and the live scan, is fine -- the goal is a + general trend, not point-in-time precision. +- **Retention**: apply the lesson from `eps_alarm_history.json`'s HTTP 413 + bug (see `docs/developer/...` -- fixed 2026-09-18 in `eps_status_generator.py`, + `_MAX_UPLOAD_BYTES`/`_shrink_to_fit`): cap this ring buffer by *both* count + and a hard byte budget from the start, reusing that same pattern, not just + a count cap discovered insufficient after the fact. + +## Per-setup reality check (2026-09-19 survey -- see below for what's actually there) + +The three setups' interferometer-read primitives are **not interchangeable** +-- this can't be one generic helper at the RT-controller layer: + +| Setup | Read primitive | Axes/channels | Threshold/check helper | +|---|---|---|---| +| flomni | `RtFlomniController.read_ssi_interferometer(axis_number)` (`rt_flomni_ophyd.py:462`), generic `j{n}` socket read | 0-3 | `laser_tracker_check_signalstrength()` (`rt_flomni_ophyd.py:466`) -- reads axis 1, warns only, does not act | +| LamNI | **no equivalent single-axis reader.** `RtLamniController.show_signal_strength_interferometer()` (`rt_lamni_ophyd.py:268`) reads 3 channels (ST FZP horizontal/vertical, angle) via distinct `J2`/`J3`/`J7` commands | 3 (different meaning than flomni's 0-3) | none -- no `min_signal`/`low_signal` threshold logic exists in this file at all | +| omny | `RtOMNYController.laser_tracker_check_and_wait_for_signalstrength()` (`rt_omny_ophyd.py:270`) -- different behavior class: polls and *auto-triggers* `omny_interferometer_align_tracking()` if below `low_signal`, not just a warning. `show_signal_strength_interferometer()` (`rt_omny_ophyd.py:736`) reads 5 channels via a `self.ssi` dict from `feedback_get_status_and_ssi()` | 5 (OSA FZP Y/X, ST OSA Y/X, Angle) | yes, but actively corrective, not passive | + +There is no shared `csaxs_bec`-level RT-controller base across the three +(`RtFlomniController`/`RtLamniController`/`RtOMNYController` each subclass the +generic external `ophyd_devices.utils.controller.Controller` directly). LamNI +also doesn't reuse the one shared local module that does exist +(`csaxs_bec/devices/omny/rt/rt_ophyd.py`'s `RtSignalRO`/etc., used by flomni +and omny) -- it redefines its own parallel Signal classes. + +**Conclusion**: the shared piece of this feature should be the trend +storage/rendering (a common global-var shape + webpage section), not the +signal read itself. Each setup needs its own small adapter that knows its own +axis semantics and feeds the shared throttled-stash helper. + +## Where the throttled read would hook in, per setup + +- **flomni**: `laser_tracker_check_signalstrength()` is already called once + per projection during `tomo_scan()` (`flomni.py:3624`) -- cheapest option is + to throttle *at that call site* (check "have I stashed a data point in the + last hour?" before writing one), reusing an already-happening RT round-trip + rather than adding a new one. +- **LamNI**: confirmed (2026-09-19 survey) there is **no** scan-time + signal-strength call anywhere in the LamNI plugin -- `show_signal_strength` + (`lamni.py:724`) is a manual/interactive-only helper, never called from + `tomo_scan_projection` or `tomo_scan`. There's no existing per-projection + hook to throttle against, so this would need a separate low-rate timer + instead -- the natural fit is `WebpageGeneratorBase`'s own `_cycle()` loop + (`OMNY_shared/webpage_generator_base.py`, default 15s cadence, already runs + continuously including when idle), which already has a documented + subclass-override hook for instrument-specific data + (`_collect_setup_data()`, used today by `FlomniWebpageGenerator`/ + `LamniWebpageGenerator` for temperatures/sample name/etc.) -- add the + throttle check there instead of in the scan loop. +- **omny**: blocked on a prerequisite -- **omny has no working webpage + generator at all right now.** `omny_webpage_generator.py` looks like one + (defines `WebpageGeneratorBase`, `FlomniWebpageGenerator`, a + `make_webpage_generator()`, etc.) but is confirmed dead/orphaned: nothing + in `omny.py`/`omny/__init__.py` imports it, and the real factory + (`OMNY_shared/webpage_generator_base.py` ~line 1409) explicitly falls + through to the plain base class for the omny session with a comment noting + this file is "an unwired, stale duplicate." Building a real + `OmnyWebpageGenerator` subclass is a prerequisite for this feature on omny, + not just an extra per-setup adapter -- do not revive the stale duplicate + as-is. + +## Suggested shape (for whenever this is implemented) + +- A global var, e.g. `interferometer_signal_trend`, holding a small + ring-buffer list of `{timestamp, axis, value}` (or per-axis sub-lists), + written via a proxy class mirroring `_ProgressProxy`/`_TomoQueueProxy` + (`OMNY_shared/tomo_queue_mixin.py`) -- the established pattern in this + codebase for cross-process/cross-thread state via + `client.get_global_var`/`set_global_var`. +- A shared rendering piece (webpage HTML/JS section, sparkline or simple line + chart) that reads this same global var shape regardless of which setup + wrote it, so the display code doesn't need to know about per-setup axis + differences -- only each setup's adapter does. +- Byte-budget-capped storage per the eps history lesson above. + +## Open questions (not yet decided) + +- Exact axis(es) worth trending per setup -- flomni's axis 1 is free (already + read every projection); trending flomni's other 3 axes, or any of LamNI's/ + omny's channels, means new dedicated round-trips on whatever cadence is + chosen for those. +- Whether sampling should continue during idle time between scans (needs a + timer-based approach for all three setups, not just LamNI/omny) or only + while a scan is actively running (cheaper, but leaves gaps covering the + most interesting idle-drift periods). +- Exact webpage layout/placement of the new section -- not designed yet. diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/Webpage.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/Webpage.md index 18554f43..a9eca2cb 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/Webpage.md +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/Webpage.md @@ -198,6 +198,13 @@ cannot drift. - **Consider**: the PSI **EPICS archiver REST interface** to sidestep CA firewall concerns. +### Interferometer signal-strength trend (proposed, not implemented) +- See `OMNY_shared/AI_docs/TODO_interferometer_signal_trend.md` for the full + design note (2026-09-19) — a new webpage section trending interferometer + signal strength over time, sampled at ~1 point/hour or less (not + per-projection). Blocked on omny specifically needing a real + `OmnyWebpageGenerator` built first (its current file is dead/unwired code). + ### Deferred - **LamNI**: fill in `LamniWebpageGenerator` — real `tomo_type` key values in `TOMO_TYPES`, device paths / `_collect_setup_data()`, `LamNI.png`. Add a -- 2.54.0 From 0a12d4892f80097c9049404f602f7fa082500a3e Mon Sep 17 00:00:00 2001 From: x01dc Date: Sat, 19 Sep 2026 10:06:49 +0200 Subject: [PATCH 42/78] feat(flomni,lamni): unconditional 0-deg reference projection at scan start Take one real 0-degree projection at the very start of every new tomogram (subtomo_number=1, included in reconstruction like any grid projection), independent of zero_deg_reference_at_each_subtomo / golden_projections_at_0_deg_for_damage_estimation -- gives an immediate radiation-damage baseline even when those flags are off. Placed right after Flomni's existing collect_empty_frames() step; LamNI gets the same 0-deg shot but no flat-field step, since it has no way to retract the sample out of the beam. Omny intentionally left as a follow-up (not implemented here). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- .../bec_ipython_client/plugins/LamNI/lamni.py | 12 ++++++++++++ .../bec_ipython_client/plugins/flomni/flomni.py | 10 ++++++++++ .../test_lamni_tomo_angles.py | 17 +++++++++++++---- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index 785c02e4..506cd8e5 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -1836,6 +1836,18 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self.progress["estimated_finish_time"] = None self.progress["accumulated_idle_time"] = 0.0 self.progress["heartbeat"] = None + # Unconditional single 0-degree reference projection at the very + # start of a new tomogram, independent of + # zero_deg_reference_at_each_subtomo / + # golden_projections_at_0_deg_for_damage_estimation -- gives an + # immediate radiation-damage baseline even when those flags are + # off. Real data (uses _tomo_scan_at_angle like any grid + # projection), included in reconstruction like any other + # projection. Logged under subtomo_number=1. Mirrors Flomni's + # equivalent addition -- no flat-field/empty-frame step here + # (unlike Flomni's collect_empty_frames()), since LamNI has no + # way to retract the sample out of the beam. + self._tomo_scan_at_angle(0, 1) try: with scans.dataset_id_on_hold: diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 519d1bf7..cfd575a9 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -2731,6 +2731,16 @@ class Flomni( self.progress["accumulated_idle_time"] = 0.0 self.progress["heartbeat"] = None self.collect_empty_frames() + # Unconditional single 0-degree reference projection at the very + # start of a new tomogram, independent of + # zero_deg_reference_at_each_subtomo / + # golden_projections_at_0_deg_for_damage_estimation -- gives an + # immediate radiation-damage baseline even when those flags are + # off. Real data (uses _tomo_scan_at_angle like any grid + # projection), included in reconstruction like any other + # projection -- not a flat-field/reference-only frame like + # collect_empty_frames() above. Logged under subtomo_number=1. + self._tomo_scan_at_angle(0, 1) try: with scans.dataset_id_on_hold: diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py index f9a63fe9..36e9e220 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py @@ -164,6 +164,10 @@ def make_lamni_for_tomo_scan( obj.special_angles = [] obj.write_pdf_report = lambda: None obj.sub_tomo_scan = lambda subtomo_number, start_angle=None: None + # Default no-op for the unconditional 0-deg reference projection tomo_scan() + # now fires at the start of every new scan (see lamni.py) -- tests that + # care about it override this after calling make_lamni_for_tomo_scan(). + obj._tomo_scan_at_angle = lambda angle, subtomo_number: None obj._print_progress = lambda: None obj._format_duration = lambda seconds: "0s" obj.lamnigui_show_progress = lambda: None @@ -301,7 +305,9 @@ def test_subtomo_starts_near_zero(subtomo_number, expected): def test_zero_deg_reference_disabled_by_default(monkeypatch): """zero_deg_reference_at_each_subtomo defaults to False -- a fresh - tomo_scan() must not fire any extra 0-deg shots.""" + tomo_scan() must not fire any *flag-gated* extra 0-deg shots, but the + unconditional single 0-deg reference projection at scan start (see + lamni.py) always fires regardless of the flag.""" lamni = make_lamni_for_tomo_scan(monkeypatch, 45.0, active_account="") lamni.add_sample_database = lambda *a, **k: 0 recorded = [] @@ -312,13 +318,16 @@ def test_zero_deg_reference_disabled_by_default(monkeypatch): lamni.tomo_scan() - assert recorded == [] + assert recorded == [(0, 1)] def test_zero_deg_reference_fires_for_odd_subtomos_and_final_shot(monkeypatch): """With the flag on, a fresh tomo_scan() must fire an extra angle-0 shot before each odd sub-tomogram (1, 3, 5, 7) plus one final shot after - sub-tomogram 8 completes -- mirrors Flomni.tomo_scan()'s equivalent.""" + sub-tomogram 8 completes -- mirrors Flomni.tomo_scan()'s equivalent. The + leading (0, 1) is the unconditional scan-start reference projection + (fires regardless of the flag), followed by the flag's own (0, 1) for + sub-tomogram 1 itself -- the two are independent and both fire here.""" lamni = make_lamni_for_tomo_scan(monkeypatch, 45.0, active_account="") lamni.add_sample_database = lambda *a, **k: 0 lamni.zero_deg_reference_at_each_subtomo = True @@ -330,7 +339,7 @@ def test_zero_deg_reference_fires_for_odd_subtomos_and_final_shot(monkeypatch): lamni.tomo_scan() - assert recorded == [(0, 1), (0, 3), (0, 5), (0, 7), (0, 8)] + assert recorded == [(0, 1), (0, 1), (0, 3), (0, 5), (0, 7), (0, 8)] def test_zero_deg_reference_skipped_when_resuming_mid_subtomo(monkeypatch): -- 2.54.0 From 306b93cc0754936770eaf6d4d0f6e9f0eff4d170 Mon Sep 17 00:00:00 2001 From: x01dc Date: Sat, 19 Sep 2026 10:07:06 +0200 Subject: [PATCH 43/78] docs(omny): note unconditional 0-deg reference projection follow-up Not implemented for omny yet -- captures the same feature just added to Flomni/LamNI so the intent isn't lost. Omny still needs its own retraction- capability check and tomo_scan() entry point located before this can land. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- .../TODO_zero_deg_reference_projection.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_zero_deg_reference_projection.md diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_zero_deg_reference_projection.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_zero_deg_reference_projection.md new file mode 100644 index 00000000..da4c4e6e --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_zero_deg_reference_projection.md @@ -0,0 +1,40 @@ +# TODO: unconditional 0-degree reference projection at tomogram start + +Status: **not implemented for omny.** Discussed 2026-09-19; implemented for +Flomni and LamNI the same day (see their `tomo_scan()` in `flomni.py`/ +`lamni.py` — search for "Unconditional single 0-degree reference +projection"). + +## What was implemented elsewhere + +At the very start of a new tomogram (not on resume), both Flomni and LamNI +now unconditionally take one real 0-degree projection — `self._tomo_scan_at_angle(0, 1)` +— independent of the `zero_deg_reference_at_each_subtomo` / +`golden_projections_at_0_deg_for_damage_estimation` flags. It's real data +(included in reconstruction, logged under `subtomo_number=1`), not a +reference-only/flat-field frame — this was an explicit choice: the point is +an immediate radiation-damage baseline that exists even when the periodic +damage-estimation flags are off, not a normalization frame. + +Flomni additionally takes 10 flat-field/empty frames at 0° before this +(`collect_empty_frames()` — sample retracted out of the beam via `fsamx` +shifted by `fovx/2`, excluded from reconstruction). **LamNI does not get an +equivalent** — confirmed 2026-09-19 that LamNI has no way to retract the +sample out of the beam at all, so only the 0-degree projection was added +there, no flat-field step. + +## What's needed for omny + +1. **Confirm whether omny can retract the sample out of the beam at all** + (same open question as LamNI, not yet checked for omny specifically) — + determines whether an empty-frames/flat-field step is even possible here, + or whether omny should only get the 0-degree-projection part like LamNI. +2. Locate omny's `tomo_scan()` equivalent (or whatever its tomogram-start + entry point is) and its own `_tomo_scan_at_angle`-equivalent method, and + add the same unconditional call in its "new scan" branch, following the + same placement pattern used in `flomni.py`/`lamni.py`. +3. Decide the logged `subtomo_number` / whether omny's tomo-type numbering + matches flomni/lamni's convention closely enough to reuse `1` directly. + +Not investigated yet — this note exists so the intent isn't lost, not +because the omny-side work has been scoped in detail. -- 2.54.0 From 5096008286644c2099c51f2c7d3bce487272b144 Mon Sep 17 00:00:00 2001 From: x01dc Date: Sat, 19 Sep 2026 17:05:51 +0200 Subject: [PATCH 44/78] docs(flomni): document stitch_x/stitch_y tomo parameters flomni.md had no mention of stitching at all. Add a paragraph explaining the tiled-Fermat-scan mechanism (flomni.py:3521-3527) and tomo_stitch_overlap, matching the existing zero_deg_reference_at_each_subtomo callout's style. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- docs/user/ptychography/flomni.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index 932cf613..67fb08b0 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -372,6 +372,8 @@ Three modes for angular sampling are implemented and they have different optiona For the "8 sub-tomograms" mode, `flomni.tomo_parameters()` also offers a `zero_deg_reference_at_each_subtomo` option: when enabled, an additional reference projection at exactly 0 degrees is acquired at the start of every odd (forward) sub-tomogram and once more after the final sub-tomogram, useful for tracking radiation damage across the full measurement. +`flomni.tomo_parameters()` also offers `stitch_x`/`stitch_y` (integers, default 0, up to 50 each) for a field of view larger than a single Fermat scan can cover: when nonzero, every projection angle is acquired as a `(2*stitch_x+1) × (2*stitch_y+1)` grid of separate Fermat scans instead of a single one, tiled around the normal alignment-corrected center and spaced by `fovx`/`fovy` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(2*stitch_x+1) × fovx` by `(2*stitch_y+1) × fovy`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. Stitching is forced to 0 (disabled) whenever `single_point_instead_of_fermat_scan` is enabled. + The parameters above can be used to __restart an interrupted acquisition__ manually, or - more conveniently - by running `flomni.tomo_scan_resume()` which reads the last recorded progress and resumes automatically at the exact point (subtomogram/angle, or projection for the golden ratio modes) the scan was interrupted at, without needing to look up the values by hand. When running from the tomo scan queuing system use `flomni.tomo_queue_execute()` instead! -- 2.54.0 From 1e553b000493dfc46e5032ee958ef366640a68c1 Mon Sep 17 00:00:00 2001 From: x01dc Date: Sat, 19 Sep 2026 18:14:31 +0200 Subject: [PATCH 45/78] docs(lamni): document lamni_stitch_x/lamni_stitch_y; note omny follow-up Same gap as flomni.md (fixed previous commit): lamni.md had no mention of stitching. Add the equivalent paragraph using lamni's own parameter names (lamni_stitch_x/y, lamni_piezo_range_x/y) and cite tomo_scan_projection(), which passes them straight through to scans.lamni_fermat_scan(). Also add an omny/AI_docs TODO note -- omny.md has the same gap and omny's mechanism turns out to use identical parameter names to flomni's (stitch_x/stitch_y/tomo_stitch_overlap), so the flomni.md paragraph should carry over directly once written. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- .../omny/AI_docs/TODO_document_stitching.md | 31 +++++++++++++++++++ docs/user/ptychography/lamni.md | 2 ++ 2 files changed, 33 insertions(+) create mode 100644 csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_document_stitching.md diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_document_stitching.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_document_stitching.md new file mode 100644 index 00000000..2ab11066 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_document_stitching.md @@ -0,0 +1,31 @@ +# TODO: document stitch_x/stitch_y in the omny user manual + +Status: **not implemented.** `docs/user/ptychography/omny.md` has zero +mentions of stitching, same gap just fixed 2026-09-19 in `flomni.md` and +`lamni.md`. + +## What to add + +Omny's mechanism is essentially identical to Flomni's (same parameter +names, even) — `omny.py:1263-1269`: + +```python +for stitch_x in range(-self.stitch_x, self.stitch_x + 1): + for stitch_y in range(-self.stitch_y, self.stitch_y + 1): + ... + cenx = sum_offset_x + stitch_x * (self.fovx - self.tomo_stitch_overlap) + ceny = sum_offset_y + stitch_y * (self.fovy - self.tomo_stitch_overlap) +``` + +Same paragraph shape as the ones just added to `flomni.md`/`lamni.md` should +carry over directly, substituting nothing (param names are identical): +`stitch_x`/`stitch_y` (default 0), tiled `(2*stitch_x+1) × (2*stitch_y+1)` +grid of scans per projection angle, spaced by `fovx`/`fovy` minus +`tomo_stitch_overlap` (µm, default 0.2), effective FOV roughly +`(2*stitch_x+1) × fovx` by `(2*stitch_y+1) × fovy`. + +Not yet checked: omny.md's actual section structure/where the equivalent of +flomni's "### Tomography" / lamni's "### Test projections and Laminography +scan" section lives, and whether omny has anything resembling +`single_point_instead_of_fermat_scan`'s stitch-forced-to-0 interaction +(flomni does; not confirmed for omny). diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md index c5d08758..12bc9c03 100644 --- a/docs/user/ptychography/lamni.md +++ b/docs/user/ptychography/lamni.md @@ -111,6 +111,8 @@ which reads the last recorded progress and resumes automatically at the exact po In case of eight equally spaced sub-tomograms, an individual sub-tomogram can be scanned by `lamni.sub_tomo_scan(subtomo_number, start_angle)`. If the start angle is not specified, it is computed depending on `subtomo_number`, ranging from 1 to 8. +`lamni.tomo_parameters()` also offers `lamni_stitch_x`/`lamni_stitch_y` (integers, default 0, up to 50 each) for a field of view larger than a single scan can cover: when nonzero, every projection angle is acquired as a `(2*lamni_stitch_x+1) × (2*lamni_stitch_y+1)` grid of separate Fermat scans instead of a single one, tiled around the normal alignment-corrected center and spaced by `lamni_piezo_range_x`/`lamni_piezo_range_y` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(2*lamni_stitch_x+1) × lamni_piezo_range_x` by `(2*lamni_stitch_y+1) × lamni_piezo_range_y`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. Same mechanism as flomni's `stitch_x`/`stitch_y`, just under lamni's own parameter names (see `lamni.tomo_scan_projection()`, which passes `stitch_x`/`stitch_y`/`stitch_overlap` straight through to `scans.lamni_fermat_scan()`). + #### Queueing multiple scans Several tomo parameter sets can be queued and run sequentially on the same sample, without having to start each one by hand. Easiest is to use the tomo parameters GUI for preparing the scans. But it is also possible on the command line interface. -- 2.54.0 From 583435ddd64cf0d3561044922facd7fbc8e3c95a Mon Sep 17 00:00:00 2001 From: x01dc Date: Sat, 19 Sep 2026 19:34:49 +0200 Subject: [PATCH 46/78] =?UTF-8?q?fix(flomni):=20tighten=20manual=5Fshift?= =?UTF-8?q?=5Fy=20GUI=20range=20to=20=C2=B150=20=C2=B5m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manual_shift_y (the "additional offset y" that ultimately feeds tomo_additional_offsety at the scan/RT-controller layer, where it's bounded by rty's own ±100 hardware travel limit) had no meaningful upper bound of its own in the Tomo Parameters GUI (-1000 to 1000 µm spinbox range) -- tighten it to ±50 µm, a sane bound at the parameter-entry level, well inside rty's hardware limit which is left unchanged. GUI-only change (manual_shift_y itself has no property-level range validation, matching the existing convention for fovx/fovy/stitch_x/stitch_y). LamNI's own manual_shift_x/y range is untouched. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index b7484d49..dea8dce2 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -2327,7 +2327,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { ("fovy", "FOV y (µm)", 0.1, 100.0, 2), ], "stitch_fields": [("stitch_x", "Stitch x", 0, 50), ("stitch_y", "Stitch y", 0, 50)], - "manual_shift_fields": [("manual_shift_y", "Manual shift y (µm)", -1000.0, 1000.0, 3)], + "manual_shift_fields": [("manual_shift_y", "Manual shift y (µm)", -50.0, 50.0, 3)], "piezo_range_fields": [], "offset_fields": [], "offset_getter": None, -- 2.54.0 From 158dc800f01fee89773e5b842593aceef3b51242 Mon Sep 17 00:00:00 2001 From: x01dc Date: Sat, 19 Sep 2026 20:21:20 +0200 Subject: [PATCH 47/78] fix(flomni): enforce GUI parameter ranges in tomo_parameters() CLI too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _get_val() (the wizard's input prompt) previously did no range validation at all -- only the Tomo Parameters GUI's spinboxes enforced limits, so the CLI silently accepted any value of the right type (e.g. manual_shift_y=500 despite the GUI's ±50 µm range). _get_val() now takes optional min_/max_ and re-prompts until the value is in range; wired up for every tomo_parameters() field that already has a GUI-defined range (tomo_countingtime, tomo_shellstep, fermat_asymmetry, fovx, fovy, stitch_x/y, manual_shift_y, frames_per_trigger, single_point_random_shift_max, total projections, golden_ratio_bunch_size, golden_max_number_of_projections, projections/sub-tomo). fermat_asymmetry reuses FlomniFermatScan's own MIN/MAX constants rather than duplicating them. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- .../plugins/flomni/flomni.py | 61 +++++++++++++++---- .../test_flomni_get_val_range.py | 61 +++++++++++++++++++ 2 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 tests/tests_bec_ipython_client/test_flomni_get_val_range.py diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index cfd575a9..87d93ed4 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -3818,13 +3818,23 @@ class Flomni( if self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"): print("... excellent!") else: - self.tomo_countingtime = self._get_val(" s", self.tomo_countingtime, float) - self.tomo_shellstep = self._get_val(" um", self.tomo_shellstep, float) - self.fermat_asymmetry = self._get_val( - "", self.fermat_asymmetry, float + from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan + + self.tomo_countingtime = self._get_val( + " s", self.tomo_countingtime, float, min_=0.001, max_=100.0 ) - self.fovx = self._get_val(" um", self.fovx, float) - self.fovy = self._get_val(" um", self.fovy, float) + self.tomo_shellstep = self._get_val( + " um", self.tomo_shellstep, float, min_=0.001, max_=1000.0 + ) + self.fermat_asymmetry = self._get_val( + "", + self.fermat_asymmetry, + float, + min_=FlomniFermatScan.MIN_FERMAT_ASYMMETRY, + max_=FlomniFermatScan.MAX_FERMAT_ASYMMETRY, + ) + self.fovx = self._get_val(" um", self.fovx, float, min_=0.1, max_=220.0) + self.fovy = self._get_val(" um", self.fovy, float, min_=0.1, max_=100.0) if self.single_point_instead_of_fermat_scan: print( "Stitching is disabled while single point instead of fermat scan is" @@ -3833,14 +3843,16 @@ class Flomni( self.stitch_x = 0 self.stitch_y = 0 else: - self.stitch_x = self._get_val("", self.stitch_x, int) - self.stitch_y = self._get_val("", self.stitch_y, int) + self.stitch_x = self._get_val("", self.stitch_x, int, min_=0, max_=50) + self.stitch_y = self._get_val("", self.stitch_y, int, min_=0, max_=50) self.ptycho_reconstruct_foldername = self._get_val( "Reconstruction queue ", self.ptycho_reconstruct_foldername, str ) - self.manual_shift_y = self._get_val(" um", self.manual_shift_y, float) + self.manual_shift_y = self._get_val( + " um", self.manual_shift_y, float, min_=-50.0, max_=50.0 + ) self.frames_per_trigger = self._get_val( - "Frames per trigger (burst)", self.frames_per_trigger, int + "Frames per trigger (burst)", self.frames_per_trigger, int, min_=1, max_=100 ) self.single_point_instead_of_fermat_scan = bool( self._get_val( @@ -3863,6 +3875,8 @@ class Flomni( " um (0 = off)", self.single_point_random_shift_max, float, + min_=0.0, + max_=10.0, ) print("Tomography type:") @@ -3884,7 +3898,7 @@ class Flomni( # never reflect a prior adjustment. _, _, current_total = self._tomo_type1_actual_grid() tomo_numberofprojections = self._get_val( - "Total number of projections", current_total, int + "Total number of projections", current_total, int, min_=8, max_=10000 ) # N/step (and therefore total projections) are always # computed against a fixed 180 degrees, independent of @@ -3933,11 +3947,15 @@ class Flomni( "Number of projections sorted per bunch (default 20)", self.golden_ratio_bunch_size, int, + min_=1, + max_=10000, ) self.golden_max_number_of_projections = self._get_val( "Stop after number of projections (zero for endless)", self.golden_max_number_of_projections, int, + min_=0, + max_=1_000_000, ) self.golden_projections_at_0_deg_for_damage_estimation = self._get_val( "Repeat projections at 0 deg every second subtomo 1/0 ?", @@ -3950,12 +3968,16 @@ class Flomni( "Number of projections per sub-tomogram", int(180 / self.tomo_angle_stepsize), int, + min_=1, + max_=10000, ) self.tomo_angle_stepsize = 180 / numprj self.golden_max_number_of_projections = self._get_val( "Stop after number of projections (zero for endless)", self.golden_max_number_of_projections, int, + min_=0, + max_=1_000_000, ) self.golden_projections_at_0_deg_for_damage_estimation = self._get_val( "Repeat projections at 0 deg every second subtomo", @@ -3964,8 +3986,21 @@ class Flomni( ) @staticmethod - def _get_val(msg: str, default_value, data_type): - return data_type(input(f"{msg} ({default_value}): ") or default_value) + def _get_val(msg: str, default_value, data_type, min_=None, max_=None): + """Prompt for a value, casting to data_type. When min_/max_ are + given, re-prompts (rather than silently clamping) until the typed + value is in range, mirroring the Tomo Parameters GUI's spinbox + bounds (tomo_params.py) so tomo_parameters() enforces the same + limits instead of accepting anything of the right type.""" + while True: + val = data_type(input(f"{msg} ({default_value}): ") or default_value) + if min_ is not None and val < min_: + print(f"Value must be >= {min_}. Please try again.") + continue + if max_ is not None and val > max_: + print(f"Value must be <= {max_}. Please try again.") + continue + return val # Ordered set of all global-var-backed tomo scan parameters: exactly the # settings shown by tomo_parameters(), plus manual_shift_y/ diff --git a/tests/tests_bec_ipython_client/test_flomni_get_val_range.py b/tests/tests_bec_ipython_client/test_flomni_get_val_range.py new file mode 100644 index 00000000..f1b2704e --- /dev/null +++ b/tests/tests_bec_ipython_client/test_flomni_get_val_range.py @@ -0,0 +1,61 @@ +"""Tests for Flomni._get_val()'s min_/max_ range validation. + +tomo_parameters() (the CLI wizard) used to accept any value of the right +type with no range check at all, even though the Tomo Parameters GUI +enforces spinbox ranges for the same fields (tomo_params.py) -- this let the +CLI silently set e.g. manual_shift_y or fovx far outside their GUI-intended +bounds. _get_val() now optionally re-prompts until the typed value is in +range, so tomo_parameters() enforces the same limits as the GUI. +""" + +import builtins + +import pytest + +from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni + + +def _fake_inputs(monkeypatch, values): + it = iter(values) + monkeypatch.setattr(builtins, "input", lambda prompt="": next(it)) + + +def test_get_val_without_bounds_accepts_anything(monkeypatch): + _fake_inputs(monkeypatch, ["12345"]) + assert Flomni._get_val("x", 0.0, float) == 12345.0 + + +def test_get_val_accepts_in_range_value_first_try(monkeypatch): + _fake_inputs(monkeypatch, ["25"]) + assert Flomni._get_val("x", 0.0, float, min_=0.1, max_=220.0) == 25.0 + + +def test_get_val_reprompts_when_above_max(monkeypatch, capsys): + _fake_inputs(monkeypatch, ["500", "50"]) + result = Flomni._get_val("FOV X", 0.0, float, min_=0.1, max_=220.0) + assert result == 50.0 + assert "must be <= 220.0" in capsys.readouterr().out + + +def test_get_val_reprompts_when_below_min(monkeypatch, capsys): + _fake_inputs(monkeypatch, ["-999", "-30"]) + result = Flomni._get_val("manual_shift_y", 0.0, float, min_=-50.0, max_=50.0) + assert result == -30.0 + assert "must be >= -50.0" in capsys.readouterr().out + + +def test_get_val_reprompts_until_valid_across_multiple_bad_attempts(monkeypatch): + _fake_inputs(monkeypatch, ["-5", "999", "30"]) + assert Flomni._get_val("stitch X", 0, int, min_=0, max_=50) == 30 + + +def test_get_val_empty_input_uses_default_and_still_checked(monkeypatch): + # empty input -> falls back to default_value, which is already in range + _fake_inputs(monkeypatch, [""]) + assert Flomni._get_val("x", 25, int, min_=0, max_=50) == 25 + + +def test_get_val_boundary_values_are_accepted(monkeypatch): + _fake_inputs(monkeypatch, ["0.1", "220.0"]) + assert Flomni._get_val("a", 0.0, float, min_=0.1, max_=220.0) == 0.1 + assert Flomni._get_val("b", 0.0, float, min_=0.1, max_=220.0) == 220.0 -- 2.54.0 From 1dd03d16d8b730369a3a14b30b517181c2b7516d Mon Sep 17 00:00:00 2001 From: x01dc Date: Sat, 19 Sep 2026 21:05:16 +0200 Subject: [PATCH 48/78] fix(flomni): tighten tomo_shellstep/stitch/golden_ratio_bunch_size ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tomo_shellstep: 0.001-1000.0 -> 0.025-20.0 um stitch_x/stitch_y (flomni only): 0-50 -> 0-10 golden_ratio_bunch_size: 1-10000 -> 1-100 tomo_stitch_overlap: 0.0-50.0 -> 0.0-10.0 um Applied in both the GUI (tomo_params.py) and the tomo_parameters() CLI wizard (flomni.py), matching the ±50 um manual_shift_y precedent. Note: tomo_shellstep, tomo_stitch_overlap, and golden_ratio_bunch_size are built by shared (not per-profile) GUI code, so these ranges also apply to LamNI's Tomo Parameters panel, not just flomni's -- stitch_x/stitch_y are the only one of the four that's flomni-specific (lamni_stitch_x/y are a separate, untouched profile entry). tomo_stitch_overlap has no CLI prompt to update (property-only, not part of the tomo_parameters() wizard). All existing defaults (tomo_shellstep=1, stitch_x/y=0, tomo_stitch_overlap=0.2, golden_ratio_bunch_size=20) remain within the new ranges. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py | 8 ++++---- .../bec_widgets/widgets/tomo_params/tomo_params.py | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 87d93ed4..86c53c4f 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -3824,7 +3824,7 @@ class Flomni( " s", self.tomo_countingtime, float, min_=0.001, max_=100.0 ) self.tomo_shellstep = self._get_val( - " um", self.tomo_shellstep, float, min_=0.001, max_=1000.0 + " um", self.tomo_shellstep, float, min_=0.025, max_=20.0 ) self.fermat_asymmetry = self._get_val( "", @@ -3843,8 +3843,8 @@ class Flomni( self.stitch_x = 0 self.stitch_y = 0 else: - self.stitch_x = self._get_val("", self.stitch_x, int, min_=0, max_=50) - self.stitch_y = self._get_val("", self.stitch_y, int, min_=0, max_=50) + self.stitch_x = self._get_val("", self.stitch_x, int, min_=0, max_=10) + self.stitch_y = self._get_val("", self.stitch_y, int, min_=0, max_=10) self.ptycho_reconstruct_foldername = self._get_val( "Reconstruction queue ", self.ptycho_reconstruct_foldername, str ) @@ -3948,7 +3948,7 @@ class Flomni( self.golden_ratio_bunch_size, int, min_=1, - max_=10000, + max_=100, ) self.golden_max_number_of_projections = self._get_val( "Stop after number of projections (zero for endless)", diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index dea8dce2..ec9eced8 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -466,8 +466,8 @@ class OMNY_TomoParams(BECWidget, QWidget): common_form, "tomo_shellstep", "Shell step (µm)", - min_=0.001, - max_=1000.0, + min_=0.025, + max_=20.0, decimals=3, ), "fermat_asymmetry": lambda: self._build_fermat_asymmetry_row(common_form), @@ -478,7 +478,7 @@ class OMNY_TomoParams(BECWidget, QWidget): "tomo_stitch_overlap", "Stitch overlap (µm)", min_=0.0, - max_=50.0, + max_=10.0, decimals=3, ), "ptycho_reconstruct_foldername": lambda: self._add_text( @@ -677,7 +677,7 @@ class OMNY_TomoParams(BECWidget, QWidget): form, "_numprj_type3", "Projections per sub-tomo (type 3)", min_=1, max_=10000 ) self._add_int( - form, "golden_ratio_bunch_size", "Bunch size (type 2 only)", min_=1, max_=10000 + form, "golden_ratio_bunch_size", "Bunch size (type 2 only)", min_=1, max_=100 ) self._add_double( form, @@ -2326,7 +2326,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { ("fovx", "FOV x (µm)", 0.1, 220.0, 2), ("fovy", "FOV y (µm)", 0.1, 100.0, 2), ], - "stitch_fields": [("stitch_x", "Stitch x", 0, 50), ("stitch_y", "Stitch y", 0, 50)], + "stitch_fields": [("stitch_x", "Stitch x", 0, 10), ("stitch_y", "Stitch y", 0, 10)], "manual_shift_fields": [("manual_shift_y", "Manual shift y (µm)", -50.0, 50.0, 3)], "piezo_range_fields": [], "offset_fields": [], -- 2.54.0 From a7e1b5ecf0153f5c5f46912c4583bc4179ecf845 Mon Sep 17 00:00:00 2001 From: x01dc Date: Sat, 19 Sep 2026 21:05:53 +0200 Subject: [PATCH 49/78] docs(flomni): fix stale comment above _TOMO_SCAN_PARAM_NAMES manual_shift_y is prompted by tomo_parameters() (has been all along) -- the comment wrongly grouped it with tomo_stitch_overlap/corridor_size/ at_each_angle_hook as "only set directly as properties". Only those three are actually property-only. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 86c53c4f..1edc4411 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -4003,11 +4003,12 @@ class Flomni( return val # Ordered set of all global-var-backed tomo scan parameters: exactly the - # settings shown by tomo_parameters(), plus manual_shift_y/ - # tomo_stitch_overlap/corridor_size/at_each_angle_hook which also affect - # the scan but are only set directly as properties. This is the full - # "parameter set" that tomo_queue_add()/tomo_queue_execute() snapshot - # and restore. + # settings shown by tomo_parameters() (including manual_shift_y, which + # is prompted there too), plus tomo_stitch_overlap/corridor_size/ + # at_each_angle_hook, which also affect the scan but are only ever set + # directly as properties -- tomo_parameters() never prompts for them. + # This is the full "parameter set" that tomo_queue_add()/ + # tomo_queue_execute() snapshot and restore. _TOMO_SCAN_PARAM_NAMES = ( "tomo_countingtime", "tomo_shellstep", -- 2.54.0 From d77b5cede968a49bb12423492448af0583860a1f Mon Sep 17 00:00:00 2001 From: x01dc Date: Sat, 19 Sep 2026 21:16:28 +0200 Subject: [PATCH 50/78] fix(lamni): tighten circfov/piezo-range GUI limits, enforce ranges in CLI - tomo_circfov: GUI upper bound 200.0 -> 500.0 um (floor kept at 0.0 so "0 = disabled" -- checked explicitly in lamni_fermat_scan.py:484 -- still works) - lamni_piezo_range_x/y: GUI upper bound 200.0 -> 80.0 um, matching the "(max 80)" already printed in the CLI prompt. The property setter's large_range_scan bypass is left untouched (uncertain hardware rationale) -- this only tightens the GUI/CLI entry points, which is where "<80" actually becomes enforced in practice, since both now validate before ever calling the (still-conditionally-permissive) property setter. - LamNI._get_val() now supports min_/max_ (mirrors Flomni._get_val() exactly, added previously) -- LamNI's tomo_parameters() CLI wizard previously had zero range validation at all. Wired up for tomo_countingtime (0.001-100.0), tomo_shellstep (0.025-20.0), lamni_piezo_range_x/y (0.0-80.0), and tomo_circfov (0.0-500.0). - tomo_stitch_overlap gets a brand new CLI prompt (0.0-10.0 um) -- it had no prompt at all before (property-only, same gap Flomni still has). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- .../bec_ipython_client/plugins/LamNI/lamni.py | 37 ++++++++++++--- .../widgets/tomo_params/tomo_params.py | 6 +-- .../test_lamni_get_val_range.py | 46 +++++++++++++++++++ 3 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 tests/tests_bec_ipython_client/test_lamni_get_val_range.py diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index 506cd8e5..b054677d 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -2153,17 +2153,26 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools print("OK. continue.") return - self.tomo_countingtime = self._get_val(" s", self.tomo_countingtime, float) - self.tomo_shellstep = self._get_val(" um", self.tomo_shellstep, float) + self.tomo_countingtime = self._get_val( + " s", self.tomo_countingtime, float, min_=0.001, max_=100.0 + ) + self.tomo_shellstep = self._get_val( + " um", self.tomo_shellstep, float, min_=0.025, max_=20.0 + ) self.lamni_piezo_range_x = self._get_val( - " um", self.lamni_piezo_range_x, float + " um", self.lamni_piezo_range_x, float, min_=0.0, max_=80.0 ) self.lamni_piezo_range_y = self._get_val( - " um", self.lamni_piezo_range_y, float + " um", self.lamni_piezo_range_y, float, min_=0.0, max_=80.0 ) self.lamni_stitch_x = self._get_val("", self.lamni_stitch_x, int) self.lamni_stitch_y = self._get_val("", self.lamni_stitch_y, int) - self.tomo_circfov = self._get_val(" um", self.tomo_circfov, float) + self.tomo_stitch_overlap = self._get_val( + " um", self.tomo_stitch_overlap, float, min_=0.0, max_=10.0 + ) + self.tomo_circfov = self._get_val( + " um", self.tomo_circfov, float, min_=0.0, max_=500.0 + ) self.ptycho_reconstruct_foldername = self._get_val( "Reconstruction queue", self.ptycho_reconstruct_foldername, str ) @@ -2243,8 +2252,22 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools ) @staticmethod - def _get_val(msg: str, default_value, data_type): - return data_type(input(f"{msg} ({default_value}): ") or default_value) + def _get_val(msg: str, default_value, data_type, min_=None, max_=None): + """Prompt for a value, casting to data_type. When min_/max_ are + given, re-prompts (rather than silently clamping) until the typed + value is in range, mirroring the Tomo Parameters GUI's spinbox + bounds (tomo_params.py) so tomo_parameters() enforces the same + limits instead of accepting anything of the right type. Mirrors + Flomni._get_val() exactly.""" + while True: + val = data_type(input(f"{msg} ({default_value}): ") or default_value) + if min_ is not None and val < min_: + print(f"Value must be >= {min_}. Please try again.") + continue + if max_ is not None and val > max_: + print(f"Value must be <= {max_}. Please try again.") + continue + return val def _confirm_sequence_override(self, warning: str, force: bool) -> bool: """Print *warning* and ask whether to proceed anyway. diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index ec9eced8..caf9dcf6 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -2362,7 +2362,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { "has_180_mode": False, "has_single_point": False, "has_zero_deg_reference": True, - "fov_fields": [("tomo_circfov", "Circular FOV (µm, 0 = disabled)", 0.0, 200.0, 2)], + "fov_fields": [("tomo_circfov", "Circular FOV (µm, 0 = disabled)", 0.0, 500.0, 2)], "stitch_fields": [ ("lamni_stitch_x", "Stitch x", 0, 50), ("lamni_stitch_y", "Stitch y", 0, 50), @@ -2372,8 +2372,8 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { ("manual_shift_y", "Manual shift y (µm)", -1000.0, 1000.0, 3), ], "piezo_range_fields": [ - ("lamni_piezo_range_x", "Piezo range x (µm)", 0.0, 200.0, 2), - ("lamni_piezo_range_y", "Piezo range y (µm)", 0.0, 200.0, 2), + ("lamni_piezo_range_x", "Piezo range x (µm)", 0.0, 80.0, 2), + ("lamni_piezo_range_y", "Piezo range y (µm)", 0.0, 80.0, 2), ], # tomo_fovx_offset/tomo_fovy_offset: alignment values (LamNIAlignmentMixin), # not part of _TOMO_SCAN_PARAM_NAMES -- shown/editable here (mirroring diff --git a/tests/tests_bec_ipython_client/test_lamni_get_val_range.py b/tests/tests_bec_ipython_client/test_lamni_get_val_range.py new file mode 100644 index 00000000..8e5774f1 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_lamni_get_val_range.py @@ -0,0 +1,46 @@ +"""Tests for LamNI._get_val()'s min_/max_ range validation. + +Mirrors test_flomni_get_val_range.py -- LamNI's tomo_parameters() CLI wizard +previously had no range validation at all (unlike the GUI's spinbox +ranges); _get_val() now optionally re-prompts until the typed value is in +range, same mechanism as Flomni's. +""" + +import builtins + +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI + + +def _fake_inputs(monkeypatch, values): + it = iter(values) + monkeypatch.setattr(builtins, "input", lambda prompt="": next(it)) + + +def test_get_val_without_bounds_accepts_anything(monkeypatch): + _fake_inputs(monkeypatch, ["12345"]) + assert LamNI._get_val("x", 0.0, float) == 12345.0 + + +def test_get_val_accepts_in_range_value_first_try(monkeypatch): + _fake_inputs(monkeypatch, ["10"]) + assert LamNI._get_val("shellstep", 0.0, float, min_=0.025, max_=20.0) == 10.0 + + +def test_get_val_reprompts_when_above_max(monkeypatch, capsys): + _fake_inputs(monkeypatch, ["999", "50"]) + result = LamNI._get_val("circfov", 0.0, float, min_=0.0, max_=500.0) + assert result == 50.0 + assert "must be <= 500.0" in capsys.readouterr().out + + +def test_get_val_reprompts_when_below_min(monkeypatch, capsys): + _fake_inputs(monkeypatch, ["-5", "10"]) + result = LamNI._get_val("piezo range x", 20.0, float, min_=0.0, max_=80.0) + assert result == 10.0 + assert "must be >= 0.0" in capsys.readouterr().out + + +def test_get_val_boundary_values_are_accepted(monkeypatch): + _fake_inputs(monkeypatch, ["0.025", "20.0"]) + assert LamNI._get_val("a", 0.0, float, min_=0.025, max_=20.0) == 0.025 + assert LamNI._get_val("b", 0.0, float, min_=0.025, max_=20.0) == 20.0 -- 2.54.0 From f0c5a251d92d2f4af7a314696592de04a4aa8978 Mon Sep 17 00:00:00 2001 From: x01dc Date: Sat, 19 Sep 2026 21:17:18 +0200 Subject: [PATCH 51/78] docs(omny): note follow-up to tighten tomo-parameter ranges + CLI validation Not implemented for omny yet -- records the range tightening and _get_val() min_/max_ enforcement just done for flomni/lamni (2026-09-19), and confirms omny's parameter names mirror flomni's exactly. Also confirms tomo_params.py has no "omny" SETUP_PROFILES entry -- omny doesn't use that shared GUI widget at all today, so there's nothing to tighten there until its actual parameter-entry UI (if any) is located. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- .../TODO_tighten_tomo_parameter_ranges.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tighten_tomo_parameter_ranges.md diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tighten_tomo_parameter_ranges.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tighten_tomo_parameter_ranges.md new file mode 100644 index 00000000..1abf4331 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tighten_tomo_parameter_ranges.md @@ -0,0 +1,62 @@ +# TODO: tighten tomo-parameter ranges + enforce them in the CLI wizard + +Status: **not implemented for omny.** Discussed and implemented for Flomni +and LamNI on 2026-09-19 — this note exists so the same pass isn't forgotten +for omny. + +## What was done for Flomni/LamNI + +Two separate things, both worth doing here too: + +1. **Tightened several parameter ranges** that had grown far looser than + practically useful (GUI spinbox ranges in `tomo_params.py`, plus the + matching property/CLI bounds where they existed): + - `tomo_shellstep`: 0.001–1000.0 → **0.025–20.0 µm** + - `stitch_x`/`stitch_y`: 0–50 → **0–10** (flomni only — lamni's own + `lamni_stitch_x`/`y` were left at 0–50) + - `golden_ratio_bunch_size`: 1–10000 → **1–100** + - `tomo_stitch_overlap`: 0.0–50.0 → **0.0–10.0 µm** + - `manual_shift_y` (flomni): -1000–1000 → **-50–50 µm** + - lamni-specific: `tomo_circfov` 0.0–200.0 → **0.0–500.0 µm** (floor kept + at 0.0 — `fov_circular == 0` means "disabled", checked explicitly in + `lamni_fermat_scan.py:484`, so don't raise the floor above 0); + `lamni_piezo_range_x`/`y` 0.0–200.0 → **0.0–80.0 µm**, matching the + "(max 80)" already printed in lamni's CLI prompt text. + +2. **`_get_val()` (the `tomo_parameters()` CLI wizard's input helper) now + supports `min_`/`max_`** and re-prompts until the value is in range, + instead of accepting anything of the right type with zero validation. + Both Flomni's and LamNI's `_get_val()` got this treatment (they're + separate static methods, not shared code) and every prompted field that + has a GUI-defined range got the matching `min_`/`max_` wired in. + +## What's needed for omny + +Omny has its own `tomo_parameters()` and `_get_val()` +(`csaxs_bec/bec_ipython_client/plugins/omny/omny.py:1293` / +`omny.py:1422`) — same bare/unvalidated pattern Flomni and LamNI both had +before this pass. Confirmed (2026-09-19) that omny's parameter names mirror +**flomni's** naming exactly, not lamni's: `fovx`/`fovy`, `manual_shift_y`, +`tomo_type`, `stitch_x`/`stitch_y`, `tomo_stitch_overlap`, +`golden_ratio_bunch_size`, `tomo_shellstep`, `tomo_countingtime` all exist +under those same names in `omny.py`. So the flomni numbers above likely +carry over directly, but this hasn't been checked against omny's own +physical setup/hardware constraints (e.g. its FOV/shellstep/piezo range may +have different practical limits than flomni's cryo ptycho setup) — don't +just copy the numbers blind. + +Steps, mirroring what was done for flomni/lamni: +1. Confirmed (2026-09-19): `tomo_params.py`'s `SETUP_PROFILES` dict has only + `"flomni"` and `"lamni"` entries — **no `"omny"` profile exists**, so + omny doesn't use this shared Tomo Parameters GUI widget at all right now. + Whatever GUI (if any) omny actually uses for entering these parameters + needs to be located first; there's nothing to tighten in `tomo_params.py` + for omny today. (Separately, `omny_webpage_generator.py` was confirmed in + an earlier session to be dead/unwired code — not related to this GUI + question, just don't confuse the two when investigating.) +2. Add `min_`/`max_` support to omny's own `_get_val()` (same mechanism, + copy-pasted independently — these are three separate static methods, not + shared code, by design in this codebase). +3. Confirm with whoever runs omny scans what the actual practical ranges + should be for omny's hardware, rather than assuming flomni's numbers + apply unchanged. -- 2.54.0 From 1b802aaad66d53bdcfa1b9edc5e270c0bf660954 Mon Sep 17 00:00:00 2001 From: x01dc Date: Sat, 19 Sep 2026 21:24:14 +0200 Subject: [PATCH 52/78] docs(flomni,lamni): clarify tomo_countingtime is per-frame, not per-point tomo_countingtime is the exposure time of each individual frame within a frames_per_trigger burst -- total dwell time at a position scales with frames_per_trigger (see the scan's own timing model, flomni.py:2553: normal_cadence = ... tomo_countingtime * frames_per_trigger). The GUI label and both setups' tomo_parameters() CLI prompt/print text previously just said "Counting time" with no qualifier, which reads as a per-point total. Relabeled to "Counting time per frame" everywhere it appears. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py | 4 ++-- csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py | 4 ++-- csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index b054677d..7c093288 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -2088,7 +2088,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools def tomo_parameters(self): """Print and interactively update the tomo parameters.""" print("Current settings:") - print(f"Counting time = {self.tomo_countingtime} s") + print(f"Counting time per frame = {self.tomo_countingtime} s") print(f"Stepsize microns = {self.tomo_shellstep}") print( f"Piezo range (max 80) = {self.lamni_piezo_range_x}," @@ -2154,7 +2154,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools return self.tomo_countingtime = self._get_val( - " s", self.tomo_countingtime, float, min_=0.001, max_=100.0 + " s", self.tomo_countingtime, float, min_=0.001, max_=100.0 ) self.tomo_shellstep = self._get_val( " um", self.tomo_shellstep, float, min_=0.025, max_=20.0 diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 1edc4411..5255d260 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -3729,7 +3729,7 @@ class Flomni( def tomo_parameters(self): """print and update the tomo parameters""" print("Current settings:") - print(f"Counting time = {self.tomo_countingtime} s") + print(f"Counting time per frame = {self.tomo_countingtime} s") print(f"Stepsize microns = {self.tomo_shellstep}") print(f"Fermat asymmetry (x/y) = {self.fermat_asymmetry}") print(f"FOV (220/100) = {self.fovx}, {self.fovy}") @@ -3821,7 +3821,7 @@ class Flomni( from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan self.tomo_countingtime = self._get_val( - " s", self.tomo_countingtime, float, min_=0.001, max_=100.0 + " s", self.tomo_countingtime, float, min_=0.001, max_=100.0 ) self.tomo_shellstep = self._get_val( " um", self.tomo_shellstep, float, min_=0.025, max_=20.0 diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index caf9dcf6..d95fe871 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -457,7 +457,7 @@ class OMNY_TomoParams(BECWidget, QWidget): "tomo_countingtime": lambda: self._add_double( common_form, "tomo_countingtime", - "Counting time (s)", + "Counting time per frame (s)", min_=0.001, max_=100.0, decimals=3, -- 2.54.0 From d01af690a97383fa9e9f23024ea6ba9d2be213ba Mon Sep 17 00:00:00 2001 From: x01dc Date: Sat, 19 Sep 2026 21:30:22 +0200 Subject: [PATCH 53/78] fix(flomni,lamni): fovy/piezo-range bounds didn't match scan's strict "<" Real hardware failure: flomni.fovy=100.0 was accepted by the property, GUI, and CLI (all capped at an inclusive 100.0), but FlomniFermatScan's own ScanArgument requires fovy strictly less than 100 -- so the scan rejected it at runtime with ScanInputValidationError, only surfacing once actually run. - flomni.fovy property setter: now raises at >=100 (was >100), matching FlomniFermatScan's gt=0/lt=100. GUI/CLI max tightened 100.0 -> 99.9. - lamni_piezo_range_x/y (become LamniFermatScan's fovx/fovy, gt=0/lt=80): found the same bug class at BOTH ends while writing the regression test -- GUI/CLI min was an inclusive 0.0 (scan requires strictly >0) and max was an inclusive 80.0 (scan requires strictly <80). Tightened to 0.1-79.9 in both GUI and CLI. Property setter's large_range_scan bypass left untouched per earlier explicit instruction -- this fixes the GUI/CLI entry points, which is where the bound is actually enforced in practice. - Added test_fov_bounds_match_scan_args.py: introspects the real ScanArgument gt/ge/lt/le from FlomniFermatScan/LamniFermatScan and asserts our configured GUI/CLI ranges are strictly inside them, so this class of bug (passes every check except the one that actually runs the scan) can't silently come back for these or future fields. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- .../bec_ipython_client/plugins/LamNI/lamni.py | 4 +- .../plugins/flomni/flomni.py | 12 ++- .../widgets/tomo_params/tomo_params.py | 6 +- .../test_fov_bounds_match_scan_args.py | 93 +++++++++++++++++++ 4 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 tests/tests_bec_ipython_client/test_fov_bounds_match_scan_args.py diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index 7c093288..df2e018d 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -2160,10 +2160,10 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools " um", self.tomo_shellstep, float, min_=0.025, max_=20.0 ) self.lamni_piezo_range_x = self._get_val( - " um", self.lamni_piezo_range_x, float, min_=0.0, max_=80.0 + " um", self.lamni_piezo_range_x, float, min_=0.1, max_=79.9 ) self.lamni_piezo_range_y = self._get_val( - " um", self.lamni_piezo_range_y, float, min_=0.0, max_=80.0 + " um", self.lamni_piezo_range_y, float, min_=0.1, max_=79.9 ) self.lamni_stitch_x = self._get_val("", self.lamni_stitch_x, int) self.lamni_stitch_y = self._get_val("", self.lamni_stitch_y, int) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 5255d260..06389265 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -2065,8 +2065,14 @@ class Flomni( @fovy.setter def fovy(self, val: float): - if val > 100: - raise ValueError("FOV cannot be larger than 100 um.") + # Strict "<" to match FlomniFermatScan's own ScanArgument bound + # (gt=0, lt=100) -- fovy=100.0 was previously accepted here (and by + # the GUI/CLI, both capped at an inclusive 100.0) but rejected at + # scan time with ScanInputValidationError, only surfacing on real + # hardware. fovx has no such gap (property and FlomniFermatScan both + # use an inclusive <=220). + if val >= 100: + raise ValueError("FOV must be less than 100 um.") self.client.set_global_var("fovy", val) @property @@ -3834,7 +3840,7 @@ class Flomni( max_=FlomniFermatScan.MAX_FERMAT_ASYMMETRY, ) self.fovx = self._get_val(" um", self.fovx, float, min_=0.1, max_=220.0) - self.fovy = self._get_val(" um", self.fovy, float, min_=0.1, max_=100.0) + self.fovy = self._get_val(" um", self.fovy, float, min_=0.1, max_=99.9) if self.single_point_instead_of_fermat_scan: print( "Stitching is disabled while single point instead of fermat scan is" diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index d95fe871..5a464656 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -2324,7 +2324,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { "has_zero_deg_reference": True, "fov_fields": [ ("fovx", "FOV x (µm)", 0.1, 220.0, 2), - ("fovy", "FOV y (µm)", 0.1, 100.0, 2), + ("fovy", "FOV y (µm)", 0.1, 99.9, 2), ], "stitch_fields": [("stitch_x", "Stitch x", 0, 10), ("stitch_y", "Stitch y", 0, 10)], "manual_shift_fields": [("manual_shift_y", "Manual shift y (µm)", -50.0, 50.0, 3)], @@ -2372,8 +2372,8 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { ("manual_shift_y", "Manual shift y (µm)", -1000.0, 1000.0, 3), ], "piezo_range_fields": [ - ("lamni_piezo_range_x", "Piezo range x (µm)", 0.0, 80.0, 2), - ("lamni_piezo_range_y", "Piezo range y (µm)", 0.0, 80.0, 2), + ("lamni_piezo_range_x", "Piezo range x (µm)", 0.1, 79.9, 2), + ("lamni_piezo_range_y", "Piezo range y (µm)", 0.1, 79.9, 2), ], # tomo_fovx_offset/tomo_fovy_offset: alignment values (LamNIAlignmentMixin), # not part of _TOMO_SCAN_PARAM_NAMES -- shown/editable here (mirroring diff --git a/tests/tests_bec_ipython_client/test_fov_bounds_match_scan_args.py b/tests/tests_bec_ipython_client/test_fov_bounds_match_scan_args.py new file mode 100644 index 00000000..f5074b60 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_fov_bounds_match_scan_args.py @@ -0,0 +1,93 @@ +"""Regression test for a real hardware bug (2026-09-19): flomni.fovy could be +set to exactly 100.0 via the property/GUI/CLI (all capped at an inclusive +100.0), but FlomniFermatScan's own ScanArgument declares fovy as gt=0, lt=100 +(strictly less than) -- so fovy=100.0 passed every check right up until the +scan itself rejected it with ScanInputValidationError on real hardware. + +lamni_piezo_range_x/y (which become LamniFermatScan's fovx/fovy) had the same +class of bug at both ends: GUI/CLI min was 0.0 but LamniFermatScan requires +gt=0, and max was an inclusive 80.0 but LamniFermatScan requires lt=80. + +This test introspects the actual ScanArgument bounds from the scan classes +themselves and checks our configured GUI/CLI ranges (tomo_params.py's +SETUP_PROFILES, and flomni.py/lamni.py's tomo_parameters() prompts) are +strictly inside them -- not just checked once by hand -- so this class of +"looks fine until you actually run the scan" bug can't silently return. +""" + +import re +from typing import get_type_hints + +from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import SETUP_PROFILES +from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan +from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan + + +def _scan_arg_bounds(scan_cls, param_name: str): + hints = get_type_hints(scan_cls.__init__, include_extras=True) + metadata = hints[param_name].__metadata__ + (scan_argument,) = [m for m in metadata if hasattr(m, "lt")] + return scan_argument.gt, scan_argument.ge, scan_argument.lt, scan_argument.le + + +def _assert_range_inside_bounds(our_min, our_max, gt, ge, lt, le): + if gt is not None: + assert our_min > gt, f"configured min {our_min} must be > scan's gt={gt}" + if ge is not None: + assert our_min >= ge, f"configured min {our_min} must be >= scan's ge={ge}" + if lt is not None: + assert our_max < lt, f"configured max {our_max} must be < scan's lt={lt}" + if le is not None: + assert our_max <= le, f"configured max {our_max} must be <= scan's le={le}" + + +def _gui_fields(profile_key: str) -> dict: + """Flatten a profile's fov_fields + piezo_range_fields into {name: (min, max)}.""" + profile = SETUP_PROFILES[profile_key] + fields = {} + for name, _label, min_, max_, *_ in profile["fov_fields"]: + fields[name] = (min_, max_) + for name, _label, min_, max_, *_ in profile.get("piezo_range_fields", []): + fields[name] = (min_, max_) + return fields + + +def test_flomni_gui_fovx_fovy_within_scan_bounds(): + fields = _gui_fields("flomni") + our_min, our_max = fields["fovx"] + _assert_range_inside_bounds(our_min, our_max, *_scan_arg_bounds(FlomniFermatScan, "fovx")) + + our_min, our_max = fields["fovy"] + _assert_range_inside_bounds(our_min, our_max, *_scan_arg_bounds(FlomniFermatScan, "fovy")) + + +def test_flomni_cli_fovy_bounds_within_scan_bounds(): + src = open( + "csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py", encoding="utf-8" + ).read() + m = re.search(r'" um".*?min_=([\d.]+), max_=([\d.]+)', src, re.DOTALL) + assert m, "could not find the fovy _get_val() call in flomni.py -- did it move?" + cli_min, cli_max = float(m.group(1)), float(m.group(2)) + _assert_range_inside_bounds(cli_min, cli_max, *_scan_arg_bounds(FlomniFermatScan, "fovy")) + + +def test_lamni_gui_piezo_range_within_scan_bounds(): + fields = _gui_fields("lamni") + for name, scan_param in (("lamni_piezo_range_x", "fovx"), ("lamni_piezo_range_y", "fovy")): + our_min, our_max = fields[name] + _assert_range_inside_bounds( + our_min, our_max, *_scan_arg_bounds(LamniFermatScan, scan_param) + ) + + +def test_lamni_cli_piezo_range_bounds_within_scan_bounds(): + src = open("csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py", encoding="utf-8").read() + for axis, scan_param in (("X", "fovx"), ("Y", "fovy")): + m = re.search( + rf'" um".*?min_=([\d.]+), max_=([\d.]+)', src, re.DOTALL + ) + assert m, f"could not find the piezo range {axis} _get_val() call in lamni.py -- did it move?" + cli_min, cli_max = float(m.group(1)), float(m.group(2)) + _assert_range_inside_bounds( + cli_min, cli_max, *_scan_arg_bounds(LamniFermatScan, scan_param) + ) -- 2.54.0 From bd85d5e673509ac076f56141aa0273890302147f Mon Sep 17 00:00:00 2001 From: x12sa Date: Sun, 20 Sep 2026 11:03:06 +0200 Subject: [PATCH 54/78] fix(xrayeye): make DAP sine fit converge by fixing start values and signed amplitude The xray eye alignment fit (A*sin(f*x+shift)+intercept, 5 points) relied on lmfit's SineModel.guess() for the start values. Its FFT-based shift guess is arbitrary for 5 points and often left the fit stuck at the shift bound (2*pi) far from the data, so the plotted fit missed the submitted points and wrong parameters were loaded into flomni. Override all five parameters so the DAP server skips guess(): signed amplitude (start 30), shift start 0 within +-pi, intercept start 0, frequency and slope still fixed. Shared by the x and y waveforms. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017szgTwuHG65YhjoBGiK2Gj --- .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index f11a4a54..1263764e 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -34,6 +34,26 @@ from qtpy.QtWidgets import ( logger = bec_logger.logger CAMERA = ("cam_xeye", "image") +# Start values / bounds for the DAP fit A*sin(f*x + shift) + intercept of the +# alignment offsets vs. angle (5 points, 0..180 deg, f fixed to 1 deg in rad). +# All five parameters are given so the DAP server does not use lmfit's +# SineModel.guess(): its FFT-based shift guess is arbitrary for 5 points and +# regularly left the fit stuck at the shift bound (2*pi) far from the data. +# A signed amplitude (SineModel defaults to A >= 0) plus a fixed start +# (A=30, shift=0, intercept=0) converged to the global least-squares optimum +# in 1500/1500 random test datasets; A >= 0 failed in a few percent. +SINE_FIT_DAP_PARAMETERS = [ + { + "amplitude": {"value": 30, "vary": True, "min": -1e6, "max": 1e6}, + "frequency": {"value": 0.0174533, "vary": False, "min": 0.01, "max": 0.02}, + "shift": {"value": 0, "vary": True, "min": -math.pi, "max": math.pi}, + }, + { + "slope": {"value": 0, "vary": False, "min": 0.0, "max": 0.02}, + "intercept": {"value": 0, "vary": True, "min": -1e6, "max": 1e6}, + }, +] + class TargetCrosshair: """ @@ -615,10 +635,7 @@ class OMNY_XRayEye(BECWidget, QWidget): y=[1], label="fit-x", dap=["SineModel", "LinearModel"], - dap_parameters=[ - {"frequency": {"value": 0.0174533, "vary": False, "min": 0.01, "max": 0.02}}, - {"slope": {"value": 0, "vary": False, "min": 0.0, "max": 0.02}}, - ], + dap_parameters=SINE_FIT_DAP_PARAMETERS, dap_oversample=5, ) self.waveform_y.plot( @@ -626,10 +643,7 @@ class OMNY_XRayEye(BECWidget, QWidget): y=[2], label="fit-y", dap=["SineModel", "LinearModel"], - dap_parameters=[ - {"frequency": {"value": 0.0174533, "vary": False, "min": 0.01, "max": 0.02}}, - {"slope": {"value": 0, "vary": False, "min": 0.0, "max": 0.02}}, - ], + dap_parameters=SINE_FIT_DAP_PARAMETERS, dap_oversample=5, ) self.fit_x = self.waveform_x.curves[0] -- 2.54.0 From bbede883b0da25da5ecbb38130e661c7cdfff4e8 Mon Sep 17 00:00:00 2001 From: x12sa Date: Sun, 20 Sep 2026 11:47:04 +0200 Subject: [PATCH 55/78] fix(panda): retry arming once after a forced reset instead of hanging in pre_scan PandaBoxOMNY inherited on_pre_scan, which waits forever for the PandA READY event before sending Arm(). After rapid abort/retry cycles the readout thread can be left parked, so READY never arrives and pre_scan_all_devices() hangs with no error (flomni_fermat_scan 11998/12000). on_pre_scan now runs a bounded arm task: wait pre_scan_timeout (default 5 s) for READY; on failure force-reset the box, restart the readout thread (after the old loop finished its cleanup) and try once more, printing and logging a warning with diagnostics. If the second attempt fails too, the status fails with a RuntimeError so the scan aborts. Arm() is sent from the task, not a status callback, so the scan is only released after it went out. A stop is not retried. Also records the outcome and the still-open ophyd_devices race in AGENTS_mirko.md. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017szgTwuHG65YhjoBGiK2Gj --- AGENTS_mirko.md | 33 +++++++ csaxs_bec/devices/panda_box/panda_box_omny.py | 99 +++++++++++++++++++ tests/tests_devices/test_panda.py | 90 +++++++++++++++++ 3 files changed, 222 insertions(+) diff --git a/AGENTS_mirko.md b/AGENTS_mirko.md index fd7a334a..ccf34012 100644 --- a/AGENTS_mirko.md +++ b/AGENTS_mirko.md @@ -13,3 +13,36 @@ repos, write it up (a short report or plan) instead of implementing/committing i Fixes that land in `csaxs_bec` itself (beamline plugin code, widgets, device configs, scans) are mine to implement, commit, and push directly. + +## TODO + +- 2026-09-20: `flomni_fermat_scan` hung on `pre_scan_all_devices()` after positions were + already sent to the rt controller (scans 11998, 12000 in `/var/log/bec/ScanServer.log` / + `DeviceServer.log`). Positions/PID-correction step (`prepare_setup_part_2()`) completed + fine; `kickoff()`/`start_scan()` (the actual rt-side trigger, logged as `Read N out of 106`) + was never reached because the joined `pre_scan_all_devices()` wait never returned. No error, + no timeout — DeviceServer just went silent until manually aborted. + Most likely culprit: `omny_panda_continuous` (PandaBox) — `on_pre_scan()` in + `ophyd_devices/devices/panda_box/panda_box.py:696` arms the PCAP block and returns a status + that only resolves on a `PandaState.READY`/`FRAME`/`END` event, with **no timeout**. Logs + show `Cannot emit async signal omny_panda_continuous_data ... with status aborted` spamming + throughout the hang window, consistent with the box being left in a stuck/unarmed state after + the preceding rapid interlock-triggered abort/retry cycle (11991–12001, ~11 attempts in 5 + minutes). + This traces into `ophyd_devices`, not `csaxs_bec` — per the ownership note above, not mine to + fix directly here. Follow-ups to consider: (a) file/flag this with whoever owns + `ophyd_devices` (add a timeout + explicit failure path to `PandaBox.on_pre_scan`'s status + callback so a stuck arm surfaces as an error instead of hanging forever), (b) check whether + `on_unstage`/`_reset_panda()` is reliably called on scan abort so the box doesn't carry a + stale state into the next attempt. + + **Status 2026-09-20 (csaxs_bec side done):** `PandaBoxOMNY.on_pre_scan` + (`csaxs_bec/devices/panda_box/panda_box_omny.py`) no longer waits for READY forever. Each + arming attempt waits `pre_scan_timeout` (default 5 s); if READY does not arrive, the box is + force-reset (`_reset_panda()`), the readout thread restarted and arming retried once (retry is + printed and logged as a warning with diagnostics). Only if the second attempt fails as well + does the status fail with a `RuntimeError`, so the scan aborts instead of hanging. + Still open for the ophyd_devices owner: the base-class race where the readout loop's + `finally: _reset_panda()` can clear `data_thread_run_event` after a fresh `on_stage()` set it + (likely root cause of the missing READY after rapid abort/retry); the new diagnostics line in + the DeviceServer log (`data_thread_run_event=False`) would confirm it. diff --git a/csaxs_bec/devices/panda_box/panda_box_omny.py b/csaxs_bec/devices/panda_box/panda_box_omny.py index 0033c8fa..52157433 100644 --- a/csaxs_bec/devices/panda_box/panda_box_omny.py +++ b/csaxs_bec/devices/panda_box/panda_box_omny.py @@ -8,6 +8,7 @@ from bec_lib.logger import bec_logger from bec_server.scan_server.scans.scan_base import ScanInfo as ScanServerScanInfo from ophyd_devices import StatusBase from ophyd_devices.devices.panda_box.panda_box import PandaBox, PandaState +from ophyd_devices.interfaces.base_classes.psi_device_base import DeviceStoppedError from pandablocks.responses import FrameData from csaxs_bec.devices.utils.utils import fetch_scan_info @@ -50,6 +51,11 @@ class PandaBoxOMNY(PandaBox): _GATE_SIGNAL_NAME = "gate_detector_active" _GATE_BIT_OFFSET = 0 + # Arming in on_pre_scan is tried this many times (one forced abort/reset in between) before + # the scan is failed. READY normally arrives within milliseconds of the readout thread + # connecting, so a missing READY means the readout thread/box is in a stale state. + _PRE_SCAN_ARM_ATTEMPTS = 2 + def __init__( self, *, @@ -61,6 +67,7 @@ class PandaBoxOMNY(PandaBox): raw_stream_mode: bool = False, raw_stream_flush_interval: float = 0.05, raw_stream_flush_row_count: int = 500, + pre_scan_timeout: float = 5.0, **kwargs, ) -> None: # Free-running raw-stream configuration. Defaults preserve today's behavior: @@ -74,6 +81,13 @@ class PandaBoxOMNY(PandaBox): self._raw_stream_buffer_lock = threading.Lock() self._raw_stream_last_flush = time.monotonic() + # Seconds to wait for the PandA READY event per arming attempt in on_pre_scan. + self.pre_scan_timeout = pre_scan_timeout + # Set while the data readout thread is parked (not inside _run_data_readout), so a + # retry in on_pre_scan can wait for the old readout loop's cleanup to be finished. + self._readout_idle = threading.Event() + self._readout_idle.set() + super().__init__( name=name, host=host, @@ -113,6 +127,91 @@ class PandaBoxOMNY(PandaBox): logger.info(f"PandaBox {self.name} on_stage completed in {time.time() - start_time:.3f}s.") + def _run_data_readout(self) -> None: + """Same as the base class, but tracks whether the readout loop is running. The base + loop ends with `_reset_panda()`, which clears `data_thread_run_event`; a retry in + `_arm_with_retry` must not set that event again before this cleanup has run.""" + self._readout_idle.clear() + try: + super()._run_data_readout() + finally: + self._readout_idle.set() + + def on_pre_scan(self): + """Arm the PCAP module. Unlike the base class this does not wait forever for the READY + event: if it does not arrive, the box is forcibly reset and arming is retried once, + and only if that fails as well the returned status fails, so the scan is aborted with + an error instead of hanging silently in pre_scan.""" + # Not registered with cancel_on_stop: the status of each single attempt in _try_arm is, + # so a stop makes the task raise DeviceStoppedError, which fails this status. Cancelling + # this one as well would make the task handler try to resolve it a second time. + return self.task_handler.submit_task(self._arm_with_retry, run=True) + + def _arm_with_retry(self) -> None: + """Task run by on_pre_scan: try to arm, force an abort/reset and try again if the + first attempt fails, raise if all attempts fail. Every retry is printed and logged.""" + attempts = self._PRE_SCAN_ARM_ATTEMPTS + for attempt in range(1, attempts + 1): + if self._try_arm(): + if attempt > 1: + logger.info( + f"PandaBox {self.name} armed successfully on attempt {attempt}/{attempts}." + ) + return + if attempt < attempts: + msg = ( + f"PandaBox {self.name} did not report READY within {self.pre_scan_timeout}s " + f"(arming attempt {attempt}/{attempts}). Forcing abort/reset and retrying." + ) + print(msg) + logger.warning(f"{msg} {self._arm_diagnostics()}") + self._reset_panda() + # Wait for the old readout loop to finish its own _reset_panda() cleanup, it + # would otherwise clear the run event we set next. + self._readout_idle.wait(timeout=2) + self.data_thread_run_event.set() + msg = ( + f"PandaBox {self.name} could not be armed after {attempts} attempts (no READY within " + f"{self.pre_scan_timeout}s each). The box was reset; check the connection and the " + "PandA state." + ) + logger.error(f"{msg} {self._arm_diagnostics()}") + self._reset_panda() + raise RuntimeError(msg) + + def _try_arm(self) -> bool: + """One arming attempt: wait for the READY event, then send Arm(). Returns False if + READY did not arrive in time or the box reported a conflicting state (FRAME/END, + i.e. an acquisition is still running). A stop/unstage is not retried.""" + if self.stopped: + raise DeviceStoppedError(f"Device {self.name} has been stopped") + status = StatusBase(obj=self) + cb_id = self.add_status_callback( + status=status, success=[PandaState.READY], failure=[PandaState.FRAME, PandaState.END] + ) + self.cancel_on_stop(status) + try: + status.wait(timeout=self.pre_scan_timeout) + except DeviceStoppedError: + self.remove_status_callback(cb_id) + raise + except Exception as exc: # pylint: disable=broad-except + # Timeout, or the box reported FRAME/END while we waited for READY + self.remove_status_callback(cb_id) + logger.warning(f"PandaBox {self.name} arming attempt failed: {exc!r}") + return False + # Sent from here rather than from a status callback (as the base class does), so the + # scan is only released after Arm() actually went out. + self._arm() + return True + + def _arm_diagnostics(self) -> str: + return ( + f"[host={self.host}, panda_state={self.panda_state}, " + f"data_thread_alive={self.data_thread.is_alive()}, " + f"data_thread_run_event={self.data_thread_run_event.is_set()}]" + ) + def on_unstage(self): """Make sure no buffered raw-stream data is left behind before resetting the device.""" self._flush_raw_stream_buffer() diff --git a/tests/tests_devices/test_panda.py b/tests/tests_devices/test_panda.py index 48f6f2f2..ee6626b3 100644 --- a/tests/tests_devices/test_panda.py +++ b/tests/tests_devices/test_panda.py @@ -3,11 +3,14 @@ # pylint: skip-file from __future__ import annotations +import threading from unittest import mock import numpy as np import pytest from ophyd import Staged +from ophyd_devices.devices.panda_box.panda_box import PandaState +from ophyd_devices.interfaces.base_classes.psi_device_base import DeviceStoppedError from pandablocks.responses import FrameData from csaxs_bec.devices.panda_box.panda_box import PandaBoxCSAXS @@ -294,6 +297,93 @@ def test_panda_omny_complete_free_running_timeout(panda_omny_raw_stream): mock_flush.assert_called_once() +def _deliver_ready_after(dev, delay: float) -> threading.Timer: + """Simulate the PandA data thread delivering the READY event `delay` seconds from now.""" + timer = threading.Timer(delay, dev._run_status_callbacks, args=(PandaState.READY,)) + timer.daemon = True + timer.start() + return timer + + +def test_panda_omny_pre_scan_arms_when_ready(panda_omny, capsys): + dev = panda_omny + dev.pre_scan_timeout = 2 + with ( + mock.patch.object(dev, "_arm") as mock_arm, + mock.patch.object(dev, "_reset_panda") as mock_reset, + ): + _deliver_ready_after(dev, 0.05) + status = dev.on_pre_scan() + status.wait(timeout=4) + assert status.success is True + mock_arm.assert_called_once() + mock_reset.assert_not_called() + assert "Forcing abort/reset" not in capsys.readouterr().out + + +def test_panda_omny_pre_scan_retries_once_then_succeeds(panda_omny, capsys): + """No READY on the first attempt: the box is force-reset and arming retried. The retry is + printed and logged.""" + dev = panda_omny + dev.pre_scan_timeout = 0.2 + + def _reset_then_ready(): + _deliver_ready_after(dev, 0.05) + + with ( + mock.patch.object(dev, "_arm") as mock_arm, + mock.patch.object(dev, "_reset_panda", side_effect=_reset_then_ready) as mock_reset, + mock.patch("csaxs_bec.devices.panda_box.panda_box_omny.logger") as mock_logger, + ): + status = dev.on_pre_scan() + status.wait(timeout=4) + assert status.success is True + mock_reset.assert_called_once() + mock_arm.assert_called_once() + assert any("Forcing abort/reset" in str(c) for c in mock_logger.warning.call_args_list) + assert any("armed successfully on attempt 2" in str(c) for c in mock_logger.info.call_args_list) + assert "Forcing abort/reset" in capsys.readouterr().out + + +def test_panda_omny_pre_scan_raises_after_retry_fails(panda_omny, capsys): + dev = panda_omny + dev.pre_scan_timeout = 0.1 + with ( + mock.patch.object(dev, "_arm") as mock_arm, + mock.patch.object(dev, "_reset_panda") as mock_reset, + mock.patch("csaxs_bec.devices.panda_box.panda_box_omny.logger") as mock_logger, + ): + status = dev.on_pre_scan() + with pytest.raises(RuntimeError, match="could not be armed after 2 attempts"): + status.wait(timeout=4) + mock_arm.assert_not_called() + # once for the forced reset before the retry, once to leave the box clean at the end + assert mock_reset.call_count == 2 + mock_logger.error.assert_called_once() + assert capsys.readouterr().out.count("Forcing abort/reset") == 1 + # stale status callbacks of the failed attempts are removed again + assert dev._status_callbacks == {} + + +def test_panda_omny_pre_scan_stop_is_not_retried(panda_omny, capsys): + dev = panda_omny + dev.pre_scan_timeout = 0.3 + with ( + mock.patch.object(dev, "_arm") as mock_arm, + mock.patch.object(dev, "_reset_panda") as mock_reset, + ): + status = dev.on_pre_scan() + threading.Event().wait(0.05) + dev._stop_stoppable_status_objects() + with pytest.raises(DeviceStoppedError): + status.wait(timeout=4) + # give a wrongly retrying task thread the time to show up + threading.Event().wait(0.5) + mock_arm.assert_not_called() + mock_reset.assert_not_called() + assert "Forcing abort/reset" not in capsys.readouterr().out + + def test_panda_omny_raw_stream_signal_alias(panda_omny_raw_stream): all_signal_names = [name for name, _ in panda_omny_raw_stream.data.signals] assert "gate_detector_active" in all_signal_names -- 2.54.0 From 15cde5a0e711b55d7429ee53efa123b476175ca3 Mon Sep 17 00:00:00 2001 From: x12sa Date: Sun, 20 Sep 2026 11:53:07 +0200 Subject: [PATCH 56/78] docs(omny): note X-ray-eye DAP fit fix and what omny inherits from it The DAP start-value fix in the shared OMNY_XRayEye widget already applies to LamNI/omny; 8-point full-period data was never affected. Records that omny/x_ray_eye_align.py is still a LamNI copy that doesn't consume the GUI fit. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017szgTwuHG65YhjoBGiK2Gj --- .../omny/AI_docs/TODO_xrayeye_dap_fit.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_xrayeye_dap_fit.md diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_xrayeye_dap_fit.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_xrayeye_dap_fit.md new file mode 100644 index 00000000..449279e8 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_xrayeye_dap_fit.md @@ -0,0 +1,37 @@ +# Note: X-ray-eye DAP sine fit (start values) -- what omny inherits, and what is still open + +Status: **nothing to fix in omny code for the fit itself; recorded 2026-09-20 so it isn't rediscovered.** + +## What was fixed (flomni, 2026-09-20) + +The GUI fit tab (`OMNY_XRayEye` in `csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py`) fits +`A*sin(f*x + shift) + intercept` through the DAP server with `f` fixed to 1 deg. The DAP server +used lmfit's `SineModel.guess()` for the start values. For flomni's 5 points over only 0..180 deg +that FFT-based guess is arbitrary and regularly left `shift` stuck at its bound (2*pi), far from +the data (plotted fit missed the submitted points; wrong `X Phase` loaded into flomni). + +Fix: `SINE_FIT_DAP_PARAMETERS` (same file) overrides all five parameters (signed amplitude, start +A=30 / shift=0 within +-pi / intercept=0), so DAP skips `guess()`. 5-pt/0..180 deg: 0 failures in 300 +random datasets through the real `LmfitService1D` (old parameters: 16 of 300). + +## Consequences for omny + +- The widget is shared by flomni, LamNI and omny, so omny's GUI fit tab already uses the new + parameters. Checked with 8 points over 0..315 deg (omny/LamNI layout): 0 of 300 failures both + with the old and the new parameters, i.e. the 8-point full-period case was never affected and + is not made worse. No omny change required. +- The fitted parameters use the same form `A*sin(radians(angle) + phase) + offset` in flomni, + LamNI and omny consumers, so the (now possibly negative) amplitude with phase in [-pi, pi] is + the same curve as before. Nothing downstream needs a sign fix. +- Cosmetic: a row of all zeros (flomni's y row) now fits to ~1e-6 noise instead of exactly 0; + the y plot autoscales that to a curved line at the 1e-6 level. Harmless (flomni overrides y + with zeros in `read_alignment_offset`). + +## Open for omny + +- `omny/x_ray_eye_align.py` is still a verbatim copy of LamNI's (class docstring "for LamNI", + uses `self.lamni.*` and `self.lamni.align.read_xray_eye_correction_from_gui()`), while + `omny/omny_alignment_mixin.py` only reads the Matlab fit from `ptychotomoalign_*.txt` + (`read_alignment_offset`) and has no `read_xray_eye_correction_from_gui`. So omny currently + does not consume the GUI fit at all. When omny's alignment is properly ported, decide whether + it should use the GUI fit (then this DAP fix is what it relies on) or keep the Matlab files. -- 2.54.0 From 028e31be418a27ce7e4266f86c52b297d9c7433f Mon Sep 17 00:00:00 2001 From: x12sa Date: Sun, 20 Sep 2026 11:55:26 +0200 Subject: [PATCH 57/78] perf(csaxs): read slit positions from cache in slits_show_all() device.read() defaults to cached=False, i.e. one RPC round trip to the device server per device (24 for the six slits). Use cached=True, which reads the last published readback from redis; a missing value still shows as '---'. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017szgTwuHG65YhjoBGiK2Gj --- .../bec_ipython_client/plugins/cSAXS/slits.py | 5 ++- .../test_cSAXS_slits.py | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 tests/tests_bec_ipython_client/test_cSAXS_slits.py diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/slits.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/slits.py index f1653c58..31246a6a 100644 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/slits.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/slits.py @@ -47,7 +47,10 @@ class cSAXSSlits: return None try: - reading = device.read() + # cached=True reads the last published readback from redis. The default + # (cached=False) is one RPC round trip to the device server per device, + # i.e. 24 of them for slits_show_all(). + reading = device.read(cached=True) value = reading[device_name]["value"] return float(value) except Exception: diff --git a/tests/tests_bec_ipython_client/test_cSAXS_slits.py b/tests/tests_bec_ipython_client/test_cSAXS_slits.py new file mode 100644 index 00000000..b381bfbd --- /dev/null +++ b/tests/tests_bec_ipython_client/test_cSAXS_slits.py @@ -0,0 +1,45 @@ +from unittest import mock + +from csaxs_bec.bec_ipython_client.plugins.cSAXS.slits import cSAXSSlits + + +def _make_slits(devices: dict): + client = mock.MagicMock() + client.device_manager.devices = devices + return cSAXSSlits(client) + + +def test_sl_get_position_uses_cached_read(): + dev = mock.MagicMock() + dev.read.return_value = {"sl4xc": {"value": 1.25}} + slits = _make_slits({"sl4xc": dev}) + + assert slits._sl_get_position("sl4xc") == 1.25 + dev.read.assert_called_once_with(cached=True) + + +def test_sl_get_position_returns_none_without_cached_value(): + dev = mock.MagicMock() + dev.read.return_value = None # nothing published to redis yet + slits = _make_slits({"sl4xc": dev}) + + assert slits._sl_get_position("sl4xc") is None + assert slits._sl_get_position("unknown_device") is None + + +def test_slits_show_all_reads_every_slit_device_cached(capsys): + devices = {} + for prefix in cSAXSSlits._SLIT_LABELS: + for suffix in ("xc", "xs", "yc", "ys"): + name = f"{prefix}{suffix}" + dev = mock.MagicMock() + dev.read.return_value = {name: {"value": 0.5}} + devices[name] = dev + slits = _make_slits(devices) + + slits.slits_show_all() + + assert len(devices) == 24 + for dev in devices.values(): + dev.read.assert_called_once_with(cached=True) + assert "cSAXS Slits Overview" in capsys.readouterr().out -- 2.54.0 From c66253709af059ca58aacd404109c1be0b04223d Mon Sep 17 00:00:00 2001 From: x12sa Date: Sun, 20 Sep 2026 12:01:17 +0200 Subject: [PATCH 58/78] perf(csaxs): read filter positions from cache in the fil_trans report _fil_trans_report is a status report, so use readback.get(cached=True) (last published readback from redis) instead of one RPC round trip per filter axis. A missing value still ends in the existing "readback unavailable" error. The post-umv position check in fil_trans stays live on purpose. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017szgTwuHG65YhjoBGiK2Gj --- .../plugins/cSAXS/filter_transmission.py | 6 ++- .../test_cSAXS_filter_transmission.py | 53 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/tests_bec_ipython_client/test_cSAXS_filter_transmission.py diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py index ea1e3a80..4d708b9a 100644 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py @@ -757,7 +757,7 @@ class cSAXSFilterTransmission: def _fil_trans_report(self, tol: float = 0.1, energy_kev: Optional[float] = None) -> None: """ Report the currently active exposure‑box filter combination. - Determines stage positions via dev..readback.get() + Determines stage positions via dev..readback.get(cached=True) with a tolerance window of ±tol relative to nominal positions. Parameters @@ -799,7 +799,9 @@ class cSAXSFilterTransmission: return try: - rb = float(axis_obj.readback.get()) + # Status report only: the last published readback (cached=True, from redis) + # avoids one RPC round trip per axis. A missing value raises in float() below. + rb = float(axis_obj.readback.get(cached=True)) except Exception: print(f"ERROR: readback unavailable for axis {axis_name}") return diff --git a/tests/tests_bec_ipython_client/test_cSAXS_filter_transmission.py b/tests/tests_bec_ipython_client/test_cSAXS_filter_transmission.py new file mode 100644 index 00000000..b7072db7 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_cSAXS_filter_transmission.py @@ -0,0 +1,53 @@ +from unittest import mock + +import pytest + +import csaxs_bec.bec_ipython_client.plugins.cSAXS.filter_transmission as ft_module +from csaxs_bec.bec_ipython_client.plugins.cSAXS.filter_transmission import ( + cSAXSFilterTransmission, +) + + +def _make_filter(): + filt = cSAXSFilterTransmission() + filt.OMNYTools = mock.MagicMock() + return filt + + +def _make_dev(readbacks: dict): + """Mock `dev` where each filter axis' readback.get returns the given value.""" + dev = mock.MagicMock() + for axis in cSAXSFilterTransmission._AXES: + axis_obj = mock.MagicMock() + axis_obj.readback.get.return_value = readbacks[axis] + setattr(dev, axis, axis_obj) + return dev + + +def test_fil_trans_report_reads_positions_cached(capsys): + filt = _make_filter() + # Sit exactly on the first defined nominal position of every unit + readbacks = {} + for axis, positions in zip(filt._AXES, filt._POSITIONS_USER): + readbacks[axis] = next(p for p in positions if p is not None) + dev = _make_dev(readbacks) + + with mock.patch.object(ft_module, "dev", dev, create=True): + filt._fil_trans_report(energy_kev=7.0) + + for axis in filt._AXES: + getattr(dev, axis).readback.get.assert_called_once_with(cached=True) + assert "Matched filter code" in capsys.readouterr().out + + +def test_fil_trans_report_without_cached_value_reports_error(capsys): + filt = _make_filter() + readbacks = {axis: None for axis in filt._AXES} # nothing published to redis yet + dev = _make_dev(readbacks) + + with mock.patch.object(ft_module, "dev", dev, create=True): + filt._fil_trans_report(energy_kev=7.0) + + out = capsys.readouterr().out + assert f"readback unavailable for axis {filt._AXES[0]}" in out + assert "Matched filter code" not in out -- 2.54.0 From 19cfee3e791067899f66d4c10716851d792f4fcb Mon Sep 17 00:00:00 2001 From: x12sa Date: Sun, 20 Sep 2026 13:49:20 +0200 Subject: [PATCH 59/78] feat(flomni): add randomize_offset scan argument to flomni_fermat_scan The per-call random sub-step offset of the Fermat lattice could not be switched off from the scan (seed is only an argument of the static position function, and a seed only makes the offset reproducible). Add an optional randomize_offset: bool = True scan argument, passed through prepare_scan() to get_flomni_fermat_spiral_pos(); False gives offset 0 (unshifted lattice). Default keeps today's behaviour; tomo scans do not pass it. Document it in the flomni user doc and add an omny AI_docs TODO describing the port. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017szgTwuHG65YhjoBGiK2Gj --- .../TODO_fermat_randomize_offset_port.md | 47 +++++++++++++++++++ csaxs_bec/scans/flomni_fermat_scan.py | 10 +++- docs/user/ptychography/flomni.md | 13 +++-- tests/tests_scans/test_flomni_fermat_scan.py | 33 +++++++++++++ 4 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_randomize_offset_port.md diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_randomize_offset_port.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_randomize_offset_port.md new file mode 100644 index 00000000..bfc80985 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_randomize_offset_port.md @@ -0,0 +1,47 @@ +# TODO: port `randomize_offset` (switch off the sub-step randomization) to OMNY + +**Status: `randomize_offset` is implemented on Flomni only (branch `fixes/flomni_beamtime_2`, +2026-09-20), not yet ported to OMNY.** + +## What landed on Flomni + +The per-call random sub-step offset of the Fermat spiral (see `docs/plans/fermat-scan-randomization.md`) +had no way to be turned off from the scan: `seed` exists only as an argument of the static position +function and is not a scan argument. A fixed seed would only make the offset reproducible anyway -- +the offset is always `uniform(0, step)`, so no seed gives the unshifted lattice. + +Flomni now has an optional scan argument `randomize_offset: bool = True` +(`csaxs_bec/scans/flomni_fermat_scan.py`): + +- `FlomniFermatScan.__init__` takes it as a `ScanArgument` (default `True`, so behaviour is + unchanged), stores `self.randomize_offset` and passes it in `prepare_scan()`. +- `get_flomni_fermat_spiral_pos(..., randomize_offset=True)` computes + `offset = np.random.default_rng(seed).uniform(0, step) if randomize_offset else 0.0`. + With `False` the offset is 0 and `seed` is irrelevant. +- Tests: `test_randomize_offset_default_is_active` and + `test_randomize_offset_false_gives_unshifted_deterministic_lattice` in + `tests/tests_scans/test_flomni_fermat_scan.py`. +- User doc: `docs/user/ptychography/flomni.md` (parameter table + paragraph on the randomization). + +## What needs to happen on OMNY + +`OmnyFermatScan.get_omny_fermat_spiral_pos` (`csaxs_bec/scans/omny_fermat_scan.py`, offset drawn at +~line 319) has the same `seed` argument and the same `offset = ...uniform(0, step)` line, so the +change ports directly: + +1. Add `randomize_offset: Annotated[bool, ScanArgument(...)] = True` to `OmnyFermatScan.__init__`, + with docstring entry, store it and pass it through in `prepare_scan()`. +2. Add `randomize_offset: bool = True` to `get_omny_fermat_spiral_pos` and use + `0.0` instead of the random draw when it is `False`. +3. Mirror the two Flomni tests in `tests/tests_scans/test_omny_fermat_scan.py`. +4. Add the parameter to the parameter table in `docs/user/ptychography/omny.md` (~line 322-339; + the omny table has no `fermat_asymmetry` row yet either, see + `TODO_fermat_asymmetry_port.md`). + +Scope notes: + +- The OMNY tomo loop (`omny.py`, `scan_kwargs` in the per-angle scan call) does not need to pass it; + as on Flomni, tomo scans keep the default (randomized). +- Compose with `fermat_asymmetry` as described in `TODO_fermat_asymmetry_port.md`: `randomize_offset=False` + only sets `offset = 0.0`; the offset-subtraction -> inflated check -> compression ordering stays as is. +- LamNI has the identical offset code (`lamni_fermat_scan.py:323`) but was not asked for this switch. diff --git a/csaxs_bec/scans/flomni_fermat_scan.py b/csaxs_bec/scans/flomni_fermat_scan.py index 787cdda5..f07cbca8 100644 --- a/csaxs_bec/scans/flomni_fermat_scan.py +++ b/csaxs_bec/scans/flomni_fermat_scan.py @@ -82,6 +82,7 @@ class FlomniFermatScan(ScanBase): angle: Annotated[float, ScanArgument(display_name="Angle", description="Rotation angle (will rotate first)", units=Units.deg, example=0)], corridor_size: Annotated[float | None, ScanArgument(display_name="Corridor Size", description="Corridor size for the corridor optimization.", units=Units.µm)] = None, fermat_asymmetry: Annotated[float, ScanArgument(display_name="Fermat Asymmetry", description="Ratio of x spacing to y spacing (x/y). 1 = symmetric. >1 widens x spacing / tightens y spacing; <1 the opposite. FOV is preserved; point count changes with the ratio.", gt=0, example=1)] = 1.0, + randomize_offset: Annotated[bool, ScanArgument(display_name="Randomize Offset", description="Randomize the sub-step offset of the Fermat lattice on every scan (decorrelates position-tied reconstruction errors across a tomographic series). Set to False for the unshifted lattice.", example=True)] = True, exp_time: DefaultArgType.ExposureTime = 0.1, frames_per_trigger: DefaultArgType.FramesPerTrigger = 1, burst_at_each_point: DefaultArgType.BurstAtEachPoint = 1, @@ -103,6 +104,8 @@ class FlomniFermatScan(ScanBase): fermat_asymmetry (float): Ratio of x spacing to y spacing (x/y). 1 = symmetric. x spacing stays fixed at step, y spacing scales by this ratio. The FOV is preserved, so the point count changes with the ratio. + randomize_offset (bool): Randomize the sub-step offset of the Fermat lattice on every + scan. Default True. If False, the lattice is not shifted. exp_time (float): Exposure time in seconds frames_per_trigger (int): Number of frames per trigger for devices that support configurable frame counts per trigger. burst_at_each_point (int): Number of triggers and readouts at each point. @@ -121,6 +124,7 @@ class FlomniFermatScan(ScanBase): self.angle = angle self.corridor_size = corridor_size self.fermat_asymmetry = fermat_asymmetry + self.randomize_offset = randomize_offset self.exp_time = exp_time self.frames_per_trigger = frames_per_trigger self.burst_at_each_point = burst_at_each_point @@ -167,6 +171,7 @@ class FlomniFermatScan(ScanBase): ceny=self.ceny, zshift=self.zshift, fermat_asymmetry=self.fermat_asymmetry, + randomize_offset=self.randomize_offset, ) if len(positions) < self.MIN_POSITIONS: @@ -430,6 +435,7 @@ class FlomniFermatScan(ScanBase): zshift: float = 0.0, seed: int | None = None, fermat_asymmetry: float = 1.0, + randomize_offset: bool = True, ): """ Calculate positions for a Fermat spiral scan. @@ -449,6 +455,8 @@ class FlomniFermatScan(ScanBase): (default). x spacing stays fixed at step; y spacing scales by this ratio. The requested FOV (m1/m2 range) is preserved, so the point count changes with the ratio. Soft-clamped to [MIN_FERMAT_ASYMMETRY, MAX_FERMAT_ASYMMETRY]. + randomize_offset(bool): draw the random sub-step offset (default). If False the + offset is 0 and the lattice is unshifted; seed is then irrelevant. Returns: positions(array): positions @@ -483,7 +491,7 @@ class FlomniFermatScan(ScanBase): # projection), matching the legacy spec behaviour: this decorrelates position-tied # reconstruction errors that would otherwise accumulate into ring artifacts across # a tomographic series. - offset = np.random.default_rng(seed).uniform(0, step) + offset = np.random.default_rng(seed).uniform(0, step) if randomize_offset else 0.0 logger.info(f"Fermat spiral sub-step offset: {offset:.4f} um") for ii in range(start, n_max): diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index 67fb08b0..1735ae9b 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -227,6 +227,7 @@ The basic scan function can be called by `scans.flomni_fermat_scan()` and offers | zshift (float) | shift in z | | angle (float) | rotation angle (will rotate first) | | corridor_size (float) | corridor size for the corridor optimization. Default 3 um (auto-estimated if not provided) | +| randomize_offset (bool) | randomize the sub-step offset of the Fermat lattice on every scan. Default `True`. Set `False` for the unshifted lattice (identical positions on every call). See below. | | exp_time (float) | exposure time per frame | | frames_per_trigger (int) | Number of burst frames per position | | burst_at_each_point (int) | Number of triggers and readouts at each point | @@ -246,10 +247,14 @@ the y spacing becomes `step / fermat_asymmetry`. So with `step=1 um`: The requested FOV stays exactly as specified either way -- only the point density (and therefore the point count and scan duration) changes. -The Fermat spiral's sub-step phase is also randomized on every projection (drawn -fresh per scan, not user-configurable) to decorrelate position-tied reconstruction -errors that would otherwise accumulate into ring artifacts across a tomographic -series. +By default the Fermat spiral's sub-step phase is randomized on every projection +(drawn fresh per scan, uniform in `[0, step)`) to decorrelate position-tied +reconstruction errors that would otherwise accumulate into ring artifacts across a +tomographic series. Pass `randomize_offset=False` to switch this off and get the +unshifted lattice, e.g. for reproducible positions when comparing scans: +`scans.flomni_fermat_scan(fovx=10, fovy=10, cenx=0, ceny=0, step=1, randomize_offset=False, ...)`. +The tomo scans (`tomo_scan`, `tomo_scan_projection`) do not pass this parameter and +therefore always randomize. `frames_per_trigger` controls burst acquisition for detectors generally. Separately, the OMNY PandABox is configured as one of two distinct devices in diff --git a/tests/tests_scans/test_flomni_fermat_scan.py b/tests/tests_scans/test_flomni_fermat_scan.py index 4bfd206d..2e2e4c0e 100644 --- a/tests/tests_scans/test_flomni_fermat_scan.py +++ b/tests/tests_scans/test_flomni_fermat_scan.py @@ -119,3 +119,36 @@ def test_fermat_asymmetry_out_of_range_is_clamped_not_raised(): loop_points = pos_too_large[:-2] assert np.max(np.abs(loop_points[:, 0])) <= fovx / 2 assert np.max(np.abs(loop_points[:, 1])) <= fovy / 2 + + +def test_randomize_offset_default_is_active(): + # Default keeps the per-call random sub-step offset: different seeds give different + # lattices, and omitting randomize_offset equals randomize_offset=True. + fovx, fovy = 10.0, 10.0 + args = (-fovx / 2, fovx / 2, -fovy / 2, fovy / 2) + a = FlomniFermatScan.get_flomni_fermat_spiral_pos(*args, step=1, seed=1) + b = FlomniFermatScan.get_flomni_fermat_spiral_pos(*args, step=1, seed=2) + c = FlomniFermatScan.get_flomni_fermat_spiral_pos( + *args, step=1, seed=1, randomize_offset=True + ) + assert a.shape != b.shape or not np.allclose(a, b) + np.testing.assert_array_equal(a, c) + + +def test_randomize_offset_false_gives_unshifted_deterministic_lattice(): + # randomize_offset=False ignores the seed and is identical from call to call. + fovx, fovy = 10.0, 10.0 + args = (-fovx / 2, fovx / 2, -fovy / 2, fovy / 2) + a = FlomniFermatScan.get_flomni_fermat_spiral_pos( + *args, step=1, seed=1, randomize_offset=False + ) + b = FlomniFermatScan.get_flomni_fermat_spiral_pos( + *args, step=1, seed=2, randomize_offset=False + ) + d = FlomniFermatScan.get_flomni_fermat_spiral_pos(*args, step=1, randomize_offset=False) + np.testing.assert_array_equal(a, b) + np.testing.assert_array_equal(a, d) + # unshifted lattice: first loop point (ii=1) is at radius step*0.57, no offset + phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2.0) + r = 0.57 + np.testing.assert_allclose(a[0, :2], [r * np.sin(phi), r * np.cos(phi)]) -- 2.54.0 From 02144a23ccc25bd1db8e03cdd69ed9619fde5fe9 Mon Sep 17 00:00:00 2001 From: x01dc Date: Sun, 20 Sep 2026 20:46:25 +0200 Subject: [PATCH 60/78] feat(machine): add SLS injection/top-up status PV Registers AGETI-CVME-MASTER-TMA:SR-Inj-Status-I (the injection-system enum PV) as injection_status, alongside the other machine-status PVs (curr, ofb, shutter). readoutPriority is 'monitored', matching curr, so the value is captured continuously throughout each scan rather than only once at scan start -- a top-up can start or stop mid-scan. '0' / 'Filling Storage Ring' means the ring is being refilled. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- csaxs_bec/device_configs/machine.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/csaxs_bec/device_configs/machine.yml b/csaxs_bec/device_configs/machine.yml index 6610aecb..abc3458f 100644 --- a/csaxs_bec/device_configs/machine.yml +++ b/csaxs_bec/device_configs/machine.yml @@ -51,3 +51,22 @@ operator: softwareTrigger: false deviceTags: - machine + +injection_status: + # Top-up gating: '0' / 'Filling Storage Ring' means the storage ring is + # being refilled. readoutPriority is 'monitored' (not 'baseline') so this + # is captured continuously throughout the scan, not just read once at + # scan start - a top-up can start or stop mid-scan. + description: SLS injection/top-up status + deviceClass: ophyd_devices.EpicsSignalRO + deviceConfig: + read_pv: AGETI-CVME-MASTER-TMA:SR-Inj-Status-I + auto_monitor: true + string: true + onFailure: buffer + enabled: true + readoutPriority: monitored + readOnly: true + softwareTrigger: false + deviceTags: + - machine -- 2.54.0 From e374dc6ae442f5c2e395ff301221a200a317f410 Mon Sep 17 00:00:00 2001 From: x01dc Date: Sun, 20 Sep 2026 20:55:27 +0200 Subject: [PATCH 61/78] docs(machine): document all 8 injection_status enum values List the full set of AGETI-CVME-MASTER-TMA:SR-Inj-Status-I enum strings in the config comment, not just the top-up-relevant ones, so the PV's possible states are documented in one place. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw --- csaxs_bec/device_configs/machine.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/csaxs_bec/device_configs/machine.yml b/csaxs_bec/device_configs/machine.yml index abc3458f..72fecf75 100644 --- a/csaxs_bec/device_configs/machine.yml +++ b/csaxs_bec/device_configs/machine.yml @@ -57,6 +57,16 @@ injection_status: # being refilled. readoutPriority is 'monitored' (not 'baseline') so this # is captured continuously throughout the scan, not just read once at # scan start - a top-up can start or stop mid-scan. + # + # Enum values of AGETI-CVME-MASTER-TMA:SR-Inj-Status-I (8 states): + # 0: Filling Storage Ring <- top-up in progress + # 1: Waiting manual trig... + # 2: Electron gun event OFF! + # 3: SR Injection ready + # 4: Inj. events OFF + # 5: Beam Dump + # 6: !Event LI-RF set on 10Hz! + # 7: !Injection guard inhibit! description: SLS injection/top-up status deviceClass: ophyd_devices.EpicsSignalRO deviceConfig: -- 2.54.0 From b2bdd3d4deeb8db7ccfa024e24c2f01854a1a0ae Mon Sep 17 00:00:00 2001 From: x12sa Date: Mon, 21 Sep 2026 10:44:52 +0200 Subject: [PATCH 62/78] sample mounting 90 deg for flat sample compatibility --- csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 06389265..5a105b75 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -465,6 +465,8 @@ class FlomniSampleTransferMixin: umv(dev.fsamx, fsamx_in) dev.fsamx.limits = [fsamx_in - 0.4, fsamx_in + 0.4] + umv(dev.fsamroy, 0) + print("Moving X-ray eye in.") # if self.OMNYTools.yesno( @@ -735,6 +737,8 @@ class FlomniSampleTransferMixin: self.laser_tracker_off() time.sleep(0.05) + umv(dev.fsamroy, 90) + self.drive_axis_to_limit(dev.fsamx, "forward") dev.fsamx.limits = [-162, 0] dev.fsamx.controller.socket_put_confirmed("axspeed[4]=25*stppermm[4]") @@ -1177,7 +1181,7 @@ class FlomniSampleTransferMixin: self.check_tray_in() if position == 0: - umv(dev.ftransx, 11.02, dev.ftransz, 3.5950) + umv(dev.ftransx, 10.95, dev.ftransz, 3.5950) if position == 1: umv( dev.ftransx, -- 2.54.0 From b486e6cb5c0e90472abf93aa386568ff6baf165d Mon Sep 17 00:00:00 2001 From: x12sa Date: Mon, 21 Sep 2026 10:45:06 +0200 Subject: [PATCH 63/78] config update --- csaxs_bec/device_configs/ptycho_flomni.yaml | 98 ++++++------ docs/plans/flomni-fsamx-pid-centering.md | 166 ++++++++++++++++++++ 2 files changed, 215 insertions(+), 49 deletions(-) create mode 100644 docs/plans/flomni-fsamx-pid-centering.md diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index b010526e..036b72a1 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -90,21 +90,21 @@ foptx: connectionTimeout: 20 userParameter: #120 micros, 60 - in: -13.890 - out: -14 + # in: -13.890 + # out: -14 #150 micros, 60 # in: -14.191 # out: -13.831 #170 micron, 60 nm - #in: -13.8909 - #out: -13.831 + in: -13.8909 + out: -13.831 #250 micron, 30 nm, Abe structures # in: -13.8809375 # out: -14.1809 #250 micron, 30 nm, Tomas structures # in: -14.5490625 # out: -14.1809 - fzp_diameter: 120 # microns + fzp_diameter: 170 # microns fzp_outermost_zone_width: 60 # nm detector_distance: 7196 # mm, sample-to-detector; unknown for now fzp_details: "manufacturing notes here" # free-text FZP manufacturing notes @@ -129,11 +129,11 @@ fopty: connectionTimeout: 20 userParameter: #150 micron, 60 - in: 1.02 - out: 0.869 + # in: 1.02 + # out: 0.869 #170 micron, 60 nm - #in: 0.42 - #out: 0.57 + in: 0.42 + out: 0.57 #250 micron, 30 nm, Abe structures # in: 2.8299 # out: 2.8299 @@ -371,17 +371,17 @@ fosax: connectionTimeout: 20 userParameter: #120micron, 60 nm, 7.9 kev - in: 8.74251 - out: 5.1 + # in: 8.74251 + # out: 5.1 #150micron, 60 nm, 7.9 kev # in: 9.0393 # out: 5.1 #170 micron, 60 nm, 7.6 kev - #in: 8.7568 - #out: 5.1 + # in: 8.7568 + # out: 5.1 #170 micron, 60 nm, 7.9 kev - #n: 8.722151 - #ut: 5.1 + in: 8.718233 + out: 5.1 #250 micron, 30 nm, Abe structures # in: 8.7392 # out: 5.1 @@ -409,13 +409,13 @@ fosay: connectionTimeout: 20 userParameter: #120 micron, 60 nm, 7.9 kev - in: -0.645276 + # in: -0.645276 #150 micron, 60 nm, 7.9 kev #in: -0.6422 #170 micron, 60 nm, 7.6 kev #in: -0.0235 #170 micron, 60 nm, 7.9 kev - #in: -0.0563 + in: -0.062253 #250 micron, 30 nm, Abe structures # in: -2.3684 #250 micron, 30 nm, Tomas structures @@ -439,15 +439,15 @@ fosaz: readoutPriority: baseline connectionTimeout: 20 userParameter: - #150 micron, 60 nm, 7.9 kev, foptz 16.9, probe size 7.5 mu - in: 11.0998 - out: 6 + #150 micron, 60 nm + # in: 11.0998 + # out: 6 #170 micron, 60 nm, 7.6 kev #in: 8.5 #out: 6 #170 micron, 60 nm, 7.9 kev, foptz 16.9, probe size 7.5 mu - # in: 13.1 - # out: 6 + in: 14.1 + out: 6 # 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 +703,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 diff --git a/docs/plans/flomni-fsamx-pid-centering.md b/docs/plans/flomni-fsamx-pid-centering.md new file mode 100644 index 00000000..1d3cbd0b --- /dev/null +++ b/docs/plans/flomni-fsamx-pid-centering.md @@ -0,0 +1,166 @@ +# Plan: improve the `fsamx` PID centering in `move_samx_to_scan_region` + +Status: **DRAFT — NOT FINAL, NEEDS FURTHER DISCUSSION. Not implemented.** +Written during live beamtime operation on request; no code changes made — see +the conversation this plan came from. It rests on one day of logs +(2026-09-18) and small samples (18 first-move cases, 25 later-iteration steps) +and has not been tested on the instrument. The open questions at the bottom +must be settled before any code is changed. + +## Context + +`RtFlomniController.move_samx_to_scan_region` +(`csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py`, ~lines 181-230) centres +`fsamx` after the rt stage has moved to `cenx`. It reads the PID X voltage +(`get_pid_x()`, `g` command on the rt controller, range ±10 V) and compares it +with the expected voltage (`rtx` user parameter `rt_pid_voltage`, stored by +`feedback_enable_with_reset`). + +- If `|V - expected|` is below the start threshold: `No correction of fsamx needed`. +- Otherwise loop until `|V - expected| < 1`, each iteration doing + `pid_x_correction -= (V - expected) * 0.006` and moving `fsamx` to + `fsamx_in + cenx/1000*0.7 + pid_x_correction`, then `laser_tracker_on()`. + +**Trigger for this plan.** The start threshold was lowered from 2.0 V to 1.2 V +(uncommitted edit in the production working tree when this was written) with +the aim of reducing the target range below 0.05 V. Afterwards corrections rose +from about 8.5 to about 49 per hour and the readings still swung by about +±1-2 V. + +**Data source.** `/var/log/bec/DeviceServer.log` and `ScanServer.log` on +`x12sa-bec-001.psi.ch`, 2026-09-18 08:00-20:19. These logs are not part of the +repo. The relevant log messages come from the `rt_flomni_ophyd` logger +(`Current PID voltage`, `Moving fsamx to …, PID portion of that …`, +`No correction of fsamx needed`, `No further correction needed`). + +## Findings this plan rests on + +1. **The loop gain is too small, not too large.** The measured stage response + is 13.4 µm/V (rms 0.18 V over 25 steps at constant `cenx`), while the code + uses `0.006` mm/V = 6 µm/V. Each step therefore removes only about 45 % of + the error (measured contraction ×0.57, predicted ×0.55). Going from 1.2 V to + below 0.05 V takes about 6 iterations, each followed by a laser tracker + wait. At constant `cenx` the loop does not overshoot. +2. **The first move of a sequence double counts.** It applies the `cenx` + feed-forward jump (`0.7*Δcenx` since the last move, median 35 µm) and a + correction increment `-0.006*(V-e)` computed from a reading that already + contains that `cenx` lag. The sign of V flips after the first move in 12 of + 18 cases, e.g. V = -2.44 V with a +36.7 µm feed-forward jump and a +14.6 µm + correction step gives V = +1.82 V. A two-term fit suggests the true + feed-forward factor is nearer 0.8 than 0.7 (weak evidence, n = 18). +3. **Blind steps at the rail overshoot.** In four cases (12:25, 13:56, 18:42, + 19:04) a scan stop was followed by a reading pinned at -10.0 V. The loop + then made three fixed +60 µm moves (`0.006 * 10 V`), the reading flipped to + +10.0 V, and the laser tracker waited 37-100 s for the target. Twice the + wait exceeded 100 polls and raised + `RtError: Failed to reach laser target position`. +4. **`pid_x_correction` is lost on reload.** After the 19:30 device reload it + restarted from 0 (first PID part -0.009), while `fsamx` itself stayed where + it was. +5. **About 1 V of scan-to-scan alternation is unexplained.** Consecutive scan + points alternate high/low with a period of about 46 s. It was present all + day, including before the threshold change (08:00-08:30, 10:00-16:00). The + `cenx` and last-`fsamx`-position model leaves a residual of about 1 V + (R² 0.67 before 19:30, 0.30 after 19:36). This is a rotation-tomography + run: every scan point is a new `angle` and `cenx` follows it. +6. **The `Expected PID voltage` log line is commented out** + (`rt_flomni_ophyd.py:196`). The expected value only appears once per + restart as `Using PID voltage from rtx user parameter`. + +## The math + +Notation: `V` = PID voltage, `e` = expected voltage, `s` = stage response +(mm/V), `x` = `fsamx` position relative to `fsamx_in` (mm), `c` = `cenx`. + +Plant (fits the in-loop data to 0.18 V rms): + +``` +V - e = (x - x*(c)) / s, x*(c) = 0.7*c/1000 + drift, s ≈ 0.0134 mm/V +``` + +Current step: `Δx = -0.006 * (V - e)`, i.e. gain `g = 0.006` and per-step error +ratio `r = 1 - g/s ≈ 0.55`. To reach a residual `ε` from `V0` needs +`n = ln(ε/V0) / ln(r)` steps: 6 steps for 1.2 V → 0.05 V. + +Proposed step (Newton, damped): `Δx = -DAMP * s * (V - e)` with +`DAMP ≈ 0.85`. With a 15 % gain error the ratio is about 0.15, so 1.5 V → 0.03 V +in two iterations. `s` may be adapted from the last two iterations +(`s_est = Δx_prev / (V_prev - V_now)`), clamped to 8-20 µm/V. + +Double counting: on the first move `V - e` already contains `-(0.7*(c - c_prev)/1000)/s`, +which the feed-forward jump removes. Adding `-0.006*(V-e)` on top overshoots. + +## Proposed changes (all in `move_samx_to_scan_region`) + +1. **Newton step** as above instead of the fixed small gain. +2. **Remove the double counting.** + - **Option A (preferred):** compute the target from the actual position, + `x_target = x_now - DAMP*s*(V-e)`, and store + `pid_x_correction = x_target - 0.7*cenx/1000`, so the `cenx` lag is never + accumulated as drift. + - **Option B:** apply the feed-forward move first, re-read V, then correct. + Simpler, but costs about 1 s plus a tracker wait per correction. +3. **Derive `x_now` from the `fsamx` readback** instead of a running sum, so a + reload does not reset the bookkeeping. +4. **Clamp steps at the rail.** When `|V| >= 9.9`, cap the step at about 30 µm + and re-read instead of making blind ±60 µm steps. +5. **Hysteresis.** Start about 0.5 V, stop about 0.15 V (currently start 1.2 V, + stop 1.0 V, so the next `cenx` step re-triggers immediately). +6. **Logging.** + - Restore the `Expected PID voltage` line next to each `Current PID voltage`. + - Log `angle`, `cenx`, V and the `fsamx` readback per scan point. + - Log `laser_tracker_show_all()` output when `laser_tracker_wait_on_target` + times out (the tracker's beam position, target and piezo state are not + logged today). + +Sketch (not tested): + +```python +S, DAMP, FF, VCLIP = 0.0134, 0.85, 0.7e-3, 8.0 # mm/V, -, mm per cenx unit, V +x_now = fsamx.obj.readback.get() - fsamx_in # actual position (mm) +for _ in range(MAX_ITER): + v = self.get_pid_x() - expected_voltage + if abs(v) < V_STOP: + break + x_now -= DAMP * S * np.clip(v, -VCLIP, VCLIP) + fsamx.obj.pid_x_correction = x_now - FF * cenx # drift not explained by cenx + fsamx.obj.move(fsamx_in + x_now, wait=True) + time.sleep(0.1) + self.laser_tracker_on() +``` + +## What this will not fix + +The unexplained ~1 V scan-to-scan alternation (finding 5) is not addressed. A +0.05 V threshold would chase it with a move on nearly every scan point, and each +move costs a laser tracker wait. The plan therefore assumes about 0.15 V as the +stop level until the alternation is understood. + +## Verification (once approved) + +- **Offline replay:** run the proposed step logic against the logged + (V, `cenx`, position) sequences from 2026-09-18 and compare iterations per + correction and residual V with what happened. +- **Bench check:** on the instrument, force a known offset of about 2 V and + confirm convergence to below 0.15 V in at most 3 iterations with no sign + flip; repeat with about 5 V. +- **Rail case:** stop and restart a scan mid-way and confirm no ±10 V + overshoot and no tracker timeout. +- **Rate check:** confirm corrections per hour do not rise above the + 8.5/hour baseline. +- **Unit tests:** the step function should be a pure function so it can be + tested without hardware (see `tests/tests_devices`). + +## Open questions for discussion + +1. Is a threshold of 0.05 V realistic while the alternation is unexplained? The + plan assumes about 0.15 V for the stop level. +2. What is the true feed-forward factor: 0.7 (code) or about 0.8 (fit)? +3. Option A or B for the double counting? +4. Should the alternation be diagnosed first (angle dependence, backlash, the + `v0`/`v1` FZP feedback toggle) before any code change? +5. Is the 13.4 µm/V response stable across days? Only 2026-09-18 was analysed; + the rotated logs from 2026-09-11 to 2026-09-17 could be checked. +6. Should the already-made 1.2 V threshold change be kept, reverted, or left + until this is settled? It is currently uncommitted in the production working + tree. -- 2.54.0 From 26df688512aba674edd3681726da944e127b72c0 Mon Sep 17 00:00:00 2001 From: x12sa Date: Mon, 21 Sep 2026 13:44:06 +0200 Subject: [PATCH 64/78] fix(flomni): motion stop button stops all three Galil controllers The GUI "Motion Stop" button did not stop the stages when pressed during a sample transfer: - stop_all_axes was not in the Galil controllers' USER_ACCESS, so the foptx stop over RPC could never work (only logged in the GUI server). - fsamroy's controller (port 8084) was never stopped. - an unresolvable device in _stop_device_controller returned silently. The widget now takes hard_stop_device_names (flomni: ftransy, foptx, fsamroy; transfer controller first) and sends stop_all_axes() to each controller from its own thread, so a hung RPC cannot delay the others. Devices are resolved freshly at click time with retries, every outcome is logged and shown in the widget's message label, and positioning mode is restored on the first controller only after all stops were sent. If stop_all_axes is not yet exposed by the device server, the raw XQ#STOP,1 is sent via socket_put_and_receive. The legacy hard_stop_device_name/extra_hard_stop_device_name kwargs still work. Not yet tested on hardware. Co-Authored-By: Claude Sonnet 5 --- .../FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md | 13 +- .../plugins/flomni/gui_tools.py | 18 +- .../console_buttons/console_buttons.py | 242 +++++++++++++----- csaxs_bec/devices/omny/galil/fgalil_ophyd.py | 1 + csaxs_bec/devices/omny/galil/galil_ophyd.py | 1 + .../test_console_buttons_hard_stop.py | 214 ++++++++++++++++ 6 files changed, 402 insertions(+), 87 deletions(-) create mode 100644 tests/tests_bec_widgets/test_console_buttons_hard_stop.py diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md index 17c91123..2ba01a01 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md @@ -82,12 +82,13 @@ implementation plan — nothing here has been started. ## 5. No hard-stop button wired into lamni's GUI -- flomni: `flomnigui_show_cameras()` (`flomni/gui_tools.py:143-150`) wires up - a `ConsoleButtonsWidget` (`hard_stop_device_name="ftransy"`, - `extra_hard_stop_device_name="foptx"`) calling the shared, generic - `GalilController.hard_abort_and_restore_positioning_mode()` — added - specifically to replace an older blind stop-all-devices broadcast that - could crash the scan worker thread. +- flomni: `flomnigui_show_cameras()` (`flomni/gui_tools.py`) wires up + a `ConsoleButtonsWidget` (`hard_stop_device_names=["ftransy", "foptx", + "fsamroy"]`, one device per Galil controller, transfer controller first) + that sends `stop_all_axes()` to each controller and then calls the shared, + generic `GalilController.hard_abort_and_restore_positioning_mode()` on the + first — added specifically to replace an older blind stop-all-devices + broadcast that could crash the scan worker thread. - lamni: `LamNI/gui_tools.py` has no `ConsoleButtonsWidget`/hard-stop wiring at all. - The original commit message explicitly notes this was "scoped to flomni diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py index 0a933ec9..8acd261a 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py @@ -138,20 +138,18 @@ class flomniGuiTools: print("Cannot open camera_overview. Device does not exist.") # Confirm/abort console, docked below the cameras. The hard-stop - # button directly calls dev.ftransy.controller - # .hard_abort_and_restore_positioning_mode() (the same Galil - # hard-stop ftransfer_abort() uses) -- see ConsoleButtonsWidget's - # docstring for why this replaced a blind stop-all-devices - # broadcast. foptx sits on a physically separate Galil - # controller (the optics stage board, not the transfer stage - # board) so it needs its own stop_all_axes() call -- also - # explained in ConsoleButtonsWidget's docstring. + # button sends a stop to each of flomni's three Galil controllers + # (one representative device per controller, in this order, the + # sample-transfer controller first): ftransy (fsamx, fsamy, + # ftransx/y/z, ftray), foptx (optics, eye, heater, tracking) and + # fsamroy (rotation). Afterwards it restores positioning mode on + # the first one (the same Galil hard-stop ftransfer_abort() uses) + # -- see ConsoleButtonsWidget's docstring. self.console = self.gui.flomni.new( "z_ConsoleButtonsWidget", object_name="console", where="bottom", - hard_stop_device_name="ftransy", - extra_hard_stop_device_name="foptx", + hard_stop_device_names=["ftransy", "foptx", "fsamroy"], hard_stop_label="Flomni Motion Stop", ) # set_layout_ratios uses relative weights, not pixels -- there is diff --git a/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py b/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py index c109557f..cd740471 100644 --- a/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py +++ b/csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py @@ -2,6 +2,8 @@ from __future__ import annotations import os import signal +import threading +import time from bec_lib import bec_logger from bec_widgets import BECWidget, SafeProperty, SafeSlot @@ -26,11 +28,13 @@ class z_ConsoleButtonsWidget(BECWidget, QWidget): let an unrelated, un-suppressed device error kill the scan worker thread and require a full BEC restart), then sends a real SIGINT to the BEC IPython client process (the parent of the GUI server - process), then directly calls ``hard_stop_device_name``'s - ``.controller.hard_abort_and_restore_positioning_mode()`` -- the same - Galil hard-stop (XQ#STOP,1) the CLI's ``ftransfer_abort()`` uses -- - and, if configured, ``extra_hard_stop_device_name``'s - ``.controller.stop_all_axes()``. + process), then sends a stop (``controller.stop_all_axes()``, i.e. + ``XQ#STOP,1``) to the Galil controller of EVERY device listed in + ``hard_stop_device_names`` -- one device per physical controller, in + that order (put the sample-transfer controller first) -- and finally + restores positioning mode on the FIRST listed controller via + ``.controller.hard_abort_and_restore_positioning_mode()`` (the same + Galil hard-stop the CLI's ``ftransfer_abort()`` uses). SIGINT is sent BEFORE the hard stop, not after: if a client-side loop is actively polling this same transfer (e.g. ftransfer_get_sample's @@ -41,41 +45,38 @@ class z_ConsoleButtonsWidget(BECWidget, QWidget): ``ensure_gripper_up()``, which must not run mid-transfer. Sending SIGINT first (an os.kill() call, effectively instant) gives the target process's own ``except KeyboardInterrupt: ftransfer_abort(); - raise FlomniError(...)`` handler a head start over the hard stop's - own multi-step sequence (which itself takes hundreds of ms due to - its internal polling/settling waits) -- without adding any - artificial delay of its own, so the actual motion stop is still - effectively immediate. + raise FlomniError(...)`` handler a head start over the hard stop. - ``hard_stop_device_name`` names the device whose ``.controller`` the - hard stop is called on (e.g. ``"ftransy"`` for flomni). Without a device - *name* configured, the button is disabled rather than silently doing - nothing. A configured name that doesn't actually resolve to a device - (e.g. missing from the current session's config) is not checked ahead - of time -- it's re-resolved at click time in ``_on_abort()``, which - already logs and no-ops on failure -- so the button being enabled is - not a guarantee the device exists, only that a target was configured. + ``hard_stop_device_names`` is a list of device names, one per Galil + controller (flomni: ``["ftransy", "foptx", "fsamroy"]`` -- the transfer + stages' controller, the optics controller and the rotation controller). + The legacy kwargs ``hard_stop_device_name`` / ``extra_hard_stop_device_name`` + are still accepted and simply form the list ``[first, extra]``. With no + names configured, the button is disabled rather than silently doing + nothing. Names are NOT checked when the widget is built (that live check + proved unreliable, see git history), but they are resolved freshly from + the GUI client's device manager at click time, with a few retries, and + every outcome -- including "device could not be resolved" -- is logged + and shown in the widget's message label, so a controller that was not + stopped is never silent. - ``extra_hard_stop_device_name`` optionally names a second device on a - physically separate Galil controller (e.g. ``"foptx"`` for flomni's - optics stage, a different board/socket port from the transfer stage - ``ftransy`` sits on -- one abort press only halts axes on ``ftransy``'s - controller otherwise). Its ``.controller.stop_all_axes()`` is called - instead of ``hard_abort_and_restore_positioning_mode()``: the latter's - ``#POSMODE``/``mntmod`` handling is specific to the sample-transfer/ - mount program that only runs on the transfer controller, so - ``stop_all_axes()`` (a plain ``XQ#STOP,1``) is the correct generic - "halt everything on this controller" primitive for any other board. - Entirely optional -- unset means only ``hard_stop_device_name`` is - stopped, as before. + The stops are emitted from one thread per controller, started in the + configured order, so a hung RPC to one controller cannot delay the + others. If the client-side proxy does not (yet) expose + ``stop_all_axes`` (the device server publishes exposed methods when it + starts), the raw command ``XQ#STOP,1`` is sent through the always + exposed ``socket_put_and_receive`` instead. """ USER_ACCESS = ["message", "message.setter", "response", "clear_response"] PLUGIN = True def __init__(self, parent=None, **kwargs): - self._hard_stop_device_name = kwargs.pop("hard_stop_device_name", None) - self._extra_hard_stop_device_name = kwargs.pop("extra_hard_stop_device_name", None) + self._hard_stop_device_names = self._collect_hard_stop_names( + kwargs.pop("hard_stop_device_names", None), + kwargs.pop("hard_stop_device_name", None), + kwargs.pop("extra_hard_stop_device_name", None), + ) self._hard_stop_label = kwargs.pop("hard_stop_label", "Motion Stop") super().__init__(parent=parent, **kwargs) self._response = "" @@ -87,22 +88,39 @@ class z_ConsoleButtonsWidget(BECWidget, QWidget): self._client_pid = os.getppid() self._init_ui() - def _hard_stop_available(self) -> bool: - """True if a hard_stop_device_name was configured for this widget. + # A fresh lookup at click time is retried a few times: a lookup can fail + # for a moment while the GUI client's device manager is being (re)filled. + _RESOLVE_ATTEMPTS = 3 + _RESOLVE_DELAY_S = 0.05 + # Upper bound for waiting on the per-controller stop threads. + _STOP_JOIN_TIMEOUT_S = 10.0 - Deliberately does not check whether the name actually resolves to a - live, enabled device: that live check (a getattr(self.dev, name) - device-manager lookup) proved unreliable in practice -- a - confirmed-present, confirmed-enabled device could still evaluate as - unavailable here, permanently disabling the button for the widget's - lifetime with no way to recover short of recreating the dock. Since - whatever device is named is re-resolved with its own try/except in - _on_abort() anyway, a stale or wrong name still fails safely - (logged, no-op) instead of silently doing nothing -- so skipping the - redundant, unreliable pre-check here only removes a false-negative - failure mode, not real safety. + @staticmethod + def _collect_hard_stop_names(names, first=None, extra=None) -> list[str]: + """Ordered, de-duplicated list of the devices whose controllers get a + stop. ``names`` (the ``hard_stop_device_names`` kwarg) wins; without + it the legacy pair ``[first, extra]`` is used.""" + if isinstance(names, str): + names = [names] + candidates = list(names) if names else [first, extra] + result: list[str] = [] + for name in candidates: + if name and name not in result: + result.append(name) + return result + + def _hard_stop_available(self) -> bool: + """True if at least one hard-stop device name was configured. + + Deliberately does not check whether the names actually resolve to + live devices: that live check (a getattr(self.dev, name) + device-manager lookup at construction time) proved unreliable in + practice -- a confirmed-present, confirmed-enabled device could still + evaluate as unavailable, permanently disabling the button for the + widget's lifetime. Names are resolved at click time instead, and an + unresolvable name is reported loudly (log + message label). """ - return bool(self._hard_stop_device_name) + return bool(self._hard_stop_device_names) def _init_ui(self): layout = QVBoxLayout(self) @@ -170,6 +188,9 @@ class z_ConsoleButtonsWidget(BECWidget, QWidget): @SafeSlot() def _on_abort(self): + if not self._hard_stop_device_names: + return # button is disabled in this case; defensive only + # 1) Coordinated abort first: a safe no-op if nothing is queued: when # something IS queue-tracked, this registers a real stop_id so the # device server suppresses the resulting error instead of killing @@ -190,34 +211,113 @@ class z_ConsoleButtonsWidget(BECWidget, QWidget): logger.warning(f"z_ConsoleButtonsWidget: sending SIGINT to client pid {self._client_pid}") os.kill(self._client_pid, signal.SIGINT) - # 3) Hard motion stop(s) -- not delayed by anything above: sending - # SIGINT is a near-instant os.kill() call, so these still run - # effectively immediately. The two devices are independent - # controllers (see class docstring): each is attempted and - # logged on its own, so a failure on one doesn't skip the other. - if self._hard_stop_device_name: - self._stop_device_controller( - self._hard_stop_device_name, "hard_abort_and_restore_positioning_mode" - ) - if self._extra_hard_stop_device_name: - self._stop_device_controller(self._extra_hard_stop_device_name, "stop_all_axes") + # 3) Stop every Galil controller, in the configured order (transfer + # controller first), then 4) restore positioning mode on the first + # one -- last, because it takes >= 0.5 s and must not delay the + # other stops. Nothing here raises: every failure is logged and + # shown in the message label. + stop_results = self._stop_all_controllers() + parts = [ + f"{name}: OK" if ok else f"{name}: NOT STOPPED ({detail})" + for name, ok, detail in stop_results + ] + text = "Motion stop sent -> " + " | ".join(parts) + if not all(ok for _, ok, _ in stop_results): + text += "\nCHECK THE STAGES MANUALLY!" + self.message_label.setText(text) - def _stop_device_controller(self, device_name: str, method_name: str) -> None: - """Resolve device_name and call method_name() on its .controller, - logging and swallowing any failure -- so a missing device or a - failed stop on one controller can't prevent an already-issued stop - on another, or crash the rest of _on_abort().""" + restore_ok, restore_detail = self._restore_positioning_mode(self._hard_stop_device_names[0]) + self.message_label.setText( + text + f"\nPositioning mode: {'restored' if restore_ok else 'NOT restored'}" + + ("" if restore_ok else f" ({restore_detail})") + ) + + def _resolve_controller(self, device_name: str): + """Fresh lookup of ``device_name`` in the GUI client's device manager + (not the ``self.dev`` reference cached at widget construction), with a + few retries. Returns the device's ``.controller`` RPC proxy or raises + RuntimeError with the reason.""" + reason = "unknown" + for attempt in range(self._RESOLVE_ATTEMPTS): + try: + device = self.client.device_manager.devices.get(device_name) + if device is not None: + return device.controller + reason = "device not found in the GUI client's device manager" + except Exception as exc: # DeviceConfigError etc. are not AttributeErrors + reason = f"{type(exc).__name__}: {exc}" + if attempt < self._RESOLVE_ATTEMPTS - 1: + time.sleep(self._RESOLVE_DELAY_S) + raise RuntimeError(reason) + + def _stop_controller(self, device_name: str) -> tuple[bool, str]: + """Send a stop to the Galil controller behind ``device_name``. + Returns (sent, detail); never raises.""" try: - device = getattr(self.dev, device_name, None) - except Exception: - device = None - if device is None: - return - logger.warning(f"z_ConsoleButtonsWidget: hard-stopping {device_name} ({method_name})") + controller = self._resolve_controller(device_name) + except Exception as exc: + logger.error(f"z_ConsoleButtonsWidget: cannot resolve {device_name}: {exc}") + return False, f"not resolved: {exc}" + logger.warning(f"z_ConsoleButtonsWidget: stopping all axes on {device_name}'s controller") try: - getattr(device.controller, method_name)() - except Exception: - logger.exception(f"z_ConsoleButtonsWidget: hard motion stop of {device_name} failed") + stop_all_axes = getattr(controller, "stop_all_axes", None) + if not callable(stop_all_axes): + raise AttributeError("stop_all_axes is not exposed by the device server yet") + reply = stop_all_axes() + logger.warning(f"z_ConsoleButtonsWidget: {device_name} stop_all_axes -> {reply!r}") + return True, str(reply) + except Exception as exc: + logger.warning( + f"z_ConsoleButtonsWidget: stop_all_axes on {device_name} failed ({exc!r}); " + "falling back to raw XQ#STOP,1" + ) + try: + reply = controller.socket_put_and_receive("XQ#STOP,1") + logger.warning(f"z_ConsoleButtonsWidget: {device_name} raw XQ#STOP,1 -> {reply!r}") + return True, str(reply) + except Exception as exc: + logger.exception(f"z_ConsoleButtonsWidget: stopping {device_name} failed") + return False, f"stop failed: {exc}" + + def _stop_all_controllers(self) -> list[tuple[str, bool, str]]: + """Stop the controllers of all configured devices. One thread per + controller, started in the configured order, so a hung RPC to one + controller cannot delay the others. Returns (name, sent, detail) in + the configured order.""" + results: dict[str, tuple[bool, str]] = {} + + def worker(name: str) -> None: + results[name] = self._stop_controller(name) + + threads = [] + for name in self._hard_stop_device_names: + thread = threading.Thread(target=worker, args=(name,), daemon=True) + thread.start() + threads.append(thread) + deadline = time.monotonic() + self._STOP_JOIN_TIMEOUT_S + for thread in threads: + thread.join(max(0.0, deadline - time.monotonic())) + out = [] + for name in self._hard_stop_device_names: + ok, detail = results.get(name, (False, f"no answer within {self._STOP_JOIN_TIMEOUT_S} s")) + out.append((name, ok, detail)) + return out + + def _restore_positioning_mode(self, device_name: str) -> tuple[bool, str]: + """Wait for the transfer routine to halt and switch the controller + behind ``device_name`` back to positioning mode. Never raises.""" + try: + controller = self._resolve_controller(device_name) + logger.warning( + f"z_ConsoleButtonsWidget: restoring positioning mode on {device_name}'s controller" + ) + controller.hard_abort_and_restore_positioning_mode() + return True, "" + except Exception as exc: + logger.exception( + f"z_ConsoleButtonsWidget: restoring positioning mode on {device_name} failed" + ) + return False, str(exc) @SafeProperty(str) def message(self): diff --git a/csaxs_bec/devices/omny/galil/fgalil_ophyd.py b/csaxs_bec/devices/omny/galil/fgalil_ophyd.py index da9e8b5a..e955ae71 100644 --- a/csaxs_bec/devices/omny/galil/fgalil_ophyd.py +++ b/csaxs_bec/devices/omny/galil/fgalil_ophyd.py @@ -42,6 +42,7 @@ class FlomniGalilController(GalilController): "lights_on", "print_command_history", "hard_abort_and_restore_positioning_mode", + "stop_all_axes", ] def is_axis_moving(self, axis_Id, axis_Id_numeric) -> bool: diff --git a/csaxs_bec/devices/omny/galil/galil_ophyd.py b/csaxs_bec/devices/omny/galil/galil_ophyd.py index ea1ddc7a..ea71c7f3 100644 --- a/csaxs_bec/devices/omny/galil/galil_ophyd.py +++ b/csaxs_bec/devices/omny/galil/galil_ophyd.py @@ -47,6 +47,7 @@ class GalilController(Controller): "all_axes_referenced", "print_command_history", "hard_abort_and_restore_positioning_mode", + "stop_all_axes", ] OKBLUE = "\033[94m" diff --git a/tests/tests_bec_widgets/test_console_buttons_hard_stop.py b/tests/tests_bec_widgets/test_console_buttons_hard_stop.py new file mode 100644 index 00000000..16cd74e3 --- /dev/null +++ b/tests/tests_bec_widgets/test_console_buttons_hard_stop.py @@ -0,0 +1,214 @@ +"""Tests for the hard-stop logic of z_ConsoleButtonsWidget: every configured +Galil controller gets a stop (one thread each, configured order in the +result), a device that can't be resolved or a controller that fails is +reported instead of silently skipped, and one failure never prevents the +other stops. + +The stop helpers are plain methods (no Qt slots), so they are exercised on a +minimal stub instead of constructing the full widget, which needs a live BEC +client context via get_bec_shortcuts() -- the same approach as +test_x_ray_eye_vertical_lock.py. No hardware or BEC service is contacted. +""" + +import threading +from unittest import mock + +import pytest + +from csaxs_bec.bec_widgets.widgets.console_buttons.console_buttons import z_ConsoleButtonsWidget + +Widget = z_ConsoleButtonsWidget + + +class _StubConsole: + """Minimal stand-in for z_ConsoleButtonsWidget, isolated to the + attributes the hard-stop helpers touch.""" + + _RESOLVE_ATTEMPTS = 3 + _RESOLVE_DELAY_S = 0.0 + _STOP_JOIN_TIMEOUT_S = 5.0 + + _resolve_controller = Widget._resolve_controller + _stop_controller = Widget._stop_controller + _stop_all_controllers = Widget._stop_all_controllers + _restore_positioning_mode = Widget._restore_positioning_mode + + +def _make_stub(devices: dict, names: list[str]): + stub = _StubConsole() + stub._hard_stop_device_names = names + stub.client = mock.Mock() + stub.client.device_manager.devices = devices + return stub + + +def _device(controller): + return mock.Mock(controller=controller) + + +def _controller(stop_reply=":"): + ctrl = mock.Mock() + ctrl.stop_all_axes.return_value = stop_reply + ctrl.socket_put_and_receive.return_value = ":" + return ctrl + + +# ---------------------------------------------------------------- name list + + +def test_collect_names_prefers_new_list_and_drops_duplicates(): + assert Widget._collect_hard_stop_names(["a", "b", "a", None, ""], "x", "y") == ["a", "b"] + + +def test_collect_names_falls_back_to_legacy_pair(): + assert Widget._collect_hard_stop_names(None, "ftransy", "foptx") == ["ftransy", "foptx"] + assert Widget._collect_hard_stop_names(None, "ftransy", None) == ["ftransy"] + assert Widget._collect_hard_stop_names(None, None, None) == [] + + +def test_collect_names_accepts_a_single_string(): + assert Widget._collect_hard_stop_names("ftransy") == ["ftransy"] + + +# ---------------------------------------------------------------- resolving + + +def test_resolve_returns_controller(): + ctrl = _controller() + stub = _make_stub({"ftransy": _device(ctrl)}, ["ftransy"]) + assert stub._resolve_controller("ftransy") is ctrl + + +def test_resolve_reports_missing_device_after_retries(): + devices = mock.MagicMock() + devices.get.return_value = None + stub = _make_stub(devices, ["ftransy"]) + + with pytest.raises(RuntimeError, match="not found"): + stub._resolve_controller("ftransy") + assert devices.get.call_count == _StubConsole._RESOLVE_ATTEMPTS + + +def test_resolve_retries_after_transient_lookup_error(): + ctrl = _controller() + devices = mock.MagicMock() + devices.get.side_effect = [Exception("container being reloaded"), _device(ctrl)] + stub = _make_stub(devices, ["ftransy"]) + + assert stub._resolve_controller("ftransy") is ctrl + + +# ---------------------------------------------------------------- one stop + + +def test_stop_controller_uses_stop_all_axes(): + ctrl = _controller() + stub = _make_stub({"ftransy": _device(ctrl)}, ["ftransy"]) + + assert stub._stop_controller("ftransy") == (True, ":") + ctrl.stop_all_axes.assert_called_once_with() + ctrl.socket_put_and_receive.assert_not_called() + + +def test_stop_controller_falls_back_to_raw_command_when_not_exposed(): + ctrl = mock.Mock(spec=["socket_put_and_receive"]) # no stop_all_axes attribute + ctrl.socket_put_and_receive.return_value = ":" + stub = _make_stub({"ftransy": _device(ctrl)}, ["ftransy"]) + + assert stub._stop_controller("ftransy") == (True, ":") + ctrl.socket_put_and_receive.assert_called_once_with("XQ#STOP,1") + + +def test_stop_controller_falls_back_to_raw_command_when_rpc_call_fails(): + ctrl = _controller() + ctrl.stop_all_axes.side_effect = TimeoutError("rpc timeout") + stub = _make_stub({"ftransy": _device(ctrl)}, ["ftransy"]) + + assert stub._stop_controller("ftransy") == (True, ":") + ctrl.socket_put_and_receive.assert_called_once_with("XQ#STOP,1") + + +def test_stop_controller_reports_unresolved_device(): + devices = mock.MagicMock() + devices.get.return_value = None + stub = _make_stub(devices, ["fsamroy"]) + + ok, detail = stub._stop_controller("fsamroy") + assert not ok + assert "not resolved" in detail + + +def test_stop_controller_reports_total_failure(): + ctrl = _controller() + ctrl.stop_all_axes.side_effect = RuntimeError("boom") + ctrl.socket_put_and_receive.side_effect = RuntimeError("socket dead") + stub = _make_stub({"ftransy": _device(ctrl)}, ["ftransy"]) + + ok, detail = stub._stop_controller("ftransy") + assert not ok + assert "socket dead" in detail + + +# ---------------------------------------------------------------- all stops + + +def test_stop_all_controllers_stops_each_and_keeps_configured_order(): + ctrls = {name: _controller() for name in ("ftransy", "foptx", "fsamroy")} + devices = {name: _device(ctrl) for name, ctrl in ctrls.items()} + stub = _make_stub(devices, ["ftransy", "foptx", "fsamroy"]) + + results = stub._stop_all_controllers() + + assert [name for name, _, _ in results] == ["ftransy", "foptx", "fsamroy"] + assert all(ok for _, ok, _ in results) + for ctrl in ctrls.values(): + ctrl.stop_all_axes.assert_called_once_with() + + +def test_one_unresolved_device_does_not_prevent_the_other_stops(): + ctrls = {name: _controller() for name in ("ftransy", "fsamroy")} + devices = mock.MagicMock() + devices.get.side_effect = lambda name: _device(ctrls[name]) if name in ctrls else None + stub = _make_stub(devices, ["ftransy", "foptx", "fsamroy"]) + + results = {name: ok for name, ok, _ in stub._stop_all_controllers()} + + assert results == {"ftransy": True, "foptx": False, "fsamroy": True} + ctrls["ftransy"].stop_all_axes.assert_called_once_with() + ctrls["fsamroy"].stop_all_axes.assert_called_once_with() + + +def test_hung_controller_does_not_delay_the_others(): + release = threading.Event() + hung = _controller() + hung.stop_all_axes.side_effect = lambda: release.wait(5) and ":" + fast = {name: _controller() for name in ("foptx", "fsamroy")} + devices = {"ftransy": _device(hung), **{n: _device(c) for n, c in fast.items()}} + stub = _make_stub(devices, ["ftransy", "foptx", "fsamroy"]) + stub._STOP_JOIN_TIMEOUT_S = 0.5 + + try: + results = {name: (ok, detail) for name, ok, detail in stub._stop_all_controllers()} + finally: + release.set() + + assert results["foptx"][0] and results["fsamroy"][0] + assert not results["ftransy"][0] + assert "no answer" in results["ftransy"][1] + for ctrl in fast.values(): + ctrl.stop_all_axes.assert_called_once_with() + + +# ---------------------------------------------------------------- restore + + +def test_restore_positioning_mode_success_and_failure(): + ctrl = _controller() + stub = _make_stub({"ftransy": _device(ctrl)}, ["ftransy"]) + assert stub._restore_positioning_mode("ftransy") == (True, "") + ctrl.hard_abort_and_restore_positioning_mode.assert_called_once_with() + + ctrl.hard_abort_and_restore_positioning_mode.side_effect = RuntimeError("still in mount mode") + ok, detail = stub._restore_positioning_mode("ftransy") + assert not ok + assert "still in mount mode" in detail -- 2.54.0 From 5b688df428f6b1b11ffe35514e30697da59998bb Mon Sep 17 00:00:00 2001 From: x12sa Date: Mon, 21 Sep 2026 14:55:20 +0200 Subject: [PATCH 65/78] fix(flomni): always define tomo_alignment_fit The global var was deleted on stage-in / new alignment and never created at startup, so manual tweaks like fit[0][2] += 5.0 failed with an undefined variable. reset_tomo_alignment_fit() now writes an all-zero 2x5 fit instead of deleting, and Flomni.__init__ creates it if missing (an existing fit is kept so it survives client restarts). Co-Authored-By: Claude Sonnet 5 --- .../bec_ipython_client/plugins/flomni/flomni.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 5a105b75..f8cda11a 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -1406,7 +1406,21 @@ class FlomniAlignmentMixin: logger.warning("Not applying any correction.") def reset_tomo_alignment_fit(self): - self.client.delete_global_var("tomo_alignment_fit") + """Reset the alignment fit to all zeros (no offset applied). + + Writes zeros rather than deleting the global var, so that + "tomo_alignment_fit" is always defined and can be tweaked by hand + (see the layout comment above get_alignment_offset). + """ + self.client.set_global_var("tomo_alignment_fit", np.zeros((2, 5)).tolist()) + + def ensure_tomo_alignment_fit(self): + """Define "tomo_alignment_fit" (all zeros) if it does not exist yet. + + An existing fit is left untouched so it survives a client restart. + """ + if self.client.get_global_var("tomo_alignment_fit") is None: + self.reset_tomo_alignment_fit() def read_alignment_offset( self, @@ -1810,6 +1824,7 @@ class Flomni( self.label_printer = PTouchLabelPrinter(host="BRN94DDF8AAB8EC.psi.ch", template_number=1) self.set_client(client) + self.ensure_tomo_alignment_fit() self._maybe_reset_params_on_account_change() def set_web_password(self, password: str) -> None: -- 2.54.0 From e802d551122c47c95ae0d67c1a06d74b5a701e4e Mon Sep 17 00:00:00 2001 From: x12sa Date: Mon, 21 Sep 2026 15:03:44 +0200 Subject: [PATCH 66/78] docs(omny): TODO to port always-defined tomo_alignment_fit from flomni Co-Authored-By: Claude Sonnet 5 --- .../TODO_tomo_alignment_fit_always_defined.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_alignment_fit_always_defined.md diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_alignment_fit_always_defined.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_alignment_fit_always_defined.md new file mode 100644 index 00000000..bd554c1d --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_alignment_fit_always_defined.md @@ -0,0 +1,58 @@ +# TODO: port "always define `tomo_alignment_fit`" to OMNY now that it's landed on Flomni + +**Status: implemented on Flomni** (commit `5b688df`, branch +`fixes/flomni_beamtime_2`), not yet ported to OMNY. + +## Problem (as seen on Flomni) + +The `tomo_alignment_fit` global var was deleted by `reset_tomo_alignment_fit()` +(on stage-in and at the start of a new alignment) and never created at +startup. Manual offset tweaks from the client failed with an undefined +variable: + +```python +fit = flomni.client.get_global_var("tomo_alignment_fit") # -> None +fit[0][2] += 5.0 # x offset, microns -> TypeError +``` + +## What Flomni does now (`flomni/flomni.py`) + +- `reset_tomo_alignment_fit()` writes `np.zeros((2, 5)).tolist()` instead of + calling `delete_global_var`, so the var is always defined. +- New `ensure_tomo_alignment_fit()` sets the zero fit **only if** + `get_global_var("tomo_alignment_fit") is None`. It is called from + `Flomni.__init__`, so an existing fit survives a client restart. +- `get_alignment_offset()` is unchanged; with zeros it prints zero offsets + instead of the "Not applying any alignment offsets" message. Same result. + +Fit layout (row 0 = x, row 1 = y): + +``` +# x amp, phase, offset, y amp, phase, offset, 3rd order amp, 3rd order phase +# 0 0 0 1 0 2 1 0 1 1 1 2 1 3 1 4 +``` + +## What to do on OMNY + +`omny/omny_alignment_mixin.py` still has the old behaviour: + +- `reset_tomo_alignment_fit()` (~line 57) does + `self.client.delete_global_var("tomo_alignment_fit")`. +- Nothing defines the var at startup. `OMNY.__init__` is in `omny/omny.py` + (~line 557). + +Steps: + +1. Change `reset_tomo_alignment_fit()` to write `np.zeros((2, 5)).tolist()`. +2. Add `ensure_tomo_alignment_fit()` exactly as on Flomni. +3. Call it from `OMNY.__init__` (after `self.client` is set). +4. Check the callers: at the time of writing, nothing in `omny/` calls + `reset_tomo_alignment_fit()` apart from its definition, so decide where + OMNY should reset the fit (start of an alignment / sample transfer), + mirroring Flomni's `ftransfer_flomni_stage_in()` and + `XrayEyeAlign._align_impl()`. +5. `read_alignment_offset()` in the OMNY mixin builds the fit as `np.zeros((2, 5))` + and stores it with `set_global_var`, so it needs no change. + +LamNI does not reference `tomo_alignment_fit` at all (checked by grep), so no +action there. -- 2.54.0 From 0ebe55f31ca1d12556777dd7e147aebf678c66d6 Mon Sep 17 00:00:00 2001 From: x12sa Date: Mon, 21 Sep 2026 15:43:56 +0200 Subject: [PATCH 67/78] docs(flomni): plan for a Skip button on the xrayeye alignment widget Idea only, not implemented. Includes the 4-point DAP sine-fit stability study and the decision to send submit=-1 as the skip signal. Co-Authored-By: Claude Sonnet 5 --- docs/plans/flomni-xrayeye-skip-button.md | 113 +++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/plans/flomni-xrayeye-skip-button.md diff --git a/docs/plans/flomni-xrayeye-skip-button.md b/docs/plans/flomni-xrayeye-skip-button.md new file mode 100644 index 00000000..c18f0d5d --- /dev/null +++ b/docs/plans/flomni-xrayeye-skip-button.md @@ -0,0 +1,113 @@ +# Plan: "Skip" button on the X-ray-eye alignment widget (flomni: leave out one angle, e.g. 90 deg) + +Status: **not implemented (idea)** — written 2026-09-21 after a read-only stability study of the +DAP sine fit with 4 instead of 5 angles. No code was changed. + +## Context + +Flat samples are edge-on at 90 deg, so their centre cannot be submitted there. The flomni X-ray-eye +alignment (`XrayEyeAlign._align_impl` in +`csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py`) submits 5 angles +0/45/90/135/180. The GUI fit tab (`OMNY_XRayEye` in +`csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py`) fits +`A*sin(f*x + shift) + intercept` through the DAP server (`LmfitService1D`, model +`SineModel + LinearModel`, `f` fixed to 1 deg, start values `SINE_FIT_DAP_PARAMETERS`), i.e. 3 free +parameters. With 4 points there is 1 degree of freedom (today 2). + +Wanted: a **Skip** button on the widget. One position may be skipped as long as at least 4 values +remain for the fit; otherwise error. + +## Stability study (does the fit still work with 4 points?) + +**Yes.** Method: (1) linear noise propagation for several angle sets, (2) Monte Carlo through the +real `LmfitService1D` (`configure(data_x=, data_y=, parameters=SINE_FIT_DAP_PARAMETERS, +oversample=5)` then `process()`, in `bec_venv`) with the widget's exact start values, compared with +the global least-squares optimum. Truth `A*sin(rad(angle)+phase)+C` plus Gaussian noise. There is no +real alignment data on disk, so the click noise sigma is unknown (pixel = 0.44 um, hand-placed ROI +centre, probably 1-3 um); results are in units of sigma. + +- ~49000 fits, **0 failed fits**. +- RMS error of the fitted correction curve over 0-180 / 0-360 deg (units of sigma): + + | angles used | cond. number | 0-180 | 0-360 | + |---|---|---|---| + | 0,45,90,135,180 (today) | 3.11 | 0.71 | 1.12 | + | skip 90: 0,45,135,180 | 3.23 | 0.88 | 1.29 | + | skip 45 or 135 | 2.87 | 0.82 | 1.17 | + | skip 0 or 180 | 5.36 | 1.02 | 1.91 | + + Monte Carlo at sigma = 1 um agrees (skip 90: median 0.72 sigma vs 0.61 sigma, p95 1.6 vs 1.2). + So skipping 90/45/135 is cheap (+15..25% noise); skipping an end point (0/180) is clearly worse + but still usable. +- Optimizer misses versus the global optimum only occur for a large offset |C| >~ 45 um (start + intercept C = 0 is far away): 15/4000 in that regime for both 4 and 5 points, 0/4000 for + |C| <= 20 um. A property of the start values, not of the point count. Optional hardening: seed + the intercept from the data mean (the static widget parameters cannot do that themselves). +- What is really lost with 4 points is **redundancy**: 1 dof, the residual has one fixed pattern + (0.71, -1, 1, -0.71 for 0,45,135,180), so chi-square / the plot cannot tell which click was bad, + and a mis-click at 0/180 (leverage 0.83) is mostly absorbed into phase/offset. +- Other 4-point layouts (0,60,120,180: 0.80 sigma; 0,30,150,180: 1.17 sigma). Extending beyond + 180 deg is the most robust option if the flat-sample geometry and fsamroy travel allow it: + 0,45,135,225 gives 0.84 / 0.93 sigma (0-180 / 0-360). + +## Decision: what does Skip send to the client script? + +**Reuse the existing `submit` signal with a sentinel: `omny_xray_gui.submit.set(-1)`** +(0 = idle, 1 = submit, -1 = skip). The widget writes no `xval_x_k / yval_y_k / width_*_k` for a skip. + +- The client already polls exactly this one signal (`submit.get() == 1`), so there is no new polling + path and a skip is atomic (no race between "values written" and "flag set"). +- No device change. A new `skip` `Signal` on `OMNYXRayAlignGUI` + (`csaxs_bec/devices/omny/xray_epics_gui.py`) would need a device-server reload; `submit` exists. +- No NaN/stale-data trick. `submit = 1` with NaN in `xval_x_k` would make every consumer + (`alignment_values`, `update_fov`, `roi_pixel_data`) responsible for noticing it, and the stale + values of an earlier run would be read if one forgets. With -1 the client never reads that step. + +Rejected: separate `skip` signal (cleaner, but needs device reload), NaN payload (implicit, +error-prone). + +## Implementation plan + +**Widget** (`x_ray_eye.py`; shared with LamNI and omny, so it must be inert for them): + +- `self.skip_button = QPushButton("Skip", ...)` under the Submit button, **disabled by default**. + Its slot does `self.dev.omny_xray_gui.submit.set(-1)` (no ROI needed, unlike `submit()`). +- New RPC `enable_skip_button(enable: bool)`: add to `USER_ACCESS` next to `enable_submit_button` + and regenerate `csaxs_bec/bec_widgets/widgets/client.py` (auto-generated by `generate_cli.py`). + LamNI/omny never call it, so the button stays disabled there. + +**Client** (`flomni/x_ray_eye_align.py`): + +- Loop: `submit_val = dev.omny_xray_gui.submit.get()`; act on `1` or `-1`; reset to 0 as today. +- `MIN_FIT_POINTS = 4`; track `self.skipped_steps`. A skip is legal only for real fit points (steps + 1-5 **after** `_height_centered`; not k = 0 FZP centre, not the height-centering submission at + k == 1) and only while `5 - n_skipped - 1 >= MIN_FIT_POINTS`. Otherwise do nothing to the run, + `send_message("Cannot skip: at least 4 positions are needed for the fit")` and keep waiting, so + the error is immediate and not after the whole rotation series. Call `enable_skip_button(False)` + once the budget is used and `True` wherever `enable_submit_button(True)` is called for steps 1-5. +- Skip branch: no `alignment_values[k]`, store a NaN / "skipped" row in `roi_pixel_data`, **no + `update_fov(k)`** (would read stale widths), then advance like a submit: for `1 <= k < 5` rotate + to the next angle (`tomo_rotate(k * 45)`, `update_frame`, next step message); at `k == 5` finish + (`hide_crosshair`, `break`) without `update_fov`. +- `write_output()`: build angles/offsets only from non-skipped steps (e.g. `[0,45,135,180]`); + final guard `if len(angles) < MIN_FIT_POINTS: raise`. `submit_fit_array` and the fit tab are + already length-agnostic. Record the skipped angles in the h5 (`alignment_fit` attrs). +- Messages: with 4 points "1 dof: the fit cannot flag a bad click - check the plot"; extra warning + if the skipped angle is 0 or 180 (noisier fit). Step numbering "k/5" stays. + +## Verification + +- Mock/`test_wo_movements=True` run of the client loop with a fake `submit` sequence: (1,1,-1,1,1) + ends with 4 fit points; a second -1 is refused with a message; -1 at k == 0 or at the + height-centering submission is refused; -1 at the last step finishes cleanly. +- `write_output()` gives the 4-point `angles`/`offsets`; the fit tab plots 4 points; DAP parameters + arrive in `omny_xray_gui.fit_params_x`; `flomni.read_alignment_offset(get_data_from_gui=True)` + loads sane `tomo_alignment_fit[0]`. +- Regression: the default 5-submit run is unchanged; LamNI/omny show Skip disabled. +- Hardware: dry run at the beamline with a flat sample, skipping 90 deg. + +## Open questions + +- Real click noise sigma (no saved alignment data on the analysis machine). +- Should skipping 0/180 be forbidden instead of only warned about? +- Is an angle beyond 180 deg possible for flat samples (better 4-point layout)? -- 2.54.0 From 0ad6c5014d7478e61b450669783707c0dd829c4d Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 21 Sep 2026 15:56:10 +0200 Subject: [PATCH 68/78] feat(flomni): Skip button on the xrayeye alignment to leave out one angle Flat samples are edge-on at 90 deg and cannot be centered there. The XRayEye widget gets a Skip button (disabled unless the client enables it, so LamNI/omny are unaffected) that signals omny_xray_gui.submit = -1. XrayEyeAlign._align_impl leaves that step out of the fit: one of the five angles may be skipped, at least 4 are needed. Skips of the FZP center, the height marking, or a second angle are refused with a message and the run continues. write_output() builds angles/offsets from the remaining steps and raises if fewer than 4 are left; the h5 records the skipped angles. Not yet tried on real hardware. Co-Authored-By: Claude Sonnet 5 --- .../plugins/flomni/x_ray_eye_align.py | 164 ++++++++++--- csaxs_bec/bec_widgets/widgets/client.py | 12 + .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 36 +++ docs/plans/flomni-xrayeye-skip-button.md | 11 +- docs/user/ptychography/flomni.md | 2 + .../test_flomni_xray_eye_skip.py | 219 ++++++++++++++++++ .../test_x_ray_eye_skip_button.py | 88 +++++++ 7 files changed, 496 insertions(+), 36 deletions(-) create mode 100644 tests/tests_bec_ipython_client/test_flomni_xray_eye_skip.py create mode 100644 tests/tests_bec_widgets/test_x_ray_eye_skip_button.py diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py index e74598f1..80ee722d 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py @@ -41,6 +41,14 @@ class XrayEyeAlign: HEIGHT_CENTERING_SIGN = -1 + # Fit steps k = 1..N_FIT_STEPS are the angles 0, 45, ..., 180 deg (angle = (k - 1) * + # ANGLE_STEP_DEG). The sine fit has 3 free parameters, so at most + # N_FIT_STEPS - MIN_FIT_POINTS of them may be skipped from the GUI (submit == -1), e.g. + # the 90 deg step for a flat sample that is edge-on there. + N_FIT_STEPS = 5 + ANGLE_STEP_DEG = 45 + MIN_FIT_POINTS = 4 + def __init__(self, client, flomni: Flomni) -> None: self.client = client self.flomni = flomni @@ -95,14 +103,50 @@ class XrayEyeAlign: # image_idx refers to alignment_images[image_idx], i.e. the last # frame captured before that submit (shutter is closed at submit time). self.roi_pixel_data = [] + # Fit steps (k) the user skipped from the GUI; they have no alignment_values entry. + self.skipped_steps = [] - def _save_alignment_data(self, file_path: str, fit_data: np.ndarray | None = None): + def _step_angle(self, k: int) -> int: + """Rotation angle in deg of fit step k (1..N_FIT_STEPS).""" + return (k - 1) * self.ANGLE_STEP_DEG + + def _step_prompt(self, k: int) -> str: + """The instruction shown to the user while waiting for the submission of step k.""" + if k == 0: + return "Submit center value of FZP." + if k == 1 and not self._height_centered: + return "Mark the sample height with the selection tool, then submit." + return f"Step {k}/{self.N_FIT_STEPS}: Submit sample center" + + def _skip_refusal_reason(self, k: int) -> str | None: + """Why step k cannot be skipped, or None if a skip is allowed. + + Only real fit points can be skipped: not the FZP center (k == 0) nor the height-marking + submission (the first submission at k == 1), and only while at least MIN_FIT_POINTS + remain for the fit. + """ + if k == 0: + return "the FZP center cannot be skipped" + if k == 1 and not self._height_centered: + return "the sample height marking cannot be skipped" + if len(self.skipped_steps) + 1 > self.N_FIT_STEPS - self.MIN_FIT_POINTS: + return f"at least {self.MIN_FIT_POINTS} positions are needed for the fit" + return None + + def _save_alignment_data( + self, + file_path: str, + fit_data: np.ndarray | None = None, + skipped_angles: list[int] | None = None, + ): expanded = os.path.expanduser(file_path) os.makedirs(os.path.dirname(expanded), exist_ok=True) with h5py.File(expanded, "w") as f: - f.create_dataset( + ds = f.create_dataset( "alignment_values", data=np.array(list(self.alignment_values.values())) ) + # skipped steps have no entry, so say which step each value belongs to + ds.attrs["step_k"] = list(self.alignment_values.keys()) f.create_dataset("alignment_images", data=np.array(self.alignment_images)) if self.roi_pixel_data: ds = f.create_dataset("roi_pixel_data", data=np.array(self.roi_pixel_data)) @@ -110,6 +154,7 @@ class XrayEyeAlign: if fit_data is not None: ds = f.create_dataset("alignment_fit", data=fit_data) ds.attrs["rows"] = ["angles_deg", "offsets_um", "zeros"] + ds.attrs["skipped_angles_deg"] = list(skipped_angles or []) def update_frame(self, keep_shutter_open=False): if self.flomni._flomnigui_check_attribute_not_exists("xeyegui"): @@ -164,6 +209,10 @@ class XrayEyeAlign: try: self._align_impl(keep_shutter_open) finally: + try: + self.gui.enable_skip_button(False) + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Failed to disable XRayEye skip button: {exc}") try: self.gui.set_dap_params_forwarding(False) except Exception as exc: # pylint: disable=broad-except @@ -186,6 +235,7 @@ class XrayEyeAlign: self.alignment_images = [] self.gui.enable_submit_button(False) + self.gui.enable_skip_button(False) # Initialize xray align device # clear potential pending movement requests @@ -247,32 +297,59 @@ class XrayEyeAlign: k = 0 while True: - if dev.omny_xray_gui.submit.get() == 1: + # submit: 0 = idle, 1 = submitted, -1 = skip this step (see the Skip button) + submit_val = dev.omny_xray_gui.submit.get() + if submit_val == -1: + refusal = self._skip_refusal_reason(k) + if refusal is not None: + # refused: the run is untouched, keep waiting at the same step + dev.omny_xray_gui.submit.set(0) + self.gui.enable_skip_button(False) + self.send_message(f"Cannot skip: {refusal}. {self._step_prompt(k)}") + print(f"Skip refused at step {k}: {refusal}.") + submit_val = 0 + if submit_val in (1, -1): + skipped = submit_val == -1 - self.alignment_values[k] = ( - getattr(dev.omny_xray_gui, f"xval_x_{k}").get() * self.pixel_calibration - ) # in mm - print(f"Clicked position {k}: x {self.alignment_values[k]}") - rtx_position = dev.rtx.readback.get() / 1000 - print(f"Current rtx position {rtx_position}") - self.alignment_values[k] -= rtx_position - print(f"Corrected position {k}: x {self.alignment_values[k]}") - # reset submit channel - dev.omny_xray_gui.submit.set(0) + if skipped: + # no xval_x_k / width_*_k were written for this step, so never read them + dev.omny_xray_gui.submit.set(0) + self.skipped_steps.append(k) + _img_idx = len(self.alignment_images) - 1 + self.roi_pixel_data.append([k, np.nan, np.nan, np.nan, np.nan, _img_idx]) + print(f"Skipped position {k} ({self._step_angle(k)} deg)") + if self._step_angle(k) in (0, 180): + print( + "Warning: skipping an end point (0/180 deg) makes the fit noticeably" + " less stable than skipping 45/90/135 deg." + ) + # only allow another skip if the fit can still spare a point + self.gui.enable_skip_button(self._skip_refusal_reason(k) is None) + else: + self.alignment_values[k] = ( + getattr(dev.omny_xray_gui, f"xval_x_{k}").get() * self.pixel_calibration + ) # in mm + print(f"Clicked position {k}: x {self.alignment_values[k]}") + rtx_position = dev.rtx.readback.get() / 1000 + print(f"Current rtx position {rtx_position}") + self.alignment_values[k] -= rtx_position + print(f"Corrected position {k}: x {self.alignment_values[k]}") + # reset submit channel + dev.omny_xray_gui.submit.set(0) - # Raw pixel position and ROI size at submit time. - # The relevant image is the last captured frame (shutter is - # closed by the time the user clicks submit). - _raw_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get() - _raw_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get() - _raw_w = getattr(dev.omny_xray_gui, f"width_x_{k}").get() - _raw_h = getattr(dev.omny_xray_gui, f"width_y_{k}").get() - _img_idx = len(self.alignment_images) - 1 - print( - f" Submit k={k}: px x={_raw_x:.1f} y={_raw_y:.1f} " - f"w={_raw_w:.1f} h={_raw_h:.1f} img={_img_idx}" - ) - self.roi_pixel_data.append([k, _raw_x, _raw_y, _raw_w, _raw_h, _img_idx]) + # Raw pixel position and ROI size at submit time. + # The relevant image is the last captured frame (shutter is + # closed by the time the user clicks submit). + _raw_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get() + _raw_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get() + _raw_w = getattr(dev.omny_xray_gui, f"width_x_{k}").get() + _raw_h = getattr(dev.omny_xray_gui, f"width_y_{k}").get() + _img_idx = len(self.alignment_images) - 1 + print( + f" Submit k={k}: px x={_raw_x:.1f} y={_raw_y:.1f} " + f"w={_raw_w:.1f} h={_raw_h:.1f} img={_img_idx}" + ) + self.roi_pixel_data.append([k, _raw_x, _raw_y, _raw_w, _raw_h, _img_idx]) # Controls whether `k` advances to the next step below. Left # True except for the height-centering submission, which @@ -348,6 +425,7 @@ class XrayEyeAlign: self.send_message("Step 1/5: Submit sample center") self.gui.enable_submit_button(True) + self.gui.enable_skip_button(True) self.movement_buttons_enabled(True, False) advance_step = False @@ -363,13 +441,16 @@ class XrayEyeAlign: self.send_message(f"Step {k+1}/5: Submit sample center") self.gui.enable_submit_button(True) self.movement_buttons_enabled(True, False) - self.update_fov(k) + if not skipped: + self.update_fov(k) elif k == 5: # received sample center value at samroy 270 and done self.send_message("done...") self.gui.enable_submit_button(False) self.movement_buttons_enabled(False, False) - self.update_fov(k) + self.gui.enable_skip_button(False) + if not skipped: + self.update_fov(k) self.gui.hide_crosshair() break @@ -434,19 +515,34 @@ class XrayEyeAlign: timestamp = time.strftime("%Y%m%d_%H%M%S") file_h5 = f"~/data/raw/logs/xrayeye_alignmentvalues/xrayeye_alignmentvalues_{timestamp}.h5" - fovx_offsets = np.zeros(5) - for k in range(1, 6): + steps = [k for k in range(1, self.N_FIT_STEPS + 1) if k not in self.skipped_steps] + if len(steps) < self.MIN_FIT_POINTS: + raise RuntimeError( + f"Only {len(steps)} alignment positions available, at least" + f" {self.MIN_FIT_POINTS} are needed for the fit." + ) + + fovx_offsets = np.zeros(len(steps)) + for i, k in enumerate(steps): fovx_offset = self.alignment_values[0] - self.alignment_values[k] - fovx_offsets[k - 1] = fovx_offset + fovx_offsets[i] = fovx_offset print(f"Alignment number {k}, value x {fovx_offset}") + skipped_angles = [self._step_angle(k) for k in self.skipped_steps] + if skipped_angles: + print( + f"Skipped angles {skipped_angles} deg, fitting {len(steps)} positions. With 4" + " points the fit has 1 degree of freedom and cannot flag a bad click - check the" + " plot." + ) + data = np.array( [ - [0, 45, 90, 135, 180], # angles + [self._step_angle(k) for k in steps], # angles fovx_offsets * 1000, # fovx_offset values - [0, 0, 0, 0, 0], + np.zeros(len(steps)), ] ) - self._save_alignment_data(file_h5, fit_data=data) + self._save_alignment_data(file_h5, fit_data=data, skipped_angles=skipped_angles) self.gui.submit_fit_array(data) print(f"fit submited with {data}") \ No newline at end of file diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index 37122c73..2d693c6c 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -194,6 +194,18 @@ class OMNY_XRayEye(RPCBase): enable(int): -1 disable else enable """ + @rpc_timeout(20) + @rpc_call + def enable_skip_button(self, enable: "bool"): + """ + Allow/forbid skipping the current alignment angle. + + The button is only clickable while this is True *and* the submit button is enabled, + so it follows the client's "please wait ..." phases automatically. + Args: + enable(bool): True if the client accepts a skip for the current step + """ + @property @rpc_call def sample_name(self): diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index 1263764e..0dedfe59 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -262,6 +262,7 @@ class OMNY_XRayEye(BECWidget, QWidget): "on_live_view_enabled", "on_motors_enable", "enable_submit_button", + "enable_skip_button", "sample_name", "sample_name.setter", "enable_move_buttons", @@ -578,10 +579,18 @@ class OMNY_XRayEye(BECWidget, QWidget): self.step_size.setValue(100) # Submit button self.submit_button = QPushButton("Submit", parent=self) + # Skip button: leaves out the current angle of the alignment (flat samples are + # edge-on at 90 deg). Disabled unless the client script opts in via + # enable_skip_button(), so LamNI/omny never see it active. + self._skip_enabled = False + self.skip_button = QPushButton("Skip", parent=self) + self.skip_button.setEnabled(False) + self.skip_button.setToolTip("Leave out this angle (at least 4 positions are needed)") # Add to layout form step_size_form.addWidget(QLabel("Step Size", parent=self), 0, 0) step_size_form.addWidget(self.step_size, 0, 1) step_size_form.addWidget(self.submit_button, 2, 0, 1, 2) + step_size_form.addWidget(self.skip_button, 3, 0, 1, 2) # Add form to control panel self.control_panel_layout.addLayout(step_size_form) @@ -670,6 +679,7 @@ class OMNY_XRayEye(BECWidget, QWidget): lambda x: self.motor_control_2d.setProperty("step_size", x) ) self.submit_button.clicked.connect(self.submit) + self.skip_button.clicked.connect(self.skip) # ROIPropertyTree's compact_color only styles the line color; line width # still needs to be forced per-ROI here. self.roi_manager.controller.roiAdded.connect(self._style_new_roi) @@ -1289,6 +1299,24 @@ class OMNY_XRayEye(BECWidget, QWidget): self.submit_button.setEnabled(True) else: self.submit_button.setEnabled(False) + self._refresh_skip_button() + + @SafeSlot(bool) + @rpc_timeout(20) + def enable_skip_button(self, enable: bool): + """ + Allow/forbid skipping the current alignment angle. + + The button is only clickable while this is True *and* the submit button is enabled, + so it follows the client's "please wait ..." phases automatically. + Args: + enable(bool): True if the client accepts a skip for the current step + """ + self._skip_enabled = bool(enable) + self._refresh_skip_button() + + def _refresh_skip_button(self): + self.skip_button.setEnabled(self._skip_enabled and self.submit_button.isEnabled()) @SafeSlot(bool) @rpc_timeout(20) @@ -1384,6 +1412,14 @@ class OMNY_XRayEye(BECWidget, QWidget): finally: self.submit_button.blockSignals(False) + @SafeSlot() + def skip(self): + """Skip the current alignment angle: signal the client with submit = -1 (no ROI needed).""" + # Disable right away so a double click cannot skip the following step as well; the + # client re-enables the buttons once it is ready for the next step. + self.skip_button.setEnabled(False) + self.dev.omny_xray_gui.submit.set(-1) + def cleanup(self): """Cleanup connections on widget close -> disconnect slots and stop live mode of camera.""" self._queue_idle_timer.stop() diff --git a/docs/plans/flomni-xrayeye-skip-button.md b/docs/plans/flomni-xrayeye-skip-button.md index c18f0d5d..b6e08a40 100644 --- a/docs/plans/flomni-xrayeye-skip-button.md +++ b/docs/plans/flomni-xrayeye-skip-button.md @@ -1,7 +1,14 @@ # Plan: "Skip" button on the X-ray-eye alignment widget (flomni: leave out one angle, e.g. 90 deg) -Status: **not implemented (idea)** — written 2026-09-21 after a read-only stability study of the -DAP sine fit with 4 instead of 5 angles. No code was changed. +Status: **implemented 2026-09-21** on a BEC test server (tests: `tests_bec_ipython_client/test_flomni_xray_eye_skip.py`, +`tests_bec_widgets/test_x_ray_eye_skip_button.py`); **not yet tried on real hardware** (see Verification). +Written after a read-only stability study of the DAP sine fit with 4 instead of 5 angles. + +Deviations from the plan below: the widget couples the Skip button to the Submit button +(`enable_skip_button(True)` only takes effect while Submit is enabled), so the client calls it +just once per run phase instead of next to every `enable_submit_button`. A refused skip re-sends +the pending step prompt. The h5 `alignment_values` dataset got a `step_k` attr and +`alignment_fit` a `skipped_angles_deg` attr. Skipping 0/180 is warned about, not forbidden. ## Context diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index 1735ae9b..65ebe493 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -56,6 +56,8 @@ If you see your sample already at the approximately correct height: 1. `flomni.xrayeye_alignment_start()` start the coarse alignment of the sample by measuring (clicking in the X-ray eye software) the sample position at its height and then angles of 0, 45, 90, 135, 180 degrees. The GUI will present a fit of this data, which is automatically loaded to BEC for aligning the sample. + **Flat samples** are edge-on at 90 degrees, so their center cannot be measured there: press **Skip** in the GUI instead of Submit to leave that angle out. One angle may be skipped, the fit needs at least 4 of the 5 angles; a second Skip is refused with a message. Skipping 45, 90 or 135 degrees costs little accuracy, skipping 0 or 180 degrees makes the fit noticeably noisier (a warning is printed). The FZP center and the height marking cannot be skipped. With 4 points the fit cannot flag a bad click, so check the plot. The skipped angles are stored in the alignment h5 file. + Otherwise adjust the height manually: 1. `flomni.umvr_fsamy_tracked(0.01)`, attention: unit , move the sample stage relative up (positive) or down (negative) until the sample is approximately vertically centered in xray eye screen diff --git a/tests/tests_bec_ipython_client/test_flomni_xray_eye_skip.py b/tests/tests_bec_ipython_client/test_flomni_xray_eye_skip.py new file mode 100644 index 00000000..dee5ab2f --- /dev/null +++ b/tests/tests_bec_ipython_client/test_flomni_xray_eye_skip.py @@ -0,0 +1,219 @@ +"""Tests for the "Skip" button of the flomni X-ray-eye alignment (see +docs/plans/flomni-xrayeye-skip-button.md): the GUI signals a skip with +``omny_xray_gui.submit == -1`` and XrayEyeAlign._align_impl leaves that angle out of the +fit, as long as at least 4 of the 5 angles (0/45/90/135/180 deg) remain. + +The real _align_impl loop is driven with a scripted sequence of ``submit`` values; the +hardware (dev, umv, flomni, gui) is mocked, no BEC service is contacted. +""" + +from unittest import mock + +import numpy as np +import pytest + +from csaxs_bec.bec_ipython_client.plugins.flomni.x_ray_eye_align import XrayEyeAlign + +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name + +MODULE = "csaxs_bec.bec_ipython_client.plugins.flomni.x_ray_eye_align" + + +class _Signal: + def __init__(self, value=0.0): + self.value = value + + def get(self): + return self.value + + def set(self, value): + self.value = value + return mock.MagicMock() + + +class _ScriptedSubmit(_Signal): + """submit signal: get() plays back the script (one entry per loop iteration), then + fails loudly instead of letting a broken loop spin forever.""" + + def __init__(self, script): + super().__init__(0) + self.script = list(script) + self.sets = [] + + def get(self): + if not self.script: + raise AssertionError("client loop polled submit more often than scripted") + return self.script.pop(0) + + def set(self, value): + self.sets.append(value) + return super().set(value) + + +def _make_dev(submit_script): + """dev mock with a real-number omny_xray_gui: step k was clicked at x = 100 + 10 k px + (pixel_calibration 0.001 mm/px -> 0.1 + 0.01 k mm).""" + gui = mock.MagicMock() + gui.submit = _ScriptedSubmit(submit_script) + gui.mvx = _Signal(0) + gui.mvy = _Signal(0) + for k in range(6): + setattr(gui, f"xval_x_{k}", _Signal(100.0 + 10 * k)) + setattr(gui, f"yval_y_{k}", _Signal(50.0)) + setattr(gui, f"width_x_{k}", _Signal(20.0 + k)) + setattr(gui, f"width_y_{k}", _Signal(30.0 + k)) + dev = mock.MagicMock() + dev.omny_xray_gui = gui + dev.cam_xeye.user_parameter.get.return_value = 0.001 + dev.rtx.readback.get.return_value = 0.0 + return dev + + +def _run(submit_script): + """Run the whole _align_impl loop; returns (align, dev, messages, write_output data).""" + align = XrayEyeAlign(mock.MagicMock(), mock.MagicMock()) + align.test_wo_movements = True + messages = [] + align.send_message = messages.append + align.update_frame = mock.MagicMock() + align._save_alignment_data = mock.MagicMock() + dev = _make_dev(submit_script) + with mock.patch(f"{MODULE}.dev", dev), mock.patch(f"{MODULE}.umv"), mock.patch( + f"{MODULE}.time.sleep" + ): + align._align_impl() + return align, dev, messages + + +def _fit_data(align): + return align.gui.submit_fit_array.call_args.args[0] + + +# FZP center (k=0), height marking (first submit at k=1), then the five angle submissions +# 0/45/90/135/180 deg = steps k=1..5. 0 entries are idle polls. +FZP, HEIGHT = 1, 1 + + +def test_default_run_uses_all_five_angles(): + align, _, _ = _run([FZP, HEIGHT, 1, 1, 1, 1, 1]) + + assert align.skipped_steps == [] + assert list(_fit_data(align)[0]) == [0, 45, 90, 135, 180] + assert len(_fit_data(align)[1]) == 5 + # every non-skipped angle contributed to the field of view + assert align._xray_fov_xy == [25.0, 35.0] + + +def test_skip_90_deg_leaves_four_points(): + align, _, _ = _run([FZP, HEIGHT, 1, 1, -1, 1, 1]) + + assert align.skipped_steps == [3] + data = _fit_data(align) + assert list(data[0]) == [0, 45, 135, 180] + # offset = value[0] - value[k] (um): steps 1, 2, 4, 5 -> clicked at 110, 120, 140, 150 px + np.testing.assert_allclose(data[1], [-10.0, -20.0, -40.0, -50.0]) + assert data.shape == (3, 4) + kwargs = align._save_alignment_data.call_args.kwargs + assert kwargs["skipped_angles"] == [90] + + +def test_skip_writes_no_values_and_a_nan_row_for_the_step(): + align, _, _ = _run([FZP, HEIGHT, 1, 1, -1, 1, 1]) + + assert sorted(align.alignment_values) == [0, 1, 2, 4, 5] + rows = {int(r[0]): r for r in align.roi_pixel_data} + assert np.isnan(rows[3][1:5]).all() + assert not np.isnan(rows[4][1:5]).any() + + +def test_skipped_step_does_not_read_its_stale_widths_for_the_fov(): + # step 5 would be the widest ROI (25 px) but is skipped; stale widths must not be read + align, _, _ = _run([FZP, HEIGHT, 1, 1, 1, 1, -1]) + + assert align.skipped_steps == [5] + assert align._xray_fov_xy == [24.0, 34.0] + assert list(_fit_data(align)[0]) == [0, 45, 90, 135] + + +def test_skip_at_last_step_finishes_cleanly(): + align, dev, messages = _run([FZP, HEIGHT, 1, 1, 1, 1, -1]) + + assert "done..." in messages + align.gui.hide_crosshair.assert_called() + assert dev.omny_xray_gui.submit.value == 0 + + +def test_second_skip_is_refused_and_run_continues(): + # the second -1 (at k=4) is refused, the same step is then submitted normally + align, dev, messages = _run([FZP, HEIGHT, 1, 1, -1, -1, 1, 1]) + + assert align.skipped_steps == [3] + assert list(_fit_data(align)[0]) == [0, 45, 135, 180] + refusals = [m for m in messages if m.startswith("Cannot skip")] + assert len(refusals) == 1 + assert "at least 4 positions" in refusals[0] + # the message keeps telling the user which step is pending + assert "Step 4/5" in refusals[0] + # a refused skip is reset, so it cannot leak into the next step + assert dev.omny_xray_gui.submit.value == 0 + align.gui.enable_skip_button.assert_called_with(False) + + +def test_skip_of_fzp_center_is_refused(): + align, _, messages = _run([-1, FZP, HEIGHT, 1, 1, 1, 1, 1]) + + assert align.skipped_steps == [] + assert list(_fit_data(align)[0]) == [0, 45, 90, 135, 180] + assert "Cannot skip: the FZP center cannot be skipped. Submit center value of FZP." in messages + + +def test_skip_of_height_marking_is_refused(): + align, _, messages = _run([FZP, -1, HEIGHT, 1, 1, 1, 1, 1]) + + assert align.skipped_steps == [] + assert list(_fit_data(align)[0]) == [0, 45, 90, 135, 180] + assert any(m.startswith("Cannot skip: the sample height marking") for m in messages) + + +def test_skip_button_is_enabled_only_for_fit_steps(): + align, _, _ = _run([FZP, HEIGHT, 1, -1, 1, 1, 1]) + + calls = [c.args[0] for c in align.gui.enable_skip_button.call_args_list] + # start of the run: off; height marked: on; after the one allowed skip the budget is used + # up: off; last step: off + assert calls == [False, True, False, False] + + +def test_second_skip_would_leave_three_points_write_output_guard(): + align = XrayEyeAlign(mock.MagicMock(), mock.MagicMock()) + align._reset_init_values() + align.alignment_values = {k: 0.1 * k for k in range(6)} + align.skipped_steps = [2, 3] + align.gui.submit_fit_array = mock.MagicMock() + align._save_alignment_data = mock.MagicMock() + + with pytest.raises(RuntimeError, match="at least 4"): + align.write_output() + + align._save_alignment_data.assert_not_called() + align.gui.submit_fit_array.assert_not_called() + + +def test_skip_end_point_prints_warning(capsys): + _run([FZP, HEIGHT, 1, 1, 1, 1, -1]) + + assert "skipping an end point" in capsys.readouterr().out + + +def test_skip_refusal_reason_table(): + align = XrayEyeAlign(mock.MagicMock(), mock.MagicMock()) + align._reset_init_values() + + assert align._skip_refusal_reason(0) is not None + assert align._skip_refusal_reason(1) is not None # height marking + align._height_centered = True + assert align._skip_refusal_reason(1) is None # angle 0 deg + assert align._skip_refusal_reason(5) is None + align.skipped_steps = [3] + assert "at least 4" in align._skip_refusal_reason(4) diff --git a/tests/tests_bec_widgets/test_x_ray_eye_skip_button.py b/tests/tests_bec_widgets/test_x_ray_eye_skip_button.py new file mode 100644 index 00000000..f85a7a62 --- /dev/null +++ b/tests/tests_bec_widgets/test_x_ray_eye_skip_button.py @@ -0,0 +1,88 @@ +"""Tests for the Skip button of OMNY_XRayEye (see docs/plans/flomni-xrayeye-skip-button.md): +it is disabled by default (LamNI/omny never enable it), only clickable while the client +allows a skip *and* the submit button is enabled, and a click signals ``submit = -1`` +without needing an ROI. + +The slots are exercised on a minimal stub instead of the full widget, which needs a live BEC +client context via get_bec_shortcuts() -- same approach as test_x_ray_eye_vertical_lock.py. +""" + +from unittest import mock + +from csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye import OMNY_XRayEye + + +class _Button: + def __init__(self, enabled=False): + self._enabled = enabled + + def setEnabled(self, enabled): + self._enabled = bool(enabled) + + def isEnabled(self): + return self._enabled + + +class _StubXRayEye: + enable_submit_button = OMNY_XRayEye.enable_submit_button + enable_skip_button = OMNY_XRayEye.enable_skip_button + _refresh_skip_button = OMNY_XRayEye._refresh_skip_button + skip = OMNY_XRayEye.skip + + +def _make_stub(): + stub = _StubXRayEye() + stub._skip_enabled = False + stub.submit_button = _Button(enabled=False) + stub.skip_button = _Button(enabled=False) + stub.dev = mock.Mock() + return stub + + +def test_skip_button_stays_disabled_unless_the_client_enables_it(): + stub = _make_stub() + + stub.enable_submit_button(True) # what LamNI/omny do + + assert stub.submit_button.isEnabled() + assert not stub.skip_button.isEnabled() + + +def test_skip_button_follows_submit_button_once_enabled(): + stub = _make_stub() + stub.enable_skip_button(True) + assert not stub.skip_button.isEnabled() # submit still disabled ("please wait ...") + + stub.enable_submit_button(True) + assert stub.skip_button.isEnabled() + + stub.enable_submit_button(False) + assert not stub.skip_button.isEnabled() + + stub.enable_submit_button(True) # next step is ready again + assert stub.skip_button.isEnabled() + + +def test_skip_button_can_be_withdrawn_while_submit_stays_enabled(): + stub = _make_stub() + stub.enable_skip_button(True) + stub.enable_submit_button(True) + + stub.enable_skip_button(False) + + assert stub.submit_button.isEnabled() + assert not stub.skip_button.isEnabled() + + +def test_skip_click_signals_minus_one_and_blocks_double_click(): + stub = _make_stub() + stub.enable_skip_button(True) + stub.enable_submit_button(True) + + stub.skip() + + stub.dev.omny_xray_gui.submit.set.assert_called_once_with(-1) + assert not stub.skip_button.isEnabled() + # the client re-enables the buttons for the next step + stub.enable_submit_button(True) + assert stub.skip_button.isEnabled() -- 2.54.0 From 7695a6293aab0b95e7fb9448f7aafa1f95c54a24 Mon Sep 17 00:00:00 2001 From: x12sa Date: Mon, 21 Sep 2026 15:57:38 +0200 Subject: [PATCH 69/78] remove the optics before rotating to 90, in case of close situation with OSA and 2d sample pins --- csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index f8cda11a..4f0f3c79 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -737,6 +737,8 @@ class FlomniSampleTransferMixin: self.laser_tracker_off() time.sleep(0.05) + self.foptics_out() + umv(dev.fsamroy, 90) self.drive_axis_to_limit(dev.fsamx, "forward") -- 2.54.0 From 337cdc45e9069bba74e8805d90a6f483d46879d4 Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 21 Sep 2026 16:09:58 +0200 Subject: [PATCH 70/78] fix(xrayeye): hide the Skip button until a client script opts in LamNI/omny never call enable_skip_button, so they only had a greyed-out Skip button. Keep it hidden; the first enable_skip_button() call (flomni does this at the start of the alignment) shows it. Co-Authored-By: Claude Sonnet 5 --- csaxs_bec/bec_widgets/widgets/client.py | 1 + .../bec_widgets/widgets/xray_eye/x_ray_eye.py | 7 +++++-- .../test_x_ray_eye_skip_button.py | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index 2d693c6c..9c5945e8 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -200,6 +200,7 @@ class OMNY_XRayEye(RPCBase): """ Allow/forbid skipping the current alignment angle. + The first call also shows the button (it is hidden for clients that never call this). The button is only clickable while this is True *and* the submit button is enabled, so it follows the client's "please wait ..." phases automatically. Args: diff --git a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py index 0dedfe59..92da4806 100644 --- a/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py +++ b/csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py @@ -580,10 +580,11 @@ class OMNY_XRayEye(BECWidget, QWidget): # Submit button self.submit_button = QPushButton("Submit", parent=self) # Skip button: leaves out the current angle of the alignment (flat samples are - # edge-on at 90 deg). Disabled unless the client script opts in via - # enable_skip_button(), so LamNI/omny never see it active. + # edge-on at 90 deg). Hidden and disabled until a client script opts in via + # enable_skip_button(), so LamNI/omny never see it. self._skip_enabled = False self.skip_button = QPushButton("Skip", parent=self) + self.skip_button.setVisible(False) self.skip_button.setEnabled(False) self.skip_button.setToolTip("Leave out this angle (at least 4 positions are needed)") # Add to layout form @@ -1307,11 +1308,13 @@ class OMNY_XRayEye(BECWidget, QWidget): """ Allow/forbid skipping the current alignment angle. + The first call also shows the button (it is hidden for clients that never call this). The button is only clickable while this is True *and* the submit button is enabled, so it follows the client's "please wait ..." phases automatically. Args: enable(bool): True if the client accepts a skip for the current step """ + self.skip_button.setVisible(True) self._skip_enabled = bool(enable) self._refresh_skip_button() diff --git a/tests/tests_bec_widgets/test_x_ray_eye_skip_button.py b/tests/tests_bec_widgets/test_x_ray_eye_skip_button.py index f85a7a62..14b6fda2 100644 --- a/tests/tests_bec_widgets/test_x_ray_eye_skip_button.py +++ b/tests/tests_bec_widgets/test_x_ray_eye_skip_button.py @@ -15,6 +15,13 @@ from csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye import OMNY_XRayEye class _Button: def __init__(self, enabled=False): self._enabled = enabled + self._visible = False + + def setVisible(self, visible): + self._visible = bool(visible) + + def isVisible(self): + return self._visible def setEnabled(self, enabled): self._enabled = bool(enabled) @@ -46,6 +53,16 @@ def test_skip_button_stays_disabled_unless_the_client_enables_it(): assert stub.submit_button.isEnabled() assert not stub.skip_button.isEnabled() + assert not stub.skip_button.isVisible() + + +def test_skip_button_appears_on_the_first_enable_call(): + stub = _make_stub() + + stub.enable_skip_button(False) # flomni does this at the start of a run + + assert stub.skip_button.isVisible() + assert not stub.skip_button.isEnabled() def test_skip_button_follows_submit_button_once_enabled(): -- 2.54.0 From 527113340e48c64a212769f7084312a1456102ad Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 21 Sep 2026 16:20:48 +0200 Subject: [PATCH 71/78] docs(flomni): xrayeye skip button tested in the flomni simulator Co-Authored-By: Claude Sonnet 5 --- docs/plans/flomni-xrayeye-skip-button.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/flomni-xrayeye-skip-button.md b/docs/plans/flomni-xrayeye-skip-button.md index b6e08a40..60f650e8 100644 --- a/docs/plans/flomni-xrayeye-skip-button.md +++ b/docs/plans/flomni-xrayeye-skip-button.md @@ -1,7 +1,7 @@ # Plan: "Skip" button on the X-ray-eye alignment widget (flomni: leave out one angle, e.g. 90 deg) Status: **implemented 2026-09-21** on a BEC test server (tests: `tests_bec_ipython_client/test_flomni_xray_eye_skip.py`, -`tests_bec_widgets/test_x_ray_eye_skip_button.py`); **not yet tried on real hardware** (see Verification). +`tests_bec_widgets/test_x_ray_eye_skip_button.py`); tested live in the flomni simulator (works, ok'd by Ana); **not yet tried on real hardware** (see Verification). Written after a read-only stability study of the DAP sine fit with 4 instead of 5 angles. Deviations from the plan below: the widget couples the Skip button to the Submit button -- 2.54.0 From 7fe20ea3d1866e10459a8a7a10e0cbc36d9aa768 Mon Sep 17 00:00:00 2001 From: x12sa Date: Mon, 21 Sep 2026 16:22:54 +0200 Subject: [PATCH 72/78] docs: add note-location rule and OMNY tomo_alignment_fit TODO to AGENTS_mirko Co-Authored-By: Claude Sonnet 5 --- AGENTS_mirko.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/AGENTS_mirko.md b/AGENTS_mirko.md index ccf34012..36ad2608 100644 --- a/AGENTS_mirko.md +++ b/AGENTS_mirko.md @@ -14,8 +14,22 @@ repos, write it up (a short report or plan) instead of implementing/committing i Fixes that land in `csaxs_bec` itself (beamline plugin code, widgets, device configs, scans) are mine to implement, commit, and push directly. +## Where notes go + +Never write plans, TODOs or notes to Claude's local memory / local plan files (they are +machine-local and invisible on other machines). Put them in this file, or in a markdown file in +the repo when I ask for one. + ## TODO +- 2026-09-21: port "always define `tomo_alignment_fit`" from Flomni to OMNY. Flomni is done + (commit `5b688df`): `reset_tomo_alignment_fit()` writes zeros instead of deleting the global + var, and `ensure_tomo_alignment_fit()` (called from `Flomni.__init__`) creates the zero fit + only if missing, so manual tweaks like `fit[0][2] += 5.0` work. OMNY + (`omny/omny_alignment_mixin.py`) still deletes the var and never defines it at startup; nothing + in `omny/` calls the reset yet, so decide where OMNY should reset it. LamNI does not use it. + Details and steps: `csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_tomo_alignment_fit_always_defined.md`. + - 2026-09-20: `flomni_fermat_scan` hung on `pre_scan_all_devices()` after positions were already sent to the rt controller (scans 11998, 12000 in `/var/log/bec/ScanServer.log` / `DeviceServer.log`). Positions/PID-correction step (`prepare_setup_part_2()`) completed -- 2.54.0 From 3eb6e0355f1cc6d73c62709e65660a8b26bc0d5d Mon Sep 17 00:00:00 2001 From: x01dc Date: Mon, 21 Sep 2026 20:21:23 +0200 Subject: [PATCH 73/78] fix(panda): drop stale frame_counter carryover in Mode B raw stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnosed against a real scan (S06313): grouping raw interf_st_fzp_x/_y samples by frame_counter + gate_detector_active reproduces rt_positions' trusted average_x_st_fzp/_y to R² > 0.9999, confirming that method -- but one point (167) was off by ~116 nm because PandA's COUNTER1.OUT is not cleared by Arm(). The first raw samples of an acquisition can briefly carry a stale frame_counter value left over from the previous acquisition's tail (observed: 36 samples reading a stale 167 before dropping to 0), which silently contaminates whichever real point later in the same scan happens to share that counter value. PandaBoxOMNY now drops raw rows captured before it observes frame_counter == 0 for the first time in each acquisition -- on every arm, including on_pre_scan retries, since each Arm() reopens the same stale-value window -- and drops leftover buffered rows from an abandoned attempt at the same point. Verified against S06313: point 167's residual drops from ~116 nm to ~0.1 nm after the fix, in line with the rest of the scan. Write-up, including the diagnostic method and a possible PandA-layout-level alternative fix, in docs/developer/panda_box_free_running_setup.md. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NPZ9gzN2rt3wgiMnTKyrug --- csaxs_bec/devices/panda_box/panda_box_omny.py | 79 +++++++++++++- .../developer/panda_box_free_running_setup.md | 64 +++++++++++ tests/tests_devices/test_panda.py | 100 ++++++++++++++++++ 3 files changed, 239 insertions(+), 4 deletions(-) diff --git a/csaxs_bec/devices/panda_box/panda_box_omny.py b/csaxs_bec/devices/panda_box/panda_box_omny.py index 52157433..738f0697 100644 --- a/csaxs_bec/devices/panda_box/panda_box_omny.py +++ b/csaxs_bec/devices/panda_box/panda_box_omny.py @@ -56,6 +56,20 @@ class PandaBoxOMNY(PandaBox): # connecting, so a missing READY means the readout thread/box is in a stale state. _PRE_SCAN_ARM_ATTEMPTS = 2 + # PandA's COUNTER1.OUT (the block behind frame_counter) is not cleared by Arm() -- + # COUNTER1.ENABLE=PCAP.ACTIVE only gates counting, not the persisted output value, and + # COUNTER1.SET is wired to a constant (never pulsed) in the saved layout (see + # layouts/omny_freerun.ini). So the first raw samples of a new acquisition can briefly + # carry a stale frame_counter value left over from the previous acquisition's tail -- + # confirmed 2026-09-21 against a real scan (S06313): 36 samples reading a stale value + # before dropping to 0 and counting correctly from there. If that stale value collides + # with a real point index later in the same scan, grouping raw samples by frame_counter + # silently mixes those stale rows into that point's average (see + # docs/developer/panda_box_free_running_setup.md, "Known data-quality caveat"). Rows are + # dropped client-side until the first observed frame_counter == 0 per acquisition to + # guard against this; see _reset_raw_stream_state/_drop_stale_frame_counter_rows below. + _FRAME_COUNTER_SIGNAL_NAME = "frame_counter" + def __init__( self, *, @@ -80,6 +94,10 @@ class PandaBoxOMNY(PandaBox): self._raw_stream_buffer_row_count = 0 self._raw_stream_buffer_lock = threading.Lock() self._raw_stream_last_flush = time.monotonic() + # Whether frame_counter == 0 has been observed yet for the current acquisition; see + # _FRAME_COUNTER_SIGNAL_NAME above. Reset for every fresh arm, not just once per stage, + # since on_pre_scan retries re-arm the box (and thus reset COUNTER1's counting) too. + self._raw_stream_seen_reset = False # Seconds to wait for the PandA READY event per arming attempt in on_pre_scan. self.pre_scan_timeout = pre_scan_timeout @@ -108,10 +126,7 @@ class PandaBoxOMNY(PandaBox): super().on_stage() self.scan_parameters = fetch_scan_info(self.scan_info) - with self._raw_stream_buffer_lock: - self._raw_stream_buffer = {} - self._raw_stream_buffer_row_count = 0 - self._raw_stream_last_flush = time.monotonic() + self._reset_raw_stream_state() # TODO, adjust as seen fit. # Adjust the acquisition group based on scan parameters if needed @@ -200,6 +215,10 @@ class PandaBoxOMNY(PandaBox): self.remove_status_callback(cb_id) logger.warning(f"PandaBox {self.name} arming attempt failed: {exc!r}") return False + # Reset raw-stream state right before arming, not just once in on_stage: a retry here + # re-arms the box (and thus resets COUNTER1's counting) too, so the stale-value window + # applies again on every attempt, not just the first. + self._reset_raw_stream_state() # Sent from here rather than from a status callback (as the base class does), so the # scan is only released after Arm() actually went out. self._arm() @@ -320,6 +339,11 @@ class PandaBoxOMNY(PandaBox): return converted = self.convert_frame_data(frame_data=data) + converted = self._drop_stale_frame_counter_rows(converted) + if converted is None: + # Every row in this chunk still carried a stale frame_counter from the previous + # acquisition; nothing here belongs to the current one. + return with self._raw_stream_buffer_lock: self._merge_into_raw_stream_buffer(converted) elapsed = time.monotonic() - self._raw_stream_last_flush @@ -329,6 +353,53 @@ class PandaBoxOMNY(PandaBox): ): self._flush_raw_stream_buffer_locked() + def _reset_raw_stream_state(self) -> None: + """Drop any leftover buffered rows and mark that frame_counter == 0 has not yet been + observed, for a fresh acquisition. Called from on_stage (once per scan) and from + _try_arm right before every Arm() (including on_pre_scan retries), since each Arm() + resets COUNTER1's counting and thus reopens the stale-value window described at + _FRAME_COUNTER_SIGNAL_NAME above. Also drops whatever was buffered from a previous, + now-abandoned attempt, since that data belongs to an acquisition that never completed. + """ + with self._raw_stream_buffer_lock: + self._raw_stream_buffer = {} + self._raw_stream_buffer_row_count = 0 + self._raw_stream_last_flush = time.monotonic() + self._raw_stream_seen_reset = False + + def _drop_stale_frame_counter_rows( + self, converted: dict[str, dict[str, Any]] + ) -> dict[str, dict[str, Any]] | None: + """Drop rows captured before frame_counter first reads 0 for this acquisition. + + See the class-level note on `_FRAME_COUNTER_SIGNAL_NAME`: PandA's COUNTER1.OUT is not + cleared by Arm(), so the first raw samples of a new acquisition can briefly carry a + stale value left over from the previous acquisition's tail. Truncating every signal's + row list at the same index keeps all channels aligned. + + Returns the (possibly truncated) dict, or None if every row in this chunk is still + stale and should be dropped entirely. + """ + if self._raw_stream_seen_reset: + return converted + counter_entry = converted.get(self._FRAME_COUNTER_SIGNAL_NAME) + if counter_entry is None: + # This layout/config doesn't capture frame_counter -- nothing to filter against. + self._raw_stream_seen_reset = True + return converted + reset_index = next( + (i for i, v in enumerate(counter_entry["value"]) if v == 0), None + ) + if reset_index is None: + return None + self._raw_stream_seen_reset = True + if reset_index == 0: + return converted + return { + key: {**entry, "value": entry["value"][reset_index:]} + for key, entry in converted.items() + } + def _merge_into_raw_stream_buffer(self, converted: dict[str, dict[str, Any]]) -> None: """Append newly converted rows into the pending raw-stream buffer. Must be called while holding `_raw_stream_buffer_lock`.""" diff --git a/docs/developer/panda_box_free_running_setup.md b/docs/developer/panda_box_free_running_setup.md index a872010f..7cc5a34b 100644 --- a/docs/developer/panda_box_free_running_setup.md +++ b/docs/developer/panda_box_free_running_setup.md @@ -220,6 +220,70 @@ The BEC-side data schema produced by Mode B (what ends up in the HDF5 async dataset) is documented separately in `docs/developer/panda_box_free_running_data_schema.md`. +## Known data-quality caveat: stale `frame_counter` carryover (found + fixed 2026-09-21) + +Diagnosed against a real scan (`S06313`): grouping raw `interf_st_fzp_x`/`_y` +samples by `frame_counter` (restricted to `gate_detector_active == 1`) and +averaging per group reproduces `rt_positions`'s trusted +`average_x_st_fzp`/`average_y_st_fzp` to R² > 0.9999 once a single constant +per-axis offset is removed -- an expected reference-zero difference between +the PandA/`INENC` counter and RT's own internal tracking, irrelevant to +reconstruction since it's the same constant for every point in a scan. This +also confirms the grouping/filtering method itself: naively treating every +raw row as its own position (without grouping by `frame_counter` and +filtering to `gate_detector_active == 1`) does not reproduce the true +positions and cannot be fixed by any scale/offset correction, since most raw +rows are samples taken between real exposures, not at them (see "Continuous +gate/arm" above). + +One point out of 472 was a clear outlier under that method: its +`frame_counter` group had 38 extra samples (1036 vs. the usual ~998) and its +recomputed position was off by ~116 nm in Y, against ~5 nm typical. + +Root cause: PandA's `COUNTER1.OUT` (the block behind `frame_counter`) is not +cleared by `Arm()`. `COUNTER1.ENABLE=PCAP.ACTIVE` only gates *counting*, not +the persisted output value, and `COUNTER1.SET` -- the block's reset-trigger +input -- is wired to the constant `ZERO` in the saved layout (see +`layouts/omny_freerun.ini`), i.e. never pulsed on arm, unlike `ENABLE` which +*is* wired to a live source (`PCAP.ACTIVE`). So the first raw samples of a new +acquisition can briefly carry a stale `frame_counter` value left over from the +previous acquisition's tail -- observed directly in `S06313`'s raw data: 36 +samples reading a stale `167` before dropping to `0` and counting correctly +from there. Harmless on its own, except that stale value collided with a real +point index later in the *same* scan (point 167), so grouping by +`frame_counter` silently mixed ~38 stale rows into that one point's average. + +Fix, implemented in `panda_box_omny.py` (software-only, no PandA layout +change needed): every time PCAP is armed -- including `on_pre_scan` retries, +since each `Arm()` reopens the same stale-value window -- `PandaBoxOMNY` now +drops any raw rows captured before it observes `frame_counter == 0` for the +first time in that acquisition (`_drop_stale_frame_counter_rows`, called from +`_receive_frame_data`), and drops any leftover buffered rows from an +abandoned previous attempt at the same point (`_reset_raw_stream_state`, +called from `on_stage` and from `_try_arm` right before every `Arm()`). Every +signal's row list is truncated at the same index to keep channels aligned. +Verified against `S06313`: dropping the (37, in that scan) leading stale rows +before grouping brings point 167's residual from ~116 nm down to ~0.1 nm, in +line with the rest of the scan. + +A more thorough fix would rewire `COUNTER1.SET` on the PandA layout itself +(from the constant `ZERO` to `PCAP.ACTIVE`'s rising edge, matching how +`ENABLE` is already wired) so `OUT` resets at the source on every arm, rather +than relying on the client to detect and discard the stale prefix. Not +implemented here since it requires a PandA layout change to be tested against +live hardware; the software-side drop is a correct and sufficient fix in the +meantime, and remains a reasonable defense-in-depth even if the layout is +fixed later. If this is revisited: re-verify against a range of scans +(especially ones with `frame_counter` staying below a few hundred, where a +stale-value collision like point 167's is most likely) before removing the +software-side check. + +This also means the "gap in an otherwise-monotonic sequence" completeness +check described in the mode-comparison table's "Data completeness +verification" row needs one caveat: a single backward step at the very start +of the raw sequence, from the previous acquisition's terminal value down to +`0`, is this expected (now-filtered) artifact, not a sign of dropped rows. + ## Switching between modes `omny_panda` (Mode A) and `omny_panda_continuous` (Mode B) need different PandA hardware diff --git a/tests/tests_devices/test_panda.py b/tests/tests_devices/test_panda.py index ee6626b3..8e1c336a 100644 --- a/tests/tests_devices/test_panda.py +++ b/tests/tests_devices/test_panda.py @@ -384,6 +384,106 @@ def test_panda_omny_pre_scan_stop_is_not_retried(panda_omny, capsys): assert "Forcing abort/reset" not in capsys.readouterr().out +def test_panda_omny_raw_stream_drops_stale_leading_frame_counter_rows(panda_omny_raw_stream): + """PandA's COUNTER1.OUT is not cleared by Arm(), so the first raw samples of a new + acquisition can briefly carry a stale frame_counter value left over from the previous + acquisition's tail (confirmed 2026-09-21 against a real scan, S06313). Rows before the + first observed frame_counter == 0 must be dropped, and every channel must be truncated + at the same row so they stay aligned.""" + dev = panda_omny_raw_stream + # Two stale rows carrying the previous acquisition's terminal counter value (167), then + # the real acquisition starting at frame_counter 0. + data = np.array( + [(167.0, 1.0), (167.0, 0.0), (0.0, 1.0), (0.0, 0.0), (1.0, 1.0)], + dtype=[("COUNTER1.OUT.Value", " Date: Mon, 21 Sep 2026 20:27:15 +0200 Subject: [PATCH 74/78] docs(flomni): document PandA Mode B recovered-position validation method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write-up for continuing this testing in a future session without needing to re-derive it: the required gate_detector_active/frame_counter grouping (and why naive per-row comparison can't be fixed by any scale/offset correction), the exact interferometer + capacitive-sensor conversion formula, the stale-frame_counter carryover bug and its fix (3eb6e03), and the validation method (R²/residuals against rt_positions' trusted average_x_st_fzp/_y). Also records the two agreed next steps: burst acquisition testing and reduced data mode (the latter still undefined, to be scoped in that session). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NPZ9gzN2rt3wgiMnTKyrug --- .../AI_docs/PANDA_POSITION_VALIDATION.md | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md new file mode 100644 index 00000000..2e6094e6 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md @@ -0,0 +1,186 @@ +# Validating PandaBox (`omny_panda_continuous`) recovered positions against RT + +**Purpose of this document:** how to check whether positions recovered from +the PandaBox's Mode B (free-running raw stream, `raw_stream_mode: true`) +capture actually match the true stage positions, using the RT controller's +own trusted per-point averages as ground truth. Read this before continuing +that testing in a new session — it captures the full methodology, the exact +conversion formula, and a bug that was found and fixed this way, so none of +it needs re-deriving from scratch. + +Companion docs: +- `docs/developer/panda_box_free_running_setup.md` — the PandA layout/mode + design doc. Has the mode comparison, signal alias mapping, and (as of + 2026-09-21) the "Known data-quality caveat: stale `frame_counter` + carryover" section this investigation produced. +- `docs/developer/panda_box_free_running_data_schema.md` — the HDF5 field + list for the Mode B raw stream. +- `csaxs_bec/devices/panda_box/panda_box_omny.py` — the device. See + `_drop_stale_frame_counter_rows`/`_reset_raw_stream_state` for the fix + described below. + +--- + +## 1. The problem this validates + +Mode B captures raw samples continuously at `CLOCK1`'s rate for the whole +burst window — **not** one sample per scan point. Only a subset of rows +coincide with an actual detector exposure (`gate_detector_active == 1`), and +even those need to be grouped by which point they belong to +(`frame_counter`). Treating every raw row as its own recovered position +(without grouping/filtering) produces a scatter that roughly traces the +scan's envelope but does not correspond point-for-point to the true +positions — and critically, **no scale/offset correction can fix that**, +because it's a many-to-one correspondence problem, not a coordinate error. +This was confirmed visually (see the git history around 2026-09-20/21 for the +red/blue scatter plot that started this investigation) before the grouping +method below was worked out. + +## 2. Ground truth: `rt_positions` + +The RT controller logs its own per-point values, already correctly averaged +and known-good (used successfully in reconstruction): + +- `rt_positions_data_target_x`/`_y` — commanded (programmed) position. +- `rt_positions_data_average_x_st_fzp`/`_y` — **the trusted measured + position**, in microns. Compare recovered PandA positions against these, + not against `target_x`/`_y`. +- `rt_positions_data_stdev_x_st_fzp`/`_y` — RT's own per-point stdev, useful + as a sanity scale for how much residual noise to expect (order 0.005 µm in + the validated scan). + +One row per scan point (472 points in the validated scan, `S06313`). + +## 3. Recovering positions from the Mode B raw stream + +### 3.1 Raw fields (all same length, one entry per raw sample) + +Under `entry/collection/devices/omny_panda_continuous/` in the master HDF5: +`omny_panda_continuous_data_/value`, where `` is one of +`interf_st_fzp_x`, `interf_st_fzp_y`, `cap_voltage_fzp_x`, `cap_voltage_fzp_y`, +`frame_counter`, `gate_detector_active` (plus `interf_st_rotx`/`_rotz`, +`pcap_gate_duration_value`, not needed for x/y position recovery). + +### 3.2 Required grouping/filtering — do this before anything else + +1. Filter to rows where `gate_detector_active == 1` (real exposure, not + inter-point flight time). +2. Group the remaining rows by `frame_counter` (one group per scan point; + `frame_counter` ranges `0..num_points-1` for a single-frame-per-point + scan — see §6 for the burst case, still untested). +3. Average `interf_st_fzp_x`/`_y` and `cap_voltage_fzp_x`/`_y` within each + group. + +Skipping this step is the #1 way to get a result that looks roughly +right-shaped but doesn't actually match — see §1. + +### 3.3 Stale `frame_counter` carryover — check this if a session predates the fix + +PandA's `COUNTER1.OUT` is not cleared by `Arm()`, so the first raw samples of +an acquisition can briefly carry a stale `frame_counter` value left over from +the *previous* acquisition's tail. If that stale value collides with a real +point index later in the same scan, it contaminates that point's group. +`panda_box_omny.py` fixes this client-side as of commit `3eb6e03` +(2026-09-21) — it drops rows before the first observed `frame_counter == 0` +on every arm. **Any HDF5 file captured before that fix was live on the real +device** (i.e. anything from before 2026-09-21, `S06313` included) still has +the stale rows in it and needs the same drop applied manually before +grouping: + +```python +reset_idx = np.argmax(frame_counter_raw == 0.0) # first occurrence +frame_counter_raw = frame_counter_raw[reset_idx:] +# ...apply the same [reset_idx:] slice to every other raw field, in lockstep +``` + +For anything captured after the fix, this is unnecessary (already handled +device-side) — but it's cheap and harmless to keep doing as a defense-in-depth +check; if it ever finds `reset_idx > 0` again on a fixed device, that's a +regression worth investigating. + +### 3.4 Conversion formula (from Mirko, confirmed 2026-09-21) + +```python +factor_x = -0.000309078 +factor_y = 0.000154539 + +interfx = grouped_interf_st_fzp_x * factor_x # microns +interfy = grouped_interf_st_fzp_y * factor_y # microns + +capmuy = -(grouped_cap_voltage_fzp_y) * 5.0 / 20.0 +capmux = (grouped_cap_voltage_fzp_x) * 5.0 / 20.0 + +CapX_FZP_microns = 0.9539 * capmux + 0.0009008 * capmuy +CapY_FZP_microns = -0.023140 * capmux + 1.0120 * capmuy + +pos_x_um = interfx - CapX_FZP_microns # microns -- compare directly against average_x_st_fzp +pos_y_um = interfy - CapY_FZP_microns # microns -- compare directly against average_y_st_fzp +``` + +**Units trap:** the original formula this was transcribed from ends with +`pos_x = (interfx - CapX_FZP_microns) * 1e-6` to get meters, for whatever +consumed it downstream. `rt_positions_data_average_x_st_fzp`/`_y` are in +**microns**, so compare against `pos_x_um`/`pos_y_um` *before* that final +`* 1e-6` — don't convert one side to meters and leave the other in microns. + +`factor_x`/`factor_y` were independently re-derived by least-squares fit +against `average_x_st_fzp`/`_y` from `S06313` and matched Mirko's values to 4 +significant figures (fit: `-3.089108e-04`/`1.545243e-04` vs. given +`-0.000309078`/`0.000154539`) — good cross-check if a future factor is ever +in doubt. + +## 4. Validating a match + +```python +def r2(pred, true): + ss_res = np.sum((true - pred) ** 2) + ss_tot = np.sum((true - true.mean()) ** 2) + return 1 - ss_res / ss_tot +``` + +Expected result on a correctly grouped, correctly converted scan (from +`S06313`, post-fix): +- A single near-constant offset per axis between `pos_x_um`/`pos_y_um` and + `average_x_st_fzp`/`_y` (validated: X ≈ −1.26 µm, Y ≈ +0.99 µm) — this is + expected (RT's internal reference zero vs. the PandA/`INENC` counter's own + free-running zero don't coincide) and irrelevant to reconstruction, since + it's the same constant for every point in one scan. Subtract the mean + residual per axis before judging fit quality. +- After removing that offset: R² > 0.9999 on both axes, residual std ~0.25– + 0.3 nm, max residual under ~1 nm across all points (post-fix; pre-fix, one + point was off by ~116 nm — see §3.3). + +If R² is poor and doesn't clean up after removing a constant offset (i.e. a +best-fit affine still doesn't work — this is the actual symptom that flagged +the original bug, see §1), suspect the grouping/filtering step (§3.2) before +suspecting the conversion factors. + +## 5. Practical notes + +- Use `/opt/bec_deployments/production/bec_venv/bin/python3` for any + h5py-based analysis — the system `python3` doesn't have `h5py` installed. +- `S06313`'s files were at `/tmp/S06313/` (`S06313_master.h5` has both + `omny_panda_continuous` and `rt_positions`; `S06313_eiger_*` are detector + data, not needed for this). `/tmp` is not persistent — get a fresh scan's + files from whoever's driving the beamtime if this path is gone. +- Relevant H5 group prefix: + `entry/collection/devices//_data_/value` (and + `.../timestamp`, much shorter — one entry per buffer flush, not per row, + see `_flush_raw_stream_buffer_locked` in `panda_box_omny.py`). + +## 6. Next steps (as of 2026-09-21, not yet done) + +1. **Burst acquisition**: test the case where the number of PandA frames + equals the number of burst sub-frames (`frames_per_trigger > 1`), and RT + still logs exactly one `average_x_st_fzp`/`_y` per *point* (not per + sub-frame). This changes the grouping in §3.2 — open question going in: + does `frame_counter` increment once per sub-frame trigger (meaning + `frames_per_trigger` distinct `frame_counter` values need to be + aggregated per RT point) or once per point? Check + `panda_box_free_running_setup.md`'s completion-check description + (`COUNTER1` "increments on the detector-trigger's falling edge") against + how many falling edges occur per point when `frames_per_trigger > 1` + before assuming either way. +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. -- 2.54.0 From c3c1b10d7eab02261ddcce8a67ca533d321f8ca1 Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 22 Sep 2026 15:41:02 +0200 Subject: [PATCH 75/78] feat(flomni): commission heater down/up sequencing and OSA safety checks - 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 --- .../AI_docs/PANDA_POSITION_VALIDATION.md | 89 ++++++++++++++- .../plugins/flomni/flomni.py | 10 +- .../plugins/flomni/flomni_optics_mixin.py | 108 ++++++++++-------- csaxs_bec/device_configs/ptycho_flomni.yaml | 68 ++++++----- .../simulated_omny/simulated_flomni.yaml | 3 + 5 files changed, 193 insertions(+), 85 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md index 2e6094e6..a223f483 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md @@ -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/` on +`x12sa-bec-001`, files from 2026-09-22 ~12:45–12:58): + +- **`S00386`** (174 points, ~348k raw rows): `frame_counter` starts at `0` on + the very first raw row and has zero backward steps across the whole raw + stream — no leading stale-value prefix at all, unlike pre-fix `S06313`. + Full grouping+conversion validation against `rt_positions`: R² = + 0.99999997 (X) / 0.99999998 (Y) after removing the expected constant + per-axis offset (−1.23 µm X, +1.08 µm Y, consistent with §4's `S06313` + values); residual std 0.30 nm (X) / 0.25 nm (Y); worst single-point + residual 1.02 nm (X) / 0.51 nm (Y) — no outlier anywhere near the ~116 nm + the pre-fix bug produced. Clean confirmation of the fix. +- **`S00388`** (785 points, ~1.4M raw rows): one backward step, at raw row + 36 (`frame_counter` reads `28` for rows 0–36, then drops to `0` from row + 37 on) — structurally the same leading-stale-prefix signature the fix is + meant to strip, still present in data captured after the fix. However, + every one of those 37 stale rows has `gate_detector_active == 0`, so + they'd never enter a point's average regardless (§3.2 already filters to + `gate_detector_active == 1` before grouping) — this scan happened not to + be exposed to the bug's actual failure mode. Full validation still comes + out clean: R² = 0.99999999 (X) / 0.99999998 (Y), residual std 0.27 nm (X) + / 0.26 nm (Y), max residual 0.85 nm (X) / 0.56 nm (Y). + +**Root-cause investigation (updated 2026-09-22, still not fully resolved):** + +Ruled out first: the leading `frame_counter` column can't be missing from +`S00388`'s first post-arm chunk the way an earlier version of this note +speculated. `pandablocks.connections.DataConnection`'s captured field set is +fixed once per TCP data connection (established once per `on_stage`, before +any `FrameData` is produced), so every `FrameData` chunk within one +acquisition carries the same columns — the "layout doesn't capture +`frame_counter`" fallback in `_drop_stale_frame_counter_rows` +(`counter_entry is None`) isn't reachable here. + +What actually happened, confirmed from `ScanServer.log`/`DeviceServer.log` +for 2026-09-22: the scan immediately preceding `S00388` — scan **#387** +(786 points, `eb7cfd11-3610-4511-94a0-75f49b403e62`) — was opened at +`12:50:10` and force-**aborted** by the user only ~7 seconds later +(`12:50:16`–`17`, `action: 'abort'` in the scan queue). At `exp_time=0.1`s/point +that's on the right order to land around point ~20–30, consistent with the +leftover `frame_counter == 28` that then showed up at the start of `#388`'s +raw stream (`#388` itself opened cleanly at `12:50:39`, no arm retries +logged — matches the "one `on_stage` per scan number" mapping confirmed +against the log). This is exactly the hardware mechanism `3eb6e03`'s commit +message describes (`COUNTER1.OUT` not cleared by `Arm()`) — just triggered +by an **abort mid-scan** rather than a normal scan-to-scan transition, which +is what `S06313` (the original bug report) and `S00386` (clean, §7 above) +both were. + +Why the client-side filter didn't strip it is not fully pinned down. +`PandaBoxOMNY.stop()` (inherited, not overridden) does not call +`_reset_raw_stream_state()` — only `on_stage()` (once per scan) and +`_try_arm()` (right before every `Arm()`) do, and both ran normally for +`#388` per the logs. So by the code's own logic the state should have been +freshly reset well before `#388`'s `Arm()` went out. One structural +suspect, not yet confirmed as the actual cause: `_raw_stream_buffer`/ +`_raw_stream_buffer_row_count`/`_raw_stream_last_flush` are all mutated only +under `_raw_stream_buffer_lock`, but `_raw_stream_seen_reset` is set outside +that lock in `_reset_raw_stream_state()` (after the `with` block) and read +outside the lock at the top of `_drop_stale_frame_counter_rows()` — the one +piece of this acquisition's reset state that isn't synchronized the same +way as everything else it's reset alongside. Whether that gap is actually +reachable in a plain abort → restage sequence (as opposed to needing an +`on_pre_scan` retry, which `#388` didn't have) isn't shown by the logs +available here; would need instrumentation (e.g. temporarily logging +`_raw_stream_seen_reset` transitions) against a reproduced abort-then-rescan +to confirm. + +Practically: harmless in both scans checked so far, since the stale rows' +`gate_detector_active` was `0` throughout — they never entered a point's +average regardless of the row-level filter. But an abort mid-scan is a +routine, user-triggered event (unlike an `on_pre_scan` retry), so this path +is worth closing rather than relying on the gate happening to be low during +the stale window. If revisited: consider (a) locking `_raw_stream_seen_reset` +reads/writes the same as the buffer fields, and (b) also calling +`_reset_raw_stream_state()` from `on_stop`/`stop()`, not only `on_stage`/ +`_try_arm`, so an aborted acquisition can't leave stale state for the next +one to inherit. Re-check against a scan where the stale run's rows show +`gate_detector_active == 1` before considering this fully closed. diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 4f0f3c79..60205aed 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -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) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py index 5fabf040..2853a47d 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py @@ -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") diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index 036b72a1..649e045c 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -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 diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml index 97dd2f98..a4eaa26e 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml @@ -104,6 +104,9 @@ fheater: readOnly: false readoutPriority: baseline connectionTimeout: 20 + userParameter: + in: -0.5 + enabled: 1 deviceTags: - simulated_flomni -- 2.54.0 From 55906334fc706b1308f6ae1d3a82b8e3174a71c4 Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 22 Sep 2026 16:00:20 +0200 Subject: [PATCH 76/78] feat(panda): only arm PandA for flomni_fermat_scan Generic BEC staging (stage_all_devices/pre_scan_all_devices) has no per-scan device opt-in -- every enabled device is staged/pre_scanned for every scan type (grid_scan, line_scan, alignment moves, ...), not just the ones that consume its data. deviceTags (ptycho_flomni) are never consulted by that code path, they're purely a client-side grouping convenience. So omny_panda_continuous was being Arm()ed and disarmed for every scan in a session even though only flomni_fermat_scan reads its data -- unnecessary hardware arm/disarm cycling, and it directly compounds the stale frame_counter carryover bug documented in PANDA_POSITION_VALIDATION.md sec 7 (more arm cycles means more abort->restage windows where that bug can be triggered). PandaBoxOMNY now takes an arm_scan_allowlist constructor kwarg (None by default, arms for every scan -- unchanged behavior). When set, on_stage() computes _should_arm_panda from scan_parameters.scan_name against the list; on_pre_scan() skips sending Arm() entirely when it's False, and on_complete() skips the wait-for-frame-count poll loop the same way, both resolving immediately instead. The data-readout thread/TCP connection startup in on_stage() is untouched, since it doesn't touch PandA's hardware register state (COUNTER1) the way Arm() does. ptycho_flomni.yaml's omny_panda_continuous now sets arm_scan_allowlist: [flomni_fermat_scan]. Co-Authored-By: Claude Sonnet 5 --- .../AI_docs/PANDA_POSITION_VALIDATION.md | 11 ++ csaxs_bec/device_configs/ptycho_flomni.yaml | 7 + csaxs_bec/devices/panda_box/panda_box_omny.py | 49 ++++++- .../developer/panda_box_free_running_setup.md | 29 ++++ tests/tests_devices/test_panda.py | 129 ++++++++++++++++++ 5 files changed, 223 insertions(+), 2 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md index a223f483..600fca43 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md @@ -269,3 +269,14 @@ reads/writes the same as the buffer fields, and (b) also calling `_try_arm`, so an aborted acquisition can't leave stale state for the next one to inherit. Re-check against a scan where the stale run's rows show `gate_detector_active == 1` before considering this fully closed. + +**Mitigation added 2026-09-22:** `omny_panda_continuous` was being armed and +disarmed for *every* scan (any `grid_scan`/`line_scan`/alignment move, not +just `flomni_fermat_scan`), since generic BEC staging has no per-scan device +opt-in and `deviceTags` aren't consulted by it — see "Conditional arming +(`arm_scan_allowlist`)" in `docs/developer/panda_box_free_running_setup.md`. +It's now only actually `Arm()`ed for `flomni_fermat_scan` via the new +`arm_scan_allowlist` device config, which directly cuts down how often this +class of bug can even be triggered (fewer arm cycles → fewer abort→restage +windows like `S00388`'s), independent of whatever the remaining root cause +above turns out to be. diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index 649e045c..3dc569d0 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -707,6 +707,13 @@ omny_panda_continuous: 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 diff --git a/csaxs_bec/devices/panda_box/panda_box_omny.py b/csaxs_bec/devices/panda_box/panda_box_omny.py index 738f0697..542d8b95 100644 --- a/csaxs_bec/devices/panda_box/panda_box_omny.py +++ b/csaxs_bec/devices/panda_box/panda_box_omny.py @@ -82,6 +82,7 @@ class PandaBoxOMNY(PandaBox): raw_stream_flush_interval: float = 0.05, raw_stream_flush_row_count: int = 500, pre_scan_timeout: float = 5.0, + arm_scan_allowlist: list[str] | None = None, **kwargs, ) -> None: # Free-running raw-stream configuration. Defaults preserve today's behavior: @@ -99,6 +100,15 @@ class PandaBoxOMNY(PandaBox): # since on_pre_scan retries re-arm the box (and thus reset COUNTER1's counting) too. self._raw_stream_seen_reset = False + # Scan names (ScanServerScanInfo.scan_name, e.g. "flomni_fermat_scan") this device + # should actually be armed for. None (default) means "arm for every scan" -- today's + # behavior, fully backward compatible. Generic BEC staging (stage_all_devices/ + # pre_scan_all_devices in bec_server) arms every enabled device for every scan type, + # not just the ones that consume this device's data; this lets a beamline config opt + # this device out of Arm()/COUNTER1 cycling (and the associated stale-frame_counter + # exposure, see AI_docs/PANDA_POSITION_VALIDATION.md sec 7) for scans that don't need it. + self.arm_scan_allowlist = arm_scan_allowlist + # Seconds to wait for the PandA READY event per arming attempt in on_pre_scan. self.pre_scan_timeout = pre_scan_timeout # Set while the data readout thread is parked (not inside _run_data_readout), so a @@ -120,11 +130,28 @@ class PandaBoxOMNY(PandaBox): self._acquisition_group = "burst" self._timeout_on_completed = 10 self.scan_parameters: ScanServerScanInfo | None = None + # Safe default (arm): matches arm_scan_allowlist=None semantics, and covers the case + # where on_pre_scan/on_complete are called before any on_stage (some tests do this). + self._should_arm_panda: bool = True + + def _compute_should_arm_panda(self) -> bool: + """Whether this scan should actually Arm() the PandA and wait on completion. See the + class-level note on arm_scan_allowlist. Fails open (arms) whenever the allowlist isn't + set or scan_parameters isn't available yet, preserving today's unconditional-arm + behavior in both cases.""" + if self.arm_scan_allowlist is None: + return True + if self.scan_parameters is None: + return True + return self.scan_parameters.scan_name in self.arm_scan_allowlist def on_stage(self): start_time = time.time() super().on_stage() self.scan_parameters = fetch_scan_info(self.scan_info) + # Recomputed fresh every stage cycle: the same device instance is staged for many + # different scan types across a session, not just the ones in arm_scan_allowlist. + self._should_arm_panda = self._compute_should_arm_panda() self._reset_raw_stream_state() @@ -156,7 +183,15 @@ class PandaBoxOMNY(PandaBox): """Arm the PCAP module. Unlike the base class this does not wait forever for the READY event: if it does not arrive, the box is forcibly reset and arming is retried once, and only if that fails as well the returned status fails, so the scan is aborted with - an error instead of hanging silently in pre_scan.""" + an error instead of hanging silently in pre_scan. + + If arm_scan_allowlist excludes the current scan (see _should_arm_panda/on_stage), PandA + is left untouched entirely -- no Arm() is sent -- and this resolves immediately. + """ + if not self._should_arm_panda: + status = StatusBase(obj=self) + status.set_finished() + return status # Not registered with cancel_on_stop: the status of each single attempt in _try_arm is, # so a stop makes the task raise DeviceStoppedError, which fails this status. Cancelling # this one as well would make the task handler try to resolve it a second time. @@ -237,7 +272,17 @@ class PandaBoxOMNY(PandaBox): return super().on_unstage() def on_complete(self): - """On complete is called after the scan is complete. We need to wait for the capture to complete before we can disarm the PandaBox.""" + """On complete is called after the scan is complete. We need to wait for the capture to + complete before we can disarm the PandaBox -- unless this device was never armed for + this scan (see _should_arm_panda/on_stage), in which case there is nothing to wait for + and no wait-loop is started. _disarm() is deliberately not called here in that case: + the box is already guaranteed disarmed (on_stage waits for DISARMED before staging), + nothing armed it since Arm() was skipped, and on_unstage()/stop() unconditionally + disarm anyway at the end of the lifecycle.""" + if not self._should_arm_panda: + status = StatusBase(obj=self) + status.set_finished() + return status if self.raw_stream_mode: return self._on_complete_free_running() return self._on_complete_burst() diff --git a/docs/developer/panda_box_free_running_setup.md b/docs/developer/panda_box_free_running_setup.md index 7cc5a34b..111caa21 100644 --- a/docs/developer/panda_box_free_running_setup.md +++ b/docs/developer/panda_box_free_running_setup.md @@ -284,6 +284,35 @@ verification" row needs one caveat: a single backward step at the very start of the raw sequence, from the previous acquisition's terminal value down to `0`, is this expected (now-filtered) artifact, not a sign of dropped rows. +## Conditional arming (`arm_scan_allowlist`, added 2026-09-22) + +Generic BEC staging (`stage_all_devices`/`pre_scan_all_devices` in +`bec_server.scan_server.scans.scan_actions`) has no concept of "this device +only matters for scan X" -- every `enabled: true` device gets `stage()`/ +`pre_scan()`/`unstage()` called for *every* scan that runs (`grid_scan`, +`line_scan`, alignment moves, ...), not just the ones that actually consume +its data. `deviceTags` (e.g. `ptycho_flomni`) are never consulted by that +staging code -- they're purely a client-side grouping convenience. So +without any further gating, `omny_panda_continuous` was being `Arm()`ed and +disarmed for every single scan run during a session, even though only +`flomni_fermat_scan` reads its data. That's unnecessary hardware arm/disarm +cycling, and it directly compounds the stale-`frame_counter` bug above: more +arm cycles mean more windows where a mid-scan abort can leave a stale +counter value for the *next* acquisition to inherit (see +`AI_docs/PANDA_POSITION_VALIDATION.md` sec 7 for a real incident traced to +exactly this). + +`PandaBoxOMNY` now takes an `arm_scan_allowlist: list[str] | None` constructor +kwarg (`None` by default -- arm for every scan, unchanged behavior). When +set, `on_stage()` computes `_should_arm_panda` from +`scan_parameters.scan_name` against that list; `on_pre_scan()` skips sending +`Arm()` entirely (resolves immediately) when it's `False`, and `on_complete()` +skips the wait-for-frame-count poll loop the same way. `on_stage()`'s own +readout-thread/TCP-connection startup is untouched by this -- it doesn't +touch PandA's hardware register state (`COUNTER1`), unlike `Arm()`, so it +stays out of scope. `ptycho_flomni.yaml`'s `omny_panda_continuous` sets +`arm_scan_allowlist: [flomni_fermat_scan]`. + ## Switching between modes `omny_panda` (Mode A) and `omny_panda_continuous` (Mode B) need different PandA hardware diff --git a/tests/tests_devices/test_panda.py b/tests/tests_devices/test_panda.py index 8e1c336a..ff017212 100644 --- a/tests/tests_devices/test_panda.py +++ b/tests/tests_devices/test_panda.py @@ -484,6 +484,135 @@ def test_panda_omny_pre_scan_arm_resets_raw_stream_state(panda_omny): mock_reset_raw.assert_called_once() +def test_panda_omny_arm_scan_allowlist_defaults_to_none(panda_omny): + """A device constructed without arm_scan_allowlist must arm for every scan (today's + behavior), both via the raw config attribute and the computed should-arm flag.""" + dev = panda_omny + assert dev.arm_scan_allowlist is None + assert dev._should_arm_panda is True + + +def test_panda_omny_stage_should_arm_stays_true_when_allowlist_none(panda_omny): + """Backward-compatibility regression: staging with the default allowlist (None) must keep + _should_arm_panda True regardless of the scan_name in scan_info.""" + dev = panda_omny + dev.stage() + assert dev._should_arm_panda is True + + +def test_panda_omny_stage_should_arm_true_when_scan_name_in_allowlist(panda_omny): + dev = panda_omny + dev.arm_scan_allowlist = ["flomni_fermat_scan"] + dev.scan_info.msg.info["scan_name"] = "flomni_fermat_scan" + dev.stage() + assert dev._should_arm_panda is True + + +def test_panda_omny_stage_should_arm_false_when_scan_name_not_in_allowlist(panda_omny): + dev = panda_omny + dev.arm_scan_allowlist = ["flomni_fermat_scan"] + dev.scan_info.msg.info["scan_name"] = "grid_scan" + dev.stage() + assert dev._should_arm_panda is False + + +def test_panda_omny_should_arm_recomputed_every_stage(panda_omny): + """The same device instance is staged for many different scan types across a session; the + should-arm decision must be recomputed fresh on every stage, not stick from a previous + scan.""" + dev = panda_omny + dev.arm_scan_allowlist = ["flomni_fermat_scan"] + + with mock.patch.object(dev, "_disarm"): + dev.scan_info.msg.info["scan_name"] = "grid_scan" + dev.stage() + assert dev._should_arm_panda is False + dev.unstage() + + dev.scan_info.msg.info["scan_name"] = "flomni_fermat_scan" + dev.stage() + assert dev._should_arm_panda is True + + +def test_panda_omny_pre_scan_skips_arm_when_should_arm_false(panda_omny): + """When arm_scan_allowlist excludes the current scan, on_pre_scan must resolve immediately + without sending Arm() or spinning up the retry task at all.""" + dev = panda_omny + dev._should_arm_panda = False + with ( + mock.patch.object(dev, "_arm") as mock_arm, + mock.patch.object(dev, "_arm_with_retry") as mock_arm_with_retry, + mock.patch.object(dev.task_handler, "submit_task") as mock_submit_task, + ): + status = dev.on_pre_scan() + assert status.done is True + assert status.success is True + mock_arm.assert_not_called() + mock_arm_with_retry.assert_not_called() + mock_submit_task.assert_not_called() + + +def test_panda_omny_pre_scan_arms_when_should_arm_true_via_allowlist(panda_omny): + """End-to-end: a scan_name matching arm_scan_allowlist still goes through the full arm/ + retry machinery exactly as with the default (None) allowlist.""" + dev = panda_omny + dev.pre_scan_timeout = 2 + dev.arm_scan_allowlist = ["flomni_fermat_scan"] + dev.scan_info.msg.info["scan_name"] = "flomni_fermat_scan" + dev.stage() + assert dev._should_arm_panda is True + with ( + mock.patch.object(dev, "_arm") as mock_arm, + mock.patch.object(dev, "_reset_panda") as mock_reset, + ): + _deliver_ready_after(dev, 0.05) + status = dev.on_pre_scan() + status.wait(timeout=4) + assert status.success is True + mock_arm.assert_called_once() + mock_reset.assert_not_called() + + +def test_panda_omny_complete_skips_wait_loop_when_should_arm_false(panda_omny): + """When this device was never armed for the current scan, on_complete must resolve + immediately without polling PandA or disarming it.""" + dev = panda_omny + dev._should_arm_panda = False + with ( + mock.patch.object(dev, "send_raw") as mock_send_raw, + mock.patch.object(dev, "_disarm") as mock_disarm, + mock.patch.object(dev.task_handler, "submit_task") as mock_submit_task, + ): + status = dev.on_complete() + assert status.done is True + assert status.success is True + mock_send_raw.assert_not_called() + mock_disarm.assert_not_called() + mock_submit_task.assert_not_called() + + +def test_panda_omny_complete_skips_wait_loop_when_should_arm_false_raw_stream_mode( + panda_omny_raw_stream, +): + """Same as test_panda_omny_complete_skips_wait_loop_when_should_arm_false, but on the + raw_stream_mode=True path that would otherwise dispatch to _on_complete_free_running.""" + dev = panda_omny_raw_stream + dev._should_arm_panda = False + with ( + mock.patch.object(dev, "send_raw") as mock_send_raw, + mock.patch.object(dev, "_disarm") as mock_disarm, + mock.patch.object(dev, "_flush_raw_stream_buffer") as mock_flush, + mock.patch.object(dev.task_handler, "submit_task") as mock_submit_task, + ): + status = dev.on_complete() + assert status.done is True + assert status.success is True + mock_send_raw.assert_not_called() + mock_disarm.assert_not_called() + mock_flush.assert_not_called() + mock_submit_task.assert_not_called() + + def test_panda_omny_raw_stream_signal_alias(panda_omny_raw_stream): all_signal_names = [name for name, _ in panda_omny_raw_stream.data.signals] assert "gate_detector_active" in all_signal_names -- 2.54.0 From aeefe5b2dece6c01f0c7951d1c54dd73b2f233b7 Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 22 Sep 2026 16:13:06 +0200 Subject: [PATCH 77/78] docs(flomni): batch-validate S00391-399, resolve burst-grouping and reduced-data-mode open questions Validated nine flomni_fermat_scan runs (S00391-399: single-frame, frames_per_trigger=3 burst, and reduced-data-mode groups, two aborted mid-scan) against rt_positions ground truth. All nine reproduce positions with R^2 >= 0.9999999 on both axes and no anomalous residuals. Resolves two open questions from sec 6: - Burst-mode grouping: frame_counter increments once per sub-frame trigger, not once per point -- point index is frame_counter // frames_per_trigger, confirmed at R^2 = 1.0000000 against S00395/396/398. - Reduced data mode: characterized empirically via S00399 -- keeps only gate_detector_active==1 rows (~99.5% vs ~39% of rows in normal continuous mode), roughly halving row count with no accuracy loss. Also nails down the abort-into-restage stale frame_counter mechanism more precisely: S00393 aborted with exactly 29 points recorded -> S00394 inherits a stale-29 prefix; S00397 (burst) aborted at 17 points x 3 sub-frames=51 -> S00398 inherits a stale-51 prefix. Both harmless (gate_detector_active==0 throughout the stale rows in both cases), and both an exact match between the aborted scan's last count and the next scan's stale value. Note: this batch does not exercise the arm_scan_allowlist fix (5590633) -- the running device-server process predates that commit -- though it wouldn't have changed anything here anyway since every scan in this batch is flomni_fermat_scan. Co-Authored-By: Claude Sonnet 5 --- .../AI_docs/PANDA_POSITION_VALIDATION.md | 107 +++++++++++++++--- 1 file changed, 89 insertions(+), 18 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md index 600fca43..b797064d 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md @@ -65,9 +65,15 @@ Under `entry/collection/devices/omny_panda_continuous/` in the master HDF5: 1. Filter to rows where `gate_detector_active == 1` (real exposure, not inter-point flight time). -2. Group the remaining rows by `frame_counter` (one group per scan point; - `frame_counter` ranges `0..num_points-1` for a single-frame-per-point - scan — see §6 for the burst case, still untested). +2. Group the remaining rows by **point** index. For a single-frame-per-point + scan (`frames_per_trigger == 1`), `frame_counter` directly ranges + `0..num_points-1` and *is* the point index. For a burst scan + (`frames_per_trigger > 1`), `frame_counter` increments once per + sub-frame trigger, not once per point (confirmed 2026-09-22 against real + `frames_per_trigger=3` scans, `S00395`/`S00396`/`S00398` — see §7): the + point index is `frame_counter // frames_per_trigger`, and *all* of a + point's sub-frames' gate-active rows get averaged together into that + point's one `rt_positions` value, not kept separate. 3. Average `interf_st_fzp_x`/`_y` and `cap_voltage_fzp_x`/`_y` within each group. @@ -171,22 +177,24 @@ suspecting the conversion factors. `.../timestamp`, much shorter — one entry per buffer flush, not per row, see `_flush_raw_stream_buffer_locked` in `panda_box_omny.py`). -## 6. Next steps (as of 2026-09-21, not yet done) +## 6. Next steps (as of 2026-09-21) -1. **Burst acquisition**: test the case where the number of PandA frames - equals the number of burst sub-frames (`frames_per_trigger > 1`), and RT - still logs exactly one `average_x_st_fzp`/`_y` per *point* (not per - sub-frame). This changes the grouping in §3.2 — open question going in: - does `frame_counter` increment once per sub-frame trigger (meaning - `frames_per_trigger` distinct `frame_counter` values need to be - aggregated per RT point) or once per point? Check - `panda_box_free_running_setup.md`'s completion-check description - (`COUNTER1` "increments on the detector-trigger's falling edge") against - how many falling edges occur per point when `frames_per_trigger > 1` - before assuming either way. -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. +1. **Burst acquisition** — RESOLVED 2026-09-22, see §7/§3.2: `frame_counter` + increments once per sub-frame trigger, not once per point; + `frame_counter // frames_per_trigger` is the point index, and all + sub-frames of a point get averaged together to match RT's one value per + point. Confirmed against real `frames_per_trigger=3` scans + (`S00395`/`S00396`/`S00398`), R² = 1.0000000 on both axes. +2. **Reduced data mode** — characterized empirically 2026-09-22, see §7 + (`S00399`): captures only rows with `gate_detector_active == 1` (or very + close to it — 99.5% vs. ~39% of rows in normal continuous mode), cutting + total row count roughly in half to a third while keeping the same + per-point exposure sample count and full positional accuracy (R² = + 1.0000000). The underlying PandA-side layout/config change that produces + this is not visible from BEC's `configuration` snapshot in the HDF5 file + (empty for both modes) — this section describes the observed effect on + the data, not the PandA-side mechanism; get that from whoever configured + it if it needs modifying. ## 7. Post-fix confirmation against live scans (2026-09-22) @@ -280,3 +288,66 @@ It's now only actually `Arm()`ed for `flomni_fermat_scan` via the new class of bug can even be triggered (fewer arm cycles → fewer abort→restage windows like `S00388`'s), independent of whatever the remaining root cause above turns out to be. + +## 8. Batch validation across `S00391`-`S00399` (2026-09-22) + +Nine `flomni_fermat_scan` runs at `/tmp/S00<391..399>/` on `x12sa-bec-001`, +run as three groups: `S00391`-`394` (single-frame, `frames_per_trigger=1`, +`393` aborted mid-scan), `S00395`-`398` (burst, `frames_per_trigger=3`, +`397` aborted mid-scan), `S00399` (burst, reduced data mode). Note: the +running `bec-device-server` process had been up since before commit +`5590633` (`arm_scan_allowlist`) landed, so it was still running the +pre-`arm_scan_allowlist` code for this whole batch — doesn't matter here +since every scan in this batch is `flomni_fermat_scan`, which +`arm_scan_allowlist` always arms anyway, but worth knowing: this batch +doesn't demonstrate that fix, only the pre-existing stale-`frame_counter` +fix (`3eb6e03`, which does predate the process start and was active). + +**Full position-recovery validation** (§3.2-3.4/§4 methodology, extended for +burst grouping per §6) against `rt_positions`, all nine scans: + +| Scan | mode | points | R² x / y | residual std (nm) x / y | max abs (nm) x / y | +|---|---|---|---|---|---| +| S00391 | single | 99 | 0.9999999 / 0.9999999 | 0.94 / 0.71 | 2.64 / 1.83 | +| S00392 | single | 98 | 0.9999999 / 0.9999999 | 0.95 / 0.76 | 2.75 / 2.09 | +| S00394 | single (post-abort) | 97 | 0.9999999 / 0.9999999 | 0.93 / 0.73 | 2.28 / 2.04 | +| S00395 | burst×3 | 98 | 1.0000000 / 1.0000000 | 0.47 / 0.55 | 0.98 / 1.18 | +| S00396 | burst×3 | 101 | 1.0000000 / 1.0000000 | 0.41 / 0.55 | 1.12 / 1.17 | +| S00398 | burst×3 (post-abort) | 99 | 1.0000000 / 1.0000000 | 0.45 / 0.54 | 1.32 / 1.13 | +| S00399 | burst×3, reduced data | 100 | 1.0000000 / 1.0000000 | 0.48 / 0.50 | 1.69 / 1.14 | + +No point had zero grouped samples in any scan; no residual outlier anywhere +close to the ~116 nm the pre-fix bug produced. Burst-mode R² came out even +tighter than single-frame mode (1.0000000 vs. 0.9999999), consistent with +averaging 3x more raw samples per point. + +**Stale-`frame_counter` prefix — two more clean, precisely-explained +repros**, both immediately following an abort, both harmless (all stale +rows have `gate_detector_active == 0`): + +- `S00393` aborted with `rt_positions` recording exactly 29 points (panda + `frame_counter` max = `29.0`, matching exactly). The very next scan, + `S00394`, starts with 37 rows reading a stale `29.0` before dropping to + `0` at row 37 — same signature as `S00388`, same stale value as `#393`'s + own last completed point. +- `S00397` (burst×3) aborted with `rt_positions` recording exactly 17 + points — 17×3 = 51 sub-frame counts, and panda `frame_counter` max there + is indeed `51.0`. The next scan, `S00398`, starts with 37 rows reading a + stale `51.0` before dropping to `0` at row 37 — same signature again, and + the stale value again matches `#397`'s own last completed sub-frame count + exactly. + +This nails down the trigger mechanism about as precisely as it can be nailed +down from data alone: the leftover `frame_counter` value inherited by the +next scan is *exactly* the aborted scan's last completed count (point count +for single-frame, point×frames_per_trigger for burst) — not some unrelated +stale value from further back. The remaining open question from §7 (why the +client-side filter doesn't strip these rows in the abort→restage case, given +`on_stage`/`_try_arm` both reset state normally beforehand) is unchanged by +this batch — still worth instrumenting if pursued further, though it +remains harmless in every case observed so far because the stale window +never overlaps a gated exposure. + +**Reduced data mode** (`S00399`) validated cleanly at full accuracy — see +§6 for the row-count/gate-fraction characterization. No contamination, no +missing points, same R² bar as the other burst scans. -- 2.54.0 From bc094dd172332d8ed89c8e8b05adec29689138bd Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 22 Sep 2026 16:19:45 +0200 Subject: [PATCH 78/78] fix(tests): resolve flomni/lamni CLI source paths via module __file__ test_flomni_cli_fovy_bounds_within_scan_bounds and test_lamni_cli_piezo_range_bounds_within_scan_bounds opened the CLI source files using a path hardcoded relative to the repo root, which only works if pytest's cwd happens to equal the repo root. In CI the job's cwd is one level above the checkout, so the same relative path raised FileNotFoundError. Resolve the path from the already-imported module's __file__ instead, which is independent of cwd. Co-Authored-By: Claude Sonnet 5 --- .../test_fov_bounds_match_scan_args.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/tests_bec_ipython_client/test_fov_bounds_match_scan_args.py b/tests/tests_bec_ipython_client/test_fov_bounds_match_scan_args.py index f5074b60..fcc3140f 100644 --- a/tests/tests_bec_ipython_client/test_fov_bounds_match_scan_args.py +++ b/tests/tests_bec_ipython_client/test_fov_bounds_match_scan_args.py @@ -18,6 +18,8 @@ strictly inside them -- not just checked once by hand -- so this class of import re from typing import get_type_hints +import csaxs_bec.bec_ipython_client.plugins.flomni.flomni as _flomni_cli_module +import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as _lamni_cli_module from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import SETUP_PROFILES from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan @@ -62,9 +64,7 @@ def test_flomni_gui_fovx_fovy_within_scan_bounds(): def test_flomni_cli_fovy_bounds_within_scan_bounds(): - src = open( - "csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py", encoding="utf-8" - ).read() + src = open(_flomni_cli_module.__file__, encoding="utf-8").read() m = re.search(r'" um".*?min_=([\d.]+), max_=([\d.]+)', src, re.DOTALL) assert m, "could not find the fovy _get_val() call in flomni.py -- did it move?" cli_min, cli_max = float(m.group(1)), float(m.group(2)) @@ -81,7 +81,7 @@ def test_lamni_gui_piezo_range_within_scan_bounds(): def test_lamni_cli_piezo_range_bounds_within_scan_bounds(): - src = open("csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py", encoding="utf-8").read() + src = open(_lamni_cli_module.__file__, encoding="utf-8").read() for axis, scan_param in (("X", "fovx"), ("Y", "fovy")): m = re.search( rf'" um".*?min_=([\d.]+), max_=([\d.]+)', src, re.DOTALL -- 2.54.0