From 64170812a9c802ba11e8a8ba25d1754df54810a6 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 20 Jul 2026 14:04:00 +0200
Subject: [PATCH 01/36] config adjustments
---
...ml => simulated_bl_endstation_flomni.yaml} | 0
.../simulated_bl_endstation_lamni.yaml | 24 +++++++++++++++++++
2 files changed, 24 insertions(+)
rename csaxs_bec/device_configs/simulated_omny/{simulated_bl_endstation.yaml => simulated_bl_endstation_flomni.yaml} (100%)
create mode 100644 csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_lamni.yaml
diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_flomni.yaml
similarity index 100%
rename from csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation.yaml
rename to csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_flomni.yaml
diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_lamni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_lamni.yaml
new file mode 100644
index 0000000..4442b48
--- /dev/null
+++ b/csaxs_bec/device_configs/simulated_omny/simulated_bl_endstation_lamni.yaml
@@ -0,0 +1,24 @@
+ddg1:
+ description: Simulated main delay Generator for triggering
+ deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.SimulatedDDG1
+ enabled: true
+ deviceConfig:
+ prefix: 'X12SA-CPCL-DDG1:'
+ onFailure: raise
+ readOnly: false
+ readoutPriority: baseline
+ softwareTrigger: true
+fsh:
+ description: Simulated fast shutter manual control and readback
+ deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.cSAXSSimulatedFastShutter
+ deviceConfig:
+ prefix: 'X12SA-ES1-TTL:'
+ onFailure: raise
+ enabled: true
+ readoutPriority: monitored
+
+flomni:
+ - !include ../ptycho_lamni.yaml
+
+# machine:
+# - !include ../machine.yml
--
2.54.0
From 3c46555df43e8dc1ac0b58b0ed05705b024728cb Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 20 Jul 2026 14:04:20 +0200
Subject: [PATCH 02/36] fix/increase delay after NEWPAR in FES
---
csaxs_bec/devices/omny/galil/galil_ophyd.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/csaxs_bec/devices/omny/galil/galil_ophyd.py b/csaxs_bec/devices/omny/galil/galil_ophyd.py
index 563c99e..254fb5d 100644
--- a/csaxs_bec/devices/omny/galil/galil_ophyd.py
+++ b/csaxs_bec/devices/omny/galil/galil_ophyd.py
@@ -201,7 +201,7 @@ class GalilController(Controller):
self.socket_put_confirmed(f"naxis={axis_Id_numeric}")
self.socket_put_confirmed(f"ndir={direction_flag}")
self.socket_put_confirmed("XQ#NEWPAR")
- time.sleep(0.1)
+ time.sleep(0.3)
self.socket_put_confirmed("XQ#FES")
time.sleep(0.1)
axis_Id = self.axis_Id_numeric_to_alpha(axis_Id_numeric)
--
2.54.0
From cdc4ae02b5c524f7116fec084e7d89214813c998 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 20 Jul 2026 14:18:26 +0200
Subject: [PATCH 03/36] fix/delays after newpar. lamni needs more time
---
csaxs_bec/devices/omny/galil/galil_ophyd.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/csaxs_bec/devices/omny/galil/galil_ophyd.py b/csaxs_bec/devices/omny/galil/galil_ophyd.py
index 254fb5d..4c5df96 100644
--- a/csaxs_bec/devices/omny/galil/galil_ophyd.py
+++ b/csaxs_bec/devices/omny/galil/galil_ophyd.py
@@ -201,7 +201,7 @@ class GalilController(Controller):
self.socket_put_confirmed(f"naxis={axis_Id_numeric}")
self.socket_put_confirmed(f"ndir={direction_flag}")
self.socket_put_confirmed("XQ#NEWPAR")
- time.sleep(0.3)
+ 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)
--
2.54.0
From f7a1bf4a9821b6585d859e8e19769ad8d9fa724c Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 20 Jul 2026 14:22:53 +0200
Subject: [PATCH 04/36] update/docs
---
docs/user/ptychography/lamni.md | 12 +++++-------
1 file changed, 5 insertions(+), 7 deletions(-)
diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md
index 3ba29d8..5dee5c1 100644
--- a/docs/user/ptychography/lamni.md
+++ b/docs/user/ptychography/lamni.md
@@ -344,14 +344,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
--
2.54.0
From 6f07c636ad78af1bcb055e8af79ae7b06279410c Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 20 Jul 2026 14:31:15 +0200
Subject: [PATCH 05/36] remove data10 references
---
.../plugins/LamNI/AI_docs/TOMO_PARAMS_GUI_PORT.md | 1 +
csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py | 2 +-
csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py | 2 +-
.../bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py | 4 ++--
csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py | 2 +-
5 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/TOMO_PARAMS_GUI_PORT.md b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/TOMO_PARAMS_GUI_PORT.md
index 754bb69..caa39f6 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/TOMO_PARAMS_GUI_PORT.md
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/TOMO_PARAMS_GUI_PORT.md
@@ -256,3 +256,4 @@ changed.
apply to flomni's identical, already-working call in the same harness.
- No live GUI verification performed (per Mirko's instruction) — verified
manually by Mirko instead.
+-note Data10/ folder is replaced by data/raw/ folder
\ No newline at end of file
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py
index 3bdafc7..a1c3fc2 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py
@@ -76,7 +76,7 @@ class DataDrivenLamNI(LamNI):
self,
subtomo_start=1,
start_index=None,
- fname="~/Data10/data_driven_config/datadriven_params.h5",
+ fname="~/data/raw/data_driven_config/datadriven_params.h5",
):
"""Start a data-driven tomo scan.
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 7340e18..2235457 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -1388,7 +1388,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
f"{'Tomo type:':<{padding}}{self.tomo_type:>{padding}}\n",
]
content = "".join(content)
- user_target = os.path.expanduser(f"~/Data10/documentation/tomo_scan_ID_{self.tomo_id}.pdf")
+ 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)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py
index a7ee1fb..afc28a5 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py
@@ -173,10 +173,10 @@ class LamNIAlignmentMixin:
# Args:
# dir_path: Directory containing ``ptychotomoalign_{A,B,C}{x,y}.txt``.
- # Defaults to ``~/Data10/specES1/internal/``.
+ # Defaults to ``~/data/raw/logs/``.
# """
# if dir_path is None:
- # dir_path = os.path.expanduser("~/Data10/specES1/internal/")
+ # dir_path = os.path.expanduser("~/data/raw/logs/")
# tomo_fit_xray_eye = np.zeros((2, 3))
# for i, axis in enumerate(["x", "y"]):
# for j, coeff in enumerate(["A", "B", "C"]):
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py
index 6049ace..2b12b35 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py
@@ -530,7 +530,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")
--
2.54.0
From c0fdba0e6c5076f6456236b351ab76c4685b04ac Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 20 Jul 2026 14:35:09 +0200
Subject: [PATCH 06/36] feat/smaract_show_all
---
csaxs_bec/devices/smaract/smaract_controller.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/csaxs_bec/devices/smaract/smaract_controller.py b/csaxs_bec/devices/smaract/smaract_controller.py
index 9226207..dff4dd6 100644
--- a/csaxs_bec/devices/smaract/smaract_controller.py
+++ b/csaxs_bec/devices/smaract/smaract_controller.py
@@ -442,6 +442,11 @@ class SmaractController(Controller):
t.add_row([None for t in t.field_names])
print(t)
+ def smaract_show_all(self) -> None:
+ for controller in self._controller_instances.values():
+ if isinstance(controller, SmaractController):
+ controller.describe()
+
@axis_checked
def _error_str(self, axis_Id_numeric: int, error_number: int):
return f":E{axis_Id_numeric},{error_number}"
--
2.54.0
From 55f6f944f7d12059c7a5b68fcddd0dffe4922912 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 20 Jul 2026 14:37:00 +0200
Subject: [PATCH 07/36] fix/improve doc
---
csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
index 7e5610c..8160b82 100644
--- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
+++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
@@ -533,7 +533,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:
--
2.54.0
From 6c3d60306d79b7333219a4e9a5d7a4180a7928ef Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 20 Jul 2026 21:13:21 +0200
Subject: [PATCH 08/36] feat/lamni tomogram timing overview +
repeat-on-interruption
Port two flomni tomo_scan() behaviors lamni was missing: an end-of-scan
timing overview (total time, time excluding gaps, time lost to gaps,
sample/hook info) printed and written to scilog, and automatic repeat
of an entire projection when the beamline interlock trips mid-scan.
The latter needed two pieces together: enabling
bec.builtin_actors.scan_interlock at tomo_scan() start (previously
never enabled for lamni), and wrapping _tomo_scan_at_angle in
@scan_repeat so the resulting ScanRestart (or any other transient
exception) redoes the whole projection instead of aborting the scan.
A new LamNIError marks the one non-retryable case (unregistered
at_each_angle_hook), replacing a plain ValueError there.
Co-Authored-By: Claude Sonnet 5
---
.../plugins/LamNI/extra_tomo.py | 3 +
.../bec_ipython_client/plugins/LamNI/lamni.py | 59 +++++++++++++++++--
.../test_lamni_tomo_angles.py | 8 ++-
.../test_lamni_tomo_queue.py | 6 +-
4 files changed, 68 insertions(+), 8 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py
index a1c3fc2..1b07dfa 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py
@@ -88,6 +88,9 @@ class DataDrivenLamNI(LamNI):
bec = builtins.__dict__.get("bec")
scans = builtins.__dict__.get("scans")
+ bec.builtin_actors.scan_interlock.trigger_setting = "restart_scan"
+ bec.builtin_actors.scan_interlock.enabled = True
+
fname = os.path.expanduser(fname)
if not os.path.exists(fname):
raise FileNotFoundError(f"Could not find datadriven params file in {fname}.")
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 2235457..8614da7 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -9,6 +9,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 +108,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
@@ -778,7 +784,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
if hook_name:
hook = self._at_each_angle_hooks.get(hook_name)
if hook is None:
- raise ValueError(
+ raise LamNIError(
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}, "
@@ -891,6 +897,20 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
self.progress["angle"] = angle
self._tomo_scan_at_angle(angle, subtomo_number)
+ @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
@@ -1013,6 +1033,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 (
@@ -1147,10 +1171,37 @@ 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.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.at_each_angle_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
diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
index 8a5f176..b1178ce 100644
--- a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
+++ b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
@@ -166,8 +166,14 @@ 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
+ obj.at_each_angle_hook = None
+ obj.OMNYTools = types.SimpleNamespace(printgreenbold=lambda msg: None)
builtins.__dict__["bec"] = types.SimpleNamespace(
- active_account=active_account, queue=types.SimpleNamespace(next_scan_number=1)
+ 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()
return obj
diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py b/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py
index ab43eb2..0f98425 100644
--- a/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py
+++ b/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py
@@ -11,7 +11,7 @@ import types
import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module
import csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin as tomo_queue_mixin
-from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI
+from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, LamNIError
class _FakeRtx:
@@ -200,7 +200,7 @@ 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)
--
2.54.0
From cdfc02335d360898a3d1666b106629a7df437e8d Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 20 Jul 2026 21:19:10 +0200
Subject: [PATCH 09/36] fix/tomo queue tooltip hides fields irrelevant to a
job's tomo_type
Hovering a queue entry showed every snapshotted param unconditionally,
including golden-ratio settings on a type-1 (non-golden) job -- and
vice versa, type-1-only fields (angle range, zero-deg reference) on a
golden-ratio job. Reuses _update_type_visibility()'s existing
per-tomo_type section/field gating (already used for the live edit
form) to filter _job_tooltip()'s params list the same way, for both
flomni and lamni jobs.
Co-Authored-By: Claude Sonnet 5
---
.../widgets/tomo_params/tomo_params.py | 28 +++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
index 8160b82..87a5d3d 100644
--- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
+++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
@@ -85,6 +85,29 @@ TOMO_TYPES = {
3: "Equally spaced, golden ratio start",
}
+# Mirrors _update_type_visibility()'s section/field gating (_sec_type1 shown
+# only for type 1, golden_ratio_bunch_size shown only for type 2 within
+# _sec_type23, the rest of _sec_type23 shown for type 2/3) -- reused by
+# _job_tooltip() so a queued job's hover details don't show fields that are
+# meaningless for its own tomo_type (e.g. golden-ratio settings on a type-1
+# job), on both flomni and lamni.
+_TYPE1_ONLY_PARAMS = {"tomo_angle_range", "zero_deg_reference_at_each_subtomo"}
+_TYPE2_ONLY_PARAMS = {"golden_ratio_bunch_size"}
+_TYPE23_PARAMS = {"golden_max_number_of_projections", "golden_projections_at_0_deg_for_damage_estimation"}
+
+
+def _irrelevant_params_for_type(tomo_type: Any) -> set[str]:
+ """Param names that don't apply to a job's own tomo_type and should be
+ hidden from its tooltip."""
+ irrelevant: set[str] = set()
+ if tomo_type != 1:
+ irrelevant |= _TYPE1_ONLY_PARAMS
+ if tomo_type not in (2, 3):
+ irrelevant |= _TYPE23_PARAMS
+ if tomo_type != 2:
+ irrelevant |= _TYPE2_ONLY_PARAMS
+ return irrelevant
+
STATUS_COLORS = {
"pending": "#888888",
"running": "#2196F3",
@@ -2280,7 +2303,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)
--
2.54.0
From 382e325093b76da902129ec71261c764798c55ce Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 20 Jul 2026 21:27:42 +0200
Subject: [PATCH 10/36] feat/print constant shift alongside per-projection
corrections
Correction 1, correction 2, and the x-ray-eye correction were already
printed for every projection, but the manual/constant shift
(manual_shift_x/manual_shift_y) applied in the same offset math wasn't
-- added on both setups, right after the existing correction calls in
lamni's tomo_scan_projection() and flomni's tomo_scan_projection() /
tomo_acquire_at_angle().
Co-Authored-By: Claude Sonnet 5
---
csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py | 1 +
csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py | 2 ++
2 files changed, 3 insertions(+)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 8614da7..184f68d 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -731,6 +731,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 = []
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
index beae086..ed26e01 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -3196,6 +3196,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 +3307,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
--
2.54.0
From 80f6972a311147fd5a3c157c64b1827fdad7ea74 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 20 Jul 2026 21:37:51 +0200
Subject: [PATCH 11/36] feat/show at_each_angle_hook in lamni tomo_parameters()
flomni's tomo_parameters() CLI printout already showed the active
at_each_angle_hook; lamni's didn't, making it easy to have a custom
per-projection hook active without noticing from the parameter dump.
Added the identical guarded print, reusing the shared
TomoQueueMixin._describe_active_hook() helper both classes already
inherit. Also switched lamni's tomo_scan() end-of-scan summary to use
that same helper instead of the raw attribute, matching flomni's
equivalent block and correctly flagging a stale/unregistered hook name.
Co-Authored-By: Claude Sonnet 5
---
csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 184f68d..fcb659e 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -1190,7 +1190,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
f"Sample: {self.sample_name}",
]
if self.at_each_angle_hook:
- timing_lines.append(f"At-each-angle hook: {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(
@@ -1265,6 +1265,8 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
print(f" _tomo_fovy_offset = {self.tomo_fovy_offset:.4f}")
print(f" _manual_shift_x = {self.manual_shift_x:.4f}")
print(f" _manual_shift_y = {self.manual_shift_y:.4f}")
+ if self.at_each_angle_hook:
+ print(f"At-each-angle hook = {self._describe_active_hook()}")
print("")
if self.tomo_type == 1:
print("\x1b[1mTomo type 1:\x1b[0m 8 equally spaced sub-tomograms (360 deg)")
--
2.54.0
From abc2a3d0415071a805e7d60cf66387762abd68ec Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 20 Jul 2026 22:00:22 +0200
Subject: [PATCH 12/36] feat/tomo queue reacquire from an earlier projection
Add tomo_queue_reacquire(job_index, projection_number) to the shared
TomoQueueMixin: reopens any queue job (even an already-"done" one) at
an earlier projection than its own recorded progress, and resets every
job queued after it -- tomo and command jobs alike -- to "pending", so
a following tomo_queue_resume() (a new alias for tomo_queue_execute(),
added for naming symmetry with tomo_scan_resume()) re-runs everything
from there forward in order. Reuses the existing tomo_scan_resume()
machinery by writing the requested resume point into the shared
progress global var, rather than adding new parameters to
tomo_scan()/tomo_queue_execute(). Guards against clobbering another
job's progress if one is already "incomplete"/"running" elsewhere in
the queue.
A flat projection number is used as the resume-point unit for all
three tomo_types, including type 1 (8 equally-spaced sub-tomograms),
via a new per-setup _resolve_type1_projection() that maps it to
(subtomo_start, start_angle). flomni's version reuses its existing
_subtomo_angle_plan(); lamni's inline angle math in sub_tomo_scan() was
extracted into an analogous _subtomo_angle_plan() static method first
(behavior-preserving -- verified against the existing angle-math test
suite) so both setups share the same pattern.
Co-Authored-By: Claude Sonnet 5
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 51 +++-
.../plugins/OMNY_shared/tomo_queue_mixin.py | 145 +++++++++++
.../plugins/flomni/flomni.py | 24 ++
.../test_tomo_queue_reacquire.py | 244 ++++++++++++++++++
4 files changed, 460 insertions(+), 4 deletions(-)
create mode 100644 tests/tests_bec_ipython_client/test_tomo_queue_reacquire.py
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index fcb659e..0e4b28e 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -859,11 +859,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
@@ -886,6 +897,38 @@ 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)."""
+ self._write_subtomo_to_scilog(subtomo_number)
+
+ 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
diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py
index 6465b6a..2740e56 100644
--- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py
@@ -26,6 +26,11 @@ Consumers (e.g. ``Flomni``, ``LamNI``) must:
the same way, but is setup-specific enough -- e.g. flomni's legacy
``flomni_at_each_angle`` builtins hook, single-point acquisition -- that
it stays out of this mixin)
+ - implement their own ``_resolve_type1_projection()`` (dispatched
+ generically by ``tomo_queue_reacquire()``), mapping a flat tomo_type-1
+ projection number to a ``(subtomo_start, start_angle)`` pair -- the
+ angle math is setup-specific (see each setup's own
+ ``_subtomo_angle_plan()``), same reasoning as ``tomo_scan()`` above
"""
from __future__ import annotations
@@ -756,3 +761,143 @@ 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 *other*
+ job in the queue is already "incomplete"/"running" (the
+ queue's single-resumable-job invariant -- resolve that one
+ first, or pass its own index).
+ 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):
+ if idx != job_index and other.get("status") in ("incomplete", "running"):
+ raise TomoQueueError(
+ f"tomo_queue_reacquire: job #{idx} ('{other['label']}') is already "
+ f"'{other['status']}' -- resolve it first (or pass index {idx} "
+ "instead), since only one job's progress can be tracked at a time."
+ )
+
+ for name, value in job["params"].items():
+ setattr(self, name, value)
+
+ if self.tomo_type == 1:
+ subtomo_start, start_angle = self._resolve_type1_projection(projection_number)
+ self.progress["subtomo"] = subtomo_start
+ self.progress["angle"] = start_angle
+ elif self.tomo_type in (2, 3):
+ if projection_number < 0:
+ raise ValueError("tomo_queue_reacquire: projection_number must be >= 0.")
+ max_prj = job["params"].get("golden_max_number_of_projections", 0) or 0
+ if max_prj > 0 and projection_number >= max_prj:
+ raise ValueError(
+ f"projection_number must be < {max_prj} "
+ "(golden_max_number_of_projections) for this job."
+ )
+ self.progress["projection"] = projection_number
+ else:
+ raise TomoQueueError(
+ f"tomo_queue_reacquire: unknown tomo_type {self.tomo_type} for job "
+ f"#{job_index} ('{job['label']}')."
+ )
+
+ # Fresh acquisition attempt -- reset timing bookkeeping the same way
+ # tomo_scan()'s own "new scan" branch does, so the eventual
+ # end-of-scan summary reflects this attempt, not whatever job ran
+ # most recently.
+ self.progress["tomo_start_time"] = datetime.datetime.now().isoformat()
+ self.progress["estimated_remaining_time"] = None
+ self.progress["estimated_finish_time"] = None
+ self.progress["accumulated_idle_time"] = 0.0
+ self.progress["heartbeat"] = None
+
+ job_id = job["id"]
+ self._tomo_queue_proxy.update_by_id(job_id, status="incomplete")
+
+ reset_count = 0
+ for later_job in jobs[job_index + 1 :]:
+ if later_job.get("status") != "pending":
+ self._tomo_queue_proxy.update_by_id(later_job["id"], status="pending")
+ reset_count += 1
+
+ print(
+ f"Job #{job_index} ('{job['label']}') reopened at projection "
+ f"{projection_number}. {reset_count} later job(s) reset to pending. "
+ "Call tomo_queue_resume() to run."
+ )
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
index ed26e01..d5685c4 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -2272,6 +2272,30 @@ class Flomni(
return angles, subtomo_offset, N, step
+ def _resolve_type1_projection(self, projection_number: int) -> tuple[int, float]:
+ """Map a flat 0-indexed tomo_type-1 projection number to
+ (subtomo_start, start_angle), for tomo_queue_reacquire().
+
+ The mapping mirrors exactly how sub_tomo_scan() itself numbers
+ projections (self.progress["projection"] = (subtomo_number - 1) * N
+ + subtomo_projection), so a projection_number read off e.g. the
+ tomography_scannumbers.txt log resolves to the same angle
+ sub_tomo_scan() would have produced at that position.
+ """
+ _, _, N, _ = self._subtomo_angle_plan(1, self.tomo_angle_range, self.tomo_angle_stepsize)
+ total = N * 8
+ if not 0 <= projection_number < total:
+ raise ValueError(
+ f"projection_number must be in 0..{total - 1} for this job's grid "
+ f"(tomo_angle_range={self.tomo_angle_range}, "
+ f"tomo_angle_stepsize={self.tomo_angle_stepsize})."
+ )
+ subtomo_number = projection_number // N + 1
+ angles, _, _, _ = self._subtomo_angle_plan(
+ subtomo_number, self.tomo_angle_range, self.tomo_angle_stepsize
+ )
+ return subtomo_number, float(angles[projection_number % N])
+
def sub_tomo_scan(self, subtomo_number, start_angle=None):
"""
Performs a sub tomogram scan.
diff --git a/tests/tests_bec_ipython_client/test_tomo_queue_reacquire.py b/tests/tests_bec_ipython_client/test_tomo_queue_reacquire.py
new file mode 100644
index 0000000..7fcaed6
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_tomo_queue_reacquire.py
@@ -0,0 +1,244 @@
+"""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
+ lamni._write_subtomo_to_scilog = lambda subtomo_number: None
+ 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_another_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(i1, status="incomplete")
+
+ with pytest.raises(TomoQueueError):
+ lamni.tomo_queue_reacquire(i0, projection_number=0)
+
+
+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"
--
2.54.0
From 4ada8e85ce0f0ba8ef18a05c42ebcbbf1733a98f Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 03:20:55 +0200
Subject: [PATCH 13/36] docs/document tomo_queue_reacquire and
tomo_queue_resume
Add both to the tomo-queue command reference in flomni.md and
lamni.md: tomo_queue_resume() as an alias for tomo_queue_execute(),
and tomo_queue_reacquire(job_index, projection_number) for reopening
a job (including an already-"done" one) at an earlier projection and
cascading later jobs to pending, mirroring the code added in the
previous commit.
Co-Authored-By: Claude Sonnet 5
---
docs/user/ptychography/flomni.md | 33 ++++++++++++++++++++++++++++++++
docs/user/ptychography/lamni.md | 32 +++++++++++++++++++++++++++++++
2 files changed, 65 insertions(+)
diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md
index 948e41f..eceda96 100644
--- a/docs/user/ptychography/flomni.md
+++ b/docs/user/ptychography/flomni.md
@@ -345,6 +345,7 @@ Several tomo parameter sets can be queued and run sequentially on the same sampl
| `flomni.tomo_queue_delete(*indices)` | Delete one or more jobs by index. |
| `flomni.tomo_queue_clear()` | Empty the queue. |
| `flomni.tomo_queue_execute(start_index=0)` | Run all pending jobs in sequence, on the current sample. |
+| `flomni.tomo_queue_resume(start_index=0)` | Alias for `tomo_queue_execute()` — identical behavior, just a name that matches `tomo_scan_resume()`. |
The queue is persisted (it survives a BEC client restart). Each job's status is one of `pending`, `running`, `incomplete`, or `done`. A job that did not run to completion (an exception was caught, or the BEC client itself crashed mid-scan) is automatically resumed - rather than restarted - the next time `flomni.tomo_queue_execute()` is called.
@@ -375,6 +376,38 @@ open in the panel. If you have an unsaved edit open and click the queue window's
button instead of the panel's, it warns and names the correct one before proceeding,
since it would otherwise queue your last-submitted values, not what you just typed.
+**Reacquiring from an earlier projection.** A resumed job normally picks up
+exactly where it stopped. If a beamline problem means earlier projections need
+redoing too — even in a job that's already marked `done` — use:
+
+`flomni.tomo_queue_reacquire(job_index, projection_number)`
+
+`job_index` is the job's position, same indexing as `tomo_queue_show()`.
+`projection_number` is a flat, 0-indexed projection count that works the same
+way for all three tomography modes — for "8 sub-tomograms" it counts straight
+through all 8 in order (subtomogram 1's projections first, then subtomogram
+2's, ...), exactly matching the running `projection` counter already shown in
+the live progress report / `flomni.progress["projection"]`; for the
+golden-ratio modes it's the same projection number those already use. This
+reopens the job at that point and resets every job queued *after* it —
+tomograms and command jobs alike, regardless of their own current status — to
+`pending`, so the next `flomni.tomo_queue_resume()` (or `tomo_queue_execute()`)
+call re-runs everything from there forward, in order. It does not run
+anything itself.
+
+Example — job 0 already finished, but its last few projections (and the jobs
+queued after it) need redoing:
+```
+flomni.tomo_queue_show() # find the job index and the projection number to restart at
+flomni.tomo_queue_reacquire(0, 130) # reopen job 0 at projection 130; jobs 1+ reset to pending
+flomni.tomo_queue_resume() # actually re-run, in order
+```
+
+Refuses (raises) if `job_index` points at a command job (nothing to
+reacquire), or if some *other* job in the queue is already `incomplete`/
+`running` — only one job's progress can be tracked at a time, so resolve that
+one first (or pass its own index instead).
+
#### Command jobs — reconfiguring the beamline between scans
In addition to tomogram jobs, the same queue can hold **command jobs**: an ordered
diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md
index 5dee5c1..b8df89b 100644
--- a/docs/user/ptychography/lamni.md
+++ b/docs/user/ptychography/lamni.md
@@ -113,6 +113,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 +150,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
--
2.54.0
From 8a7a350280b84600d3c55727736aec3cec984d7f Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 03:52:44 +0200
Subject: [PATCH 14/36] feat/warn early about Fermat scans below the minimum
point count
FlomniFermatScan/LamNIFermatScan already refuse to run with fewer than
20 positions (_check_min_positions(), raises ScanAbortion), but only
once the scan actually starts -- too late for an unattended tomo-queue
run, where the job just aborts mid-run and pauses the queue. Surface
the same prediction earlier instead, as a non-blocking warning:
- Extracted the position-generating methods on both scan classes
(get_flomni_fermat_spiral_pos, and lamni's chain --
_lamni_compute_scan_center, _lamni_compute_stitch_center,
_compute_total_shift, _lamni_check_pos_in_fov_range_and_circ_fov,
get_lamni_fermat_spiral_pos) into pure @staticmethods, so the exact
same algorithm the scan server runs can also be called from
client-side code without a live scan session. Behavior-preserving --
verified against the existing exact-position/instruction assertion
tests for both classes. Lifted the hardcoded "20" into a _MIN_POSITIONS
class attribute on each, so client code references the same threshold.
- lamni.py/flomni.py: new _expected_fermat_position_count() calls the
real scan-class algorithm with the live tomo parameters and prints a
warning line in tomo_parameters() when below the minimum.
- tomo_params.py: a new live-updating "Estimated Fermat scan points"
field (mirroring the existing achievable-step preview's styling),
wired to the fov/step/stitch/piezo-range fields, flagged orange below
the minimum -- never blocks Submit/Add-to-queue.
lamni's circular-FOV crop is angle/stitch-dependent, so the estimate is
representative (center tile, angle 0 for the CLI; the live stitch tile
for the GUI, which has those fields right there) rather than an exact
per-projection guarantee -- documented as such in both places.
Co-Authored-By: Claude Sonnet 5
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 54 +++++++
.../plugins/flomni/flomni.py | 45 ++++++
.../widgets/tomo_params/tomo_params.py | 114 +++++++++++++++
csaxs_bec/scans/LamNIFermatScan.py | 120 ++++++++++++----
csaxs_bec/scans/flomni_fermat_scan.py | 52 +++++--
.../test_fermat_position_warning.py | 134 ++++++++++++++++++
.../test_lamni_tomo_params_widget_math.py | 78 ++++++++++
.../test_tomo_params_widget_math.py | 27 ++++
8 files changed, 581 insertions(+), 43 deletions(-)
create mode 100644 tests/tests_bec_ipython_client/test_fermat_position_warning.py
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 0e4b28e..0099548 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -1288,6 +1288,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:")
@@ -1300,6 +1345,15 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
print(f"Stitching number x,y = {self.lamni_stitch_x}, {self.lamni_stitch_y}")
print(f"Stitching overlap = {self.tomo_stitch_overlap}")
print(f"Circular FOV diam = {self.tomo_circfov}")
+ expected_positions = self._expected_fermat_position_count()
+ min_positions = self._fermat_min_positions()
+ if expected_positions < min_positions:
+ print(
+ f"Estimated Fermat scan points per projection: {expected_positions} "
+ f"(WARNING: below the minimum of {min_positions} -- the scan will abort when run)"
+ )
+ else:
+ print(f"Estimated Fermat scan points per projection: {expected_positions}")
print(f"Reconstruction queue name = {self.ptycho_reconstruct_foldername}")
print(f"Frames per trigger (burst) = {self.frames_per_trigger}")
print("FOV offset rotates to find the ROI; initial values determined in Xrayeye alignment.")
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
index d5685c4..dac92c4 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -3393,6 +3393,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:")
@@ -3401,6 +3435,17 @@ class Flomni(
print(f"FOV (200/100) = {self.fovx}, {self.fovy}")
print(f"Stitching number x,y = {self.stitch_x}, {self.stitch_y}")
print(f"Stitching overlap = {self.tomo_stitch_overlap}")
+ if not self.single_point_instead_of_fermat_scan:
+ expected_positions = self._expected_fermat_position_count()
+ min_positions = self._fermat_min_positions()
+ if expected_positions < min_positions:
+ print(
+ f"Estimated Fermat scan points per projection: {expected_positions} "
+ f"(WARNING: below the minimum of {min_positions} -- the scan will abort"
+ " when run)"
+ )
+ else:
+ print(f"Estimated Fermat scan points per projection: {expected_positions}")
print(f"Reconstruction queue name = {self.ptycho_reconstruct_foldername}")
print(f" _manual_shift_y = {self.manual_shift_y}")
print(f"Frames per trigger (burst) = {self.frames_per_trigger}")
diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
index 87a5d3d..e360738 100644
--- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
+++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
@@ -481,6 +481,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
@@ -732,6 +743,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
@@ -888,6 +900,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:
@@ -2089,6 +2125,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 =
@@ -2155,6 +2257,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",
},
@@ -2205,6 +2309,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",
},
diff --git a/csaxs_bec/scans/LamNIFermatScan.py b/csaxs_bec/scans/LamNIFermatScan.py
index 37974b9..83da5b1 100644
--- a/csaxs_bec/scans/LamNIFermatScan.py
+++ b/csaxs_bec/scans/LamNIFermatScan.py
@@ -49,7 +49,8 @@ def lamni_from_stage_coordinates(x_stage: float, y_stage: float) -> tuple:
class LamNIMixin:
- def _lamni_compute_scan_center(self, x, y, angle_deg):
+ @staticmethod
+ def _lamni_compute_scan_center(x, y, angle_deg):
# assuming a scan point was found at interferometer x,y at zero degrees
# this function computes the new interferometer coordinates of this spot
# at a different rotation angle based on the lamni geometry
@@ -216,6 +217,13 @@ class LamNIFermatScan(AsyncFlyScanBase, LamNIMixin):
arg_input = {}
arg_bundle_size = {"bundle": len(arg_input), "min": None, "max": None}
+ # Minimum number of Fermat-spiral positions a scan is allowed to run
+ # with -- exposed as a class attribute (not just a literal inside
+ # _check_min_positions()) so client-side code (tomo_parameters(),
+ # tomo_params.py) can warn about a too-few-points configuration before
+ # it ever reaches the scan server, using the exact same threshold.
+ _MIN_POSITIONS = 20
+
def __init__(self, *args, parameter: dict = None, frames_per_trigger:int=1, exp_time:float=0,**kwargs):
"""
A LamNI scan following Fermat's spiral.
@@ -286,20 +294,24 @@ class LamNIFermatScan(AsyncFlyScanBase, LamNIMixin):
self._check_min_positions()
def _check_min_positions(self):
- if self.num_pos < 20:
+ if self.num_pos < self._MIN_POSITIONS:
raise ScanAbortion(
- f"The number of positions must exceed 20. Currently: {self.num_pos}."
+ f"The number of positions must exceed {self._MIN_POSITIONS}. Currently:"
+ f" {self.num_pos}."
)
- def _lamni_check_pos_in_fov_range_and_circ_fov(self, x, y) -> bool:
+ @staticmethod
+ def _lamni_check_pos_in_fov_range_and_circ_fov(
+ x, y, stitch_x, stitch_y, angle, fov_size, stitch_overlap, fov_circular
+ ) -> bool:
# this function checks if positions are reachable in a scan
# these x y intererometer positions are not shifted to the scan center
# so its purpose is to see if the position is reachable by the
# rotated piezo stage. For a scan these positions have to be shifted to
# the current scan center before starting the scan
stage_x, stage_y = lamni_to_stage_coordinates(x, y)
- stage_x_with_stitch, stage_y_with_stitch = self._lamni_compute_stitch_center(
- self.stitch_x, self.stitch_y, self.angle
+ stage_x_with_stitch, stage_y_with_stitch = LamNIFermatScan._lamni_compute_stitch_center(
+ stitch_x, stitch_y, angle, fov_size, stitch_overlap
)
stage_x_with_stitch, stage_y_with_stitch = lamni_to_stage_coordinates(
stage_x_with_stitch, stage_y_with_stitch
@@ -307,7 +319,7 @@ class LamNIFermatScan(AsyncFlyScanBase, LamNIMixin):
# piezo stage is currently rotated to stage_angle_deg in degrees
# rotate positions to the piezo stage system
- alpha = (self.angle - 300 + 30.5) / 180 * np.pi
+ alpha = (angle - 300 + 30.5) / 180 * np.pi
stage_x_rot = np.cos(alpha) * stage_x + np.sin(alpha) * stage_y
stage_y_rot = -np.sin(alpha) * stage_x + np.cos(alpha) * stage_y
@@ -319,22 +331,32 @@ class LamNIFermatScan(AsyncFlyScanBase, LamNIMixin):
)
return (
- np.abs(stage_x_rot) <= (self.fov_size[1] / 2)
- and np.abs(stage_y_rot) <= (self.fov_size[0] / 2)
+ np.abs(stage_x_rot) <= (fov_size[1] / 2)
+ and np.abs(stage_y_rot) <= (fov_size[0] / 2)
and (
- self.fov_circular == 0
+ fov_circular == 0
or (
np.power((stage_x_rot_with_stitch + stage_x_rot), 2)
+ np.power((stage_y_rot_with_stitch + stage_y_rot), 2)
)
- <= pow((self.fov_circular / 2), 2)
+ <= pow((fov_circular / 2), 2)
)
)
def _prepare_setup(self):
yield from self.stubs.send_rpc_and_wait("rtx", "controller.clear_trajectory_generator")
yield from self.lamni_rotation(self.angle)
- total_shift_x, total_shift_y = self._compute_total_shift()
+ total_shift_x, total_shift_y = self._compute_total_shift(
+ self.center_x,
+ self.center_y,
+ self.angle,
+ self.stitch_x,
+ self.stitch_y,
+ self.stitch_overlap,
+ self.shift_x,
+ self.shift_y,
+ self.fov_size,
+ )
yield from self.lamni_new_scan_center_interferometer(total_shift_x, total_shift_y)
# self._plot_target_pos()
if self.scan_type == "fly":
@@ -363,36 +385,73 @@ class LamNIFermatScan(AsyncFlyScanBase, LamNIMixin):
step=self.step,
spiral_type=0,
center=False,
+ center_x=self.center_x,
+ center_y=self.center_y,
+ angle=self.angle,
+ stitch_x=self.stitch_x,
+ stitch_y=self.stitch_y,
+ stitch_overlap=self.stitch_overlap,
+ shift_x=self.shift_x,
+ shift_y=self.shift_y,
+ fov_size=self.fov_size,
+ fov_circular=self.fov_circular,
)
- def _lamni_compute_stitch_center(self, xcount, ycount, angle_deg):
+ @staticmethod
+ def _lamni_compute_stitch_center(xcount, ycount, angle_deg, fov_size, stitch_overlap):
alpha = angle_deg / 180 * np.pi
- stage_x = xcount * (self.fov_size[0] - self.stitch_overlap)
- stage_y = ycount * (self.fov_size[1] - self.stitch_overlap)
+ stage_x = xcount * (fov_size[0] - stitch_overlap)
+ stage_y = ycount * (fov_size[1] - stitch_overlap)
x_rot = np.cos(alpha) * stage_x - np.sin(alpha) * stage_y
y_rot = np.sin(alpha) * stage_x + np.cos(alpha) * stage_y
return lamni_from_stage_coordinates(x_rot, y_rot)
- def _compute_total_shift(self):
- _shfitx, _shfity = self._lamni_compute_scan_center(self.center_x, self.center_y, self.angle)
- x_stitch_shift, y_stitch_shift = self._lamni_compute_stitch_center(
- self.stitch_x, self.stitch_y, self.angle
+ @staticmethod
+ def _compute_total_shift(
+ center_x, center_y, angle, stitch_x, stitch_y, stitch_overlap, shift_x, shift_y, fov_size
+ ):
+ _shfitx, _shfity = LamNIFermatScan._lamni_compute_scan_center(center_x, center_y, angle)
+ x_stitch_shift, y_stitch_shift = LamNIFermatScan._lamni_compute_stitch_center(
+ stitch_x, stitch_y, angle, fov_size, stitch_overlap
)
logger.info(
- f"Total shift [mm] {_shfitx+x_stitch_shift/1000+self.shift_x},"
- f" {_shfity+y_stitch_shift/1000+self.shift_y}"
- )
- return (
- _shfitx + x_stitch_shift / 1000 + self.shift_x,
- _shfity + y_stitch_shift / 1000 + self.shift_y,
+ f"Total shift [mm] {_shfitx+x_stitch_shift/1000+shift_x},"
+ f" {_shfity+y_stitch_shift/1000+shift_y}"
)
+ return (_shfitx + x_stitch_shift / 1000 + shift_x, _shfity + y_stitch_shift / 1000 + shift_y)
+ @staticmethod
def get_lamni_fermat_spiral_pos(
- self, m1_start, m1_stop, m2_start, m2_stop, step=1, spiral_type=0, center=False
+ m1_start,
+ m1_stop,
+ m2_start,
+ m2_stop,
+ step=1,
+ spiral_type=0,
+ center=False,
+ center_x=0.0,
+ center_y=0.0,
+ angle=0.0,
+ stitch_x=0,
+ stitch_y=0,
+ stitch_overlap=1,
+ shift_x=0.0,
+ shift_y=0.0,
+ fov_size=None,
+ fov_circular=0,
):
"""[summary]
+ Pure function (no device I/O) -- a @staticmethod rather than an
+ instance method (center_x/center_y/angle/stitch_x/stitch_y/
+ stitch_overlap/shift_x/shift_y/fov_size/fov_circular used to be read
+ off self.*) so it can also be called directly from client-side code
+ (tomo_parameters(), tomo_params.py) to predict the point count of a
+ not-yet-run scan, using the exact same algorithm the scan itself
+ will use -- including the rotated-stage and circular-FOV cropping
+ in _lamni_check_pos_in_fov_range_and_circ_fov().
+
Args:
m1_start (float): start position motor 1
m1_stop (float): end position motor 1
@@ -402,6 +461,7 @@ class LamNIFermatScan(AsyncFlyScanBase, LamNIMixin):
spiral_type (float, optional): Angular offset in radians that determines the shape of the spiral.
A spiral with spiral_type=2 is the same as spiral_type=0. Defaults to 0.
center (bool, optional): Add a center point. Defaults to False.
+ fov_size (list): [fov_x, fov_y] used for the rotated-stage/circular-FOV crop.
Raises:
TypeError: [description]
@@ -423,7 +483,9 @@ class LamNIFermatScan(AsyncFlyScanBase, LamNIMixin):
length_axis2 = np.abs(m2_stop - m2_start)
n_max = int(length_axis1 * length_axis2 * 3.2 / step / step)
- total_shift_x, total_shift_y = self._compute_total_shift()
+ total_shift_x, total_shift_y = LamNIFermatScan._compute_total_shift(
+ center_x, center_y, angle, stitch_x, stitch_y, stitch_overlap, shift_x, shift_y, fov_size
+ )
for ii in range(start, n_max):
radius = step * 0.57 * np.sqrt(ii)
@@ -434,7 +496,9 @@ class LamNIFermatScan(AsyncFlyScanBase, LamNIMixin):
# continue
x = radius * np.sin(ii * phi)
y = radius * np.cos(ii * phi)
- if self._lamni_check_pos_in_fov_range_and_circ_fov(x, y):
+ if LamNIFermatScan._lamni_check_pos_in_fov_range_and_circ_fov(
+ x, y, stitch_x, stitch_y, angle, fov_size, stitch_overlap, fov_circular
+ ):
positions.extend([(x + total_shift_x * 1000, y + total_shift_y * 1000)])
# for testing we just shift by center_i and prepare also the setup to center_i
return np.array(positions)
diff --git a/csaxs_bec/scans/flomni_fermat_scan.py b/csaxs_bec/scans/flomni_fermat_scan.py
index d724f27..f3c6d41 100644
--- a/csaxs_bec/scans/flomni_fermat_scan.py
+++ b/csaxs_bec/scans/flomni_fermat_scan.py
@@ -41,6 +41,13 @@ class FlomniFermatScan(AsyncFlyScanBase):
arg_input = {}
arg_bundle_size = {"bundle": len(arg_input), "min": None, "max": None}
+ # Minimum number of Fermat-spiral positions a scan is allowed to run
+ # with -- exposed as a class attribute (not just a literal inside
+ # _check_min_positions()) so client-side code (tomo_parameters(),
+ # tomo_params.py) can warn about a too-few-points configuration before
+ # it ever reaches the scan server, using the exact same threshold.
+ _MIN_POSITIONS = 20
+
def __init__(
self,
fovx: float,
@@ -148,9 +155,10 @@ class FlomniFermatScan(AsyncFlyScanBase):
self._check_min_positions()
def _check_min_positions(self):
- if self.num_pos < 20:
+ if self.num_pos < self._MIN_POSITIONS:
raise ScanAbortion(
- f"The number of positions must exceed 20. Currently: {self.num_pos}."
+ f"The number of positions must exceed {self._MIN_POSITIONS}. Currently:"
+ f" {self.num_pos}."
)
def _prepare_setup(self):
@@ -237,14 +245,33 @@ class FlomniFermatScan(AsyncFlyScanBase):
step=self.step,
spiral_type=0,
center=False,
+ cenx=self.cenx,
+ ceny=self.ceny,
+ zshift=self.zshift,
)
+ @staticmethod
def get_flomni_fermat_spiral_pos(
- self, m1_start, m1_stop, m2_start, m2_stop, step=1, spiral_type=0, center=False
+ m1_start,
+ m1_stop,
+ m2_start,
+ m2_stop,
+ step=1,
+ spiral_type=0,
+ center=False,
+ cenx=0.0,
+ ceny=0.0,
+ zshift=0.0,
):
"""
Calculate positions for a Fermat spiral scan.
+ Pure function (no device I/O) -- a @staticmethod rather than an
+ instance method (cenx/ceny/zshift used to be read off self.*) so it
+ can also be called directly from client-side code (tomo_parameters(),
+ tomo_params.py) to predict the point count of a not-yet-run scan,
+ using the exact same algorithm the scan itself will use.
+
Args:
m1_start(float): start position in m1
m1_stop(float): stop position in m1
@@ -253,6 +280,9 @@ class FlomniFermatScan(AsyncFlyScanBase):
step(float): stepsize
spiral_type(int): 0 for traditional Fermat spiral
center(bool): whether to include the center position
+ cenx(float): center offset added to every x position
+ ceny(float): center offset added to every y position
+ zshift(float): z position for every point
Returns:
positions(array): positions
@@ -266,7 +296,7 @@ class FlomniFermatScan(AsyncFlyScanBase):
length_axis2 = np.abs(m2_stop - m2_start)
n_max = int(length_axis1 * length_axis2 * 3.2 / step / step)
- z_pos = self.zshift
+ z_pos = zshift
for ii in range(start, n_max):
radius = step * 0.57 * np.sqrt(ii)
@@ -277,17 +307,9 @@ class FlomniFermatScan(AsyncFlyScanBase):
continue
x = radius * np.sin(ii * phi)
y = radius * np.cos(ii * phi)
- positions.append([x + self.cenx, y + self.ceny, z_pos])
- left_lower_corner = [
- min(m1_start, m1_stop) + self.cenx,
- min(m2_start, m2_stop) + self.ceny,
- z_pos,
- ]
- right_upper_corner = [
- max(m1_start, m1_stop) + self.cenx,
- max(m2_start, m2_stop) + self.ceny,
- z_pos,
- ]
+ positions.append([x + cenx, y + ceny, z_pos])
+ left_lower_corner = [min(m1_start, m1_stop) + cenx, min(m2_start, m2_stop) + ceny, z_pos]
+ right_upper_corner = [max(m1_start, m1_stop) + cenx, max(m2_start, m2_stop) + ceny, z_pos]
positions.append(left_lower_corner)
positions.append(right_upper_corner)
return np.array(positions)
diff --git a/tests/tests_bec_ipython_client/test_fermat_position_warning.py b/tests/tests_bec_ipython_client/test_fermat_position_warning.py
new file mode 100644
index 0000000..73ea8f7
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_fermat_position_warning.py
@@ -0,0 +1,134 @@
+"""Tests for the Fermat-scan minimum-position warning: LamNI/Flomni's
+_expected_fermat_position_count()/_fermat_min_positions() (lamni.py/
+flomni.py), which predict a scan's point count before it ever reaches the
+scan server, by calling the exact same algorithm the real scan classes use
+(FlomniFermatScan.get_flomni_fermat_spiral_pos()/
+LamNIFermatScan.get_lamni_fermat_spiral_pos(), both now pure @staticmethods
+-- see csaxs_bec/scans/flomni_fermat_scan.py and LamNIFermatScan.py).
+"""
+
+import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module
+from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni
+from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI
+from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan
+from csaxs_bec.scans.LamNIFermatScan import LamNIFermatScan
+
+
+class FakeClient:
+ """Minimal in-memory stand-in for BEC's global-var store."""
+
+ def __init__(self):
+ self._vars = {}
+
+ def get_global_var(self, key):
+ return self._vars.get(key)
+
+ def set_global_var(self, key, value):
+ self._vars[key] = value
+
+
+class _FakeRtx:
+ user_parameter = {"large_range_scan": True}
+
+
+class _FakeDev:
+ rtx = _FakeRtx()
+
+ def __contains__(self, name):
+ return hasattr(self, name)
+
+ def __getitem__(self, name):
+ return getattr(self, name)
+
+
+def make_flomni():
+ obj = object.__new__(Flomni)
+ obj.client = FakeClient()
+ return obj
+
+
+def make_lamni():
+ # lamni_piezo_range_x/y's setter checks dev.rtx.user_parameter -- only
+ # ever defined (via the `if builtins.__dict__.get("bec") is not None`
+ # block at module import time) inside a real BEC session. Stand in a
+ # fake, same pattern as test_lamni_tomo_queue.py/test_tomo_queue_reacquire.py.
+ lamni_module.dev = _FakeDev()
+ obj = object.__new__(LamNI)
+ obj.client = FakeClient()
+ return obj
+
+
+def test_flomni_min_positions_threshold_is_20():
+ assert FlomniFermatScan._MIN_POSITIONS == 20
+
+
+def test_lamni_min_positions_threshold_is_20():
+ assert LamNIFermatScan._MIN_POSITIONS == 20
+
+
+def test_flomni_expected_position_count_below_threshold():
+ flomni = make_flomni()
+ flomni.fovx = 5.0
+ flomni.fovy = 5.0
+ flomni.tomo_shellstep = 2.0
+ flomni.single_point_instead_of_fermat_scan = False
+
+ count = flomni._expected_fermat_position_count()
+
+ assert count < flomni._fermat_min_positions()
+ # matches calling the real scan-class algorithm directly
+ expected = FlomniFermatScan.get_flomni_fermat_spiral_pos(-2.5, 2.5, -2.5, 2.5, step=2.0)
+ assert count == len(expected)
+
+
+def test_flomni_expected_position_count_above_threshold():
+ flomni = make_flomni()
+ flomni.fovx = 100.0
+ flomni.fovy = 80.0
+ flomni.tomo_shellstep = 1.0
+ flomni.single_point_instead_of_fermat_scan = False
+
+ count = flomni._expected_fermat_position_count()
+
+ assert count >= flomni._fermat_min_positions()
+
+
+def test_lamni_expected_position_count_below_threshold():
+ lamni = make_lamni()
+ lamni.lamni_piezo_range_x = 5.0
+ lamni.lamni_piezo_range_y = 5.0
+ lamni.tomo_shellstep = 2.0
+ lamni.tomo_stitch_overlap = 0.2
+ lamni.tomo_circfov = 0.0
+
+ count = lamni._expected_fermat_position_count()
+
+ assert count < lamni._fermat_min_positions()
+ # matches calling the real scan-class algorithm directly, center tile, angle 0
+ expected = LamNIFermatScan.get_lamni_fermat_spiral_pos(
+ -2.5,
+ 2.5,
+ -2.5,
+ 2.5,
+ step=2.0,
+ angle=0.0,
+ stitch_x=0,
+ stitch_y=0,
+ stitch_overlap=0.2,
+ fov_size=[5.0, 5.0],
+ fov_circular=0.0,
+ )
+ assert count == len(expected)
+
+
+def test_lamni_expected_position_count_above_threshold():
+ lamni = make_lamni()
+ lamni.lamni_piezo_range_x = 20.0
+ lamni.lamni_piezo_range_y = 20.0
+ lamni.tomo_shellstep = 1.0
+ lamni.tomo_stitch_overlap = 0.2
+ lamni.tomo_circfov = 0.0
+
+ count = lamni._expected_fermat_position_count()
+
+ assert count >= lamni._fermat_min_positions()
diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py
index 7d4c74c..bb5878f 100644
--- a/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py
+++ b/tests/tests_bec_ipython_client/test_lamni_tomo_params_widget_math.py
@@ -22,6 +22,7 @@ from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import (
LAMNI_QUEUE_PARAM_NAMES,
QUEUE_PARAM_NAMES,
SETUP_PROFILES,
+ _compute_fermat_positions_lamni,
_compute_type1,
_lamni_compute_type1,
_lamni_get_tomo_fov_offset,
@@ -29,6 +30,7 @@ from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import (
_lamni_set_tomo_fov_offset,
_requested_to_stepsize,
)
+from csaxs_bec.scans.LamNIFermatScan import LamNIFermatScan
STEPSIZES = [10.0, 7.0, 25.0, 12.5]
@@ -165,3 +167,79 @@ def test_lamni_field_order_matches_tomo_parameters_cli():
"offsets",
"at_each_angle_hook",
]
+
+
+def test_compute_fermat_positions_lamni_matches_scan_class_below_threshold():
+ params = {
+ "lamni_piezo_range_x": 5.0,
+ "lamni_piezo_range_y": 5.0,
+ "tomo_shellstep": 2.0,
+ "tomo_stitch_overlap": 0.2,
+ "tomo_circfov": 0.0,
+ "lamni_stitch_x": 0,
+ "lamni_stitch_y": 0,
+ }
+ count, min_positions = _compute_fermat_positions_lamni(params)
+ assert min_positions == LamNIFermatScan._MIN_POSITIONS
+ assert count < min_positions
+ expected = LamNIFermatScan.get_lamni_fermat_spiral_pos(
+ -2.5,
+ 2.5,
+ -2.5,
+ 2.5,
+ step=2.0,
+ angle=0.0,
+ stitch_x=0,
+ stitch_y=0,
+ stitch_overlap=0.2,
+ fov_size=[5.0, 5.0],
+ fov_circular=0.0,
+ )
+ assert count == len(expected)
+
+
+def test_compute_fermat_positions_lamni_matches_scan_class_above_threshold():
+ params = {
+ "lamni_piezo_range_x": 20.0,
+ "lamni_piezo_range_y": 20.0,
+ "tomo_shellstep": 1.0,
+ "tomo_stitch_overlap": 0.2,
+ "tomo_circfov": 0.0,
+ "lamni_stitch_x": 0,
+ "lamni_stitch_y": 0,
+ }
+ count, min_positions = _compute_fermat_positions_lamni(params)
+ assert count >= min_positions
+
+
+def test_compute_fermat_positions_lamni_uses_live_stitch_tile():
+ """Unlike the CLI's tomo_parameters() (which always estimates the
+ center tile as a quick summary), the GUI has the actual stitch_x/y
+ fields right there, so it should reflect whichever tile is currently
+ being edited, not always stitch=0."""
+ base_params = {
+ "lamni_piezo_range_x": 20.0,
+ "lamni_piezo_range_y": 20.0,
+ "tomo_shellstep": 1.0,
+ "tomo_stitch_overlap": 0.2,
+ "tomo_circfov": 15.0,
+ "lamni_stitch_x": 0,
+ "lamni_stitch_y": 0,
+ }
+ shifted_params = dict(base_params, lamni_stitch_x=3, lamni_stitch_y=2)
+
+ count_center, _ = _compute_fermat_positions_lamni(base_params)
+ count_shifted, _ = _compute_fermat_positions_lamni(shifted_params)
+
+ # a large stitch offset combined with a tight circular FOV crop should
+ # shift the count -- if this ever becomes flaky because both happen to
+ # match, widen fov_circular/stitch further rather than removing the check.
+ assert count_center != count_shifted
+
+
+def test_compute_fermat_positions_lamni_zero_step_is_safe():
+ count, min_positions = _compute_fermat_positions_lamni(
+ {"lamni_piezo_range_x": 5.0, "lamni_piezo_range_y": 5.0, "tomo_shellstep": 0.0}
+ )
+ assert count == 0
+ assert min_positions == LamNIFermatScan._MIN_POSITIONS
diff --git a/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py
index 018c98f..7e504a7 100644
--- a/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py
+++ b/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py
@@ -10,9 +10,11 @@ import pytest
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni
from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import (
+ _compute_fermat_positions_flomni,
_compute_type1,
_requested_to_stepsize,
)
+from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan
STEPSIZES = [10.0, 7.0, 25.0, 12.5]
@@ -46,3 +48,28 @@ def _flomni_reference(stepsize):
N = int(180.0 / stepsize)
step = 180.0 / N
return N * 8, step, N
+
+
+def test_compute_fermat_positions_flomni_matches_scan_class_below_threshold():
+ params = {"fovx": 5.0, "fovy": 5.0, "tomo_shellstep": 2.0}
+ count, min_positions = _compute_fermat_positions_flomni(params)
+ assert min_positions == FlomniFermatScan._MIN_POSITIONS
+ assert count < min_positions
+ expected = FlomniFermatScan.get_flomni_fermat_spiral_pos(-2.5, 2.5, -2.5, 2.5, step=2.0)
+ assert count == len(expected)
+
+
+def test_compute_fermat_positions_flomni_matches_scan_class_above_threshold():
+ params = {"fovx": 100.0, "fovy": 80.0, "tomo_shellstep": 1.0}
+ count, min_positions = _compute_fermat_positions_flomni(params)
+ assert count >= min_positions
+ expected = FlomniFermatScan.get_flomni_fermat_spiral_pos(-50.0, 50.0, -40.0, 40.0, step=1.0)
+ assert count == len(expected)
+
+
+def test_compute_fermat_positions_flomni_zero_step_is_safe():
+ count, min_positions = _compute_fermat_positions_flomni(
+ {"fovx": 5.0, "fovy": 5.0, "tomo_shellstep": 0.0}
+ )
+ assert count == 0
+ assert min_positions == FlomniFermatScan._MIN_POSITIONS
--
2.54.0
From 5ee00c997aa1b53bfdbfd02d9425b9446ddf788a Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 03:58:33 +0200
Subject: [PATCH 15/36] docs/add session summary + test checklist for this
branch's fixes
Brief AI_docs note covering commits 6c3d603..8a7a350: what changed and
what to test (automated + manual/simulated-session) to confirm the
branch is in good shape before relying on it at the beamline.
Co-Authored-By: Claude Sonnet 5
---
.../HW_COMMISSIONING_SESSION_2026-07.md | 59 +++++++++++++++++++
1 file changed, 59 insertions(+)
create mode 100644 csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/HW_COMMISSIONING_SESSION_2026-07.md
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/HW_COMMISSIONING_SESSION_2026-07.md b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/HW_COMMISSIONING_SESSION_2026-07.md
new file mode 100644
index 0000000..3e159fd
--- /dev/null
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/HW_COMMISSIONING_SESSION_2026-07.md
@@ -0,0 +1,59 @@
+# flomni/lamni parity fixes — 2026-07 session summary
+
+Branch `fixes/lamni_hw_commissioning`, commits `6c3d603..8a7a350`. Very brief
+by design — `git show ` on any commit below has the full reasoning and
+diff; that's the source of truth if something needs revisiting.
+
+## What was done
+
+1. **`6c3d603`** — lamni: end-of-scan timing overview (total time / time lost
+ to gaps) + scilog write, matching flomni. `_tomo_scan_at_angle()` wrapped in
+ `@scan_repeat` so any interruption (not just the one `AlarmBase` case
+ already handled) redoes the whole projection. `scan_interlock` now enabled
+ at `tomo_scan()` start (was flomni-only before).
+2. **`cdfc023`** — tomo queue hover tooltip no longer shows golden-ratio
+ fields on a non-golden job (or type-1-only fields on a golden job).
+3. **`382e325`** — the manual/constant shift is now printed alongside
+ correction 1/2 + x-ray-eye correction during a projection, both setups.
+4. **`80f6972`** — lamni's `tomo_parameters()` now shows the active
+ `at_each_angle_hook`, matching flomni.
+5. **`abc2a3d`, `4ada8e8`** — new `tomo_queue_reacquire(job_index,
+ projection_number)`: reopen any job (even "done") at an earlier
+ projection, cascades every later job to `pending`. New `tomo_queue_resume()`
+ alias for `tomo_queue_execute()`. Docs updated in
+ `docs/user/ptychography/{lamni,flomni}.md`.
+6. **`8a7a350`** — `tomo_parameters()` (CLI) and the GUI (`tomo_params.py`,
+ live field) now warn if the current settings would produce fewer than 20
+ Fermat-scan points. Required converting `FlomniFermatScan`/
+ `LamNIFermatScan`'s position math to pure `@staticmethod`s (same
+ algorithm, no behavior change — verified against their existing
+ exact-position tests).
+
+**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 `412 passed, 15 skipped`:
+```
+/opt/bec_deployments/production/bec_venv/bin/python -m pytest tests/ -q
+```
+
+**Manual, on a simulated or real session, before relying on this at the
+beamline:**
+
+- `lamni.tomo_scan()` (short run): "Tomoscan finished" banner + timing
+ summary at the end; force a mid-scan exception once and confirm the same
+ projection retries instead of the scan aborting.
+- `lamni.tomo_queue_add()` a couple of jobs → `tomo_queue_execute()` →
+ `tomo_queue_reacquire(0, )` on a finished job → confirm status flips and
+ later jobs go `pending` → `tomo_queue_resume()` re-runs them.
+- `lamni.tomo_parameters()`: confirm the hook line (if one's registered) and
+ the "Estimated Fermat scan points" line both appear, with a warning shown
+ for a deliberately tiny piezo range / coarse step.
+- 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 the same tiny-FOV condition.
+- 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).
--
2.54.0
From c3877ecdef61bc71b1e50164231eb7e251f0433a Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 04:18:23 +0200
Subject: [PATCH 16/36] feat/add zero-deg radiation-damage reference shot for
lamni tomo_type 1
Ports flomni's zero_deg_reference_at_each_subtomo to lamni's "8 equally
spaced sub-tomograms" mode: an extra dedicated projection at exactly 0
degrees before every sub-tomogram where the rotation naturally passes
back through 0, plus one final shot once the tomogram completes, for
tracking radiation damage over a long acquisition. lamni's tomo_type
2/3 already had the equivalent (golden_projections_at_0_deg_for_damage_estimation,
byte-for-byte identical to flomni's) -- only tomo_type 1 was missing.
_subtomo_starts_near_zero() uses subtomo_number % 2 (every odd
sub-tomogram) rather than flomni's 360-mode subtomo_number % 4 == 1 --
confirmed against lamni's actual rotation behavior rather than derived
from the position-array math, which doesn't cleanly split odd/even.
The GUI (tomo_params.py) needed no new UI code: _build_type1_section()
already gated this control on a per-setup has_zero_deg_reference flag,
so enabling it for lamni was just flipping that flag plus adding the
param name/default, mirroring flomni's entries exactly.
Co-Authored-By: Claude Sonnet 5
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 62 ++++++++++++++++++
.../widgets/tomo_params/tomo_params.py | 14 ++--
.../test_lamni_tomo_angles.py | 65 +++++++++++++++++++
3 files changed, 137 insertions(+), 4 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 0099548..6e951b7 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -138,6 +138,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",
)
@@ -656,6 +657,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")
@@ -941,6 +960,20 @@ 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.
@@ -1120,9 +1153,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
@@ -1374,6 +1426,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}")
@@ -1442,6 +1496,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:
diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
index e360738..a8b1771 100644
--- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
+++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
@@ -169,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",
@@ -191,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",
)
@@ -214,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,
}
@@ -2269,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),
diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
index b1178ce..7e19c67 100644
--- a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
+++ b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
@@ -268,3 +268,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():
+ """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(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():
+ """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(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():
+ """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(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)]
--
2.54.0
From 8733b6cf9184d1671afa6ff5e5ee9cc0a4e21650 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 04:19:01 +0200
Subject: [PATCH 17/36] docs/update session summary with zero-deg reference
feature
Extend commit range to c3877ec, add item 7 (zero_deg_reference_at_each_subtomo
for lamni tomo_type 1), update expected test count to 423, and add a
manual-check line for the new feature.
Co-Authored-By: Claude Sonnet 5
---
.../HW_COMMISSIONING_SESSION_2026-07.md | 20 +++++++++++++++++--
1 file changed, 18 insertions(+), 2 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/HW_COMMISSIONING_SESSION_2026-07.md b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/HW_COMMISSIONING_SESSION_2026-07.md
index 3e159fd..1e44f44 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/HW_COMMISSIONING_SESSION_2026-07.md
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/HW_COMMISSIONING_SESSION_2026-07.md
@@ -1,6 +1,6 @@
# flomni/lamni parity fixes — 2026-07 session summary
-Branch `fixes/lamni_hw_commissioning`, commits `6c3d603..8a7a350`. Very brief
+Branch `fixes/lamni_hw_commissioning`, commits `6c3d603..c3877ec`. Very brief
by design — `git show ` on any commit below has the full reasoning and
diff; that's the source of truth if something needs revisiting.
@@ -28,13 +28,24 @@ diff; that's the source of truth if something needs revisiting.
`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`.
**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 `412 passed, 15 skipped`:
+**Automated** — should show `423 passed, 15 skipped`:
```
/opt/bec_deployments/production/bec_venv/bin/python -m pytest tests/ -q
```
@@ -57,3 +68,8 @@ beamline:**
- 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` (or the GUI's "0°
+ reference each sub-tomo" checkbox, type 1 only), 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, in the
+ console output / `tomography_scannumbers.txt`.
--
2.54.0
From 4f938fc54307a572083a9b760d6f982ca8600d4d Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 04:33:29 +0200
Subject: [PATCH 18/36] docs/document remaining flomni->lamni feature gaps for
triage
Follow-up sweep after this session's ports (timing overview, scan
interruption handling, tooltip filtering, constant shift, hook
display, zero-deg reference). Found and verified 9 items: a missing
per-projection/tomogram timing log + scilog_last_ptycho_scans()
command, no reset-tomo-params-on-account-change offer, no
collect_empty_frames() flat-field acquisition, no skip-if-already-in
optimization on lfzp_in(), no hard-stop button wired into lamni's
GUI, tomo_alignment_scan() with no clear lamni equivalent (lamni has
a different lookup-table correction scheme instead -- needs
discussion, not necessarily a gap), no OSA-collision-clearance
warning in lfzp_info(), a large confirmed-hardware-specific block
(sample-transfer/gripper/laser-tracker methods, not portable), and a
webpage-display opportunity that's symmetric on both setups rather
than a one-sided gap. No code changes -- findings only, for review.
Co-Authored-By: Claude Sonnet 5
---
.../FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md | 167 ++++++++++++++++++
1 file changed, 167 insertions(+)
create mode 100644 csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md
new file mode 100644
index 0000000..17c9112
--- /dev/null
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md
@@ -0,0 +1,167 @@
+# flomni → lamni feature gaps still open (2026-07 sweep)
+
+Follow-up to `FLOMNI_TO_LAMNI_COMPARISON.md` (flomni's `AI_docs/`) and this
+folder's `TOMO_QUEUE_PORT.md`/`TOMO_PARAMS_GUI_PORT.md` — those covered the
+tomo-queue backend, the GUI, the webpage generator, and the angle-distribution
+mapping, all since ported. This sweep looked for *further* flomni features
+lamni still lacks, after the same session also ported: the end-of-scan timing
+overview + `scan_repeat`/`scan_interlock` interruption handling, tomo-queue
+tooltip filtering, the constant-shift print, `at_each_angle_hook` in
+`tomo_parameters()`, and `zero_deg_reference_at_each_subtomo` for tomo_type 1.
+
+Every item below was independently confirmed by direct grep/read against
+both files, not just taken from the sweep — file:line citations are given so
+you can jump straight to the code. Ordered roughly by how promising each is
+to port, most-actionable first. This is a findings list for triage, not an
+implementation plan — nothing here has been started.
+
+## 1. Per-projection/tomogram timing log + `scilog_last_ptycho_scans()` — missing entirely
+
+- flomni: `flomni.py:2945` (`_TIMING_LOG_DIR`), `_log_projection_timing()`
+ (`2971`), `_log_tomogram_timing()` (`3019`), `_read_last_timing_records()`
+ (`3056`), `scilog_last_ptycho_scans()` (`3082`).
+- lamni: none of these exist anywhere in `lamni.py`.
+- flomni appends a JSON-lines record per completed projection and per
+ completed tomogram (FOV, exposure, stitch, corridor, duration, scan-number
+ range) to `~/data/raw/logs/timing_statistics/*.jsonl`, feeding a future
+ scan-time-prediction model, and exposes `scilog_last_ptycho_scans(n)` — a
+ user command that writes a scilog entry summarizing the last *n*
+ projections (scan numbers/FOV/exposure/duration) with a free-text comment
+ prompt.
+- **Portable**: the mechanism only needs `_TOMO_SCAN_PARAM_NAMES` (lamni
+ already has via `TomoQueueMixin`) and generic file I/O — nothing
+ flomni-hardware-specific.
+- Size: medium — new logging infra, but the shape can be lifted close to
+ verbatim.
+
+## 2. Tomo-parameter reset offer on experiment-account change — missing entirely
+
+- flomni: `Flomni.__init__` calls `_maybe_reset_params_on_account_change()`
+ (`flomni.py:1656`, defined `1662`) — compares the live BEC account against
+ a persisted `defaults_applied_for_account` global var and, on a genuine
+ account change, offers to reset tomo params via `_set_default_tomo_params()`
+ (`1694`).
+- lamni: `LamNI.__init__` has no such call, no `defaults_applied_for_account`
+ anywhere in `lamni.py`.
+- Prevents a new user silently inheriting the previous experiment's tuned
+ FOV/stitch/etc.
+- **Portable**: pure session-lifecycle/UX logic, no hardware dependency.
+- Size: small.
+
+## 3. `collect_empty_frames()` — flat-field acquisition at the start of a new tomo scan — missing entirely
+
+- flomni: `collect_empty_frames()` (`flomni.py:2419`), called unconditionally
+ from the "new scan" branch of `tomo_scan()` (`2533`, right after
+ `write_pdf_report()`/progress reset — only on a genuinely new scan, not a
+ resume).
+- lamni: zero occurrences of `collect_empty_frames`/"empty frame"/"flat
+ field" anywhere in `LamNI/*.py`.
+- Acquires 10 flat-field images at angle 0 with the sample shifted out of the
+ beam by half the FOV, logged with `subtomo_number=0` but deliberately kept
+ out of `tomo_reconstruct()`'s queue.
+- **Judgment call, not purely a software gap**: whether this is worth porting
+ depends on whether lamni's ptycho reconstruction pipeline actually
+ uses/needs flat fields the way flomni's does — worth confirming with Mirko
+ before implementing, not just a code-porting decision.
+- Size: small-medium (geometry needs adapting to `tomo_circfov`/lamni's
+ offset properties, but the shape is a direct port).
+
+## 4. `lfzp_in()` has no "skip the reset cycle if already in position" optimization
+
+- flomni: `ffzp_in(force_feedback_reset=False)` (`flomni_optics_mixin.py:98`)
+ + `_ffzp_is_in()` (`132`) — skips the expensive feedback-disable +
+ `feedback_enable_with_reset()` cycle when the FZP doesn't actually need to
+ move, avoiding an unnecessary interferometer re-zero/position shift during
+ repeated alignment scans.
+- lamni: `lfzp_in()` (`lamni_optics_mixin.py:237`) always runs the full
+ cycle unconditionally — no `_lfzp_is_in()`-equivalent guard.
+- **Portable**: identical underlying `dev.rtx.controller.feedback_disable()`/
+ `feedback_enable_with_reset()` API, used the same way by both setups.
+- Size: trivial-to-small — add an `_lfzp_is_in()` check + a
+ `force_feedback_reset` kwarg, direct port of the pattern.
+
+## 5. No hard-stop button wired into lamni's GUI
+
+- flomni: `flomnigui_show_cameras()` (`flomni/gui_tools.py:143-150`) wires up
+ a `ConsoleButtonsWidget` (`hard_stop_device_name="ftransy"`,
+ `extra_hard_stop_device_name="foptx"`) calling the shared, generic
+ `GalilController.hard_abort_and_restore_positioning_mode()` — added
+ specifically to replace an older blind stop-all-devices broadcast that
+ could crash the scan worker thread.
+- lamni: `LamNI/gui_tools.py` has no `ConsoleButtonsWidget`/hard-stop wiring
+ at all.
+- The original commit message explicitly notes this was "scoped to flomni
+ only this session (OMNY/LamNI wiring deferred)" — a known, flagged to-do,
+ not an oversight.
+- **Portable** (the widget + `GalilController` method are already
+ generic/shared), but needs a home in lamni's GUI first — lamni has no
+ gripper-camera dock to piggyback on the way flomni does, so this needs a
+ decision on where it lives and which lamni Galil device(s) it targets.
+- Size: small-medium. Safety-relevant — worth prioritizing despite the extra
+ design step.
+
+## 6. `tomo_alignment_scan()` — no lamni equivalent, but may be architecturally superseded
+
+- flomni: `tomo_alignment_scan()` (`flomni.py:2037`) — dedicated 5-point
+ (0/45/90/135/180°) alignment tomogram, writes scan numbers to
+ `~/data/raw/logs/ptychotomoalign_scannum.txt` for an external MATLAB tool
+ (`BEC_ptycho_align`), loaded back via `get_alignment_offset()`/
+ `read_alignment_offset()` (`1439`, `1337`).
+- lamni: no `tomo_alignment_scan`/`write_alignment_scan_numbers` anywhere.
+ Instead has a *different* mechanism: `read_additional_correction()`/
+ `read_additional_correction_2()` (`lamni_alignment_mixin.py:285,291`) — a
+ lookup-table correction (`corr_pos_x/y` vs `corr_angle` bins) from an
+ externally-produced file, consumed by `compute_additional_correction()`.
+- **Needs discussion, not a clear-cut gap**: is lamni's lookup-table scheme a
+ deliberate replacement for flomni's 5-point/MATLAB-fit approach, or would
+ lamni users also want a quick dedicated alignment-tomogram command? Don't
+ assume either way — ask before scoping.
+- Size: needs its own design discussion if pursued at all.
+
+## 7. No OSA-collision-clearance warning in `lfzp_info()`
+
+- flomni: `ffzp_info()` (`flomni_optics_mixin.py:256`) compares live `fosaz`
+ against the nominal `fosaz_in` position (10 µm tolerance) and warns if the
+ OSA is currently closer to a collision than its defined IN position.
+- lamni: `lfzp_info()` (`lamni_optics_mixin.py:291`) only prints
+ sample-to-FZP distance and a diameter/focal-distance/beam-size table — no
+ collision-clearance section.
+- **Needs lamni-specific input**: the concept (warn if a movable optic is
+ closer to a known collision point than nominal) is generic, but the exact
+ formula (`33 - foptz_val` in flomni) is specific to flomni's optics
+ geometry — porting correctly needs lamni's own collision-geometry
+ constants, not just a code copy.
+- Size: small once the geometry constants are known.
+
+## 8. Large block of flomni-only methods — confirmed hardware-specific, not portable
+
+`FlomniSampleTransferMixin` (`flomni.py`, ~46 methods): `ftransfer_*`,
+gripper open/close/move, `save_reference_image()`, `laser_tracker_show_all`/
+`on`/`off`, `laser_parameters_*`, `laser_tweak`,
+`umvr_fsamy_tracked`/`umv_fsamy_tracked` — all depend on flomni's automatic
+gripper/tray sample changer and its `rtx`-integrated laser tracker, neither of
+which lamni has (lamni samples are mounted manually — existing, known
+constraint). Listed only so you can confirm none of these were expected to
+have a lamni counterpart; not recommended for porting.
+
+## 9. `zero_deg_reference_at_each_subtomo` not yet on either status webpage — shared opportunity, not a flomni-ahead-of-lamni gap
+
+Neither `flomni_webpage_generator.py`'s `_CURRENT_PARAM_KEYS`
+(`flomni_webpage_generator.py:62-76`) nor `LamNI_webpage_generator.py`'s
+`TQ_PARAM_DISPLAY` (`LamNI_webpage_generator.py:71-86`) show
+`zero_deg_reference_at_each_subtomo` (or, for lamni's tomo_type 2/3, its
+`golden_projections_at_0_deg_for_damage_estimation` sibling). Since it's
+missing symmetrically on both, there's no flomni feature to "port" here —
+just a possible small addition to both webpages if useful, now that lamni's
+tomo_type 1 property actually exists.
+
+## Checked and already at parity (not gaps)
+
+`write_to_scilog`/`_scilog_write` failure tolerance, `@scan_repeat`
+retry-skip-on-definite-error, `frames_per_trigger` validation, `corridor_size`
+conditional passing, `estimated_finish_time` progress field, measurement-ID
+in the end-of-scan scilog summary, `at_each_angle_hook` name in that same
+summary, x-ray-eye alignment image HDF5 saving, tomo-queue command-jobs/
+move/reorder-floor semantics, and `tomo_params.py`'s `SETUP_PROFILES`
+capability flags (including `has_zero_deg_reference`, now symmetric) — all
+confirmed present and equivalent on both sides.
--
2.54.0
From e59a42f97868eb15e373b0872bca6c3a27a74ec3 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 10:50:08 +0200
Subject: [PATCH 19/36] feat/port timing-statistics log +
scilog_last_ptycho_scans() to lamni
Ports flomni's per-projection/tomogram JSONL timing log
(~/data/raw/logs/timing_statistics/*.jsonl, feeding a future scan-time
prediction model) and the scilog_last_ptycho_scans(n) user command to
lamni. Direct port adapted only for lamni's own param names (no
fovx/fovy/stitch_x/stitch_y/single_point_instead_of_fermat_scan --
uses tomo_circfov/lamni_stitch_x/y/lamni_piezo_range_x/y instead, no
single-point concept at all).
_log_projection_timing() is only called on a clean (non-error_caught)
completion of _tomo_scan_at_angle()'s retry loop, so an
AlarmBase/TimeoutError attempt (no reliable duration measurement)
doesn't pollute the log.
Item 1 of csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/
FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md.
Co-Authored-By: Claude Sonnet 5
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 227 ++++++++++++++++++
.../test_lamni_timing_log.py | 136 +++++++++++
2 files changed, 363 insertions(+)
create mode 100644 tests/tests_bec_ipython_client/test_lamni_timing_log.py
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 6e951b7..93a4e05 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -1,5 +1,6 @@
import builtins
import datetime
+import json
import os
import subprocess
import time
@@ -714,6 +715,216 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
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")
with open(tomo_scan_numbers_file, "a+") as out_file:
@@ -1034,8 +1245,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":
@@ -1049,6 +1262,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):
@@ -1267,6 +1493,7 @@ 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()
+ self._log_tomogram_timing()
self.OMNYTools.printgreenbold("Tomoscan finished")
idle_s = self.progress.get("accumulated_idle_time", 0.0)
diff --git a/tests/tests_bec_ipython_client/test_lamni_timing_log.py b/tests/tests_bec_ipython_client/test_lamni_timing_log.py
new file mode 100644
index 0000000..e3a0da1
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_lamni_timing_log.py
@@ -0,0 +1,136 @@
+"""Tests for lamni's timing-statistics log + scilog_last_ptycho_scans(),
+ported from flomni (see csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/
+FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md, item 1).
+
+Uses a bare LamNI instance (bypassing __init__'s heavy side effects), same
+pattern as test_lamni_tomo_angles.py/test_fermat_position_warning.py.
+"""
+
+import json
+
+import pytest
+
+from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy
+
+
+class FakeClient:
+ """Minimal in-memory stand-in for BEC's global-var store."""
+
+ def __init__(self):
+ self._vars = {}
+
+ def get_global_var(self, key):
+ return self._vars.get(key)
+
+ def set_global_var(self, key, value):
+ self._vars[key] = value
+
+
+def make_lamni(tmp_path):
+ obj = object.__new__(LamNI)
+ obj.client = FakeClient()
+ obj._progress_proxy = _ProgressProxy(obj.client)
+ obj.tomo_id = -1
+ obj._TIMING_LOG_DIR = str(tmp_path)
+ return obj
+
+
+def _read_jsonl(path):
+ with open(path) as f:
+ return [json.loads(line) for line in f if line.strip()]
+
+
+def test_append_and_read_timing_records_round_trip(tmp_path):
+ lamni = make_lamni(tmp_path)
+ lamni._append_timing_record(lamni._PROJECTION_TIMING_LOG, {"a": 1})
+ lamni._append_timing_record(lamni._PROJECTION_TIMING_LOG, {"a": 2})
+ lamni._append_timing_record(lamni._PROJECTION_TIMING_LOG, {"a": 3})
+
+ records = lamni._read_last_timing_records(2)
+
+ assert [r["a"] for r in records] == [2, 3]
+
+
+def test_read_timing_records_missing_file_returns_empty(tmp_path, capsys):
+ lamni = make_lamni(tmp_path)
+ records = lamni._read_last_timing_records(2)
+ assert records == []
+ assert "timing log not found" in capsys.readouterr().out
+
+
+def test_append_timing_record_tolerates_write_failure(tmp_path, monkeypatch):
+ """Must never raise -- a timing-log write failure must not abort or
+ interfere with an in-progress measurement."""
+ lamni = make_lamni(tmp_path)
+ lamni._TIMING_LOG_DIR = "/nonexistent-root-that-cannot-be-created/xyz"
+ lamni._append_timing_record(lamni._PROJECTION_TIMING_LOG, {"a": 1}) # must not raise
+
+
+def test_log_projection_timing_uses_lamni_param_names(tmp_path):
+ lamni = make_lamni(tmp_path)
+ lamni.tomo_type = 1
+ lamni.tomo_shellstep = 1.0
+ lamni.tomo_countingtime = 0.1
+ lamni.frames_per_trigger = 1
+ lamni.lamni_stitch_x = 1
+ lamni.lamni_stitch_y = 0
+ lamni.tomo_stitch_overlap = 0.2
+ lamni.corridor_size = -1
+ lamni.client.set_global_var("lamni_piezo_range_x", 20.0)
+ lamni.client.set_global_var("lamni_piezo_range_y", 20.0)
+ lamni.tomo_circfov = 0.0
+
+ lamni._log_projection_timing(
+ angle=12.5, subtomo_number=1, duration_s=3.2, start_scan_number=10, end_scan_number=12
+ )
+
+ records = _read_jsonl(f"{tmp_path}/{lamni._PROJECTION_TIMING_LOG}")
+ assert len(records) == 1
+ record = records[0]
+ assert record["setup"] == "lamni"
+ assert record["angle"] == 12.5
+ assert record["n_scans"] == 2
+ assert record["lamni_piezo_range_x"] == 20.0
+ assert record["lamni_piezo_range_y"] == 20.0
+ assert record["lamni_stitch_x"] == 1
+ # lamni has no fovx/fovy/single_point_instead_of_fermat_scan concept
+ assert "fovx" not in record
+ assert "single_point_instead_of_fermat_scan" not in record
+
+
+def test_log_tomogram_timing_snapshots_all_param_names(tmp_path):
+ lamni = make_lamni(tmp_path)
+ lamni.progress["tomo_start_time"] = None
+ lamni.progress["accumulated_idle_time"] = 0.0
+ lamni.progress["total_projections"] = 144
+ lamni.progress["tomo_type"] = "Equally spaced sub-tomograms"
+
+ lamni._log_tomogram_timing()
+
+ records = _read_jsonl(f"{tmp_path}/{lamni._TOMOGRAM_TIMING_LOG}")
+ assert len(records) == 1
+ record = records[0]
+ assert record["setup"] == "lamni"
+ assert record["total_projections"] == 144
+ assert set(record["params"]) == set(LamNI._TOMO_SCAN_PARAM_NAMES)
+
+
+@pytest.mark.parametrize("bad_value", [-1, 5, 1.5, True, "2"])
+def test_scilog_last_ptycho_scans_rejects_invalid_number_of_scans(tmp_path, bad_value, capsys):
+ lamni = make_lamni(tmp_path)
+ lamni.scilog_last_ptycho_scans(bad_value)
+ out = capsys.readouterr().out
+ assert "must be" in out
+
+
+def test_scilog_last_ptycho_scans_comment_only(tmp_path, monkeypatch):
+ lamni = make_lamni(tmp_path)
+ lamni.sample_name = "test"
+ sent = {}
+ lamni.write_to_scilog = lambda content, tags: sent.update(content=content, tags=tags)
+ monkeypatch.setattr("builtins.input", lambda *_: "test comment")
+
+ lamni.scilog_last_ptycho_scans(0)
+
+ assert sent["content"] == "test comment"
+ assert sent["tags"] == ["tomoscan"]
--
2.54.0
From b4c262d41bb64cee2b89f5e0eb43bc94a0790866 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 11:37:37 +0200
Subject: [PATCH 20/36] feat/offer tomo-param reset on lamni experiment-account
change
Ports flomni's _maybe_reset_params_on_account_change()/
_set_default_tomo_params() to lamni: at LamNI.__init__, if the active
BEC account differs from the one defaults were last applied for
(persisted via defaults_applied_for_account), interactively offer to
reset tomo params to defaults -- preventing a new experiment silently
inheriting the previous one's tuned FOV/stitch/etc. Same account is a
silent no-op, so restarting a client mid-experiment never disturbs
tuned parameters.
_set_default_tomo_params() is a direct port adapted to lamni's own
param names (tomo_circfov/lamni_stitch_x/y/lamni_piezo_range_x/y
instead of fovx/fovy/stitch_x/y, no tomo_angle_range/
single_point_random_shift_max -- lamni has neither concept). Per-
sample alignment state is deliberately not touched, matching flomni.
Item 2 of csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/
FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md.
Co-Authored-By: Claude Sonnet 5
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 67 +++++++++
.../test_lamni_account_change_reset.py | 127 ++++++++++++++++++
2 files changed, 194 insertions(+)
create mode 100644 tests/tests_bec_ipython_client/test_lamni_account_change_reset.py
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 93a4e05..e495ec3 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -238,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
# ------------------------------------------------------------------
diff --git a/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py b/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py
new file mode 100644
index 0000000..d879831
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py
@@ -0,0 +1,127 @@
+"""Tests for lamni's tomo-parameter reset offer on experiment-account
+change, ported from flomni (see csaxs_bec/bec_ipython_client/plugins/LamNI/
+AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md, item 2).
+
+Uses a bare LamNI instance (bypassing __init__'s heavy side effects), same
+pattern as test_lamni_tomo_queue.py/test_tomo_queue_reacquire.py.
+"""
+
+import builtins
+import types
+
+import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module
+from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy
+
+
+class FakeClient:
+ """Minimal in-memory stand-in for BEC's global-var store."""
+
+ def __init__(self):
+ self._vars = {}
+
+ def get_global_var(self, key):
+ return self._vars.get(key)
+
+ def set_global_var(self, key, value):
+ self._vars[key] = value
+
+
+class FakeOMNYTools:
+ def __init__(self, answer=True):
+ self.answer = answer
+ self.prompts = []
+
+ def yesno(self, prompt, default):
+ self.prompts.append(prompt)
+ return self.answer
+
+
+class _FakeRtx:
+ user_parameter = {"large_range_scan": True}
+
+
+class _FakeDev:
+ rtx = _FakeRtx()
+
+ def __contains__(self, name):
+ return hasattr(self, name)
+
+ def __getitem__(self, name):
+ return getattr(self, name)
+
+
+def make_lamni(answer=True):
+ lamni_module.dev = _FakeDev()
+ obj = object.__new__(LamNI)
+ obj.client = FakeClient()
+ obj._progress_proxy = _ProgressProxy(obj.client)
+ obj.OMNYTools = FakeOMNYTools(answer=answer)
+ obj.reconstructor = types.SimpleNamespace(folder_name=None)
+ return obj
+
+
+def test_skips_silently_when_no_active_account():
+ lamni = make_lamni()
+ 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():
+ lamni = make_lamni()
+ lamni.client.set_global_var("defaults_applied_for_account", "e12345")
+ 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():
+ 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
+ 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():
+ lamni = make_lamni(answer=False)
+ lamni.client.set_global_var("defaults_applied_for_account", "e11111")
+ lamni.tomo_shellstep = 99.0
+ 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
--
2.54.0
From 7863b8fd371bcd4e1f8cc24720bbd1b284d2b63b Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 12:40:45 +0200
Subject: [PATCH 21/36] feat/skip lfzp_in/losa_in/losa_out moves when already
in position
Ports flomni's ffzp_in()/fosa_in() "skip the move (and, for the FZP,
the expensive feedback-disable + reset-enable cycle) if already there"
optimization to lamni's lfzp_in()/losa_in(). Also adds the same
skip-if-already-there check to losa_out(), which has no flomni
equivalent to mirror (fosa_out() always moves unconditionally there)
-- added as a lamni-side enhancement per discussion.
Checked every existing caller of lfzp_in()/loptics_in() before making
this change, since a skip changes behavior for callers that used to
get an unconditional reset. Found one real risk:
x_ray_eye_align.py's "Step 0: FZP centre" (the start of a fresh
alignment run) had its manual feedback disable/enable calls already
commented out, meaning it relied entirely on lfzp_in()'s old
unconditional reset to establish a correct interferometer zero.
flomni's equivalent call site doesn't have this problem because
flomni's alignment flow does its own explicit
feedback_enable_with_reset() independently beforehand -- lamni's
doesn't. Fixed by passing force_feedback_reset=True at that one call
site so it keeps its current always-reset behavior; no other lamni
caller needed this.
Item 4 of csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/
FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md.
Co-Authored-By: Claude Sonnet 5
---
.../plugins/LamNI/lamni_optics_mixin.py | 103 +++++++++++--
.../plugins/LamNI/x_ray_eye_align.py | 8 +-
.../test_lamni_optics_skip_if_in_position.py | 143 ++++++++++++++++++
3 files changed, 244 insertions(+), 10 deletions(-)
create mode 100644 tests/tests_bec_ipython_client/test_lamni_optics_skip_if_in_position.py
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py
index 2c4b036..19196a3 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py
@@ -1,6 +1,7 @@
import builtins
import time
+import numpy as np
from rich import box
from rich.console import Console
from rich.table import Table
@@ -232,21 +233,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 +331,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:
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py
index 2b12b35..ebaffc4 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py
@@ -255,7 +255,13 @@ class XrayEyeAlign:
# --- Step 0: FZP centre ------------------------------------------
#self._disable_rt_feedback()
- self.lamni.lfzp_in()
+ # force_feedback_reset=True: this is the start of a fresh alignment
+ # run, so the interferometer reference must always be freshly
+ # re-zeroed here, regardless of whether the FZP happens to already
+ # be in position (e.g. left over from a previous run/tomogram) --
+ # lfzp_in()'s default skip-if-already-in-position optimization must
+ # not apply at this specific call site.
+ self.lamni.lfzp_in(force_feedback_reset=True)
#self._enable_rt_feedback()
self.update_frame(keep_shutter_open)
diff --git a/tests/tests_bec_ipython_client/test_lamni_optics_skip_if_in_position.py b/tests/tests_bec_ipython_client/test_lamni_optics_skip_if_in_position.py
new file mode 100644
index 0000000..ac9f9e7
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_lamni_optics_skip_if_in_position.py
@@ -0,0 +1,143 @@
+"""Tests for lamni's "skip if already in position" optics optimization,
+ported from flomni's ffzp_in()/fosa_in() (see csaxs_bec/bec_ipython_client/
+plugins/LamNI/AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md, item 4).
+
+Methods under test (lamni_optics_mixin.py) reference module-level `dev`/
+`umv` globals (resolved from this module's own namespace, not an instance
+attribute), so tests monkeypatch those module globals directly.
+"""
+
+import types
+
+import pytest
+
+import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_optics_mixin as optics_mixin_module
+from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_optics_mixin import LamNIOpticsMixin
+
+
+class FakeAxis:
+ def __init__(self, value, in_pos=None, out_pos=None):
+ self.value = value
+ self.readback = types.SimpleNamespace(get=lambda: self.value)
+ self.user_parameter = {}
+ if in_pos is not None:
+ self.user_parameter["in"] = in_pos
+ if out_pos is not None:
+ self.user_parameter["out"] = out_pos
+
+
+class FakeController:
+ def __init__(self):
+ self.calls = []
+
+ def feedback_disable(self):
+ self.calls.append("disable")
+
+ def feedback_enable_with_reset(self):
+ self.calls.append("enable_with_reset")
+
+
+class FakeDev:
+ def __init__(self, rtx_enabled=True):
+ self.loptx = FakeAxis(-0.5, in_pos=-0.5, out_pos=-1.0)
+ self.lopty = FakeAxis(3.3, in_pos=3.3, out_pos=3.0)
+ self.losax = FakeAxis(-1.0, in_pos=-1.0, out_pos=0.0)
+ self.losay = FakeAxis(-0.2, in_pos=-0.2, out_pos=1.0)
+ self.losaz = FakeAxis(1.0, in_pos=1.0, out_pos=-1.0)
+ self.rtx = types.SimpleNamespace(enabled=rtx_enabled, controller=FakeController())
+
+ def __contains__(self, name):
+ return hasattr(self, name)
+
+ def __getitem__(self, name):
+ return getattr(self, name)
+
+
+@pytest.fixture
+def fake_dev(monkeypatch):
+ dev = FakeDev()
+ monkeypatch.setattr(optics_mixin_module, "dev", dev)
+ return dev
+
+
+@pytest.fixture
+def umv_calls(monkeypatch):
+ calls = []
+ monkeypatch.setattr(optics_mixin_module, "umv", lambda *args: calls.append(args))
+ return calls
+
+
+def make_optics():
+ return LamNIOpticsMixin()
+
+
+def test_lfzp_in_skips_move_when_already_in_position(fake_dev, umv_calls, capsys):
+ optics = make_optics()
+
+ optics.lfzp_in()
+
+ assert umv_calls == []
+ assert "already at the in position" in capsys.readouterr().out
+ assert fake_dev.rtx.controller.calls == []
+
+
+def test_lfzp_in_moves_and_resets_feedback_when_out_of_position(fake_dev, umv_calls):
+ fake_dev.loptx.value = -0.9 # not at "in" (-0.5)
+ optics = make_optics()
+
+ optics.lfzp_in()
+
+ assert umv_calls == [(fake_dev.loptx, -0.5, fake_dev.lopty, 3.3)]
+ assert fake_dev.rtx.controller.calls == ["disable", "enable_with_reset"]
+
+
+def test_lfzp_in_force_feedback_reset_runs_cycle_even_if_already_in(fake_dev, umv_calls):
+ optics = make_optics()
+
+ optics.lfzp_in(force_feedback_reset=True)
+
+ assert umv_calls == [] # still no physical move needed
+ assert fake_dev.rtx.controller.calls == ["disable", "enable_with_reset"]
+
+
+def test_losa_in_skips_move_when_already_in_position(fake_dev, umv_calls, capsys):
+ optics = make_optics()
+
+ optics.losa_in()
+
+ assert umv_calls == []
+ assert "already at the IN position" in capsys.readouterr().out
+
+
+def test_losa_in_moves_when_out_of_position(fake_dev, umv_calls):
+ fake_dev.losaz.value = 5.0 # not at "in" (1.0)
+ optics = make_optics()
+
+ optics.losa_in()
+
+ assert umv_calls == [
+ (fake_dev.losax, -1.0, fake_dev.losay, -0.2),
+ (fake_dev.losaz, 1.0),
+ ]
+
+
+def test_losa_out_skips_move_when_already_out(fake_dev, umv_calls, capsys):
+ fake_dev.losay.value = 1.0 # matches "out"
+ fake_dev.losaz.value = -1.0 # matches "out"
+ optics = make_optics()
+
+ optics.losa_out()
+
+ assert umv_calls == []
+ assert "already at the OUT position" in capsys.readouterr().out
+
+
+def test_losa_out_moves_when_not_out(fake_dev, umv_calls):
+ optics = make_optics() # currently at "in" positions, not "out"
+
+ optics.losa_out()
+
+ assert umv_calls == [
+ (fake_dev.losaz, -1.0),
+ (fake_dev.losay, 1.0),
+ ]
--
2.54.0
From db1892974292e1aba20e5d8a9035bacb7e566167 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 12:58:24 +0200
Subject: [PATCH 22/36] feat/add OSA collision-clearance warning to lamni
lfzp_info()
Ports flomni's ffzp_info() OSA collision-clearance section (compares
live losaz against nominal "in" position and a collision-boundary
constant, warns if closer to collision than nominal or reports
clearance at IN) -- same formula shape and sign convention as flomni
(motion directions confirmed identical between the two setups).
lamni's collision-boundary constant has never been measured, so it's
read from loptz's device config (userParameter.collision_offset) via
a new _get_user_param_optional() helper -- unlike the existing
_get_user_param_safe(), it returns None instead of raising when the
parameter is undefined (as it currently is for every lamni device
config, including the simulated one), since absence here means
"not yet commissioned" rather than a config error. lfzp_info() prints
a clear warning and skips the numeric estimate entirely until someone
adds the real measured value to the config -- deliberately not a
CLI-settable/global-var parameter, since this is deployment-level
hardware calibration.
Item 7 of csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/
FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md.
Co-Authored-By: Claude Sonnet 5
---
.../plugins/LamNI/lamni_optics_mixin.py | 60 +++++++
.../test_lamni_osa_collision_info.py | 146 ++++++++++++++++++
2 files changed, 206 insertions(+)
create mode 100644 tests/tests_bec_ipython_client/test_lamni_osa_collision_info.py
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py
index 19196a3..90c9806 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py
@@ -205,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()
@@ -415,3 +425,53 @@ class LamNIOpticsMixin:
print(
"The numbers presented here are for a sample in the plane of the lamni sample holder.\n"
)
+
+ collision_offset = self._get_user_param_optional("loptz", "collision_offset")
+
+ print("\nOSA Information:")
+ if collision_offset is None:
+ print(
+ " \033[93mWarning: OSA collision-clearance calibration has not been "
+ "commissioned for LamNI yet -- set 'collision_offset' under loptz's "
+ "userParameter in the device config once measured (mirrors flomni's "
+ "ffzp_info(), whose equivalent constant is already commissioned). "
+ "Skipping the collision-clearance estimate below.\033[0m"
+ )
+ else:
+ losaz_val = dev.losaz.readback.get()
+ losaz_in = self._get_user_param_safe("losaz", "in")
+
+ tol_osa = 0.010 # mm, 10 microns
+
+ remaining_current = -losaz_val + (collision_offset - loptz_val)
+ remaining_at_in = -losaz_in + (collision_offset - loptz_val)
+
+ print(f" Current losaz {losaz_val:.1f}")
+ print(
+ " The OSA will collide with a normal sample holder at losaz "
+ f"\033[1m{(collision_offset - losaz_val):.1f}\033[0m"
+ )
+ print(f" Remaining space (right now): \033[1m{remaining_current:.1f}\033[0m")
+
+ diff = losaz_val - losaz_in # >0: OSA is currently more "in" than nominal -> worse
+
+ if abs(diff) > tol_osa:
+ if diff > 0:
+ # current position is already more collision-prone than the defined IN position
+ print(
+ f" \033[91mWarning: current losaz ({losaz_val:.4f}) is "
+ f"{diff*1000:.1f} um further IN than the defined IN position "
+ f"({losaz_in:.4f}) -- closer to collision than normal "
+ "operation.\033[0m"
+ )
+ else:
+ # OSA is out (parked further away than its nominal in-position) -- not urgent
+ # now, but flag what clearance will be once it's moved to IN
+ print(
+ f" Note: OSA is {(-diff)*1000:.1f} um away from its IN position "
+ "(likely parked OUT)."
+ )
+ print(
+ " Remaining space if OSA is moved to its IN position: "
+ f"\033[1m{remaining_at_in:.1f}\033[0m"
+ )
diff --git a/tests/tests_bec_ipython_client/test_lamni_osa_collision_info.py b/tests/tests_bec_ipython_client/test_lamni_osa_collision_info.py
new file mode 100644
index 0000000..1a44121
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_lamni_osa_collision_info.py
@@ -0,0 +1,146 @@
+"""Tests for lamni's OSA collision-clearance section in lfzp_info(), ported
+from flomni's ffzp_info() (see csaxs_bec/bec_ipython_client/plugins/LamNI/
+AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md, item 7).
+
+Methods under test (lamni_optics_mixin.py) reference module-level `dev`
+(resolved from this module's own namespace, not an instance attribute), so
+tests monkeypatch that module global directly.
+"""
+
+import types
+
+import pytest
+
+import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_optics_mixin as optics_mixin_module
+from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_optics_mixin import LamNIOpticsMixin
+
+
+class FakeAxis:
+ def __init__(self, value, name=None, user_parameter=None):
+ self.value = value
+ self._name = name
+ self.readback = types.SimpleNamespace(get=lambda: self.value)
+ self.user_parameter = user_parameter or {}
+
+ def read(self):
+ return {self._name: {"value": self.value}}
+
+
+class FakeDev:
+ def __init__(self, collision_offset=None):
+ self.mokev = FakeAxis(7.0)
+ self.loptz = FakeAxis(
+ 80.0,
+ name="loptz",
+ user_parameter=(
+ {"collision_offset": collision_offset} if collision_offset is not None else {}
+ ),
+ )
+ self.losaz = FakeAxis(0.0, user_parameter={"in": 0.0})
+
+ def __contains__(self, name):
+ return hasattr(self, name)
+
+ def __getitem__(self, name):
+ return getattr(self, name)
+
+
+@pytest.fixture
+def fake_dev(monkeypatch):
+ def _make(collision_offset=None):
+ dev = FakeDev(collision_offset=collision_offset)
+ monkeypatch.setattr(optics_mixin_module, "dev", dev)
+ return dev
+
+ return _make
+
+
+def make_optics():
+ return LamNIOpticsMixin()
+
+
+def test_get_user_param_optional_missing_param_returns_none(fake_dev):
+ fake_dev()
+ optics = make_optics()
+ assert optics._get_user_param_optional("loptz", "collision_offset") is None
+
+
+def test_get_user_param_optional_missing_user_parameter_dict_returns_none(monkeypatch):
+ class NoParamAxis:
+ user_parameter = None
+
+ class Dev:
+ loptz = NoParamAxis()
+
+ def __getitem__(self, name):
+ return getattr(self, name)
+
+ monkeypatch.setattr(optics_mixin_module, "dev", Dev())
+ optics = make_optics()
+ assert optics._get_user_param_optional("loptz", "collision_offset") is None
+
+
+def test_get_user_param_optional_returns_value_when_present(fake_dev):
+ fake_dev(collision_offset=33.0)
+ optics = make_optics()
+ assert optics._get_user_param_optional("loptz", "collision_offset") == 33.0
+
+
+def test_get_user_param_safe_still_raises_when_missing(fake_dev):
+ """Regression guard: the existing raising helper must stay untouched --
+ only the new _get_user_param_optional() should tolerate an absent param."""
+ fake_dev()
+ optics = make_optics()
+ with pytest.raises(ValueError):
+ optics._get_user_param_safe("loptz", "collision_offset")
+
+
+def test_lfzp_info_warns_and_skips_numbers_when_uncommissioned(fake_dev, capsys):
+ fake_dev(collision_offset=None)
+ optics = make_optics()
+
+ optics.lfzp_info(mokev_val=7.0)
+
+ out = capsys.readouterr().out
+ assert "has not been commissioned" in out
+ assert "Remaining space" not in out
+ assert "collide with a normal sample holder" not in out
+
+
+def test_lfzp_info_prints_collision_estimate_when_commissioned(fake_dev, capsys):
+ dev = fake_dev(collision_offset=113.0)
+ dev.losaz.value = 0.0
+ dev.losaz.user_parameter["in"] = 0.0
+
+ optics = make_optics()
+ optics.lfzp_info(mokev_val=7.0)
+
+ out = capsys.readouterr().out
+ assert "has not been commissioned" not in out
+ assert "Current losaz 0.0" in out
+ assert "Remaining space (right now)" in out
+
+
+def test_lfzp_info_warns_when_closer_to_collision_than_nominal(fake_dev, capsys):
+ dev = fake_dev(collision_offset=113.0)
+ dev.losaz.user_parameter["in"] = 0.0
+ dev.losaz.value = 0.02 # further IN than nominal "in" (0.0), beyond the 0.010 tolerance
+
+ optics = make_optics()
+ optics.lfzp_info(mokev_val=7.0)
+
+ out = capsys.readouterr().out
+ assert "further IN than the defined IN position" in out
+
+
+def test_lfzp_info_notes_parked_out(fake_dev, capsys):
+ dev = fake_dev(collision_offset=113.0)
+ dev.losaz.user_parameter["in"] = 0.0
+ dev.losaz.value = -0.5 # parked further OUT than nominal "in"
+
+ optics = make_optics()
+ optics.lfzp_info(mokev_val=7.0)
+
+ out = capsys.readouterr().out
+ assert "likely parked OUT" in out
+ assert "Remaining space if OSA is moved to its IN position" in out
--
2.54.0
From 12b2538333ec6fee0676c90d35054180a63cb7c6 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 13:22:01 +0200
Subject: [PATCH 23/36] feat/add lamni.tomo_alignment_scan() -- dedicated
fine-alignment scan
Replaces the awkward documented workaround (configure a full tomo_type
1 setup with 96 projections, then launch just sub_tomo_scan(1, 0)) with
a dedicated command, ported from flomni's tomo_alignment_scan(): adjust
tomo_parameters() (FOV/step/counting time), then call
lamni.tomo_alignment_scan() directly -- no tomo_type/sub-tomogram
bookkeeping involved, matching flomni's clean two-step workflow.
Runs 12 points evenly spaced across the full 360 degrees (lamni has no
180-degree symmetry the way flomni does, so unlike flomni's 5-point/
180-degree scan, this covers the full circle -- point count matches
what the old workaround's docs defaulted to, endpoint=False since
360==0 degrees). Aborts if x-ray-eye alignment hasn't been done yet
(tomo_fit_xray_eye unset), mirroring flomni's equivalent guard.
write_alignment_scan_numbers() writes the same 4-line scan-number/
angle/offset log flomni's version does, to
~/data/raw/logs/ptychotomoalign_scannum.txt for SPEC_ptycho_align.m,
also printed at the console (flomni's own console-print equivalent is
dead/commented-out code; lamni's actually prints).
Scope note: flomni's version conditionally skips its eye-out/optics-in
transition when already in measurement condition with feedback
running, to avoid an unneeded interferometer reset -- lamni has no
equivalent helpers for that check, so this calls leye_out()
unconditionally instead. Left as a possible follow-up, not in scope
here.
Item 6 of csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/
FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md. Documented in
docs/user/ptychography/lamni.md's "Fine alignment" section.
Co-Authored-By: Claude Sonnet 5
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 94 ++++++++++++
docs/user/ptychography/lamni.md | 8 +-
.../test_lamni_tomo_alignment_scan.py | 135 ++++++++++++++++++
3 files changed, 232 insertions(+), 5 deletions(-)
create mode 100644 tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index e495ec3..24214bb 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -1068,6 +1068,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 SPEC_ptycho_align.m.
+
+ 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 SPEC_ptycho_align.m,
+ 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 SPEC_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")
diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md
index b8df89b..673e01d 100644
--- a/docs/user/ptychography/lamni.md
+++ b/docs/user/ptychography/lamni.md
@@ -62,11 +62,9 @@ or with the shutter left open: `lamni.xrayeye_update_frame(keep_shutter_open=Tru
The sample fine alignment can be obtained using ptychography. For this a short laminogram has to be recorded.
-* `lamni.tomo_parameters()` adjust the parameters for a coarse scan: A large step size and large FOV. Especially select **FOV offset = 0** and **number of projections = 96** (only one sub-laminogram will be recorded).
-* `lamni.sub_tomo_scan(1,0)` record one sub-laminogram
-* use the corresponding scan numbers in `SPEC_ptycho_align.m`
-* Record a last projection for all scans to reconstruct `lamni.tomo_scan_projection(0)` and wait for the reconstructions to be complete
-* Run `SPEC_ptycho_align.m` (in Matlab, **force ptycho=1**, and **correct scan numbers**)
+* `lamni.tomo_parameters()` adjust the ptychographic scan parameters for the alignment scan (FOV/step size/counting time) — `tomo_type` and number of projections are ignored, since the alignment scan always runs its own fixed 12 points spread evenly across the full 360 degrees, independent of the main tomogram's settings.
+* `lamni.tomo_alignment_scan()` perform the alignment scan. Requires x-ray-eye alignment to have already been done — it will abort with a message otherwise. Scan numbers, angles and the existing x-ray-eye-fit offset at each angle are written to `~/data/raw/logs/ptychotomoalign_scannum.txt` and also printed at the end.
+* Run `SPEC_ptycho_align.m` (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()`
diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py b/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py
new file mode 100644
index 0000000..2fff068
--- /dev/null
+++ b/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py
@@ -0,0 +1,135 @@
+"""Tests for lamni.tomo_alignment_scan()/write_alignment_scan_numbers(),
+ported from flomni (see csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/
+FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md, item 6).
+
+Uses a bare LamNI instance (bypassing __init__'s heavy side effects), same
+pattern as test_lamni_tomo_angles.py's make_lamni_for_tomo_scan().
+"""
+
+import builtins
+import types
+
+import numpy as np
+import pytest
+
+import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module
+from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy
+
+
+class FakeClient:
+ def __init__(self):
+ self._vars = {}
+
+ def get_global_var(self, key):
+ return self._vars.get(key)
+
+ def set_global_var(self, key, value):
+ self._vars[key] = value
+
+
+class FakeQueue:
+ def __init__(self, start=100):
+ self.next_scan_number = start
+
+ def request_queue_reset(self):
+ pass
+
+
+class FakeAxis:
+ value = 0
+
+
+class FakeDev:
+ def __init__(self):
+ self.lsamrot = FakeAxis()
+
+ def __contains__(self, name):
+ return hasattr(self, name)
+
+ def __getitem__(self, name):
+ return getattr(self, name)
+
+
+def make_lamni(monkeypatch, xray_eye_fit=None):
+ obj = object.__new__(LamNI)
+ obj.client = FakeClient()
+ obj._progress_proxy = _ProgressProxy(obj.client)
+ obj.tomo_id = -1
+ obj.sample_name = "test"
+ obj.OMNYTools = types.SimpleNamespace(printgreenbold=lambda msg: None)
+ obj._scilog_calls = []
+ obj.write_to_scilog = lambda content, tags: obj._scilog_calls.append((content, tags))
+ obj.leye_out = lambda: None
+
+ if xray_eye_fit is not None:
+ obj.client.set_global_var("tomo_fit_xray_eye", xray_eye_fit)
+
+ fake_bec = types.SimpleNamespace(queue=FakeQueue())
+ fake_dev = FakeDev()
+ monkeypatch.setitem(builtins.__dict__, "bec", fake_bec)
+ monkeypatch.setitem(builtins.__dict__, "dev", fake_dev)
+ monkeypatch.setattr(lamni_module, "umv", lambda *a: None, raising=False)
+
+ return obj
+
+
+def test_tomo_alignment_scan_aborts_without_xray_eye_fit(monkeypatch):
+ lamni = make_lamni(monkeypatch, xray_eye_fit=None)
+ calls = []
+ lamni.tomo_scan_projection = lambda angle: calls.append(angle)
+
+ lamni.tomo_alignment_scan()
+
+ assert calls == []
+ assert lamni._scilog_calls == []
+
+
+def test_tomo_alignment_scan_runs_12_projections_across_360(monkeypatch):
+ lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]])
+ calls = []
+
+ def _fake_projection(angle):
+ calls.append(angle)
+ builtins.__dict__["bec"].queue.next_scan_number += 1
+
+ lamni.tomo_scan_projection = _fake_projection
+
+ lamni.tomo_alignment_scan()
+
+ expected_angles = list(np.linspace(0, 360, num=12, endpoint=False))
+ assert calls == expected_angles
+ assert len(lamni._scilog_calls) == 1
+ content, tags = lamni._scilog_calls[0]
+ assert tags == ["alignmentscan"]
+ assert "Number of alignment scans: 12" in content, content
+
+
+def test_tomo_alignment_scan_rotates_back_to_zero(monkeypatch):
+ lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]])
+ lamni.tomo_scan_projection = lambda angle: None
+ umv_calls = []
+ monkeypatch.setattr(lamni_module, "umv", lambda *a: umv_calls.append(a), raising=False)
+
+ lamni.tomo_alignment_scan()
+
+ assert umv_calls[-1][1] == 0 # umv(dev.lsamrot, 0)
+
+
+def test_write_alignment_scan_numbers_format(tmp_path, monkeypatch):
+ lamni = make_lamni(monkeypatch, xray_eye_fit=[[1.0, 0.0, 0.5], [0.0, 0.0, 0.0]])
+ monkeypatch.setattr(
+ "os.path.expanduser",
+ lambda p: p.replace("~", str(tmp_path)) if p.startswith("~") else p,
+ )
+
+ lamni.write_alignment_scan_numbers(100)
+
+ log_file = tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt"
+ lines = log_file.read_text().splitlines()
+ assert len(lines) == 4
+ scans = [int(s) for s in lines[0].split()]
+ angles = [float(a) for a in lines[1].split()]
+ assert scans == list(range(100, 112))
+ assert angles == list(np.linspace(0, 360, num=12, endpoint=False))
+ assert len(lines[2].split()) == 12
+ assert len(lines[3].split()) == 12
--
2.54.0
From e3c979a375afc8956d3263afc72100e574b867eb Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 13:24:37 +0200
Subject: [PATCH 24/36] fix/correct matlab tool name to BEC_ptycho_align in
tomo_alignment_scan
The tool is called BEC_ptycho_align now, not SPEC_ptycho_align.m as
the previous docs/comments said -- fixes the references introduced in
the previous commit (12b2538) in lamni.py's docstrings/print message
and the user docs.
Co-Authored-By: Claude Sonnet 5
---
csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py | 6 +++---
docs/user/ptychography/lamni.md | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 24214bb..743c3c1 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -1069,7 +1069,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
)
def write_alignment_scan_numbers(self, first_scan: int) -> None:
- """Write the scan-number/angle/offset log consumed by SPEC_ptycho_align.m.
+ """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
@@ -1105,7 +1105,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
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 SPEC_ptycho_align.m,
+ ~/data/raw/logs/ptychotomoalign_scannum.txt for BEC_ptycho_align,
prints them, and creates a scilog entry summarising the alignment
scan numbers.
"""
@@ -1145,7 +1145,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
umv(dev.lsamrot, 0)
self.OMNYTools.printgreenbold(
- "\n\nAlignment scan finished. Please run SPEC_ptycho_align and load the new fit"
+ "\n\nAlignment scan finished. Please run BEC_ptycho_align and load the new fit"
" by lamni.read_additional_correction()."
)
diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md
index 673e01d..4a1b25d 100644
--- a/docs/user/ptychography/lamni.md
+++ b/docs/user/ptychography/lamni.md
@@ -64,7 +64,7 @@ The sample fine alignment can be obtained using ptychography. For this a short l
* `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 `SPEC_ptycho_align.m` (in Matlab, **force ptycho=1**, and **correct scan numbers**) using the printed/logged scan numbers.
+* 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()`
--
2.54.0
From df19ce917a98a2144adcde613db30131b3df9319 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 13:25:19 +0200
Subject: [PATCH 25/36] docs/update session summary through e3c979a
Add items 8-12 (timing log, account-change reset, optics
skip-if-in-position, OSA collision warning, tomo_alignment_scan) plus
the BEC_ptycho_align naming fix, updated test count (458), and
corresponding manual-check lines.
Co-Authored-By: Claude Sonnet 5
---
.../HW_COMMISSIONING_SESSION_2026-07.md | 79 ++++++++++++++++---
1 file changed, 69 insertions(+), 10 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/HW_COMMISSIONING_SESSION_2026-07.md b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/HW_COMMISSIONING_SESSION_2026-07.md
index 1e44f44..82783fa 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/HW_COMMISSIONING_SESSION_2026-07.md
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/HW_COMMISSIONING_SESSION_2026-07.md
@@ -1,6 +1,6 @@
# flomni/lamni parity fixes — 2026-07 session summary
-Branch `fixes/lamni_hw_commissioning`, commits `6c3d603..c3877ec`. Very brief
+Branch `fixes/lamni_hw_commissioning`, commits `6c3d603..e3c979a`. Very brief
by design — `git show ` on any commit below has the full reasoning and
diff; that's the source of truth if something needs revisiting.
@@ -39,13 +39,58 @@ diff; that's the source of truth if something needs revisiting.
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 `423 passed, 15 skipped`:
+**Automated** — should show `458 passed, 15 skipped`:
```
/opt/bec_deployments/production/bec_venv/bin/python -m pytest tests/ -q
```
@@ -59,17 +104,31 @@ beamline:**
- `lamni.tomo_queue_add()` a couple of jobs → `tomo_queue_execute()` →
`tomo_queue_reacquire(0, )` on a finished job → confirm status flips and
later jobs go `pending` → `tomo_queue_resume()` re-runs them.
-- `lamni.tomo_parameters()`: confirm the hook line (if one's registered) and
- the "Estimated Fermat scan points" line both appear, with a warning shown
- for a deliberately tiny piezo range / coarse step.
+- `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 the same tiny-FOV condition.
+ 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` (or the GUI's "0°
- reference each sub-tomo" checkbox, type 1 only), run a short type-1
+- `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, in the
- console output / `tomography_scannumbers.txt`.
+ 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).
--
2.54.0
From f25eea94888446059fcf57ef5a1322b274b65c30 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 14:23:35 +0200
Subject: [PATCH 26/36] newline removed
---
.../plugins/LamNI/lamni_alignment_mixin.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py
index afc28a5..03a1575 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py
@@ -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]
--
2.54.0
From d963cd6dfd2a1239cd2adba306fbac0d7cd1ca56 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 15:08:11 +0200
Subject: [PATCH 27/36] feat/auto-refresh stale at_each_angle hooks on
redefinition
register_at_each_angle_hook() stores a function object by reference, so
editing a hook's def and forgetting to re-register left the old version
running silently -- easy to miss mid-beamtime. Detect it via
func.__globals__ (a redefined top-level def, or a reloaded module,
mutates the same namespace dict in place) and auto-adopt the new
definition with a printed note instead of failing or staying stale.
Centralizes the shared lookup/error logic (previously duplicated in
lamni.py and flomni.py) into TomoQueueMixin._resolve_at_each_angle_hook().
Co-Authored-By: Claude Sonnet 5
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 9 +--
.../plugins/OMNY_shared/tomo_queue_mixin.py | 44 ++++++++++++
.../plugins/flomni/flomni.py | 9 +--
docs/user/ptychography/flomni.md | 19 ++++-
docs/user/ptychography/lamni.md | 19 ++++-
.../test_lamni_tomo_queue.py | 72 +++++++++++++++++++
6 files changed, 150 insertions(+), 22 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 743c3c1..c265a47 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -1174,14 +1174,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 LamNIError(
- f"at_each_angle_hook '{hook_name}' is not registered in this "
- "session. Hooks are session-only and do not survive a kernel "
- f"restart -- call register_at_each_angle_hook({hook_name!r}, "
- ") again, then re-run tomo_queue_execute() to resume."
- )
+ hook = self._resolve_at_each_angle_hook(hook_name, LamNIError)
hook(self, angle)
return
diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py
index 2740e56..ac8d27a 100644
--- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py
@@ -40,6 +40,7 @@ import datetime
import inspect
import json
import uuid
+from typing import Callable
from typeguard import check_type
@@ -248,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):
@@ -267,6 +277,40 @@ class TomoQueueMixin:
self._publish_at_each_angle_hooks()
print(f"Registered at_each_angle hook '{name}'.")
+ def _resolve_at_each_angle_hook(self, hook_name: str, error_cls: type) -> Callable:
+ """Look up a registered at_each_angle hook by name for execution,
+ raising ``error_cls`` if it isn't registered in this session.
+
+ If the function has been redefined since it was registered -- i.e.
+ the name it was defined under, in the namespace it was defined in
+ (``func.__globals__``), no longer points at the same object -- the
+ live definition is adopted in its place and a note is printed. This
+ catches editing and re-running a ``def`` in the same session/cell, or
+ in a file that's then ``importlib.reload()``-ed (the module dict is
+ ``func.__globals__`` itself, mutated in place by reload); it does not
+ catch plain ``%run`` (without ``-i``), which builds a fresh namespace
+ per run.
+ """
+ hook = self._at_each_angle_hooks.get(hook_name)
+ if hook is None:
+ raise error_cls(
+ f"at_each_angle_hook '{hook_name}' is not registered in this "
+ "session. Hooks are session-only and do not survive a kernel "
+ f"restart -- call register_at_each_angle_hook({hook_name!r}, "
+ ") again, then re-run tomo_queue_execute() to resume."
+ )
+ live = getattr(hook, "__globals__", {}).get(getattr(hook, "__name__", None))
+ if callable(live) and live is not hook:
+ self._at_each_angle_hooks[hook_name] = live
+ print(
+ f"Note: at_each_angle_hook '{hook_name}' was redefined since "
+ "it was registered -- using the new definition. Call "
+ "register_at_each_angle_hook() explicitly if that's not what "
+ "you want."
+ )
+ hook = live
+ return hook
+
def unregister_at_each_angle_hook(self, name: str) -> None:
"""Remove a previously registered at_each_angle hook by name."""
if self._at_each_angle_hooks.pop(name, None) is None:
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
index dac92c4..21262fe 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -2853,14 +2853,7 @@ class Flomni(
def _at_each_angle(self, angle: float) -> None:
hook_name = self.at_each_angle_hook
if hook_name:
- hook = self._at_each_angle_hooks.get(hook_name)
- if hook is None:
- raise FlomniError(
- f"at_each_angle_hook '{hook_name}' is not registered in this "
- "session. Hooks are session-only and do not survive a kernel "
- f"restart -- call register_at_each_angle_hook({hook_name!r}, "
- ") again, then re-run tomo_queue_execute() to resume."
- )
+ hook = self._resolve_at_each_angle_hook(hook_name, FlomniError)
hook(self, angle)
return
diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md
index eceda96..661948b 100644
--- a/docs/user/ptychography/flomni.md
+++ b/docs/user/ptychography/flomni.md
@@ -515,9 +515,22 @@ 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.
**`tomo_scan_projection()` vs `tomo_acquire_at_angle()`:** these are not
interchangeable. `tomo_scan_projection(angle)` always runs a full Fermat-scan
diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md
index 4a1b25d..a44ba62 100644
--- a/docs/user/ptychography/lamni.md
+++ b/docs/user/ptychography/lamni.md
@@ -282,9 +282,22 @@ 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.
**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()`) —
diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py b/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py
index 0f98425..3e5a233 100644
--- a/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py
+++ b/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py
@@ -204,3 +204,75 @@ def test_at_each_angle_raises_for_unregistered_hook_name():
except LamNIError as exc:
assert "missing_hook" in str(exc)
assert "not registered" in str(exc)
+
+
+def test_at_each_angle_hook_refreshed_when_redefined(capsys):
+ """Redefining a hook's `def` in the same namespace (as happens re-running
+ a cell in IPython) and forgetting to re-register must not silently keep
+ running the old version -- the live definition should be picked up
+ automatically, with a note printed."""
+ lamni = make_lamni()
+ ns = {}
+ exec("def hook_func(setup, angle):\n setup.marker = 'old'\n", ns)
+ lamni.register_at_each_angle_hook("swap", ns["hook_func"])
+ lamni.at_each_angle_hook = "swap"
+
+ exec("def hook_func(setup, angle):\n setup.marker = 'new'\n", ns)
+ lamni._at_each_angle(5.0)
+
+ assert lamni.marker == "new"
+ assert "redefined" in capsys.readouterr().out
+
+
+def test_register_at_each_angle_hook_with_lambda_never_flagged_stale(capsys):
+ """Lambdas have no real namespace binding under their `__name__`
+ (""), so the redefinition check must never fire a false positive
+ for them -- covered separately from the dispatch test above since that
+ one doesn't assert anything about stdout."""
+ lamni = make_lamni()
+ lamni.register_at_each_angle_hook("record", lambda self, angle: None)
+ lamni.at_each_angle_hook = "record"
+
+ lamni._at_each_angle(1.0)
+
+ assert "redefined" not in capsys.readouterr().out
+
+
+def test_at_each_angle_hook_refreshed_after_module_reload(tmp_path, capsys):
+ """Mirrors the recommended file-based workflow: define the hook in a
+ .py file, load it, edit the file, and reload the module -- the module's
+ __dict__ (== the hook function's __globals__) is mutated in place by
+ importlib.reload(), so this must be picked up the same way an
+ interactive redefinition is."""
+ import importlib
+ import os
+ import sys
+
+ module_name = "lamni_hook_reload_test_module"
+ module_path = tmp_path / f"{module_name}.py"
+
+ def write(marker_value, mtime):
+ module_path.write_text(f"def hook_func(setup, angle):\n setup.marker = {marker_value!r}\n")
+ os.utime(module_path, (mtime, mtime))
+
+ # Distinct, well-separated mtimes -- otherwise both writes can land in
+ # the same filesystem mtime-resolution window and the loader reuses the
+ # cached .pyc from the first write, silently defeating the reload.
+ write("old", 1_700_000_000)
+ sys.path.insert(0, str(tmp_path))
+ try:
+ module = importlib.import_module(module_name)
+ lamni = make_lamni()
+ lamni.register_at_each_angle_hook("swap", module.hook_func)
+ lamni.at_each_angle_hook = "swap"
+
+ write("new", 1_700_000_010)
+ importlib.reload(module)
+
+ lamni._at_each_angle(5.0)
+
+ assert lamni.marker == "new"
+ assert "redefined" in capsys.readouterr().out
+ finally:
+ sys.path.remove(str(tmp_path))
+ sys.modules.pop(module_name, None)
--
2.54.0
From a001bc20bdb3a1d3ba0feee71a562a375e8ff0aa Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 15:12:40 +0200
Subject: [PATCH 28/36] fix/tomo_queue_reacquire wrongly blocked on a later
incomplete job
The single-resumable-job invariant check scanned all other jobs, but
jobs after job_index get force-reset to pending anyway -- so a later
incomplete/running job was never a real conflict, only an earlier one
is (since earlier jobs are left untouched).
Co-Authored-By: Claude Sonnet 5
---
.../plugins/OMNY_shared/tomo_queue_mixin.py | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py
index ac8d27a..ac887e0 100644
--- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py
@@ -872,10 +872,11 @@ class TomoQueueMixin:
Raises:
TomoQueueError: ``job_index`` is out of range, the job at that
- index is a command job (not a tomo scan), or some *other*
+ 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).
+ 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.
"""
@@ -890,8 +891,8 @@ class TomoQueueMixin:
"command job, not a tomo scan -- nothing to reacquire."
)
- for idx, other in enumerate(jobs):
- if idx != job_index and other.get("status") in ("incomplete", "running"):
+ 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} "
--
2.54.0
From 887c732865f2bd1a9140000630e959cbfa74efce Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 15:41:25 +0200
Subject: [PATCH 29/36] fix/tomo queue GUI projection count for tomo_type 3
_format_projections() computed type 3's count with type 1's fixed
"N*8 sub-tomograms" formula, ignoring golden_max_number_of_projections
-- the value that actually bounds a type-3 run (lamni.py/flomni.py's
type-3 scan loop has no sub-tomogram cap, it stops only once
ii > golden_max_number_of_projections, same as type 2). This produced
an ~8x-inflated "Projections" column (e.g. 240 instead of 30) for any
type-3 job. Merged type 3 into type 2's branch since both share the
same uncapped/golden_max_number_of_projections model.
Co-Authored-By: Claude Sonnet 5
---
.../widgets/tomo_params/tomo_params.py | 15 +++++----------
1 file changed, 5 insertions(+), 10 deletions(-)
diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
index a8b1771..7f4ed37 100644
--- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
+++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
@@ -2340,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:
@@ -2355,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))
--
2.54.0
From 636dd0ec884eeb4539f37aa722bd6d463fb02591 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 15:59:02 +0200
Subject: [PATCH 30/36] fix/stop LamNI per-sub-tomogram scilog entries
LamNI's tomo_scan() wrote a separate scilog entry ("Starting subtomo:
N...") every time the sub-tomogram number changed -- 8 extra entries
per tomo_type-1 scan, and one per sub-tomogram traversed for types 2/3
-- leftover behavior carried over from omny.py during the flomni->lamni
port. Flomni itself never does this: it only writes at scan start (PDF
report) and scan end (timing summary). Removed the three
_write_subtomo_to_scilog() call sites and the now-unused method so
LamNI's scilog behavior matches Flomni's.
Also fixed a stale test (test_tomo_queue_reacquire_rejects_when_another_job_is_incomplete)
that encoded the old, buggy tomo_queue_reacquire() invariant (rejecting
on ANY other job being incomplete/running) rather than the fixed one
from the prior commit (only an EARLIER job is a real conflict) --
split it into two tests covering both directions.
Co-Authored-By: Claude Sonnet 5
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 15 ------------
.../test_lamni_tomo_angles.py | 1 -
.../test_tomo_queue_reacquire.py | 24 +++++++++++++++----
3 files changed, 20 insertions(+), 20 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index c265a47..7d97c66 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -771,17 +771,6 @@ 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"
@@ -1306,8 +1295,6 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
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)
-
angles, N = self._subtomo_angle_plan(
subtomo_number, self.tomo_angle_stepsize, start_angle=start_angle
)
@@ -1564,7 +1551,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
@@ -1603,7 +1589,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
diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
index 7e19c67..127531d 100644
--- a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
+++ b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
@@ -46,7 +46,6 @@ def make_lamni(tomo_angle_stepsize: float) -> LamNI:
obj._progress_proxy = _ProgressProxy(obj.client)
obj.tomo_angle_stepsize = tomo_angle_stepsize
obj.tomo_id = -1
- obj._write_subtomo_to_scilog = lambda subtomo_number: None
return obj
diff --git a/tests/tests_bec_ipython_client/test_tomo_queue_reacquire.py b/tests/tests_bec_ipython_client/test_tomo_queue_reacquire.py
index 7fcaed6..2d1bf83 100644
--- a/tests/tests_bec_ipython_client/test_tomo_queue_reacquire.py
+++ b/tests/tests_bec_ipython_client/test_tomo_queue_reacquire.py
@@ -82,7 +82,6 @@ def make_flomni():
def test_lamni_resolve_type1_projection_matches_sub_tomo_scan(stepsize):
lamni = make_lamni()
lamni.tomo_angle_stepsize = stepsize
- lamni._write_subtomo_to_scilog = lambda subtomo_number: None
recorded = {}
lamni._tomo_scan_at_angle = lambda angle, subtomo_number: recorded.__setitem__(
lamni.progress["projection"], (subtomo_number, angle)
@@ -219,7 +218,21 @@ def test_tomo_queue_reacquire_rejects_command_job():
lamni.tomo_queue_reacquire(0, projection_number=0)
-def test_tomo_queue_reacquire_rejects_when_another_job_is_incomplete():
+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
@@ -227,8 +240,11 @@ def test_tomo_queue_reacquire_rejects_when_another_job_is_incomplete():
i1 = lamni.tomo_queue_add(label="job1")
lamni._tomo_queue_proxy.update(i1, status="incomplete")
- with pytest.raises(TomoQueueError):
- lamni.tomo_queue_reacquire(i0, projection_number=0)
+ 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():
--
2.54.0
From 5be3d0719b14f364b13e6243fee0678434511968 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 16:03:20 +0200
Subject: [PATCH 31/36] feat/include at-each-angle hook in lamni's
start-of-scan PDF/scilog
write_pdf_report() didn't mention the active at_each_angle_hook at all,
unlike Flomni's equivalent which already appends the hook's description
and source to both the PDF and its scilog entry. Ported that pattern
over using the same _describe_active_hook()/_active_hook_source() shared
helpers lamni already uses elsewhere (end-of-scan summary,
tomo_parameters() printout) -- no new logic needed, just the missing
call site.
Co-Authored-By: Claude Sonnet 5
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 7d97c66..f5cca17 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -1966,11 +1966,19 @@ 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)
+ 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)
+ if hook_source:
+ file.write(
+ f"\nAt-each-angle hook source ('{self.at_each_angle_hook}'):\n{hook_source}"
+ )
subprocess.run(
"xterm /work/sls/spec/local/XOMNY/bin/upload/upload_last_pon.sh &", shell=True
)
@@ -1978,9 +1986,14 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
# configured (e.g. a dev/sim session) must not crash report generation
# over the logbook upload -- the PDF itself is already written above.
try:
+ scilog_text = content
+ if hook_source:
+ scilog_text += (
+ f"\n\nAt-each-angle hook source ('{self.at_each_angle_hook}'):\n{hook_source}"
+ )
msg = bec.logbook.LogbookMessage()
logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "LamNI_logo.png")
- msg.add_file(logo_path).add_text("".join(content).replace("\n", "
")).add_tag(
+ msg.add_file(logo_path).add_text(scilog_text.replace("\n", "
")).add_tag(
["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "LamNI", self.sample_name]
)
self.client.logbook.send_logbook_message(msg)
--
2.54.0
From f524021ccef0865e43bbb8ff88acf39a5146c0eb Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 16:21:05 +0200
Subject: [PATCH 32/36] feat/show estimated finish time and active hook in
lamni progress ring
Flomni's ring-progress center label already showed "Est. finish" and
the active at-each-angle hook name; LamNI's equivalent
(_lamnigui_update_progress()) was missing both. Ported the same
formatting/display logic over so the two setups' progress displays
match.
Co-Authored-By: Claude Sonnet 5
---
.../plugins/LamNI/gui_tools.py | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py
index ed118fe..e5efa97 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py
@@ -250,6 +250,15 @@ class LamniGuiTools:
else:
eta_display = "N/A"
+ # Format estimated finish (wall-clock) time
+ finish_str = self.progress.get("estimated_finish_time")
+ if finish_str is not None:
+ finish_display = datetime.datetime.fromisoformat(finish_str).strftime(
+ "%Y-%m-%d %H:%M:%S"
+ )
+ else:
+ finish_display = "N/A"
+
text = (
f"Progress report:\n"
f" Tomo type: {self.progress['tomo_type']}\n"
@@ -260,8 +269,15 @@ class LamniGuiTools:
f" Current projection within subtomo: {self.progress['subtomo_projection']}\n"
f" Total projections per subtomo: {int(self.progress['subtomo_total_projections'])}\n"
f" Scan started: {start_display}\n"
- f" Est. remaining: {eta_display}"
+ f" Est. remaining: {eta_display}\n"
+ f" Est. finish: {finish_display}"
)
+ # self._describe_active_hook() comes from LamNI itself (this is a
+ # mixin, always combined with it) -- shown only when a hook is
+ # actually active, mirrors Flomni's identical center-label note.
+ hook_description = self._describe_active_hook()
+ if hook_description:
+ text += f"\n Hook: {hook_description}"
self.progressbar.set_center_label(text)
--
2.54.0
From a64d7cff9326b6e83c99136778a58604bc599194 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 16:21:12 +0200
Subject: [PATCH 33/36] refactor/move add_sample_database() into shared
TomoQueueMixin
Flomni.add_sample_database() and LamNI.add_sample_database() were
byte-for-byte identical -- both just forward to
self.tomo_id_manager.register(), a TomoIDManager from
omny/omny_general_tools.py that each setup already constructs
independently in its own __init__. Moved the method into
TomoQueueMixin (OMNY_shared), the base class both already inherit,
same "moved code, not changed logic" precedent as the tomo-queue
backend and webpage generator. omny.py's own separate, older
add_sample_database() (different URL, no e-account fallback) is
untouched.
Co-Authored-By: Claude Sonnet 5
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 19 ++---------------
.../plugins/OMNY_shared/tomo_queue_mixin.py | 21 +++++++++++++++++++
.../plugins/flomni/flomni.py | 15 ++-----------
3 files changed, 25 insertions(+), 30 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index f5cca17..d8fe500 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -989,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
diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py
index ac887e0..f351f5d 100644
--- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py
+++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py
@@ -369,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:
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
index 21262fe..a4678ec 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -2836,19 +2836,8 @@ 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
--
2.54.0
From 1362a63a85598ee3d1747e003fe1204d07c914e3 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 16:24:33 +0200
Subject: [PATCH 34/36] fix/disable lamni's broken upload_last_pon.sh call
Confirmed this script no longer works and needs a rewrite. Commented
out the subprocess.run() call in write_pdf_report(), mirroring Flomni
which already has the identical call disabled the same way. omny.py's
own copy is left untouched (out of scope for this migration).
Co-Authored-By: Claude Sonnet 5
---
csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index d8fe500..cf71c76 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -1964,9 +1964,11 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
file.write(
f"\nAt-each-angle hook source ('{self.at_each_angle_hook}'):\n{hook_source}"
)
- subprocess.run(
- "xterm /work/sls/spec/local/XOMNY/bin/upload/upload_last_pon.sh &", shell=True
- )
+ # 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.
--
2.54.0
From 9198638f9b851fbc5d46553a8b7399bf507bdb05 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 16:38:00 +0200
Subject: [PATCH 35/36] docs/add file-loaded at_each_angle hook example
(exposure + step size)
Adds a worked example to both lamni.md and flomni.md showing a hook
loaded from a file that temporarily bumps tomo_countingtime and
tomo_shellstep every 5th projection within sub-tomogram 2 (tomo_type 1),
restoring both in a finally block -- demonstrating that a hook can
change any scan parameter, not just move devices like the existing
polarizer example. Notes that progress["subtomo"]/["subtomo_projection"]
work the same way for tomo_types 2/3, just with an open-ended
sub-tomogram count there instead of type 1's fixed 8.
Co-Authored-By: Claude Sonnet 5
---
docs/user/ptychography/flomni.md | 41 ++++++++++++++++++++++++++++++++
docs/user/ptychography/lamni.md | 41 ++++++++++++++++++++++++++++++++
2 files changed, 82 insertions(+)
diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md
index 661948b..5796480 100644
--- a/docs/user/ptychography/flomni.md
+++ b/docs/user/ptychography/flomni.md
@@ -532,6 +532,47 @@ 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
projection; `tomo_acquire_at_angle(angle)` always runs a single-point acquisition.
diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md
index a44ba62..590f538 100644
--- a/docs/user/ptychography/lamni.md
+++ b/docs/user/ptychography/lamni.md
@@ -299,6 +299,47 @@ 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()`) —
the GUI process can't register hooks itself, only select one by name for the job
--
2.54.0
From 10ef93557bc1995f5e5c3ff346ff5423c13c97e4 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 21 Jul 2026 16:57:06 +0200
Subject: [PATCH 36/36] fix/stop lamni tests from leaking a fake bec global
across the suite
test_lamni_account_change_reset.py and test_lamni_tomo_angles.py set
builtins.__dict__["bec"] directly with no cleanup, so a leaked fake
account ("e22222") from an earlier test file was still in place when
test_x_ray_eye_align.py later constructed a real LamNI(), tripping
_maybe_reset_params_on_account_change()'s interactive yesno() prompt
and failing under pytest's captured stdin. Use monkeypatch.setitem so
pytest reverts the global after each test, matching the pattern already
used in test_lamni_tomo_alignment_scan.py.
Co-Authored-By: Claude Sonnet 5
---
.../test_lamni_account_change_reset.py | 16 +++---
.../test_lamni_tomo_angles.py | 52 +++++++++++--------
2 files changed, 37 insertions(+), 31 deletions(-)
diff --git a/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py b/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py
index d879831..fe2ce65 100644
--- a/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py
+++ b/tests/tests_bec_ipython_client/test_lamni_account_change_reset.py
@@ -60,9 +60,9 @@ def make_lamni(answer=True):
return obj
-def test_skips_silently_when_no_active_account():
+def test_skips_silently_when_no_active_account(monkeypatch):
lamni = make_lamni()
- builtins.__dict__["bec"] = type("Bec", (), {"active_account": ""})()
+ monkeypatch.setitem(builtins.__dict__, "bec", type("Bec", (), {"active_account": ""})())
lamni._maybe_reset_params_on_account_change()
@@ -70,10 +70,10 @@ def test_skips_silently_when_no_active_account():
assert lamni.OMNYTools.prompts == []
-def test_skips_silently_on_same_account():
+def test_skips_silently_on_same_account(monkeypatch):
lamni = make_lamni()
lamni.client.set_global_var("defaults_applied_for_account", "e12345")
- builtins.__dict__["bec"] = type("Bec", (), {"active_account": "e12345"})()
+ monkeypatch.setitem(builtins.__dict__, "bec", type("Bec", (), {"active_account": "e12345"})())
lamni.tomo_shellstep = 99.0
lamni._maybe_reset_params_on_account_change()
@@ -82,12 +82,12 @@ def test_skips_silently_on_same_account():
assert lamni.tomo_shellstep == 99.0
-def test_prompts_and_resets_on_new_account_when_confirmed():
+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
- builtins.__dict__["bec"] = type("Bec", (), {"active_account": "e22222"})()
+ monkeypatch.setitem(builtins.__dict__, "bec", type("Bec", (), {"active_account": "e22222"})())
lamni._maybe_reset_params_on_account_change()
@@ -99,11 +99,11 @@ def test_prompts_and_resets_on_new_account_when_confirmed():
assert lamni.client.get_global_var("defaults_applied_for_account") == "e22222"
-def test_records_account_without_resetting_when_declined():
+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
- builtins.__dict__["bec"] = type("Bec", (), {"active_account": "e22222"})()
+ monkeypatch.setitem(builtins.__dict__, "bec", type("Bec", (), {"active_account": "e22222"})())
lamni._maybe_reset_params_on_account_change()
diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
index 127531d..0246079 100644
--- a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
+++ b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
@@ -152,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
@@ -167,22 +169,26 @@ def make_lamni_for_tomo_scan(tomo_angle_stepsize: float, active_account: str) ->
obj.lamnigui_show_progress = lambda: None
obj.at_each_angle_hook = None
obj.OMNYTools = types.SimpleNamespace(printgreenbold=lambda msg: None)
- 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)
+ 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")
)
@@ -192,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):
@@ -210,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")
@@ -224,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
@@ -236,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):
@@ -249,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):
@@ -282,10 +288,10 @@ def test_subtomo_starts_near_zero(subtomo_number, expected):
assert lamni._subtomo_starts_near_zero(subtomo_number) is expected
-def test_zero_deg_reference_disabled_by_default():
+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(45.0, active_account="")
+ 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(
@@ -298,11 +304,11 @@ def test_zero_deg_reference_disabled_by_default():
assert recorded == []
-def test_zero_deg_reference_fires_for_odd_subtomos_and_final_shot():
+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(45.0, active_account="")
+ 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 = []
@@ -316,12 +322,12 @@ def test_zero_deg_reference_fires_for_odd_subtomos_and_final_shot():
assert recorded == [(0, 1), (0, 3), (0, 5), (0, 7), (0, 8)]
-def test_zero_deg_reference_skipped_when_resuming_mid_subtomo():
+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(45.0, active_account="")
+ 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(
--
2.54.0