Plan: fermat_asymmetry (x/y spacing ratio) for the Flomni Fermat scan¶
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¶
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:
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
fovxhalf-width as before. For y, generate/filter against an inflated half-widthlength_axis2 * fermat_asymmetry, then compress the accepted y-coordinate back down by dividing byfermat_asymmetrybefore storing the final position. - This keeps x spacing at
step(untouched), while y spacing becomesstep / fermat_asymmetry— "keep x spacing constant, scale y spacing by the ratio". Whenfermat_asymmetry == 1.0the 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"togui_config["Scan Parameters"](line 54-63). - Add a new
__init__parameter (nearstep, line 77):Store asfermat_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,self.fermat_asymmetry = fermat_asymmetryand document it in the docstringArgs:block. - Pass it through in
prepare_scan()'s call toget_flomni_fermat_spiral_pos(line 150-161): addfermat_asymmetry=self.fermat_asymmetry. - Update
get_flomni_fermat_spiral_pos(line 410-463): addfermat_asymmetry: float = 1.0parameter, implement the clamp (withlogger.warningwhen out of[MIN_FERMAT_ASYMMETRY, MAX_FERMAT_ASYMMETRY]) and the x/y-scaling math described above, replacing the directradius*sin/radius*cosfilter+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 bytomo_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 savedparamsdict (tomo_queue_mixin.py:898-899), so older queued jobs simply leave the livefermat_asymmetryvalue untouched — the same behavior every previously-added parameter already relies on. - Thread it into the per-projection scan call,
scan_kwargsdict (line 3478-3488): addfermat_asymmetry=self.fermat_asymmetry, and into the accompanyinglogger.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): addfermat_asymmetry=self.fermat_asymmetryto theget_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"toQUEUE_PARAM_NAMES(line 127-149, the tuple mirroringFlomni._TOMO_SCAN_PARAM_NAMES) and toDEFAULTS(line 151-173):"fermat_asymmetry": 1.0. Do not touchLAMNI_QUEUE_PARAM_NAMES(line 184) — LamNI is explicitly excluded. - Add a token builder to the
token_buildersdict in_build_params_panel(~line 463-470), matching the existingtomo_shellstepentry's style:"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"toSETUP_PROFILES["flomni"]["field_order"](~line 2280-2291), right after"tomo_shellstep". - Add
"fermat_asymmetry"toSETUP_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'svalueChangedsignal to_update_fermat_position_preview) picks it up automatically — no new signal-wiring code needed. - Update
_compute_fermat_positions_flomni()(line 2162-2189): readfermat_asymmetry = params.get("fermat_asymmetry", 1.0)and passfermat_asymmetry=fermat_asymmetryinto theFlomniFermatScan.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 > 1and< 1, the ratio of median nearest-neighbor spacing along x vs. along y (viascipy.spatial.cKDTree, same technique already used in_estimate_corridor_size) approximately matches the requested ratio, whilemax(abs(x)) <= fovx/2andmax(abs(y)) <= fovy/2still hold. - A value outside
[0.1, 10](e.g.100) is silently clamped rather than raising, and does not change the FOV bounds.
Verification¶
cd /sls/x12sa/config/bec/production/csaxs_bec && python -m pytest tests/tests_scans/test_flomni_fermat_scan.py -v- Sanity-check the position generator directly for a few ratios:
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()) - In a BEC client session against the Flomni deployment: confirm
flomni.fermat_asymmetryreads/writes via the global var (persists across a client restart), shows up inflomni.tomo_parameters(), and thatscans.flomni_fermat_scan(..., fermat_asymmetry=2.0)is accepted by the queue (client-sideScanInputValidatorand server-sideScanAssemblerboth validate against the newScanArgument). - Open the
tomo_paramswidget 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, seecsaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md.LamniFermatScan/LamNI— explicitly excluded, not planned.