docs(flomni): add plan for Fermat scan x/y spacing asymmetry
CI for csaxs_bec / test (push) Successful in 2m20s
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:
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user