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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user