Skip to content

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 fovx half-width as before. For y, generate/filter against an inflated half-width length_axis2 * fermat_asymmetry, then compress the accepted y-coordinate back down by dividing by fermat_asymmetry before storing the final position.
  • This keeps x spacing at step (untouched), while y spacing becomes step / fermat_asymmetry — "keep x spacing constant, scale y spacing by the ratio". When fermat_asymmetry == 1.0 the 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" to gui_config["Scan Parameters"] (line 54-63).
  • Add a new __init__ parameter (near step, line 77):
    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,
    
    Store as self.fermat_asymmetry = fermat_asymmetry and document it in the docstring Args: block.
  • Pass it through in prepare_scan()'s call to get_flomni_fermat_spiral_pos (line 150-161): add fermat_asymmetry=self.fermat_asymmetry.
  • Update get_flomni_fermat_spiral_pos (line 410-463): add fermat_asymmetry: float = 1.0 parameter, implement the clamp (with logger.warning when out of [MIN_FERMAT_ASYMMETRY, MAX_FERMAT_ASYMMETRY]) and the x/y-scaling math described above, replacing the direct radius*sin/radius*cos filter+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 by tomo_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 saved params dict (tomo_queue_mixin.py:898-899), so older queued jobs simply leave the live fermat_asymmetry value untouched — the same behavior every previously-added parameter already relies on.
  • Thread it into the per-projection scan call, scan_kwargs dict (line 3478-3488): add fermat_asymmetry=self.fermat_asymmetry, and into the accompanying logger.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): add fermat_asymmetry=self.fermat_asymmetry to the get_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" to QUEUE_PARAM_NAMES (line 127-149, the tuple mirroring Flomni._TOMO_SCAN_PARAM_NAMES) and to DEFAULTS (line 151-173): "fermat_asymmetry": 1.0. Do not touch LAMNI_QUEUE_PARAM_NAMES (line 184) — LamNI is explicitly excluded.
  • Add a token builder to the token_builders dict in _build_params_panel (~line 463-470), matching the existing tomo_shellstep entry'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" to SETUP_PROFILES["flomni"]["field_order"] (~line 2280-2291), right after "tomo_shellstep".
  • Add "fermat_asymmetry" to SETUP_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's valueChanged signal to _update_fermat_position_preview) picks it up automatically — no new signal-wiring code needed.
  • Update _compute_fermat_positions_flomni() (line 2162-2189): read fermat_asymmetry = params.get("fermat_asymmetry", 1.0) and pass fermat_asymmetry=fermat_asymmetry into the FlomniFermatScan.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 > 1 and < 1, the ratio of median nearest-neighbor spacing along x vs. along y (via scipy.spatial.cKDTree, same technique already used in _estimate_corridor_size) approximately matches the requested ratio, while max(abs(x)) <= fovx/2 and max(abs(y)) <= fovy/2 still hold.
  • A value outside [0.1, 10] (e.g. 100) is silently clamped rather than raising, and does not change the FOV bounds.

Verification

  1. cd /sls/x12sa/config/bec/production/csaxs_bec && python -m pytest tests/tests_scans/test_flomni_fermat_scan.py -v
  2. 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())
    
  3. In a BEC client session against the Flomni deployment: confirm flomni.fermat_asymmetry reads/writes via the global var (persists across a client restart), shows up in flomni.tomo_parameters(), and that scans.flomni_fermat_scan(..., fermat_asymmetry=2.0) is accepted by the queue (client-side ScanInputValidator and server-side ScanAssembler both validate against the new ScanArgument).
  4. Open the tomo_params widget 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, see csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md.
  • LamniFermatScan / LamNI — explicitly excluded, not planned.