From bac3fa875426d7f0cccf94132d84c40bce3eb634 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 16 Jul 2026 13:39:08 +0200 Subject: [PATCH] feat(lamni,bec_widgets): generalize tomo-params GUI to lamni, fix two sub_tomo_scan angle bugs Generalize TomoParamsWidget to lamni via a SETUP_PROFILES mechanism (setup detection, per-setup field lists/order, a new Offsets section, a setup-agnostic "Duplicate job" queue button), fixing two latent TomoQueueDialog bugs that silently mishandled lamni jobs along the way. While verifying the GUI's projection-count preview against the CLI, found two real, pre-existing bugs in LamNI.sub_tomo_scan() unrelated to the GUI itself: a duplicate closing angle every sub-tomogram (360=0 degrees), and a phase offset computed from the raw stepsize instead of the achievable one, breaking the equally-spaced-when- combined guarantee for sub-tomogram pairs/quads/the full set. Both fixed to mirror Flomni's existing, correct equivalents. Also fills in lamni's user documentation with the queue/command-job/ at-each-angle-hook system, which it previously lacked entirely, mirroring flomni.md's coverage. Co-Authored-By: Claude Sonnet 5 --- .../LamNI/AI_docs/TOMO_PARAMS_GUI_PORT.md | 162 ++++ .../plugins/LamNI/AI_docs/TOMO_QUEUE_PORT.md | 3 +- .../bec_ipython_client/plugins/LamNI/lamni.py | 86 ++- .../widgets/tomo_params/tomo_params.py | 689 +++++++++++++----- docs/user/ptychography/lamni.md | 185 +++++ .../test_lamni_tomo_angles.py | 127 ++++ .../test_lamni_tomo_params_widget_math.py | 167 +++++ 7 files changed, 1223 insertions(+), 196 deletions(-) create mode 100644 csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/TOMO_PARAMS_GUI_PORT.md create mode 100644 tests/tests_bec_ipython_client/test_lamni_tomo_angles.py create mode 100644 tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/TOMO_PARAMS_GUI_PORT.md b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/TOMO_PARAMS_GUI_PORT.md new file mode 100644 index 0000000..92b6ae6 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/TOMO_PARAMS_GUI_PORT.md @@ -0,0 +1,162 @@ +# Tomo-params GUI generalization + two sub_tomo_scan() angle bugs fixed + +Companion to `AI_docs/TOMO_QUEUE_PORT.md` (the backend port this GUI work +builds on) and `csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/FLOMNI_TO_LAMNI_COMPARISON.md` +(which identified `TomoParamsWidget` as flomni-gated). This session's +follow-up work: generalized the widget to also support lamni, then — while +verifying the widget's projection-count preview against the CLI — found and +fixed two real, pre-existing bugs in `LamNI.sub_tomo_scan()` that predate +this session and affected every lamni tomogram ever run with a non-trivially- +achievable `tomo_angle_stepsize`. + +**Status:** All of the below implemented, tested, and live-verified against +the running simulated lamni deployment. Not yet committed as of this doc. + +## 1. GUI: `TomoParamsWidget` generalized to lamni + +`csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py`'s `SETUP_PROFILES` +dict (one entry per setup) now drives nearly everything setup-specific: + +- **Setup detection**: `_detect_setup()` checks for `fsamroy` (flomni) or + `lsamrot` (lamni) — real discriminator devices, same pattern the CLI side + already used, just generalized from a yes/no flomni check. +- **Per-setup field lists**: `fov_fields`/`stitch_fields`/`manual_shift_fields`/ + `piezo_range_fields`/`offset_fields`, each a list of `(key, label, min, max, + decimals)` tuples the widget iterates to build/validate/read fields — + small per-concept lists, not a generic form builder. +- **`field_order`**: an explicit per-profile list of section tokens + (`_build_params_panel` is now a dispatch loop over it). flomni's order is + its unchanged, pre-existing order (verified as a no-op via a regression + test); lamni's order mirrors `lamni.tomo_parameters()`'s own print/edit + order exactly (counting time → shell step → piezo range → stitching → + stitch overlap → circular FOV → reconstruct folder → frames/trigger → + offsets → at-each-angle hook). +- **Offsets section** (lamni only): `tomo_fovx_offset`/`tomo_fovy_offset` + (from `LamNIAlignmentMixin`) plus `manual_shift_x`/`_y`, with a "?" help + button. These are alignment values, not scan parameters — shown/editable + here but **excluded from queue job snapshots** (`add_edited_to_queue()`/ + `TomoQueueDialog.add_to_queue()` now explicitly filter to + `self._profile["param_names"]`), same treatment as flomni's + `corr_pos_y`/`corr_angle_y` (also alignment-only, also not shown by this + widget). Needed dedicated accessors (`_lamni_get_tomo_fov_offset`/ + `_lamni_set_tomo_fov_offset`) because both axes are packed into one + combined global var (`tomo_fov_offset = [x_um, y_um]`, exposed in mm) — + the widget's usual direct-global-var-by-name mechanism doesn't work for + them. +- **Lamni projection math** (`_lamni_compute_type1`/`_lamni_requested_to_stepsize`): + see §2 below — simplified once the backend bug was fixed, now the same + shape as flomni's math (`N*8`, base angle 360 instead of 180). +- **`TomoQueueDialog` bugs fixed** (pre-existing, silently wrong for any + lamni job before this session, since no lamni job had ever been queued + through the GUI): `_format_projections()` always used flomni's fixed-180 + formula regardless of which setup a job belonged to; `_job_tooltip()` + iterated a hardcoded flomni-only param-name tuple, so a lamni job's + tooltip silently showed no params at all. Both now duck-type off the + job's own params dict (`"tomo_angle_range" in params` distinguishes a + flomni job from a lamni job) rather than needing to know which setup is + active. +- **New "Duplicate selected" queue button**: copies the selected job (any + status, any kind — tomo or command) to the end of the queue with a fresh + id, `status="pending"`, and `_dup` appended to the label. Setup-agnostic, + no profile-specific logic. + +## 2. Backend: two real bugs found and fixed in `LamNI.sub_tomo_scan()` + +Surfaced while checking the GUI's projection-count preview against +`lamni.tomo_parameters()`'s wizard — both predate this session and are +independent of the GUI work; they affect the CLI (and therefore every real +scan) regardless of whether the GUI is ever used. + +### 2a. Duplicate closing angle + +`sub_tomo_scan()` generated each sub-tomogram's angles with +`np.linspace(start, start+360, num=int(360/stepsize)+1, endpoint=True)`. For +a full 360° sweep, `start+360` is the *same physical angle* as `start` +(360°≡0°), so every sub-tomogram re-measured its own starting angle a +second time. Confirmed numerically: requesting 66 projections set +`tomo_angle_stepsize=43.6364`; the scan then actually ran **72** acquisitions +(9/sub-tomogram × 8), not 66 — `tomo_parameters()`'s own "Resulting in +number of projections" printout was never accurate for *any* input, since it +used plain float division with no `int()` truncation and no duplicate-point +awareness. + +Fix: new `LamNI._tomo_type1_actual_grid()` (mirrors +`Flomni._tomo_type1_actual_grid()`, base angle 360 instead of 180, no `+1` +since the closing point is a duplicate not a new measurement) is now the +single source of truth for `N`/achievable step/total, used by +`sub_tomo_scan()` (now `num=N, endpoint=False`), `tomo_parameters()`, and +`write_pdf_report()` alike — no more three independent, disagreeing +formulas. + +### 2b. Phase offset computed from the wrong step + +Separately, `sub_tomo_scan()`'s inter-sub-tomogram phase offset was +`self.tomo_angle_stepsize / 8.0 * offsets[subtomo_number]` — using the +**raw, requested** stepsize, not the achievable one (`360/N` after `int()` +truncation). These only coincide when `360/stepsize` happens to already be a +whole number. Flomni's equivalent (`_subtomo_angle_plan()`) already gets +this right and says why in its own comment: *"N/step not guaranteed to be a +whole/exact division of the configured tomo_angle_stepsize ... This +corrected step ... is used for BOTH the per-point ramp AND the +inter-sub-tomogram phase offsets."* Lamni's phase offset never got that +treatment. + +Verified numerically (stepsize=43.6364, achievable step=45): combining +sub-tomograms in pairs `(1,2)`/`(3,4)`/`(5,6)`/`(7,8)`, quads +`(1,2,3,4)`/`(5,6,7,8)`, or all 8 produced **unevenly spaced** angles with +the raw-stepsize phase (e.g. pair spacing alternating 21.8°/23.2° instead of +a uniform 22.5°) — defeating the entire point of the bit-reversal +interlacing scheme, which exists specifically to give progressively finer, +*evenly* sampled combined tomograms at each combination level. Fixed by +computing the phase from the achievable step instead, exactly like flomni. + +Also added, matching flomni's wizard UX: `tomo_parameters()` now prints +`"Note: N projections does not divide evenly into 8 equally spaced +sub-tomograms over 360 degrees; adjusted to the nearest achievable total of +M projections..."` when the requested and achievable totals differ — this +is **display only**, not a substitute for the `sub_tomo_scan()` fix: the +stored `tomo_angle_stepsize` is never rewritten to the achievable value (in +flomni either — verified by reading its wizard code), so `sub_tomo_scan()` +must keep re-deriving the achievable grid every time it runs, regardless of +which code path set `tomo_angle_stepsize` (wizard, GUI Submit, queue-job +restore, or a direct script assignment). + +## Explicitly deferred (carried over / reconfirmed) + +- `zero_deg_reference_at_each_subtomo` for lamni — not implemented anywhere + in `lamni.py` (no property, no scan logic). Mirko confirmed: separate + future task, backend first then GUI. +- Shared angle-calculation code between flomni/lamni (`sub_tomo_scan`/ + `_golden`/`tomo_scan` dispatch) — Mirko confirmed: separate follow-up, not + folded into this GUI-focused work, despite the two setups' type-1 math now + having the same *shape* (`N*8`, differing only in base angle). +- `LamniWebpageGenerator` completion — unrelated gap, still open. + +## Verification + +- New tests: `tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py` + (16 tests: profile field_order/param_names/defaults guards, offset + getter/setter round-trip including a same-axis-doesn't-clobber-the-other + check) and `test_lamni_tomo_angles.py` (48 tests: no-duplicate-angle check + per sub-tomogram/stepsize, and the pair/quad/full-8 equally-spaced + invariant per stepsize). The equally-spaced tests were sanity-checked by + temporarily reverting the phase-offset fix and confirming 9/12 of them + fail as expected, then restoring it. +- Existing pinned test `test_tomo_params_widget_math.py` (flomni's math) + untouched and still passing. +- Full offline suite (`pytest tests/tests_bec_ipython_client tests/tests_scans`): + 185 passed. +- Live-sim verification (plain scripts against the running simulated lamni + deployment, snapshotting/restoring `tomo_queue`/`tomo_progress` around + each run): + - Real `sub_tomo_scan(1)` with a coarse stepsize (170°, N=2): exactly 2 + real acquisitions (`[0.0, 180.0]`), no duplicate; `tomo_parameters()`'s + display matched (16 = 2×8) after the fix (was previously wrong for + every input). + - Real `sub_tomo_scan(1)` + `sub_tomo_scan(2)` with a non-achievable + stepsize (109.09°, N=3): combined pair sorted to + `[0, 60, 120, 180, 240, 300]`, spacing exactly 60° throughout — + confirms the phase-offset fix holds against real (not just mocked) + execution. +- No live GUI verification performed (per Mirko's instruction) — verified + manually by Mirko instead. diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/TOMO_QUEUE_PORT.md b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/TOMO_QUEUE_PORT.md index 9531ad5..6137548 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/TOMO_QUEUE_PORT.md +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/TOMO_QUEUE_PORT.md @@ -74,7 +74,8 @@ one-line addition to `LamNI._TOMO_QUEUE_MOVE_DEVICES`. ## Explicitly deferred -- `TomoParamsWidget` GUI changes — still flomni-gated (`_check_flomni_available()`). +- `TomoParamsWidget` GUI changes — **done, see `AI_docs/TOMO_PARAMS_GUI_PORT.md`** + (was still flomni-gated when this doc was first written). - `LamniWebpageGenerator` completion — separate, unrelated gap (see `FLOMNI_TO_LAMNI_COMPARISON.md` §4). diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index 7743124..c095998 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -817,21 +817,48 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools # Tomo scan orchestration # ------------------------------------------------------------------ + def _tomo_type1_actual_grid(self) -> tuple[int, float, int]: + """Compute the actual (achievable) tomo_type==1 grid from the + currently stored self.tomo_angle_stepsize -- the SAME way + sub_tomo_scan() does it. Returns (N, step, total_projections): N + unique angles per sub-tomogram, achievable per-projection step, + N*8 total. + + A full 360-degree sweep's closing angle (start_angle + 360) is the + same physical angle as its start (360 deg == 0 deg), so N (not N+1) + is the right point count for one sub-tomogram -- see + sub_tomo_scan()'s linspace call, endpoint=False for the same reason. + Mirrors Flomni._tomo_type1_actual_grid() (base angle 360 instead of + 180, no "+1": flomni's own 180-degree-span scheme never had a + self-duplicating endpoint to begin with). + """ + N = int(360.0 / self.tomo_angle_stepsize) + step = 360.0 / N + return N, step, N * 8 + def sub_tomo_scan(self, subtomo_number, start_angle=None): """Perform one sub-tomogram (tomo_type 1 only).""" self._write_subtomo_to_scilog(subtomo_number) + N, achievable_step, total_projections = self._tomo_type1_actual_grid() + if start_angle is None: + # Phase offset must be a fraction of the ACHIEVABLE step (after + # int() truncation), not the raw configured tomo_angle_stepsize -- + # they only coincide when 360/tomo_angle_stepsize happens to + # already be a whole number. Using the raw value here breaks the + # interlacing scheme: sub-tomograms combined in pairs/quads/all-8 + # land on unevenly-spaced angles instead of the intended + # progressively finer, evenly-spaced grid at every combination + # level. Mirrors Flomni._subtomo_angle_plan() exactly. offsets = {1: 0, 2: 4, 3: 2, 4: 6, 5: 1, 6: 5, 7: 3, 8: 7} - start_angle = self.tomo_angle_stepsize / 8.0 * offsets[subtomo_number] + start_angle = achievable_step / 8.0 * offsets[subtomo_number] angle_end = start_angle + 360 - angles = np.linspace( - start_angle, - angle_end, - num=int(360 / self.tomo_angle_stepsize) + 1, - endpoint=True, - ) + # endpoint=False: a full 360-degree sweep must NOT re-measure both + # start_angle and start_angle+360 -- they're the same physical angle, + # unlike a [0, 180) span's two distinct endpoints. + angles = np.linspace(start_angle, angle_end, num=N, endpoint=False) if not (subtomo_number % 2): angles = np.flip(angles) @@ -840,12 +867,11 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self.progress["tomo_type"] = "Equally spaced sub-tomograms" self.progress["subtomo"] = subtomo_number self.progress["subtomo_projection"] = np.where(angles == angle)[0][0] - self.progress["subtomo_total_projections"] = 360 / self.tomo_angle_stepsize - self.progress["projection"] = ( - (subtomo_number - 1) * self.progress["subtomo_total_projections"] - + self.progress["subtomo_projection"] - ) - self.progress["total_projections"] = 360 / self.tomo_angle_stepsize * 8 + self.progress["subtomo_total_projections"] = N + self.progress["projection"] = (subtomo_number - 1) * N + self.progress[ + "subtomo_projection" + ] + self.progress["total_projections"] = total_projections self.progress["angle"] = angle self._tomo_scan_at_angle(angle, subtomo_number) @@ -1154,8 +1180,13 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools print("") if self.tomo_type == 1: print("\x1b[1mTomo type 1:\x1b[0m 8 equally spaced sub-tomograms (360 deg)") - print(f"Angular step within sub-tomogram: {self.tomo_angle_stepsize} degrees") - print(f"Resulting in number of projections: {360/self.tomo_angle_stepsize*8}") + # _tomo_type1_actual_grid() is the same computation sub_tomo_scan() + # actually runs -- reading self.tomo_angle_stepsize/naive division + # directly here previously showed a number that didn't match what + # a real scan acquired (see the helper's docstring). + _, achievable_step, total_projections = self._tomo_type1_actual_grid() + print(f"Angular step within sub-tomogram: {achievable_step} degrees") + print(f"Resulting in number of projections: {total_projections}") elif self.tomo_type == 2: print("\x1b[1mTomo type 2:\x1b[0m Golden ratio tomography") print(f"Sorted in bunches of: {self.golden_ratio_bunch_size}") @@ -1207,11 +1238,23 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools self.tomo_type = self._get_val("Tomography type", self.tomo_type, int) if self.tomo_type == 1: - tomo_numberofprojections = self._get_val( - "Number of projections", 360 / self.tomo_angle_stepsize * 8, int - ) + _, _, current_total = self._tomo_type1_actual_grid() + tomo_numberofprojections = self._get_val("Number of projections", current_total, int) self.tomo_angle_stepsize = 360 / tomo_numberofprojections * 8 - print(f"Angular step in a subtomogram: {self.tomo_angle_stepsize}") + # Report what was ACTUALLY achieved, via the same helper + # sub_tomo_scan() itself uses -- not the raw value just typed in, + # which can silently disagree with what the scan will actually + # run (mirrors Flomni.tomo_parameters()'s equivalent note). + _, achievable_step, actual_total = self._tomo_type1_actual_grid() + if actual_total != tomo_numberofprojections: + print( + f"Note: {tomo_numberofprojections} projections does not divide evenly " + "into 8 equally spaced sub-tomograms over 360 degrees; adjusted to the " + f"nearest achievable total of {actual_total} projections to keep the " + "angular grid uniform." + ) + print(f"Angular step in a subtomogram: {achievable_step}") + print(f"Actual number of projections: {actual_total}") elif self.tomo_type == 2: while True: @@ -1280,15 +1323,16 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools piezo_range = f"{self.lamni_piezo_range_x:.2f}/{self.lamni_piezo_range_y:.2f}" stitching = f"{self.lamni_stitch_x:.2f}/{self.lamni_stitch_y:.2f}" dataset_id = str(self.client.queue.next_dataset_number) + _, _, report_total_projections = self._tomo_type1_actual_grid() content = [ f"{'Sample Name:':<{padding}}{self.sample_name:>{padding}}\n", f"{'Measurement ID:':<{padding}}{str(self.tomo_id):>{padding}}\n", f"{'Dataset ID:':<{padding}}{dataset_id:>{padding}}\n", f"{'Sample Info:':<{padding}}{'Sample Info':>{padding}}\n", f"{'e-account:':<{padding}}{str(self.client.username):>{padding}}\n", - f"{'Number of projections:':<{padding}}{int(360 / self.tomo_angle_stepsize * 8):>{padding}}\n", + f"{'Number of projections:':<{padding}}{report_total_projections:>{padding}}\n", f"{'First scan number:':<{padding}}{self.client.queue.next_scan_number:>{padding}}\n", - f"{'Last scan number approx.:':<{padding}}{self.client.queue.next_scan_number + int(360 / self.tomo_angle_stepsize * 8) + 10:>{padding}}\n", + f"{'Last scan number approx.:':<{padding}}{self.client.queue.next_scan_number + report_total_projections + 10:>{padding}}\n", f"{'Current photon energy:':<{padding}}{dev.mokev.read(cached=True)['value']:>{padding}.4f}\n", f"{'Exposure time:':<{padding}}{self.tomo_countingtime:>{padding}.2f}\n", f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:>{padding}.2f}\n", diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index fa6812c..7e5610c 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -1,10 +1,11 @@ """ -TomoParamsWidget – BEC widget for editing FlOMNI tomo scan parameters and -managing the tomo scan queue. +TomoParamsWidget – BEC widget for editing tomo scan parameters and managing +the tomo scan queue, for whichever cSAXS tomography setup (flomni, lamni, ...) +is active in the current BEC session -- see ``SETUP_PROFILES``. Parameters are stored as BEC global vars; this widget reads and writes them via ``self.client.get_global_var`` / ``self.client.set_global_var``, which -is the same mechanism used by the ``Flomni`` CLI object. +is the same mechanism used by the ``Flomni``/``LamNI`` CLI objects. Polling ------- @@ -32,14 +33,15 @@ projection — but Submit asks for confirmation. Queue execution --------------- -``tomo_queue_execute()`` is a blocking Flomni CLI method that cannot be -called directly from the GUI server process. This widget manages the queue +``tomo_queue_execute()`` is a blocking CLI method (see ``TomoQueueMixin``) +that cannot be called directly from the GUI server process. This widget manages the queue via direct global-var manipulation (add / delete / clear) and shows an explicit hint for executing it from the CLI. """ from __future__ import annotations +import copy import datetime import uuid from typing import Any, Optional @@ -143,6 +145,56 @@ DEFAULTS: dict[str, Any] = { "at_each_angle_hook": None, } +# Exact tuple from LamNI._TOMO_SCAN_PARAM_NAMES (source of truth in lamni.py). +# No tomo_angle_range/single_point_*/zero_deg_reference_at_each_subtomo -- +# lamni is laminography: always 360 degrees, no single-point acquisition, no +# per-subtomo zero-deg reference. lamni_piezo_range_x/y have no flomni +# equivalent; manual_shift_x is lamni-only (flomni only exposes shift_y). +LAMNI_QUEUE_PARAM_NAMES = ( + "tomo_countingtime", + "tomo_shellstep", + "tomo_circfov", + "lamni_stitch_x", + "lamni_stitch_y", + "tomo_stitch_overlap", + "ptycho_reconstruct_foldername", + "manual_shift_x", + "manual_shift_y", + "lamni_piezo_range_x", + "lamni_piezo_range_y", + "frames_per_trigger", + "tomo_type", + "tomo_angle_stepsize", + "golden_ratio_bunch_size", + "golden_max_number_of_projections", + "golden_projections_at_0_deg_for_damage_estimation", + "corridor_size", + "at_each_angle_hook", +) + +# Read directly off each LamNI property getter's None-fallback in lamni.py. +LAMNI_DEFAULTS: dict[str, Any] = { + "tomo_countingtime": 0.1, + "tomo_shellstep": 1, + "tomo_circfov": 0.0, + "lamni_stitch_x": 0, + "lamni_stitch_y": 0, + "tomo_stitch_overlap": 0.2, + "ptycho_reconstruct_foldername": "ptycho_reconstruct", + "manual_shift_x": 0.0, + "manual_shift_y": 0.0, + "lamni_piezo_range_x": 20, + "lamni_piezo_range_y": 20, + "frames_per_trigger": 1, + "tomo_type": 1, + "tomo_angle_stepsize": 10.0, + "golden_ratio_bunch_size": 20, + "golden_max_number_of_projections": 1000.0, + "golden_projections_at_0_deg_for_damage_estimation": 0, + "corridor_size": -1, + "at_each_angle_hook": None, +} + def _hline() -> QFrame: line = QFrame() @@ -156,7 +208,8 @@ def _hline() -> QFrame: class TomoParamsWidget(BECWidget, QWidget): """ - Interactive GUI for FlOMNI tomo scan parameter editing and queue management. + Interactive GUI for tomo scan parameter editing and queue management, for + whichever setup (see ``SETUP_PROFILES``) is active in this BEC session. Layout ------ @@ -196,13 +249,13 @@ class TomoParamsWidget(BECWidget, QWidget): self._pw: dict[str, QWidget] = {} # key -> input widget self._queue_dlg: Optional["TomoQueueDialog"] = None self._busy_banner: Optional[QLabel] = None - # check whether the FlOMNI setup is active before building the UI - self._flomni_available = self._check_flomni_available() + # detect which known setup (if any) is active before building the UI + self._profile = SETUP_PROFILES.get(self._detect_setup()) self._build_ui() self._poll_timer = QTimer(self) self._poll_timer.setInterval(self._POLL_INTERVAL_MS) self._poll_timer.timeout.connect(self._on_poll) - if self._flomni_available: + if self._profile is not None: self._poll_timer.start() self.refresh() # live beamline-busy banner: react to scan-queue changes as they @@ -217,16 +270,21 @@ class TomoParamsWidget(BECWidget, QWidget): # ── setup detection ────────────────────────────────────────────────────── - def _check_flomni_available(self) -> bool: + def _detect_setup(self) -> Optional[str]: """ - Return True if the FlOMNI setup is active in this BEC session. - Uses ``fsamroy`` (the FlOMNI-specific rotation stage) as the - discriminator — it is absent in OMNY and LamNI sessions. + Return the key of whichever known setup (see ``SETUP_PROFILES``) is + active in this BEC session, or None if none is detected. Each + profile's ``discriminator_attr`` is a device unique to that setup + (e.g. flomni's own rotation stage, ``fsamroy``) -- a BEC session is + always exactly one setup, never more than one at a time. """ - try: - return getattr(self.dev, "fsamroy", None) is not None - except Exception: - return False + for setup, profile in SETUP_PROFILES.items(): + try: + if getattr(self.dev, profile["discriminator_attr"], None) is not None: + return setup + except Exception: + continue + return None # ── global-var I/O ─────────────────────────────────────────────────────── @@ -247,9 +305,15 @@ class TomoParamsWidget(BECWidget, QWidget): def _load_params(self) -> dict[str, Any]: params = {} - for key in QUEUE_PARAM_NAMES: + for key in self._profile["param_names"]: val = self._gv_get(key) - params[key] = val if val is not None else DEFAULTS.get(key) + params[key] = val if val is not None else self._profile["defaults"].get(key) + # offset_fields (e.g. lamni's tomo_fovx_offset/tomo_fovy_offset) are + # NOT part of param_names (alignment values, not queue params) and + # don't map to a same-named global var -- read via the profile's + # own getter instead of _gv_get(). + for key, _label, _min, _max, _decimals, axis in self._profile["offset_fields"]: + params[key] = self._profile["offset_getter"](self.client, axis) return params def _load_queue(self) -> list[dict]: @@ -284,11 +348,14 @@ class TomoParamsWidget(BECWidget, QWidget): self._busy_banner.setVisible(False) root.addWidget(self._busy_banner) - if not self._flomni_available: + if self._profile is None: + known = ", ".join( + f"{p['label']} ({p['discriminator_attr']})" for p in SETUP_PROFILES.values() + ) lbl = QLabel( - "⚠ FlOMNI setup not detected in this BEC session.\n\n" - "This widget is only meaningful when the FlOMNI\n" - "BEC session is active (fsamroy device present)." + "⚠ No known tomo setup detected in this BEC session.\n\n" + "This widget is only meaningful when one of the following " + f"is active: {known}." ) lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) lbl.setWordWrap(True) @@ -340,46 +407,86 @@ class TomoParamsWidget(BECWidget, QWidget): vbox.addLayout(type_row) vbox.addWidget(_hline()) - # common parameters + # common parameters -- built by dispatching over self._profile["field_order"] + # so build ORDER (not just which fields appear) can differ per setup; + # mirrors lamni.tomo_parameters()'s exact print/edit order for lamni. + self._lbl_single_point_hook_warning = None common_form = QFormLayout() common_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) - self._add_double( - common_form, - "tomo_countingtime", - "Counting time (s)", - min_=0.001, - max_=100.0, - decimals=3, - ) - self._add_double( - common_form, "tomo_shellstep", "Shell step (µm)", min_=0.001, max_=1000.0, decimals=3 - ) - self._add_double(common_form, "fovx", "FOV x (µm)", min_=0.1, max_=200.0, decimals=2) - self._add_double(common_form, "fovy", "FOV y (µm)", min_=0.1, max_=100.0, decimals=2) - self._add_int(common_form, "stitch_x", "Stitch x", min_=0, max_=50) - self._add_int(common_form, "stitch_y", "Stitch y", min_=0, max_=50) - self._add_double( - common_form, - "tomo_stitch_overlap", - "Stitch overlap (µm)", - min_=0.0, - max_=50.0, - decimals=3, - ) - self._add_text(common_form, "ptycho_reconstruct_foldername", "Reconstruct folder") - self._add_double( - common_form, - "manual_shift_y", - "Manual shift y (µm)", - min_=-1000.0, - max_=1000.0, - decimals=3, - ) - self._add_int(common_form, "frames_per_trigger", "Frames / trigger", min_=1, max_=100) - sp = self._add_bool(common_form, "single_point_instead_of_fermat_scan", "Single-point scan") + token_builders = { + "tomo_countingtime": lambda: self._add_double( + common_form, + "tomo_countingtime", + "Counting time (s)", + min_=0.001, + max_=100.0, + decimals=3, + ), + "tomo_shellstep": lambda: self._add_double( + common_form, + "tomo_shellstep", + "Shell step (µm)", + min_=0.001, + max_=1000.0, + decimals=3, + ), + "fov": lambda: self._build_double_group(common_form, self._profile["fov_fields"]), + "stitch": lambda: self._build_int_group(common_form, self._profile["stitch_fields"]), + "tomo_stitch_overlap": lambda: self._add_double( + common_form, + "tomo_stitch_overlap", + "Stitch overlap (µm)", + min_=0.0, + max_=50.0, + decimals=3, + ), + "ptycho_reconstruct_foldername": lambda: self._add_text( + common_form, "ptycho_reconstruct_foldername", "Reconstruct folder" + ), + "manual_shift": lambda: self._build_double_group( + common_form, self._profile["manual_shift_fields"] + ), + "piezo_range": lambda: self._build_double_group( + common_form, self._profile["piezo_range_fields"] + ), + "frames_per_trigger": lambda: self._add_int( + common_form, "frames_per_trigger", "Frames / trigger", min_=1, max_=100 + ), + "single_point": lambda: self._build_single_point_section(common_form), + "offsets": lambda: self._build_offsets_section(common_form), + "at_each_angle_hook": lambda: self._build_at_each_angle_hook_row(common_form), + } + for token in self._profile["field_order"]: + token_builders[token]() + vbox.addLayout(common_form) + + # type-1 section + self._sec_type1 = self._build_type1_section() + vbox.addWidget(self._sec_type1) + + # type-2/3 section + self._sec_type23 = self._build_type23_section() + vbox.addWidget(self._sec_type23) + + return box + + def _build_double_group( + self, form: QFormLayout, fields: list[tuple[str, str, float, float, int]] + ) -> None: + for key, label, min_, max_, decimals in fields: + self._add_double(form, key, label, min_=min_, max_=max_, decimals=decimals) + + def _build_int_group(self, form: QFormLayout, fields: list[tuple[str, str, int, int]]) -> None: + for key, label, min_, max_ in fields: + self._add_int(form, key, label, min_=min_, max_=max_) + + def _build_single_point_section(self, form: QFormLayout) -> None: + if not self._profile["has_single_point"]: + return + sp = self._add_bool(form, "single_point_instead_of_fermat_scan", "Single-point scan") sp.stateChanged.connect(self._on_single_point_changed) self._add_double( - common_form, + form, "single_point_random_shift_max", "Random shift max (µm)", min_=0.0, @@ -395,40 +502,67 @@ class TomoParamsWidget(BECWidget, QWidget): self._lbl_single_point_hook_warning.setWordWrap(True) self._lbl_single_point_hook_warning.setStyleSheet("color: #B8860B;") self._lbl_single_point_hook_warning.setVisible(False) - common_form.addRow("", self._lbl_single_point_hook_warning) + form.addRow("", self._lbl_single_point_hook_warning) + + def _build_offsets_section(self, form: QFormLayout) -> None: + """FOV-offset + manual-shift group (lamni only -- see + SETUP_PROFILES["lamni"]["offset_fields"]). tomo_fovx_offset/ + tomo_fovy_offset are alignment values (LamNIAlignmentMixin), not + scan parameters -- shown here for convenience, mirroring + lamni.tomo_parameters(), but excluded from queue job snapshots + (see add_edited_to_queue()/_load_params()). + """ + if not self._profile["offset_fields"]: + return + header_row = QHBoxLayout() + header_row.addWidget(QLabel("Offsets")) + btn_help = QPushButton("?") + btn_help.setFixedWidth(24) + btn_help.setToolTip("What do these offsets do?") + btn_help.clicked.connect(self._show_offsets_help) + header_row.addWidget(btn_help) + header_row.addStretch() + form.addRow(header_row) + for key, label, min_, max_, decimals, _axis in self._profile["offset_fields"]: + self._add_double(form, key, label, min_=min_, max_=max_, decimals=decimals) + self._build_double_group(form, self._profile["manual_shift_fields"]) + + def _show_offsets_help(self) -> None: + QMessageBox.information( + self, + "Offsets", + "FOV offset rotates to find the region of interest. The initial " + "values were determined in the xrayeye alignment step. The " + "manual shifts move the rotation center.", + ) + + def _build_at_each_angle_hook_row(self, form: QFormLayout) -> None: self._pw["at_each_angle_hook"] = QComboBox() self._pw["at_each_angle_hook"].setEnabled(False) self._pw["at_each_angle_hook"].setToolTip( "Run a registered custom function at every projection angle instead " "of the normal acquisition (e.g. move a polarizer in/out between " "exposures). Registered from the CLI with " - "flomni.register_at_each_angle_hook(name, func) -- this list refreshes " + "register_at_each_angle_hook(name, func) -- this list refreshes " "automatically while the dialog is open." ) - common_form.addRow("At-each-angle hook:", self._pw["at_each_angle_hook"]) - vbox.addLayout(common_form) - - # type-1 section - self._sec_type1 = self._build_type1_section() - vbox.addWidget(self._sec_type1) - - # type-2/3 section - self._sec_type23 = self._build_type23_section() - vbox.addWidget(self._sec_type23) - - return box + form.addRow("At-each-angle hook:", self._pw["at_each_angle_hook"]) def _build_type1_section(self) -> QGroupBox: box = QGroupBox("Type 1 – sub-tomogram settings") form = QFormLayout(box) form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) - # angle range combo - self._pw["tomo_angle_range"] = QComboBox() - for v in (180, 360): - self._pw["tomo_angle_range"].addItem(f"{v}°", v) - self._pw["tomo_angle_range"].currentIndexChanged.connect(self._update_projection_preview) - form.addRow("Angle range:", self._pw["tomo_angle_range"]) + # angle range combo -- only for setups with an actual 180/360 toggle; + # lamni is laminography and always scans the full 360 degrees. + if self._profile["has_180_mode"]: + self._pw["tomo_angle_range"] = QComboBox() + for v in (180, 360): + self._pw["tomo_angle_range"].addItem(f"{v}°", v) + self._pw["tomo_angle_range"].currentIndexChanged.connect( + self._update_projection_preview + ) + form.addRow("Angle range:", self._pw["tomo_angle_range"]) # requested total projections (user entry in edit mode) self._pw["_requested_total"] = QSpinBox() @@ -444,7 +578,8 @@ class TomoParamsWidget(BECWidget, QWidget): form.addRow("Actual achievable total:", self._lbl_actual_total) form.addRow("Angular step (°):", self._lbl_achievable_step) - self._add_bool(form, "zero_deg_reference_at_each_subtomo", "0° reference each sub-tomo") + if self._profile["has_zero_deg_reference"]: + self._add_bool(form, "zero_deg_reference_at_each_subtomo", "0° reference each sub-tomo") return box def _build_type23_section(self) -> QGroupBox: @@ -567,7 +702,7 @@ class TomoParamsWidget(BECWidget, QWidget): # derive requested_total from stored tomo_angle_stepsize angle_range = int(params.get("tomo_angle_range", 180)) stepsize = float(params.get("tomo_angle_stepsize", 10.0)) - actual_total, _, _ = _compute_type1(angle_range, stepsize) + actual_total, _, _ = self._profile["compute_type1"](angle_range, stepsize) self._pw["_requested_total"].blockSignals(True) self._pw["_requested_total"].setValue(actual_total) self._pw["_requested_total"].blockSignals(False) @@ -575,9 +710,11 @@ class TomoParamsWidget(BECWidget, QWidget): self._update_projection_preview() # type-3: derive projections-per-subtomo from stored tomo_angle_stepsize - # (flomni uses 180 / tomo_angle_stepsize for type 3, hardcoded to 180) + # (base angle is 180 for flomni, 360 for lamni -- see + # self._profile["type1_base_angle"]) stepsize = float(params.get("tomo_angle_stepsize", 10.0)) - numprj_t3 = int(180.0 / stepsize) if stepsize > 0 else 18 + base_angle = self._profile["type1_base_angle"] + numprj_t3 = int(base_angle / stepsize) if stepsize > 0 else int(base_angle / 10.0) self._pw["_numprj_type3"].blockSignals(True) self._pw["_numprj_type3"].setValue(numprj_t3) self._pw["_numprj_type3"].blockSignals(False) @@ -586,9 +723,9 @@ class TomoParamsWidget(BECWidget, QWidget): def _populate_at_each_angle_hook_combo(self, current: Optional[str]) -> None: """Rebuild the at_each_angle_hook dropdown from the live - ``tomo_at_each_angle_hooks`` registry (published by - ``Flomni.register_/unregister_at_each_angle_hook()``) and select - ``current``. + ``tomo_at_each_angle_hooks`` registry (published by the active CLI + object's ``register_/unregister_at_each_angle_hook()``, see + ``TomoQueueMixin``) and select ``current``. ``current`` is added as its own entry even if it isn't in the published list -- e.g. it was registered in a different, or a @@ -632,44 +769,43 @@ class TomoParamsWidget(BECWidget, QWidget): # derive tomo_angle_stepsize from requested_total + angle_range (type 1) angle_range = int(params.get("tomo_angle_range", 180)) requested = self._pw["_requested_total"].value() - stepsize = _requested_to_stepsize(angle_range, requested) + stepsize = self._profile["requested_to_stepsize"](angle_range, requested) params["tomo_angle_stepsize"] = stepsize # type 3: override tomo_angle_stepsize from projections-per-subtomo - # (flomni uses 180 / numprj for type 3) + # (base angle is 180 for flomni, 360 for lamni) if params.get("tomo_type") == 3: numprj = self._pw["_numprj_type3"].value() if numprj > 0: - params["tomo_angle_stepsize"] = 180.0 / numprj + params["tomo_angle_stepsize"] = self._profile["type1_base_angle"] / numprj # single_point forces stitch to 0 - if params.get("single_point_instead_of_fermat_scan"): - params["stitch_x"] = 0 - params["stitch_y"] = 0 + if self._profile["has_single_point"] and params.get("single_point_instead_of_fermat_scan"): + for key, _label, _min, _max in self._profile["stitch_fields"]: + params[key] = 0 return params # ── validation ──────────────────────────────────────────────────────────── def _validate(self, params: dict[str, Any]) -> Optional[str]: - if params.get("fovx", 0) > 200: - return "fovx must be ≤ 200 µm" - if params.get("fovy", 0) > 100: - return "fovy must be ≤ 100 µm" - if not isinstance(params.get("stitch_x"), int): - return "stitch_x must be an integer" - if not isinstance(params.get("stitch_y"), int): - return "stitch_y must be an integer" + for key, label, _min, max_, _decimals in self._profile["fov_fields"]: + if params.get(key, 0) > max_: + return f"{label} must be ≤ {max_} µm" + for key, _label, _min, _max in self._profile["stitch_fields"]: + if not isinstance(params.get(key), int): + return f"{key} must be an integer" fpt = params.get("frames_per_trigger", 1) if not isinstance(fpt, int) or fpt < 1 or isinstance(fpt, bool): return "frames_per_trigger must be a positive integer" if params.get("tomo_type") not in (1, 2, 3): return "tomo_type must be 1, 2, or 3" - if params.get("tomo_angle_range") not in (180, 360): + if self._profile["has_180_mode"] and params.get("tomo_angle_range") not in (180, 360): return "tomo_angle_range must be 180 or 360" - rshift = params.get("single_point_random_shift_max", 0.0) - if not 0 <= rshift <= 10: - return "single_point_random_shift_max must be between 0 and 10 µm" + if self._profile["has_single_point"]: + rshift = params.get("single_point_random_shift_max", 0.0) + if not 0 <= rshift <= 10: + return "single_point_random_shift_max must be between 0 and 10 µm" return None # ── type visibility ─────────────────────────────────────────────────────── @@ -705,13 +841,15 @@ class TomoParamsWidget(BECWidget, QWidget): # ── type-1 projection preview ───────────────────────────────────────────── def _update_projection_preview(self) -> None: + # angle_range only exists as a widget for setups with a 180/360 + # toggle (self._profile["has_180_mode"]); for others (e.g. lamni, + # always 360) the profile's math functions ignore this value + # entirely, so any fallback here is a no-op, not a guess. angle_range_widget = self._pw.get("tomo_angle_range") - if angle_range_widget is None: - return - angle_range = int(angle_range_widget.currentData() or 180) + angle_range = int(angle_range_widget.currentData() or 180) if angle_range_widget else 180 requested = self._pw["_requested_total"].value() - stepsize = _requested_to_stepsize(angle_range, requested) - actual_total, achievable_step, _ = _compute_type1(angle_range, stepsize) + stepsize = self._profile["requested_to_stepsize"](angle_range, requested) + actual_total, achievable_step, _ = self._profile["compute_type1"](angle_range, stepsize) self._lbl_actual_total.setText(str(actual_total)) self._lbl_achievable_step.setText(f"{achievable_step:.4f}") @@ -733,12 +871,16 @@ class TomoParamsWidget(BECWidget, QWidget): self._update_type_visibility(tomo_type) def _on_single_point_changed(self, state: int) -> None: + # only ever connected when self._profile["has_single_point"] (see + # _build_params_panel), so self._profile["stitch_fields"] is always + # meaningful here. checked = bool(state) + stitch_keys = [key for key, _label, _min, _max in self._profile["stitch_fields"]] if checked: - self._pw["stitch_x"].setValue(0) - self._pw["stitch_y"].setValue(0) - self._pw["stitch_x"].setEnabled(self._edit_mode and not checked) - self._pw["stitch_y"].setEnabled(self._edit_mode and not checked) + for key in stitch_keys: + self._pw[key].setValue(0) + for key in stitch_keys: + self._pw[key].setEnabled(self._edit_mode and not checked) # random shift only applies to single-point acquisitions self._pw["single_point_random_shift_max"].setEnabled(self._edit_mode and checked) # informational, not an editable field -- shown whenever single-point @@ -815,11 +957,12 @@ class TomoParamsWidget(BECWidget, QWidget): self._refresh_sample_name() def _refresh_sample_name(self) -> None: - # Mirror the exact path used by Flomni.sample_get_name(0): - # getattr(dev.flomni_samples.sample_names, "sample0").get() + # Per-setup lookup: flomni has a device-backed sample store + # (dev.flomni_samples...); lamni has no such device and stores the + # sample name as a plain global var instead (see + # SETUP_PROFILES[...]["sample_name_getter"]). try: - name_signal = self.dev.flomni_samples.sample_names.sample0 - name = name_signal.get() + name = self._profile["sample_name_getter"](self) self._lbl_sample.setText(str(name) if name else "---") except Exception: self._lbl_sample.setText("---") @@ -830,31 +973,6 @@ class TomoParamsWidget(BECWidget, QWidget): params = self._load_params() self._populate_fields(params) - def _refresh_queue(self) -> None: - jobs = self._load_queue() - table = self._queue_table - table.setRowCount(len(jobs)) - for row, job in enumerate(jobs): - params = job.get("params", {}) - status = job.get("status", "pending") - color = STATUS_COLORS.get(status, "#888888") - - cells = [ - str(row), - job.get("label", f"job_{row}"), - status, - str(params.get("tomo_type", "?")), - _format_projections(params), - _fmt_num(params.get("tomo_countingtime")), - _fmt_num(params.get("tomo_shellstep")), - job.get("added_at", ""), - ] - for col, text in enumerate(cells): - item = QTableWidgetItem(text) - item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) - item.setForeground(table.palette().text() if col != 2 else _color_from_hex(color)) - table.setItem(row, col, item) - # ── edit-mode workflow ──────────────────────────────────────────────────── def enter_edit_mode(self) -> None: @@ -916,7 +1034,7 @@ class TomoParamsWidget(BECWidget, QWidget): Uses the ``tomo_progress["heartbeat"]`` timestamp written by ``_tomo_scan_at_angle()`` at the start of every projection — this is updated whether the scan was started via the queue or directly via - ``flomni.tomo_scan()``, so it's more reliable than queue job status. + ``.tomo_scan()``, so it's more reliable than queue job status. A heartbeat fresher than 2 minutes indicates a scan in progress; older than that it's either done, or stuck long enough that editing parameters is intentional. @@ -969,7 +1087,7 @@ class TomoParamsWidget(BECWidget, QWidget): angle_range = int(params.get("tomo_angle_range", 180)) requested = self._pw["_requested_total"].value() stepsize = params["tomo_angle_stepsize"] - actual_total, _, _ = _compute_type1(angle_range, stepsize) + actual_total, _, _ = self._profile["compute_type1"](angle_range, stepsize) if actual_total != requested: reply = QMessageBox.question( self, @@ -982,11 +1100,16 @@ class TomoParamsWidget(BECWidget, QWidget): if reply != QMessageBox.StandardButton.Yes: return + offset_axes = {key: axis for key, _l, _mn, _mx, _d, axis in self._profile["offset_fields"]} ok = True for key, val in params.items(): - if key in QUEUE_PARAM_NAMES: + if key in self._profile["param_names"]: if not self._gv_set(key, val): ok = False + elif key in offset_axes: + # tomo_fovx_offset/tomo_fovy_offset don't map to a + # same-named global var -- write via the profile's setter. + self._profile["offset_setter"](self.client, offset_axes[key], val) if ok: self._edit_mode = False @@ -1020,7 +1143,7 @@ class TomoParamsWidget(BECWidget, QWidget): angle_range = int(params.get("tomo_angle_range", 180)) requested = self._pw["_requested_total"].value() stepsize = params["tomo_angle_stepsize"] - actual_total, _, _ = _compute_type1(angle_range, stepsize) + actual_total, _, _ = self._profile["compute_type1"](angle_range, stepsize) if actual_total != requested: reply = QMessageBox.question( self, @@ -1042,12 +1165,17 @@ class TomoParamsWidget(BECWidget, QWidget): jobs = self._load_queue() if not label.strip(): label = f"job_{len(jobs)}" + # Queue snapshots only ever hold this setup's real scan params -- + # not internal-only widget state (_requested_total, _numprj_type3) + # and not alignment-only offset_fields (tomo_fovx_offset/etc.), + # which aren't part of param_names for exactly this reason. + snapshot = {k: v for k, v in params.items() if k in self._profile["param_names"]} jobs.append( { "kind": "tomo", "id": uuid.uuid4().hex, "label": label.strip(), - "params": params, + "params": snapshot, "status": "pending", "added_at": datetime.datetime.now().isoformat(timespec="seconds"), } @@ -1071,13 +1199,14 @@ class TomoParamsWidget(BECWidget, QWidget): if key in skip: continue widget.setEnabled(enabled) - # stitch locked when single_point is active; random shift is the - # inverse -- only editable while single_point is active - single_point = self._pw["single_point_instead_of_fermat_scan"].isChecked() - if enabled and single_point: - self._pw["stitch_x"].setEnabled(False) - self._pw["stitch_y"].setEnabled(False) - self._pw["single_point_random_shift_max"].setEnabled(enabled and single_point) + if self._profile["has_single_point"]: + # stitch locked when single_point is active; random shift is the + # inverse -- only editable while single_point is active + single_point = self._pw["single_point_instead_of_fermat_scan"].isChecked() + if enabled and single_point: + for key, _label, _min, _max in self._profile["stitch_fields"]: + self._pw[key].setEnabled(False) + self._pw["single_point_random_shift_max"].setEnabled(enabled and single_point) # projection requested total only meaningful in edit mode self._pw["_requested_total"].setEnabled(enabled) @@ -1098,7 +1227,7 @@ class TomoParamsWidget(BECWidget, QWidget): def _get_queue_dialog(self) -> "TomoQueueDialog": if self._queue_dlg is None: - self._queue_dlg = TomoQueueDialog(self.client, parent=self) + self._queue_dlg = TomoQueueDialog(self.client, self._profile, parent=self) return self._queue_dlg # ── cleanup ─────────────────────────────────────────────────────────────── @@ -1113,9 +1242,13 @@ class TomoParamsWidget(BECWidget, QWidget): class TomoQueueDialog(QDialog): """ - Non-modal popup window for managing the FlOMNI tomo scan queue. + Non-modal popup window for managing the tomo scan queue. - Opened from the main TomoParamsWidget via the "Queue control…" button. + Opened from the main TomoParamsWidget via the "Queue control…" button, + which passes its own detected ``profile`` (see ``SETUP_PROFILES``) down + so this dialog's "Add current params to queue" reads the right param + names/defaults for whichever setup is active -- it never detects the + setup independently. Shares the BEC client with its parent for global-var access. Polls the queue every 2 s to stay in sync with CLI-driven changes. """ @@ -1142,11 +1275,12 @@ class TomoQueueDialog(QDialog): # harmless. _MOVABLE_STATUSES = ("pending",) - def __init__(self, client, parent=None): + def __init__(self, client, profile: dict, parent=None): super().__init__(parent) self._client = client + self._profile = profile self._sort_mode = False - self.setWindowTitle("FlOMNI – Tomo Queue Control") + self.setWindowTitle(f"{profile['label']} – Tomo Queue Control") self.setMinimumSize(720, 360) self._build_ui() self._poll_timer = QTimer(self) @@ -1181,9 +1315,9 @@ class TomoQueueDialog(QDialog): def _load_params(self) -> dict: params = {} - for key in QUEUE_PARAM_NAMES: + for key in self._profile["param_names"]: val = self._gv_get(key) - params[key] = val if val is not None else DEFAULTS.get(key) + params[key] = val if val is not None else self._profile["defaults"].get(key) return params # ── UI ──────────────────────────────────────────────────────────────────── @@ -1216,6 +1350,7 @@ class TomoQueueDialog(QDialog): self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self._table.itemSelectionChanged.connect(self._update_sort_buttons) self._table.itemSelectionChanged.connect(self._update_load_button) + self._table.itemSelectionChanged.connect(self._update_duplicate_button) vbox.addWidget(self._table) btn_row = QHBoxLayout() @@ -1229,6 +1364,12 @@ class TomoQueueDialog(QDialog): ) self._btn_load.setEnabled(False) self._btn_del = QPushButton("Delete selected") + self._btn_dup = QPushButton("Duplicate selected") + self._btn_dup.setToolTip( + "Append a copy of the selected job to the end of the queue, " + "regardless of its status (pending/running/incomplete/done)." + ) + self._btn_dup.setEnabled(False) self._btn_clr = QPushButton("Clear all") self._btn_sort = QPushButton("Sort queue…") self._btn_sort.setCheckable(True) @@ -1245,6 +1386,7 @@ class TomoQueueDialog(QDialog): self._btn_add_cmd.clicked.connect(self.add_command_to_queue) self._btn_load.clicked.connect(self._on_load_into_editor) self._btn_del.clicked.connect(self._delete_selected) + self._btn_dup.clicked.connect(self._duplicate_selected) self._btn_clr.clicked.connect(self._clear_queue) self._btn_sort.toggled.connect(self._toggle_sort_mode) self._btn_move_up.clicked.connect(lambda: self._move_selected(-1)) @@ -1254,6 +1396,7 @@ class TomoQueueDialog(QDialog): btn_row.addWidget(self._btn_add_cmd) btn_row.addWidget(self._btn_load) btn_row.addWidget(self._btn_del) + btn_row.addWidget(self._btn_dup) btn_row.addWidget(self._btn_clr) btn_row.addWidget(self._btn_sort) btn_row.addWidget(self._btn_move_up) @@ -1314,6 +1457,7 @@ class TomoQueueDialog(QDialog): # queue was edited from elsewhere while a row stayed selected), so # re-check eligibility here too, not just on selection-change. self._update_load_button() + self._update_duplicate_button() # ── queue actions ───────────────────────────────────────────────────────── @@ -1389,6 +1533,12 @@ class TomoQueueDialog(QDialog): not self._sort_mode and self._selected_single_tomo_job() is not None ) + def _update_duplicate_button(self) -> None: + # Unlike Load-into-editor, duplication works for a job of any kind + # (tomo or command) and any status -- it's just a dict copy, so + # "exactly one row selected" is the only requirement. + self._btn_dup.setEnabled(not self._sort_mode and self._selected_single_row() >= 0) + def _on_load_into_editor(self) -> None: job = self._selected_single_tomo_job() if job is None: @@ -1411,7 +1561,7 @@ class TomoQueueDialog(QDialog): doesn't shift out from under the operator mid-reorder. """ self._sort_mode = checked - for btn in (self._btn_add, self._btn_add_cmd, self._btn_del, self._btn_clr): + for btn in (self._btn_add, self._btn_add_cmd, self._btn_del, self._btn_dup, self._btn_clr): btn.setEnabled(not checked) self._btn_move_up.setVisible(checked) self._btn_move_down.setVisible(checked) @@ -1423,6 +1573,7 @@ class TomoQueueDialog(QDialog): self._table.clearSelection() self._update_sort_buttons() self._update_load_button() + self._update_duplicate_button() def _selected_single_row(self) -> int: rows = sorted({idx.row() for idx in self._table.selectedIndexes()}) @@ -1552,15 +1703,37 @@ class TomoQueueDialog(QDialog): self._save_queue([]) self._refresh() + def _duplicate_selected(self) -> None: + """Append a copy of the selected job to the end of the queue. + + Works regardless of the selected job's status (pending/running/ + incomplete/done) and regardless of its kind (tomo or command) -- + it's a plain deep-copy with a fresh id/label/timestamp, so no + setup- or kind-specific logic is needed here at all. + """ + row = self._selected_single_row() + jobs = self._load_queue() + if not 0 <= row < len(jobs): + return + new_job = copy.deepcopy(jobs[row]) + new_job["id"] = uuid.uuid4().hex + new_job["status"] = "pending" + new_job["label"] = f"{jobs[row].get('label', '')}_dup" + new_job["added_at"] = datetime.datetime.now().isoformat(timespec="seconds") + jobs.append(new_job) + self._save_queue(jobs) + self._refresh() + def _show_execute_hint(self) -> None: jobs = self._load_queue() pending = sum(1 for j in jobs if j.get("status") in ("pending", "incomplete", "running")) + cli_name = self._profile["cli_hint_name"] QMessageBox.information( self, "Execute queue", f"Queue: {len(jobs)} job(s), {pending} pending/resumable.\n\n" "Execution is a blocking CLI operation — run from the BEC IPython session:\n\n" - " flomni.tomo_queue_execute()\n\n" + f" {cli_name}.tomo_queue_execute()\n\n" "Queue status updates here automatically once running.", ) @@ -1622,8 +1795,8 @@ class CommandJobBuilderDialog(QDialog): self, "No actions available", "The tomo_queue_actions registry is empty or unavailable -- " - "is a Flomni CLI session running? Command jobs can't be " - "built without it.", + "is a BEC CLI session (flomni/lamni) running? Command jobs " + "can't be built without it.", ) def _build_ui(self): @@ -1855,28 +2028,194 @@ def _compute_type1(angle_range: int, stepsize: float) -> tuple[int, float, float return actual_total, achievable_step, stepsize +def _lamni_requested_to_stepsize(angle_range: int, requested_total: int) -> float: + """Lamni sibling of _requested_to_stepsize(): inverse of + _lamni_compute_type1()'s N*8 grid -- mirrors + LamNI._tomo_type1_actual_grid()'s inverse (lamni.py) exactly. + + angle_range is accepted but ignored, for call-site symmetry with the + flomni functions above -- lamni is laminography: always 360 degrees, no + tomo_angle_range concept at all (see LamNI._TOMO_SCAN_PARAM_NAMES). + """ + if requested_total <= 0: + return 360.0 + return (360.0 / requested_total) * 8 + + +def _lamni_compute_type1(angle_range: int, stepsize: float) -> tuple[int, float, float]: + """Lamni sibling of _compute_type1(): mirrors + LamNI._tomo_type1_actual_grid() (lamni.py) exactly -- + N = int(360 / stepsize) -- unique angles per sub-tomogram + achievable_step = 360 / N + actual_total = N * 8 + + A full 360-degree sweep's closing angle is the same physical angle as + its start, so N (not N+1) is the real per-sub-tomogram count -- + sub_tomo_scan() generates it with endpoint=False for exactly this + reason. angle_range is accepted but ignored (see + _lamni_requested_to_stepsize()). + """ + if stepsize <= 0: + return 0, 0.0, 0.0 + N = int(360.0 / stepsize) + if N <= 0: + return 0, 0.0, 0.0 + achievable_step = 360.0 / N + actual_total = N * 8 + return actual_total, achievable_step, stepsize + + +def _lamni_get_tomo_fov_offset(client, axis: str) -> float: + """Mirrors LamNIAlignmentMixin.tomo_fovx_offset/tomo_fovy_offset getters + exactly: both axes are packed into one global var, ``tomo_fov_offset = + [x_um, y_um]``, exposed in mm (divided by 1000). Unlike every other + field this widget handles, there is no global var literally named + ``tomo_fovx_offset``/``tomo_fovy_offset`` -- the generic + ``_gv_get``/``_gv_set`` (direct global-var-by-name) can't be used here. + """ + val = client.get_global_var("tomo_fov_offset") + if val is None: + return 0.0 + idx = 0 if axis == "x" else 1 + return val[idx] / 1000 + + +def _lamni_set_tomo_fov_offset(client, axis: str, value_mm: float) -> None: + """Mirrors LamNIAlignmentMixin.tomo_fovx_offset/tomo_fovy_offset setters + exactly: read-modify-write the packed ``[x_um, y_um]`` list so writing + one axis never clobbers the other.""" + val_old = client.get_global_var("tomo_fov_offset") + if val_old is None: + val_old = [0.0, 0.0] + else: + val_old = list(val_old) + idx = 0 if axis == "x" else 1 + val_old[idx] = value_mm * 1000 + client.set_global_var("tomo_fov_offset", val_old) + + +# ── per-setup profiles ─────────────────────────────────────────────────────── + +SETUP_PROFILES: dict[str, dict[str, Any]] = { + "flomni": { + "label": "FlOMNI", + "discriminator_attr": "fsamroy", + "param_names": QUEUE_PARAM_NAMES, + "defaults": DEFAULTS, + "has_180_mode": True, + "has_single_point": True, + "has_zero_deg_reference": True, + "fov_fields": [ + ("fovx", "FOV x (µm)", 0.1, 200.0, 2), + ("fovy", "FOV y (µm)", 0.1, 100.0, 2), + ], + "stitch_fields": [("stitch_x", "Stitch x", 0, 50), ("stitch_y", "Stitch y", 0, 50)], + "manual_shift_fields": [("manual_shift_y", "Manual shift y (µm)", -1000.0, 1000.0, 3)], + "piezo_range_fields": [], + "offset_fields": [], + "offset_getter": None, + "offset_setter": None, + # exact current build order -- unchanged behavior for flomni + "field_order": [ + "tomo_countingtime", + "tomo_shellstep", + "fov", + "stitch", + "tomo_stitch_overlap", + "ptycho_reconstruct_foldername", + "manual_shift", + "frames_per_trigger", + "single_point", + "at_each_angle_hook", + ], + "type1_base_angle": 180.0, + "compute_type1": _compute_type1, + "requested_to_stepsize": _requested_to_stepsize, + "sample_name_getter": lambda w: w.dev.flomni_samples.sample_names.sample0.get(), + "cli_hint_name": "flomni", + }, + "lamni": { + "label": "LamNI", + "discriminator_attr": "lsamrot", + "param_names": LAMNI_QUEUE_PARAM_NAMES, + "defaults": LAMNI_DEFAULTS, + "has_180_mode": False, + "has_single_point": False, + "has_zero_deg_reference": False, + "fov_fields": [("tomo_circfov", "Circular FOV (µm)", 0.1, 200.0, 2)], + "stitch_fields": [ + ("lamni_stitch_x", "Stitch x", 0, 50), + ("lamni_stitch_y", "Stitch y", 0, 50), + ], + "manual_shift_fields": [ + ("manual_shift_x", "Manual shift x (µm)", -1000.0, 1000.0, 3), + ("manual_shift_y", "Manual shift y (µm)", -1000.0, 1000.0, 3), + ], + "piezo_range_fields": [ + ("lamni_piezo_range_x", "Piezo range x (µm)", 0.0, 200.0, 2), + ("lamni_piezo_range_y", "Piezo range y (µm)", 0.0, 200.0, 2), + ], + # tomo_fovx_offset/tomo_fovy_offset: alignment values (LamNIAlignmentMixin), + # not part of _TOMO_SCAN_PARAM_NAMES -- shown/editable here (mirroring + # lamni.tomo_parameters()) but excluded from queue job snapshots, same + # as flomni's corr_pos_y/corr_angle_y (also not shown by this widget). + "offset_fields": [ + ("tomo_fovx_offset", "FOV offset x (mm)", -1000.0, 1000.0, 4, "x"), + ("tomo_fovy_offset", "FOV offset y (mm)", -1000.0, 1000.0, 4, "y"), + ], + "offset_getter": _lamni_get_tomo_fov_offset, + "offset_setter": _lamni_set_tomo_fov_offset, + # mirrors lamni.tomo_parameters()'s exact print/edit order (lamni.py:1136-1153) + "field_order": [ + "tomo_countingtime", + "tomo_shellstep", + "piezo_range", + "stitch", + "tomo_stitch_overlap", + "fov", + "ptycho_reconstruct_foldername", + "frames_per_trigger", + "offsets", + "at_each_angle_hook", + ], + "type1_base_angle": 360.0, + "compute_type1": _lamni_compute_type1, + "requested_to_stepsize": _lamni_requested_to_stepsize, + "sample_name_getter": lambda w: w.client.get_global_var("sample_name"), + "cli_hint_name": "lamni", + }, +} + + def _format_projections(params: dict) -> str: """ Human-readable projection count for a queued job, mirroring the CLI's - per-type logic: - - type 1: int(180 / stepsize) * 8 (8 equally spaced sub-tomos, - independent of angle_range) - - type 3: int(180 / stepsize) * 8 (equally spaced, golden start) + per-type logic. A job's own ``params`` dict is that setup's own param + snapshot, so ``"tomo_angle_range" in params`` distinguishes a flomni job + (fixed-180 base, mode-independent) from a lamni job (plain 360 base, + see ``_lamni_compute_type1``/``_lamni_requested_to_stepsize``) without + this module-level helper needing to know which setup is active: + - type 1: flomni int(180/stepsize)*8, lamni int(360/stepsize)*8 + - type 3: flomni int(180/stepsize)*8, lamni int(360/stepsize)*8 - type 2: golden ratio has no fixed count -> configured max, or ∞ if unset """ try: tomo_type = int(params.get("tomo_type", 1)) stepsize = float(params.get("tomo_angle_stepsize", 0) or 0) + is_flomni_job = "tomo_angle_range" in params + base_angle = 180.0 if is_flomni_job else 360.0 + compute_type1 = _compute_type1 if is_flomni_job else _lamni_compute_type1 if tomo_type == 1: angle_range = int(params.get("tomo_angle_range", 180)) - actual_total, _, _ = _compute_type1(angle_range, stepsize) + actual_total, _, _ = compute_type1(angle_range, stepsize) return str(actual_total) if tomo_type == 3: if stepsize <= 0: return "?" - return str(int(180.0 / stepsize) * 8) + n = int(base_angle / stepsize) + return str(n * 8) if tomo_type == 2: max_prj = params.get("golden_max_number_of_projections", 0) or 0 @@ -1937,9 +2276,11 @@ def _job_tooltip(job: dict) -> str: else: lines.append("Params:") params = job.get("params", {}) - for name in QUEUE_PARAM_NAMES: - if name in params: - lines.append(f" {name}: {params[name]}") + # sorted(params) rather than a fixed module-level name tuple -- a + # job's own params dict IS whichever setup's param snapshot, so this + # works for both flomni and lamni jobs without needing to know which. + for name in sorted(params): + lines.append(f" {name}: {params[name]}") lines.append(f"Added: {job.get('added_at', '')}") return "\n".join(lines) diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md index 0cf3f18..3ba29d8 100644 --- a/docs/user/ptychography/lamni.md +++ b/docs/user/ptychography/lamni.md @@ -75,6 +75,7 @@ The sample fine alignment can be obtained using ptychography. For this a short l * `lamni.tomo_fovx/y_offset=value` [mm] will shift the field of view. Perform this adjustment from projections collected at **lsamrot 0 degrees**. This shift will rotate. In contrast the manual shift will be a constant shift, identical at all angles. +(user.ptychography.lamni.laminography)= ### Laminography scan Start the laminography scan by @@ -85,6 +86,190 @@ Start the laminography scan by `lamni.tomo_reconstruct()` 3. `lamni.tomo_scan()` to start the laminography scan +During the scan, a live progress report is printed (subtomogram/projection counters and an estimated time of completion). Gaps significantly longer than a normal acquisition cycle (e.g. a beamline-down interruption, or a crash followed by a restart) are detected automatically from the time between consecutive projections and excluded from the time estimate; the total time lost to such gaps is printed once the scan finishes. + +Three angular sampling modes are implemented, same as flOMNI, but LamNI always scans the full 360 degrees — laminography's tilted sample geometry does not have the symmetry that lets flOMNI's 180-degree mode work, so there is no angular-range choice to make: + +| tomography mode | parameters and defaults | +| --- | --- | +| 8 sub-tomograms | subtomo_start=1, start_angle=None | +| Golden ratio tomography (sorted in bunches) | projection_number=None | +| Equally spaced with golden starting angle | projection_number=None | + +The parameters above can be used to __restart an interrupted acquisition__ manually, or - more conveniently - by running +`lamni.tomo_scan_resume()` +which reads the last recorded progress and resumes automatically at the exact point (subtomogram/angle, or projection for the golden ratio modes) the scan was interrupted at, without needing to look up the values by hand. When running from the tomo scan queuing system use `lamni.tomo_queue_execute()` instead! + +In case of eight equally spaced sub-tomograms, an individual sub-tomogram can be scanned by `lamni.sub_tomo_scan(subtomo_number, start_angle)`. If the start angle is not specified, it is computed depending on `subtomo_number`, ranging from 1 to 8. + +#### Queueing multiple scans + +Several tomo parameter sets can be queued and run sequentially on the same sample, without having to start each one by hand. + +| command | explanation | +| --- | --- | +| `lamni.tomo_queue_add(label=None)` | Snapshot the currently set tomo parameters and add them as a new job to the queue. Returns the job's index. | +| `lamni.tomo_queue_show()` | Print and return the current queue, with status per job. | +| `lamni.tomo_queue_delete(*indices)` | Delete one or more jobs by index. | +| `lamni.tomo_queue_clear()` | Empty the queue. | +| `lamni.tomo_queue_execute(start_index=0)` | Run all pending jobs in sequence, on the current sample. | + +The queue is persisted (it survives a BEC client restart). Each job's status is one of `pending`, `running`, `incomplete`, or `done`. A job that did not run to completion (an exception was caught, or the BEC client itself crashed mid-scan) is automatically resumed - rather than restarted - the next time `lamni.tomo_queue_execute()` is called. + +If the process running the queue dies hard enough that it never gets to write a status update at all (a kernel restart, a Ctrl-C during a blocking prompt) a job can be left stuck showing `running` with nothing actually running. `lamni.tomo_queue_delete(index)` has no status guard and works on a stuck row regardless; you can also fix the status directly with `lamni._tomo_queue_proxy.update_by_id(job_id, status="incomplete")` (to make it resumable) or `status="done"` (to skip it). The GUI's Delete/Clear normally refuse to touch a `running` row, but detect a stale one (no recent scan heartbeat) and offer to proceed anyway with an explicit confirmation instead of blocking forever. + +Example: +``` +lamni.tomo_parameters() # set up parameter set #1 +lamni.tomo_queue_add("fast overview") +lamni.tomo_parameters() # set up parameter set #2 +lamni.tomo_queue_add("hires scan") +lamni.tomo_queue_show() +lamni.tomo_queue_execute() # runs both, in order, on this sample +``` + +**GUI: reusing an earlier job's settings.** In the ☰ Queue control… dialog, select a +single tomo job (not a command job — those have no scan parameters) and click +**"Load into editor"** to pull its saved settings into the params panel's editor, +as if you'd just clicked Edit. Nothing is written yet — review or tweak the fields, +then Submit (writes live, blocked while the beamline is busy) or "Add to queue" (always +allowed) as usual. If you already had an edit in progress, it asks before discarding it. + +**GUI: two different "Add to queue" buttons.** The params panel's own **"Add to +queue"** (visible in edit mode) queues whatever you've typed, unsubmitted. The ☰ +Queue control… window's **"Add current params to queue"** is a different button on a +different window — it always queues the *live* parameters, regardless of any edit +open in the panel. If you have an unsaved edit open and click the queue window's +button instead of the panel's, it warns and names the correct one before proceeding, +since it would otherwise queue your last-submitted values, not what you just typed. + +**GUI: "Duplicate selected".** Appends an exact copy of the selected job (any status — +pending, running, incomplete, or done, and either a tomo or a command job) to the end +of the queue, labeled with `_dup` appended, status reset to `pending`. Useful for +re-running a job with the same settings without re-typing them, or as a starting point +to tweak via "Load into editor". + +#### Command jobs — reconfiguring the beamline between scans + +In addition to tomogram jobs, the same queue can hold **command jobs**: an ordered +list of beamline reconfiguration steps (move a device, ...) that run instead of a +scan. This is what lets one queue express *"tomogram A, then reconfigure, then +tomogram B"* unattended, e.g. change energy and re-peak the undulator gap between +two tomograms on the same sample. + +`lamni.tomo_queue_add_command(steps, label=None, idempotent=None)` + +- `steps`: a single `{"action": ..., "kwargs": {...}}` dict, or a list of them run in + sequence within that one job. +- `label`: optional name shown by `tomo_queue_show()`, same as for `tomo_queue_add()`. +- `idempotent`: normally inferred (safe to blindly re-run after a crash only if every + step is); override explicitly if needed. + +Only actions from a fixed, reviewed registry can be queued — not arbitrary code: + +| action | does | parameters | +| --- | --- | --- | +| `move` | Move device(s) to absolute position(s). Only devices on the allow-list below can be targeted. | `positions`: `{device: target}`, e.g. `{"mokev": 6.2}` | +| `optimize_idgap` | Scan the undulator gap over a range and move to the peak. **Not yet implemented (no-op stub).** | `search_range`: mm, default 0.5, range 0–2 | + +Devices allowed for `move`: `mokev` (energy, keV), `idgap` (undulator gap, mm) — the +same beamline-wide devices as flOMNI's queue. A `move` naming any other device is +rejected, both when the job is added and again when it actually runs. + +Example — change energy, then re-peak idgap, before the next laminogram: +``` +lamni.tomo_queue_add_command( + [{"action": "move", "kwargs": {"positions": {"mokev": 6.2}}}, + {"action": "optimize_idgap", "kwargs": {"search_range": 0.5}}], + label="reconfigure to 6.2 keV", +) +``` + +`tomo_queue_show()` lists command jobs alongside tomogram jobs, e.g.: +``` +[2] pending reconfigure to 6.2 keV CMD move{'positions': {'mokev': 6.2}} > optimize_idgap{'search_range': 0.5} [idem] +``` + +If a command job is interrupted by a crash, there is no per-step resume point — the +whole job is either safe to redo from the top or it isn't: +- If every step is idempotent (the usual case — an absolute move is harmless to + repeat), `lamni.tomo_queue_execute()` silently re-runs the whole job from the top. +- If any step is not idempotent, you are asked whether to re-run the job from the top + or mark it done as-is. + +#### Custom behavior at each projection angle + +By default, every projection in a laminography scan is a single ptychography scan at +that angle. For cases that need something more — e.g. record a projection, move a +polarizer in, record again, move it back out, at every angle — write a small Python +function and register it as an **at_each_angle hook**. Once registered, it can be +selected per tomo-queue job, so the whole modulated laminogram runs unattended along +with any other queued jobs. + +A hook is a function `func(lamni, angle)`, called once per projection angle instead +of the normal acquisition: +```python +def polarizer_modulation(lamni, angle): + lamni.tomo_scan_projection(angle) + umv(dev.polarizer, "in") + lamni.tomo_scan_projection(angle) + umv(dev.polarizer, "out") +``` + +| command | explanation | +| --- | --- | +| `lamni.register_at_each_angle_hook("name", func)` | Load a hook function under `name`. | +| `lamni.at_each_angle_hook = "name"` | Activate it — the next tomo-queue job added will use it. | +| `lamni.at_each_angle_hook = None` | Deactivate it — back to normal projections for the next job. | +| `lamni.list_at_each_angle_hooks()` | Print the names of all currently registered hooks (the active one is marked). | +| `lamni.unregister_at_each_angle_hook("name")` | Remove a hook by name. | + +Example — queue a polarizer-modulated laminogram, then a normal one: +```python +lamni.register_at_each_angle_hook("polarizer_mod", polarizer_modulation) + +lamni.tomo_parameters() # set up the scan parameters as usual +lamni.at_each_angle_hook = "polarizer_mod" # activate the hook +lamni.tomo_queue_add("polarizer run") + +lamni.at_each_angle_hook = None # back to normal -- do not skip this! +lamni.tomo_queue_add("plain follow-up scan") + +lamni.tomo_queue_execute() # runs both, hook active only for the first +``` +**Do not forget to reset `lamni.at_each_angle_hook = None`** before adding a job +that should run normally — each queued job remembers whatever `at_each_angle_hook` +was set to at the moment it was added (like every other tomo parameter), so a job +added right after a hook-using one without resetting it first will silently run with +that hook still active. + +Registered hooks are **session-only** — they live in memory and do not survive a +kernel restart. If `tomo_queue_execute()` reaches a job whose `at_each_angle_hook` +isn't registered in the current session (e.g. after restarting BEC), it stops with a +clear error rather than silently running a plain scan; re-run +`register_at_each_angle_hook()` for that hook, then call `tomo_queue_execute()` +again to resume. + +**Editing a hook after registering it:** `register_at_each_angle_hook()` stores +whichever function object you pass it at that moment — it is not a live link to the +function's name. If you edit the function's source and re-run the `def` in your +session but do **not** call `register_at_each_angle_hook()` again, the *old* version +keeps running. Always re-register after an edit. + +**GUI:** the tomo parameters panel has an "At-each-angle hook" dropdown, listing +whatever is currently registered from the CLI (`register_at_each_angle_hook()`) — +the GUI process can't register hooks itself, only select one by name for the job +you're about to submit or add to the queue. Pick "None (default)" for a normal scan. +If a job's stored hook isn't currently registered anywhere, it still shows up in the +dropdown labeled "(not registered in this session)" so it's never silently hidden. + +**Unregistering does not clear `at_each_angle_hook`.** `unregister_at_each_angle_hook()` +only removes the function from this session's registry — if `at_each_angle_hook` (or a +queued job) still names it, that reference is untouched; it just becomes an +unregistered name, flagged as such wherever it's shown (CLI, GUI dropdown). Set +`lamni.at_each_angle_hook = None` (or the GUI dropdown's "None (default)") explicitly +if you want to actually clear the selection, not just remove the hook it points to. + ### Tips and Tricks #### Reset corrections diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py new file mode 100644 index 0000000..17c5c32 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py @@ -0,0 +1,127 @@ +"""Regression test for LamNI.sub_tomo_scan()'s actual angle generation. + +Mirrors test_flomni_tomo_angles.py's style of testing angle math directly, +without a live BEC session. Guards against two bugs fixed this session: + +1. A full 360-degree sweep's closing angle (start_angle + 360) is the same + physical angle as its start, so sub_tomo_scan() must generate exactly N + unique angles per sub-tomogram (endpoint=False), not N+1. +2. The inter-sub-tomogram phase offset must be a fraction of the ACHIEVABLE + step (post int() truncation), not the raw configured tomo_angle_stepsize + -- otherwise sub-tomograms combined in pairs/quads/all-8 land on + unevenly-spaced angles instead of the intended progressively finer, even + grid at every combination level (mirrors Flomni._subtomo_angle_plan()'s + same requirement). +""" + +import numpy as np +import pytest + +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy + +STEPSIZES = [10.0, 7.0, 25.0, 12.5] + + +class FakeClient: + """Minimal in-memory stand-in for BEC's global-var store.""" + + def __init__(self): + self._vars = {} + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + +def make_lamni(tomo_angle_stepsize: float) -> LamNI: + """Bare LamNI instance with only what sub_tomo_scan() needs to run its + angle-generation logic, bypassing __init__'s heavy side effects.""" + obj = object.__new__(LamNI) + obj.client = FakeClient() + obj._progress_proxy = _ProgressProxy(obj.client) + obj.tomo_angle_stepsize = tomo_angle_stepsize + obj.tomo_id = -1 + obj._write_subtomo_to_scilog = lambda subtomo_number: None + return obj + + +@pytest.mark.parametrize("stepsize", STEPSIZES) +@pytest.mark.parametrize("subtomo_number", range(1, 9)) +def test_sub_tomo_scan_generates_no_duplicate_angles(stepsize, subtomo_number): + lamni = make_lamni(stepsize) + recorded = [] + lamni._tomo_scan_at_angle = lambda angle, subtomo: recorded.append(float(angle)) + + lamni.sub_tomo_scan(subtomo_number) + + N, _achievable_step, _total = lamni._tomo_type1_actual_grid() + assert len(recorded) == N, "sub_tomo_scan() must generate exactly N angles, not N+1" + unique_mod_360 = {round(a % 360, 6) for a in recorded} + assert len(unique_mod_360) == N, "sub_tomo_scan() must not re-measure its own start angle" + + +@pytest.mark.parametrize("stepsize", STEPSIZES) +def test_sub_tomo_scan_projection_count_matches_actual_grid(stepsize): + """progress['subtomo_total_projections']/['total_projections'] must + match what actually ran, not a pre-truncation float estimate.""" + lamni = make_lamni(stepsize) + lamni._tomo_scan_at_angle = lambda angle, subtomo: None + + lamni.sub_tomo_scan(1) + + N, _achievable_step, total = lamni._tomo_type1_actual_grid() + assert lamni.progress["subtomo_total_projections"] == N + assert lamni.progress["total_projections"] == total + + +def _all_subtomo_angles(stepsize: float) -> dict[int, list[float]]: + """Run sub_tomo_scan() for all 8 sub-tomograms (real code path, not a + reimplementation) and return each one's acquired angles, in order.""" + result = {} + for n in range(1, 9): + lamni = make_lamni(stepsize) + recorded = [] + lamni._tomo_scan_at_angle = lambda angle, subtomo, _r=recorded: _r.append(float(angle)) + lamni.sub_tomo_scan(n) + result[n] = recorded + return result + + +def _assert_equally_spaced(angles, label: str) -> None: + sorted_angles = np.sort(np.asarray(angles) % 360) + diffs = np.diff(np.concatenate([sorted_angles, [sorted_angles[0] + 360]])) + assert np.allclose( + diffs, diffs[0], atol=1e-6 + ), f"{label}: angles are not equally spaced when combined -- got spacings {diffs}" + + +@pytest.mark.parametrize("stepsize", STEPSIZES) +def test_subtomo_pairs_are_equally_spaced_when_combined(stepsize): + """(1,2), (3,4), (5,6), (7,8) each combine into one evenly-spaced, + doubled-resolution grid -- this is the whole point of the phase-offset + interlacing scheme (bit-reversal table), and silently breaks if the + phase is computed from the wrong step (see module docstring).""" + angles = _all_subtomo_angles(stepsize) + for a, b in [(1, 2), (3, 4), (5, 6), (7, 8)]: + _assert_equally_spaced(angles[a] + angles[b], f"pair ({a},{b})") + + +@pytest.mark.parametrize("stepsize", STEPSIZES) +def test_subtomo_quads_are_equally_spaced_when_combined(stepsize): + """(1,2,3,4) and (5,6,7,8) each combine into one evenly-spaced, + quadrupled-resolution grid.""" + angles = _all_subtomo_angles(stepsize) + for quad in [(1, 2, 3, 4), (5, 6, 7, 8)]: + combined = sum((angles[n] for n in quad), []) + _assert_equally_spaced(combined, f"quad {quad}") + + +@pytest.mark.parametrize("stepsize", STEPSIZES) +def test_all_eight_subtomos_are_equally_spaced_when_combined(stepsize): + """The full combined set of all 8 sub-tomograms is the finest, + evenly-spaced grid -- the end goal of the interlacing scheme.""" + angles = _all_subtomo_angles(stepsize) + combined = sum((angles[n] for n in range(1, 9)), []) + _assert_equally_spaced(combined, "full (all 8)") diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py new file mode 100644 index 0000000..7d4c74c --- /dev/null +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py @@ -0,0 +1,167 @@ +"""Regression test for TomoParamsWidget's lamni-specific projection-count +math (_lamni_compute_type1/_lamni_requested_to_stepsize), mirroring +test_tomo_params_widget_math.py's style for flomni. + +Lamni's formula is NOT the same as flomni's: LamNI.sub_tomo_scan() sweeps a +plain 360 degrees per sub-tomogram (vs. flomni's fixed-180-base, +mode-independent _compute_type1/_requested_to_stepsize, which these tests +must not touch -- see that file's own docstring on why they're pinned by +name/signature) -- but is otherwise the same shape: N = int(360/stepsize) +unique angles per sub-tomogram, N*8 total. (sub_tomo_scan() used to also +acquire a duplicate closing angle per sub-tomogram -- start_angle+360 is the +same physical angle as start_angle -- fixed via endpoint=False; see +lamni.py's _tomo_type1_actual_grid().) +""" + +import pytest + +from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI +from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import ( + DEFAULTS, + LAMNI_QUEUE_PARAM_NAMES, + QUEUE_PARAM_NAMES, + SETUP_PROFILES, + _compute_type1, + _lamni_compute_type1, + _lamni_get_tomo_fov_offset, + _lamni_requested_to_stepsize, + _lamni_set_tomo_fov_offset, + _requested_to_stepsize, +) + +STEPSIZES = [10.0, 7.0, 25.0, 12.5] + + +@pytest.mark.parametrize("stepsize", STEPSIZES) +def test_lamni_compute_type1_matches_lamni_subtomo_scan(stepsize): + """Reference formula mirrors LamNI.sub_tomo_scan()'s exact (fixed) grid: + N = int(360/stepsize) unique angles per sub-tomogram, N*8 total.""" + N = int(360.0 / stepsize) + expected_total = N * 8 + expected_step = 360.0 / N + + actual_total, achievable_step, _ = _lamni_compute_type1(360, stepsize) + assert actual_total == expected_total + assert achievable_step == pytest.approx(expected_step) + + +@pytest.mark.parametrize("requested_total", [32, 64, 160, 320]) +def test_lamni_requested_to_stepsize_round_trips(requested_total): + """requested_total must already be a multiple of 8 for the round trip + to land exactly back on it -- these were chosen as N=4,8,20,40.""" + stepsize = _lamni_requested_to_stepsize(360, requested_total) + actual_total, _, _ = _lamni_compute_type1(360, stepsize) + assert actual_total == requested_total + + +def test_lamni_formula_differs_from_flomni_formula(): + """Cheap insurance against accidentally aliasing the wrong function into + a SETUP_PROFILES entry -- lamni's math must not silently match flomni's.""" + for stepsize in STEPSIZES: + flomni_total, flomni_step, _ = _compute_type1(180, stepsize) + lamni_total, lamni_step, _ = _lamni_compute_type1(360, stepsize) + assert (flomni_total, flomni_step) != (lamni_total, lamni_step) + + for requested in (24, 48, 96, 144): + assert _requested_to_stepsize(180, requested) != _lamni_requested_to_stepsize( + 360, requested + ) + + +def test_setup_profiles_param_names_match_cli_classes(): + """Guards against the known mirror-drift risk: SETUP_PROFILES' + param_names must stay in sync with each CLI class's own + _TOMO_SCAN_PARAM_NAMES (there is no shared import between bec_widgets + and the ipython-client plugins, so nothing else catches this).""" + assert set(SETUP_PROFILES["flomni"]["param_names"]) == set(Flomni._TOMO_SCAN_PARAM_NAMES) + assert set(SETUP_PROFILES["lamni"]["param_names"]) == set(LamNI._TOMO_SCAN_PARAM_NAMES) + assert set(QUEUE_PARAM_NAMES) == set(Flomni._TOMO_SCAN_PARAM_NAMES) + assert set(LAMNI_QUEUE_PARAM_NAMES) == set(LamNI._TOMO_SCAN_PARAM_NAMES) + + +def test_setup_profiles_defaults_cover_all_param_names(): + for setup, profile in SETUP_PROFILES.items(): + missing = set(profile["param_names"]) - set(profile["defaults"]) + assert not missing, f"{setup} profile is missing defaults for: {missing}" + + +class _FakeClient: + """Minimal in-memory stand-in for BEC's global-var store.""" + + def __init__(self): + self._vars = {} + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + +def test_lamni_fov_offset_round_trip_does_not_clobber_other_axis(): + """tomo_fov_offset packs both axes into one global var ([x_um, y_um]) -- + writing one axis must read-modify-write, not silently reset the other + (this is exactly the kind of bug a naive per-key implementation would + introduce).""" + client = _FakeClient() + + _lamni_set_tomo_fov_offset(client, "x", 1.5) + assert _lamni_get_tomo_fov_offset(client, "x") == pytest.approx(1.5) + assert _lamni_get_tomo_fov_offset(client, "y") == pytest.approx(0.0) + + _lamni_set_tomo_fov_offset(client, "y", -2.25) + assert _lamni_get_tomo_fov_offset(client, "x") == pytest.approx(1.5) + assert _lamni_get_tomo_fov_offset(client, "y") == pytest.approx(-2.25) + + assert client.get_global_var("tomo_fov_offset") == [1500.0, -2250.0] + + +def test_lamni_fov_offset_defaults_to_zero_when_unset(): + client = _FakeClient() + assert _lamni_get_tomo_fov_offset(client, "x") == 0.0 + assert _lamni_get_tomo_fov_offset(client, "y") == 0.0 + + +def test_lamni_offset_fields_excluded_from_param_names(): + """tomo_fovx_offset/tomo_fovy_offset are alignment values, shown in the + GUI but deliberately excluded from queue job snapshots -- must never + appear in param_names.""" + offset_keys = {key for key, *_ in SETUP_PROFILES["lamni"]["offset_fields"]} + assert offset_keys == {"tomo_fovx_offset", "tomo_fovy_offset"} + assert offset_keys.isdisjoint(SETUP_PROFILES["lamni"]["param_names"]) + assert not SETUP_PROFILES["flomni"]["offset_fields"] + + +def test_flomni_field_order_matches_pre_reorder_layout(): + """Regression guard: the field_order dispatch must reproduce flomni's + exact, unchanged field build order (this task only reordered lamni).""" + assert SETUP_PROFILES["flomni"]["field_order"] == [ + "tomo_countingtime", + "tomo_shellstep", + "fov", + "stitch", + "tomo_stitch_overlap", + "ptycho_reconstruct_foldername", + "manual_shift", + "frames_per_trigger", + "single_point", + "at_each_angle_hook", + ] + + +def test_lamni_field_order_matches_tomo_parameters_cli(): + """Mirrors lamni.tomo_parameters()'s exact print/edit order + (lamni.py:1136-1153).""" + assert SETUP_PROFILES["lamni"]["field_order"] == [ + "tomo_countingtime", + "tomo_shellstep", + "piezo_range", + "stitch", + "tomo_stitch_overlap", + "fov", + "ptycho_reconstruct_foldername", + "frames_per_trigger", + "offsets", + "at_each_angle_hook", + ]