From 2f07c3e2513461c7896202ff5235a9190ddcf763 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 14:48:53 +0200
Subject: [PATCH 01/29] docs+fix(LamNI): trim obsolete xrayeye doc section,
drop archival text file
- Remove the manual fit-parameter reload docs: fit params already load
automatically, and the fallback path pointed at the retired
~/Data10/specES1 layout.
- Fix stale slits/slit0wh references (slits takes no argument; the
gap monitor is now slit1).
- Stop writing the plain-text xrayeye_alignmentvalues archive in
write_output() -- it was only for external fitting scripts and now
collides with xrayeye_alignmentvalues being a directory used for the
per-run HDF5 archives.
---
.../plugins/LamNI/x_ray_eye_align.py | 37 +++++++++----------
docs/user/ptychography/lamni.md | 10 +----
2 files changed, 20 insertions(+), 27 deletions(-)
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 2b633de..7d87b3b 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
@@ -561,26 +561,25 @@ class XrayEyeAlign:
row 1: x offsets [um]
row 2: y offsets [um]
- Also writes a timestamped HDF5 file alongside the archival text file,
- containing the full raw record of the alignment run: alignment_values
- (FZP centre + all 8 angle clicks, in mm), alignment_images (one frame
- per update_frame() call), roi_pixel_data (raw pixel coords/size at
- each submit), and this same fit array as alignment_fit.
+ Writes a timestamped HDF5 file containing the full raw record of the
+ alignment run: alignment_values (FZP centre + all 8 angle clicks, in
+ mm), alignment_images (one frame per update_frame() call),
+ roi_pixel_data (raw pixel coords/size at each submit), and this same
+ fit array as alignment_fit.
"""
- # Archival text file (backward compatible with any external scripts)
- 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")
- for k in range(2, 10):
- angle_deg = LAMNI_ALIGNMENT_ANGLES[k - 2]
- x_off = (self.alignment_values[0][0] - self.alignment_values[k][0]) * 1000
- y_off = (self.alignment_values[k][1] - self.alignment_values[0][1]) * 1000
- f.write(f"{angle_deg}\t{x_off:.4f}\t{y_off:.4f}\n")
- print(
- f" Angle {angle_deg:3d} deg: "
- f"x_offset={x_off:.2f} um, y_offset={y_off:.2f} um"
- )
+ # NOTE: this used to also write a plain-text archival file at
+ # ~/data/raw/logs/xrayeye_alignmentvalues for external fitting
+ # scripts. That's no longer needed, and xrayeye_alignmentvalues is
+ # now a directory (holding the timestamped HDF5 files below), so
+ # writing a flat file at that same path would clash with it.
+ for k in range(2, 10):
+ angle_deg = LAMNI_ALIGNMENT_ANGLES[k - 2]
+ x_off = (self.alignment_values[0][0] - self.alignment_values[k][0]) * 1000
+ y_off = (self.alignment_values[k][1] - self.alignment_values[0][1]) * 1000
+ print(
+ f" Angle {angle_deg:3d} deg: "
+ f"x_offset={x_off:.2f} um, y_offset={y_off:.2f} um"
+ )
angles = np.array(LAMNI_ALIGNMENT_ANGLES, dtype=float)
x_offsets = np.array(
diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md
index 569c935..3fd1292 100644
--- a/docs/user/ptychography/lamni.md
+++ b/docs/user/ptychography/lamni.md
@@ -61,12 +61,6 @@ This opens the X-ray eye widget automatically. The procedure collects the sample
With LamNI it can be difficult to relocate the sample between rotations. To keep the shutter open throughout, pass:
`lamni.xrayeye_alignment_start(keep_shutter_open=True)`
-To manually reload the fit parameters after the procedure has completed:
-`lamni.read_xray_eye_correction_from_gui()`
-**Note:** this reads from the live GUI widget via the `omny_xray_gui` device. It only works as long as the XRayEye GUI window remains open. If the window has been closed, reload from the archived text files instead:
-`lamni.read_xray_eye_correction()`
-(these files are written to `~/Data10/specES1/internal/xrayeye_alignmentvalues` at the end of every alignment run)
-
The correction is applied at each projection angle via
`lamni.lamni_compute_additional_correction_xeye_mu(angle)`
which is called automatically inside `lamni.tomo_scan_projection()`.
@@ -75,9 +69,9 @@ To capture a single fresh frame without running the full alignment:
`lamni.xrayeye_update_frame()`
or with the shutter left open: `lamni.xrayeye_update_frame(keep_shutter_open=True)`
-* If slits were opened during alignment, close the slits: `slits 1` to around 0.3
+* If slits were opened during alignment, close the slits: `slits` to around 0.3
* `lamni.leye_out()` remove the X-ray eye and move the flight tube in
-* *possibly check slit0wh, idgap*
+* *possibly check slit1, idgap*
#### Fine alignment
--
2.54.0
From f736900a5259113abd2168b4cd341d302c90539c Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 16:11:04 +0200
Subject: [PATCH 02/29] feat(LamNI): notify BEC client before long lsamrot
reset-moves
feedback_enable_with_reset() silently rotates lsamrot back to 0 as part
of the interferometer reset; when the stage is far from 0 this can take
a while with no feedback in the client session. A plain print() there
only reaches the device-server console, not the client, so use
connector.send_client_info() instead.
---
csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py b/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py
index 3a3f6b0..05ae915 100644
--- a/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py
+++ b/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py
@@ -328,6 +328,15 @@ class RtLamniController(Controller):
) # we set all three outputs of the traj. gen. although in LamNI case only 0,1 are used
self.clear_trajectory_generator()
+ lsamrot_current = self.device_manager.devices.lsamrot.obj.readback.get()
+ if abs(lsamrot_current) > 10:
+ self.device_manager.connector.send_client_info(
+ f"lsamrot is at {lsamrot_current:.1f} deg -- rotating back to 0 deg as part "
+ "of the interferometer feedback reset. This is a long move and may take a "
+ "while...",
+ scope="feedback_enable_with_reset",
+ show_asap=True,
+ )
self.device_manager.devices.lsamrot.obj.move(0, wait=True)
galil_controller_rt_status = (
--
2.54.0
From 40002b9a48301f6005b8984fd8e931f507530166 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 16:11:21 +0200
Subject: [PATCH 03/29] fix(LamNI): drop duplicate debug print in
interferometer readback
RtLamniReadbackSignal._socket_get() printed the same raw socket
response twice in a row with nothing modifying it in between --
leftover debug output, not by design.
---
csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py b/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py
index 05ae915..c9c70f8 100644
--- a/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py
+++ b/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py
@@ -422,14 +422,12 @@ class RtLamniReadbackSignal(RtLamniSignalRO):
float: Readback value after adjusting for sign and motor resolution.
"""
return_table = (self.controller.socket_put_and_receive(f"J4")).split(",")
- print(return_table)
if self.parent.axis_Id_numeric == 0:
readback_index = 2
elif self.parent.axis_Id_numeric == 1:
readback_index = 1
else:
raise RtLamniError("Currently, only two axes are supported.")
- print(return_table)
current_pos = float(return_table[readback_index])
current_pos *= self.parent.sign
--
2.54.0
From 0fef9157d329885d2f1a34d8cd61b3af78cee4e6 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 16:11:32 +0200
Subject: [PATCH 04/29] feat(LamNI): make camera live-mode poll interval
configurable
IDSCamera's live-mode loop had a hardcoded 0.2s sleep (nominal 5 Hz
cap) between frame grabs. Expose it as live_mode_poll_interval_s
(default 0.2, unchanged for existing cameras) and lower it for LamNI's
cam_xeye (0.02s) so exposure/acquisition time, not this artificial
sleep, becomes the real limit on smear-sweep capture rate.
---
csaxs_bec/device_configs/ptycho_lamni.yaml | 1 +
csaxs_bec/devices/ids_cameras/ids_camera.py | 8 +++++++-
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/csaxs_bec/device_configs/ptycho_lamni.yaml b/csaxs_bec/device_configs/ptycho_lamni.yaml
index 7c23157..e4e43a3 100644
--- a/csaxs_bec/device_configs/ptycho_lamni.yaml
+++ b/csaxs_bec/device_configs/ptycho_lamni.yaml
@@ -296,6 +296,7 @@ cam_xeye:
transpose: false
force_monochrome: true
m_n_colormode: 1
+ live_mode_poll_interval_s: 0.02
enabled: true
onFailure: buffer
readOnly: false
diff --git a/csaxs_bec/devices/ids_cameras/ids_camera.py b/csaxs_bec/devices/ids_cameras/ids_camera.py
index 2ea5bdc..e429c91 100644
--- a/csaxs_bec/devices/ids_cameras/ids_camera.py
+++ b/csaxs_bec/devices/ids_cameras/ids_camera.py
@@ -88,6 +88,7 @@ class IDSCamera(PSIDeviceBase):
num_rotation_90: int = 0,
transpose: bool = False,
force_monochrome: bool = False,
+ live_mode_poll_interval_s: float = 0.2,
**kwargs,
):
"""Initialize the IDS Camera.
@@ -100,10 +101,15 @@ class IDSCamera(PSIDeviceBase):
m_n_colormode (Literal[0, 1, 2, 3]): Color mode for the camera.
bits_per_pixel (Literal[8, 24]): Number of bits per pixel for the camera.
live_mode (bool): Whether to enable live mode for the camera.
+ live_mode_poll_interval_s (float): Delay between frame grabs in
+ the live-mode loop. Lower this for cameras/use cases that
+ need a higher live-mode push rate; the achievable rate is
+ still bounded by the camera's own exposure/acquisition time.
"""
super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs)
self._live_mode_thread: threading.Thread | None = None
self._stop_live_mode_event: threading.Event = threading.Event()
+ self._live_mode_poll_interval_s = live_mode_poll_interval_s
# Rolling buffer of push timestamps from _live_mode_loop, used to
# measure the actual live-mode frame rate (see get_live_fps()).
self._live_frame_times: deque[float] = deque(maxlen=10)
@@ -206,7 +212,7 @@ class IDSCamera(PSIDeviceBase):
logger.error(f"Error in live mode loop: {e}")
break
self._live_frame_times.append(time.time())
- stop_event.wait(0.2) # 5 Hz
+ stop_event.wait(self._live_mode_poll_interval_s)
self.cam.set_camera_rate_limiting(False)
def get_live_fps(self) -> float | None:
--
2.54.0
From 866891398f9d23a60e415627742de7ab4c451cd4 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 16:11:54 +0200
Subject: [PATCH 05/29] feat(LamNI): show and prompt for sample name in the
XRayEye GUI
The shared XRayEye widget already supports a sample_name field, but
LamNI's x_ray_eye_align.py never set it (unlike FlOMNI's align(),
which does) -- so it was always blank. Add _sync_sample_name(): pushes
lamni.sample_name into the GUI everywhere align()/find_rotation_center*
run, and additionally prompts for it (Enter keeps the current value,
same as tomo_parameters()) at the rotation-center steps, since those
are often the first alignment action for a new sample.
---
.../plugins/LamNI/x_ray_eye_align.py | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
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 7d87b3b..c460077 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
@@ -200,6 +200,21 @@ class XrayEyeAlign:
print(f"Alignment GUI: {msg}")
self.gui.user_message = msg
+ def _sync_sample_name(self, prompt: bool = False):
+ """Push lamni.sample_name into the XRayEye GUI's sample_name field.
+
+ If prompt=True, first ask for it via the same _get_val() pattern
+ tomo_parameters() uses (Enter keeps the current value) -- used at
+ the rotation-center steps, which are often the first alignment
+ action for a new sample, before tomo_parameters() has necessarily
+ run.
+ """
+ if prompt:
+ self.lamni.sample_name = self.lamni._get_val(
+ "sample name", self.lamni.sample_name, str
+ )
+ self.gui.sample_name = self.lamni.sample_name
+
# ------------------------------------------------------------------
# Main alignment procedure
# ------------------------------------------------------------------
@@ -262,6 +277,7 @@ class XrayEyeAlign:
then load fit parameters into the global variable store.
"""
self.lamni.lamnigui_show_xeyealign()
+ self._sync_sample_name()
self.gui.set_dap_params_forwarding(True)
self.send_message("Getting things ready. Please wait...")
@@ -848,6 +864,7 @@ class XrayEyeAlign:
tuple: (new_lsamx_center, new_lsamy_center) in mm.
"""
self.lamni.lamnigui_show_xeyealign()
+ self._sync_sample_name(prompt=True)
self.gui.set_dap_params_forwarding(False)
self._reset_init_values()
self.alignment_images = []
@@ -1136,6 +1153,7 @@ class XrayEyeAlign:
)
self.lamni.lamnigui_show_xeyealign()
+ self._sync_sample_name(prompt=True)
self.gui.set_dap_params_forwarding(False)
self._reset_init_values()
self.alignment_images = []
--
2.54.0
From 574a1bdde8d514f937f6c2fdb00e25fe4d7c1e7d Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 16:12:04 +0200
Subject: [PATCH 06/29] fix(tomo_params): block submit/queue-add when Fermat
scan is below minimum
The estimated Fermat-scan point count was only ever flagged with an
orange label -- Submit and Add-to-queue would both happily accept a
configuration that's guaranteed to abort when actually run. Enforce
the same minimum in _validate(), shared by both actions, so it's now a
hard block instead of a cosmetic warning.
---
.../bec_widgets/widgets/tomo_params/tomo_params.py | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 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 7f4ed37..f58fe23 100644
--- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
+++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py
@@ -848,6 +848,13 @@ class TomoParamsWidget(BECWidget, QWidget):
rshift = params.get("single_point_random_shift_max", 0.0)
if not 0 <= rshift <= 10:
return "single_point_random_shift_max must be between 0 and 10 µm"
+ count, min_positions = self._profile["compute_fermat_positions"](params)
+ if count < min_positions:
+ return (
+ f"Estimated Fermat scan points ({count}) is below the minimum of "
+ f"{min_positions} required for the scan to run -- increase FOV, "
+ "reduce step size, or adjust stitch/piezo range before submitting."
+ )
return None
# ── type visibility ───────────────────────────────────────────────────────
@@ -911,8 +918,10 @@ class TomoParamsWidget(BECWidget, QWidget):
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."""
+ reimplementation). This is just the live preview cue (flags orange
+ below the scan server's own minimum); the actual minimum is
+ enforced as a hard block in _validate(), shared by both
+ submit_params() and add_edited_to_queue()."""
params = {}
for key in self._profile["fermat_position_fields"]:
widget = self._pw.get(key)
--
2.54.0
From 33c9c0e433861a65f3f0c378a9ea98791eaa2bdb Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 16:13:10 +0200
Subject: [PATCH 07/29] fix(LamNI): stop double-printing the Xeye correction
table
lamni_compute_additional_correction_xeye_mu() always printed its
result. write_alignment_scan_numbers() called it once per angle just
to grab the x-offset for the log file, which dumped all 12 corrections
up front -- immediately followed by the exact same values reprinted
one-by-one as tomo_alignment_scan() actually runs each angle. Add a
verbose flag (default True) and pass verbose=False from the lookahead
call.
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 2 +-
.../plugins/LamNI/lamni_alignment_mixin.py | 22 ++++++++++++++-----
2 files changed, 17 insertions(+), 7 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index f20f8bd..c584804 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -1183,7 +1183,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
x_vals = []
for angle in angles:
- x, _y = self.lamni_compute_additional_correction_xeye_mu(angle)
+ x, _y = self.lamni_compute_additional_correction_xeye_mu(angle, verbose=False)
x_vals.append(x)
zeros = [0] * len(angles)
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 03a1575..e404710 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
@@ -248,15 +248,24 @@ class LamNIAlignmentMixin:
f" Y: A={fit[1][0]:.4f}, B={fit[1][1]:.4f}, C={fit[1][2]:.4f}"
)
- def lamni_compute_additional_correction_xeye_mu(self, angle):
+ def lamni_compute_additional_correction_xeye_mu(self, angle, verbose: bool = True):
"""Evaluate the sinusoidal X-ray eye correction at *angle* degrees.
+ Args:
+ verbose: if True (default), print the computed correction. Pass
+ False for bulk/lookahead uses (e.g. write_alignment_scan_numbers())
+ that just need the numbers and would otherwise print the same
+ values a second time, ahead of and redundant with the
+ per-projection print that happens when this is actually
+ applied during the scan.
+
Returns:
tuple: ``(correction_x_mm, correction_y_mm)``
"""
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.")
+ if verbose:
+ print("Not applying any X-ray eye correction. No fit data available.")
return (0, 0)
correction_x = (
@@ -272,10 +281,11 @@ class LamNIAlignmentMixin:
+ tomo_fit_xray_eye[1][2]
) / 1000
- print(
- f"Xeye correction x={correction_x:.6f} mm,"
- f" y={correction_y:.6f} mm @ angle={angle}"
- )
+ if verbose:
+ print(
+ f"Xeye correction x={correction_x:.6f} mm,"
+ f" y={correction_y:.6f} mm @ angle={angle}"
+ )
return (correction_x, correction_y)
# ------------------------------------------------------------------
--
2.54.0
From b5655fd6eb6181c28f3cc141eaa766dc05f2301b Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 16:14:11 +0200
Subject: [PATCH 08/29] feat(LamNI): add ring-progress display for
tomo_alignment_scan()
tomo_alignment_scan() had no GUI feedback at all -- just console
prints -- unlike the real tomogram, which already drives a
RingProgressBar via lamnigui_show_progress(). Add the same kind of
single-ring display for the 12-angle alignment loop
(lamnigui_show_alignment_progress()/_lamnigui_update_alignment_progress()),
backed by its own alignment_scan_progress global var rather than
tomo_progress, so anything watching tomo_progress for the real
tomogram (heartbeat/idle-time tracking) never sees alignment-scan
writes mixed in.
---
.../plugins/LamNI/gui_tools.py | 44 +++++++++++++++++++
.../bec_ipython_client/plugins/LamNI/lamni.py | 37 +++++++++++++++-
2 files changed, 80 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 f625a26..ca5928b 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/gui_tools.py
@@ -25,6 +25,7 @@ class LamniGuiTools:
self.lamni_window = None
self.text_box = None
self.progressbar = None
+ self.alignment_progressbar = None
self.xeyegui = None
self.pdf_viewer = None
self.idle_text_box = None
@@ -72,6 +73,7 @@ class LamniGuiTools:
if hasattr(self.gui, "lamni"):
self.gui.lamni.delete_all(timeout=self.GUI_RPC_TIMEOUT)
self.progressbar = None
+ self.alignment_progressbar = None
self.text_box = None
self.xeyegui = None
self.pdf_viewer = None
@@ -288,6 +290,48 @@ class LamniGuiTools:
text += f"\n Hook: {hook_description}"
self.progressbar.set_center_label(text)
+ # ------------------------------------------------------------------
+ # Alignment scan progress bar
+ # ------------------------------------------------------------------
+
+ def lamnigui_show_alignment_progress(self):
+ """Open (or raise) a single-ring progress bar for tomo_alignment_scan().
+
+ A separate dock/widget from lamnigui_show_progress() (the real
+ tomogram's 3-ring bar) -- kept distinct since it's backed by its
+ own global var (see LamNI.alignment_scan_progress).
+ """
+ self.lamnigui_show_gui()
+ if self._lamnigui_is_missing("alignment_progressbar"):
+ self.lamnigui_remove_all_docks()
+ self.alignment_progressbar = self.gui.lamni.new(
+ "RingProgressBar", timeout=self.GUI_RPC_TIMEOUT
+ )
+ # Single ring: alignment-scan angle progress (manual update)
+ self.alignment_progressbar.add_ring().set_update("manual")
+
+ self._lamnigui_update_alignment_progress()
+
+ def _lamnigui_update_alignment_progress(self):
+ """Update the alignment-scan progress ring and centre label from
+ self.alignment_scan_progress (see LamNI.alignment_scan_progress)."""
+ if self.alignment_progressbar is None:
+ return
+
+ ring = self.alignment_progressbar.rings[0]
+ total = self.alignment_scan_progress["total_angles"]
+ done = self.alignment_scan_progress["angle_index"]
+ progress = done / total * 100 if total else 0
+ ring.set_value(progress)
+
+ angle = self.alignment_scan_progress.get("angle", 0.0)
+ text = (
+ f"Alignment scan progress:\n"
+ f" Angle {done}/{total}\n"
+ f" Current angle: {angle:.1f} deg"
+ )
+ self.alignment_progressbar.set_center_label(text)
+
if __name__ == "__main__":
from bec_lib.client import BECClient
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index c584804..f2be986 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -109,6 +109,18 @@ class _ProgressProxy:
return self._load()
+class _AlignmentScanProgressProxy(_ProgressProxy):
+ """Same dict-proxy pattern as _ProgressProxy, but for tomo_alignment_scan()'s
+ own progress (angle N/12) -- kept in its own global var, deliberately
+ separate from tomo_progress, so anything watching tomo_progress for the
+ real tomogram (e.g. heartbeat/idle-time tracking) never sees alignment
+ scan writes mixed in.
+ """
+
+ _GLOBAL_VAR_KEY = "alignment_scan_progress"
+ _DEFAULTS: dict = {"angle_index": 0, "total_angles": 12, "angle": 0.0}
+
+
class LamNIError(Exception):
"""A definite, non-transient tomo-scan failure (bad config, unmet
precondition, ...) that should never be retried by @scan_repeat."""
@@ -224,6 +236,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
# tomo_scan()); use tomo_progress_reset() to explicitly clear stale
# progress without starting a new scan.
self._progress_proxy = _ProgressProxy(self.client)
+ self._alignment_scan_progress_proxy = _AlignmentScanProgressProxy(self.client)
self._init_tomo_queue()
from csaxs_bec.bec_ipython_client.plugins.LamNI.LamNI_webpage_generator import (
@@ -392,6 +405,21 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
self._progress_proxy.reset()
print("Tomo progress reset.")
+ @property
+ def alignment_scan_progress(self) -> _AlignmentScanProgressProxy:
+ """Proxy dict backed by the BEC global variable ``alignment_scan_progress``.
+
+ Tracks tomo_alignment_scan()'s own progress (angle N/total) --
+ deliberately separate from ``progress``/``tomo_progress`` (the real
+ tomogram's state), so the two can never be confused by anything
+ watching one or the other.
+
+ Readable from any BEC client session via::
+
+ client.get_global_var("alignment_scan_progress")
+ """
+ return self._alignment_scan_progress_proxy
+
@staticmethod
def _format_duration(seconds: float) -> str:
"""Format a duration in seconds as a human-readable string, e.g. '2h 03m 15s'."""
@@ -1222,7 +1250,11 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
angles = list(np.linspace(0, 360, num=12, endpoint=False))
alignment_scan_numbers = []
- for angle in angles:
+ self.alignment_scan_progress.reset()
+ self.alignment_scan_progress.update(total_angles=len(angles), angle_index=0, angle=angles[0])
+ self.lamnigui_show_alignment_progress()
+
+ for idx, angle in enumerate(angles):
successful = False
print(f"Starting LamNI scan for angle {angle}")
while not successful:
@@ -1242,6 +1274,9 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
successful = True
+ self.alignment_scan_progress.update(angle_index=idx + 1, angle=angle)
+ self._lamnigui_update_alignment_progress()
+
umv(dev.lsamrot, 0)
self.OMNYTools.printgreenbold(
"\n\nAlignment scan finished. Please run BEC_ptycho_align and load the new fit"
--
2.54.0
From cdc20457f1f05d15d90953455eccc3849dc36274 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 16:15:04 +0200
Subject: [PATCH 09/29] feat(OMNY): upload tomo PDF report to the samples web
folder
Replaces the old, already-broken upload_last_pon.sh shell-out (and
assorted other dead/abandoned upload attempts left commented out in
flomni.py) with a direct HTTP upload: POSTs the just-written PDF,
base64-encoded, to omny-test.psi.ch/samples/upload.php with
filename="new.pdf" so the server assigns the number itself from its
own counter file -- the same counter newmeasurement.php
(TomoIDManager.register(), called via add_sample_database() just
before write_pdf_report()) already incremented and returned as
self.tomo_id. Runs in a background thread so a slow/unreachable host
never delays the calling tomo_scan(), and cross-checks the response
against the expected filename to flag a possible race with a
concurrent registration.
Added to TomoQueueMixin (OMNY_shared) rather than duplicated in both
LamNI and Flomni, following add_sample_database()'s existing pattern
of shared, setup-agnostic tomo-id/database logic.
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 9 +--
.../plugins/OMNY_shared/tomo_queue_mixin.py | 77 +++++++++++++++++++
.../plugins/flomni/flomni.py | 14 +---
3 files changed, 83 insertions(+), 17 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index f2be986..576bccf 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -2,7 +2,6 @@ import builtins
import datetime
import json
import os
-import subprocess
import time
from pathlib import Path
@@ -2124,11 +2123,9 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
file.write(
f"\nAt-each-angle hook source ('{self.at_each_angle_hook}'):\n{hook_source}"
)
- # upload_last_pon.sh no longer works and needs a rewrite -- disabled
- # for now (mirrors Flomni, which already has this commented out).
- # subprocess.run(
- # "xterm /work/sls/spec/local/XOMNY/bin/upload/upload_last_pon.sh &", shell=True
- # )
+ # Replaces the old upload_last_pon.sh script (broken, never rewritten --
+ # see git history) with a direct HTTP upload to the samples web folder.
+ self._upload_pdf_report_to_samples(user_target)
# 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.
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 f351f5d..f91f71a 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
@@ -39,6 +39,7 @@ import builtins
import datetime
import inspect
import json
+import threading
import uuid
from typing import Callable
@@ -390,6 +391,82 @@ class TomoQueueMixin:
user=user,
)
+ # Only omny-test.psi.ch, not v1p0zyg2w9n2k9c1.myfritz.net (the host
+ # TomoIDManager.OMNY_URL registers measurements against, and the
+ # webpage generators mirror their own content to): only
+ # omny-test.psi.ch has the sample counter that actually matches
+ # self.tomo_id.
+ _SAMPLES_UPLOAD_HOSTS = ("https://omny-test.psi.ch",)
+
+ def _upload_pdf_report_to_samples(self, pdf_path: str) -> None:
+ """Upload a just-written PDF report to the OMNY samples web folder
+ (see _SAMPLES_UPLOAD_HOSTS). Identical for every setup that uses
+ ``self.tomo_id`` (set via add_sample_database() just before
+ write_pdf_report() calls this) -- shared here so LamNI/Flomni don't
+ each carry their own copy. Replaces the old, broken
+ upload_last_pon.sh script both used to shell out to.
+
+ POSTs to /samples/upload.php with filename="new.pdf" so the
+ server assigns the number itself from its own counter file
+ (countersaver.txt) -- the same counter newmeasurement.php
+ (TomoIDManager.register()) already incremented and returned as
+ self.tomo_id. Any other filename would be saved under samples/png/
+ instead of directly in samples/, per upload.php's own branching, so
+ "new.pdf" is the only way to land the PDF at samples/.pdf
+ (matching the link newmeasurement.php writes into
+ the samples listing).
+
+ Runs in a background daemon thread so a slow/unreachable host never
+ delays the calling tomo_scan().
+ """
+
+ def _run():
+ try:
+ import base64
+
+ import requests
+ import urllib3
+
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+ except ImportError:
+ print("Could not upload PDF report: 'requests' library not installed.")
+ return
+
+ try:
+ with open(pdf_path, "rb") as f:
+ filedata = base64.b64encode(f.read()).decode("ascii")
+ except OSError as exc:
+ print(f"Could not read PDF report for upload ({pdf_path}): {exc}")
+ return
+
+ expected_name = f"{self.tomo_id}.pdf"
+ for host in self._SAMPLES_UPLOAD_HOSTS:
+ url = f"{host}/samples/upload.php"
+ try:
+ r = requests.post(
+ url,
+ data={"filename": "new.pdf", "filedata": filedata},
+ timeout=20,
+ verify=False, # accept self-signed certs
+ allow_redirects=False, # SSRF hardening
+ )
+ if r.status_code != 200:
+ print(f"PDF upload to {url} -> HTTP {r.status_code}: {r.text[:120]}")
+ continue
+ if expected_name not in r.text:
+ print(
+ f"PDF upload to {url} succeeded but the server-assigned "
+ f"filename doesn't match tomo_id {self.tomo_id} (response: "
+ f"{r.text[:120]!r}) -- a concurrent measurement "
+ "registration may have raced this upload."
+ )
+ else:
+ print(f"Uploaded PDF report to {url} as {expected_name}.")
+ except Exception as exc:
+ print(f"PDF upload to {url} failed: {exc}")
+
+ threading.Thread(target=_run, name="PdfUpload", daemon=True).start()
+
# ── 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 cdd3958..bf66e7a 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -3,7 +3,6 @@ import datetime
import json
import os
import random
-import subprocess
import time
from pathlib import Path
@@ -3817,16 +3816,9 @@ class Flomni(
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
- # )
- # status = subprocess.run(f"cp /tmp/spec-e20131-specES1.pdf {user_target}", shell=True)
- # msg = bec.tomo_progress.tomo_progressMessage()
- # 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(
- # ["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "flOMNI", self.sample_name]
- # )
- # self.client.tomo_progress.send_tomo_progress_message("~/data/raw/documentation/tomo_scan_ID_{self.tomo_id}.pdf").send()
+ # Replaces the old upload_last_pon.sh script (broken, never rewritten)
+ # with a direct HTTP upload to the samples web folder.
+ self._upload_pdf_report_to_samples(user_target)
import csaxs_bec
# Ensure this is a Path object, not a string
--
2.54.0
From 297b0dd7d371ef7221d17dcf132a2c9bf2399a89 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 16:15:15 +0200
Subject: [PATCH 10/29] fix(flomni): stop calling write_pdf_report() twice per
scan start
tomo_scan()'s new-scan branch called write_pdf_report() once inside
the "real e-account" if-branch and again unconditionally right after,
so every scan start under a real account wrote the PDF, sent the
scilog entry, and (as of the previous commit) uploaded to the samples
folder twice. Keep only the unconditional call, matching LamNI's
equivalent structure.
---
csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
index bf66e7a..f416cca 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -2529,7 +2529,6 @@ class Flomni(
"test additional info",
"BEC",
)
- self.write_pdf_report()
else:
self.tomo_id = 0
self.write_pdf_report()
--
2.54.0
From 9fc0770055044022fc74dfa0eee6c006cbb0a573 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 16:58:11 +0200
Subject: [PATCH 11/29] feat(LamNI): invalidate X-ray-eye/fine alignment on a
new rotation centre
Re-running rotation-center calibration after X-ray-eye alignment (or
fine alignment) was already done left the stale fit/correction in
place, silently calibrated around the old center. Record
lamni_center_found_at and cascade-invalidate via the existing
reset_xray_eye_correction()/reset_correction() whenever a new center
is actually applied, in both find_rotation_center() and
find_rotation_center_smear_experimental().
---
.../plugins/LamNI/x_ray_eye_align.py | 23 +++++++++++++++++++
1 file changed, 23 insertions(+)
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 c460077..0b1b41a 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
@@ -961,6 +961,7 @@ class XrayEyeAlign:
if answer in ("", "y", "yes"):
dev.lsamx.update_user_parameter({"center": float(new_lsamx)})
dev.lsamy.update_user_parameter({"center": float(new_lsamy)})
+ self._mark_center_found_and_invalidate_downstream()
print(
f"[rotation-center][smear] lsamx.user_parameter['center'] = "
f"{dev.lsamx.user_parameter.get('center')}, "
@@ -1116,6 +1117,27 @@ class XrayEyeAlign:
f"interferometer rtx/rty now read ({rtx_after:.2f}, {rty_after:.2f}) um"
)
+ def _mark_center_found_and_invalidate_downstream(self):
+ """Record that the rotation centre was just (re-)established, and
+ invalidate any X-ray-eye/fine-alignment state calibrated around the
+ previous centre.
+
+ Called right after a new lsamx/lsamy centre is actually applied (by
+ both find_rotation_center() and find_rotation_center_smear_experimental()).
+ Reuses the LamNI alignment mixin's own reset methods rather than
+ introducing separate invalidation logic -- see
+ lamni.xrayeye_alignment_start()/tomo_alignment_scan()/tomo_scan(),
+ which gate on lamni_center_found_at / tomo_fit_xray_eye / corr_pos_x
+ respectively.
+ """
+ import datetime
+
+ self.client.set_global_var(
+ "lamni_center_found_at", datetime.datetime.now().isoformat()
+ )
+ self.lamni.reset_xray_eye_correction()
+ self.lamni.reset_correction()
+
def find_rotation_center(
self, sample_type: str = "isolated", keep_shutter_open: bool = False, apply: bool = True
):
@@ -1271,6 +1293,7 @@ class XrayEyeAlign:
if answer in ("", "y", "yes"):
dev.lsamx.update_user_parameter({"center": float(new_lsamx)})
dev.lsamy.update_user_parameter({"center": float(new_lsamy)})
+ self._mark_center_found_and_invalidate_downstream()
print(
f"[rotation-center] lsamx.user_parameter['center'] = "
f"{dev.lsamx.user_parameter.get('center')}, "
--
2.54.0
From e911f8d5e35d32999398f8844ce3cbf17ae5a04e Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 16:58:21 +0200
Subject: [PATCH 12/29] feat(LamNI): warn (overridable) when alignment steps
are stale or missing
Nothing previously enforced the intended sequence: rotation-center
calibration -> X-ray-eye alignment -> fine alignment scan -> real
tomogram. Add three soft gates -- xrayeye_alignment_start() (no center
found yet), tomo_alignment_scan() (X-ray-eye fit missing/invalidated,
softened from a hard abort), and tomo_scan() (no fine alignment
loaded) -- each printing a specific warning and prompting to continue,
via a new shared _confirm_sequence_override() helper. Not a hard
block: each method also gets force: bool = False to skip the check
entirely for cases that legitimately don't need it (e.g. a large FOV
that doesn't need fine alignment).
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 70 +++++++++++++++++--
1 file changed, 65 insertions(+), 5 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 576bccf..020b33b 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -435,7 +435,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
# X-ray eye alignment entry points
# ------------------------------------------------------------------
- def xrayeye_alignment_start(self, keep_shutter_open: bool = False):
+ def xrayeye_alignment_start(self, keep_shutter_open: bool = False, force: bool = False):
"""Run the BEC GUI-based X-ray eye alignment procedure.
Creates a fresh :class:`XrayEyeAlignGUI` instance, which resets the
@@ -445,7 +445,19 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
Args:
keep_shutter_open: If True the shutter is left open between angle
steps so the sample remains visible in live view.
+ force: skip the rotation-center-calibration check below without
+ prompting.
"""
+ if self.client.get_global_var("lamni_center_found_at") is None:
+ if not self._confirm_sequence_override(
+ "No rotation-center calibration has been recorded yet "
+ "(xrayeye_rotation_center_calibration_isolated/extended/"
+ "smear_experimental()). X-ray-eye alignment is normally done "
+ "after finding the rotation centre.",
+ force,
+ ):
+ print("Aborting X-ray eye alignment.")
+ return
aligner = XrayEyeAlignGUI(self.client, self)
try:
aligner.align(keep_shutter_open=keep_shutter_open)
@@ -1223,7 +1235,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
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:
+ def tomo_alignment_scan(self, force: bool = False) -> 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 --
@@ -1234,10 +1246,19 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
~/data/raw/logs/ptychotomoalign_scannum.txt for BEC_ptycho_align,
prints them, and creates a scilog entry summarising the alignment
scan numbers.
+
+ Args:
+ force: skip the X-ray-eye-alignment check below without prompting.
"""
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
+ if not self._confirm_sequence_override(
+ "No X-ray-eye alignment fit is loaded (or it was invalidated "
+ "by a rotation-center calibration run since). The alignment "
+ "scan's per-angle offsets would then all be zero.",
+ force,
+ ):
+ print("Aborting alignment scan.")
+ return
bec = builtins.__dict__.get("bec")
dev = builtins.__dict__.get("dev")
@@ -1609,13 +1630,16 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
)
return angle, subtomo_number
- def tomo_scan(self, subtomo_start=1, start_angle=None, projection_number=None):
+ def tomo_scan(
+ self, subtomo_start=1, start_angle=None, projection_number=None, force: bool = False
+ ):
"""Start a tomo scan.
Args:
subtomo_start (int): For tomo_type 1, the sub-tomogram to start from. Defaults to 1.
start_angle (float, optional): Override starting angle of the first sub-tomogram.
projection_number (int, optional): For tomo_types 2 and 3, resume from this index.
+ force: skip the fine-alignment check below without prompting.
"""
self.lamnigui_show_progress()
@@ -1632,6 +1656,17 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
or (self.tomo_type == 2 and projection_number is None)
or (self.tomo_type == 3 and projection_number is None)
):
+ if not self.corr_pos_x:
+ if not self._confirm_sequence_override(
+ "No fine (ptycho) alignment correction is loaded -- the "
+ "sample centre will drift across projection angles "
+ "uncorrected. Fine for a large FOV that doesn't need it; "
+ "otherwise run tomo_alignment_scan() and "
+ "read_additional_correction() first.",
+ force,
+ ):
+ print("Aborting tomo scan.")
+ return
# bec.active_account is already a plain str, not bytes -- .decode()
# crashes with AttributeError. Also guard against no active
# e-account (empty string, e.g. a dev/sim session not logged into
@@ -2061,6 +2096,31 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
def _get_val(msg: str, default_value, data_type):
return data_type(input(f"{msg} ({default_value}): ") or default_value)
+ @staticmethod
+ def _confirm_sequence_override(warning: str, force: bool) -> bool:
+ """Print *warning* and ask whether to proceed anyway.
+
+ Used by the alignment-sequence gates (xrayeye_alignment_start(),
+ tomo_alignment_scan(), tomo_scan()) -- these check whether the
+ expected prior step (rotation-center calibration / X-ray-eye
+ alignment / fine alignment) is still valid, but never hard-block:
+ an operator can always choose to continue, or pass force=True to
+ skip the prompt entirely (needed for non-interactive/queued use,
+ e.g. tomo-queue command jobs, where input() isn't viable).
+
+ Unlike most confirmation prompts in this codebase (which default to
+ "yes" on Enter), this defaults to "no" -- skipping a real sequence
+ check should be a deliberate choice, not an accidental Enter.
+
+ Returns:
+ bool: True if the caller should proceed.
+ """
+ if force:
+ return True
+ print(warning)
+ answer = input("Continue anyway? [y/N]: ").strip().lower()
+ return answer in ("y", "yes")
+
# ------------------------------------------------------------------
# PDF report
# ------------------------------------------------------------------
--
2.54.0
From b95c284de91033a5bedbdd740a668a296dff130d Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 16:58:33 +0200
Subject: [PATCH 13/29] feat(OMNY): register test-account measurements against
the test server
TomoIDManager.register() used to skip OMNY registration entirely for
any non-e-account (e.g. test accounts), always returning the tomo ID
0 fallback. Register against a new TEST_OMNY_URL (omny-test.psi.ch)
instead for those accounts, so testing still gets a real, incrementing
tomo ID matching the counter the samples-folder PDF upload reads from
on that same host -- accepting a mismatched eaccount in that test
database. FALLBACK_TOMO_ID is now reserved for genuine failures
(server unreachable), not merely a non-standard account name.
---
.../plugins/omny/omny_general_tools.py | 30 ++++++++++++-------
1 file changed, 20 insertions(+), 10 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py
index 78e9892..74b03af 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py
@@ -329,8 +329,12 @@ class TomoIDManager:
"""Registers a tomography measurement in the OMNY sample database
and returns its assigned tomo ID.
- Falls back to tomo ID 0 for non-production accounts (e.g. test
- accounts like "gac-x01dc") which the server rejects.
+ Non-production accounts (e.g. test accounts like "gac-x01dc") register
+ against the test server (TEST_OMNY_URL) instead of production, so
+ testing still gets a real, incrementing tomo ID -- matching the
+ counter the samples-folder PDF upload reads from on that same test
+ host -- accepting that the eaccount recorded in that test database
+ won't be a real e-account.
Usage:
id_manager = TomoIDManager()
@@ -346,6 +350,7 @@ class TomoIDManager:
"""
OMNY_URL = "https://v1p0zyg2w9n2k9c1.myfritz.net/samples/newmeasurement.php"
+ TEST_OMNY_URL = "https://omny-test.psi.ch/samples/newmeasurement.php"
TMP_FILE = "~/currsamplesnr.txt"
FALLBACK_TOMO_ID = 0
@@ -366,18 +371,23 @@ class TomoIDManager:
) -> int:
"""Register a new measurement and return the assigned tomo ID.
- Returns FALLBACK_TOMO_ID (0) if the account is not a real e-account
- or if the server cannot be reached / returns an unusable response.
+ Registers against OMNY_URL (production) for a real e-account, or
+ TEST_OMNY_URL (test server) otherwise. Returns FALLBACK_TOMO_ID (0)
+ only if the server actually can't be reached / returns an unusable
+ response.
"""
- if not self._is_valid_eaccount(eaccount):
+ if self._is_valid_eaccount(eaccount):
+ omny_url = self.OMNY_URL
+ else:
+ omny_url = self.TEST_OMNY_URL
logger.warning(
- f"Account '{eaccount}' is not a valid e-account; "
- f"skipping OMNY registration, using tomo ID {self.FALLBACK_TOMO_ID}."
+ f"Account '{eaccount}' is not a valid e-account; registering "
+ f"against the test server ({self.TEST_OMNY_URL}) instead of "
+ "production -- the eaccount recorded there won't be real."
)
- return self.FALLBACK_TOMO_ID
url = (
- f"{self.OMNY_URL}"
+ f"{omny_url}"
f"?sample={sample_name}"
f"&date={date}"
f"&eaccount={eaccount}"
@@ -392,7 +402,7 @@ class TomoIDManager:
result = subprocess.run(f"wget -q -O {tmp_file} '{url}'", shell=True, timeout=30)
if result.returncode != 0:
raise OMNYToolsError(
- f"wget failed (exit code {result.returncode}) fetching tomo ID from {self.OMNY_URL}"
+ f"wget failed (exit code {result.returncode}) fetching tomo ID from {omny_url}"
)
with open(tmp_file) as f:
content = f.read().strip()
--
2.54.0
From fe47059f880064c195d6f89bcae07a7b444439b6 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 16:58:43 +0200
Subject: [PATCH 14/29] fix(OMNY): skip PDF upload without a real tomo_id,
widen error logging
tomo_id == FALLBACK_TOMO_ID (0) means add_sample_database() never
actually registered a measurement server-side, so there's no reserved
"new.pdf" slot -- uploading anyway would silently claim whatever
number countersaver.txt currently holds, i.e. an unrelated real
measurement's slot. Skip with a clear message instead. Also widen the
truncated response text in the upload warning/log messages (120 -> 400
chars), which had cut off the file+line a real PHP error would show,
making a live parse-error response undiagnosable from the client log
alone (as happened while testing this).
---
.../plugins/OMNY_shared/tomo_queue_mixin.py | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 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 f91f71a..e3b7cb1 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
@@ -421,6 +421,20 @@ class TomoQueueMixin:
"""
def _run():
+ # tomo_id_manager.FALLBACK_TOMO_ID (0) means add_sample_database()
+ # never actually registered anything server-side (no valid
+ # e-account) -- there's no real "new.pdf" slot reserved for this
+ # session, so uploading would just claim whatever number
+ # countersaver.txt currently happens to hold, i.e. some unrelated
+ # real measurement's slot. Skip rather than risk clobbering it.
+ if self.tomo_id == self.tomo_id_manager.FALLBACK_TOMO_ID:
+ print(
+ f"Skipping PDF upload: tomo_id={self.tomo_id} means no real "
+ "measurement was registered (not a valid e-account) -- "
+ "there's no samples-folder slot to upload into."
+ )
+ return
+
try:
import base64
@@ -451,13 +465,13 @@ class TomoQueueMixin:
allow_redirects=False, # SSRF hardening
)
if r.status_code != 200:
- print(f"PDF upload to {url} -> HTTP {r.status_code}: {r.text[:120]}")
+ print(f"PDF upload to {url} -> HTTP {r.status_code}: {r.text[:400]}")
continue
if expected_name not in r.text:
print(
f"PDF upload to {url} succeeded but the server-assigned "
f"filename doesn't match tomo_id {self.tomo_id} (response: "
- f"{r.text[:120]!r}) -- a concurrent measurement "
+ f"{r.text[:400]!r}) -- a concurrent measurement "
"registration may have raced this upload."
)
else:
--
2.54.0
From 64f5a57fa1b97b8426fd8176e0afecf000ae27a9 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 17:01:31 +0200
Subject: [PATCH 15/29] feat(OMNY): add OMNYTools.printredbold()
Mirrors the existing printgreenbold() -- needed for the tomo_scan()
unattended-queue warning (bold red instead of bold green).
---
.../bec_ipython_client/plugins/omny/omny_general_tools.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py
index 74b03af..c41d5e6 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py
@@ -68,6 +68,9 @@ class OMNYTools:
def printgreenbold(self, string: str):
print(self.BOLD + self.OKGREEN + string + self.ENDC)
+ def printredbold(self, string: str):
+ print(self.BOLD + self.FAIL + string + self.ENDC)
+
def yesno(self, message: str, default="none", autoconfirm=0) -> bool:
if autoconfirm and default == "y":
self.printgreen(message + " Automatically confirming default: yes")
--
2.54.0
From 1159b018f0e264efdf94e873c9507b818b146596 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 17:01:52 +0200
Subject: [PATCH 16/29] fix(LamNI): don't block the tomo queue on the
fine-alignment check
tomo_queue_execute() runs queued scans unattended, potentially for
hours -- tomo_scan()'s fine-alignment prompt would block forever there
with nobody watching. Add interactive: bool = True; when False (queue
use), the check no longer prompts or aborts -- it prints a bold red
warning, waits 10s, and always proceeds.
Also remove the outer "bec.active_account != ''" short-circuit that
hard-coded tomo_id=0 for an empty account without even attempting
registration -- always call add_sample_database() now and let
TomoIDManager.register() (test-host registration, previous commit)
decide the right outcome instead of pre-empting it here.
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 67 ++++++++++++-------
1 file changed, 42 insertions(+), 25 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 020b33b..4e2bd65 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -1631,7 +1631,12 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
return angle, subtomo_number
def tomo_scan(
- self, subtomo_start=1, start_angle=None, projection_number=None, force: bool = False
+ self,
+ subtomo_start=1,
+ start_angle=None,
+ projection_number=None,
+ force: bool = False,
+ interactive: bool = True,
):
"""Start a tomo scan.
@@ -1639,7 +1644,13 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
subtomo_start (int): For tomo_type 1, the sub-tomogram to start from. Defaults to 1.
start_angle (float, optional): Override starting angle of the first sub-tomogram.
projection_number (int, optional): For tomo_types 2 and 3, resume from this index.
- force: skip the fine-alignment check below without prompting.
+ force: skip the fine-alignment check below entirely (no warning at all).
+ interactive: if True (default, normal CLI use), the fine-alignment
+ check prompts and can abort. If False (used by
+ tomo_queue_execute() for unattended queued runs, where
+ input() would just hang forever with nobody watching), the
+ check instead prints a bold warning, waits 10s, and always
+ proceeds -- it never aborts or raises.
"""
self.lamnigui_show_progress()
@@ -1656,34 +1667,40 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
or (self.tomo_type == 2 and projection_number is None)
or (self.tomo_type == 3 and projection_number is None)
):
- if not self.corr_pos_x:
- if not self._confirm_sequence_override(
+ if not self.corr_pos_x and not force:
+ warning = (
"No fine (ptycho) alignment correction is loaded -- the "
"sample centre will drift across projection angles "
"uncorrected. Fine for a large FOV that doesn't need it; "
"otherwise run tomo_alignment_scan() and "
- "read_additional_correction() first.",
- force,
- ):
- print("Aborting tomo scan.")
- return
- # bec.active_account is already a plain str, not bytes -- .decode()
- # crashes with AttributeError. Also guard against no active
- # e-account (empty string, e.g. a dev/sim session not logged into
- # a real account) rather than trying to register a sample under
- # one -- mirrors Flomni.tomo_scan()'s equivalent check exactly.
- if bec.active_account != "":
- self.tomo_id = self.add_sample_database(
- self.sample_name,
- str(datetime.date.today()),
- bec.active_account,
- bec.queue.next_scan_number,
- "lamni",
- "test additional info",
- "BEC",
+ "read_additional_correction() first."
)
- else:
- self.tomo_id = 0
+ if interactive:
+ if not self._confirm_sequence_override(warning, force=False):
+ print("Aborting tomo scan.")
+ return
+ else:
+ self.OMNYTools.printredbold(f"WARNING: {warning}")
+ self.OMNYTools.printredbold(
+ "Proceeding automatically in 10 s (unattended/queued run)..."
+ )
+ time.sleep(10)
+ # bec.active_account is already a plain str, not bytes -- .decode()
+ # crashes with AttributeError. Always attempt registration (even
+ # for an empty/test account) and let add_sample_database() ->
+ # TomoIDManager.register() decide production vs. test-server vs.
+ # genuine-failure fallback -- this used to short-circuit straight
+ # to tomo_id=0 for an empty account, which also skipped the
+ # test-server registration path entirely.
+ self.tomo_id = self.add_sample_database(
+ self.sample_name,
+ str(datetime.date.today()),
+ bec.active_account or "",
+ bec.queue.next_scan_number,
+ "lamni",
+ "test additional info",
+ "BEC",
+ )
self.write_pdf_report()
self.progress["tomo_start_time"] = datetime.datetime.now().isoformat()
# reset stale estimates from any previous scan, otherwise the GUI
--
2.54.0
From d97692e090edd7f0934aebf995b0e447d22dabf0 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 17:02:05 +0200
Subject: [PATCH 17/29] fix(flomni): always attempt tomo_id registration,
accept interactive kwarg
Mirrors LamNI.tomo_scan()'s same fix: remove the outer
"bec.active_account != ''" short-circuit so an empty/test account still
goes through add_sample_database() -> TomoIDManager.register() (which
now handles test-host registration itself) instead of hard-coding
tomo_id=0. Also accept an unused interactive kwarg for signature
compatibility with LamNI.tomo_scan(), since tomo_queue_execute() (shared
between both setups) calls tomo_scan(interactive=False) uniformly.
---
.../plugins/flomni/flomni.py | 37 ++++++++++++-------
1 file changed, 23 insertions(+), 14 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
index f416cca..9d8c46f 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -2489,8 +2489,16 @@ class Flomni(
for scan_nr in range(start_scan_number, end_scan_number):
self._write_tomo_scan_number(scan_nr, angle, subtomo_number=0)
- def tomo_scan(self, subtomo_start=1, start_angle=None, projection_number=None):
- """start a tomo scan"""
+ def tomo_scan(
+ self, subtomo_start=1, start_angle=None, projection_number=None, interactive: bool = True
+ ):
+ """start a tomo scan
+
+ Args:
+ interactive: accepted for signature compatibility with
+ LamNI.tomo_scan() (tomo_queue_execute() calls both the same
+ way) -- unused here, FlOMNI has no fine-alignment gate.
+ """
if not self._check_eye_out_and_optics_in():
print(
@@ -2519,18 +2527,19 @@ class Flomni(
):
# pylint: disable=undefined-variable
- if bec.active_account != "":
- self.tomo_id = self.add_sample_database(
- self.sample_name,
- str(datetime.date.today()),
- bec.active_account,
- bec.queue.next_scan_number,
- "flomni",
- "test additional info",
- "BEC",
- )
- else:
- self.tomo_id = 0
+ # Always attempt registration (even for an empty/test account)
+ # and let add_sample_database() -> TomoIDManager.register()
+ # decide production vs. test-server vs. genuine-failure fallback
+ # -- see LamNI.tomo_scan()'s equivalent change for why.
+ self.tomo_id = self.add_sample_database(
+ self.sample_name,
+ str(datetime.date.today()),
+ bec.active_account or "",
+ bec.queue.next_scan_number,
+ "flomni",
+ "test additional info",
+ "BEC",
+ )
self.write_pdf_report()
self.progress["tomo_start_time"] = datetime.datetime.now().isoformat()
# reset stale estimates from any previous scan, otherwise the GUI
--
2.54.0
From 3422eedcc17b1449ea1fe64f934dba5c5053f8b3 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 17:02:17 +0200
Subject: [PATCH 18/29] fix(OMNY): call tomo_scan(interactive=False) from
tomo_queue_execute()
Wires the queue-safe fine-alignment warning (previous LamNI/Flomni
commits) into the actual queue dispatch path.
---
.../plugins/OMNY_shared/tomo_queue_mixin.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
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 e3b7cb1..15cac04 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
@@ -868,7 +868,10 @@ class TomoQueueMixin:
if resume_job:
self.tomo_scan_resume()
else:
- self.tomo_scan()
+ # interactive=False: an unattended queued run must
+ # never block on input() -- see LamNI.tomo_scan()'s
+ # fine-alignment check.
+ self.tomo_scan(interactive=False)
except Exception as exc:
self._tomo_queue_proxy.update_by_id(job_id, status="incomplete")
print(f"Tomo queue job '{label}' did not complete: {exc}")
--
2.54.0
From eb61df1c93522715a57006da873309bb1f0960c3 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 17:06:10 +0200
Subject: [PATCH 19/29] fix(OMNY): use requests instead of shelling out to wget
for tomo ID
TomoIDManager.register() called wget via subprocess with shell=True,
interpolating sample_name/eaccount/etc. unescaped into the command
string -- broke outright wherever wget isn't installed (as hit while
testing: "wget: command not found", silently falling back to tomo ID
0), and was a latent shell-injection risk. Use requests.get() with a
params dict instead: no external binary dependency, proper URL
encoding, and the same self-signed-cert/SSRF handling already used by
the samples PDF upload. Drops the now-unused OMNYToolsError class and
TMP_FILE/subprocess, which only existed to support the wget call.
---
.../plugins/omny/omny_general_tools.py | 57 ++++++++++---------
1 file changed, 31 insertions(+), 26 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py
index c41d5e6..8f8de0d 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py
@@ -4,7 +4,6 @@ import fcntl
import json
import os
import socket
-import subprocess
import sys
import termios
import threading
@@ -36,10 +35,6 @@ def umvr(*args):
return scans.umv(*args, relative=True)
-class OMNYToolsError(Exception):
- pass
-
-
class OMNYTools:
HEADER = "\033[95m"
@@ -354,7 +349,6 @@ class TomoIDManager:
OMNY_URL = "https://v1p0zyg2w9n2k9c1.myfritz.net/samples/newmeasurement.php"
TEST_OMNY_URL = "https://omny-test.psi.ch/samples/newmeasurement.php"
- TMP_FILE = "~/currsamplesnr.txt"
FALLBACK_TOMO_ID = 0
@staticmethod
@@ -389,28 +383,39 @@ class TomoIDManager:
"production -- the eaccount recorded there won't be real."
)
- url = (
- f"{omny_url}"
- f"?sample={sample_name}"
- f"&date={date}"
- f"&eaccount={eaccount}"
- f"&scannr={scan_number}"
- f"&setup={setup}"
- f"&additional={additional_info}"
- f"&user={user}"
- )
+ params = {
+ "sample": sample_name,
+ "date": date,
+ "eaccount": eaccount,
+ "scannr": scan_number,
+ "setup": setup,
+ "additional": additional_info,
+ "user": user,
+ }
- tmp_file = os.path.expanduser(self.TMP_FILE)
try:
- result = subprocess.run(f"wget -q -O {tmp_file} '{url}'", shell=True, timeout=30)
- if result.returncode != 0:
- raise OMNYToolsError(
- f"wget failed (exit code {result.returncode}) fetching tomo ID from {omny_url}"
- )
- with open(tmp_file) as f:
- content = f.read().strip()
- return int(content)
- except (subprocess.TimeoutExpired, FileNotFoundError, ValueError, OMNYToolsError) as exc:
+ import requests
+ import urllib3
+
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+ except ImportError as exc:
+ logger.warning(
+ f"Could not obtain tomo ID from OMNY database ('requests' library not "
+ f"installed: {exc}); falling back to tomo ID {self.FALLBACK_TOMO_ID}."
+ )
+ return self.FALLBACK_TOMO_ID
+
+ try:
+ response = requests.get(
+ omny_url,
+ params=params,
+ timeout=30,
+ verify=False, # accept self-signed certs
+ allow_redirects=False, # SSRF hardening
+ )
+ response.raise_for_status()
+ return int(response.text.strip())
+ except (requests.RequestException, ValueError) as exc:
logger.warning(
f"Could not obtain tomo ID from OMNY database ({exc}); "
f"falling back to tomo ID {self.FALLBACK_TOMO_ID}."
--
2.54.0
From f8ced692d3d8a844c07ed464492e3cfc435fd5cb Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 17:13:49 +0200
Subject: [PATCH 20/29] fix(LamNI): use the real setup logo in the PDF report,
fix column gaps
Replace the ASCII-art header with the actual LamNI.png logo, embedded
via PDFWriter's underlying fpdf object (bec_lib's PDFWriter has no
public image API). Also fix the label/value formatting: values were
right-justified in a wide fixed field, leaving a big ragged gap after
short labels -- left-justify both instead for a clean, tight
"label: value" layout.
Also fixes a real bug found along the way: the scilog attachment
referenced "LamNI_logo.png", a file that has never existed (the actual
file is LamNI.png) -- the resulting FileNotFoundError was swallowed by
write_pdf_report()'s generic try/except, so the scilog message has
been silently failing to send every time. Now uses the one correct,
shared logo_path for both the PDF and the scilog attachment.
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 66 ++++++++++---------
1 file changed, 35 insertions(+), 31 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 4e2bd65..9f332e9 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -2145,17 +2145,15 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
def write_pdf_report(self):
"""Create and write the PDF report with current LamNI settings."""
dev = builtins.__dict__.get("dev")
- header = (
- " \n" * 3
- + " ::: ::: ::: ::: :::: ::: ::::::::::: \n"
- + " :+: :+: :+: :+:+: :+:+: :+:+: :+: :+: \n"
- + " +:+ +:+ +:+ +:+ +:+:+ +:+ :+:+:+ +:+ +:+ \n"
- + " +#+ +#++:++#++: +#+ +:+ +#+ +#+ +:+ +#+ +#+ \n"
- + " +#+ +#+ +#+ +#+ +#+ +#+ +#+#+# +#+ \n"
- + " #+# #+# #+# #+# #+# #+# #+#+# #+# \n"
- + " ########## ### ### ### ### ### #### ########### \n"
- )
- padding = 20
+ # LamNI.png (not the previously-referenced, nonexistent
+ # "LamNI_logo.png" -- that typo silently broke the scilog logo
+ # attachment below, since the resulting FileNotFoundError was caught
+ # by the generic try/except and never surfaced).
+ logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "LamNI.png")
+ # Widest label below ("Number of individual sub-tomograms:") is 36
+ # chars; left-justify both label and value (no right-justify) so
+ # short values don't leave a big ragged gap after the label.
+ padding = 38
piezo_range = f"{self.lamni_piezo_range_x:.2f}/{self.lamni_piezo_range_y:.2f}"
stitching = f"{self.lamni_stitch_x:.2f}/{self.lamni_stitch_y:.2f}"
dataset_id = str(self.client.queue.next_dataset_number)
@@ -2169,32 +2167,39 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
except Exception:
energy_str = "N/A"
content = [
- f"{'Sample Name:':<{padding}}{self.sample_name:>{padding}}\n",
- f"{'Measurement ID:':<{padding}}{str(self.tomo_id):>{padding}}\n",
- f"{'Dataset ID:':<{padding}}{dataset_id:>{padding}}\n",
- f"{'Sample Info:':<{padding}}{'Sample Info':>{padding}}\n",
- f"{'e-account:':<{padding}}{str(self.client.username):>{padding}}\n",
- f"{'Number of projections:':<{padding}}{report_total_projections:>{padding}}\n",
- f"{'First scan number:':<{padding}}{self.client.queue.next_scan_number:>{padding}}\n",
- f"{'Last scan number approx.:':<{padding}}{self.client.queue.next_scan_number + report_total_projections + 10:>{padding}}\n",
- f"{'Current photon energy:':<{padding}}{energy_str:>{padding}}\n",
- f"{'Exposure time:':<{padding}}{self.tomo_countingtime:>{padding}.2f}\n",
- f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:>{padding}.2f}\n",
- f"{'Piezo range (FOV sample plane):':<{padding}}{piezo_range:>{padding}}\n",
- f"{'Restriction to circular FOV:':<{padding}}{self.tomo_circfov:>{padding}.2f}\n",
- f"{'Stitching:':<{padding}}{stitching:>{padding}}\n",
- f"{'Number of individual sub-tomograms:':<{padding}}{8:>{padding}}\n",
- f"{'Angular step within sub-tomogram:':<{padding}}{self.tomo_angle_stepsize:>{padding}.2f}\n",
- f"{'Tomo type:':<{padding}}{self.tomo_type:>{padding}}\n",
+ f"{'Sample Name:':<{padding}}{self.sample_name}\n",
+ f"{'Measurement ID:':<{padding}}{self.tomo_id}\n",
+ f"{'Dataset ID:':<{padding}}{dataset_id}\n",
+ f"{'Sample Info:':<{padding}}Sample Info\n",
+ f"{'e-account:':<{padding}}{self.client.username}\n",
+ f"{'Number of projections:':<{padding}}{report_total_projections}\n",
+ f"{'First scan number:':<{padding}}{self.client.queue.next_scan_number}\n",
+ f"{'Last scan number approx.:':<{padding}}"
+ f"{self.client.queue.next_scan_number + report_total_projections + 10}\n",
+ f"{'Current photon energy:':<{padding}}{energy_str}\n",
+ f"{'Exposure time:':<{padding}}{self.tomo_countingtime:.2f}\n",
+ f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:.2f}\n",
+ f"{'Piezo range (FOV sample plane):':<{padding}}{piezo_range}\n",
+ f"{'Restriction to circular FOV:':<{padding}}{self.tomo_circfov:.2f}\n",
+ f"{'Stitching:':<{padding}}{stitching}\n",
+ f"{'Number of individual sub-tomograms:':<{padding}}8\n",
+ f"{'Angular step within sub-tomogram:':<{padding}}{self.tomo_angle_stepsize:.2f}\n",
+ f"{'Tomo type:':<{padding}}{self.tomo_type}\n",
]
hook_description = self._describe_active_hook()
if hook_description:
- content.append(f"{'At-each-angle hook:':<{padding}}{hook_description:>{padding}}\n")
+ content.append(f"{'At-each-angle hook:':<{padding}}{hook_description}\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)
+ # PDFWriter (bec_lib) has no public image API -- reach into its
+ # underlying fpdf object directly. logo_w chosen to keep the
+ # header modest relative to the A4 page width (210mm).
+ if os.path.exists(logo_path):
+ logo_w = 50
+ file._pdf.image(logo_path, x=(210 - logo_w) / 2, w=logo_w)
+ file._pdf.ln(5)
file.write(content)
if hook_source:
file.write(
@@ -2213,7 +2218,6 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
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(scilog_text.replace("\n", "
")).add_tag(
["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "LamNI", self.sample_name]
)
--
2.54.0
From 97ea67b01a0fefe24a3a69a0fc80e1a54dbfe632 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 17:13:57 +0200
Subject: [PATCH 21/29] fix(flomni): use the real setup logo in the PDF report,
fix column gaps
Mirrors LamNI.write_pdf_report()'s same fix: replace the ASCII-art
header with the actual flOMNI.png logo (already correctly resolved
for the scilog attachment, just never used in the PDF itself),
embedded via PDFWriter's underlying fpdf object. Also left-justify
values instead of right-justifying them in a wide fixed field, to
remove the big ragged gap after short labels. Drops a leftover debug
print(logo_file).
---
.../plugins/flomni/flomni.py | 78 +++++++++----------
1 file changed, 37 insertions(+), 41 deletions(-)
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
index 9d8c46f..ff7cb1c 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -3770,19 +3770,19 @@ class Flomni(
def write_pdf_report(self):
"""create and write the pdf report with the current flomni settings"""
dev = builtins.__dict__.get("dev")
- # header = ""
- header = (
- " \n" * 3
- + " .d888 888 .d88888b. 888b d888 888b 888 8888888 \n"
- + ' d88P" 888 d88P" "Y88b 8888b d8888 8888b 888 888 \n'
- + " 888 888 888 888 88888b.d88888 88888b 888 888 \n"
- + " 888888 888 888 888 888Y88888P888 888Y88b 888 888 \n"
- + " 888 888 888 888 888 Y888P 888 888 Y88b888 888 \n"
- + " 888 888 888 888 888 Y8P 888 888 Y88888 888 \n"
- + ' 888 888 Y88b. .d88P 888 " 888 888 Y8888 888 \n'
- + ' 888 888 "Y88888P" 888 888 888 Y888 8888888 \n'
- )
- padding = 20
+ import csaxs_bec
+
+ # Ensure this is a Path object, not a string
+ csaxs_bec_basepath = Path(csaxs_bec.__file__)
+ logo_file_rel = "flOMNI.png"
+ # Build the absolute path correctly
+ logo_file = (
+ csaxs_bec_basepath.parent / "bec_ipython_client" / "plugins" / "flomni" / logo_file_rel
+ ).resolve()
+ # Widest label below ("Number of individual sub-tomograms:") is 36
+ # chars; left-justify both label and value (no right-justify) so
+ # short values don't leave a big ragged gap after the label.
+ padding = 38
fovxy = f"{self.fovx:.1f}/{self.fovy:.1f}"
stitching = f"{self.stitch_x:.0f}/{self.stitch_y:.0f}"
dataset_id = str(self.client.queue.next_dataset_number)
@@ -3792,33 +3792,40 @@ class Flomni(
# recompute int((tomo_angle_range/tomo_angle_stepsize)*8) locally.
_, _, tomo_type1_total_projections = self._tomo_type1_actual_grid()
content = [
- f"{'Sample Name:':<{padding}}{self.sample_name:>{padding}}\n",
- f"{'Measurement ID:':<{padding}}{str(self.tomo_id):>{padding}}\n",
- f"{'Dataset ID:':<{padding}}{dataset_id:>{padding}}\n",
- f"{'Sample Info:':<{padding}}{'Sample Info':>{padding}}\n",
- f"{'e-account:':<{padding}}{str(account):>{padding}}\n",
- f"{'Number of projections:':<{padding}}{tomo_type1_total_projections:>{padding}}\n",
- f"{'First scan number:':<{padding}}{self.client.queue.next_scan_number:>{padding}}\n",
- f"{'Last scan number approx.:':<{padding}}{self.client.queue.next_scan_number + tomo_type1_total_projections + 10:>{padding}}\n",
+ f"{'Sample Name:':<{padding}}{self.sample_name}\n",
+ f"{'Measurement ID:':<{padding}}{self.tomo_id}\n",
+ f"{'Dataset ID:':<{padding}}{dataset_id}\n",
+ f"{'Sample Info:':<{padding}}Sample Info\n",
+ f"{'e-account:':<{padding}}{account}\n",
+ f"{'Number of projections:':<{padding}}{tomo_type1_total_projections}\n",
+ f"{'First scan number:':<{padding}}{self.client.queue.next_scan_number}\n",
+ f"{'Last scan number approx.:':<{padding}}"
+ f"{self.client.queue.next_scan_number + tomo_type1_total_projections + 10}\n",
f"{'Current photon energy:':<{padding}}To be implemented\n",
- # f"{'Current photon energy:':<{padding}}{dev.mokev.read()['mokev']['value']:>{padding}.4f}\n",
- f"{'Exposure time:':<{padding}}{self.tomo_countingtime:>{padding}.2f}\n",
- f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:>{padding}.2f}\n",
- f"{'FOV:':<{padding}}{fovxy:>{padding}}\n",
- f"{'Stitching:':<{padding}}{stitching:>{padding}}\n",
- f"{'Number of individual sub-tomograms:':<{padding}}{8:>{padding}}\n",
- f"{'Angular step within sub-tomogram:':<{padding}}{self.tomo_angle_stepsize:>{padding}.2f}\n",
+ # f"{'Current photon energy:':<{padding}}{dev.mokev.read()['mokev']['value']:.4f}\n",
+ f"{'Exposure time:':<{padding}}{self.tomo_countingtime:.2f}\n",
+ f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:.2f}\n",
+ f"{'FOV:':<{padding}}{fovxy}\n",
+ f"{'Stitching:':<{padding}}{stitching}\n",
+ f"{'Number of individual sub-tomograms:':<{padding}}8\n",
+ f"{'Angular step within sub-tomogram:':<{padding}}{self.tomo_angle_stepsize:.2f}\n",
]
hook_description = self._describe_active_hook()
if hook_description:
- content.append(f"{'At-each-angle hook:':<{padding}}{hook_description:>{padding}}\n")
+ content.append(f"{'At-each-angle hook:':<{padding}}{hook_description}\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)
+ # PDFWriter (bec_lib) has no public image API -- reach into its
+ # underlying fpdf object directly. logo_w chosen to keep the
+ # header modest relative to the A4 page width (210mm).
+ if logo_file.exists():
+ logo_w = 50
+ file._pdf.image(str(logo_file), x=(210 - logo_w) / 2, w=logo_w)
+ file._pdf.ln(5)
file.write(content)
if hook_source:
file.write(
@@ -3827,18 +3834,7 @@ class Flomni(
# Replaces the old upload_last_pon.sh script (broken, never rewritten)
# with a direct HTTP upload to the samples web folder.
self._upload_pdf_report_to_samples(user_target)
- import csaxs_bec
- # Ensure this is a Path object, not a string
- csaxs_bec_basepath = Path(csaxs_bec.__file__)
-
- logo_file_rel = "flOMNI.png"
-
- # Build the absolute path correctly
- logo_file = (
- csaxs_bec_basepath.parent / "bec_ipython_client" / "plugins" / "flomni" / logo_file_rel
- ).resolve()
- print(logo_file)
scilog = getattr(bec.messaging, "scilog", None)
if scilog is None or not getattr(scilog, "_enabled", False):
logger.warning("SciLog is not enabled; skipping PDF report entry.")
--
2.54.0
From 6954fbb7f469c0cc719408a73f4b2f2b1396becf Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 17:19:22 +0200
Subject: [PATCH 22/29] refactor(OMNY): move omny_general_tools.py into
OMNY_shared
OMNYTools, PtychoReconstructor, and TomoIDManager were sitting under
the omny plugin's own directory, but are imported by LamNI, FlOMNI,
and cSAXS too -- none of the three classes reference anything
OMNY-specific (device names are always passed in as parameters).
OMNY_shared already exists for exactly this kind of cross-setup code
(tomo_queue_mixin.py, web_common.py, webpage_generator_base.py), so
move it there and update all five import sites. Pure relocation, no
behavior change.
---
csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py | 2 +-
.../bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py | 2 +-
.../plugins/{omny => OMNY_shared}/omny_general_tools.py | 0
csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py | 2 +-
csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py | 2 +-
csaxs_bec/bec_ipython_client/plugins/omny/omny.py | 2 +-
6 files changed, 5 insertions(+), 5 deletions(-)
rename csaxs_bec/bec_ipython_client/plugins/{omny => OMNY_shared}/omny_general_tools.py (100%)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 9f332e9..a3d065e 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -12,7 +12,7 @@ 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 (
+from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import (
OMNYTools,
PtychoReconstructor,
TomoIDManager,
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 5cbf41b..a7f8683 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
@@ -7,7 +7,7 @@ from rich.console import Console
from rich.table import Table
from csaxs_bec.bec_ipython_client.plugins.cSAXS import epics_put
-from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import OMNYTools
+from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import OMNYTools
dev = builtins.__dict__.get("dev")
bec = builtins.__dict__.get("bec")
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py
similarity index 100%
rename from csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py
rename to csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py
diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py
index 42325de..da74795 100644
--- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py
+++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py
@@ -11,7 +11,7 @@ from csaxs_bec.bec_ipython_client.plugins.cSAXS.intensity_map_predict_gap import
from csaxs_bec.bec_ipython_client.plugins.cSAXS.slits import cSAXSSlits
from csaxs_bec.bec_ipython_client.plugins.cSAXS.smaract import cSAXSInitSmaractStages
from csaxs_bec.bec_ipython_client.plugins.cSAXS.smaract import cSAXSSmaract
-from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import OMNYTools
+from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import OMNYTools
logger = bec_logger.logger
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
index ff7cb1c..5cf7676 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -23,7 +23,7 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import (
TomoQueueMixin,
_GlobalVarParam,
)
-from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import (
+from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import (
OMNYTools,
PtychoReconstructor,
TomoIDManager,
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index a6bcb36..68c49de 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -14,7 +14,7 @@ from typeguard import typechecked
from csaxs_bec.bec_ipython_client.plugins.cSAXS import cSAXSBeamlineChecks
from csaxs_bec.bec_ipython_client.plugins.omny.gui_tools import OMNYGuiTools
from csaxs_bec.bec_ipython_client.plugins.omny.omny_alignment_mixin import OMNYAlignmentMixin
-from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import OMNYTools
+from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import OMNYTools
from csaxs_bec.bec_ipython_client.plugins.omny.omny_optics_mixin import OMNYOpticsMixin
from csaxs_bec.bec_ipython_client.plugins.omny.omny_rt import OMNY_rt_client
from csaxs_bec.bec_ipython_client.plugins.omny.omny_sample_transfer_mixin import (
--
2.54.0
From 8b3a65606c86c2474d16697b5942325d7243c5c8 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 17:35:10 +0200
Subject: [PATCH 23/29] config(LamNI,flomni): add FZP
diameter/zone-width/detector-distance params
Adds fzp_diameter (170 micron), fzp_outermost_zone_width (60 nm), and
detector_distance (-1, unknown for now) as userParameter entries on
loptx/foptx. LamNI's values match FlOMNI's already-documented active
FZP (per its "#170 micron, 60 nm" comment) -- confirmed with the user
that LamNI currently uses the same optic. Feeds the new PDF report
fields in the next commit.
---
csaxs_bec/device_configs/ptycho_flomni.yaml | 3 +++
csaxs_bec/device_configs/ptycho_lamni.yaml | 4 ++++
2 files changed, 7 insertions(+)
diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml
index b8a3f26..69fe470 100644
--- a/csaxs_bec/device_configs/ptycho_flomni.yaml
+++ b/csaxs_bec/device_configs/ptycho_flomni.yaml
@@ -91,6 +91,9 @@ foptx:
#250 micron, 30 nm, Tomas structures
# in: -14.5490625
# out: -14.1809
+ fzp_diameter: 170 # microns
+ fzp_outermost_zone_width: 60 # nm
+ detector_distance: -1 # mm, sample-to-detector; unknown for now
deviceTags:
- ptycho_flomni
diff --git a/csaxs_bec/device_configs/ptycho_lamni.yaml b/csaxs_bec/device_configs/ptycho_lamni.yaml
index e4e43a3..2ca412d 100644
--- a/csaxs_bec/device_configs/ptycho_lamni.yaml
+++ b/csaxs_bec/device_configs/ptycho_lamni.yaml
@@ -62,6 +62,10 @@ loptx:
userParameter:
in: -0.244
out: -0.699
+ # 170 micron, 60 nm
+ fzp_diameter: 170 # microns
+ fzp_outermost_zone_width: 60 # nm
+ detector_distance: -1 # mm, sample-to-detector; unknown for now
deviceTags:
- ptycho_lamni
lopty:
--
2.54.0
From e7df1209d1a636fbc7f6b607b911da5f26ae7402 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 17:35:33 +0200
Subject: [PATCH 24/29] fix(LamNI): lfzp_info() printed focal distance in
meters labeled as mm
focal_distance was computed in meters (consistent with the rest of the
function, e.g. beam_size's own internal *1000 conversions), but the
table row printed it directly as "X.XX mm" with no conversion -- off
by 1000x (e.g. showing "0.05 mm" instead of "51.34 mm"). FlOMNI's and
OMNY's equivalent ffzp_info()/ofzp_info() already convert correctly;
fix just the display here to match, without touching the
meters-based internal variable beam_size still depends on.
---
.../bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 a7f8683..981d526 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
@@ -417,7 +417,7 @@ class LamNIOpticsMixin:
)
table.add_row(
f"{diameter*1e6:.2f} microns",
- f"{focal_distance:.2f} mm",
+ f"{focal_distance*1000:.2f} mm",
f"{beam_size:.2f} microns",
)
--
2.54.0
From 1b6b6a41a105138b2e7c088976a3b966231f0117 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 17:43:53 +0200
Subject: [PATCH 25/29] feat(LamNI,flomni): add FZP optics info to the PDF
report
Adds FZP diameter, outermost zone width, focal distance,
focus-to-sample distance, and sample-to-detector distance to
write_pdf_report(). Diameter/zone-width/detector-distance come from
the userParameter entries just added to loptx/foptx; focal distance
is computed with the same formula lfzp_info()/ffzp_info() already use
(diameter * zone_width / wavelength); focus-to-sample distance reuses
their existing live z-stage-based calculation rather than storing a
separate value. Replaces this exact old SPEC workflow
(_tomo_other_parameters interactively asking for these same numbers
every time) with values read automatically from config + live
hardware.
Also fixes FlOMNI's "Current photon energy: To be implemented"
placeholder (never wired up) -- needed a real value for the new focal
distance calculation anyway, and dev.ccm_energy is the same device
ffzp_info() already reads.
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 36 +++++++++++++++-
.../plugins/flomni/flomni.py | 43 ++++++++++++++++++-
2 files changed, 76 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 a3d065e..51ba6c0 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -2163,9 +2163,38 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
# configs). Read it defensively so a missing/misbehaving device
# doesn't crash the whole report instead of just omitting one line.
try:
- energy_str = f"{dev.ccm_energy.read(cached=True)[dev.ccm_energy.name]['value']:.4f}"
+ energy_kev = dev.ccm_energy.read(cached=True)[dev.ccm_energy.name]["value"]
+ energy_str = f"{energy_kev:.4f}"
except Exception:
+ energy_kev = None
energy_str = "N/A"
+ # FZP focal distance: same formula as lfzp_info(), from the
+ # diameter/outermost-zone-width userParameter on loptx (see device
+ # config) and the current photon energy above.
+ try:
+ fzp_diameter_um = self._get_user_param_safe("loptx", "fzp_diameter")
+ fzp_zone_width_nm = self._get_user_param_safe("loptx", "fzp_outermost_zone_width")
+ wavelength_m = 1.2398e-9 / energy_kev
+ focal_distance_mm = (
+ fzp_diameter_um * 1e-6 * fzp_zone_width_nm * 1e-9 / wavelength_m * 1000
+ )
+ focal_distance_str = f"{focal_distance_mm:.2f}"
+ except Exception:
+ fzp_diameter_um = fzp_zone_width_nm = "N/A"
+ focal_distance_str = "N/A"
+ # FZP focus-to-sample distance: same live z-stage-based calculation
+ # lfzp_info() already uses -- not a stored value.
+ try:
+ loptz_val = dev.loptz.read()["loptz"]["value"]
+ fzp_sample_distance_str = f"{-loptz_val + 85.6 + 52:.1f}"
+ except Exception:
+ fzp_sample_distance_str = "N/A"
+ # Sample-to-detector distance: userParameter on loptx, defaults to
+ # -1 (unknown) until someone measures and sets it.
+ detector_distance = self._get_user_param_safe("loptx", "detector_distance")
+ detector_distance_str = (
+ f"{detector_distance:.1f}" if detector_distance and detector_distance > 0 else "N/A"
+ )
content = [
f"{'Sample Name:':<{padding}}{self.sample_name}\n",
f"{'Measurement ID:':<{padding}}{self.tomo_id}\n",
@@ -2185,6 +2214,11 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
f"{'Number of individual sub-tomograms:':<{padding}}8\n",
f"{'Angular step within sub-tomogram:':<{padding}}{self.tomo_angle_stepsize:.2f}\n",
f"{'Tomo type:':<{padding}}{self.tomo_type}\n",
+ f"{'FZP diameter:':<{padding}}{fzp_diameter_um} microns\n",
+ f"{'FZP outermost zone width:':<{padding}}{fzp_zone_width_nm} nm\n",
+ f"{'FZP focal distance:':<{padding}}{focal_distance_str} mm\n",
+ f"{'FZP focus-to-sample distance:':<{padding}}{fzp_sample_distance_str} mm\n",
+ f"{'Sample-to-detector distance:':<{padding}}{detector_distance_str} mm\n",
]
hook_description = self._describe_active_hook()
if hook_description:
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
index 5cf7676..4fc9122 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -3791,6 +3791,41 @@ class Flomni(
# _tomo_type1_actual_grid()'s docstring for why this can't just
# recompute int((tomo_angle_range/tomo_angle_stepsize)*8) locally.
_, _, tomo_type1_total_projections = self._tomo_type1_actual_grid()
+ # Same device ffzp_info() already reads. Defensive: may not be
+ # configured/available in every session (e.g. simulated configs).
+ try:
+ energy_kev = dev.ccm_energy.get().user_readback
+ energy_str = f"{energy_kev:.4f}"
+ except Exception:
+ energy_kev = None
+ energy_str = "N/A"
+ # FZP focal distance: same formula as ffzp_info(), from the
+ # diameter/outermost-zone-width userParameter on foptx (see device
+ # config) and the current photon energy above.
+ try:
+ fzp_diameter_um = self._get_user_param_safe("foptx", "fzp_diameter")
+ fzp_zone_width_nm = self._get_user_param_safe("foptx", "fzp_outermost_zone_width")
+ wavelength_m = 1.2398e-9 / energy_kev
+ focal_distance_mm = (
+ fzp_diameter_um * 1e-6 * fzp_zone_width_nm * 1e-9 / wavelength_m * 1000
+ )
+ focal_distance_str = f"{focal_distance_mm:.2f}"
+ except Exception:
+ fzp_diameter_um = fzp_zone_width_nm = "N/A"
+ focal_distance_str = "N/A"
+ # FZP focus-to-sample distance: same live z-stage-based calculation
+ # ffzp_info() already uses -- not a stored value.
+ try:
+ foptz_val = dev.foptz.readback.get()
+ fzp_sample_distance_str = f"{-foptz_val + 43.15 + 36.7:.1f}"
+ except Exception:
+ fzp_sample_distance_str = "N/A"
+ # Sample-to-detector distance: userParameter on foptx, defaults to
+ # -1 (unknown) until someone measures and sets it.
+ detector_distance = self._get_user_param_safe("foptx", "detector_distance")
+ detector_distance_str = (
+ f"{detector_distance:.1f}" if detector_distance and detector_distance > 0 else "N/A"
+ )
content = [
f"{'Sample Name:':<{padding}}{self.sample_name}\n",
f"{'Measurement ID:':<{padding}}{self.tomo_id}\n",
@@ -3801,14 +3836,18 @@ class Flomni(
f"{'First scan number:':<{padding}}{self.client.queue.next_scan_number}\n",
f"{'Last scan number approx.:':<{padding}}"
f"{self.client.queue.next_scan_number + tomo_type1_total_projections + 10}\n",
- f"{'Current photon energy:':<{padding}}To be implemented\n",
- # f"{'Current photon energy:':<{padding}}{dev.mokev.read()['mokev']['value']:.4f}\n",
+ f"{'Current photon energy:':<{padding}}{energy_str}\n",
f"{'Exposure time:':<{padding}}{self.tomo_countingtime:.2f}\n",
f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:.2f}\n",
f"{'FOV:':<{padding}}{fovxy}\n",
f"{'Stitching:':<{padding}}{stitching}\n",
f"{'Number of individual sub-tomograms:':<{padding}}8\n",
f"{'Angular step within sub-tomogram:':<{padding}}{self.tomo_angle_stepsize:.2f}\n",
+ f"{'FZP diameter:':<{padding}}{fzp_diameter_um} microns\n",
+ f"{'FZP outermost zone width:':<{padding}}{fzp_zone_width_nm} nm\n",
+ f"{'FZP focal distance:':<{padding}}{focal_distance_str} mm\n",
+ f"{'FZP focus-to-sample distance:':<{padding}}{fzp_sample_distance_str} mm\n",
+ f"{'Sample-to-detector distance:':<{padding}}{detector_distance_str} mm\n",
]
hook_description = self._describe_active_hook()
if hook_description:
--
2.54.0
From 6c06b63eced8c4ccfcaba31c50312ffd0ba9fa42 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 17:48:26 +0200
Subject: [PATCH 26/29] fix(LamNI,flomni): FZP diameter/zone-width wrongly
blanked to N/A
fzp_diameter_um/fzp_zone_width_nm were read inside the same try block
as the focal-distance calculation, which also needs energy_kev. When
the photon energy read fails (as it does in at least one simulated
session -- confirmed live: dev.ccm_energy unavailable), the exception
wiped out the diameter/zone-width display too, even though those come
from config (loptx/foptx userParameter) and were read just fine.
Split into two independent try blocks: diameter/zone-width no longer
depend on a working energy read, only the focal distance itself does.
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 12 +-
.../plugins/OMNY_shared/psi_logo.svg | 119 ++++++++++++++++++
.../plugins/flomni/flomni.py | 12 +-
3 files changed, 135 insertions(+), 8 deletions(-)
create mode 100644 csaxs_bec/bec_ipython_client/plugins/OMNY_shared/psi_logo.svg
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 51ba6c0..1c301ff 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -2168,19 +2168,23 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
except Exception:
energy_kev = None
energy_str = "N/A"
- # FZP focal distance: same formula as lfzp_info(), from the
- # diameter/outermost-zone-width userParameter on loptx (see device
- # config) and the current photon energy above.
+ # FZP diameter/outermost-zone-width: userParameter on loptx (see
+ # device config) -- independent of the energy read above, so a
+ # failed energy read shouldn't blank these out too.
try:
fzp_diameter_um = self._get_user_param_safe("loptx", "fzp_diameter")
fzp_zone_width_nm = self._get_user_param_safe("loptx", "fzp_outermost_zone_width")
+ except Exception:
+ fzp_diameter_um = fzp_zone_width_nm = "N/A"
+ # FZP focal distance: same formula as lfzp_info(), from the values
+ # above and the current photon energy -- only this needs energy_kev.
+ try:
wavelength_m = 1.2398e-9 / energy_kev
focal_distance_mm = (
fzp_diameter_um * 1e-6 * fzp_zone_width_nm * 1e-9 / wavelength_m * 1000
)
focal_distance_str = f"{focal_distance_mm:.2f}"
except Exception:
- fzp_diameter_um = fzp_zone_width_nm = "N/A"
focal_distance_str = "N/A"
# FZP focus-to-sample distance: same live z-stage-based calculation
# lfzp_info() already uses -- not a stored value.
diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/psi_logo.svg b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/psi_logo.svg
new file mode 100644
index 0000000..1ca31d4
--- /dev/null
+++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/psi_logo.svg
@@ -0,0 +1,119 @@
+
diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
index 4fc9122..326caf7 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -3799,19 +3799,23 @@ class Flomni(
except Exception:
energy_kev = None
energy_str = "N/A"
- # FZP focal distance: same formula as ffzp_info(), from the
- # diameter/outermost-zone-width userParameter on foptx (see device
- # config) and the current photon energy above.
+ # FZP diameter/outermost-zone-width: userParameter on foptx (see
+ # device config) -- independent of the energy read above, so a
+ # failed energy read shouldn't blank these out too.
try:
fzp_diameter_um = self._get_user_param_safe("foptx", "fzp_diameter")
fzp_zone_width_nm = self._get_user_param_safe("foptx", "fzp_outermost_zone_width")
+ except Exception:
+ fzp_diameter_um = fzp_zone_width_nm = "N/A"
+ # FZP focal distance: same formula as ffzp_info(), from the values
+ # above and the current photon energy -- only this needs energy_kev.
+ try:
wavelength_m = 1.2398e-9 / energy_kev
focal_distance_mm = (
fzp_diameter_um * 1e-6 * fzp_zone_width_nm * 1e-9 / wavelength_m * 1000
)
focal_distance_str = f"{focal_distance_mm:.2f}"
except Exception:
- fzp_diameter_um = fzp_zone_width_nm = "N/A"
focal_distance_str = "N/A"
# FZP focus-to-sample distance: same live z-stage-based calculation
# ffzp_info() already uses -- not a stored value.
--
2.54.0
From 79f257f072a85701899aef8ff31fc2e7e20ae816 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 17:48:57 +0200
Subject: [PATCH 27/29] feat(LamNI,flomni): add PSI logo to every PDF report
page footer
Downloaded from https://intranet.psi.ch/themes/custom/design/logo.svg
(no gradients/filters/embedded fonts -- confirmed fpdf2 renders it
directly, no PNG conversion needed) and stored as psi_logo.svg in
OMNY_shared. Added TomoQueueMixin._add_psi_footer(), which overrides
the fpdf instance's footer() callback (PDFWriter/BECPDF live in
bec_lib, a separate repo with no public API for this) so the logo
repeats on every page -- unlike the header logo, which is drawn once
inline, a report can span multiple pages (e.g. a long
at_each_angle_hook source dump). Also drops the microseconds from the
existing "BEC, " footer text while reimplementing it, per
request ("ms precision is not needed"). Verified end-to-end with
bec_lib's real PDFWriter: a 300-line hook source forced a 7-page
report, with the logo correctly repeating on every page.
---
.../bec_ipython_client/plugins/LamNI/lamni.py | 1 +
.../plugins/OMNY_shared/tomo_queue_mixin.py | 34 +++++++++++++++++++
.../plugins/flomni/flomni.py | 1 +
3 files changed, 36 insertions(+)
diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
index 1c301ff..ff03247 100644
--- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py
@@ -2231,6 +2231,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
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:
+ self._add_psi_footer(file)
# PDFWriter (bec_lib) has no public image API -- reach into its
# underlying fpdf object directly. logo_w chosen to keep the
# header modest relative to the A4 page width (210mm).
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 15cac04..6a0f9cb 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
@@ -39,6 +39,7 @@ import builtins
import datetime
import inspect
import json
+import os
import threading
import uuid
from typing import Callable
@@ -481,6 +482,39 @@ class TomoQueueMixin:
threading.Thread(target=_run, name="PdfUpload", daemon=True).start()
+ def _add_psi_footer(self, pdf_writer) -> None:
+ """Add the PSI logo to every page's footer of a PDFWriter report.
+
+ PDFWriter/BECPDF (bec_lib, a separate repo) has no public API for
+ this, and its footer() is fpdf's own automatic per-page callback
+ (unlike the header logo, which is drawn once inline right after
+ opening the PDFWriter) -- a report can span multiple pages (e.g. a
+ long at_each_angle_hook source dump), so the logo needs to repeat
+ on each one. Fully replaces bec_lib's own footer() (rather than
+ calling it and adding to it) so the timestamp can drop the
+ microseconds str(datetime.datetime.now()) includes -- same visual
+ layout/font otherwise (see bec_lib/pdf_writer.py's BECPDF.footer()).
+ """
+ psi_logo = os.path.join(os.path.dirname(os.path.abspath(__file__)), "psi_logo.svg")
+ if not os.path.exists(psi_logo):
+ return
+ from fpdf import XPos, YPos
+
+ pdf = pdf_writer._pdf
+
+ def _footer_with_logo():
+ pdf.set_y(-15)
+ pdf.image(psi_logo, x=pdf.l_margin, y=pdf.h - 22, h=6)
+ pdf.set_font("Courier", "", 8)
+ pdf.set_text_color(128)
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ pdf.cell(0, 10, f"BEC, {timestamp}", 0, new_x=XPos.RIGHT, new_y=YPos.TOP, align="L")
+ pdf.cell(
+ 0, 10, "Page " + str(pdf.page_no()), 0, new_x=XPos.RIGHT, new_y=YPos.TOP, align="R"
+ )
+
+ pdf.footer = _footer_with_logo
+
# ── 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 326caf7..1471c24 100644
--- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
+++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py
@@ -3862,6 +3862,7 @@ class Flomni(
f"~/data/raw/documentation/tomo_scan_ID_{self.tomo_id}.pdf"
)
with PDFWriter(user_target) as file:
+ self._add_psi_footer(file)
# PDFWriter (bec_lib) has no public image API -- reach into its
# underlying fpdf object directly. logo_w chosen to keep the
# header modest relative to the A4 page width (210mm).
--
2.54.0
From 56467682a972d3574f2c8a3483ceaea716b90eea Mon Sep 17 00:00:00 2001
From: x01dc
Date: Mon, 27 Jul 2026 17:49:05 +0200
Subject: [PATCH 28/29] config(LamNI,flomni): add FZP params to the simulated
device configs too
simulated_lamni.yaml/simulated_flomni.yaml define their own separate
loptx/foptx (not shared with ptycho_lamni.yaml/ptycho_flomni.yaml) --
missed these when adding fzp_diameter/fzp_outermost_zone_width/
detector_distance earlier, so a simulated-only session wouldn't have
had them. Same values (170 micron, 60 nm, -1).
---
csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml | 3 +++
csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml | 4 ++++
2 files changed, 7 insertions(+)
diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml
index 6e4e185..32b7db8 100644
--- a/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml
+++ b/csaxs_bec/device_configs/simulated_omny/simulated_flomni.yaml
@@ -130,6 +130,9 @@ foptx:
#170 micron, 60 nm
in: -13.831
out: -13.831
+ fzp_diameter: 170 # microns
+ fzp_outermost_zone_width: 60 # nm
+ detector_distance: -1 # mm, sample-to-detector; unknown for now
deviceTags:
- simulated_flomni
diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml
index f4c39a4..ab07a1e 100644
--- a/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml
+++ b/csaxs_bec/device_configs/simulated_omny/simulated_lamni.yaml
@@ -74,6 +74,10 @@ loptx:
userParameter:
in: -0.244
out: -0.699
+ # 170 micron, 60 nm
+ fzp_diameter: 170 # microns
+ fzp_outermost_zone_width: 60 # nm
+ detector_distance: -1 # mm, sample-to-detector; unknown for now
deviceTags:
- simulated_lamni
lopty:
--
2.54.0
From 13e9cb21e4c16139482a720864d17b1d145feea2 Mon Sep 17 00:00:00 2001
From: x01dc
Date: Tue, 28 Jul 2026 09:53:59 +0200
Subject: [PATCH 29/29] fix(LamNI): update tests for prompts/kwargs added
earlier this branch
Several tomo_scan()/tomo_alignment_scan()/find_rotation_center() tests
started failing (CI) after recent commits on this branch introduced new
input()-backed confirmation/sample-name prompts, an
alignment_scan_progress GUI proxy, and tomo_queue_execute()'s
interactive=False kwarg -- none of which the existing test fixtures or
assertions were updated for. Mock/bypass the new prompts and GUI calls,
add the missing _alignment_scan_progress_proxy to the bare-object test
fixture, and update two assertions (tomo_queue interactive kwarg,
account-less sample registration) to match the intended new behavior.
---
.../test_lamni_tomo_alignment_scan.py | 11 +++++++-
.../test_lamni_tomo_angles.py | 26 +++++++++++++------
.../test_lamni_tomo_queue.py | 2 +-
.../test_x_ray_eye_align.py | 4 +++
4 files changed, 33 insertions(+), 10 deletions(-)
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
index 2fff068..258dc92 100644
--- a/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py
+++ b/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py
@@ -13,7 +13,11 @@ 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
+from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import (
+ LamNI,
+ _AlignmentScanProgressProxy,
+ _ProgressProxy,
+)
class FakeClient:
@@ -54,12 +58,16 @@ def make_lamni(monkeypatch, xray_eye_fit=None):
obj = object.__new__(LamNI)
obj.client = FakeClient()
obj._progress_proxy = _ProgressProxy(obj.client)
+ obj._alignment_scan_progress_proxy = _AlignmentScanProgressProxy(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
+ obj.lamnigui_show_alignment_progress = lambda: None
+ obj._lamnigui_update_alignment_progress = lambda: None
+ obj._confirm_sequence_override = lambda *a, **k: True
if xray_eye_fit is not None:
obj.client.set_global_var("tomo_fit_xray_eye", xray_eye_fit)
@@ -75,6 +83,7 @@ def make_lamni(monkeypatch, xray_eye_fit=None):
def test_tomo_alignment_scan_aborts_without_xray_eye_fit(monkeypatch):
lamni = make_lamni(monkeypatch, xray_eye_fit=None)
+ lamni._confirm_sequence_override = lambda *a, **k: False
calls = []
lamni.tomo_scan_projection = lambda angle: calls.append(angle)
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 0246079..0b42093 100644
--- a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
+++ b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py
@@ -169,6 +169,10 @@ def make_lamni_for_tomo_scan(
obj.lamnigui_show_progress = lambda: None
obj.at_each_angle_hook = None
obj.OMNYTools = types.SimpleNamespace(printgreenbold=lambda msg: None)
+ # These tests exercise tomo_scan()'s account-handling/heartbeat/progress-GUI
+ # logic, not the fine-alignment confirmation gate -- bypass it so it never
+ # blocks on input().
+ obj._confirm_sequence_override = lambda *a, **k: True
monkeypatch.setitem(
builtins.__dict__,
"bec",
@@ -184,18 +188,24 @@ def make_lamni_for_tomo_scan(
return obj
-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."""
+def test_tomo_scan_registers_sample_even_without_active_account(monkeypatch):
+ """Empty active_account (e.g. a dev/sim session) must not crash -- it is
+ still passed through to add_sample_database() (as ""), letting
+ TomoIDManager.register() decide the outcome (test-server registration)
+ instead of pre-empting it with a hardcoded tomo_id=0."""
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")
- )
+ recorded = {}
+
+ def _fake_add_sample_database(samplename, date, eaccount, scan_number, setup, info, user):
+ recorded["eaccount"] = eaccount
+ return 7
+
+ lamni.add_sample_database = _fake_add_sample_database
lamni.tomo_scan()
- assert lamni.tomo_id == 0
+ assert recorded["eaccount"] == ""
+ assert lamni.tomo_id == 7
def test_tomo_scan_registers_sample_with_plain_string_account(monkeypatch):
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 3e5a233..67c9692 100644
--- a/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py
+++ b/tests/tests_bec_ipython_client/test_lamni_tomo_queue.py
@@ -122,7 +122,7 @@ def test_tomo_queue_execute_runs_fresh_job_then_marks_done():
lamni.tomo_queue_add(label="job1")
lamni.tomo_queue_execute()
- assert calls == [("scan", (), {})]
+ assert calls == [("scan", (), {"interactive": False})]
job = lamni._tomo_queue_proxy.as_list()[0]
assert job["status"] == "done"
diff --git a/tests/tests_bec_ipython_client/test_x_ray_eye_align.py b/tests/tests_bec_ipython_client/test_x_ray_eye_align.py
index 3bbc25a..4404582 100644
--- a/tests/tests_bec_ipython_client/test_x_ray_eye_align.py
+++ b/tests/tests_bec_ipython_client/test_x_ray_eye_align.py
@@ -158,6 +158,10 @@ def _make_calibration_align(client):
align.lamni.loptics_out = mock.MagicMock()
align.lamni.losa_out = mock.MagicMock()
align.lamni.lamnigui_show_xeyealign = mock.MagicMock()
+ # _sync_sample_name(prompt=True) calls lamni._get_val(), which reads from
+ # input() -- keep the current default (as if Enter was pressed) instead of
+ # blocking on stdin.
+ align.lamni._get_val = lambda msg, default_value, data_type: default_value
# Replace the real Scans proxy (which would try to talk to a live scan
# server) with a plain mock -- these tests only care that
# lamni_move_to_scan_center is *called* with the right kwargs.
--
2.54.0