Files
csaxs_bec/docs/plans/fermat-scan-randomization.md
T
x01dcandClaude Sonnet 5 5e26b86d03
CI for csaxs_bec / test (push) Successful in 2m14s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW
2026-09-17 12:30:13 +02:00

7.2 KiB

Fix needed: restore random sub-step offset in Fermat spiral scans (OMNY, flOMNI, LamNI)

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

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:

_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:

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.