docs(flomni): add plan for Fermat scan x/y spacing asymmetry
CI for csaxs_bec / test (push) Successful in 2m20s

Introduces fermat_asymmetry, a ratio of x spacing to y spacing for the
Flomni Fermat-spiral scan: x spacing stays fixed at step while y spacing
scales by the ratio, with the requested field of view preserved (so the
total position count changes with the ratio). Covers the scan class,
global var/CLI, queuing, and the tomo_params Qt widget. Plan only, not
implemented -- written during a live beamtime experiment.

Also flags this for a future OMNY port (same Fermat-spiral algorithm),
explicitly excluding LamNI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW873PFzsY4N1r65aQgfFn
This commit is contained in:
x12sa
2026-09-16 20:08:47 +02:00
co-authored by Claude Sonnet 5
parent df433c88c4
commit 861d2f2ff0
2 changed files with 265 additions and 0 deletions
@@ -0,0 +1,40 @@
# TODO: port `fermat_asymmetry` (x/y spacing ratio) to OMNY once it lands on Flomni
Flomni is getting a new Fermat-spiral scan parameter, `fermat_asymmetry`
(ratio of x spacing to y spacing; default 1.0 = symmetric, keeps x spacing
fixed and scales y spacing by the ratio while preserving the requested FOV).
See `docs/plans/flomni-fermat-asymmetry.md` for the full design and the
exact math.
OMNY's `OmnyFermatScan.get_omny_fermat_spiral_pos`
(`csaxs_bec/scans/omny_fermat_scan.py:275-333`) uses the byte-for-byte same
spiral algorithm as Flomni's `get_flomni_fermat_spiral_pos` (`radius =
step * 0.57 * sqrt(ii)`, same FOV-window filter), so the same change ports
directly: add `fermat_asymmetry` to `OmnyFermatScan.__init__`
(`omny_fermat_scan.py:63-79`), thread it through
`get_omny_fermat_spiral_pos`, and mirror the CLI-side plumbing on `OMNY`
(`csaxs_bec/bec_ipython_client/plugins/omny/omny.py`): a
`tomo_shellstep`-style property pair (OMNY hand-writes
`get_global_var`/`set_global_var` property pairs, e.g. `omny.py:671-679`,
rather than using the `_GlobalVarParam` descriptor Flomni uses), the
`scan_kwargs` dict in the per-angle scan call (~`omny.py:1282-1284`), and
`tomo_parameters()` (~`omny.py:1293-1341`).
## Difference from Flomni to account for when porting
OMNY has no tomo-queue system — `OMNY` does not mix in `TomoQueueMixin` and
has no `_TOMO_SCAN_PARAM_NAMES` (unlike `Flomni`/`LamNI`). So there is no
queue-snapshot list to add the new parameter to on OMNY's side, and no
`tomo_params` Qt widget profile for OMNY today either (`SETUP_PROFILES` in
`tomo_params.py` only has `"flomni"` and `"lamni"` entries) — the widget
part of the Flomni plan doesn't apply until/unless OMNY gets its own queue
+ widget support. Scope the OMNY port to: the scan class parameter, the
global var, and `tomo_parameters()` display/prompt only.
## Explicitly not for LamNI
This feature was scoped to Flomni (and, via this TODO, OMNY) only; do not
add it to `LamniFermatScan` / `LamNI` as part of this port.
Not scoped/designed further here beyond the pointers above — flagging it so
it isn't lost, same as the other `TODO_*.md` files in this folder.
+225
View File
@@ -0,0 +1,225 @@
# Plan: `fermat_asymmetry` (x/y spacing ratio) for the Flomni Fermat scan
Status: **not implemented** (written during live beamtime operation on request; no
code changes made — see conversation this plan came from).
## 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`:
```python
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):
```python
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:
```python
"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:
```python
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.