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 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-16 13:39:08 +02:00
co-authored by Claude Sonnet 5
parent 95d46c9d49
commit bac3fa8754
7 changed files with 1223 additions and 196 deletions
@@ -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.
@@ -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).
@@ -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",
File diff suppressed because it is too large Load Diff
+185
View File
@@ -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 02 |
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
@@ -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)")
@@ -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",
]