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