diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md new file mode 100644 index 00000000..502aa887 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_fermat_asymmetry_port.md @@ -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. diff --git a/docs/plans/flomni-fermat-asymmetry.md b/docs/plans/flomni-fermat-asymmetry.md new file mode 100644 index 00000000..0ea0e338 --- /dev/null +++ b/docs/plans/flomni-fermat-asymmetry.md @@ -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.