Fixes/lamni hw commissioning #266
+167
@@ -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.
|
||||
+134
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user