From 19cfee3e791067899f66d4c10716851d792f4fcb Mon Sep 17 00:00:00 2001 From: x12sa Date: Sun, 20 Sep 2026 13:49:20 +0200 Subject: [PATCH] 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)])