diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md
new file mode 100644
index 0000000..17c9112
--- /dev/null
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md
@@ -0,0 +1,167 @@
+# flomni → lamni feature gaps still open (2026-07 sweep)
+
+Follow-up to `FLOMNI_TO_LAMNI_COMPARISON.md` (flomni's `AI_docs/`) and this
+folder's `TOMO_QUEUE_PORT.md`/`TOMO_PARAMS_GUI_PORT.md` — those covered the
+tomo-queue backend, the GUI, the webpage generator, and the angle-distribution
+mapping, all since ported. This sweep looked for *further* flomni features
+lamni still lacks, after the same session also ported: the end-of-scan timing
+overview + `scan_repeat`/`scan_interlock` interruption handling, tomo-queue
+tooltip filtering, the constant-shift print, `at_each_angle_hook` in
+`tomo_parameters()`, and `zero_deg_reference_at_each_subtomo` for tomo_type 1.
+
+Every item below was independently confirmed by direct grep/read against
+both files, not just taken from the sweep — file:line citations are given so
+you can jump straight to the code. Ordered roughly by how promising each is
+to port, most-actionable first. This is a findings list for triage, not an
+implementation plan — nothing here has been started.
+
+## 1. Per-projection/tomogram timing log + `scilog_last_ptycho_scans()` — missing entirely
+
+- flomni: `flomni.py:2945` (`_TIMING_LOG_DIR`), `_log_projection_timing()`
+ (`2971`), `_log_tomogram_timing()` (`3019`), `_read_last_timing_records()`
+ (`3056`), `scilog_last_ptycho_scans()` (`3082`).
+- lamni: none of these exist anywhere in `lamni.py`.
+- flomni appends a JSON-lines record per completed projection and per
+ completed tomogram (FOV, exposure, stitch, corridor, duration, scan-number
+ range) to `~/data/raw/logs/timing_statistics/*.jsonl`, feeding a future
+ scan-time-prediction model, and exposes `scilog_last_ptycho_scans(n)` — a
+ user command that writes a scilog entry summarizing the last *n*
+ projections (scan numbers/FOV/exposure/duration) with a free-text comment
+ prompt.
+- **Portable**: the mechanism only needs `_TOMO_SCAN_PARAM_NAMES` (lamni
+ already has via `TomoQueueMixin`) and generic file I/O — nothing
+ flomni-hardware-specific.
+- Size: medium — new logging infra, but the shape can be lifted close to
+ verbatim.
+
+## 2. Tomo-parameter reset offer on experiment-account change — missing entirely
+
+- flomni: `Flomni.__init__` calls `_maybe_reset_params_on_account_change()`
+ (`flomni.py:1656`, defined `1662`) — compares the live BEC account against
+ a persisted `defaults_applied_for_account` global var and, on a genuine
+ account change, offers to reset tomo params via `_set_default_tomo_params()`
+ (`1694`).
+- lamni: `LamNI.__init__` has no such call, no `defaults_applied_for_account`
+ anywhere in `lamni.py`.
+- Prevents a new user silently inheriting the previous experiment's tuned
+ FOV/stitch/etc.
+- **Portable**: pure session-lifecycle/UX logic, no hardware dependency.
+- Size: small.
+
+## 3. `collect_empty_frames()` — flat-field acquisition at the start of a new tomo scan — missing entirely
+
+- flomni: `collect_empty_frames()` (`flomni.py:2419`), called unconditionally
+ from the "new scan" branch of `tomo_scan()` (`2533`, right after
+ `write_pdf_report()`/progress reset — only on a genuinely new scan, not a
+ resume).
+- lamni: zero occurrences of `collect_empty_frames`/"empty frame"/"flat
+ field" anywhere in `LamNI/*.py`.
+- Acquires 10 flat-field images at angle 0 with the sample shifted out of the
+ beam by half the FOV, logged with `subtomo_number=0` but deliberately kept
+ out of `tomo_reconstruct()`'s queue.
+- **Judgment call, not purely a software gap**: whether this is worth porting
+ depends on whether lamni's ptycho reconstruction pipeline actually
+ uses/needs flat fields the way flomni's does — worth confirming with Mirko
+ before implementing, not just a code-porting decision.
+- Size: small-medium (geometry needs adapting to `tomo_circfov`/lamni's
+ offset properties, but the shape is a direct port).
+
+## 4. `lfzp_in()` has no "skip the reset cycle if already in position" optimization
+
+- flomni: `ffzp_in(force_feedback_reset=False)` (`flomni_optics_mixin.py:98`)
+ + `_ffzp_is_in()` (`132`) — skips the expensive feedback-disable +
+ `feedback_enable_with_reset()` cycle when the FZP doesn't actually need to
+ move, avoiding an unnecessary interferometer re-zero/position shift during
+ repeated alignment scans.
+- lamni: `lfzp_in()` (`lamni_optics_mixin.py:237`) always runs the full
+ cycle unconditionally — no `_lfzp_is_in()`-equivalent guard.
+- **Portable**: identical underlying `dev.rtx.controller.feedback_disable()`/
+ `feedback_enable_with_reset()` API, used the same way by both setups.
+- Size: trivial-to-small — add an `_lfzp_is_in()` check + a
+ `force_feedback_reset` kwarg, direct port of the pattern.
+
+## 5. No hard-stop button wired into lamni's GUI
+
+- flomni: `flomnigui_show_cameras()` (`flomni/gui_tools.py:143-150`) wires up
+ a `ConsoleButtonsWidget` (`hard_stop_device_name="ftransy"`,
+ `extra_hard_stop_device_name="foptx"`) calling the shared, generic
+ `GalilController.hard_abort_and_restore_positioning_mode()` — added
+ specifically to replace an older blind stop-all-devices broadcast that
+ could crash the scan worker thread.
+- lamni: `LamNI/gui_tools.py` has no `ConsoleButtonsWidget`/hard-stop wiring
+ at all.
+- The original commit message explicitly notes this was "scoped to flomni
+ only this session (OMNY/LamNI wiring deferred)" — a known, flagged to-do,
+ not an oversight.
+- **Portable** (the widget + `GalilController` method are already
+ generic/shared), but needs a home in lamni's GUI first — lamni has no
+ gripper-camera dock to piggyback on the way flomni does, so this needs a
+ decision on where it lives and which lamni Galil device(s) it targets.
+- Size: small-medium. Safety-relevant — worth prioritizing despite the extra
+ design step.
+
+## 6. `tomo_alignment_scan()` — no lamni equivalent, but may be architecturally superseded
+
+- flomni: `tomo_alignment_scan()` (`flomni.py:2037`) — dedicated 5-point
+ (0/45/90/135/180°) alignment tomogram, writes scan numbers to
+ `~/data/raw/logs/ptychotomoalign_scannum.txt` for an external MATLAB tool
+ (`BEC_ptycho_align`), loaded back via `get_alignment_offset()`/
+ `read_alignment_offset()` (`1439`, `1337`).
+- lamni: no `tomo_alignment_scan`/`write_alignment_scan_numbers` anywhere.
+ Instead has a *different* mechanism: `read_additional_correction()`/
+ `read_additional_correction_2()` (`lamni_alignment_mixin.py:285,291`) — a
+ lookup-table correction (`corr_pos_x/y` vs `corr_angle` bins) from an
+ externally-produced file, consumed by `compute_additional_correction()`.
+- **Needs discussion, not a clear-cut gap**: is lamni's lookup-table scheme a
+ deliberate replacement for flomni's 5-point/MATLAB-fit approach, or would
+ lamni users also want a quick dedicated alignment-tomogram command? Don't
+ assume either way — ask before scoping.
+- Size: needs its own design discussion if pursued at all.
+
+## 7. No OSA-collision-clearance warning in `lfzp_info()`
+
+- flomni: `ffzp_info()` (`flomni_optics_mixin.py:256`) compares live `fosaz`
+ against the nominal `fosaz_in` position (10 µm tolerance) and warns if the
+ OSA is currently closer to a collision than its defined IN position.
+- lamni: `lfzp_info()` (`lamni_optics_mixin.py:291`) only prints
+ sample-to-FZP distance and a diameter/focal-distance/beam-size table — no
+ collision-clearance section.
+- **Needs lamni-specific input**: the concept (warn if a movable optic is
+ closer to a known collision point than nominal) is generic, but the exact
+ formula (`33 - foptz_val` in flomni) is specific to flomni's optics
+ geometry — porting correctly needs lamni's own collision-geometry
+ constants, not just a code copy.
+- Size: small once the geometry constants are known.
+
+## 8. Large block of flomni-only methods — confirmed hardware-specific, not portable
+
+`FlomniSampleTransferMixin` (`flomni.py`, ~46 methods): `ftransfer_*`,
+gripper open/close/move, `save_reference_image()`, `laser_tracker_show_all`/
+`on`/`off`, `laser_parameters_*`, `laser_tweak`,
+`umvr_fsamy_tracked`/`umv_fsamy_tracked` — all depend on flomni's automatic
+gripper/tray sample changer and its `rtx`-integrated laser tracker, neither of
+which lamni has (lamni samples are mounted manually — existing, known
+constraint). Listed only so you can confirm none of these were expected to
+have a lamni counterpart; not recommended for porting.
+
+## 9. `zero_deg_reference_at_each_subtomo` not yet on either status webpage — shared opportunity, not a flomni-ahead-of-lamni gap
+
+Neither `flomni_webpage_generator.py`'s `_CURRENT_PARAM_KEYS`
+(`flomni_webpage_generator.py:62-76`) nor `LamNI_webpage_generator.py`'s
+`TQ_PARAM_DISPLAY` (`LamNI_webpage_generator.py:71-86`) show
+`zero_deg_reference_at_each_subtomo` (or, for lamni's tomo_type 2/3, its
+`golden_projections_at_0_deg_for_damage_estimation` sibling). Since it's
+missing symmetrically on both, there's no flomni feature to "port" here —
+just a possible small addition to both webpages if useful, now that lamni's
+tomo_type 1 property actually exists.
+
+## Checked and already at parity (not gaps)
+
+`write_to_scilog`/`_scilog_write` failure tolerance, `@scan_repeat`
+retry-skip-on-definite-error, `frames_per_trigger` validation, `corridor_size`
+conditional passing, `estimated_finish_time` progress field, measurement-ID
+in the end-of-scan scilog summary, `at_each_angle_hook` name in that same
+summary, x-ray-eye alignment image HDF5 saving, tomo-queue command-jobs/
+move/reorder-floor semantics, and `tomo_params.py`'s `SETUP_PROFILES`
+capability flags (including `has_zero_deg_reference`, now symmetric) — all
+confirmed present and equivalent on both sides.
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/HW_COMMISSIONING_SESSION_2026-07.md b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/HW_COMMISSIONING_SESSION_2026-07.md
new file mode 100644
index 0000000..82783fa
--- /dev/null
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/HW_COMMISSIONING_SESSION_2026-07.md
@@ -0,0 +1,134 @@
+# flomni/lamni parity fixes — 2026-07 session summary
+
+Branch `fixes/lamni_hw_commissioning`, commits `6c3d603..e3c979a`. Very brief
+by design — `git show ` on any commit below has the full reasoning and
+diff; that's the source of truth if something needs revisiting.
+
+## What was done
+
+1. **`6c3d603`** — lamni: end-of-scan timing overview (total time / time lost
+ to gaps) + scilog write, matching flomni. `_tomo_scan_at_angle()` wrapped in
+ `@scan_repeat` so any interruption (not just the one `AlarmBase` case
+ already handled) redoes the whole projection. `scan_interlock` now enabled
+ at `tomo_scan()` start (was flomni-only before).
+2. **`cdfc023`** — tomo queue hover tooltip no longer shows golden-ratio
+ fields on a non-golden job (or type-1-only fields on a golden job).
+3. **`382e325`** — the manual/constant shift is now printed alongside
+ correction 1/2 + x-ray-eye correction during a projection, both setups.
+4. **`80f6972`** — lamni's `tomo_parameters()` now shows the active
+ `at_each_angle_hook`, matching flomni.
+5. **`abc2a3d`, `4ada8e8`** — new `tomo_queue_reacquire(job_index,
+ projection_number)`: reopen any job (even "done") at an earlier
+ projection, cascades every later job to `pending`. New `tomo_queue_resume()`
+ alias for `tomo_queue_execute()`. Docs updated in
+ `docs/user/ptychography/{lamni,flomni}.md`.
+6. **`8a7a350`** — `tomo_parameters()` (CLI) and the GUI (`tomo_params.py`,
+ live field) now warn if the current settings would produce fewer than 20
+ Fermat-scan points. Required converting `FlomniFermatScan`/
+ `LamNIFermatScan`'s position math to pure `@staticmethod`s (same
+ algorithm, no behavior change — verified against their existing
+ exact-position tests).
+7. **`c3877ec`** — new `zero_deg_reference_at_each_subtomo` for lamni's
+ tomo_type 1 (8 equally spaced sub-tomograms): an extra 0-deg reference
+ projection before every sub-tomogram where the rotation naturally passes
+ back through 0 (every **odd** sub-tomogram — `subtomo_number % 2`,
+ confirmed against lamni's actual hardware behavior, *not* flomni's
+ 360-mode `% 4 == 1`, which doesn't apply to lamni's tomo_type 1 shape),
+ plus one final shot once the tomogram completes. tomo_type 2/3 already
+ had the equivalent (`golden_projections_at_0_deg_for_damage_estimation`)
+ before this session — confirmed identical to flomni's, nothing changed
+ there. GUI needed no new UI code, just flipping lamni's
+ `has_zero_deg_reference` profile flag to `True`.
+8. **`e59a42f`** — ported flomni's per-projection/tomogram JSONL timing log
+ (`~/data/raw/logs/timing_statistics/*.jsonl`) and `scilog_last_ptycho_scans(n)`
+ to lamni, adapted for lamni's own param names (no `fovx`/`fovy`/
+ `single_point_instead_of_fermat_scan` — uses `tomo_circfov`/
+ `lamni_piezo_range_x`/`y` instead).
+9. **`b4c262d`** — lamni now offers a tomo-parameter reset when the active
+ BEC account changes (`_maybe_reset_params_on_account_change()`/
+ `_set_default_tomo_params()`), preventing a new experiment silently
+ inheriting the previous one's tuned FOV/stitch/etc.
+10. **`7863b8f`** — `lfzp_in()`/`losa_in()` now skip the move (and, for the
+ FZP, the expensive feedback-disable + reset-enable cycle) when already in
+ position, matching flomni's `ffzp_in()`/`fosa_in()`. Also added the same
+ optimization to `losa_out()` as a lamni-side enhancement (flomni's
+ `fosa_out()` has no equivalent). **Found and fixed a real correctness
+ risk while checking every existing caller**: `x_ray_eye_align.py`'s
+ "Step 0: FZP centre" (start of a fresh alignment run) depended entirely
+ on `lfzp_in()`'s old *unconditional* reset to re-zero the interferometer
+ — now explicitly calls `lfzp_in(force_feedback_reset=True)` there so that
+ behavior is preserved regardless of the new skip-optimization.
+11. **`db18929`** — added flomni's OSA collision-clearance warning
+ (`ffzp_info()`) to lamni's `lfzp_info()`. lamni's own collision-boundary
+ constant has never been measured, so it's read from `loptz`'s device
+ config (`userParameter.collision_offset`, currently unset everywhere,
+ including the simulated config) via a new non-raising
+ `_get_user_param_optional()` helper — prints a clear "not commissioned"
+ warning and skips the numeric estimate until someone adds the real
+ measured value to the config. Deliberately **not** a CLI-settable
+ parameter (per your explicit correction) — this is deployment-level
+ hardware calibration.
+12. **`12b2538`, `e3c979a`** — new `lamni.tomo_alignment_scan()`, replacing
+ the awkward documented workaround (configure a full tomo_type-1 setup
+ with 96 projections, launch just `sub_tomo_scan(1, 0)`) with a dedicated
+ command mirroring flomni's: adjust `tomo_parameters()`, then call
+ `tomo_alignment_scan()` directly — no tomo_type/sub-tomogram bookkeeping
+ needed. Runs 12 points across the full 360° (lamni has no 180° symmetry
+ the way flomni does, so covers the whole circle rather than flomni's
+ 5-point/180° scan). Aborts if x-ray-eye alignment hasn't been done yet.
+ Writes the same 4-line scan-number/angle/offset log flomni's version
+ does, to `~/data/raw/logs/ptychotomoalign_scannum.txt`, for
+ **`BEC_ptycho_align`** (not `SPEC_ptycho_align.m` as initially assumed —
+ corrected in `e3c979a`). `docs/user/ptychography/lamni.md`'s "Fine
+ alignment" section rewritten to match. Known scope limit: calls
+ `leye_out()` unconditionally (flomni's equivalent skip-if-already-set-up
+ optimization has no lamni-side helpers to reuse yet — separate follow-up
+ if needed).
+
+**Parked, not done:** `DataDrivenLamNI`'s broken `_start_beam_check()` calls
+(missing mixin) — deferred on request, separate task if ever needed.
+
+## What to test to confirm everything's still healthy
+
+**Automated** — should show `458 passed, 15 skipped`:
+```
+/opt/bec_deployments/production/bec_venv/bin/python -m pytest tests/ -q
+```
+
+**Manual, on a simulated or real session, before relying on this at the
+beamline:**
+
+- `lamni.tomo_scan()` (short run): "Tomoscan finished" banner + timing
+ summary at the end; force a mid-scan exception once and confirm the same
+ projection retries instead of the scan aborting.
+- `lamni.tomo_queue_add()` a couple of jobs → `tomo_queue_execute()` →
+ `tomo_queue_reacquire(0, )` on a finished job → confirm status flips and
+ later jobs go `pending` → `tomo_queue_resume()` re-runs them.
+- `lamni.tomo_parameters()`: confirm the hook line (if one's registered),
+ the "Estimated Fermat scan points" line, and the account-change reset
+ prompt (switch `bec.active_account` and re-instantiate `LamNI`) all behave
+ as expected.
+- GUI (`TomoParamsWidget`, lamni active): hover a queued job and confirm only
+ relevant fields show; confirm the new Fermat-points field updates live and
+ turns orange under a tiny-FOV condition; confirm the "0° reference each
+ sub-tomo" checkbox (type 1) is now available.
+- A real `lamni_fermat_scan` with too few points should still hit its
+ `ScanAbortion` safety net unchanged (the warning above is advisory, not a
+ replacement for it).
+- `lamni.zero_deg_reference_at_each_subtomo = True`, run a short type-1
+ `tomo_scan()`, and confirm the extra 0-deg shots land right before
+ sub-tomograms 1/3/5/7 plus one final shot after sub-tomogram 8.
+- Call `lamni.lfzp_in()`/`losa_in()`/`losa_out()` twice in a row and confirm
+ the second call skips the move; run `start_x_ray_eye_alignment()` twice in
+ a row (FZP already in position the second time) and confirm the
+ interferometer is still freshly reset at Step 0 both times.
+- `lamni.lfzp_info()`: confirm the OSA "not commissioned" warning appears
+ (no `collision_offset` configured yet); once a real value is measured and
+ added to `loptz`'s device config, confirm the collision-clearance numbers
+ print sensibly.
+- `lamni.tomo_alignment_scan()`: with `tomo_fit_xray_eye` unset, confirm it
+ aborts; with it set, confirm 12 scans run across 360°, the scan-number
+ file is written, and the console/scilog summary is correct. Confirm
+ `BEC_ptycho_align` can actually consume the written file format (this is
+ the one item in this batch whose exact file-format compatibility with the
+ real Matlab tool hasn't been confirmed against real data).
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
index 754bb69..caa39f6 100644
--- 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
@@ -256,3 +256,4 @@ changed.
apply to flomni's identical, already-working call in the same harness.
- No live GUI verification performed (per Mirko's instruction) — verified
manually by Mirko instead.
+-note Data10/ folder is replaced by data/raw/ folder
\ No newline at end of file
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py
index 3bdafc7..1b07dfa 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py
@@ -76,7 +76,7 @@ class DataDrivenLamNI(LamNI):
self,
subtomo_start=1,
start_index=None,
- fname="~/Data10/data_driven_config/datadriven_params.h5",
+ fname="~/data/raw/data_driven_config/datadriven_params.h5",
):
"""Start a data-driven tomo scan.
@@ -88,6 +88,9 @@ class DataDrivenLamNI(LamNI):
bec = builtins.__dict__.get("bec")
scans = builtins.__dict__.get("scans")
+ bec.builtin_actors.scan_interlock.trigger_setting = "restart_scan"
+ bec.builtin_actors.scan_interlock.enabled = True
+
fname = os.path.expanduser(fname)
if not os.path.exists(fname):
raise FileNotFoundError(f"Could not find datadriven params file in {fname}.")
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py
index ed118fe..e5efa97 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py
@@ -250,6 +250,15 @@ class LamniGuiTools:
else:
eta_display = "N/A"
+ # Format estimated finish (wall-clock) time
+ finish_str = self.progress.get("estimated_finish_time")
+ if finish_str is not None:
+ finish_display = datetime.datetime.fromisoformat(finish_str).strftime(
+ "%Y-%m-%d %H:%M:%S"
+ )
+ else:
+ finish_display = "N/A"
+
text = (
f"Progress report:\n"
f" Tomo type: {self.progress['tomo_type']}\n"
@@ -260,8 +269,15 @@ class LamniGuiTools:
f" Current projection within subtomo: {self.progress['subtomo_projection']}\n"
f" Total projections per subtomo: {int(self.progress['subtomo_total_projections'])}\n"
f" Scan started: {start_display}\n"
- f" Est. remaining: {eta_display}"
+ f" Est. remaining: {eta_display}\n"
+ f" Est. finish: {finish_display}"
)
+ # self._describe_active_hook() comes from LamNI itself (this is a
+ # mixin, always combined with it) -- shown only when a hook is
+ # actually active, mirrors Flomni's identical center-label note.
+ hook_description = self._describe_active_hook()
+ if hook_description:
+ text += f"\n Hook: {hook_description}"
self.progressbar.set_center_label(text)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 7340e18..cf71c76 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -1,5 +1,6 @@
import builtins
import datetime
+import json
import os
import subprocess
import time
@@ -9,6 +10,7 @@ import numpy as np
from bec_lib import bec_logger
from bec_lib.alarm_handler import AlarmBase
from bec_lib.pdf_writer import PDFWriter
+from bec_lib.scan_repeat import scan_repeat
from typeguard import typechecked
from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import (
@@ -107,6 +109,11 @@ class _ProgressProxy:
return self._load()
+class LamNIError(Exception):
+ """A definite, non-transient tomo-scan failure (bad config, unmet
+ precondition, ...) that should never be retried by @scan_repeat."""
+
+
class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools):
# Ordered set of all global-var-backed tomo scan parameters snapshotted
# by tomo_queue_add()/restored by tomo_queue_execute(). Lamni's own
@@ -132,6 +139,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
"golden_ratio_bunch_size",
"golden_max_number_of_projections",
"golden_projections_at_0_deg_for_damage_estimation",
+ "zero_deg_reference_at_each_subtomo",
"corridor_size",
"at_each_angle_hook",
)
@@ -230,10 +238,77 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
)
self._webpage_gen.start()
+ self._maybe_reset_params_on_account_change()
+
def set_web_password(self, password: str) -> None:
"""Set the web password for the current BEC account."""
self._webpage_gen.set_web_password(password)
+ def _maybe_reset_params_on_account_change(self) -> None:
+ """Offer a tomo-parameter reset when the active account has changed.
+
+ Called at BEC session start. The account for which defaults were last
+ applied is stored in the global var ``defaults_applied_for_account`` so
+ it survives client restarts. Only a genuine change of account triggers
+ the (interactive) reset; the same account is a silent no-op, so
+ restarting a client within the same experiment never disturbs tuned
+ parameters. Mirrors Flomni._maybe_reset_params_on_account_change()
+ exactly.
+ """
+ bec = builtins.__dict__.get("bec")
+ try:
+ account = bec.active_account
+ except Exception as exc:
+ print(f"account-change check skipped: cannot read active_account: {exc}")
+ return
+ if not account:
+ return
+
+ last_account = self.client.get_global_var("defaults_applied_for_account")
+ if account == last_account:
+ return
+
+ if self.OMNYTools.yesno(
+ f"New account '{account}' detected (previous: '{last_account}').\n"
+ "Reset tomo parameters to defaults for the new experiment?",
+ "y",
+ ):
+ self._set_default_tomo_params()
+ print(f"Tomo parameters reset to defaults for account '{account}'.")
+ self.client.set_global_var("defaults_applied_for_account", account)
+
+ def _set_default_tomo_params(self) -> None:
+ """Write all tomo scan parameters back to their default values.
+
+ These are the same baseline values used as the getter fallbacks. Per-
+ sample alignment state (corrections, alignment offsets) is
+ deliberately not reset here, as it is overwritten by the next
+ alignment anyway. Mirrors Flomni._set_default_tomo_params(), adapted
+ to lamni's own param names -- no fovx/fovy/stitch_x/stitch_y/
+ tomo_angle_range/single_point_random_shift_max; lamni has
+ tomo_circfov/lamni_stitch_x/y/lamni_piezo_range_x/y instead, is
+ always 360 degrees, and has no single-point acquisition mode.
+ """
+ self.tomo_shellstep = 1
+ self.tomo_countingtime = 0.1
+ self.manual_shift_x = 0.0
+ self.manual_shift_y = 0.0
+ self.tomo_circfov = 0.0
+ self.tomo_type = 1
+ self.corridor_size = -1
+ self.lamni_stitch_x = 0
+ self.lamni_stitch_y = 0
+ self.ptycho_reconstruct_foldername = "ptycho_reconstruct"
+ self.tomo_angle_stepsize = 10.0
+ self.golden_max_number_of_projections = 1000.0
+ self.tomo_stitch_overlap = 0.2
+ self.golden_projections_at_0_deg_for_damage_estimation = 0
+ self.zero_deg_reference_at_each_subtomo = False
+ self.golden_ratio_bunch_size = 20
+ self.frames_per_trigger = 1
+ self.lamni_piezo_range_x = 20
+ self.lamni_piezo_range_y = 20
+
# ------------------------------------------------------------------
# Special angles
# ------------------------------------------------------------------
@@ -650,6 +725,24 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
"golden_projections_at_0_deg_for_damage_estimation", val
)
+ @property
+ def zero_deg_reference_at_each_subtomo(self):
+ """If True (tomo_type == 1 only), an additional projection at
+ exactly 0 degrees is acquired at the start of every odd sub-tomogram
+ -- every time the rotation passes back through 0 degrees -- and
+ once more after the final (8th) sub-tomogram completes. Useful for
+ tracking radiation damage over time. Mirrors
+ golden_projections_at_0_deg_for_damage_estimation, which provides
+ the same functionality for tomo_type 2/3."""
+ val = self.client.get_global_var("zero_deg_reference_at_each_subtomo")
+ if val is None:
+ return False
+ return val
+
+ @zero_deg_reference_at_each_subtomo.setter
+ def zero_deg_reference_at_each_subtomo(self, val: bool):
+ self.client.set_global_var("zero_deg_reference_at_each_subtomo", val)
+
@property
def sample_name(self):
val = self.client.get_global_var("sample_name")
@@ -678,16 +771,215 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
except Exception:
logger.warning("Failed to write to scilog.")
- def _write_subtomo_to_scilog(self, subtomo_number):
- bec = builtins.__dict__.get("bec")
- if self.tomo_id > 0:
- tags = ["BEC_subtomo", self.sample_name, f"tomo_id_{self.tomo_id}"]
- else:
- tags = ["BEC_subtomo", self.sample_name]
- self.write_to_scilog(
- f"Starting subtomo: {subtomo_number}. First scan number: {bec.queue.next_scan_number}.",
- tags,
+ _TIMING_LOG_DIR = "~/data/raw/logs/timing_statistics"
+ _TIMING_SETUP = "lamni"
+ _PROJECTION_TIMING_LOG = "projection_timing_log.jsonl"
+ _TOMOGRAM_TIMING_LOG = "tomogram_timing_log.jsonl"
+
+ def _append_timing_record(self, filename: str, record: dict) -> None:
+ """Append one JSON record as a line to a timing-statistics log file.
+
+ Deliberately best-effort: a failure to write a timing record must
+ never abort or interfere with an in-progress measurement, so any
+ exception here is caught and logged rather than propagated. The
+ files live in a dedicated subfolder (self._TIMING_LOG_DIR), separate
+ from tomography_scannumbers.txt and any other existing log, and are
+ pure append-only JSONL (one JSON object per line) so they are
+ trivial to load later with pandas.read_json(..., lines=True) for the
+ eventual scan-time prediction model.
+ """
+ try:
+ log_dir = os.path.expanduser(self._TIMING_LOG_DIR)
+ os.makedirs(log_dir, exist_ok=True)
+ log_file = os.path.join(log_dir, filename)
+ with open(log_file, "a+") as out_file:
+ out_file.write(json.dumps(record) + "\n")
+ except Exception as exc: # pylint: disable=broad-except
+ logger.warning(f"Failed to write timing record to {filename}: {exc}")
+
+ def _log_projection_timing(
+ self,
+ angle: float,
+ subtomo_number: int,
+ duration_s: float,
+ start_scan_number: int,
+ end_scan_number: int,
+ ) -> None:
+ """Write one per-projection timing record.
+
+ A "projection" here is one completed _at_each_angle() call (all the
+ stitch tiles for one angle). Only successfully completed projections
+ reach this point: because _tomo_scan_at_angle is wrapped in
+ @scan_repeat, a failed attempt re-invokes the whole method and never
+ returns to the logging call, so retried attempts are naturally
+ excluded and only the final successful duration is recorded.
+
+ The parameters logged are exactly the scan settings that plausibly
+ drive the duration (piezo range, step, counting time, burst frames,
+ stitch tiling, circular FOV crop, corridor) -- the raw feature set
+ for the prediction model. Mirrors Flomni._log_projection_timing()
+ (fovx/fovy/stitch_x/stitch_y/single_point_instead_of_fermat_scan
+ there become lamni_piezo_range_x/y/lamni_stitch_x/y/tomo_circfov
+ here -- lamni has no single-point acquisition mode at all).
+ """
+ record = {
+ "setup": self._TIMING_SETUP,
+ "timestamp": datetime.datetime.now().isoformat(),
+ "duration_s": duration_s,
+ "angle": angle,
+ "subtomo_number": subtomo_number,
+ "start_scan_number": start_scan_number,
+ "end_scan_number": end_scan_number,
+ "n_scans": end_scan_number - start_scan_number,
+ "tomo_type": self.tomo_type,
+ "lamni_piezo_range_x": self.lamni_piezo_range_x,
+ "lamni_piezo_range_y": self.lamni_piezo_range_y,
+ "tomo_circfov": self.tomo_circfov,
+ "tomo_shellstep": self.tomo_shellstep,
+ "tomo_countingtime": self.tomo_countingtime,
+ "frames_per_trigger": self.frames_per_trigger,
+ "lamni_stitch_x": self.lamni_stitch_x,
+ "lamni_stitch_y": self.lamni_stitch_y,
+ "tomo_stitch_overlap": self.tomo_stitch_overlap,
+ "corridor_size": self.corridor_size,
+ }
+ self._append_timing_record(self._PROJECTION_TIMING_LOG, record)
+
+ def _log_tomogram_timing(self) -> None:
+ """Write one per-tomogram timing record at the end of a tomo_scan().
+
+ Captures a full snapshot of the scan parameters (reusing
+ _TOMO_SCAN_PARAM_NAMES -- the single source of truth for "every
+ parameter that affects how the scan runs") together with the
+ tomogram-level wall-clock timing already tracked in self.progress:
+ total elapsed, the accumulated idle time detected from inter-
+ projection gaps, and the active (elapsed-minus-idle) measurement
+ time. Standalone by design -- no tomo_id / sample-database cross-
+ reference for now. Mirrors Flomni._log_tomogram_timing() exactly.
+ """
+ start_str = self.progress.get("tomo_start_time")
+ now = datetime.datetime.now()
+ elapsed_s = None
+ if start_str is not None:
+ try:
+ elapsed_s = (now - datetime.datetime.fromisoformat(start_str)).total_seconds()
+ except (ValueError, TypeError):
+ elapsed_s = None
+
+ idle_s = self.progress.get("accumulated_idle_time", 0.0)
+ active_s = elapsed_s - idle_s if elapsed_s is not None else None
+
+ record = {
+ "setup": self._TIMING_SETUP,
+ "timestamp": now.isoformat(),
+ "tomo_start_time": start_str,
+ "elapsed_s": elapsed_s,
+ "accumulated_idle_time_s": idle_s,
+ "active_s": active_s,
+ "total_projections": self.progress.get("total_projections"),
+ "tomo_type_label": self.progress.get("tomo_type"),
+ "params": {name: getattr(self, name) for name in self._TOMO_SCAN_PARAM_NAMES},
+ }
+ self._append_timing_record(self._TOMOGRAM_TIMING_LOG, record)
+
+ def _read_last_timing_records(self, number_of_scans: int) -> list[dict]:
+ """Return the last ``number_of_scans`` projection timing records.
+
+ Reads back the append-only projection timing log written by
+ _log_projection_timing(). Returns a list ordered oldest-to-newest
+ (i.e. in acquisition order), or an empty list if the log is missing
+ or unreadable.
+ """
+ log_file = os.path.join(
+ os.path.expanduser(self._TIMING_LOG_DIR), self._PROJECTION_TIMING_LOG
)
+ records: list[dict] = []
+ try:
+ with open(log_file, "r") as in_file:
+ lines = [ln for ln in in_file if ln.strip()]
+ for line in lines[-number_of_scans:]:
+ try:
+ records.append(json.loads(line))
+ except json.JSONDecodeError:
+ continue
+ except FileNotFoundError:
+ print(f"scilog_last_scans: timing log not found at {log_file}")
+ except Exception as exc: # pylint: disable=broad-except
+ print(f"scilog_last_scans: could not read timing log: {exc}")
+ return records
+
+ def scilog_last_ptycho_scans(self, number_of_scans: int = 1) -> None:
+ """Write a scilog entry with a user comment and recent scan info.
+
+ The user is prompted for a free-text comment, which appears at the
+ top of the entry. For each of the last ``number_of_scans`` projections
+ (1 to 4) the entry then lists the scan number(s), field of view
+ (piezo range), exposure (counting) time and scan duration, read from
+ the projection timing log. Pass ``number_of_scans=0`` to send a
+ comment only, with no scan information.
+
+ Only ptycho projections write a timing record, so non-ptycho scans do
+ not appear; if fewer records exist than requested, whatever is
+ available is used and a note is added.
+ """
+ if not isinstance(number_of_scans, int) or isinstance(number_of_scans, bool):
+ print("scilog_last_scans: number_of_scans must be an integer between 0 and 4.")
+ return
+ if not 0 <= number_of_scans <= 4:
+ print("scilog_last_scans: number_of_scans must be between 0 and 4.")
+ return
+
+ records = self._read_last_timing_records(number_of_scans) if number_of_scans else []
+ if number_of_scans and not records:
+ print(
+ "scilog_last_scans: no scans found in the timing log; "
+ "sending a comment-only entry."
+ )
+
+ comment = input("Enter a comment for the scilog entry: ").strip()
+ if not comment and not records:
+ print("scilog_last_scans: empty comment and no scans — nothing to write.")
+ return
+
+ lines = []
+ if comment:
+ lines.append(f"{comment}")
+
+ if records:
+ if comment:
+ lines.append("")
+ if len(records) < number_of_scans:
+ lines.append(
+ f"LamNI summary of the last {len(records)} scan(s) "
+ f"(only {len(records)} of {number_of_scans} requested were found):"
+ )
+ else:
+ lines.append("LamNI parameters:")
+ lines.append("")
+ for rec in records:
+ start = rec.get("start_scan_number")
+ end = rec.get("end_scan_number")
+ if start is not None and end is not None and end - start > 1:
+ scan_str = f"{start}-{end - 1}"
+ elif start is not None:
+ scan_str = f"{start}"
+ else:
+ scan_str = "?"
+ fovx = rec.get("lamni_piezo_range_x")
+ fovy = rec.get("lamni_piezo_range_y")
+ fov_str = f"{fovx} x {fovy} um" if fovx is not None and fovy is not None else "?"
+ exposure = rec.get("tomo_countingtime")
+ exp_str = f"{exposure} s" if exposure is not None else "?"
+ duration = rec.get("duration_s")
+ dur_str = self._format_duration(duration) if duration is not None else "?"
+ lines.append(
+ f"scan {scan_str}: FOV {fov_str}, exposure {exp_str}, duration {dur_str}"
+ )
+
+ content = "\n".join(lines)
+ print(content)
+
+ self.write_to_scilog(content, ["tomoscan"])
def _write_tomo_scan_number(self, scan_number: int, angle: float, subtomo_number: int) -> None:
tomo_scan_numbers_file = os.path.expanduser("~/data/raw/logs/tomography_scannumbers.txt")
@@ -697,23 +989,8 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
f" {self.tomo_id} {subtomo_number} {0} {'lamni'}\n"
)
- # ------------------------------------------------------------------
- # Sample database — delegated to TomoIDManager
- # ------------------------------------------------------------------
-
- def add_sample_database(
- self, samplename, date, eaccount, scan_number, setup, sample_additional_info, user
- ):
- """Add a sample to the OMNY sample database and retrieve the tomo id."""
- return self.tomo_id_manager.register(
- sample_name=samplename,
- date=date,
- eaccount=eaccount,
- scan_number=scan_number,
- setup=setup,
- additional_info=sample_additional_info,
- user=user,
- )
+ # add_sample_database() moved to TomoQueueMixin (OMNY_shared) -- was
+ # byte-identical to Flomni's own copy.
# ------------------------------------------------------------------
# Scan projection
@@ -725,6 +1002,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
additional_correction = self.compute_additional_correction(angle)
additional_correction_2 = self.compute_additional_correction_2(angle)
correction_xeye_mu = self.lamni_compute_additional_correction_xeye_mu(angle)
+ print(f"Constant shift: x={self.manual_shift_x}, y={self.manual_shift_y}")
self._current_scan_list = []
@@ -764,6 +1042,100 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
optim_trajectory_corridor=corridor_size,
)
+ def write_alignment_scan_numbers(self, first_scan: int) -> None:
+ """Write the scan-number/angle/offset log consumed by BEC_ptycho_align.
+
+ Mirrors Flomni.write_alignment_scan_numbers() exactly (same 4-line
+ format), adapted to lamni's own alignment-scan angle set (12 points
+ across the full 360 degrees, see tomo_alignment_scan()) and its own
+ x-ray-eye-fit offset source (lamni_compute_additional_correction_xeye_mu()
+ instead of get_alignment_offset()). Assumes exactly one scan per
+ alignment point (no stitching) -- same assumption flomni's own
+ version makes.
+ """
+ angles = list(np.linspace(0, 360, num=12, endpoint=False))
+ scans = [first_scan + k for k in range(len(angles))]
+
+ x_vals = []
+ for angle in angles:
+ x, _y = self.lamni_compute_additional_correction_xeye_mu(angle)
+ x_vals.append(x)
+
+ zeros = [0] * len(angles)
+
+ file = os.path.expanduser("~/data/raw/logs/ptychotomoalign_scannum.txt")
+ os.makedirs(os.path.dirname(file), exist_ok=True)
+ with open(file, "w") as f:
+ f.write(" ".join(map(str, scans)) + "\n")
+ f.write(" ".join(map(str, angles)) + "\n")
+ f.write(" ".join(f"{x:.2f}" for x in x_vals) + "\n")
+ f.write(" ".join(map(str, x_vals)) + "\n")
+
+ def tomo_alignment_scan(self) -> None:
+ """Perform a laminogram alignment scan: a quick ptychography scan at
+ 12 angles evenly spaced across the full 360 degrees, using whatever
+ tomo_parameters() are currently set (FOV/step/counting time --
+ tomo_type and requested projection count are ignored, same as
+ Flomni.tomo_alignment_scan()). Collects all scan numbers acquired
+ during the alignment, writes them (with angles and the existing
+ x-ray-eye-fit offset at each angle) to
+ ~/data/raw/logs/ptychotomoalign_scannum.txt for BEC_ptycho_align,
+ prints them, and creates a scilog entry summarising the alignment
+ scan numbers.
+ """
+ if self.client.get_global_var("tomo_fit_xray_eye") is None:
+ print("It appears that the xrayeye alignment was not performed or loaded. Aborting.")
+ return
+
+ bec = builtins.__dict__.get("bec")
+ dev = builtins.__dict__.get("dev")
+
+ self.leye_out()
+
+ self.write_alignment_scan_numbers(bec.queue.next_scan_number)
+
+ angles = list(np.linspace(0, 360, num=12, endpoint=False))
+ alignment_scan_numbers = []
+
+ for angle in angles:
+ successful = False
+ print(f"Starting LamNI scan for angle {angle}")
+ while not successful:
+ try:
+ start_scan_number = bec.queue.next_scan_number
+ self.tomo_scan_projection(angle)
+ except AlarmBase as exc:
+ if exc.alarm_type == "TimeoutError":
+ bec.queue.request_queue_reset()
+ time.sleep(2)
+ else:
+ raise exc
+
+ end_scan_number = bec.queue.next_scan_number
+ for scan_nr in range(start_scan_number, end_scan_number):
+ alignment_scan_numbers.append(scan_nr)
+
+ successful = True
+
+ umv(dev.lsamrot, 0)
+ self.OMNYTools.printgreenbold(
+ "\n\nAlignment scan finished. Please run BEC_ptycho_align and load the new fit"
+ " by lamni.read_additional_correction()."
+ )
+
+ scan_list_str = ", ".join(str(s) for s in alignment_scan_numbers)
+ print(f"\nAlignment scan numbers ({len(alignment_scan_numbers)} total): {scan_list_str}")
+ print(f"Angles: {', '.join(str(a) for a in angles)}")
+
+ scilog_content = (
+ f"Alignment scan finished.\n"
+ f"Sample: {self.sample_name}\n"
+ f"Number of alignment scans: {len(alignment_scan_numbers)}\n"
+ f"Alignment scan numbers: {scan_list_str}\n"
+ )
+ print(scilog_content)
+ self.write_to_scilog(scilog_content, ["alignmentscan"])
+
def tomo_reconstruct(self, base_path="~/data/raw/logs/reconstruction_queue"):
"""write the tomo reconstruct file for the reconstruction queue"""
bec = builtins.__dict__.get("bec")
@@ -776,14 +1148,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
def _at_each_angle(self, angle: float) -> None:
hook_name = self.at_each_angle_hook
if hook_name:
- hook = self._at_each_angle_hooks.get(hook_name)
- if hook is None:
- raise ValueError(
- f"at_each_angle_hook '{hook_name}' is not registered in this "
- "session. Hooks are session-only and do not survive a kernel "
- f"restart -- call register_at_each_angle_hook({hook_name!r}, "
- ") again, then re-run tomo_queue_execute() to resume."
- )
+ hook = self._resolve_at_each_angle_hook(hook_name, LamNIError)
hook(self, angle)
return
@@ -852,11 +1217,22 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
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)
+ @staticmethod
+ def _subtomo_angle_plan(subtomo_number, tomo_angle_stepsize, start_angle=None):
+ """Pure angle-generation logic for one sub-tomogram (tomo_type 1
+ only, 360-degree span). No device I/O / progress side effects --
+ kept separate from sub_tomo_scan() so the angle math is directly
+ unit-testable and reusable (e.g. by tomo_queue_reacquire()'s
+ projection-number resolver). Mirrors Flomni._subtomo_angle_plan()
+ (no tomo_angle_range/forward-reverse split -- lamni always scans
+ the full 360 degrees).
- N, achievable_step, total_projections = self._tomo_type1_actual_grid()
+ Returns:
+ angles (np.ndarray): the N angles (degrees) for this sub-tomogram.
+ N (int): number of projections in this (and every) sub-tomogram.
+ """
+ N = int(360.0 / tomo_angle_stepsize)
+ achievable_step = 360.0 / N
if start_angle is None:
# Phase offset must be a fraction of the ACHIEVABLE step (after
@@ -879,6 +1255,36 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
if not (subtomo_number % 2):
angles = np.flip(angles)
+ return angles, N
+
+ def _resolve_type1_projection(self, projection_number: int) -> tuple[int, float]:
+ """Map a flat 0-indexed tomo_type-1 projection number to
+ (subtomo_start, start_angle), for tomo_queue_reacquire().
+
+ The mapping mirrors exactly how sub_tomo_scan() itself numbers
+ projections (self.progress["projection"] = (subtomo_number - 1) * N
+ + subtomo_projection), so a projection_number read off e.g. the
+ tomography_scannumbers.txt log resolves to the same angle
+ sub_tomo_scan() would have produced at that position.
+ """
+ _, N = self._subtomo_angle_plan(1, self.tomo_angle_stepsize)
+ total = N * 8
+ if not 0 <= projection_number < total:
+ raise ValueError(
+ f"projection_number must be in 0..{total - 1} for this job's grid "
+ f"(tomo_angle_stepsize={self.tomo_angle_stepsize})."
+ )
+ subtomo_number = projection_number // N + 1
+ angles, _ = self._subtomo_angle_plan(subtomo_number, self.tomo_angle_stepsize)
+ return subtomo_number, float(angles[projection_number % N])
+
+ def sub_tomo_scan(self, subtomo_number, start_angle=None):
+ """Perform one sub-tomogram (tomo_type 1 only)."""
+ angles, N = self._subtomo_angle_plan(
+ subtomo_number, self.tomo_angle_stepsize, start_angle=start_angle
+ )
+ total_projections = N * 8
+
for angle in angles:
self.progress["tomo_type"] = "Equally spaced sub-tomograms"
self.progress["subtomo"] = subtomo_number
@@ -891,6 +1297,34 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
self.progress["angle"] = angle
self._tomo_scan_at_angle(angle, subtomo_number)
+ def _subtomo_starts_near_zero(self, subtomo_number: int) -> bool:
+ """True if this sub-tomogram's own natural sweep begins near angle 0
+ -- i.e. the rotation stage passes back through 0 degrees at the
+ start of this sub-tomogram. Used to gate the
+ zero_deg_reference_at_each_subtomo damage-tracking shot: forcing
+ that shot before a sub-tomogram that doesn't actually start near 0
+ would mean a large, wasted detour.
+
+ Every odd sub-tomogram (same cadence as Flomni's 180-degree-mode
+ branch of _subtomo_starts_near_zero() -- lamni's own rotation
+ trajectory arrives back near 0 every second sub-tomogram, not every
+ fourth the way Flomni's 360-degree-mode branch does)."""
+ return bool(subtomo_number % 2)
+
+ @staticmethod
+ def _retry_unless_lamni_error(exc: Exception, attempt: int) -> bool:
+ """scan_repeat() exc_handler: retry any exception except LamNIError.
+
+ LamNIError marks a definite, non-transient failure (bad config,
+ unmet precondition, ...) -- e.g. _at_each_angle() raises it when a
+ job's at_each_angle_hook name isn't registered in this session.
+ Retrying that up to max_repeats times just burns 10 attempts before
+ surfacing a much less informative TooManyScanRestarts, instead of
+ the actual, actionable error message immediately.
+ """
+ return not isinstance(exc, LamNIError)
+
+ @scan_repeat(max_repeats=10, default=True, exc_handler=_retry_unless_lamni_error)
def _tomo_scan_at_angle(self, angle, subtomo_number):
successful = False
error_caught = False
@@ -937,8 +1371,10 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
num_repeats = 1
try:
start_scan_number = bec.queue.next_scan_number
+ projection_start = time.perf_counter()
for i in range(num_repeats):
self._at_each_angle(angle)
+ projection_duration = time.perf_counter() - projection_start
error_caught = False
except AlarmBase as exc:
if exc.alarm_type == "TimeoutError":
@@ -952,6 +1388,19 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
for scan_nr in range(start_scan_number, end_scan_number):
self._write_tomo_scan_number(scan_nr, angle, subtomo_number)
+ if not error_caught:
+ # Only reached for a clean completion -- an
+ # AlarmBase/TimeoutError attempt above has no reliable
+ # duration measurement, so it's excluded from the
+ # timing log rather than polluting it.
+ self._log_projection_timing(
+ angle=angle,
+ subtomo_number=subtomo_number,
+ duration_s=projection_duration,
+ start_scan_number=start_scan_number,
+ end_scan_number=end_scan_number,
+ )
+
successful = True
def _golden(self, ii, howmany_sorted, maxangle=360, reverse=False):
@@ -1013,6 +1462,10 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
bec = builtins.__dict__.get("bec")
scans = builtins.__dict__.get("scans")
+
+ bec.builtin_actors.scan_interlock.trigger_setting = "restart_scan"
+ bec.builtin_actors.scan_interlock.enabled = True
+
self._current_special_angles = self.special_angles.copy()
if (
@@ -1052,9 +1505,28 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
if self.tomo_type == 1:
self.progress["tomo_type"] = "Equally spaced sub-tomograms"
for ii in range(subtomo_start, 9):
+ if (
+ start_angle is None
+ and self._subtomo_starts_near_zero(ii)
+ and self.zero_deg_reference_at_each_subtomo
+ ):
+ # Dedicated reference shot at exactly 0 degrees, taken
+ # every time the rotation passes back through 0, for
+ # tracking radiation damage over the full tomogram.
+ # Skipped when resuming mid-sub-tomogram (start_angle
+ # given explicitly) since we're not actually passing
+ # through 0 deg at that moment.
+ self._tomo_scan_at_angle(0, ii)
self.sub_tomo_scan(ii, start_angle=start_angle)
start_angle = None
+ if self.zero_deg_reference_at_each_subtomo:
+ # Final reference shot at exactly 0 degrees once the whole
+ # tomogram is complete, giving a clean "before vs after"
+ # pair for radiation-damage comparison across the full
+ # acquisition.
+ self._tomo_scan_at_angle(0, 8)
+
elif self.tomo_type == 2:
self.progress["tomo_type"] = "Golden ratio tomography"
previous_subtomo_number = -1
@@ -1064,7 +1536,6 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
ii, self.golden_ratio_bunch_size, maxangle=360, reverse=True
)
if previous_subtomo_number != subtomo_number:
- self._write_subtomo_to_scilog(subtomo_number)
if (
subtomo_number % 2 == 1
and ii > 10
@@ -1103,7 +1574,6 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
ii, int(360 / self.tomo_angle_stepsize), maxangle=360, reverse=True
)
if previous_subtomo_number != subtomo_number:
- self._write_subtomo_to_scilog(subtomo_number)
if (
subtomo_number % 2 == 1
and ii > 10
@@ -1147,10 +1617,38 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
self.progress["projection"] = self.progress["total_projections"]
self.progress["subtomo_projection"] = self.progress["subtomo_total_projections"]
self._print_progress()
- print(
- "Total measurement time lost to detected gaps:"
- f" {self._format_duration(self.progress.get('accumulated_idle_time', 0.0))}"
+ self._log_tomogram_timing()
+ self.OMNYTools.printgreenbold("Tomoscan finished")
+
+ idle_s = self.progress.get("accumulated_idle_time", 0.0)
+ start_str = self.progress.get("tomo_start_time")
+ elapsed_s = None
+ if start_str is not None:
+ try:
+ elapsed_s = (
+ datetime.datetime.now() - datetime.datetime.fromisoformat(start_str)
+ ).total_seconds()
+ except (ValueError, TypeError):
+ elapsed_s = None
+ timing_lines = [
+ "Tomoscan finished.",
+ f"Measurement ID: {self.tomo_id}",
+ f"Sample: {self.sample_name}",
+ ]
+ if self.at_each_angle_hook:
+ timing_lines.append(f"At-each-angle hook: {self._describe_active_hook()}")
+ if elapsed_s is not None:
+ timing_lines.append(f"Total measurement time: {self._format_duration(elapsed_s)}")
+ timing_lines.append(
+ f"Total measurement time excluding detected gaps: {self._format_duration(elapsed_s - idle_s)}"
+ )
+ timing_lines.append(
+ f"Total measurement time lost to detected gaps: {self._format_duration(idle_s)}"
)
+ for line in timing_lines[3:]:
+ print(line)
+ timing_content = "\n".join(timing_lines)
+ self.write_to_scilog(timing_content, ["tomoscan"])
def tomo_scan_resume(self) -> None:
"""Resume a tomo_scan() that crashed or was interrupted, picking up
@@ -1193,6 +1691,51 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
# Parameter display and interactive update
# ------------------------------------------------------------------
+ def _expected_fermat_position_count(self) -> int:
+ """Predict the number of Fermat-spiral scan positions the current
+ settings would produce per projection tile, using the exact same
+ algorithm LamNIFermatScan runs at scan time
+ (LamNIFermatScan.get_lamni_fermat_spiral_pos()) -- so a
+ too-few-points configuration (which the scan server would only
+ catch by aborting with ScanAbortion once the scan actually starts)
+ can be caught here instead, while just looking at tomo_parameters().
+
+ center_x/center_y/shift_x/shift_y are deliberately left at their
+ defaults (0): they only shift the final absolute position, applied
+ after the FOV/circular-FOV keep-or-discard check, so they can never
+ affect the resulting count. Computed for the center stitch tile
+ (stitch_x=stitch_y=0) at angle=0, as a representative estimate --
+ the real per-projection count can vary a bit with angle/stitch
+ tile, since the circular FOV crop (tomo_circfov) is checked against
+ the rotated stage position.
+ """
+ from csaxs_bec.scans.LamNIFermatScan import LamNIFermatScan
+
+ positions = LamNIFermatScan.get_lamni_fermat_spiral_pos(
+ -abs(self.lamni_piezo_range_x / 2),
+ abs(self.lamni_piezo_range_x / 2),
+ -abs(self.lamni_piezo_range_y / 2),
+ abs(self.lamni_piezo_range_y / 2),
+ step=self.tomo_shellstep,
+ spiral_type=0,
+ center=False,
+ angle=0.0,
+ stitch_x=0,
+ stitch_y=0,
+ stitch_overlap=self.tomo_stitch_overlap,
+ fov_size=[self.lamni_piezo_range_x, self.lamni_piezo_range_y],
+ fov_circular=self.tomo_circfov,
+ )
+ return len(positions)
+
+ @staticmethod
+ def _fermat_min_positions() -> int:
+ """LamNIFermatScan's own minimum-position threshold -- see
+ _expected_fermat_position_count()."""
+ from csaxs_bec.scans.LamNIFermatScan import LamNIFermatScan
+
+ return LamNIFermatScan._MIN_POSITIONS
+
def tomo_parameters(self):
"""Print and interactively update the tomo parameters."""
print("Current settings:")
@@ -1205,6 +1748,15 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
print(f"Stitching number x,y = {self.lamni_stitch_x}, {self.lamni_stitch_y}")
print(f"Stitching overlap = {self.tomo_stitch_overlap}")
print(f"Circular FOV diam = {self.tomo_circfov}")
+ expected_positions = self._expected_fermat_position_count()
+ min_positions = self._fermat_min_positions()
+ if expected_positions < min_positions:
+ print(
+ f"Estimated Fermat scan points per projection: {expected_positions} "
+ f"(WARNING: below the minimum of {min_positions} -- the scan will abort when run)"
+ )
+ else:
+ print(f"Estimated Fermat scan points per projection: {expected_positions}")
print(f"Reconstruction queue name = {self.ptycho_reconstruct_foldername}")
print(f"Frames per trigger (burst) = {self.frames_per_trigger}")
print("FOV offset rotates to find the ROI; initial values determined in Xrayeye alignment.")
@@ -1213,6 +1765,8 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
print(f" _tomo_fovy_offset = {self.tomo_fovy_offset:.4f}")
print(f" _manual_shift_x = {self.manual_shift_x:.4f}")
print(f" _manual_shift_y = {self.manual_shift_y:.4f}")
+ if self.at_each_angle_hook:
+ print(f"At-each-angle hook = {self._describe_active_hook()}")
print("")
if self.tomo_type == 1:
print("\x1b[1mTomo type 1:\x1b[0m 8 equally spaced sub-tomograms (360 deg)")
@@ -1223,6 +1777,8 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
_, 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}")
+ if self.zero_deg_reference_at_each_subtomo:
+ print("Repeating projections at 0 deg at start of every odd sub-tomogram + end.")
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}")
@@ -1291,6 +1847,14 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
)
print(f"Angular step in a subtomogram: {achievable_step}")
print(f"Actual number of projections: {actual_total}")
+ self.zero_deg_reference_at_each_subtomo = bool(
+ self._get_val(
+ "Take 0-deg reference shots (start of each odd sub-tomo + end) for"
+ " damage estimation 1/0?",
+ int(self.zero_deg_reference_at_each_subtomo),
+ int,
+ )
+ )
elif self.tomo_type == 2:
while True:
@@ -1387,21 +1951,36 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
f"{'Angular step within sub-tomogram:':<{padding}}{self.tomo_angle_stepsize:>{padding}.2f}\n",
f"{'Tomo type:':<{padding}}{self.tomo_type:>{padding}}\n",
]
+ hook_description = self._describe_active_hook()
+ if hook_description:
+ content.append(f"{'At-each-angle hook:':<{padding}}{hook_description:>{padding}}\n")
content = "".join(content)
- user_target = os.path.expanduser(f"~/Data10/documentation/tomo_scan_ID_{self.tomo_id}.pdf")
+ hook_source = self._active_hook_source()
+ user_target = os.path.expanduser(f"~/data/raw/documentation/tomo_scan_ID_{self.tomo_id}.pdf")
with PDFWriter(user_target) as file:
file.write(header)
file.write(content)
- subprocess.run(
- "xterm /work/sls/spec/local/XOMNY/bin/upload/upload_last_pon.sh &", shell=True
- )
+ if hook_source:
+ file.write(
+ f"\nAt-each-angle hook source ('{self.at_each_angle_hook}'):\n{hook_source}"
+ )
+ # upload_last_pon.sh no longer works and needs a rewrite -- disabled
+ # for now (mirrors Flomni, which already has this commented out).
+ # subprocess.run(
+ # "xterm /work/sls/spec/local/XOMNY/bin/upload/upload_last_pon.sh &", shell=True
+ # )
# Same tolerance as write_to_scilog(): a session without scilog/logbook
# configured (e.g. a dev/sim session) must not crash report generation
# over the logbook upload -- the PDF itself is already written above.
try:
+ scilog_text = content
+ if hook_source:
+ scilog_text += (
+ f"\n\nAt-each-angle hook source ('{self.at_each_angle_hook}'):\n{hook_source}"
+ )
msg = bec.logbook.LogbookMessage()
logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "LamNI_logo.png")
- msg.add_file(logo_path).add_text("".join(content).replace("\n", "
")).add_tag(
+ msg.add_file(logo_path).add_text(scilog_text.replace("\n", "
")).add_tag(
["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "LamNI", self.sample_name]
)
self.client.logbook.send_logbook_message(msg)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py
index a7ee1fb..03a1575 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py
@@ -173,10 +173,10 @@ class LamNIAlignmentMixin:
# Args:
# dir_path: Directory containing ``ptychotomoalign_{A,B,C}{x,y}.txt``.
- # Defaults to ``~/Data10/specES1/internal/``.
+ # Defaults to ``~/data/raw/logs/``.
# """
# if dir_path is None:
- # dir_path = os.path.expanduser("~/Data10/specES1/internal/")
+ # dir_path = os.path.expanduser("~/data/raw/logs/")
# tomo_fit_xray_eye = np.zeros((2, 3))
# for i, axis in enumerate(["x", "y"]):
# for j, coeff in enumerate(["A", "B", "C"]):
@@ -256,7 +256,7 @@ class LamNIAlignmentMixin:
"""
tomo_fit_xray_eye = self.client.get_global_var("tomo_fit_xray_eye")
if tomo_fit_xray_eye is None:
- print("Not applying any X-ray eye correction. No fit data available.\n")
+ print("Not applying any X-ray eye correction. No fit data available.")
return (0, 0)
correction_x = (
@@ -274,7 +274,7 @@ class LamNIAlignmentMixin:
print(
f"Xeye correction x={correction_x:.6f} mm,"
- f" y={correction_y:.6f} mm @ angle={angle}\n"
+ f" y={correction_y:.6f} mm @ angle={angle}"
)
return (correction_x, correction_y)
@@ -343,7 +343,7 @@ class LamNIAlignmentMixin:
def _compute_correction_xy(self, angle, corr_pos_x, corr_pos_y, corr_angle, label=""):
"""Find the correction for the closest angle in the lookup table."""
if not corr_pos_x:
- print(f"Not applying additional correction {label}. No data available.\n")
+ print(f"Not applying additional correction {label}. No data available.")
return (0, 0)
shift_x = corr_pos_x[0]
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py
index 2c4b036..90c9806 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py
@@ -1,6 +1,7 @@
import builtins
import time
+import numpy as np
from rich import box
from rich.console import Console
from rich.table import Table
@@ -204,6 +205,16 @@ class LamNIOpticsMixin:
raise ValueError(f"Device {device} has no user parameter definition for {var}.")
return param.get(var)
+ @staticmethod
+ def _get_user_param_optional(device, var):
+ """Like _get_user_param_safe(), but returns None instead of raising
+ when the parameter isn't defined in the device config -- for
+ genuinely optional, not-yet-commissioned calibration values."""
+ param = dev[device].user_parameter
+ if not param:
+ return None
+ return param.get(var)
+
def leye_out(self):
self.loptics_in()
dev.fsh.fshopen()
@@ -232,21 +243,72 @@ class LamNIOpticsMixin:
def _lfzp_in(self):
loptx_in = self._get_user_param_safe("loptx", "in")
lopty_in = self._get_user_param_safe("lopty", "in")
- umv(dev.loptx, loptx_in, dev.lopty, lopty_in)
- def lfzp_in(self):
- """Move in the LamNI zone plate, disabling/re-enabling RT feedback around the move."""
- if "rtx" in dev and dev.rtx.enabled:
+ current_loptx = dev.loptx.readback.get()
+ current_lopty = dev.lopty.readback.get()
+
+ tol = 0.003
+
+ # if either axis is outside the tolerance -> move both
+ need_move_optics = not np.isclose(current_loptx, loptx_in, atol=tol) or not np.isclose(
+ current_lopty, lopty_in, atol=tol
+ )
+
+ if need_move_optics:
+ umv(dev.loptx, loptx_in, dev.lopty, lopty_in)
+ else:
+ print("FZP is already at the in position.")
+
+ return need_move_optics
+
+ def lfzp_in(self, force_feedback_reset=False):
+ """
+ Move in the LamNI zone plate.
+ This will disable rt feedback, move the FZP and re-enable the feedback.
+
+ The FZP move requires rt feedback OFF, and moving the FZP invalidates
+ the interferometer zero, so feedback is re-enabled *with reset*
+ afterwards. That reset is expensive: it re-zeros the interferometers
+ and moves you away from wherever the sample currently sits, which is
+ undesirable when the FZP is already in and feedback is already running
+ (e.g. repeated alignment scans, or interleaving an alignment run into a
+ tomogram).
+
+ Therefore the disable/move/reset cycle is skipped entirely when the FZP
+ does not actually need to move. Pass ``force_feedback_reset=True`` to
+ force the full disable + reset cycle even if the FZP is already in --
+ e.g. x_ray_eye_align.py's "Step 0: FZP centre" of a fresh alignment
+ run always needs a freshly-zeroed interferometer reference,
+ regardless of whether the FZP physically needs to move.
+ """
+ rtx_present = "rtx" in dev and dev.rtx.enabled
+
+ # Only disable feedback if we're going to move the FZP (or a reset was
+ # explicitly requested). If the FZP is already in and feedback is
+ # already running, leave it untouched -- disabling and
+ # re-enabling-with-reset would needlessly re-zero the interferometers.
+ needs_move = not self._lfzp_is_in()
+ do_cycle = needs_move or force_feedback_reset
+
+ if rtx_present and do_cycle:
dev.rtx.controller.feedback_disable()
self._lfzp_in()
- if "rtx" in dev and dev.rtx.enabled:
+ if rtx_present and do_cycle:
print("Re-establishing interferometer feedback...")
_t0 = time.time()
dev.rtx.controller.feedback_enable_with_reset()
print(f"Interferometer feedback re-established ({time.time() - _t0:.1f} s).")
+ def _lfzp_is_in(self, tol=0.003):
+ """True if both FZP axes (loptx, lopty) are within ``tol`` of their IN position."""
+ loptx_in = self._get_user_param_safe("loptx", "in")
+ lopty_in = self._get_user_param_safe("lopty", "in")
+ return np.isclose(dev.loptx.readback.get(), loptx_in, atol=tol) and np.isclose(
+ dev.lopty.readback.get(), lopty_in, atol=tol
+ )
+
def loptics_in(self):
"""Move in the LamNI optics (FZP + OSA)."""
self.lfzp_in()
@@ -279,14 +341,47 @@ class LamNIOpticsMixin:
losax_in = self._get_user_param_safe("losax", "in")
losay_in = self._get_user_param_safe("losay", "in")
losaz_in = self._get_user_param_safe("losaz", "in")
- umv(dev.losax, losax_in, dev.losay, losay_in)
- umv(dev.losaz, losaz_in)
+
+ current_losax = dev.losax.readback.get()
+ current_losay = dev.losay.readback.get()
+ current_losaz = dev.losaz.readback.get()
+
+ tol = 0.003
+
+ need_move_osa = (
+ not np.isclose(current_losax, losax_in, atol=tol)
+ or not np.isclose(current_losay, losay_in, atol=tol)
+ or not np.isclose(current_losaz, losaz_in, atol=tol)
+ )
+
+ if need_move_osa:
+ umv(dev.losax, losax_in, dev.losay, losay_in)
+ umv(dev.losaz, losaz_in)
+ else:
+ print("OSA is already at the IN position.")
def losa_out(self):
losay_out = self._get_user_param_safe("losay", "out")
losaz_out = self._get_user_param_safe("losaz", "out")
- umv(dev.losaz, losaz_out)
- umv(dev.losay, losay_out)
+
+ current_losay = dev.losay.readback.get()
+ current_losaz = dev.losaz.readback.get()
+
+ tol = 0.003
+
+ # No flomni equivalent for OSA-out exists (fosa_out() always moves
+ # unconditionally there) -- added here anyway as a lamni-side
+ # enhancement, mirroring the same skip-if-already-there shape as
+ # losa_in()/lfzp_in() above.
+ need_move_osa = not np.isclose(current_losay, losay_out, atol=tol) or not np.isclose(
+ current_losaz, losaz_out, atol=tol
+ )
+
+ if need_move_osa:
+ umv(dev.losaz, losaz_out)
+ umv(dev.losay, losay_out)
+ else:
+ print("OSA is already at the OUT position.")
def lfzp_info(self, mokev_val=-1):
if mokev_val == -1:
@@ -330,3 +425,53 @@ class LamNIOpticsMixin:
print(
"The numbers presented here are for a sample in the plane of the lamni sample holder.\n"
)
+
+ collision_offset = self._get_user_param_optional("loptz", "collision_offset")
+
+ print("\nOSA Information:")
+ if collision_offset is None:
+ print(
+ " \033[93mWarning: OSA collision-clearance calibration has not been "
+ "commissioned for LamNI yet -- set 'collision_offset' under loptz's "
+ "userParameter in the device config once measured (mirrors flomni's "
+ "ffzp_info(), whose equivalent constant is already commissioned). "
+ "Skipping the collision-clearance estimate below.\033[0m"
+ )
+ else:
+ losaz_val = dev.losaz.readback.get()
+ losaz_in = self._get_user_param_safe("losaz", "in")
+
+ tol_osa = 0.010 # mm, 10 microns
+
+ remaining_current = -losaz_val + (collision_offset - loptz_val)
+ remaining_at_in = -losaz_in + (collision_offset - loptz_val)
+
+ print(f" Current losaz {losaz_val:.1f}")
+ print(
+ " The OSA will collide with a normal sample holder at losaz "
+ f"\033[1m{(collision_offset - losaz_val):.1f}\033[0m"
+ )
+ print(f" Remaining space (right now): \033[1m{remaining_current:.1f}\033[0m")
+
+ diff = losaz_val - losaz_in # >0: OSA is currently more "in" than nominal -> worse
+
+ if abs(diff) > tol_osa:
+ if diff > 0:
+ # current position is already more collision-prone than the defined IN position
+ print(
+ f" \033[91mWarning: current losaz ({losaz_val:.4f}) is "
+ f"{diff*1000:.1f} um further IN than the defined IN position "
+ f"({losaz_in:.4f}) -- closer to collision than normal "
+ "operation.\033[0m"
+ )
+ else:
+ # OSA is out (parked further away than its nominal in-position) -- not urgent
+ # now, but flag what clearance will be once it's moved to IN
+ print(
+ f" Note: OSA is {(-diff)*1000:.1f} um away from its IN position "
+ "(likely parked OUT)."
+ )
+ print(
+ " Remaining space if OSA is moved to its IN position: "
+ f"\033[1m{remaining_at_in:.1f}\033[0m"
+ )
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py
index 6049ace..ebaffc4 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py
@@ -255,7 +255,13 @@ class XrayEyeAlign:
# --- Step 0: FZP centre ------------------------------------------
#self._disable_rt_feedback()
- self.lamni.lfzp_in()
+ # force_feedback_reset=True: this is the start of a fresh alignment
+ # run, so the interferometer reference must always be freshly
+ # re-zeroed here, regardless of whether the FZP happens to already
+ # be in position (e.g. left over from a previous run/tomogram) --
+ # lfzp_in()'s default skip-if-already-in-position optimization must
+ # not apply at this specific call site.
+ self.lamni.lfzp_in(force_feedback_reset=True)
#self._enable_rt_feedback()
self.update_frame(keep_shutter_open)
@@ -530,7 +536,7 @@ class XrayEyeAlign:
each submit), and this same fit array as alignment_fit.
"""
# Archival text file (backward compatible with any external scripts)
- file = os.path.expanduser("~/Data10/specES1/internal/xrayeye_alignmentvalues")
+ file = os.path.expanduser("~/data/raw/logs/xrayeye_alignmentvalues")
os.makedirs(os.path.dirname(file), exist_ok=True)
with open(file, "w") as f:
f.write("angle\thorizontal\tvertical\n")
diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py
index 6465b6a..f351f5d 100644
--- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py
@@ -26,6 +26,11 @@ Consumers (e.g. ``Flomni``, ``LamNI``) must:
the same way, but is setup-specific enough -- e.g. flomni's legacy
``flomni_at_each_angle`` builtins hook, single-point acquisition -- that
it stays out of this mixin)
+ - implement their own ``_resolve_type1_projection()`` (dispatched
+ generically by ``tomo_queue_reacquire()``), mapping a flat tomo_type-1
+ projection number to a ``(subtomo_start, start_angle)`` pair -- the
+ angle math is setup-specific (see each setup's own
+ ``_subtomo_angle_plan()``), same reasoning as ``tomo_scan()`` above
"""
from __future__ import annotations
@@ -35,6 +40,7 @@ import datetime
import inspect
import json
import uuid
+from typing import Callable
from typeguard import check_type
@@ -243,6 +249,15 @@ class TomoQueueMixin:
time if the name isn't registered in the session running the queue --
re-run this registration call, then resume ``tomo_queue_execute()``.
+ If you edit ``func``'s source and re-run the ``def`` (in a cell, or
+ via ``importlib.reload()`` of the module it lives in) without calling
+ this again, the next execution automatically picks up the new
+ definition and prints a note saying so -- see
+ ``_resolve_at_each_angle_hook()``. This does NOT cover plain
+ ``%run script.py`` (without ``-i``), which re-executes in a fresh
+ namespace each time; use ``%run -i`` or ``import`` + ``reload()`` for
+ hooks defined in a file.
+
Example -- record a projection, insert a polarizer, record again::
def polarizer_modulation(setup, angle):
@@ -262,6 +277,40 @@ class TomoQueueMixin:
self._publish_at_each_angle_hooks()
print(f"Registered at_each_angle hook '{name}'.")
+ def _resolve_at_each_angle_hook(self, hook_name: str, error_cls: type) -> Callable:
+ """Look up a registered at_each_angle hook by name for execution,
+ raising ``error_cls`` if it isn't registered in this session.
+
+ If the function has been redefined since it was registered -- i.e.
+ the name it was defined under, in the namespace it was defined in
+ (``func.__globals__``), no longer points at the same object -- the
+ live definition is adopted in its place and a note is printed. This
+ catches editing and re-running a ``def`` in the same session/cell, or
+ in a file that's then ``importlib.reload()``-ed (the module dict is
+ ``func.__globals__`` itself, mutated in place by reload); it does not
+ catch plain ``%run`` (without ``-i``), which builds a fresh namespace
+ per run.
+ """
+ hook = self._at_each_angle_hooks.get(hook_name)
+ if hook is None:
+ raise error_cls(
+ f"at_each_angle_hook '{hook_name}' is not registered in this "
+ "session. Hooks are session-only and do not survive a kernel "
+ f"restart -- call register_at_each_angle_hook({hook_name!r}, "
+ ") again, then re-run tomo_queue_execute() to resume."
+ )
+ live = getattr(hook, "__globals__", {}).get(getattr(hook, "__name__", None))
+ if callable(live) and live is not hook:
+ self._at_each_angle_hooks[hook_name] = live
+ print(
+ f"Note: at_each_angle_hook '{hook_name}' was redefined since "
+ "it was registered -- using the new definition. Call "
+ "register_at_each_angle_hook() explicitly if that's not what "
+ "you want."
+ )
+ hook = live
+ return hook
+
def unregister_at_each_angle_hook(self, name: str) -> None:
"""Remove a previously registered at_each_angle hook by name."""
if self._at_each_angle_hooks.pop(name, None) is None:
@@ -320,6 +369,27 @@ class TomoQueueMixin:
except (OSError, TypeError):
return None
+ def add_sample_database(
+ self, samplename, date, eaccount, scan_number, setup, sample_additional_info, user
+ ):
+ """Add a sample to the OMNY sample database and retrieve the tomo id.
+
+ Identical for every setup that uses ``self.tomo_id_manager``
+ (a ``TomoIDManager``, constructed once in each setup's own
+ ``__init__``) -- only ``setup`` (e.g. "flomni"/"lamni") differs per
+ caller. Was duplicated byte-for-byte in ``Flomni``/``LamNI`` before
+ being moved here.
+ """
+ return self.tomo_id_manager.register(
+ sample_name=samplename,
+ date=date,
+ eaccount=eaccount,
+ scan_number=scan_number,
+ setup=setup,
+ additional_info=sample_additional_info,
+ user=user,
+ )
+
# ── command-job action registry / dispatch ──────────────────────────────
def _validate_action_kwargs(self, action_name: str, kwargs: dict) -> None:
@@ -756,3 +826,144 @@ class TomoQueueMixin:
spec = self._TOMO_QUEUE_ACTIONS[step["action"]]
method = getattr(self, spec["func"])
method(**step["kwargs"])
+
+ # ── reacquire-from-an-earlier-projection ────────────────────────────────
+
+ def _resolve_type1_projection(self, projection_number: int) -> tuple[int, float]:
+ """Map a flat 0-indexed tomo_type-1 projection number to
+ (subtomo_start, start_angle), for tomo_queue_reacquire().
+
+ Subclasses must override this -- the angle math is setup-specific,
+ see each setup's own ``_subtomo_angle_plan()``. Not needed for
+ tomo_types 2/3, where a projection number is already the native
+ resume unit (see ``tomo_scan_resume()``).
+ """
+ raise NotImplementedError(
+ f"{type(self).__name__} must implement _resolve_type1_projection() to use "
+ "tomo_queue_reacquire() on a tomo_type-1 job."
+ )
+
+ def tomo_queue_resume(self, start_index: int = 0) -> None:
+ """Continue running the tomo queue from wherever it currently
+ stands -- an alias for ``tomo_queue_execute()``, added purely so
+ the queue has a command name that mirrors ``tomo_scan_resume()``'s.
+ The underlying pick-next mechanism (resume any incomplete/running
+ job first, else the next pending one) is identical either way.
+ """
+ self.tomo_queue_execute(start_index=start_index)
+
+ def tomo_queue_reacquire(self, job_index: int, projection_number: int) -> None:
+ """Reopen tomo queue job #``job_index`` at an earlier projection
+ than wherever it currently stands -- including a job that's
+ already "done" -- and reset every job queued after it (tomo and
+ command jobs alike, regardless of their own current status) to
+ "pending", so a following ``tomo_queue_resume()``/
+ ``tomo_queue_execute()`` call re-runs everything from here
+ forward, in order.
+
+ This does NOT run anything itself. It only:
+ - restores ``job_index``'s own snapshotted params onto the live
+ properties (so ``tomo_type`` etc. reflect the job being
+ reopened, not whatever ran most recently)
+ - writes the requested resume point into the shared ``progress``
+ global var exactly as if a crash had just occurred there, and
+ resets its timing bookkeeping (``tomo_start_time``/
+ ``accumulated_idle_time``/``heartbeat``/ETA) the same way
+ ``tomo_scan()``'s own "new scan" branch does -- this is a
+ fresh acquisition attempt, not a continuation of whatever job
+ ran most recently
+ - flips ``job_index``'s own status to "incomplete"
+
+ Call ``tomo_queue_resume()`` (or ``tomo_queue_execute()``)
+ afterward to actually re-acquire.
+
+ Known limitation: does not re-register a new tomo_id/sample-
+ database entry for the reopened job -- it keeps whatever tomo_id
+ is currently live in the session, exactly like a normal
+ ``tomo_scan_resume()`` already does.
+
+ Args:
+ job_index: Position of the job to reopen, in the *current*
+ queue order (same indexing as ``tomo_queue_show()``/
+ ``tomo_queue_delete()``).
+ projection_number: Flat 0-indexed projection to resume from,
+ for any tomo_type (1, 2, or 3) -- see
+ ``_resolve_type1_projection()`` for how this maps onto a
+ tomo_type-1 job's (subtomo, angle).
+
+ Raises:
+ TomoQueueError: ``job_index`` is out of range, the job at that
+ index is a command job (not a tomo scan), or some *earlier*
+ job in the queue is already "incomplete"/"running" (the
+ queue's single-resumable-job invariant -- resolve that one
+ first, or pass its own index). A later job in this state is
+ not a conflict -- it gets reset to "pending" below anyway.
+ ValueError: ``projection_number`` is out of range for this
+ job's own tomo scan parameters.
+ """
+ jobs = self._tomo_queue_proxy.ensure_ids()
+ if not 0 <= job_index < len(jobs):
+ raise TomoQueueError(f"tomo_queue_reacquire: no job at index {job_index}.")
+
+ job = jobs[job_index]
+ if job.get("kind", "tomo") != "tomo":
+ raise TomoQueueError(
+ f"tomo_queue_reacquire: job #{job_index} ('{job['label']}') is a "
+ "command job, not a tomo scan -- nothing to reacquire."
+ )
+
+ for idx, other in enumerate(jobs[:job_index]):
+ if other.get("status") in ("incomplete", "running"):
+ raise TomoQueueError(
+ f"tomo_queue_reacquire: job #{idx} ('{other['label']}') is already "
+ f"'{other['status']}' -- resolve it first (or pass index {idx} "
+ "instead), since only one job's progress can be tracked at a time."
+ )
+
+ for name, value in job["params"].items():
+ setattr(self, name, value)
+
+ if self.tomo_type == 1:
+ subtomo_start, start_angle = self._resolve_type1_projection(projection_number)
+ self.progress["subtomo"] = subtomo_start
+ self.progress["angle"] = start_angle
+ elif self.tomo_type in (2, 3):
+ if projection_number < 0:
+ raise ValueError("tomo_queue_reacquire: projection_number must be >= 0.")
+ max_prj = job["params"].get("golden_max_number_of_projections", 0) or 0
+ if max_prj > 0 and projection_number >= max_prj:
+ raise ValueError(
+ f"projection_number must be < {max_prj} "
+ "(golden_max_number_of_projections) for this job."
+ )
+ self.progress["projection"] = projection_number
+ else:
+ raise TomoQueueError(
+ f"tomo_queue_reacquire: unknown tomo_type {self.tomo_type} for job "
+ f"#{job_index} ('{job['label']}')."
+ )
+
+ # Fresh acquisition attempt -- reset timing bookkeeping the same way
+ # tomo_scan()'s own "new scan" branch does, so the eventual
+ # end-of-scan summary reflects this attempt, not whatever job ran
+ # most recently.
+ self.progress["tomo_start_time"] = datetime.datetime.now().isoformat()
+ self.progress["estimated_remaining_time"] = None
+ self.progress["estimated_finish_time"] = None
+ self.progress["accumulated_idle_time"] = 0.0
+ self.progress["heartbeat"] = None
+
+ job_id = job["id"]
+ self._tomo_queue_proxy.update_by_id(job_id, status="incomplete")
+
+ reset_count = 0
+ for later_job in jobs[job_index + 1 :]:
+ if later_job.get("status") != "pending":
+ self._tomo_queue_proxy.update_by_id(later_job["id"], status="pending")
+ reset_count += 1
+
+ print(
+ f"Job #{job_index} ('{job['label']}') reopened at projection "
+ f"{projection_number}. {reset_count} later job(s) reset to pending. "
+ "Call tomo_queue_resume() to run."
+ )
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
index beae086..a4678ec 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -2272,6 +2272,30 @@ class Flomni(
return angles, subtomo_offset, N, step
+ def _resolve_type1_projection(self, projection_number: int) -> tuple[int, float]:
+ """Map a flat 0-indexed tomo_type-1 projection number to
+ (subtomo_start, start_angle), for tomo_queue_reacquire().
+
+ The mapping mirrors exactly how sub_tomo_scan() itself numbers
+ projections (self.progress["projection"] = (subtomo_number - 1) * N
+ + subtomo_projection), so a projection_number read off e.g. the
+ tomography_scannumbers.txt log resolves to the same angle
+ sub_tomo_scan() would have produced at that position.
+ """
+ _, _, N, _ = self._subtomo_angle_plan(1, self.tomo_angle_range, self.tomo_angle_stepsize)
+ total = N * 8
+ if not 0 <= projection_number < total:
+ raise ValueError(
+ f"projection_number must be in 0..{total - 1} for this job's grid "
+ f"(tomo_angle_range={self.tomo_angle_range}, "
+ f"tomo_angle_stepsize={self.tomo_angle_stepsize})."
+ )
+ subtomo_number = projection_number // N + 1
+ angles, _, _, _ = self._subtomo_angle_plan(
+ subtomo_number, self.tomo_angle_range, self.tomo_angle_stepsize
+ )
+ return subtomo_number, float(angles[projection_number % N])
+
def sub_tomo_scan(self, subtomo_number, start_angle=None):
"""
Performs a sub tomogram scan.
@@ -2812,31 +2836,13 @@ class Flomni(
print(f"Estimated finish time: ........... {finish_str}\x1b[0m")
self._flomnigui_update_progress()
- def add_sample_database(
- self, samplename, date, eaccount, scan_number, setup, sample_additional_info, user
- ):
- """Add a sample to the omny sample database. This also retrieves the tomo id."""
- return self.tomo_id_manager.register(
- sample_name=samplename,
- date=date,
- eaccount=eaccount,
- scan_number=scan_number,
- setup=setup,
- additional_info=sample_additional_info,
- user=user,
- )
+ # add_sample_database() moved to TomoQueueMixin (OMNY_shared) -- was
+ # byte-identical to LamNI's own copy.
def _at_each_angle(self, angle: float) -> None:
hook_name = self.at_each_angle_hook
if hook_name:
- hook = self._at_each_angle_hooks.get(hook_name)
- if hook is None:
- raise FlomniError(
- f"at_each_angle_hook '{hook_name}' is not registered in this "
- "session. Hooks are session-only and do not survive a kernel "
- f"restart -- call register_at_each_angle_hook({hook_name!r}, "
- ") again, then re-run tomo_queue_execute() to resume."
- )
+ hook = self._resolve_at_each_angle_hook(hook_name, FlomniError)
hook(self, angle)
return
@@ -3196,6 +3202,7 @@ class Flomni(
- self.compute_additional_correction_y_2(angle)
+ self.manual_shift_y
)
+ print(f"Constant shift: y={self.manual_shift_y}")
sum_offset_z = offsets[2]
# TODO this fix is while the tracker z is broken
probe_propagation = -sum_offset_z * 1e-6
@@ -3306,6 +3313,7 @@ class Flomni(
+ self.manual_shift_y
+ random_shift_y
)
+ print(f"Constant shift: y={self.manual_shift_y}")
sum_offset_z = offsets[2]
# TODO this fix is while the tracker z is broken
@@ -3367,6 +3375,40 @@ class Flomni(
step = 180.0 / N
return N, step, N * 8
+ def _expected_fermat_position_count(self) -> int:
+ """Predict the number of Fermat-spiral scan positions the current
+ settings would produce per projection tile, using the exact same
+ algorithm FlomniFermatScan runs at scan time
+ (FlomniFermatScan.get_flomni_fermat_spiral_pos()) -- so a
+ too-few-points configuration (which the scan server would only
+ catch by aborting with ScanAbortion once the scan actually starts)
+ can be caught here instead, while just looking at tomo_parameters().
+
+ cenx/ceny/zshift are deliberately left at their defaults: they only
+ shift the final position, applied after the FOV keep-or-discard
+ check, so they can never affect the resulting count.
+ """
+ from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan
+
+ positions = FlomniFermatScan.get_flomni_fermat_spiral_pos(
+ -abs(self.fovx / 2),
+ abs(self.fovx / 2),
+ -abs(self.fovy / 2),
+ abs(self.fovy / 2),
+ step=self.tomo_shellstep,
+ spiral_type=0,
+ center=False,
+ )
+ return len(positions)
+
+ @staticmethod
+ def _fermat_min_positions() -> int:
+ """FlomniFermatScan's own minimum-position threshold -- see
+ _expected_fermat_position_count()."""
+ from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan
+
+ return FlomniFermatScan._MIN_POSITIONS
+
def tomo_parameters(self):
"""print and update the tomo parameters"""
print("Current settings:")
@@ -3375,6 +3417,17 @@ class Flomni(
print(f"FOV (200/100) = {self.fovx}, {self.fovy}")
print(f"Stitching number x,y = {self.stitch_x}, {self.stitch_y}")
print(f"Stitching overlap = {self.tomo_stitch_overlap}")
+ if not self.single_point_instead_of_fermat_scan:
+ expected_positions = self._expected_fermat_position_count()
+ min_positions = self._fermat_min_positions()
+ if expected_positions < min_positions:
+ print(
+ f"Estimated Fermat scan points per projection: {expected_positions} "
+ f"(WARNING: below the minimum of {min_positions} -- the scan will abort"
+ " when run)"
+ )
+ else:
+ print(f"Estimated Fermat scan points per projection: {expected_positions}")
print(f"Reconstruction queue name = {self.ptycho_reconstruct_foldername}")
print(f" _manual_shift_y = {self.manual_shift_y}")
print(f"Frames per trigger (burst) = {self.frames_per_trigger}")
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 7e5610c..7f4ed37 100644
--- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
+++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
@@ -85,6 +85,29 @@ TOMO_TYPES = {
3: "Equally spaced, golden ratio start",
}
+# Mirrors _update_type_visibility()'s section/field gating (_sec_type1 shown
+# only for type 1, golden_ratio_bunch_size shown only for type 2 within
+# _sec_type23, the rest of _sec_type23 shown for type 2/3) -- reused by
+# _job_tooltip() so a queued job's hover details don't show fields that are
+# meaningless for its own tomo_type (e.g. golden-ratio settings on a type-1
+# job), on both flomni and lamni.
+_TYPE1_ONLY_PARAMS = {"tomo_angle_range", "zero_deg_reference_at_each_subtomo"}
+_TYPE2_ONLY_PARAMS = {"golden_ratio_bunch_size"}
+_TYPE23_PARAMS = {"golden_max_number_of_projections", "golden_projections_at_0_deg_for_damage_estimation"}
+
+
+def _irrelevant_params_for_type(tomo_type: Any) -> set[str]:
+ """Param names that don't apply to a job's own tomo_type and should be
+ hidden from its tooltip."""
+ irrelevant: set[str] = set()
+ if tomo_type != 1:
+ irrelevant |= _TYPE1_ONLY_PARAMS
+ if tomo_type not in (2, 3):
+ irrelevant |= _TYPE23_PARAMS
+ if tomo_type != 2:
+ irrelevant |= _TYPE2_ONLY_PARAMS
+ return irrelevant
+
STATUS_COLORS = {
"pending": "#888888",
"running": "#2196F3",
@@ -146,10 +169,14 @@ DEFAULTS: dict[str, Any] = {
}
# 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
+# No tomo_angle_range/single_point_* -- lamni is laminography: always 360
+# degrees, no single-point acquisition. lamni_piezo_range_x/y have no flomni
# equivalent; manual_shift_x is lamni-only (flomni only exposes shift_y).
+# zero_deg_reference_at_each_subtomo (tomo_type 1 only) mirrors flomni's --
+# ported to lamni as bool(subtomo_number % 2) (every odd sub-tomogram),
+# confirmed against lamni's own hardware behavior -- not flomni's
+# subtomo_number % 4 == 1 (that's specific to flomni's 360-mode split into
+# two 180-degree halves, which lamni's tomo_type 1 doesn't have).
LAMNI_QUEUE_PARAM_NAMES = (
"tomo_countingtime",
"tomo_shellstep",
@@ -168,6 +195,7 @@ LAMNI_QUEUE_PARAM_NAMES = (
"golden_ratio_bunch_size",
"golden_max_number_of_projections",
"golden_projections_at_0_deg_for_damage_estimation",
+ "zero_deg_reference_at_each_subtomo",
"corridor_size",
"at_each_angle_hook",
)
@@ -191,6 +219,7 @@ LAMNI_DEFAULTS: dict[str, Any] = {
"golden_ratio_bunch_size": 20,
"golden_max_number_of_projections": 1000.0,
"golden_projections_at_0_deg_for_damage_estimation": 0,
+ "zero_deg_reference_at_each_subtomo": False,
"corridor_size": -1,
"at_each_angle_hook": None,
}
@@ -458,6 +487,17 @@ class TomoParamsWidget(BECWidget, QWidget):
}
for token in self._profile["field_order"]:
token_builders[token]()
+
+ # Estimated Fermat-scan point count -- live preview, updates as the
+ # fov/step/stitch/piezo-range fields above change, flagged orange if
+ # below the scan server's own minimum (see _update_fermat_position_preview()).
+ self._lbl_fermat_positions = QLabel("---")
+ common_form.addRow("Estimated Fermat scan points:", self._lbl_fermat_positions)
+ for key in self._profile["fermat_position_fields"]:
+ widget = self._pw.get(key)
+ if widget is not None:
+ widget.valueChanged.connect(self._update_fermat_position_preview)
+
vbox.addLayout(common_form)
# type-1 section
@@ -533,7 +573,8 @@ class TomoParamsWidget(BECWidget, QWidget):
"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.",
+ "manual shifts move the rotation center, i.e. they are a ."
+ "constant shift that will not rotate.",
)
def _build_at_each_angle_hook_row(self, form: QFormLayout) -> None:
@@ -708,6 +749,7 @@ class TomoParamsWidget(BECWidget, QWidget):
self._pw["_requested_total"].blockSignals(False)
self._update_projection_preview()
+ self._update_fermat_position_preview()
# type-3: derive projections-per-subtomo from stored tomo_angle_stepsize
# (base angle is 180 for flomni, 360 for lamni -- see
@@ -864,6 +906,30 @@ class TomoParamsWidget(BECWidget, QWidget):
self._lbl_actual_total.setStyleSheet("")
self._lbl_actual_total.setToolTip("")
+ def _update_fermat_position_preview(self) -> None:
+ """Live estimate of the Fermat-scan point count for the currently
+ edited fov/step/stitch/piezo-range fields (see
+ self._profile["compute_fermat_positions"], which calls the real
+ scan class's own position-generation algorithm -- not a
+ reimplementation). Warning-only: flags orange below the scan
+ server's own minimum, never blocks Submit/Add-to-queue."""
+ params = {}
+ for key in self._profile["fermat_position_fields"]:
+ widget = self._pw.get(key)
+ if widget is not None:
+ params[key] = widget.value()
+
+ count, min_positions = self._profile["compute_fermat_positions"](params)
+ self._lbl_fermat_positions.setText(str(count))
+ if count < min_positions:
+ self._lbl_fermat_positions.setStyleSheet("color: orange;")
+ self._lbl_fermat_positions.setToolTip(
+ f"Below the minimum of {min_positions} -- the scan will abort when run."
+ )
+ else:
+ self._lbl_fermat_positions.setStyleSheet("")
+ self._lbl_fermat_positions.setToolTip("")
+
# ── slots ─────────────────────────────────────────────────────────────────
def _on_type_changed(self, _index: int) -> None:
@@ -2065,6 +2131,72 @@ def _lamni_compute_type1(angle_range: int, stepsize: float) -> tuple[int, float,
return actual_total, achievable_step, stepsize
+def _compute_fermat_positions_flomni(params: dict[str, Any]) -> tuple[int, int]:
+ """Estimated Fermat-spiral point count for the currently edited flomni
+ fields, plus the scan-server's own minimum threshold.
+
+ Unlike _compute_type1() above, this does NOT reimplement the spiral
+ math -- it calls FlomniFermatScan.get_flomni_fermat_spiral_pos()
+ directly (a pure @staticmethod, no device I/O), the exact same
+ algorithm the scan itself runs, so the estimate can never drift out of
+ sync with what actually happens at scan time. cenx/ceny/zshift are left
+ at their defaults: they only shift the final position, applied after
+ the FOV keep-or-discard check, so they never affect the count. Stitch
+ doesn't affect flomni's count either -- every stitched tile is an
+ identically-shaped Fermat scan, just centered differently.
+
+ Returns:
+ (estimated_count, minimum_required)
+ """
+ from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan
+
+ fovx = params.get("fovx", 0.0)
+ fovy = params.get("fovy", 0.0)
+ step = params.get("tomo_shellstep", 0.0)
+ if step <= 0:
+ return 0, FlomniFermatScan._MIN_POSITIONS
+ positions = FlomniFermatScan.get_flomni_fermat_spiral_pos(
+ -abs(fovx / 2), abs(fovx / 2), -abs(fovy / 2), abs(fovy / 2), step=step, spiral_type=0
+ )
+ return len(positions), FlomniFermatScan._MIN_POSITIONS
+
+
+def _compute_fermat_positions_lamni(params: dict[str, Any]) -> tuple[int, int]:
+ """Lamni sibling of _compute_fermat_positions_flomni(): calls
+ LamNIFermatScan.get_lamni_fermat_spiral_pos() directly -- including its
+ rotated-stage and circular-FOV (tomo_circfov) cropping, which can matter
+ a lot and would be easy to get subtly wrong in a reimplementation.
+ Evaluated at the *currently edited* stitch tile and angle=0 (lamni has
+ no angle field in this widget -- the real per-projection count can vary
+ a bit with angle, since the circular crop is angle-dependent).
+
+ Returns:
+ (estimated_count, minimum_required)
+ """
+ from csaxs_bec.scans.LamNIFermatScan import LamNIFermatScan
+
+ piezo_x = params.get("lamni_piezo_range_x", 0.0)
+ piezo_y = params.get("lamni_piezo_range_y", 0.0)
+ step = params.get("tomo_shellstep", 0.0)
+ if step <= 0:
+ return 0, LamNIFermatScan._MIN_POSITIONS
+ positions = LamNIFermatScan.get_lamni_fermat_spiral_pos(
+ -abs(piezo_x / 2),
+ abs(piezo_x / 2),
+ -abs(piezo_y / 2),
+ abs(piezo_y / 2),
+ step=step,
+ spiral_type=0,
+ angle=0.0,
+ stitch_x=params.get("lamni_stitch_x", 0),
+ stitch_y=params.get("lamni_stitch_y", 0),
+ stitch_overlap=params.get("tomo_stitch_overlap", 1.0),
+ fov_size=[piezo_x, piezo_y],
+ fov_circular=params.get("tomo_circfov", 0.0),
+ )
+ return len(positions), LamNIFermatScan._MIN_POSITIONS
+
+
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 =
@@ -2131,6 +2263,8 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
"type1_base_angle": 180.0,
"compute_type1": _compute_type1,
"requested_to_stepsize": _requested_to_stepsize,
+ "compute_fermat_positions": _compute_fermat_positions_flomni,
+ "fermat_position_fields": ["tomo_shellstep", "fovx", "fovy"],
"sample_name_getter": lambda w: w.dev.flomni_samples.sample_names.sample0.get(),
"cli_hint_name": "flomni",
},
@@ -2141,7 +2275,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
"defaults": LAMNI_DEFAULTS,
"has_180_mode": False,
"has_single_point": False,
- "has_zero_deg_reference": False,
+ "has_zero_deg_reference": True,
"fov_fields": [("tomo_circfov", "Circular FOV (µm)", 0.1, 200.0, 2)],
"stitch_fields": [
("lamni_stitch_x", "Stitch x", 0, 50),
@@ -2181,6 +2315,16 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
"type1_base_angle": 360.0,
"compute_type1": _lamni_compute_type1,
"requested_to_stepsize": _lamni_requested_to_stepsize,
+ "compute_fermat_positions": _compute_fermat_positions_lamni,
+ "fermat_position_fields": [
+ "tomo_shellstep",
+ "tomo_circfov",
+ "lamni_stitch_x",
+ "lamni_stitch_y",
+ "tomo_stitch_overlap",
+ "lamni_piezo_range_x",
+ "lamni_piezo_range_y",
+ ],
"sample_name_getter": lambda w: w.client.get_global_var("sample_name"),
"cli_hint_name": "lamni",
},
@@ -2196,14 +2340,15 @@ def _format_projections(params: dict) -> str:
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
+ - type 2 and 3: golden ratio-flavored, no fixed sub-tomogram count ->
+ configured max (golden_max_number_of_projections), or ∞ if unset.
+ Type 3 shifts sub-tomogram starting angles by the golden ratio but
+ is otherwise capped exactly like type 2, not type 1's fixed grid.
"""
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:
@@ -2211,13 +2356,7 @@ def _format_projections(params: dict) -> str:
actual_total, _, _ = compute_type1(angle_range, stepsize)
return str(actual_total)
- if tomo_type == 3:
- if stepsize <= 0:
- return "?"
- n = int(base_angle / stepsize)
- return str(n * 8)
-
- if tomo_type == 2:
+ if tomo_type in (2, 3):
max_prj = params.get("golden_max_number_of_projections", 0) or 0
try:
max_prj = int(float(max_prj))
@@ -2279,7 +2418,12 @@ def _job_tooltip(job: dict) -> str:
# 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.
+ # Fields irrelevant to this job's own tomo_type (e.g. golden-ratio
+ # settings on a type-1 job) are skipped -- see _irrelevant_params_for_type().
+ irrelevant = _irrelevant_params_for_type(params.get("tomo_type"))
for name in sorted(params):
+ if name in irrelevant:
+ continue
lines.append(f" {name}: {params[name]}")
lines.append(f"Added: {job.get('added_at', '')}")
return "\n".join(lines)
diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_flomni.yaml
similarity index 100%
rename from csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation.yaml
rename to csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_flomni.yaml
diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_lamni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_lamni.yaml
new file mode 100644
index 0000000..4442b48
--- /dev/null
+++ b/csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_lamni.yaml
@@ -0,0 +1,24 @@
+ddg1:
+ description: Simulated main delay Generator for triggering
+ deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.SimulatedDDG1
+ enabled: true
+ deviceConfig:
+ prefix: 'X12SA-CPCL-DDG1:'
+ onFailure: raise
+ readOnly: false
+ readoutPriority: baseline
+ softwareTrigger: true
+fsh:
+ description: Simulated fast shutter manual control and readback
+ deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.cSAXSSimulatedFastShutter
+ deviceConfig:
+ prefix: 'X12SA-ES1-TTL:'
+ onFailure: raise
+ enabled: true
+ readoutPriority: monitored
+
+flomni:
+ - !include ../ptycho_lamni.yaml
+
+# machine:
+# - !include ../machine.yml
diff --git a/csaxs_bec/devices/omny/galil/galil_ophyd.py b/csaxs_bec/devices/omny/galil/galil_ophyd.py
index 563c99e..4c5df96 100644
--- a/csaxs_bec/devices/omny/galil/galil_ophyd.py
+++ b/csaxs_bec/devices/omny/galil/galil_ophyd.py
@@ -201,7 +201,7 @@ class GalilController(Controller):
self.socket_put_confirmed(f"naxis={axis_Id_numeric}")
self.socket_put_confirmed(f"ndir={direction_flag}")
self.socket_put_confirmed("XQ#NEWPAR")
- time.sleep(0.1)
+ time.sleep(0.2)
self.socket_put_confirmed("XQ#FES")
time.sleep(0.1)
axis_Id = self.axis_Id_numeric_to_alpha(axis_Id_numeric)
@@ -236,7 +236,7 @@ class GalilController(Controller):
time.sleep(0.1)
self.socket_put_confirmed(f"naxis={axis_Id_numeric}")
self.socket_put_and_receive("XQ#NEWPAR")
- time.sleep(0.1)
+ time.sleep(0.3)
self.socket_put_confirmed("XQ#FRM")
time.sleep(0.1)
axis_Id = self.axis_Id_numeric_to_alpha(axis_Id_numeric)
diff --git a/csaxs_bec/devices/smaract/smaract_controller.py b/csaxs_bec/devices/smaract/smaract_controller.py
index 9226207..dff4dd6 100644
--- a/csaxs_bec/devices/smaract/smaract_controller.py
+++ b/csaxs_bec/devices/smaract/smaract_controller.py
@@ -442,6 +442,11 @@ class SmaractController(Controller):
t.add_row([None for t in t.field_names])
print(t)
+ def smaract_show_all(self) -> None:
+ for controller in self._controller_instances.values():
+ if isinstance(controller, SmaractController):
+ controller.describe()
+
@axis_checked
def _error_str(self, axis_Id_numeric: int, error_number: int):
return f":E{axis_Id_numeric},{error_number}"
diff --git a/csaxs_bec/scans/LamNIFermatScan.py b/csaxs_bec/scans/LamNIFermatScan.py
index 37974b9..83da5b1 100644
--- a/csaxs_bec/scans/LamNIFermatScan.py
+++ b/csaxs_bec/scans/LamNIFermatScan.py
@@ -49,7 +49,8 @@ def lamni_from_stage_coordinates(x_stage: float, y_stage: float) -> tuple:
class LamNIMixin:
- def _lamni_compute_scan_center(self, x, y, angle_deg):
+ @staticmethod
+ def _lamni_compute_scan_center(x, y, angle_deg):
# assuming a scan point was found at interferometer x,y at zero degrees
# this function computes the new interferometer coordinates of this spot
# at a different rotation angle based on the lamni geometry
@@ -216,6 +217,13 @@ class LamNIFermatScan(AsyncFlyScanBase, LamNIMixin):
arg_input = {}
arg_bundle_size = {"bundle": len(arg_input), "min": None, "max": None}
+ # Minimum number of Fermat-spiral positions a scan is allowed to run
+ # with -- exposed as a class attribute (not just a literal inside
+ # _check_min_positions()) so client-side code (tomo_parameters(),
+ # tomo_params.py) can warn about a too-few-points configuration before
+ # it ever reaches the scan server, using the exact same threshold.
+ _MIN_POSITIONS = 20
+
def __init__(self, *args, parameter: dict = None, frames_per_trigger:int=1, exp_time:float=0,**kwargs):
"""
A LamNI scan following Fermat's spiral.
@@ -286,20 +294,24 @@ class LamNIFermatScan(AsyncFlyScanBase, LamNIMixin):
self._check_min_positions()
def _check_min_positions(self):
- if self.num_pos < 20:
+ if self.num_pos < self._MIN_POSITIONS:
raise ScanAbortion(
- f"The number of positions must exceed 20. Currently: {self.num_pos}."
+ f"The number of positions must exceed {self._MIN_POSITIONS}. Currently:"
+ f" {self.num_pos}."
)
- def _lamni_check_pos_in_fov_range_and_circ_fov(self, x, y) -> bool:
+ @staticmethod
+ def _lamni_check_pos_in_fov_range_and_circ_fov(
+ x, y, stitch_x, stitch_y, angle, fov_size, stitch_overlap, fov_circular
+ ) -> bool:
# this function checks if positions are reachable in a scan
# these x y intererometer positions are not shifted to the scan center
# so its purpose is to see if the position is reachable by the
# rotated piezo stage. For a scan these positions have to be shifted to
# the current scan center before starting the scan
stage_x, stage_y = lamni_to_stage_coordinates(x, y)
- stage_x_with_stitch, stage_y_with_stitch = self._lamni_compute_stitch_center(
- self.stitch_x, self.stitch_y, self.angle
+ stage_x_with_stitch, stage_y_with_stitch = LamNIFermatScan._lamni_compute_stitch_center(
+ stitch_x, stitch_y, angle, fov_size, stitch_overlap
)
stage_x_with_stitch, stage_y_with_stitch = lamni_to_stage_coordinates(
stage_x_with_stitch, stage_y_with_stitch
@@ -307,7 +319,7 @@ class LamNIFermatScan(AsyncFlyScanBase, LamNIMixin):
# piezo stage is currently rotated to stage_angle_deg in degrees
# rotate positions to the piezo stage system
- alpha = (self.angle - 300 + 30.5) / 180 * np.pi
+ alpha = (angle - 300 + 30.5) / 180 * np.pi
stage_x_rot = np.cos(alpha) * stage_x + np.sin(alpha) * stage_y
stage_y_rot = -np.sin(alpha) * stage_x + np.cos(alpha) * stage_y
@@ -319,22 +331,32 @@ class LamNIFermatScan(AsyncFlyScanBase, LamNIMixin):
)
return (
- np.abs(stage_x_rot) <= (self.fov_size[1] / 2)
- and np.abs(stage_y_rot) <= (self.fov_size[0] / 2)
+ np.abs(stage_x_rot) <= (fov_size[1] / 2)
+ and np.abs(stage_y_rot) <= (fov_size[0] / 2)
and (
- self.fov_circular == 0
+ fov_circular == 0
or (
np.power((stage_x_rot_with_stitch + stage_x_rot), 2)
+ np.power((stage_y_rot_with_stitch + stage_y_rot), 2)
)
- <= pow((self.fov_circular / 2), 2)
+ <= pow((fov_circular / 2), 2)
)
)
def _prepare_setup(self):
yield from self.stubs.send_rpc_and_wait("rtx", "controller.clear_trajectory_generator")
yield from self.lamni_rotation(self.angle)
- total_shift_x, total_shift_y = self._compute_total_shift()
+ total_shift_x, total_shift_y = self._compute_total_shift(
+ self.center_x,
+ self.center_y,
+ self.angle,
+ self.stitch_x,
+ self.stitch_y,
+ self.stitch_overlap,
+ self.shift_x,
+ self.shift_y,
+ self.fov_size,
+ )
yield from self.lamni_new_scan_center_interferometer(total_shift_x, total_shift_y)
# self._plot_target_pos()
if self.scan_type == "fly":
@@ -363,36 +385,73 @@ class LamNIFermatScan(AsyncFlyScanBase, LamNIMixin):
step=self.step,
spiral_type=0,
center=False,
+ center_x=self.center_x,
+ center_y=self.center_y,
+ angle=self.angle,
+ stitch_x=self.stitch_x,
+ stitch_y=self.stitch_y,
+ stitch_overlap=self.stitch_overlap,
+ shift_x=self.shift_x,
+ shift_y=self.shift_y,
+ fov_size=self.fov_size,
+ fov_circular=self.fov_circular,
)
- def _lamni_compute_stitch_center(self, xcount, ycount, angle_deg):
+ @staticmethod
+ def _lamni_compute_stitch_center(xcount, ycount, angle_deg, fov_size, stitch_overlap):
alpha = angle_deg / 180 * np.pi
- stage_x = xcount * (self.fov_size[0] - self.stitch_overlap)
- stage_y = ycount * (self.fov_size[1] - self.stitch_overlap)
+ stage_x = xcount * (fov_size[0] - stitch_overlap)
+ stage_y = ycount * (fov_size[1] - stitch_overlap)
x_rot = np.cos(alpha) * stage_x - np.sin(alpha) * stage_y
y_rot = np.sin(alpha) * stage_x + np.cos(alpha) * stage_y
return lamni_from_stage_coordinates(x_rot, y_rot)
- def _compute_total_shift(self):
- _shfitx, _shfity = self._lamni_compute_scan_center(self.center_x, self.center_y, self.angle)
- x_stitch_shift, y_stitch_shift = self._lamni_compute_stitch_center(
- self.stitch_x, self.stitch_y, self.angle
+ @staticmethod
+ def _compute_total_shift(
+ center_x, center_y, angle, stitch_x, stitch_y, stitch_overlap, shift_x, shift_y, fov_size
+ ):
+ _shfitx, _shfity = LamNIFermatScan._lamni_compute_scan_center(center_x, center_y, angle)
+ x_stitch_shift, y_stitch_shift = LamNIFermatScan._lamni_compute_stitch_center(
+ stitch_x, stitch_y, angle, fov_size, stitch_overlap
)
logger.info(
- f"Total shift [mm] {_shfitx+x_stitch_shift/1000+self.shift_x},"
- f" {_shfity+y_stitch_shift/1000+self.shift_y}"
- )
- return (
- _shfitx + x_stitch_shift / 1000 + self.shift_x,
- _shfity + y_stitch_shift / 1000 + self.shift_y,
+ f"Total shift [mm] {_shfitx+x_stitch_shift/1000+shift_x},"
+ f" {_shfity+y_stitch_shift/1000+shift_y}"
)
+ return (_shfitx + x_stitch_shift / 1000 + shift_x, _shfity + y_stitch_shift / 1000 + shift_y)
+ @staticmethod
def get_lamni_fermat_spiral_pos(
- self, m1_start, m1_stop, m2_start, m2_stop, step=1, spiral_type=0, center=False
+ m1_start,
+ m1_stop,
+ m2_start,
+ m2_stop,
+ step=1,
+ spiral_type=0,
+ center=False,
+ center_x=0.0,
+ center_y=0.0,
+ angle=0.0,
+ stitch_x=0,
+ stitch_y=0,
+ stitch_overlap=1,
+ shift_x=0.0,
+ shift_y=0.0,
+ fov_size=None,
+ fov_circular=0,
):
"""[summary]
+ Pure function (no device I/O) -- a @staticmethod rather than an
+ instance method (center_x/center_y/angle/stitch_x/stitch_y/
+ stitch_overlap/shift_x/shift_y/fov_size/fov_circular used to be read
+ off self.*) so it can also be called directly from client-side code
+ (tomo_parameters(), tomo_params.py) to predict the point count of a
+ not-yet-run scan, using the exact same algorithm the scan itself
+ will use -- including the rotated-stage and circular-FOV cropping
+ in _lamni_check_pos_in_fov_range_and_circ_fov().
+
Args:
m1_start (float): start position motor 1
m1_stop (float): end position motor 1
@@ -402,6 +461,7 @@ class LamNIFermatScan(AsyncFlyScanBase, LamNIMixin):
spiral_type (float, optional): Angular offset in radians that determines the shape of the spiral.
A spiral with spiral_type=2 is the same as spiral_type=0. Defaults to 0.
center (bool, optional): Add a center point. Defaults to False.
+ fov_size (list): [fov_x, fov_y] used for the rotated-stage/circular-FOV crop.
Raises:
TypeError: [description]
@@ -423,7 +483,9 @@ class LamNIFermatScan(AsyncFlyScanBase, LamNIMixin):
length_axis2 = np.abs(m2_stop - m2_start)
n_max = int(length_axis1 * length_axis2 * 3.2 / step / step)
- total_shift_x, total_shift_y = self._compute_total_shift()
+ total_shift_x, total_shift_y = LamNIFermatScan._compute_total_shift(
+ center_x, center_y, angle, stitch_x, stitch_y, stitch_overlap, shift_x, shift_y, fov_size
+ )
for ii in range(start, n_max):
radius = step * 0.57 * np.sqrt(ii)
@@ -434,7 +496,9 @@ class LamNIFermatScan(AsyncFlyScanBase, LamNIMixin):
# continue
x = radius * np.sin(ii * phi)
y = radius * np.cos(ii * phi)
- if self._lamni_check_pos_in_fov_range_and_circ_fov(x, y):
+ if LamNIFermatScan._lamni_check_pos_in_fov_range_and_circ_fov(
+ x, y, stitch_x, stitch_y, angle, fov_size, stitch_overlap, fov_circular
+ ):
positions.extend([(x + total_shift_x * 1000, y + total_shift_y * 1000)])
# for testing we just shift by center_i and prepare also the setup to center_i
return np.array(positions)
diff --git a/csaxs_bec/scans/flomni_fermat_scan.py b/csaxs_bec/scans/flomni_fermat_scan.py
index d724f27..f3c6d41 100644
--- a/csaxs_bec/scans/flomni_fermat_scan.py
+++ b/csaxs_bec/scans/flomni_fermat_scan.py
@@ -41,6 +41,13 @@ class FlomniFermatScan(AsyncFlyScanBase):
arg_input = {}
arg_bundle_size = {"bundle": len(arg_input), "min": None, "max": None}
+ # Minimum number of Fermat-spiral positions a scan is allowed to run
+ # with -- exposed as a class attribute (not just a literal inside
+ # _check_min_positions()) so client-side code (tomo_parameters(),
+ # tomo_params.py) can warn about a too-few-points configuration before
+ # it ever reaches the scan server, using the exact same threshold.
+ _MIN_POSITIONS = 20
+
def __init__(
self,
fovx: float,
@@ -148,9 +155,10 @@ class FlomniFermatScan(AsyncFlyScanBase):
self._check_min_positions()
def _check_min_positions(self):
- if self.num_pos < 20:
+ if self.num_pos < self._MIN_POSITIONS:
raise ScanAbortion(
- f"The number of positions must exceed 20. Currently: {self.num_pos}."
+ f"The number of positions must exceed {self._MIN_POSITIONS}. Currently:"
+ f" {self.num_pos}."
)
def _prepare_setup(self):
@@ -237,14 +245,33 @@ class FlomniFermatScan(AsyncFlyScanBase):
step=self.step,
spiral_type=0,
center=False,
+ cenx=self.cenx,
+ ceny=self.ceny,
+ zshift=self.zshift,
)
+ @staticmethod
def get_flomni_fermat_spiral_pos(
- self, m1_start, m1_stop, m2_start, m2_stop, step=1, spiral_type=0, center=False
+ m1_start,
+ m1_stop,
+ m2_start,
+ m2_stop,
+ step=1,
+ spiral_type=0,
+ center=False,
+ cenx=0.0,
+ ceny=0.0,
+ zshift=0.0,
):
"""
Calculate positions for a Fermat spiral scan.
+ Pure function (no device I/O) -- a @staticmethod rather than an
+ instance method (cenx/ceny/zshift used to be read off self.*) so it
+ can also be called directly from client-side code (tomo_parameters(),
+ tomo_params.py) to predict the point count of a not-yet-run scan,
+ using the exact same algorithm the scan itself will use.
+
Args:
m1_start(float): start position in m1
m1_stop(float): stop position in m1
@@ -253,6 +280,9 @@ class FlomniFermatScan(AsyncFlyScanBase):
step(float): stepsize
spiral_type(int): 0 for traditional Fermat spiral
center(bool): whether to include the center position
+ cenx(float): center offset added to every x position
+ ceny(float): center offset added to every y position
+ zshift(float): z position for every point
Returns:
positions(array): positions
@@ -266,7 +296,7 @@ class FlomniFermatScan(AsyncFlyScanBase):
length_axis2 = np.abs(m2_stop - m2_start)
n_max = int(length_axis1 * length_axis2 * 3.2 / step / step)
- z_pos = self.zshift
+ z_pos = zshift
for ii in range(start, n_max):
radius = step * 0.57 * np.sqrt(ii)
@@ -277,17 +307,9 @@ class FlomniFermatScan(AsyncFlyScanBase):
continue
x = radius * np.sin(ii * phi)
y = radius * np.cos(ii * phi)
- positions.append([x + self.cenx, y + self.ceny, z_pos])
- left_lower_corner = [
- min(m1_start, m1_stop) + self.cenx,
- min(m2_start, m2_stop) + self.ceny,
- z_pos,
- ]
- right_upper_corner = [
- max(m1_start, m1_stop) + self.cenx,
- max(m2_start, m2_stop) + self.ceny,
- z_pos,
- ]
+ positions.append([x + cenx, y + ceny, z_pos])
+ left_lower_corner = [min(m1_start, m1_stop) + cenx, min(m2_start, m2_stop) + ceny, z_pos]
+ right_upper_corner = [max(m1_start, m1_stop) + cenx, max(m2_start, m2_stop) + ceny, z_pos]
positions.append(left_lower_corner)
positions.append(right_upper_corner)
return np.array(positions)
diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md
index 948e41f..5796480 100644
--- a/docs/user/ptychography/flomni.md
+++ b/docs/user/ptychography/flomni.md
@@ -345,6 +345,7 @@ Several tomo parameter sets can be queued and run sequentially on the same sampl
| `flomni.tomo_queue_delete(*indices)` | Delete one or more jobs by index. |
| `flomni.tomo_queue_clear()` | Empty the queue. |
| `flomni.tomo_queue_execute(start_index=0)` | Run all pending jobs in sequence, on the current sample. |
+| `flomni.tomo_queue_resume(start_index=0)` | Alias for `tomo_queue_execute()` — identical behavior, just a name that matches `tomo_scan_resume()`. |
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 `flomni.tomo_queue_execute()` is called.
@@ -375,6 +376,38 @@ 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.
+**Reacquiring from an earlier projection.** A resumed job normally picks up
+exactly where it stopped. If a beamline problem means earlier projections need
+redoing too — even in a job that's already marked `done` — use:
+
+`flomni.tomo_queue_reacquire(job_index, projection_number)`
+
+`job_index` is the job's position, same indexing as `tomo_queue_show()`.
+`projection_number` is a flat, 0-indexed projection count that works the same
+way for all three tomography modes — for "8 sub-tomograms" it counts straight
+through all 8 in order (subtomogram 1's projections first, then subtomogram
+2's, ...), exactly matching the running `projection` counter already shown in
+the live progress report / `flomni.progress["projection"]`; for the
+golden-ratio modes it's the same projection number those already use. This
+reopens the job at that point and resets every job queued *after* it —
+tomograms and command jobs alike, regardless of their own current status — to
+`pending`, so the next `flomni.tomo_queue_resume()` (or `tomo_queue_execute()`)
+call re-runs everything from there forward, in order. It does not run
+anything itself.
+
+Example — job 0 already finished, but its last few projections (and the jobs
+queued after it) need redoing:
+```
+flomni.tomo_queue_show() # find the job index and the projection number to restart at
+flomni.tomo_queue_reacquire(0, 130) # reopen job 0 at projection 130; jobs 1+ reset to pending
+flomni.tomo_queue_resume() # actually re-run, in order
+```
+
+Refuses (raises) if `job_index` points at a command job (nothing to
+reacquire), or if some *other* job in the queue is already `incomplete`/
+`running` — only one job's progress can be tracked at a time, so resolve that
+one first (or pass its own index instead).
+
#### Command jobs — reconfiguring the beamline between scans
In addition to tomogram jobs, the same queue can hold **command jobs**: an ordered
@@ -482,9 +515,63 @@ 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.
+function's name. If you edit the function's source and re-run the `def` (in a cell,
+or reload the file it's defined in — see below), the change is picked up
+automatically the next time the hook runs, and a note is printed telling you so.
+You don't need to call `register_at_each_angle_hook()` again after an edit.
+
+**Recommended: define hooks in a file, not inline in the shell.** For anything
+beyond a quick one-off, put the hook function in a `.py` file — ideally together
+with the `tomo_queue_add()`/`at_each_angle_hook` calls that use it, so the file is a
+single, compact record of exactly what ran during the experiment. Load it with
+`import myhooks` (or `from myhooks import my_hook`) and, after editing, reload with
+`importlib.reload(myhooks)` — the automatic pick-up described above still works,
+because it detects the change via the module's own namespace, not the local name
+you imported it under. `%run -i myhooks.py` also works (runs in the current
+namespace, same as a cell). **Avoid plain `%run myhooks.py`** (without `-i`): it
+executes in a fresh, throwaway namespace each time, so an edit-and-rerun is *not*
+picked up automatically and the old version keeps running silently.
+
+**Example — hook loaded from a file, changing exposure and step size for a subset of
+projections:** a hook can also read `flomni.progress` and temporarily change any scan
+parameter (e.g. `tomo_countingtime`, `tomo_shellstep`) just for specific projections,
+then restore it. This example (tomo_type 1) takes a longer exposure at a finer
+real-space step every 5th projection within sub-tomogram 2, and a normal projection
+everywhere else. `~/hooks/my_hooks.py`:
+```python
+def high_res_every_5th(flomni, angle):
+ if flomni.progress["subtomo"] == 2 and flomni.progress["subtomo_projection"] % 5 == 0:
+ orig_countingtime = flomni.tomo_countingtime
+ orig_shellstep = flomni.tomo_shellstep
+ flomni.tomo_countingtime = 0.5 # longer exposure
+ flomni.tomo_shellstep = 0.2 # finer real-space step
+ try:
+ flomni.tomo_scan_projection(angle)
+ finally:
+ # restore even if the scan above raises, so a retry or the next
+ # projection doesn't silently keep running with these settings
+ flomni.tomo_countingtime = orig_countingtime
+ flomni.tomo_shellstep = orig_shellstep
+ else:
+ flomni.tomo_scan_projection(angle)
+```
+Load and use it:
+```python
+import my_hooks
+flomni.register_at_each_angle_hook("high_res_every_5th", my_hooks.high_res_every_5th)
+
+flomni.tomo_parameters() # set up the scan parameters as usual
+flomni.at_each_angle_hook = "high_res_every_5th" # activate the hook
+flomni.tomo_queue_add("subtomo 2 high-res every 5th")
+
+flomni.at_each_angle_hook = None # reset the session default for whatever's queued next
+flomni.tomo_queue_execute()
+```
+`progress["subtomo"]`/`progress["subtomo_projection"]` are populated the same way for
+tomo_types 2/3, so the same pattern works there too — just note that unlike type 1's
+fixed 8 sub-tomograms, those types have no fixed sub-tomogram count (see
+[Tomography](user.ptychography.flomni.tomography) above), so "sub-tomogram 2" means
+something different (and open-ended) for them.
**`tomo_scan_projection()` vs `tomo_acquire_at_angle()`:** these are not
interchangeable. `tomo_scan_projection(angle)` always runs a full Fermat-scan
diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md
index 3ba29d8..590f538 100644
--- a/docs/user/ptychography/lamni.md
+++ b/docs/user/ptychography/lamni.md
@@ -62,11 +62,9 @@ or with the shutter left open: `lamni.xrayeye_update_frame(keep_shutter_open=Tru
The sample fine alignment can be obtained using ptychography. For this a short laminogram has to be recorded.
-* `lamni.tomo_parameters()` adjust the parameters for a coarse scan: A large step size and large FOV. Especially select **FOV offset = 0** and **number of projections = 96** (only one sub-laminogram will be recorded).
-* `lamni.sub_tomo_scan(1,0)` record one sub-laminogram
-* use the corresponding scan numbers in `SPEC_ptycho_align.m`
-* Record a last projection for all scans to reconstruct `lamni.tomo_scan_projection(0)` and wait for the reconstructions to be complete
-* Run `SPEC_ptycho_align.m` (in Matlab, **force ptycho=1**, and **correct scan numbers**)
+* `lamni.tomo_parameters()` adjust the ptychographic scan parameters for the alignment scan (FOV/step size/counting time) — `tomo_type` and number of projections are ignored, since the alignment scan always runs its own fixed 12 points spread evenly across the full 360 degrees, independent of the main tomogram's settings.
+* `lamni.tomo_alignment_scan()` perform the alignment scan. Requires x-ray-eye alignment to have already been done — it will abort with a message otherwise. Scan numbers, angles and the existing x-ray-eye-fit offset at each angle are written to `~/data/raw/logs/ptychotomoalign_scannum.txt` and also printed at the end.
+* Run `BEC_ptycho_align` (in Matlab, **force ptycho=1**, and **correct scan numbers**) using the printed/logged scan numbers.
* Click the sample position in the Matlab GUI and then load the generated file by, for example
`lamni.read_additional_correction('/sls/X12SA/data/e20632/Data10/cxs_software/ptycho/correction_lamni_um_S05389_lamni_fit.txt')`
* With this alignment a second iteration could be performed. To read the second correction file use `lamni.read_additional_correction_2()`
@@ -113,6 +111,7 @@ Several tomo parameter sets can be queued and run sequentially on the same sampl
| `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. |
+| `lamni.tomo_queue_resume(start_index=0)` | Alias for `tomo_queue_execute()` — identical behavior, just a name that matches `tomo_scan_resume()`. |
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.
@@ -149,6 +148,37 @@ of the queue, labeled with `_dup` appended, status reset to `pending`. Useful fo
re-running a job with the same settings without re-typing them, or as a starting point
to tweak via "Load into editor".
+**Reacquiring from an earlier projection.** A resumed job normally picks up
+exactly where it stopped. If a beamline problem means earlier projections need
+redoing too — even in a job that's already marked `done` — use:
+
+`lamni.tomo_queue_reacquire(job_index, projection_number)`
+
+`job_index` is the job's position, same indexing as `tomo_queue_show()`.
+`projection_number` is a flat, 0-indexed projection count that works the same
+way for all three tomography modes — for "8 sub-tomograms" it counts straight
+through all 8 in order (subtomogram 1's projections first, then subtomogram
+2's, ...), exactly matching the running `projection` counter already shown in
+the live progress report / `lamni.progress["projection"]`; for the golden-ratio
+modes it's the same projection number those already use. This reopens the job
+at that point and resets every job queued *after* it — tomograms and command
+jobs alike, regardless of their own current status — to `pending`, so the next
+`lamni.tomo_queue_resume()` (or `tomo_queue_execute()`) call re-runs everything
+from there forward, in order. It does not run anything itself.
+
+Example — job 0 already finished, but its last few projections (and the jobs
+queued after it) need redoing:
+```
+lamni.tomo_queue_show() # find the job index and the projection number to restart at
+lamni.tomo_queue_reacquire(0, 130) # reopen job 0 at projection 130; jobs 1+ reset to pending
+lamni.tomo_queue_resume() # actually re-run, in order
+```
+
+Refuses (raises) if `job_index` points at a command job (nothing to
+reacquire), or if some *other* job in the queue is already `incomplete`/
+`running` — only one job's progress can be tracked at a time, so resolve that
+one first (or pass its own index instead).
+
#### Command jobs — reconfiguring the beamline between scans
In addition to tomogram jobs, the same queue can hold **command jobs**: an ordered
@@ -252,9 +282,63 @@ 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.
+function's name. If you edit the function's source and re-run the `def` (in a cell,
+or reload the file it's defined in — see below), the change is picked up
+automatically the next time the hook runs, and a note is printed telling you so.
+You don't need to call `register_at_each_angle_hook()` again after an edit.
+
+**Recommended: define hooks in a file, not inline in the shell.** For anything
+beyond a quick one-off, put the hook function in a `.py` file — ideally together
+with the `tomo_queue_add()`/`at_each_angle_hook` calls that use it, so the file is a
+single, compact record of exactly what ran during the experiment. Load it with
+`import myhooks` (or `from myhooks import my_hook`) and, after editing, reload with
+`importlib.reload(myhooks)` — the automatic pick-up described above still works,
+because it detects the change via the module's own namespace, not the local name
+you imported it under. `%run -i myhooks.py` also works (runs in the current
+namespace, same as a cell). **Avoid plain `%run myhooks.py`** (without `-i`): it
+executes in a fresh, throwaway namespace each time, so an edit-and-rerun is *not*
+picked up automatically and the old version keeps running silently.
+
+**Example — hook loaded from a file, changing exposure and step size for a subset of
+projections:** a hook can also read `lamni.progress` and temporarily change any scan
+parameter (e.g. `tomo_countingtime`, `tomo_shellstep`) just for specific projections,
+then restore it. This example (tomo_type 1) takes a longer exposure at a finer
+real-space step every 5th projection within sub-tomogram 2, and a normal projection
+everywhere else. `~/hooks/my_hooks.py`:
+```python
+def high_res_every_5th(lamni, angle):
+ if lamni.progress["subtomo"] == 2 and lamni.progress["subtomo_projection"] % 5 == 0:
+ orig_countingtime = lamni.tomo_countingtime
+ orig_shellstep = lamni.tomo_shellstep
+ lamni.tomo_countingtime = 0.5 # longer exposure
+ lamni.tomo_shellstep = 0.2 # finer real-space step
+ try:
+ lamni.tomo_scan_projection(angle)
+ finally:
+ # restore even if the scan above raises, so a retry or the next
+ # projection doesn't silently keep running with these settings
+ lamni.tomo_countingtime = orig_countingtime
+ lamni.tomo_shellstep = orig_shellstep
+ else:
+ lamni.tomo_scan_projection(angle)
+```
+Load and use it:
+```python
+import my_hooks
+lamni.register_at_each_angle_hook("high_res_every_5th", my_hooks.high_res_every_5th)
+
+lamni.tomo_parameters() # set up the scan parameters as usual
+lamni.at_each_angle_hook = "high_res_every_5th" # activate the hook
+lamni.tomo_queue_add("subtomo 2 high-res every 5th")
+
+lamni.at_each_angle_hook = None # reset the session default for whatever's queued next
+lamni.tomo_queue_execute()
+```
+`progress["subtomo"]`/`progress["subtomo_projection"]` are populated the same way for
+tomo_types 2/3, so the same pattern works there too — just note that unlike type 1's
+fixed 8 sub-tomograms, those types have no fixed sub-tomogram count (see
+[Laminography scan](user.ptychography.lamni.laminography) above), so "sub-tomogram 2"
+means something different (and open-ended) for them.
**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()`) —
@@ -344,14 +428,12 @@ Typical values with proper alignment are
#### Interferometer feedback commands
-* `dev.rtx.feedback_enable_with_reset()`
-* `dev.rtx.feedback_disable()`
-* `dev.rtx.feedback_enable_without_reset()` \*is only used internally by lamni methods
+* `lamni.feedback_enable_with_reset()`
+* `lamni.feedback_disable()`
+* `lamni.feedback_enable_without_reset()` \*is only used internally by lamni methods
* if reset of angle interferometer is required
- `dev.rtx.feedback_disable_and_even_reset_lamni_angle_interferometer()`
-* `dev.rtx.feedback_enable_with_reset()`
-
-*ToDo Feedback status might be helpful. Plus make accessible via lamni.methods…*
+ `lamni.feedback_disable_and_even_reset_lamni_angle_interferometer()`
+* `lamni.feedback_enable_with_reset()`
### Scanning in 2D and sample alignment
diff --git a/tests/tests_bec_ipython_client/test_fermat_position_warning.py b/tests/tests_bec_ipython_client/test_fermat_position_warning.py
new file mode 100644
index 0000000..73ea8f7
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_fermat_position_warning.py
@@ -0,0 +1,134 @@
+"""Tests for the Fermat-scan minimum-position warning: LamNI/Flomni's
+_expected_fermat_position_count()/_fermat_min_positions() (lamni.py/
+flomni.py), which predict a scan's point count before it ever reaches the
+scan server, by calling the exact same algorithm the real scan classes use
+(FlomniFermatScan.get_flomni_fermat_spiral_pos()/
+LamNIFermatScan.get_lamni_fermat_spiral_pos(), both now pure @staticmethods
+-- see csaxs_bec/scans/flomni_fermat_scan.py and LamNIFermatScan.py).
+"""
+
+import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module
+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.scans.flomni_fermat_scan import FlomniFermatScan
+from csaxs_bec.scans.LamNIFermatScan import LamNIFermatScan
+
+
+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
+
+
+class _FakeRtx:
+ user_parameter = {"large_range_scan": True}
+
+
+class _FakeDev:
+ rtx = _FakeRtx()
+
+ def __contains__(self, name):
+ return hasattr(self, name)
+
+ def __getitem__(self, name):
+ return getattr(self, name)
+
+
+def make_flomni():
+ obj = object.__new__(Flomni)
+ obj.client = FakeClient()
+ return obj
+
+
+def make_lamni():
+ # lamni_piezo_range_x/y's setter checks dev.rtx.user_parameter -- only
+ # ever defined (via the `if builtins.__dict__.get("bec") is not None`
+ # block at module import time) inside a real BEC session. Stand in a
+ # fake, same pattern as test_lamni_tomo_queue.py/test_tomo_queue_reacquire.py.
+ lamni_module.dev = _FakeDev()
+ obj = object.__new__(LamNI)
+ obj.client = FakeClient()
+ return obj
+
+
+def test_flomni_min_positions_threshold_is_20():
+ assert FlomniFermatScan._MIN_POSITIONS == 20
+
+
+def test_lamni_min_positions_threshold_is_20():
+ assert LamNIFermatScan._MIN_POSITIONS == 20
+
+
+def test_flomni_expected_position_count_below_threshold():
+ flomni = make_flomni()
+ flomni.fovx = 5.0
+ flomni.fovy = 5.0
+ flomni.tomo_shellstep = 2.0
+ flomni.single_point_instead_of_fermat_scan = False
+
+ count = flomni._expected_fermat_position_count()
+
+ assert count < flomni._fermat_min_positions()
+ # matches calling the real scan-class algorithm directly
+ expected = FlomniFermatScan.get_flomni_fermat_spiral_pos(-2.5, 2.5, -2.5, 2.5, step=2.0)
+ assert count == len(expected)
+
+
+def test_flomni_expected_position_count_above_threshold():
+ flomni = make_flomni()
+ flomni.fovx = 100.0
+ flomni.fovy = 80.0
+ flomni.tomo_shellstep = 1.0
+ flomni.single_point_instead_of_fermat_scan = False
+
+ count = flomni._expected_fermat_position_count()
+
+ assert count >= flomni._fermat_min_positions()
+
+
+def test_lamni_expected_position_count_below_threshold():
+ lamni = make_lamni()
+ lamni.lamni_piezo_range_x = 5.0
+ lamni.lamni_piezo_range_y = 5.0
+ lamni.tomo_shellstep = 2.0
+ lamni.tomo_stitch_overlap = 0.2
+ lamni.tomo_circfov = 0.0
+
+ count = lamni._expected_fermat_position_count()
+
+ assert count < lamni._fermat_min_positions()
+ # matches calling the real scan-class algorithm directly, center tile, angle 0
+ expected = LamNIFermatScan.get_lamni_fermat_spiral_pos(
+ -2.5,
+ 2.5,
+ -2.5,
+ 2.5,
+ step=2.0,
+ angle=0.0,
+ stitch_x=0,
+ stitch_y=0,
+ stitch_overlap=0.2,
+ fov_size=[5.0, 5.0],
+ fov_circular=0.0,
+ )
+ assert count == len(expected)
+
+
+def test_lamni_expected_position_count_above_threshold():
+ lamni = make_lamni()
+ lamni.lamni_piezo_range_x = 20.0
+ lamni.lamni_piezo_range_y = 20.0
+ lamni.tomo_shellstep = 1.0
+ lamni.tomo_stitch_overlap = 0.2
+ lamni.tomo_circfov = 0.0
+
+ count = lamni._expected_fermat_position_count()
+
+ assert count >= lamni._fermat_min_positions()
diff --git a/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py b/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py
new file mode 100644
index 0000000..fe2ce65
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py
@@ -0,0 +1,127 @@
+"""Tests for lamni's tomo-parameter reset offer on experiment-account
+change, ported from flomni (see csaxs_bec/bec_ipython_client/plugins/LamNI/
+AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md, item 2).
+
+Uses a bare LamNI instance (bypassing __init__'s heavy side effects), same
+pattern as test_lamni_tomo_queue.py/test_tomo_queue_reacquire.py.
+"""
+
+import builtins
+import types
+
+import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module
+from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy
+
+
+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
+
+
+class FakeOMNYTools:
+ def __init__(self, answer=True):
+ self.answer = answer
+ self.prompts = []
+
+ def yesno(self, prompt, default):
+ self.prompts.append(prompt)
+ return self.answer
+
+
+class _FakeRtx:
+ user_parameter = {"large_range_scan": True}
+
+
+class _FakeDev:
+ rtx = _FakeRtx()
+
+ def __contains__(self, name):
+ return hasattr(self, name)
+
+ def __getitem__(self, name):
+ return getattr(self, name)
+
+
+def make_lamni(answer=True):
+ lamni_module.dev = _FakeDev()
+ obj = object.__new__(LamNI)
+ obj.client = FakeClient()
+ obj._progress_proxy = _ProgressProxy(obj.client)
+ obj.OMNYTools = FakeOMNYTools(answer=answer)
+ obj.reconstructor = types.SimpleNamespace(folder_name=None)
+ return obj
+
+
+def test_skips_silently_when_no_active_account(monkeypatch):
+ lamni = make_lamni()
+ monkeypatch.setitem(builtins.__dict__, "bec", type("Bec", (), {"active_account": ""})())
+
+ lamni._maybe_reset_params_on_account_change()
+
+ assert lamni.client.get_global_var("defaults_applied_for_account") is None
+ assert lamni.OMNYTools.prompts == []
+
+
+def test_skips_silently_on_same_account(monkeypatch):
+ lamni = make_lamni()
+ lamni.client.set_global_var("defaults_applied_for_account", "e12345")
+ monkeypatch.setitem(builtins.__dict__, "bec", type("Bec", (), {"active_account": "e12345"})())
+ lamni.tomo_shellstep = 99.0
+
+ lamni._maybe_reset_params_on_account_change()
+
+ assert lamni.OMNYTools.prompts == []
+ assert lamni.tomo_shellstep == 99.0
+
+
+def test_prompts_and_resets_on_new_account_when_confirmed(monkeypatch):
+ lamni = make_lamni(answer=True)
+ lamni.client.set_global_var("defaults_applied_for_account", "e11111")
+ lamni.tomo_shellstep = 99.0
+ lamni.tomo_circfov = 123.0
+ monkeypatch.setitem(builtins.__dict__, "bec", type("Bec", (), {"active_account": "e22222"})())
+
+ lamni._maybe_reset_params_on_account_change()
+
+ assert len(lamni.OMNYTools.prompts) == 1
+ assert "e22222" in lamni.OMNYTools.prompts[0]
+ assert "e11111" in lamni.OMNYTools.prompts[0]
+ assert lamni.tomo_shellstep == 1
+ assert lamni.tomo_circfov == 0.0
+ assert lamni.client.get_global_var("defaults_applied_for_account") == "e22222"
+
+
+def test_records_account_without_resetting_when_declined(monkeypatch):
+ lamni = make_lamni(answer=False)
+ lamni.client.set_global_var("defaults_applied_for_account", "e11111")
+ lamni.tomo_shellstep = 99.0
+ monkeypatch.setitem(builtins.__dict__, "bec", type("Bec", (), {"active_account": "e22222"})())
+
+ lamni._maybe_reset_params_on_account_change()
+
+ assert lamni.tomo_shellstep == 99.0
+ assert lamni.client.get_global_var("defaults_applied_for_account") == "e22222"
+
+
+def test_set_default_tomo_params_matches_getter_fallbacks():
+ """Every value _set_default_tomo_params() writes must match the property
+ getter's own None-fallback -- otherwise "reset to defaults" and "never
+ configured" would silently disagree."""
+ lamni = make_lamni()
+
+ lamni._set_default_tomo_params()
+
+ for name in LamNI._TOMO_SCAN_PARAM_NAMES:
+ if name == "at_each_angle_hook":
+ continue # deliberately not touched by a reset, mirrors flomni
+ fresh = object.__new__(LamNI)
+ fresh.client = FakeClient()
+ assert getattr(lamni, name) == getattr(fresh, name), name
diff --git a/tests/tests_bec_ipython_client/test_lamni_optics_skip_if_in_position.py b/tests/tests_bec_ipython_client/test_lamni_optics_skip_if_in_position.py
new file mode 100644
index 0000000..ac9f9e7
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_lamni_optics_skip_if_in_position.py
@@ -0,0 +1,143 @@
+"""Tests for lamni's "skip if already in position" optics optimization,
+ported from flomni's ffzp_in()/fosa_in() (see csaxs_bec/bec_ipython_client/
+plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md, item 4).
+
+Methods under test (lamni_optics_mixin.py) reference module-level `dev`/
+`umv` globals (resolved from this module's own namespace, not an instance
+attribute), so tests monkeypatch those module globals directly.
+"""
+
+import types
+
+import pytest
+
+import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_optics_mixin as optics_mixin_module
+from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_optics_mixin import LamNIOpticsMixin
+
+
+class FakeAxis:
+ def __init__(self, value, in_pos=None, out_pos=None):
+ self.value = value
+ self.readback = types.SimpleNamespace(get=lambda: self.value)
+ self.user_parameter = {}
+ if in_pos is not None:
+ self.user_parameter["in"] = in_pos
+ if out_pos is not None:
+ self.user_parameter["out"] = out_pos
+
+
+class FakeController:
+ def __init__(self):
+ self.calls = []
+
+ def feedback_disable(self):
+ self.calls.append("disable")
+
+ def feedback_enable_with_reset(self):
+ self.calls.append("enable_with_reset")
+
+
+class FakeDev:
+ def __init__(self, rtx_enabled=True):
+ self.loptx = FakeAxis(-0.5, in_pos=-0.5, out_pos=-1.0)
+ self.lopty = FakeAxis(3.3, in_pos=3.3, out_pos=3.0)
+ self.losax = FakeAxis(-1.0, in_pos=-1.0, out_pos=0.0)
+ self.losay = FakeAxis(-0.2, in_pos=-0.2, out_pos=1.0)
+ self.losaz = FakeAxis(1.0, in_pos=1.0, out_pos=-1.0)
+ self.rtx = types.SimpleNamespace(enabled=rtx_enabled, controller=FakeController())
+
+ def __contains__(self, name):
+ return hasattr(self, name)
+
+ def __getitem__(self, name):
+ return getattr(self, name)
+
+
+@pytest.fixture
+def fake_dev(monkeypatch):
+ dev = FakeDev()
+ monkeypatch.setattr(optics_mixin_module, "dev", dev)
+ return dev
+
+
+@pytest.fixture
+def umv_calls(monkeypatch):
+ calls = []
+ monkeypatch.setattr(optics_mixin_module, "umv", lambda *args: calls.append(args))
+ return calls
+
+
+def make_optics():
+ return LamNIOpticsMixin()
+
+
+def test_lfzp_in_skips_move_when_already_in_position(fake_dev, umv_calls, capsys):
+ optics = make_optics()
+
+ optics.lfzp_in()
+
+ assert umv_calls == []
+ assert "already at the in position" in capsys.readouterr().out
+ assert fake_dev.rtx.controller.calls == []
+
+
+def test_lfzp_in_moves_and_resets_feedback_when_out_of_position(fake_dev, umv_calls):
+ fake_dev.loptx.value = -0.9 # not at "in" (-0.5)
+ optics = make_optics()
+
+ optics.lfzp_in()
+
+ assert umv_calls == [(fake_dev.loptx, -0.5, fake_dev.lopty, 3.3)]
+ assert fake_dev.rtx.controller.calls == ["disable", "enable_with_reset"]
+
+
+def test_lfzp_in_force_feedback_reset_runs_cycle_even_if_already_in(fake_dev, umv_calls):
+ optics = make_optics()
+
+ optics.lfzp_in(force_feedback_reset=True)
+
+ assert umv_calls == [] # still no physical move needed
+ assert fake_dev.rtx.controller.calls == ["disable", "enable_with_reset"]
+
+
+def test_losa_in_skips_move_when_already_in_position(fake_dev, umv_calls, capsys):
+ optics = make_optics()
+
+ optics.losa_in()
+
+ assert umv_calls == []
+ assert "already at the IN position" in capsys.readouterr().out
+
+
+def test_losa_in_moves_when_out_of_position(fake_dev, umv_calls):
+ fake_dev.losaz.value = 5.0 # not at "in" (1.0)
+ optics = make_optics()
+
+ optics.losa_in()
+
+ assert umv_calls == [
+ (fake_dev.losax, -1.0, fake_dev.losay, -0.2),
+ (fake_dev.losaz, 1.0),
+ ]
+
+
+def test_losa_out_skips_move_when_already_out(fake_dev, umv_calls, capsys):
+ fake_dev.losay.value = 1.0 # matches "out"
+ fake_dev.losaz.value = -1.0 # matches "out"
+ optics = make_optics()
+
+ optics.losa_out()
+
+ assert umv_calls == []
+ assert "already at the OUT position" in capsys.readouterr().out
+
+
+def test_losa_out_moves_when_not_out(fake_dev, umv_calls):
+ optics = make_optics() # currently at "in" positions, not "out"
+
+ optics.losa_out()
+
+ assert umv_calls == [
+ (fake_dev.losaz, -1.0),
+ (fake_dev.losay, 1.0),
+ ]
diff --git a/tests/tests_bec_ipython_client/test_lamni_osa_collision_info.py b/tests/tests_bec_ipython_client/test_lamni_osa_collision_info.py
new file mode 100644
index 0000000..1a44121
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_lamni_osa_collision_info.py
@@ -0,0 +1,146 @@
+"""Tests for lamni's OSA collision-clearance section in lfzp_info(), ported
+from flomni's ffzp_info() (see csaxs_bec/bec_ipython_client/plugins/LamNI/
+AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md, item 7).
+
+Methods under test (lamni_optics_mixin.py) reference module-level `dev`
+(resolved from this module's own namespace, not an instance attribute), so
+tests monkeypatch that module global directly.
+"""
+
+import types
+
+import pytest
+
+import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_optics_mixin as optics_mixin_module
+from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_optics_mixin import LamNIOpticsMixin
+
+
+class FakeAxis:
+ def __init__(self, value, name=None, user_parameter=None):
+ self.value = value
+ self._name = name
+ self.readback = types.SimpleNamespace(get=lambda: self.value)
+ self.user_parameter = user_parameter or {}
+
+ def read(self):
+ return {self._name: {"value": self.value}}
+
+
+class FakeDev:
+ def __init__(self, collision_offset=None):
+ self.mokev = FakeAxis(7.0)
+ self.loptz = FakeAxis(
+ 80.0,
+ name="loptz",
+ user_parameter=(
+ {"collision_offset": collision_offset} if collision_offset is not None else {}
+ ),
+ )
+ self.losaz = FakeAxis(0.0, user_parameter={"in": 0.0})
+
+ def __contains__(self, name):
+ return hasattr(self, name)
+
+ def __getitem__(self, name):
+ return getattr(self, name)
+
+
+@pytest.fixture
+def fake_dev(monkeypatch):
+ def _make(collision_offset=None):
+ dev = FakeDev(collision_offset=collision_offset)
+ monkeypatch.setattr(optics_mixin_module, "dev", dev)
+ return dev
+
+ return _make
+
+
+def make_optics():
+ return LamNIOpticsMixin()
+
+
+def test_get_user_param_optional_missing_param_returns_none(fake_dev):
+ fake_dev()
+ optics = make_optics()
+ assert optics._get_user_param_optional("loptz", "collision_offset") is None
+
+
+def test_get_user_param_optional_missing_user_parameter_dict_returns_none(monkeypatch):
+ class NoParamAxis:
+ user_parameter = None
+
+ class Dev:
+ loptz = NoParamAxis()
+
+ def __getitem__(self, name):
+ return getattr(self, name)
+
+ monkeypatch.setattr(optics_mixin_module, "dev", Dev())
+ optics = make_optics()
+ assert optics._get_user_param_optional("loptz", "collision_offset") is None
+
+
+def test_get_user_param_optional_returns_value_when_present(fake_dev):
+ fake_dev(collision_offset=33.0)
+ optics = make_optics()
+ assert optics._get_user_param_optional("loptz", "collision_offset") == 33.0
+
+
+def test_get_user_param_safe_still_raises_when_missing(fake_dev):
+ """Regression guard: the existing raising helper must stay untouched --
+ only the new _get_user_param_optional() should tolerate an absent param."""
+ fake_dev()
+ optics = make_optics()
+ with pytest.raises(ValueError):
+ optics._get_user_param_safe("loptz", "collision_offset")
+
+
+def test_lfzp_info_warns_and_skips_numbers_when_uncommissioned(fake_dev, capsys):
+ fake_dev(collision_offset=None)
+ optics = make_optics()
+
+ optics.lfzp_info(mokev_val=7.0)
+
+ out = capsys.readouterr().out
+ assert "has not been commissioned" in out
+ assert "Remaining space" not in out
+ assert "collide with a normal sample holder" not in out
+
+
+def test_lfzp_info_prints_collision_estimate_when_commissioned(fake_dev, capsys):
+ dev = fake_dev(collision_offset=113.0)
+ dev.losaz.value = 0.0
+ dev.losaz.user_parameter["in"] = 0.0
+
+ optics = make_optics()
+ optics.lfzp_info(mokev_val=7.0)
+
+ out = capsys.readouterr().out
+ assert "has not been commissioned" not in out
+ assert "Current losaz 0.0" in out
+ assert "Remaining space (right now)" in out
+
+
+def test_lfzp_info_warns_when_closer_to_collision_than_nominal(fake_dev, capsys):
+ dev = fake_dev(collision_offset=113.0)
+ dev.losaz.user_parameter["in"] = 0.0
+ dev.losaz.value = 0.02 # further IN than nominal "in" (0.0), beyond the 0.010 tolerance
+
+ optics = make_optics()
+ optics.lfzp_info(mokev_val=7.0)
+
+ out = capsys.readouterr().out
+ assert "further IN than the defined IN position" in out
+
+
+def test_lfzp_info_notes_parked_out(fake_dev, capsys):
+ dev = fake_dev(collision_offset=113.0)
+ dev.losaz.user_parameter["in"] = 0.0
+ dev.losaz.value = -0.5 # parked further OUT than nominal "in"
+
+ optics = make_optics()
+ optics.lfzp_info(mokev_val=7.0)
+
+ out = capsys.readouterr().out
+ assert "likely parked OUT" in out
+ assert "Remaining space if OSA is moved to its IN position" in out
diff --git a/tests/tests_bec_ipython_client/test_lamni_timing_log.py b/tests/tests_bec_ipython_client/test_lamni_timing_log.py
new file mode 100644
index 0000000..e3a0da1
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_lamni_timing_log.py
@@ -0,0 +1,136 @@
+"""Tests for lamni's timing-statistics log + scilog_last_ptycho_scans(),
+ported from flomni (see csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/
+FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md, item 1).
+
+Uses a bare LamNI instance (bypassing __init__'s heavy side effects), same
+pattern as test_lamni_tomo_angles.py/test_fermat_position_warning.py.
+"""
+
+import json
+
+import pytest
+
+from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy
+
+
+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(tmp_path):
+ obj = object.__new__(LamNI)
+ obj.client = FakeClient()
+ obj._progress_proxy = _ProgressProxy(obj.client)
+ obj.tomo_id = -1
+ obj._TIMING_LOG_DIR = str(tmp_path)
+ return obj
+
+
+def _read_jsonl(path):
+ with open(path) as f:
+ return [json.loads(line) for line in f if line.strip()]
+
+
+def test_append_and_read_timing_records_round_trip(tmp_path):
+ lamni = make_lamni(tmp_path)
+ lamni._append_timing_record(lamni._PROJECTION_TIMING_LOG, {"a": 1})
+ lamni._append_timing_record(lamni._PROJECTION_TIMING_LOG, {"a": 2})
+ lamni._append_timing_record(lamni._PROJECTION_TIMING_LOG, {"a": 3})
+
+ records = lamni._read_last_timing_records(2)
+
+ assert [r["a"] for r in records] == [2, 3]
+
+
+def test_read_timing_records_missing_file_returns_empty(tmp_path, capsys):
+ lamni = make_lamni(tmp_path)
+ records = lamni._read_last_timing_records(2)
+ assert records == []
+ assert "timing log not found" in capsys.readouterr().out
+
+
+def test_append_timing_record_tolerates_write_failure(tmp_path, monkeypatch):
+ """Must never raise -- a timing-log write failure must not abort or
+ interfere with an in-progress measurement."""
+ lamni = make_lamni(tmp_path)
+ lamni._TIMING_LOG_DIR = "/nonexistent-root-that-cannot-be-created/xyz"
+ lamni._append_timing_record(lamni._PROJECTION_TIMING_LOG, {"a": 1}) # must not raise
+
+
+def test_log_projection_timing_uses_lamni_param_names(tmp_path):
+ lamni = make_lamni(tmp_path)
+ lamni.tomo_type = 1
+ lamni.tomo_shellstep = 1.0
+ lamni.tomo_countingtime = 0.1
+ lamni.frames_per_trigger = 1
+ lamni.lamni_stitch_x = 1
+ lamni.lamni_stitch_y = 0
+ lamni.tomo_stitch_overlap = 0.2
+ lamni.corridor_size = -1
+ lamni.client.set_global_var("lamni_piezo_range_x", 20.0)
+ lamni.client.set_global_var("lamni_piezo_range_y", 20.0)
+ lamni.tomo_circfov = 0.0
+
+ lamni._log_projection_timing(
+ angle=12.5, subtomo_number=1, duration_s=3.2, start_scan_number=10, end_scan_number=12
+ )
+
+ records = _read_jsonl(f"{tmp_path}/{lamni._PROJECTION_TIMING_LOG}")
+ assert len(records) == 1
+ record = records[0]
+ assert record["setup"] == "lamni"
+ assert record["angle"] == 12.5
+ assert record["n_scans"] == 2
+ assert record["lamni_piezo_range_x"] == 20.0
+ assert record["lamni_piezo_range_y"] == 20.0
+ assert record["lamni_stitch_x"] == 1
+ # lamni has no fovx/fovy/single_point_instead_of_fermat_scan concept
+ assert "fovx" not in record
+ assert "single_point_instead_of_fermat_scan" not in record
+
+
+def test_log_tomogram_timing_snapshots_all_param_names(tmp_path):
+ lamni = make_lamni(tmp_path)
+ lamni.progress["tomo_start_time"] = None
+ lamni.progress["accumulated_idle_time"] = 0.0
+ lamni.progress["total_projections"] = 144
+ lamni.progress["tomo_type"] = "Equally spaced sub-tomograms"
+
+ lamni._log_tomogram_timing()
+
+ records = _read_jsonl(f"{tmp_path}/{lamni._TOMOGRAM_TIMING_LOG}")
+ assert len(records) == 1
+ record = records[0]
+ assert record["setup"] == "lamni"
+ assert record["total_projections"] == 144
+ assert set(record["params"]) == set(LamNI._TOMO_SCAN_PARAM_NAMES)
+
+
+@pytest.mark.parametrize("bad_value", [-1, 5, 1.5, True, "2"])
+def test_scilog_last_ptycho_scans_rejects_invalid_number_of_scans(tmp_path, bad_value, capsys):
+ lamni = make_lamni(tmp_path)
+ lamni.scilog_last_ptycho_scans(bad_value)
+ out = capsys.readouterr().out
+ assert "must be" in out
+
+
+def test_scilog_last_ptycho_scans_comment_only(tmp_path, monkeypatch):
+ lamni = make_lamni(tmp_path)
+ lamni.sample_name = "test"
+ sent = {}
+ lamni.write_to_scilog = lambda content, tags: sent.update(content=content, tags=tags)
+ monkeypatch.setattr("builtins.input", lambda *_: "test comment")
+
+ lamni.scilog_last_ptycho_scans(0)
+
+ assert sent["content"] == "test comment"
+ assert sent["tags"] == ["tomoscan"]
diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py b/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py
new file mode 100644
index 0000000..2fff068
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py
@@ -0,0 +1,135 @@
+"""Tests for lamni.tomo_alignment_scan()/write_alignment_scan_numbers(),
+ported from flomni (see csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/
+FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md, item 6).
+
+Uses a bare LamNI instance (bypassing __init__'s heavy side effects), same
+pattern as test_lamni_tomo_angles.py's make_lamni_for_tomo_scan().
+"""
+
+import builtins
+import types
+
+import numpy as np
+import pytest
+
+import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module
+from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy
+
+
+class FakeClient:
+ 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
+
+
+class FakeQueue:
+ def __init__(self, start=100):
+ self.next_scan_number = start
+
+ def request_queue_reset(self):
+ pass
+
+
+class FakeAxis:
+ value = 0
+
+
+class FakeDev:
+ def __init__(self):
+ self.lsamrot = FakeAxis()
+
+ def __contains__(self, name):
+ return hasattr(self, name)
+
+ def __getitem__(self, name):
+ return getattr(self, name)
+
+
+def make_lamni(monkeypatch, xray_eye_fit=None):
+ obj = object.__new__(LamNI)
+ obj.client = FakeClient()
+ obj._progress_proxy = _ProgressProxy(obj.client)
+ obj.tomo_id = -1
+ obj.sample_name = "test"
+ obj.OMNYTools = types.SimpleNamespace(printgreenbold=lambda msg: None)
+ obj._scilog_calls = []
+ obj.write_to_scilog = lambda content, tags: obj._scilog_calls.append((content, tags))
+ obj.leye_out = lambda: None
+
+ if xray_eye_fit is not None:
+ obj.client.set_global_var("tomo_fit_xray_eye", xray_eye_fit)
+
+ fake_bec = types.SimpleNamespace(queue=FakeQueue())
+ fake_dev = FakeDev()
+ monkeypatch.setitem(builtins.__dict__, "bec", fake_bec)
+ monkeypatch.setitem(builtins.__dict__, "dev", fake_dev)
+ monkeypatch.setattr(lamni_module, "umv", lambda *a: None, raising=False)
+
+ return obj
+
+
+def test_tomo_alignment_scan_aborts_without_xray_eye_fit(monkeypatch):
+ lamni = make_lamni(monkeypatch, xray_eye_fit=None)
+ calls = []
+ lamni.tomo_scan_projection = lambda angle: calls.append(angle)
+
+ lamni.tomo_alignment_scan()
+
+ assert calls == []
+ assert lamni._scilog_calls == []
+
+
+def test_tomo_alignment_scan_runs_12_projections_across_360(monkeypatch):
+ lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]])
+ calls = []
+
+ def _fake_projection(angle):
+ calls.append(angle)
+ builtins.__dict__["bec"].queue.next_scan_number += 1
+
+ lamni.tomo_scan_projection = _fake_projection
+
+ lamni.tomo_alignment_scan()
+
+ expected_angles = list(np.linspace(0, 360, num=12, endpoint=False))
+ assert calls == expected_angles
+ assert len(lamni._scilog_calls) == 1
+ content, tags = lamni._scilog_calls[0]
+ assert tags == ["alignmentscan"]
+ assert "Number of alignment scans: 12" in content, content
+
+
+def test_tomo_alignment_scan_rotates_back_to_zero(monkeypatch):
+ lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]])
+ lamni.tomo_scan_projection = lambda angle: None
+ umv_calls = []
+ monkeypatch.setattr(lamni_module, "umv", lambda *a: umv_calls.append(a), raising=False)
+
+ lamni.tomo_alignment_scan()
+
+ assert umv_calls[-1][1] == 0 # umv(dev.lsamrot, 0)
+
+
+def test_write_alignment_scan_numbers_format(tmp_path, monkeypatch):
+ lamni = make_lamni(monkeypatch, xray_eye_fit=[[1.0, 0.0, 0.5], [0.0, 0.0, 0.0]])
+ monkeypatch.setattr(
+ "os.path.expanduser",
+ lambda p: p.replace("~", str(tmp_path)) if p.startswith("~") else p,
+ )
+
+ lamni.write_alignment_scan_numbers(100)
+
+ log_file = tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt"
+ lines = log_file.read_text().splitlines()
+ assert len(lines) == 4
+ scans = [int(s) for s in lines[0].split()]
+ angles = [float(a) for a in lines[1].split()]
+ assert scans == list(range(100, 112))
+ assert angles == list(np.linspace(0, 360, num=12, endpoint=False))
+ assert len(lines[2].split()) == 12
+ assert len(lines[3].split()) == 12
diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
index 8a5f176..0246079 100644
--- a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
+++ b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
@@ -46,7 +46,6 @@ def make_lamni(tomo_angle_stepsize: float) -> LamNI:
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
@@ -153,7 +152,9 @@ class _FakeScans:
dataset_id_on_hold = _FakeContextManager()
-def make_lamni_for_tomo_scan(tomo_angle_stepsize: float, active_account: str) -> LamNI:
+def make_lamni_for_tomo_scan(
+ monkeypatch, tomo_angle_stepsize: float, active_account: str
+) -> LamNI:
"""Bare LamNI instance with tomo_scan()'s "new scan" branch reachable,
everything downstream of it stubbed out (sample database, PDF report,
per-sub-tomogram scanning) so only the account-handling logic under
@@ -166,18 +167,28 @@ def make_lamni_for_tomo_scan(tomo_angle_stepsize: float, active_account: str) ->
obj._print_progress = lambda: None
obj._format_duration = lambda seconds: "0s"
obj.lamnigui_show_progress = lambda: None
- builtins.__dict__["bec"] = types.SimpleNamespace(
- active_account=active_account, queue=types.SimpleNamespace(next_scan_number=1)
+ obj.at_each_angle_hook = None
+ obj.OMNYTools = types.SimpleNamespace(printgreenbold=lambda msg: None)
+ monkeypatch.setitem(
+ builtins.__dict__,
+ "bec",
+ types.SimpleNamespace(
+ active_account=active_account,
+ queue=types.SimpleNamespace(next_scan_number=1),
+ builtin_actors=types.SimpleNamespace(
+ scan_interlock=types.SimpleNamespace(trigger_setting=None, enabled=False)
+ ),
+ ),
)
- builtins.__dict__["scans"] = _FakeScans()
+ monkeypatch.setitem(builtins.__dict__, "scans", _FakeScans())
return obj
-def test_tomo_scan_skips_sample_database_when_no_active_account():
+def test_tomo_scan_skips_sample_database_when_no_active_account(monkeypatch):
"""Empty active_account (e.g. a dev/sim session) must not crash and must
not try to register a sample -- tomo_id falls back to 0, mirroring
Flomni.tomo_scan()'s identical guard."""
- lamni = make_lamni_for_tomo_scan(45.0, active_account="")
+ lamni = make_lamni_for_tomo_scan(monkeypatch, 45.0, active_account="")
lamni.add_sample_database = lambda *a, **k: (_ for _ in ()).throw(
AssertionError("add_sample_database must not be called with no active account")
)
@@ -187,10 +198,10 @@ def test_tomo_scan_skips_sample_database_when_no_active_account():
assert lamni.tomo_id == 0
-def test_tomo_scan_registers_sample_with_plain_string_account():
+def test_tomo_scan_registers_sample_with_plain_string_account(monkeypatch):
"""A real active_account must be passed through as-is -- calling
.decode() on it (a plain str, not bytes) raises AttributeError."""
- lamni = make_lamni_for_tomo_scan(45.0, active_account="e12345")
+ lamni = make_lamni_for_tomo_scan(monkeypatch, 45.0, active_account="e12345")
recorded = {}
def _fake_add_sample_database(samplename, date, eaccount, scan_number, setup, info, user):
@@ -205,11 +216,11 @@ def test_tomo_scan_registers_sample_with_plain_string_account():
assert lamni.tomo_id == 42
-def test_tomo_scan_shows_progress_gui():
+def test_tomo_scan_shows_progress_gui(monkeypatch):
"""tomo_scan() must open/show the progress GUI unconditionally at the
start of every scan, mirroring Flomni.tomo_scan()'s
self.flomnigui_show_progress() call -- lamni.py never had this at all."""
- lamni = make_lamni_for_tomo_scan(45.0, active_account="")
+ lamni = make_lamni_for_tomo_scan(monkeypatch, 45.0, active_account="")
lamni.add_sample_database = lambda *a, **k: 0
calls = []
lamni.lamnigui_show_progress = lambda: calls.append("shown")
@@ -219,7 +230,7 @@ def test_tomo_scan_shows_progress_gui():
assert calls == ["shown"]
-def test_tomo_scan_clears_heartbeat_on_normal_completion():
+def test_tomo_scan_clears_heartbeat_on_normal_completion(monkeypatch):
"""The busy-detector heartbeat must be cleared as soon as the scan
finishes, not left for the next poll to time out (120s) before the GUI
stops showing "beamline busy" for an already-finished scan -- mirrors
@@ -231,7 +242,7 @@ def test_tomo_scan_clears_heartbeat_on_normal_completion():
since tomo_scan()'s own "new scan" branch already resets the heartbeat
to None *before* the scan body runs.
"""
- lamni = make_lamni_for_tomo_scan(45.0, active_account="")
+ lamni = make_lamni_for_tomo_scan(monkeypatch, 45.0, active_account="")
lamni.add_sample_database = lambda *a, **k: 0
def _fake_sub_tomo_scan(subtomo_number, start_angle=None):
@@ -244,12 +255,12 @@ def test_tomo_scan_clears_heartbeat_on_normal_completion():
assert lamni.progress["heartbeat"] is None
-def test_tomo_scan_clears_heartbeat_even_if_scan_raises():
+def test_tomo_scan_clears_heartbeat_even_if_scan_raises(monkeypatch):
"""The heartbeat must be cleared on every exit path, including a
mid-scan exception -- otherwise a crashed scan looks permanently "busy"
to the GUI until the 120s staleness timeout, or forever if polled more
often than that."""
- lamni = make_lamni_for_tomo_scan(45.0, active_account="")
+ lamni = make_lamni_for_tomo_scan(monkeypatch, 45.0, active_account="")
lamni.add_sample_database = lambda *a, **k: 0
def _boom(subtomo_number, start_angle=None):
@@ -262,3 +273,68 @@ def test_tomo_scan_clears_heartbeat_even_if_scan_raises():
lamni.tomo_scan()
assert lamni.progress["heartbeat"] is None
+
+
+@pytest.mark.parametrize(
+ "subtomo_number,expected",
+ [(1, True), (2, False), (3, True), (4, False), (5, True), (6, False), (7, True), (8, False)],
+)
+def test_subtomo_starts_near_zero(subtomo_number, expected):
+ """Every odd sub-tomogram is where lamni's rotation naturally passes
+ back through 0 degrees (confirmed against lamni's own hardware
+ behavior -- not flomni's 360-mode subtomo_number % 4 == 1, which is
+ specific to flomni's split into two 180-degree halves)."""
+ lamni = make_lamni(10.0)
+ assert lamni._subtomo_starts_near_zero(subtomo_number) is expected
+
+
+def test_zero_deg_reference_disabled_by_default(monkeypatch):
+ """zero_deg_reference_at_each_subtomo defaults to False -- a fresh
+ tomo_scan() must not fire any extra 0-deg shots."""
+ lamni = make_lamni_for_tomo_scan(monkeypatch, 45.0, active_account="")
+ lamni.add_sample_database = lambda *a, **k: 0
+ recorded = []
+ lamni._tomo_scan_at_angle = lambda angle, subtomo_number: recorded.append(
+ (angle, subtomo_number)
+ )
+ lamni.sub_tomo_scan = lambda subtomo_number, start_angle=None: None
+
+ lamni.tomo_scan()
+
+ assert recorded == []
+
+
+def test_zero_deg_reference_fires_for_odd_subtomos_and_final_shot(monkeypatch):
+ """With the flag on, a fresh tomo_scan() must fire an extra angle-0 shot
+ before each odd sub-tomogram (1, 3, 5, 7) plus one final shot after
+ sub-tomogram 8 completes -- mirrors Flomni.tomo_scan()'s equivalent."""
+ lamni = make_lamni_for_tomo_scan(monkeypatch, 45.0, active_account="")
+ lamni.add_sample_database = lambda *a, **k: 0
+ lamni.zero_deg_reference_at_each_subtomo = True
+ recorded = []
+ lamni._tomo_scan_at_angle = lambda angle, subtomo_number: recorded.append(
+ (angle, subtomo_number)
+ )
+ lamni.sub_tomo_scan = lambda subtomo_number, start_angle=None: None
+
+ lamni.tomo_scan()
+
+ assert recorded == [(0, 1), (0, 3), (0, 5), (0, 7), (0, 8)]
+
+
+def test_zero_deg_reference_skipped_when_resuming_mid_subtomo(monkeypatch):
+ """A resume (start_angle given explicitly for the first sub-tomogram of
+ this call) must skip the extra shot for that first sub-tomogram --
+ we're not actually passing through 0 deg at that moment -- but must
+ still fire normally for later sub-tomograms in the same call."""
+ lamni = make_lamni_for_tomo_scan(monkeypatch, 45.0, active_account="")
+ lamni.zero_deg_reference_at_each_subtomo = True
+ recorded = []
+ lamni._tomo_scan_at_angle = lambda angle, subtomo_number: recorded.append(
+ (angle, subtomo_number)
+ )
+ lamni.sub_tomo_scan = lambda subtomo_number, start_angle=None: None
+
+ lamni.tomo_scan(subtomo_start=3, start_angle=45.0)
+
+ assert recorded == [(0, 5), (0, 7), (0, 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
index 7d4c74c..bb5878f 100644
--- 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
@@ -22,6 +22,7 @@ from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import (
LAMNI_QUEUE_PARAM_NAMES,
QUEUE_PARAM_NAMES,
SETUP_PROFILES,
+ _compute_fermat_positions_lamni,
_compute_type1,
_lamni_compute_type1,
_lamni_get_tomo_fov_offset,
@@ -29,6 +30,7 @@ from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import (
_lamni_set_tomo_fov_offset,
_requested_to_stepsize,
)
+from csaxs_bec.scans.LamNIFermatScan import LamNIFermatScan
STEPSIZES = [10.0, 7.0, 25.0, 12.5]
@@ -165,3 +167,79 @@ def test_lamni_field_order_matches_tomo_parameters_cli():
"offsets",
"at_each_angle_hook",
]
+
+
+def test_compute_fermat_positions_lamni_matches_scan_class_below_threshold():
+ params = {
+ "lamni_piezo_range_x": 5.0,
+ "lamni_piezo_range_y": 5.0,
+ "tomo_shellstep": 2.0,
+ "tomo_stitch_overlap": 0.2,
+ "tomo_circfov": 0.0,
+ "lamni_stitch_x": 0,
+ "lamni_stitch_y": 0,
+ }
+ count, min_positions = _compute_fermat_positions_lamni(params)
+ assert min_positions == LamNIFermatScan._MIN_POSITIONS
+ assert count < min_positions
+ expected = LamNIFermatScan.get_lamni_fermat_spiral_pos(
+ -2.5,
+ 2.5,
+ -2.5,
+ 2.5,
+ step=2.0,
+ angle=0.0,
+ stitch_x=0,
+ stitch_y=0,
+ stitch_overlap=0.2,
+ fov_size=[5.0, 5.0],
+ fov_circular=0.0,
+ )
+ assert count == len(expected)
+
+
+def test_compute_fermat_positions_lamni_matches_scan_class_above_threshold():
+ params = {
+ "lamni_piezo_range_x": 20.0,
+ "lamni_piezo_range_y": 20.0,
+ "tomo_shellstep": 1.0,
+ "tomo_stitch_overlap": 0.2,
+ "tomo_circfov": 0.0,
+ "lamni_stitch_x": 0,
+ "lamni_stitch_y": 0,
+ }
+ count, min_positions = _compute_fermat_positions_lamni(params)
+ assert count >= min_positions
+
+
+def test_compute_fermat_positions_lamni_uses_live_stitch_tile():
+ """Unlike the CLI's tomo_parameters() (which always estimates the
+ center tile as a quick summary), the GUI has the actual stitch_x/y
+ fields right there, so it should reflect whichever tile is currently
+ being edited, not always stitch=0."""
+ base_params = {
+ "lamni_piezo_range_x": 20.0,
+ "lamni_piezo_range_y": 20.0,
+ "tomo_shellstep": 1.0,
+ "tomo_stitch_overlap": 0.2,
+ "tomo_circfov": 15.0,
+ "lamni_stitch_x": 0,
+ "lamni_stitch_y": 0,
+ }
+ shifted_params = dict(base_params, lamni_stitch_x=3, lamni_stitch_y=2)
+
+ count_center, _ = _compute_fermat_positions_lamni(base_params)
+ count_shifted, _ = _compute_fermat_positions_lamni(shifted_params)
+
+ # a large stitch offset combined with a tight circular FOV crop should
+ # shift the count -- if this ever becomes flaky because both happen to
+ # match, widen fov_circular/stitch further rather than removing the check.
+ assert count_center != count_shifted
+
+
+def test_compute_fermat_positions_lamni_zero_step_is_safe():
+ count, min_positions = _compute_fermat_positions_lamni(
+ {"lamni_piezo_range_x": 5.0, "lamni_piezo_range_y": 5.0, "tomo_shellstep": 0.0}
+ )
+ assert count == 0
+ assert min_positions == LamNIFermatScan._MIN_POSITIONS
diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py b/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py
index ab43eb2..3e5a233 100644
--- a/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py
+++ b/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py
@@ -11,7 +11,7 @@ import types
import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module
import csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin as tomo_queue_mixin
-from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI
+from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, LamNIError
class _FakeRtx:
@@ -200,7 +200,79 @@ def test_at_each_angle_raises_for_unregistered_hook_name():
lamni.at_each_angle_hook = "missing_hook"
try:
lamni._at_each_angle(0.0)
- assert False, "expected ValueError for an unregistered hook name"
- except ValueError as exc:
+ assert False, "expected LamNIError for an unregistered hook name"
+ except LamNIError as exc:
assert "missing_hook" in str(exc)
assert "not registered" in str(exc)
+
+
+def test_at_each_angle_hook_refreshed_when_redefined(capsys):
+ """Redefining a hook's `def` in the same namespace (as happens re-running
+ a cell in IPython) and forgetting to re-register must not silently keep
+ running the old version -- the live definition should be picked up
+ automatically, with a note printed."""
+ lamni = make_lamni()
+ ns = {}
+ exec("def hook_func(setup, angle):\n setup.marker = 'old'\n", ns)
+ lamni.register_at_each_angle_hook("swap", ns["hook_func"])
+ lamni.at_each_angle_hook = "swap"
+
+ exec("def hook_func(setup, angle):\n setup.marker = 'new'\n", ns)
+ lamni._at_each_angle(5.0)
+
+ assert lamni.marker == "new"
+ assert "redefined" in capsys.readouterr().out
+
+
+def test_register_at_each_angle_hook_with_lambda_never_flagged_stale(capsys):
+ """Lambdas have no real namespace binding under their `__name__`
+ (""), so the redefinition check must never fire a false positive
+ for them -- covered separately from the dispatch test above since that
+ one doesn't assert anything about stdout."""
+ lamni = make_lamni()
+ lamni.register_at_each_angle_hook("record", lambda self, angle: None)
+ lamni.at_each_angle_hook = "record"
+
+ lamni._at_each_angle(1.0)
+
+ assert "redefined" not in capsys.readouterr().out
+
+
+def test_at_each_angle_hook_refreshed_after_module_reload(tmp_path, capsys):
+ """Mirrors the recommended file-based workflow: define the hook in a
+ .py file, load it, edit the file, and reload the module -- the module's
+ __dict__ (== the hook function's __globals__) is mutated in place by
+ importlib.reload(), so this must be picked up the same way an
+ interactive redefinition is."""
+ import importlib
+ import os
+ import sys
+
+ module_name = "lamni_hook_reload_test_module"
+ module_path = tmp_path / f"{module_name}.py"
+
+ def write(marker_value, mtime):
+ module_path.write_text(f"def hook_func(setup, angle):\n setup.marker = {marker_value!r}\n")
+ os.utime(module_path, (mtime, mtime))
+
+ # Distinct, well-separated mtimes -- otherwise both writes can land in
+ # the same filesystem mtime-resolution window and the loader reuses the
+ # cached .pyc from the first write, silently defeating the reload.
+ write("old", 1_700_000_000)
+ sys.path.insert(0, str(tmp_path))
+ try:
+ module = importlib.import_module(module_name)
+ lamni = make_lamni()
+ lamni.register_at_each_angle_hook("swap", module.hook_func)
+ lamni.at_each_angle_hook = "swap"
+
+ write("new", 1_700_000_010)
+ importlib.reload(module)
+
+ lamni._at_each_angle(5.0)
+
+ assert lamni.marker == "new"
+ assert "redefined" in capsys.readouterr().out
+ finally:
+ sys.path.remove(str(tmp_path))
+ sys.modules.pop(module_name, None)
diff --git a/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py
index 018c98f..7e504a7 100644
--- a/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py
+++ b/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py
@@ -10,9 +10,11 @@ import pytest
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni
from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import (
+ _compute_fermat_positions_flomni,
_compute_type1,
_requested_to_stepsize,
)
+from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan
STEPSIZES = [10.0, 7.0, 25.0, 12.5]
@@ -46,3 +48,28 @@ def _flomni_reference(stepsize):
N = int(180.0 / stepsize)
step = 180.0 / N
return N * 8, step, N
+
+
+def test_compute_fermat_positions_flomni_matches_scan_class_below_threshold():
+ params = {"fovx": 5.0, "fovy": 5.0, "tomo_shellstep": 2.0}
+ count, min_positions = _compute_fermat_positions_flomni(params)
+ assert min_positions == FlomniFermatScan._MIN_POSITIONS
+ assert count < min_positions
+ expected = FlomniFermatScan.get_flomni_fermat_spiral_pos(-2.5, 2.5, -2.5, 2.5, step=2.0)
+ assert count == len(expected)
+
+
+def test_compute_fermat_positions_flomni_matches_scan_class_above_threshold():
+ params = {"fovx": 100.0, "fovy": 80.0, "tomo_shellstep": 1.0}
+ count, min_positions = _compute_fermat_positions_flomni(params)
+ assert count >= min_positions
+ expected = FlomniFermatScan.get_flomni_fermat_spiral_pos(-50.0, 50.0, -40.0, 40.0, step=1.0)
+ assert count == len(expected)
+
+
+def test_compute_fermat_positions_flomni_zero_step_is_safe():
+ count, min_positions = _compute_fermat_positions_flomni(
+ {"fovx": 5.0, "fovy": 5.0, "tomo_shellstep": 0.0}
+ )
+ assert count == 0
+ assert min_positions == FlomniFermatScan._MIN_POSITIONS
diff --git a/tests/tests_bec_ipython_client/test_tomo_queue_reacquire.py b/tests/tests_bec_ipython_client/test_tomo_queue_reacquire.py
new file mode 100644
index 0000000..2d1bf83
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_tomo_queue_reacquire.py
@@ -0,0 +1,260 @@
+"""Tests for TomoQueueMixin.tomo_queue_reacquire()/tomo_queue_resume() and
+the per-setup _resolve_type1_projection() resolvers it depends on (see
+csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py).
+
+Round-trip tests build a bare LamNI/Flomni instance (bypassing __init__'s
+heavy side effects, same pattern as test_lamni_tomo_queue.py) and confirm
+_resolve_type1_projection() agrees with what sub_tomo_scan() itself would
+actually produce at a given flat projection number -- not a re-derivation
+of the same formula, but a comparison against the real scan-execution path.
+"""
+
+import types
+
+import pytest
+
+import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module
+from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni
+from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import _ProgressProxy as FlomniProgressProxy
+from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy
+from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import TomoQueueError
+
+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
+
+
+class FakeOMNYTools:
+ def yesno(self, *args, **kwargs):
+ return True
+
+
+class _FakeRtx:
+ user_parameter = {"large_range_scan": True}
+
+
+class _FakeDev:
+ rtx = _FakeRtx()
+
+ def __contains__(self, name):
+ return hasattr(self, name)
+
+ def __getitem__(self, name):
+ return getattr(self, name)
+
+
+def make_lamni():
+ lamni_module.dev = _FakeDev()
+ obj = object.__new__(LamNI)
+ obj.client = FakeClient()
+ obj.OMNYTools = FakeOMNYTools()
+ obj.reconstructor = types.SimpleNamespace(folder_name=None)
+ obj.tomo_id = -1
+ obj._progress_proxy = _ProgressProxy(obj.client)
+ obj._init_tomo_queue()
+ return obj
+
+
+def make_flomni():
+ obj = object.__new__(Flomni)
+ obj.client = FakeClient()
+ obj.OMNYTools = FakeOMNYTools()
+ obj._progress_proxy = FlomniProgressProxy(obj.client)
+ obj._init_tomo_queue()
+ return obj
+
+
+# ── _resolve_type1_projection round-trip: lamni ─────────────────────────────
+
+
+@pytest.mark.parametrize("stepsize", STEPSIZES)
+def test_lamni_resolve_type1_projection_matches_sub_tomo_scan(stepsize):
+ lamni = make_lamni()
+ lamni.tomo_angle_stepsize = stepsize
+ recorded = {}
+ lamni._tomo_scan_at_angle = lambda angle, subtomo_number: recorded.__setitem__(
+ lamni.progress["projection"], (subtomo_number, angle)
+ )
+ for subtomo in range(1, 9):
+ lamni.sub_tomo_scan(subtomo)
+
+ assert len(recorded) > 0
+ for projection_number, (expected_subtomo, expected_angle) in recorded.items():
+ subtomo_start, start_angle = lamni._resolve_type1_projection(projection_number)
+ assert subtomo_start == expected_subtomo
+ assert start_angle == pytest.approx(expected_angle)
+
+
+def test_lamni_resolve_type1_projection_out_of_range():
+ lamni = make_lamni()
+ lamni.tomo_angle_stepsize = 10.0
+ with pytest.raises(ValueError):
+ lamni._resolve_type1_projection(-1)
+ with pytest.raises(ValueError):
+ lamni._resolve_type1_projection(10_000)
+
+
+# ── _resolve_type1_projection round-trip: flomni ────────────────────────────
+
+
+@pytest.mark.parametrize("stepsize", STEPSIZES)
+@pytest.mark.parametrize("angle_range", [180, 360])
+def test_flomni_resolve_type1_projection_matches_sub_tomo_scan(stepsize, angle_range):
+ flomni = make_flomni()
+ flomni.tomo_angle_range = angle_range
+ flomni.tomo_angle_stepsize = stepsize
+ recorded = {}
+ flomni._tomo_scan_at_angle = lambda angle, subtomo_number: recorded.__setitem__(
+ flomni.progress["projection"], (subtomo_number, angle)
+ )
+ for subtomo in range(1, 9):
+ flomni.sub_tomo_scan(subtomo)
+
+ assert len(recorded) > 0
+ for projection_number, (expected_subtomo, expected_angle) in recorded.items():
+ subtomo_start, start_angle = flomni._resolve_type1_projection(projection_number)
+ assert subtomo_start == expected_subtomo
+ assert start_angle == pytest.approx(expected_angle)
+
+
+def test_flomni_resolve_type1_projection_out_of_range():
+ flomni = make_flomni()
+ flomni.tomo_angle_range = 180
+ flomni.tomo_angle_stepsize = 10.0
+ with pytest.raises(ValueError):
+ flomni._resolve_type1_projection(-1)
+ with pytest.raises(ValueError):
+ flomni._resolve_type1_projection(10_000)
+
+
+# ── tomo_queue_reacquire() / tomo_queue_resume() mechanics (lamni) ──────────
+
+
+def test_tomo_queue_resume_is_an_alias_for_execute():
+ lamni = make_lamni()
+ calls = []
+ lamni.tomo_queue_execute = lambda start_index=0: calls.append(start_index)
+
+ lamni.tomo_queue_resume()
+ lamni.tomo_queue_resume(start_index=2)
+
+ assert calls == [0, 2]
+
+
+def test_tomo_queue_reacquire_type1_sets_progress_and_status():
+ lamni = make_lamni()
+ lamni.tomo_type = 1
+ lamni.tomo_angle_stepsize = 10.0
+ index = lamni.tomo_queue_add(label="job0")
+ lamni._tomo_queue_proxy.update(index, status="done")
+
+ lamni.tomo_queue_reacquire(index, projection_number=5)
+
+ job = lamni._tomo_queue_proxy.as_list()[index]
+ assert job["status"] == "incomplete"
+ subtomo_start, start_angle = lamni._resolve_type1_projection(5)
+ assert lamni.progress["subtomo"] == subtomo_start
+ assert lamni.progress["angle"] == pytest.approx(start_angle)
+ assert lamni.progress["tomo_start_time"] is not None
+ assert lamni.progress["accumulated_idle_time"] == 0.0
+
+
+def test_tomo_queue_reacquire_type23_sets_projection_directly():
+ lamni = make_lamni()
+ lamni.tomo_type = 2
+ lamni.golden_max_number_of_projections = 0
+ index = lamni.tomo_queue_add(label="job0")
+ lamni._tomo_queue_proxy.update(index, status="done")
+
+ lamni.tomo_queue_reacquire(index, projection_number=17)
+
+ assert lamni.progress["projection"] == 17
+ job = lamni._tomo_queue_proxy.as_list()[index]
+ assert job["status"] == "incomplete"
+
+
+def test_tomo_queue_reacquire_resets_later_jobs_to_pending_tomo_and_command():
+ lamni = make_lamni()
+ lamni.tomo_type = 1
+ lamni.tomo_angle_stepsize = 10.0
+
+ i0 = lamni.tomo_queue_add(label="job0")
+ i1 = lamni.tomo_queue_add(label="job1")
+ lamni.tomo_queue_add_command({"action": "move", "kwargs": {"positions": {"mokev": 6.2}}})
+ i3 = lamni.tomo_queue_add(label="job3")
+
+ for i in (i0, i1, i3):
+ lamni._tomo_queue_proxy.update(i, status="done")
+
+ lamni.tomo_queue_reacquire(i0, projection_number=0)
+
+ jobs = lamni._tomo_queue_proxy.as_list()
+ assert jobs[i0]["status"] == "incomplete"
+ for later in jobs[i0 + 1 :]:
+ assert later["status"] == "pending"
+
+
+def test_tomo_queue_reacquire_rejects_out_of_range_job_index():
+ lamni = make_lamni()
+ with pytest.raises(TomoQueueError):
+ lamni.tomo_queue_reacquire(0, projection_number=0)
+
+
+def test_tomo_queue_reacquire_rejects_command_job():
+ lamni = make_lamni()
+ lamni.tomo_queue_add_command({"action": "move", "kwargs": {"positions": {"mokev": 6.2}}})
+ with pytest.raises(TomoQueueError):
+ lamni.tomo_queue_reacquire(0, projection_number=0)
+
+
+def test_tomo_queue_reacquire_rejects_when_an_earlier_job_is_incomplete():
+ lamni = make_lamni()
+ lamni.tomo_type = 1
+ lamni.tomo_angle_stepsize = 10.0
+ i0 = lamni.tomo_queue_add(label="job0")
+ i1 = lamni.tomo_queue_add(label="job1")
+ lamni._tomo_queue_proxy.update(i0, status="incomplete")
+
+ with pytest.raises(TomoQueueError):
+ lamni.tomo_queue_reacquire(i1, projection_number=0)
+
+
+def test_tomo_queue_reacquire_allows_a_later_job_being_incomplete():
+ # A later job's own status doesn't matter -- it gets reset to
+ # "pending" by this call anyway, so it's not a real conflict.
+ lamni = make_lamni()
+ lamni.tomo_type = 1
+ lamni.tomo_angle_stepsize = 10.0
+ i0 = lamni.tomo_queue_add(label="job0")
+ i1 = lamni.tomo_queue_add(label="job1")
+ lamni._tomo_queue_proxy.update(i1, status="incomplete")
+
+ lamni.tomo_queue_reacquire(i0, projection_number=0)
+
+ jobs = lamni._tomo_queue_proxy.as_list()
+ assert jobs[i0]["status"] == "incomplete"
+ assert jobs[i1]["status"] == "pending"
+
+
+def test_tomo_queue_reacquire_allows_targeting_the_incomplete_job_itself():
+ lamni = make_lamni()
+ lamni.tomo_type = 1
+ lamni.tomo_angle_stepsize = 10.0
+ index = lamni.tomo_queue_add(label="job0")
+ lamni._tomo_queue_proxy.update(index, status="incomplete")
+
+ lamni.tomo_queue_reacquire(index, projection_number=0)
+
+ job = lamni._tomo_queue_proxy.as_list()[index]
+ assert job["status"] == "incomplete"