feat(flomni): add randomize_offset scan argument to flomni_fermat_scan
CI for csaxs_bec / test (push) Failing after 2m12s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017szgTwuHG65YhjoBGiK2Gj
This commit is contained in:
x12saandClaude Sonnet 5 committed 2026-09-20 13:49:20 +02:00
1 parent c66253709a
commit 19cfee3e79
4 files changed
+98 -5

No files matched your search

@@ -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.
+9 -1
View File
@@ -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):
+9 -4
View File
@@ -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
@@ -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)])