feat(flomni): add randomize_offset scan argument to flomni_fermat_scan
CI for csaxs_bec / test (push) Failing after 2m12s
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:
1 parent
c66253709a
commit
19cfee3e79
4 files changed
+98
-5
No files matched your search
+47
@@ -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.
|
||||
@@ -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):
|
||||
|
||||
@@ -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)])
|
||||
Reference in new issue
Block a user