Fixes/lamni hw commissioning #266

Merged
holler merged 36 commits from fixes/lamni_hw_commissioning into main 2026-07-21 17:00:59 +02:00
31 changed files with 3260 additions and 183 deletions
@@ -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.
@@ -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 <hash>` 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, <n>)` 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).
@@ -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
@@ -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}.")
@@ -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)
@@ -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}, "
"<func>) 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 <microns> = {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 <mm> = {self.tomo_fovy_offset:.4f}")
print(f" _manual_shift_x <mm> = {self.manual_shift_x:.4f}")
print(f" _manual_shift_y <mm> = {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", "</p><p>")).add_tag(
msg.add_file(logo_path).add_text(scilog_text.replace("\n", "</p><p>")).add_tag(
["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "LamNI", self.sample_name]
)
self.client.logbook.send_logbook_message(msg)
@@ -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]
@@ -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"
)
@@ -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")
@@ -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}, "
"<func>) 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."
)
@@ -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}, "
"<func>) 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) <microns> = {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 <um> = {self.manual_shift_y}")
print(f"Frames per trigger (burst) = {self.frames_per_trigger}")
@@ -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)
@@ -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
+2 -2
View File
@@ -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)
@@ -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}"
+92 -28
View File
@@ -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)
+37 -15
View File
@@ -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)
+90 -3
View File
@@ -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
+97 -15
View File
@@ -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
@@ -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()
@@ -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
@@ -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),
]
@@ -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
@@ -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"]
@@ -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
@@ -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)]
@@ -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
@@ -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__`
("<lambda>"), 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)
@@ -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
@@ -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"