From c4566d6d2a34a9f8a3ffa7a593c114bd9324d872 Mon Sep 17 00:00:00 2001 From: x12sa Date: Thu, 2 Jul 2026 13:06:32 +0200 Subject: [PATCH 01/32] improvements in gui --- .../plugins/flomni/x_ray_eye_align.py | 5 - .../widgets/slit_control/slit_control.py | 236 +++++++++++++++--- .../widgets/tomo_params/tomo_params.py | 49 +++- csaxs_bec/device_configs/ptycho_flomni.yaml | 30 ++- 4 files changed, 272 insertions(+), 48 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py index 422046f..a1e2717 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py @@ -278,12 +278,7 @@ class XrayEyeAlign: self.gui.show_crosshair() self.send_message( -<<<<<<< Updated upstream "Submit height. Use arrows if far off." -======= - "Adjust sample height with the arrows if needed, then mark " - "the sample and submit - height will be centered automatically" ->>>>>>> Stashed changes ) self.gui.enable_submit_button(True) self.movement_buttons_enabled(True, True) diff --git a/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py index 7e2c661..3dc3658 100644 --- a/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py +++ b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py @@ -1,13 +1,18 @@ from __future__ import annotations +import threading +from typing import Optional + from bec_lib import bec_logger from bec_widgets import BECWidget from bec_widgets.utils.rpc_decorator import rpc_timeout from qtpy.QtCore import Qt, QTimer +from qtpy.QtCore import Signal from qtpy.QtGui import QKeySequence from qtpy.QtWidgets import ( QButtonGroup, QDoubleSpinBox, + QFormLayout, QFrame, QGridLayout, QGroupBox, @@ -92,6 +97,9 @@ class SlitControlWidget(BECWidget, QWidget): PLUGIN = True + # carries fetched slit data from the background thread to the main thread + _slit_data_ready = Signal(object) + USER_ACCESS = [ "set_slit", "move_center", @@ -122,8 +130,14 @@ class SlitControlWidget(BECWidget, QWidget): self.step = self._STEP_DEFAULT self._ov_items: dict[tuple[int, int], QTableWidgetItem] = {} self._shortcuts: list[QShortcut] = [] - # rotating index through non-selected slits for slow background polling self._slow_poll_idx: int = 0 + self._fetch_thread: Optional[threading.Thread] = None + self._slit_data_ready.connect(self._apply_slit_data) + self._locked: bool = False + self._move_btns: list[QPushButton] = [] # populated by _build_* methods + self._lockout_timer = QTimer(self) + self._lockout_timer.setSingleShot(True) + self._lockout_timer.timeout.connect(self._release_lockout) self._build_ui() self._build_shortcuts() # created once, enabled/disabled by toggle self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) @@ -162,7 +176,28 @@ class SlitControlWidget(BECWidget, QWidget): root.addWidget(_separator()) - # 5. keyboard toggle + legend + # 5. absolute target entry + stop button + target_row = QHBoxLayout() + target_row.addWidget(self._build_target_panel(), stretch=1) + self._btn_stop = QPushButton("■ Stop") + self._btn_stop.setToolTip("Stop all axes of the selected slit") + self._btn_stop.clicked.connect(self._stop_slit) + self._btn_stop.setMinimumWidth(70) + target_row.addWidget(self._btn_stop, stretch=0) + root.addLayout(target_row) + + root.addWidget(_separator()) + + # 6. status label (move errors, limit hits) + self._status_lbl = QLabel("") + self._status_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._status_lbl.setMinimumHeight(18) + root.addWidget(self._status_lbl) + self._status_timer = QTimer(self) + self._status_timer.setSingleShot(True) + self._status_timer.timeout.connect(lambda: self._status_lbl.setText("")) + + # 7. keyboard toggle + legend self._btn_kb = QPushButton("\u2328 Keyboard tweaking") self._btn_kb.setCheckable(True) self._btn_kb.toggled.connect(self._on_kb_toggled) @@ -172,12 +207,58 @@ class SlitControlWidget(BECWidget, QWidget): self._kb_legend.setVisible(False) root.addWidget(self._kb_legend) + def _build_target_panel(self) -> QGroupBox: + """Four spinboxes to move the selected slit to an absolute position.""" + box = QGroupBox("Move to absolute position (mm)") + form = QFormLayout(box) + form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) + + self._target_spins: dict[str, QDoubleSpinBox] = {} + labels = {"xc": "x center", "xs": "x size", "yc": "y center", "ys": "y size"} + + for suffix, label in labels.items(): + spin = QDoubleSpinBox() + spin.setDecimals(4) + spin.setRange(-500.0, 500.0) + spin.setSingleStep(0.001) + self._target_spins[suffix] = spin + + go_btn = QPushButton("→") + go_btn.setFixedWidth(28) + go_btn.setToolTip(f"Move {label} to entered value") + # capture suffix in closure + go_btn.clicked.connect(lambda checked=False, s=suffix: self._move_absolute(s)) + spin.returnPressed = None # QDoubleSpinBox has no returnPressed + + row_widget = QWidget() + row_layout = QHBoxLayout(row_widget) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.addWidget(spin) + row_layout.addWidget(go_btn) + form.addRow(f"{label}:", row_widget) + + return box + def _build_overview(self) -> QGroupBox: box = QGroupBox("All slits (mm) – select to tweak") vbox = QVBoxLayout(box) tbl = QTableWidget(len(self._SLIT_LABELS), 6) - tbl.setHorizontalHeaderLabels(["", "Slit", "x center", "x size", "y center", "y size"]) + _R = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + _L = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + for col, (text, align) in enumerate( + [ + ("", _L), + ("Slit", _L), + ("x center", _R), + ("x size", _R), + ("y center", _R), + ("y size", _R), + ] + ): + hdr_item = QTableWidgetItem(text) + hdr_item.setTextAlignment(align) + tbl.setHorizontalHeaderItem(col, hdr_item) # col 0 (radio) and col 1 (name) sized to content; # value cols 2-5 share remaining width equally → evenly distributed tbl.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) @@ -243,6 +324,7 @@ class SlitControlWidget(BECWidget, QWidget): btn_shrink_y.clicked.connect(lambda: self.move_size("down")) btn_shrink_x.clicked.connect(lambda: self.move_size("left")) btn_grow_x.clicked.connect(lambda: self.move_size("right")) + self._move_btns += [btn_grow_y, btn_shrink_y, btn_shrink_x, btn_grow_x] grid.addWidget(btn_grow_y, 0, 1) grid.addWidget(btn_shrink_x, 1, 0) @@ -275,6 +357,7 @@ class SlitControlWidget(BECWidget, QWidget): btn_down.clicked.connect(lambda: self.move_center("down")) btn_left.clicked.connect(lambda: self.move_center("left")) btn_right.clicked.connect(lambda: self.move_center("right")) + self._move_btns += [btn_up, btn_down, btn_left, btn_right] grid.addWidget(btn_up, 0, 1) grid.addWidget(btn_left, 1, 0) @@ -374,40 +457,131 @@ class SlitControlWidget(BECWidget, QWidget): else: self._btn_kb.setText("\u2328 Keyboard tweaking") + def _engage_lockout(self, ms: int = 2000) -> None: + """Disable move buttons and keyboard move shortcuts for ``ms`` ms.""" + self._locked = True + for btn in self._move_btns: + btn.setEnabled(False) + # disable only the movement shortcuts (indices 6+); keep slit-select (0-5) + for sc in self._shortcuts[6:]: + sc.setEnabled(False) + self._lockout_timer.start(ms) + + def _release_lockout(self) -> None: + """Re-enable move buttons and shortcuts (called by the lockout timer).""" + self._locked = False + for btn in self._move_btns: + btn.setEnabled(True) + # only re-enable movement shortcuts if keyboard mode is active + if self._btn_kb.isChecked(): + for sc in self._shortcuts[6:]: + sc.setEnabled(True) + + def _populate_target_spins(self) -> None: + """Pre-fill target spinboxes with current device values.""" + for suffix, spin in self._target_spins.items(): + try: + device = getattr(self.dev, self._dev_name(suffix)) + reading = device.read() + value = float(reading[self._dev_name(suffix)]["value"]) + spin.setValue(value) + except Exception: + pass + + def _move_absolute(self, axis_suffix: str) -> None: + """Move one axis to the value currently entered in its target spinbox.""" + if self._locked: + return + self._engage_lockout(3000) # absolute moves may cover more distance + dev_name = self._dev_name(axis_suffix) + target = self._target_spins[axis_suffix].value() + try: + device = getattr(self.dev, dev_name) + device.move(target) + except Exception as exc: + msg = str(exc) or type(exc).__name__ + logger.warning(f"SlitControlWidget: absolute move {dev_name} failed: {msg}") + self._show_status(f"{dev_name}: {msg}") + + def _stop_slit(self) -> None: + """Stop all four axes of the currently selected slit.""" + for suffix in ("xc", "xs", "yc", "ys"): + try: + device = getattr(self.dev, self._dev_name(suffix)) + device.stop() + except Exception as exc: + msg = str(exc) or type(exc).__name__ + logger.warning(f"SlitControlWidget: stop {self._dev_name(suffix)} failed: {msg}") + self._show_status(f"Stop failed: {msg}") + + def _show_status(self, msg: str, error: bool = True) -> None: + """Show a brief status message below the controls (auto-clears after 4 s).""" + color = "#e53935" if error else "#43a047" + self._status_lbl.setText(f'{msg}') + self._status_timer.start(4000) + # ── overview polling ────────────────────────────────────────────────────── - def _refresh_slit_row(self, slit_num: int) -> None: - """Read all four axis values for one slit and update its table row.""" - row = list(self._SLIT_LABELS).index(slit_num) - for col_off, suffix in enumerate(("xc", "xs", "yc", "ys")): - dev_name = f"sl{slit_num}{suffix}" - item = self._ov_items.get((row, col_off + 2)) - if item is None: - continue - try: - device = getattr(self.dev, dev_name) - reading = device.read() - value = float(reading[dev_name]["value"]) - item.setText(f"{value:.4f}") - except Exception: - item.setText("---") + # ── background polling ──────────────────────────────────────────────────── def _refresh_overview(self) -> None: """ - Per-tick (2 s) refresh strategy: - - Selected slit: every tick → updated every 2 s. - - Other slits: one per tick in rotation → each updated every ~10 s - (5 other slits × 2 s). At most 8 device.read() calls per tick. - """ - # always refresh the selected slit - self._refresh_slit_row(self.slit) + Kick off a background thread to fetch slit values without blocking the + Qt main thread. device.read() on virtual slit devices triggers RPC + calls to the device server; doing those in the main thread stalls the UI. + The thread emits _slit_data_ready when done; _apply_slit_data updates + the table and target spinboxes safely from the main thread. - # advance through the other slits one per tick + Per-tick strategy (2 s timer): + - Selected slit: every tick. + - Other slits: one per tick in rotation (~10 s per non-selected slit). + """ + if self._fetch_thread is not None and self._fetch_thread.is_alive(): + return # previous fetch still running – skip this tick + self._fetch_thread = threading.Thread(target=self._fetch_slit_data, daemon=True) + self._fetch_thread.start() + + def _fetch_slit_data(self) -> None: + """Runs in background thread – NO Qt widget access here.""" + data: dict[tuple[int, str], Optional[float]] = {} + self._fetch_one_slit(self.slit, data) others = [n for n in self._SLIT_LABELS if n != self.slit] if others: self._slow_poll_idx = self._slow_poll_idx % len(others) - self._refresh_slit_row(others[self._slow_poll_idx]) + self._fetch_one_slit(others[self._slow_poll_idx], data) self._slow_poll_idx += 1 + self._slit_data_ready.emit(data) # thread-safe: Qt queues it to main thread + + def _fetch_one_slit(self, slit_num: int, data: dict[tuple[int, str], Optional[float]]) -> None: + """Fetch four axis values for one slit into data (background thread).""" + for suffix in ("xc", "xs", "yc", "ys"): + dev_name = f"sl{slit_num}{suffix}" + try: + device = getattr(self.dev, dev_name) + reading = device.read() + data[(slit_num, suffix)] = float(reading[dev_name]["value"]) + except Exception: + data[(slit_num, suffix)] = None + + def _apply_slit_data(self, data: dict) -> None: + """Update table and target spinboxes in the main thread (slot for signal).""" + suffix_order = ("xc", "xs", "yc", "ys") + for (slit_num, suffix), value in data.items(): + row = list(self._SLIT_LABELS).index(slit_num) + col_off = suffix_order.index(suffix) + item = self._ov_items.get((row, col_off + 2)) + if item is None: + continue + if value is not None: + item.setText(f"{value:.3f}") + if slit_num == self.slit and hasattr(self, "_target_spins"): + spin = self._target_spins.get(suffix) + if spin is not None and not spin.hasFocus(): + spin.blockSignals(True) + spin.setValue(value) + spin.blockSignals(False) + else: + item.setText("---") def _update_row_bold(self): for row, (slit_num, _) in enumerate(self._SLIT_LABELS.items()): @@ -436,6 +610,7 @@ class SlitControlWidget(BECWidget, QWidget): btn.setChecked(True) self._tweaking_lbl.setText(f"Tweaking: {self._SLIT_LABELS[slit]}") self._update_row_bold() + self._populate_target_spins() def move_center(self, direction: str): """Move the slit center. direction: up / down / left / right.""" @@ -464,6 +639,9 @@ class SlitControlWidget(BECWidget, QWidget): self._move_relative(suffix, sign * self.step) def _move_relative(self, axis_suffix: str, delta: float): + if self._locked: + return + self._engage_lockout(2000) dev_name = self._dev_name(axis_suffix) try: device = getattr(self.dev, dev_name) @@ -471,7 +649,9 @@ class SlitControlWidget(BECWidget, QWidget): current = float(reading[dev_name]["value"]) device.move(current + delta) except Exception as exc: - logger.warning(f"SlitControlWidget: failed to move {dev_name}: {exc}") + msg = str(exc) or type(exc).__name__ + logger.warning(f"SlitControlWidget: failed to move {dev_name}: {msg}") + self._show_status(f"{dev_name}: {msg}") def set_step(self, value: float): """Set step size in mm (clamped to 0.005–2.0 mm).""" 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 5b46715..1a5c070 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -292,14 +292,6 @@ class TomoParamsWidget(BECWidget, QWidget): decimals=3, ) self._add_int(common_form, "frames_per_trigger", "Frames / trigger", min_=1, max_=100) - self._add_double( - common_form, - "corridor_size", - "Corridor size (µm, −1=off)", - min_=-1.0, - max_=200.0, - decimals=2, - ) sp = self._add_bool(common_form, "single_point_instead_of_fermat_scan", "Single-point scan") sp.stateChanged.connect(self._on_single_point_changed) vbox.addLayout(common_form) @@ -642,8 +634,49 @@ class TomoParamsWidget(BECWidget, QWidget): params = self._load_params() self._populate_fields(params) + def _is_tomo_running(self) -> bool: + """ + Return True if a tomo scan is likely active. + + Uses the ``tomo_progress["heartbeat"]`` timestamp written by + ``_tomo_scan_at_angle()`` at the start of every projection — this is + updated whether the scan was started via the queue or directly via + ``flomni.tomo_scan()``, so it's more reliable than queue job status. + A heartbeat fresher than 2 minutes indicates a scan in progress; + older than that it's either done, or stuck long enough that editing + parameters is intentional. + """ + import datetime + + try: + prog = self._gv_get("tomo_progress") + if not isinstance(prog, dict): + return False + heartbeat_str = prog.get("heartbeat") + if heartbeat_str is None: + return False + heartbeat = datetime.datetime.fromisoformat(heartbeat_str) + age_s = (datetime.datetime.now() - heartbeat).total_seconds() + return age_s < 120 # 2-minute window + except Exception: + return False + def submit_params(self) -> None: """Validate, write to global vars, and re-lock fields.""" + if self._is_tomo_running(): + reply = QMessageBox.warning( + self, + "Scan in progress", + "A tomo scan appears to be running.\n\n" + "Parameters read via properties (e.g. counting time) take effect " + "immediately on the next projection. Parameters used to build the " + "scan trajectory (FOV, angle step) are already fixed for this run " + "but will affect any subsequent queue job.\n\n" + "Apply changes now?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, + ) + if reply != QMessageBox.StandardButton.Yes: + return params = self._read_fields() error = self._validate(params) if error: diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index c7d8d7b..9809928 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -58,6 +58,7 @@ fheater: readOnly: false readoutPriority: baseline connectionTimeout: 20 + foptx: description: Optics X deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor @@ -78,8 +79,12 @@ foptx: #170 micron, 60 nm #in: -13.831 #250 micron, 30 nm, Abe structures - in: -13.8809375 + # in: -13.8809375 + # out: -14.1809 + #250 micron, 30 nm, Tomas structures + in: -14.5490625 out: -14.1809 + fopty: description: Optics Y deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor @@ -101,8 +106,12 @@ fopty: #in: 0.42 #out: 0.57 #250 micron, 30 nm, Abe structures - in: 2.8299 - out: 2.8299 + # in: 2.8299 + # out: 2.8299 + #250 micron, 30 nm, Tomas structures + in: 2.8419921875 + out: 2.8419921875 + foptz: description: Optics Z deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor @@ -121,6 +130,7 @@ foptz: connectionTimeout: 20 userParameter: in: 23 + fsamroy: description: Sample rotation deviceClass: csaxs_bec.devices.omny.galil.fupr_ophyd.FuprGalilMotor @@ -154,7 +164,7 @@ fsamx: readoutPriority: baseline connectionTimeout: 20 userParameter: - in: -1.1 + in: -1.14 fsamy: description: Sample coarse Y deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor @@ -314,8 +324,12 @@ fosax: # in: 8.731922 # out: 5.1 #250 micron, 30 nm, Abe structures - in: 8.755141 + # in: 8.755141 + # out: 5.1 + #250 micron, 30 nm, Tomas structures + in: 9.420798 out: 5.1 + fosay: description: OSA Y deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor @@ -338,7 +352,9 @@ fosay: #170 micron, 60 nm, 7.6 kev #in: -0.0422 #250 micron, 30 nm, Abe structures - in: -2.357436 + # in: -2.357436 + #250 micron, 30 nm, Tomas structures + in: -2.383993 fosaz: description: OSA Z deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor @@ -364,7 +380,7 @@ fosaz: # out: 6 # micron, 30 nm, 7.9 kev, foptz 32 //abe's fzp's in: -2 - out: -5 + out: -5 ############################################################ #################### flOMNI RT motors ###################### -- 2.54.0 From bf3ca215359c35415061a04c2f03662e6249ce76 Mon Sep 17 00:00:00 2001 From: x12sa Date: Thu, 2 Jul 2026 14:08:46 +0200 Subject: [PATCH 02/32] added check so gui only works with flomni --- .../widgets/tomo_params/tomo_params.py | 100 ++++++++++++++---- 1 file changed, 80 insertions(+), 20 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 1a5c070..b18e5f1 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -162,16 +162,30 @@ class TomoParamsWidget(BECWidget, QWidget): super().__init__(parent=parent, **kwargs) self.get_bec_shortcuts() self._edit_mode = False - # widget refs populated by _build_params_panel self._pw: dict[str, QWidget] = {} # key -> input widget + self._queue_dlg: Optional["TomoQueueDialog"] = None + # check whether the FlOMNI setup is active before building the UI + self._flomni_available = self._check_flomni_available() self._build_ui() - # polling timer – does NOT refresh params (avoid overwriting edits) self._poll_timer = QTimer(self) self._poll_timer.setInterval(self._POLL_INTERVAL_MS) self._poll_timer.timeout.connect(self._on_poll) - self._poll_timer.start() - # initial load - self.refresh() + if self._flomni_available: + self._poll_timer.start() + self.refresh() + + # ── setup detection ────────────────────────────────────────────────────── + + def _check_flomni_available(self) -> bool: + """ + Return True if the FlOMNI setup is active in this BEC session. + Uses ``fsamroy`` (the FlOMNI-specific rotation stage) as the + discriminator — it is absent in OMNY and LamNI sessions. + """ + try: + return getattr(self.dev, "fsamroy", None) is not None + except Exception: + return False # ── global-var I/O ─────────────────────────────────────────────────────── @@ -212,6 +226,19 @@ class TomoParamsWidget(BECWidget, QWidget): root = QVBoxLayout(self) root.setSpacing(6) + if not self._flomni_available: + lbl = QLabel( + "⚠ FlOMNI setup not detected in this BEC session.\n\n" + "This widget is only meaningful when the FlOMNI\n" + "BEC session is active (fsamroy device present)." + ) + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + lbl.setWordWrap(True) + root.addStretch() + root.addWidget(lbl) + root.addStretch() + return + # scrollable params panel params_scroll = QScrollArea() params_scroll.setWidgetResizable(True) @@ -230,7 +257,6 @@ class TomoParamsWidget(BECWidget, QWidget): self._btn_queue.setToolTip("Open the tomo queue manager") self._btn_queue.clicked.connect(self._show_queue_dialog) root.addWidget(self._btn_queue) - self._queue_dlg: Optional["TomoQueueDialog"] = None def _build_header(self) -> QGroupBox: box = QGroupBox("Sample") @@ -340,6 +366,13 @@ class TomoParamsWidget(BECWidget, QWidget): form = QFormLayout(box) form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) + self._add_int( + form, + "_numprj_type3", + "Projections per sub-tomo (type 3)", + min_=1, + max_=10000, + ) self._add_int( form, "golden_ratio_bunch_size", "Bunch size (type 2 only)", min_=1, max_=10000 ) @@ -448,6 +481,15 @@ class TomoParamsWidget(BECWidget, QWidget): self._pw["_requested_total"].blockSignals(False) self._update_projection_preview() + + # type-3: derive projections-per-subtomo from stored tomo_angle_stepsize + # (flomni uses 180 / tomo_angle_stepsize for type 3, hardcoded to 180) + stepsize = float(params.get("tomo_angle_stepsize", 10.0)) + numprj_t3 = int(180.0 / stepsize) if stepsize > 0 else 18 + self._pw["_numprj_type3"].blockSignals(True) + self._pw["_numprj_type3"].setValue(numprj_t3) + self._pw["_numprj_type3"].blockSignals(False) + self._update_type_visibility(type_val) def _read_fields(self) -> dict[str, Any]: @@ -470,12 +512,19 @@ class TomoParamsWidget(BECWidget, QWidget): elif isinstance(widget, QComboBox): params[key] = widget.currentData() - # derive tomo_angle_stepsize from requested_total + angle_range + # derive tomo_angle_stepsize from requested_total + angle_range (type 1) angle_range = int(params.get("tomo_angle_range", 180)) requested = self._pw["_requested_total"].value() stepsize = _requested_to_stepsize(angle_range, requested) params["tomo_angle_stepsize"] = stepsize + # type 3: override tomo_angle_stepsize from projections-per-subtomo + # (flomni uses 180 / numprj for type 3) + if params.get("tomo_type") == 3: + numprj = self._pw["_numprj_type3"].value() + if numprj > 0: + params["tomo_angle_stepsize"] = 180.0 / numprj + # single_point forces stitch to 0 if params.get("single_point_instead_of_fermat_scan"): params["stitch_x"] = 0 @@ -508,19 +557,30 @@ class TomoParamsWidget(BECWidget, QWidget): def _update_type_visibility(self, tomo_type: int) -> None: self._sec_type1.setVisible(tomo_type == 1) self._sec_type23.setVisible(tomo_type in (2, 3)) - # bunch_size is type-2 only; find its row in the form layout - bunch_widget = self._pw.get("golden_ratio_bunch_size") - if bunch_widget is not None: - bunch_widget.setVisible(tomo_type == 2) - # also hide the label in the form layout - form = self._sec_type23.layout() - if isinstance(form, QFormLayout): - idx = form.indexOf(bunch_widget) - if idx >= 0: - row, role = form.getItemPosition(idx) - label_item = form.itemAt(row, QFormLayout.ItemRole.LabelRole) - if label_item and label_item.widget(): - label_item.widget().setVisible(tomo_type == 2) + # _numprj_type3: only visible for type 3 + _w__numprj_type3 = self._pw.get("_numprj_type3") + if _w__numprj_type3 is not None: + _w__numprj_type3.setVisible(tomo_type == 3) + _form = self._sec_type23.layout() + if isinstance(_form, QFormLayout): + _idx = _form.indexOf(_w__numprj_type3) + if _idx >= 0: + _row, _role = _form.getItemPosition(_idx) + _lbl = _form.itemAt(_row, QFormLayout.ItemRole.LabelRole) + if _lbl and _lbl.widget(): + _lbl.widget().setVisible(tomo_type == 3) + # golden_ratio_bunch_size: only visible for type 2 + _w_golden_ratio_bunch_size = self._pw.get("golden_ratio_bunch_size") + if _w_golden_ratio_bunch_size is not None: + _w_golden_ratio_bunch_size.setVisible(tomo_type == 2) + _form = self._sec_type23.layout() + if isinstance(_form, QFormLayout): + _idx = _form.indexOf(_w_golden_ratio_bunch_size) + if _idx >= 0: + _row, _role = _form.getItemPosition(_idx) + _lbl = _form.itemAt(_row, QFormLayout.ItemRole.LabelRole) + if _lbl and _lbl.widget(): + _lbl.widget().setVisible(tomo_type == 2) # ── type-1 projection preview ───────────────────────────────────────────── -- 2.54.0 From e2b9fbeb284aca04f81d60b224bf5884c2ba8fcf Mon Sep 17 00:00:00 2001 From: x12sa Date: Fri, 3 Jul 2026 06:48:28 +0200 Subject: [PATCH 03/32] tomo queue added --- .../flomni/flomni_webpage_generator.py | 135 +++++++++++++++++- 1 file changed, 129 insertions(+), 6 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 9e1f9ac..b00622e 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -103,10 +103,8 @@ VERBOSITY_VERBOSE = 2 VERBOSITY_DEBUG = 3 _PHONE_NUMBERS = [ - ("Beamline", "+41 56 310 5845"), - ("Beamline mobile", "+41 56 310 5844"), - ("Local contact", "+41 79 343 9230"), - ("Control room", "+41 56 310 5503"), + ("Beamline", "+41 56 310 5845"), + ("Control room", "+41 56 310 5503"), ] @@ -1030,6 +1028,12 @@ class WebpageGeneratorBase: else max(0.0, _epoch() - self._last_active_time) ) + # ── Tomo queue ─────────────────────────────────────────────── + # Persisted list of parameter-snapshot jobs (global var "tomo_queue"). + # Each job: {"label": str, "params": {...}, + # "status": pending|running|incomplete|done, "added_at": ISO str} + tomo_queue = self._bec.get_global_var("tomo_queue") or [] + # ── Reconstruction queue ────────────────────────────────────── recon = self._collect_recon_data() @@ -1061,6 +1065,7 @@ class WebpageGeneratorBase: "idle_for_human": _format_duration(idle_for_s), "queue_locks": queue_locks, "beamline_states": beamline_states_info, + "tomo_queue": tomo_queue, "progress": { "tomo_type": progress.get("tomo_type", "N/A"), "projection": progress.get("projection", 0), @@ -1806,6 +1811,52 @@ def _render_html(phone_numbers: list) -> str: .bl-badge.bl-watched-no {{ background: var(--surface2); color: var(--text-dim); }} .blstates-none {{ font-family: var(--mono); font-size: 0.75rem; color: var(--text-dim); padding: 0.5rem 0; }} + /* ── Tomo queue card ── */ + .tq-empty {{ + font-family: var(--mono); font-size: 0.78rem; color: var(--text-dim); + padding: 0.25rem 0; + }} + .tq-job {{ border-bottom: 1px solid var(--border); }} + .tq-job:last-child {{ border-bottom: none; }} + .tq-summary {{ + display: flex; align-items: center; gap: 0.75rem; + padding: 0.55rem 0; cursor: pointer; list-style: none; + font-size: 0.85rem; + }} + .tq-summary::-webkit-details-marker {{ display: none; }} + .tq-summary::before {{ + content: '▶'; font-family: var(--mono); font-size: 0.55rem; + color: var(--text-dim); transition: transform 0.2s; flex-shrink: 0; + }} + details.tq-job[open] > .tq-summary::before {{ transform: rotate(90deg); }} + .tq-num {{ + font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim); + min-width: 1.8rem; flex-shrink: 0; + }} + .tq-label {{ flex: 1; font-weight: 600; color: var(--text); }} + .tq-badge {{ + font-family: var(--mono); font-size: 0.58rem; font-weight: 700; + letter-spacing: 0.06em; text-transform: uppercase; + padding: 0.12rem 0.45rem; border-radius: 100px; white-space: nowrap; + flex-shrink: 0; + }} + .tq-badge.tq-pending {{ background: var(--surface2); color: var(--text-dim); }} + .tq-badge.tq-running {{ background: color-mix(in srgb, var(--c-scanning) 20%, transparent); color: var(--c-scanning); }} + .tq-badge.tq-incomplete {{ background: color-mix(in srgb, var(--c-blocked) 20%, transparent); color: var(--c-blocked); }} + .tq-badge.tq-done {{ background: color-mix(in srgb, var(--c-running) 20%, transparent); color: var(--c-running); }} + .tq-details {{ padding: 0.4rem 0 0.7rem 2.4rem; }} + .tq-params {{ width: 100%; border-collapse: collapse; }} + .tq-params td {{ padding: 0.18rem 0; font-size: 0.78rem; vertical-align: top; }} + .tq-params td:first-child {{ + font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim); + padding-right: 1rem; white-space: nowrap; width: 40%; + }} + .tq-params td:last-child {{ color: var(--text); font-weight: 600; }} + .tq-added {{ + font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim); + margin-top: 0.4rem; + }} + /* ── Audio card ── */ .audio-card {{ display: flex; align-items: center; @@ -1964,6 +2015,7 @@ def _render_html(phone_numbers: list) -> str:
Remaining-
Finish time-
Started-
+ @@ -2081,7 +2133,14 @@ def _render_html(phone_numbers: list) -> str: - + +
+
⋮⋮
+
Tomo queue
+
+
+ +
⋮⋮
Contacts
@@ -2119,7 +2178,7 @@ function setTheme(t) {{ // ── Drag-and-drop card ordering ────────────────────────────────────────── const CARD_ORDER_KEY = 'cardOrder'; -const DEFAULT_ORDER = ['audio','recon-queue','ptycho','instrument','blstates','contacts']; +const DEFAULT_ORDER = ['audio','recon-queue','ptycho','instrument','blstates','tomo-queue','contacts']; let _dragSrc = null; function savedOrder() {{ @@ -2555,6 +2614,61 @@ function renderBeamlineStates(bl){{ tbody.innerHTML=html; }} +// Key params to show in expanded job detail, in display order. +const TQ_PARAM_DISPLAY = [ + ['tomo_type', 'Type'], + ['tomo_countingtime', 'Exposure (s)'], + ['fovx', 'FOV x (\u00b5m)'], + ['fovy', 'FOV y (\u00b5m)'], + ['tomo_angle_stepsize', 'Angle step (\u00b0)'], + ['tomo_angle_range', 'Angle range (\u00b0)'], + ['tomo_shellstep', 'Shell step (\u00b5m)'], + ['stitch_x', 'Stitch x'], + ['stitch_y', 'Stitch y'], + ['ptycho_reconstruct_foldername','Recon folder'], +]; + +function fmtTqParam(key, val){{ + if(val==null) return '-'; + if(typeof val==='number') return Number.isInteger(val)?String(val):val.toFixed(3).replace(/\\.?0+$/,''); + return String(val); +}} + +function renderTomoQueue(jobs){{ + const el=document.getElementById('tomo-queue-content'); + if(!jobs||jobs.length===0){{ + el.innerHTML='
No jobs queued
'; + return; + }} + let html=''; + jobs.forEach((job,i)=>{{ + const status=job.status||'pending'; + const label=job.label||('Job '+(i+1)); + const params=job.params||{{}}; + let paramRows=''; + TQ_PARAM_DISPLAY.forEach(([key,dispLabel])=>{{ + if(key in params){{ + paramRows+=''+dispLabel+''+esc(fmtTqParam(key,params[key]))+''; + }} + }}); + const addedAt=job.added_at + ?'Added '+new Date(job.added_at).toLocaleString([],{{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}}) + :''; + html+='
' + +'' + +'#'+(i+1)+'' + +''+esc(label)+'' + +''+esc(status)+'' + +'' + +'
' + +(paramRows?''+paramRows+'
':'') + +(addedAt?'
'+esc(addedAt)+'
':'') + +'
' + +'
'; + }}); + el.innerHTML=html; +}} + function render(d){{ const s=d.experiment_status||'unknown',p=d.progress||{{}}; document.body.className=s; @@ -2576,6 +2690,14 @@ function render(d){{ document.getElementById('pi-remaining').textContent=p.estimated_remaining_human||'-'; document.getElementById('pi-finish').textContent=fmtTime(p.estimated_finish_time); document.getElementById('pi-start').textContent=fmtTime(p.tomo_start_time); + const runningJob=(d.tomo_queue||[]).find(j=>j.status==='running'); + const queueItem=document.getElementById('pi-queue-item'); + if(runningJob){{ + document.getElementById('pi-queue-label').textContent=runningJob.label||'-'; + queueItem.style.display=''; + }}else{{ + queueItem.style.display='none'; + }} if(d.recon){{ document.getElementById('recon-waiting').textContent=d.recon.waiting; @@ -2586,6 +2708,7 @@ function render(d){{ }} renderInstrument(d.setup); renderBeamlineStates(d.beamline_states); + renderTomoQueue(d.tomo_queue); renderPtycho(d.ptycho); document.getElementById('last-update').textContent='updated '+new Date(d.generated_at).toLocaleTimeString(); const ageS=(Date.now()/1000)-d.generated_at_epoch; -- 2.54.0 From 4d225c2d796a6675e688daab08d1189e6c55bc7f Mon Sep 17 00:00:00 2001 From: x12sa Date: Fri, 3 Jul 2026 07:02:07 +0200 Subject: [PATCH 04/32] adjustments --- .../flomni/flomni_webpage_generator.py | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index b00622e..5fb7fe2 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -2616,16 +2616,18 @@ function renderBeamlineStates(bl){{ // Key params to show in expanded job detail, in display order. const TQ_PARAM_DISPLAY = [ - ['tomo_type', 'Type'], - ['tomo_countingtime', 'Exposure (s)'], - ['fovx', 'FOV x (\u00b5m)'], - ['fovy', 'FOV y (\u00b5m)'], - ['tomo_angle_stepsize', 'Angle step (\u00b0)'], - ['tomo_angle_range', 'Angle range (\u00b0)'], - ['tomo_shellstep', 'Shell step (\u00b5m)'], - ['stitch_x', 'Stitch x'], - ['stitch_y', 'Stitch y'], - ['ptycho_reconstruct_foldername','Recon folder'], + ['tomo_type', 'Type'], + ['tomo_countingtime', 'Exposure (s)'], + ['fovx', 'FOV x (\u00b5m)'], + ['fovy', 'FOV y (\u00b5m)'], + ['tomo_angle_stepsize', 'Angle step (\u00b0)'], + ['tomo_angle_range', 'Angle range (\u00b0)'], + ['tomo_shellstep', 'Shell step (\u00b5m)'], + ['stitch_x', 'Stitch x'], + ['stitch_y', 'Stitch y'], + ['frames_per_trigger', 'Frames/trigger'], + ['single_point_instead_of_fermat_scan','Single point'], + ['ptycho_reconstruct_foldername', 'Recon folder'], ]; function fmtTqParam(key, val){{ @@ -2636,6 +2638,11 @@ function fmtTqParam(key, val){{ function renderTomoQueue(jobs){{ const el=document.getElementById('tomo-queue-content'); + // Snapshot which job indices are currently open before replacing innerHTML + const openIndices=new Set(); + el.querySelectorAll('details.tq-job').forEach((det,i)=>{{ + if(det.open) openIndices.add(i); + }}); if(!jobs||jobs.length===0){{ el.innerHTML='
No jobs queued
'; return; @@ -2645,16 +2652,41 @@ function renderTomoQueue(jobs){{ const status=job.status||'pending'; const label=job.label||('Job '+(i+1)); const params=job.params||{{}}; + // Auto-open running jobs on first render; preserve user-opened state after that + const shouldOpen=openIndices.size>0?openIndices.has(i):(status==='running'); let paramRows=''; TQ_PARAM_DISPLAY.forEach(([key,dispLabel])=>{{ if(key in params){{ paramRows+=''+dispLabel+''+esc(fmtTqParam(key,params[key]))+''; }} + // After angle step, inject a computed projection count row. + // Formula must match _tomo_type1_actual_grid() / tomo_parameters() exactly: + // type 1: N = int(range / stepsize), corrected_step = range / N, total = N * 8 + // type 2/3: golden_max_number_of_projections directly + if(key==='tomo_angle_stepsize'){{ + const ttype=params.tomo_type; + let nproj=null; + if(ttype===2||ttype===3){{ + // golden ratio / equally-spaced golden: capped at golden_max_number_of_projections + const gmax=params.golden_max_number_of_projections; + nproj=(gmax!=null&&gmax>0)?gmax:null; + }}else{{ + // type 1: 8 equally-spaced sub-tomograms. + // int(range/stepsize) = N per sub-tomo; total = N * 8 + // (mirrors _tomo_type1_actual_grid: N=int(range/step), total=N*8) + const range=params.tomo_angle_range, step=params.tomo_angle_stepsize; + if(range>0&&step>0){{ + const N=Math.floor(range/step); + nproj=N*8; + }} + }} + if(nproj!=null) paramRows+='Projections'+nproj+''; + }} }}); const addedAt=job.added_at ?'Added '+new Date(job.added_at).toLocaleString([],{{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}}) :''; - html+='
' + html+='
' +'' +'#'+(i+1)+'' +''+esc(label)+'' -- 2.54.0 From cb66b967f60fdb541f993c5b34ccb03640022cac Mon Sep 17 00:00:00 2001 From: x12sa Date: Fri, 3 Jul 2026 07:02:30 +0200 Subject: [PATCH 05/32] block entry while queue or tomo heartbeat --- .../widgets/tomo_params/tomo_params.py | 131 +++++++++++++++--- 1 file changed, 114 insertions(+), 17 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 b18e5f1..778928c 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -14,6 +14,22 @@ sync with changes made in a parallel CLI session. The parameter panel is refreshed on demand (when the user opens it or submits changes) rather than on every poll, to avoid clobbering in-progress edits. +Beamline-busy detection +------------------------ +Two independent "something is running" signals gate parameter submission and +drive an advisory banner: + +* ``tomo_progress["heartbeat"]`` — set by ``_tomo_scan_at_angle()`` at every + projection, whether the scan was queue- or CLI-started. +* the BEC scan queue — catches a single ptycho projection (or any other scan) + run manually outside a tomo run, which writes no heartbeat. The busy logic + mirrors bec_widgets' ``BECQueue`` widget so it stays consistent with the + queue display. + +The banner is advisory (soft-warn): editing while busy is still allowed — +property-backed params such as counting time take effect on the next +projection — but Submit asks for confirmation. + Queue execution --------------- ``tomo_queue_execute()`` is a blocking Flomni CLI method that cannot be @@ -28,6 +44,7 @@ import datetime from typing import Any, Optional from bec_lib import bec_logger +from bec_lib.endpoints import MessageEndpoints from bec_widgets import BECWidget, SafeSlot from qtpy.QtCore import Qt, QTimer from qtpy.QtWidgets import ( @@ -70,6 +87,10 @@ STATUS_COLORS = { "done": "#4CAF50", } +# Scan-queue item statuses that mean "not occupying hardware". +# Mirrors bec_widgets' BECQueue.update_queue busy logic verbatim. +_QUEUE_IDLE_STATUSES = ("STOPPED", "COMPLETED", "IDLE") + # Exact tuple from Flomni._TOMO_QUEUE_PARAM_NAMES (source of truth in flomni.py) QUEUE_PARAM_NAMES = ( "tomo_countingtime", @@ -132,6 +153,8 @@ class TomoParamsWidget(BECWidget, QWidget): Layout ------ + Top: + - Advisory beamline-busy banner (hidden unless a scan/queue is active) Top half (scrollable): - Read-only sample-name header - Tomo-type dropdown (always visible) @@ -148,13 +171,7 @@ class TomoParamsWidget(BECWidget, QWidget): PLUGIN = True - USER_ACCESS = [ - "refresh", - "enter_edit_mode", - "cancel_edit", - "submit_params", - "add_to_queue", - ] + USER_ACCESS = ["refresh", "enter_edit_mode", "cancel_edit", "submit_params", "add_to_queue"] _POLL_INTERVAL_MS = 2000 @@ -164,6 +181,7 @@ class TomoParamsWidget(BECWidget, QWidget): self._edit_mode = False self._pw: dict[str, QWidget] = {} # key -> input widget self._queue_dlg: Optional["TomoQueueDialog"] = None + self._busy_banner: Optional[QLabel] = None # check whether the FlOMNI setup is active before building the UI self._flomni_available = self._check_flomni_available() self._build_ui() @@ -173,6 +191,15 @@ class TomoParamsWidget(BECWidget, QWidget): if self._flomni_available: self._poll_timer.start() self.refresh() + # live beamline-busy banner: react to scan-queue changes as they + # happen (the tomo heartbeat has no event and is covered by _on_poll) + try: + self.bec_dispatcher.connect_slot( + self._on_queue_status, MessageEndpoints.scan_queue_status() + ) + except Exception as exc: + logger.warning(f"TomoParamsWidget: could not subscribe to scan queue: {exc}") + self._refresh_busy_banner() # ── setup detection ────────────────────────────────────────────────────── @@ -226,9 +253,26 @@ class TomoParamsWidget(BECWidget, QWidget): root = QVBoxLayout(self) root.setSpacing(6) + # advisory busy banner sits at the very top, present regardless of + # setup so it can warn before the user starts editing (hidden default) + self._busy_banner = QLabel("") + self._busy_banner.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._busy_banner.setWordWrap(True) + self._busy_banner.setStyleSheet( + "QLabel {" + " background-color: #FF9800;" + " color: black;" + " font-weight: bold;" + " border-radius: 4px;" + " padding: 6px;" + "}" + ) + self._busy_banner.setVisible(False) + root.addWidget(self._busy_banner) + if not self._flomni_available: lbl = QLabel( - "⚠ FlOMNI setup not detected in this BEC session.\n\n" + "⚠ FlOMNI setup not detected in this BEC session.\n\n" "This widget is only meaningful when the FlOMNI\n" "BEC session is active (fsamroy device present)." ) @@ -367,11 +411,7 @@ class TomoParamsWidget(BECWidget, QWidget): form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) self._add_int( - form, - "_numprj_type3", - "Projections per sub-tomo (type 3)", - min_=1, - max_=10000, + form, "_numprj_type3", "Projections per sub-tomo (type 3)", min_=1, max_=10000 ) self._add_int( form, "golden_ratio_bunch_size", "Bunch size (type 2 only)", min_=1, max_=10000 @@ -624,6 +664,62 @@ class TomoParamsWidget(BECWidget, QWidget): """Periodic refresh: params (guarded against edit mode) + sample name.""" self._refresh_params() self._refresh_sample_name() + self._refresh_busy_banner() + + # ── beamline-busy detection ─────────────────────────────────────────────── + + def _is_scan_queue_busy(self) -> bool: + """ + Return True if BEC's primary scan queue is actively running or paused + mid-scan. + + Catches a single ptycho projection run manually (outside a tomo scan): + it writes no ``tomo_progress`` heartbeat but still occupies the + beamline. Mirrors the busy logic in bec_widgets' BECQueue widget so + it stays consistent with what the queue display shows. + """ + try: + msg = self.client.connector.get(MessageEndpoints.scan_queue_status()) + if msg is None: + return False # no scan has run yet this session + primary = msg.content.get("queue", {}).get("primary") + if not primary: + return False + queue_info = getattr(primary, "info", None) + if not queue_info: + return False + return not all(item.status in _QUEUE_IDLE_STATUSES for item in queue_info) + except Exception as exc: + logger.warning(f"TomoParamsWidget: scan-queue busy check failed: {exc}") + return False + + def _beamline_busy_reason(self) -> Optional[str]: + """Return a short human reason if the beamline is busy, else None.""" + if self._is_tomo_running(): + return "tomo scan running" + if self._is_scan_queue_busy(): + return "scan queue active (manual projection or other scan)" + return None + + @SafeSlot(dict, dict) + def _on_queue_status(self, _content: dict, _meta: dict) -> None: + """Dispatcher slot: scan-queue status changed -> refresh the banner.""" + self._refresh_busy_banner() + + def _refresh_busy_banner(self) -> None: + """Show/hide the advisory busy banner based on current beamline state.""" + banner = getattr(self, "_busy_banner", None) + if banner is None: + return + reason = self._beamline_busy_reason() + if reason: + banner.setText( + f"\u26a0 Beamline busy — {reason}.\n" + "Editing is allowed, but submitting changes will ask for confirmation." + ) + banner.setVisible(True) + else: + banner.setVisible(False) # ── public refresh API ──────────────────────────────────────────────────── @@ -723,15 +819,16 @@ class TomoParamsWidget(BECWidget, QWidget): def submit_params(self) -> None: """Validate, write to global vars, and re-lock fields.""" - if self._is_tomo_running(): + busy_reason = self._beamline_busy_reason() + if busy_reason: reply = QMessageBox.warning( self, "Scan in progress", - "A tomo scan appears to be running.\n\n" + f"A scan appears to be running ({busy_reason}).\n\n" "Parameters read via properties (e.g. counting time) take effect " "immediately on the next projection. Parameters used to build the " - "scan trajectory (FOV, angle step) are already fixed for this run " - "but will affect any subsequent queue job.\n\n" + "scan trajectory (FOV, angle step) are already fixed for the " + "current run but will affect any subsequent queue job.\n\n" "Apply changes now?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, ) -- 2.54.0 From 3d6b6d448d6645402fa250ef0c0db16afe282ad7 Mon Sep 17 00:00:00 2001 From: x12sa Date: Fri, 3 Jul 2026 07:02:37 +0200 Subject: [PATCH 06/32] config update --- csaxs_bec/device_configs/ptycho_flomni.yaml | 28 ++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index 9809928..23695ab 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -79,11 +79,11 @@ foptx: #170 micron, 60 nm #in: -13.831 #250 micron, 30 nm, Abe structures - # in: -13.8809375 - # out: -14.1809 - #250 micron, 30 nm, Tomas structures - in: -14.5490625 + in: -13.8809375 out: -14.1809 + #250 micron, 30 nm, Tomas structures + # in: -14.5490625 + # out: -14.1809 fopty: description: Optics Y @@ -106,11 +106,11 @@ fopty: #in: 0.42 #out: 0.57 #250 micron, 30 nm, Abe structures - # in: 2.8299 - # out: 2.8299 + in: 2.8299 + out: 2.8299 #250 micron, 30 nm, Tomas structures - in: 2.8419921875 - out: 2.8419921875 + # in: 2.8419921875 + # out: 2.8419921875 foptz: description: Optics Z @@ -324,11 +324,11 @@ fosax: # in: 8.731922 # out: 5.1 #250 micron, 30 nm, Abe structures - # in: 8.755141 - # out: 5.1 - #250 micron, 30 nm, Tomas structures - in: 9.420798 + in: 8.755141 out: 5.1 + #250 micron, 30 nm, Tomas structures + # in: 9.420798 + # out: 5.1 fosay: description: OSA Y @@ -352,9 +352,9 @@ fosay: #170 micron, 60 nm, 7.6 kev #in: -0.0422 #250 micron, 30 nm, Abe structures - # in: -2.357436 + in: -2.357436 #250 micron, 30 nm, Tomas structures - in: -2.383993 + # in: -2.383993 fosaz: description: OSA Z deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor -- 2.54.0 From 65c2d5e080514901a6d4e224ac46b2550d876a5f Mon Sep 17 00:00:00 2001 From: x12sa Date: Fri, 3 Jul 2026 07:19:59 +0200 Subject: [PATCH 07/32] table in queue modified --- .../widgets/tomo_params/tomo_params.py | 70 +++++++++++++++++-- 1 file changed, 66 insertions(+), 4 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 778928c..9e1b9a8 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -758,11 +758,14 @@ class TomoParamsWidget(BECWidget, QWidget): job.get("label", f"job_{row}"), status, str(params.get("tomo_type", "?")), - f"{params.get('fovx', '?')} × {params.get('fovy', '?')}", + _format_projections(params), + _fmt_num(params.get("tomo_countingtime")), + _fmt_num(params.get("tomo_shellstep")), job.get("added_at", ""), ] for col, text in enumerate(cells): item = QTableWidgetItem(text) + item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) item.setForeground(table.palette().text() if col != 2 else _color_from_hex(color)) table.setItem(row, col, item) @@ -976,9 +979,18 @@ class TomoQueueDialog(QDialog): vbox = QVBoxLayout(self) vbox.setSpacing(6) - self._table = QTableWidget(0, 6) + self._table = QTableWidget(0, 8) self._table.setHorizontalHeaderLabels( - ["#", "Label", "Status", "Type", "FOV x×y (µm)", "Added at"] + [ + "#", + "Label", + "Status", + "Type", + "Projections", + "Exp (s)", + "Step (µm)", + "Added at", + ] ) self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) @@ -1015,11 +1027,14 @@ class TomoQueueDialog(QDialog): job.get("label", f"job_{row}"), status, str(params.get("tomo_type", "?")), - f"{params.get('fovx', '?')} × {params.get('fovy', '?')}", + _format_projections(params), + _fmt_num(params.get("tomo_countingtime")), + _fmt_num(params.get("tomo_shellstep")), job.get("added_at", ""), ] for col, text in enumerate(cells): item = QTableWidgetItem(text) + item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) if col == 2: item.setForeground(_color_from_hex(color)) self._table.setItem(row, col, item) @@ -1126,6 +1141,53 @@ def _compute_type1(angle_range: int, stepsize: float) -> tuple[int, float, float return actual_total, achievable_step, stepsize +def _format_projections(params: dict) -> str: + """ + Human-readable projection count for a queued job, mirroring the CLI's + per-type logic: + - type 1: int(angle_range / stepsize) * 8 (8 equally spaced sub-tomos) + - type 3: int(180 / stepsize) * 8 (equally spaced, golden start) + - type 2: golden ratio has no fixed count -> configured max, or ∞ if unset + """ + try: + tomo_type = int(params.get("tomo_type", 1)) + stepsize = float(params.get("tomo_angle_stepsize", 0) or 0) + + if tomo_type == 1: + angle_range = int(params.get("tomo_angle_range", 180)) + actual_total, _, _ = _compute_type1(angle_range, stepsize) + return str(actual_total) + + if tomo_type == 3: + if stepsize <= 0: + return "?" + return str(int(180.0 / stepsize) * 8) + + if tomo_type == 2: + max_prj = params.get("golden_max_number_of_projections", 0) or 0 + try: + max_prj = int(float(max_prj)) + except (TypeError, ValueError): + return "∞" + return str(max_prj) if max_prj > 0 else "∞" + + return "?" + except Exception: + return "?" + + +def _fmt_num(val) -> str: + """Compact numeric formatting for table cells; blank for None.""" + if val is None: + return "" + try: + f = float(val) + except (TypeError, ValueError): + return str(val) + # trim trailing zeros: 0.100 -> 0.1, 1.0 -> 1 + return f"{f:g}" + + def _color_from_hex(hex_color: str): from qtpy.QtGui import QColor -- 2.54.0 From e6a5568fafca771f3095c56d17a9b77c76146e39 Mon Sep 17 00:00:00 2001 From: x12sa Date: Fri, 3 Jul 2026 11:29:35 +0200 Subject: [PATCH 08/32] fix in writing reconstruction file --- csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py | 1 + 1 file changed, 1 insertion(+) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 2dbca15..2e1c766 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -2583,6 +2583,7 @@ class Flomni( # --- acquire --- n_frames = frames_per_trigger if frames_per_trigger is not None else self.frames_per_trigger + self._current_scan_list = [bec.queue.next_scan_number] scans.acquire(exp_time=self.tomo_countingtime, frames_per_trigger=n_frames) self.tomo_reconstruct(probe_propagation=probe_propagation) -- 2.54.0 From 7e656ea9c11f7f13e81b7b987e62161730f9d14b Mon Sep 17 00:00:00 2001 From: x12sa Date: Fri, 3 Jul 2026 11:34:35 +0200 Subject: [PATCH 09/32] list active account on webpage top --- .../plugins/flomni/flomni_webpage_generator.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 5fb7fe2..6825d73 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -1091,6 +1091,7 @@ class WebpageGeneratorBase: "generator": { "owner_id": self._owner_id, "cycle_interval_s": self._cycle_interval, + "active_account": getattr(self._bec, "active_account", None) or "", }, } @@ -1573,6 +1574,10 @@ def _render_html(phone_numbers: list) -> str: font-family: var(--mono); font-size: 0.7rem; font-weight: 700; letter-spacing: 0.15em; color: var(--text-dim); }} + .logo-account {{ + font-family: var(--mono); font-size: 0.7rem; font-weight: 400; + letter-spacing: 0.06em; color: var(--text-dim); + }} /* header right: update time + theme switcher */ .header-right {{ display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }} @@ -1972,6 +1977,7 @@ def _render_html(phone_numbers: list) -> str: onerror="document.getElementById('logo-img').hidden=true; document.getElementById('logo-text').hidden=false;"> · STATUS +
@@ -2749,6 +2755,8 @@ function render(d){{ handleStale(isStale); handleBlocked(s==='blocked'); document.getElementById('footer-gen').textContent='generator: '+((d.generator||{{}}).owner_id||'-'); + const acct=(d.generator||{{}}).active_account||''; + document.getElementById('active-account').textContent=acct?'\u00b7\u00a0'+acct:''; document.getElementById('footer-hb').textContent='tomo_heartbeat: '+(p.tomo_heartbeat_age_s!=null?p.tomo_heartbeat_age_s+'s ago':'none'); const prevStatus=lastStatus; -- 2.54.0 From be900c228e063458fc9ab51eef0fc618277f737a Mon Sep 17 00:00:00 2001 From: x12sa Date: Fri, 3 Jul 2026 12:10:28 +0200 Subject: [PATCH 10/32] parameter update --- csaxs_bec/device_configs/ptycho_flomni.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index 23695ab..4e22c13 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -403,8 +403,8 @@ rtx: readoutPriority: on_request connectionTimeout: 20 userParameter: - low_signal: 10000 - min_signal: 9000 + low_signal: 9500 + min_signal: 8500 rt_pid_voltage: -0.06219 rty: description: flomni rt -- 2.54.0 From a1295ac967810415067c490e1786822f0e7c45d6 Mon Sep 17 00:00:00 2001 From: x12sa Date: Fri, 3 Jul 2026 13:28:02 +0200 Subject: [PATCH 11/32] option for random offset in single point acquisitions --- .../plugins/flomni/flomni.py | 58 +++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 2e1c766..d34af24 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -1,6 +1,7 @@ import builtins import datetime import os +import random import subprocess import time from pathlib import Path @@ -1597,6 +1598,25 @@ class Flomni( def manual_shift_y(self, val: float): self.client.set_global_var("manual_shift_y", val) + @property + def single_point_random_shift_max(self): + """Maximum absolute random shift [um] applied independently in x and y + at each single-point acquisition (tomo_acquire_at_angle). At each + angle a fresh random offset drawn uniformly from + [-single_point_random_shift_max, +single_point_random_shift_max] is + added to both x and y. 0 disables the random shift. Only takes effect + when single_point_instead_of_fermat_scan is active.""" + val = self.client.get_global_var("single_point_random_shift_max") + if val is None: + return 0.0 + return val + + @single_point_random_shift_max.setter + def single_point_random_shift_max(self, val: float): + if not 0 <= val <= 10: + raise ValueError("single_point_random_shift_max must be between 0 and 10 um.") + self.client.set_global_var("single_point_random_shift_max", val) + @property def fovx(self): val = self.client.get_global_var("fovx") @@ -2443,6 +2463,8 @@ class Flomni( self, base_path="~/data/raw/logs/reconstruction_queue", probe_propagation: float | None = None, + random_offset_x: float | None = None, + random_offset_y: float | None = None, ): """write the tomo reconstruct file for the reconstruction queue""" bec = builtins.__dict__.get("bec") @@ -2451,6 +2473,8 @@ class Flomni( next_scan_number=bec.queue.next_scan_number, base_path=base_path, probe_file_propagation=probe_propagation, + random_offset_x=random_offset_x, + random_offset_y=random_offset_y, ) def _write_tomo_scan_number(self, scan_number: int, angle: float, subtomo_number: int) -> None: @@ -2458,7 +2482,7 @@ class Flomni( with open(tomo_scan_numbers_file, "a+") as out_file: # pylint: disable=undefined-variable out_file.write( - f"{scan_number} {angle} {dev.fsamroy.read()['fsamroy']['value']:.3f} {self.tomo_id} {subtomo_number} {0} {self.sample_name}\n" + f"{scan_number} {angle} {dev.fsamroy.read()['fsamroy']['value']:.5f} {self.tomo_id} {subtomo_number} {0} {self.sample_name}\n" ) def tomo_scan_projection(self, angle: float): @@ -2550,17 +2574,30 @@ class Flomni( else: print("No rotation required") + # --- optional random shift in x and y (single-point acquisitions) --- + # A fresh offset is drawn per angle from a uniform distribution over + # [-single_point_random_shift_max, +single_point_random_shift_max] and + # added independently to x and y. 0 (the default) disables it. + random_shift_max = self.single_point_random_shift_max + if random_shift_max > 0: + random_shift_x = random.uniform(-random_shift_max, random_shift_max) + random_shift_y = random.uniform(-random_shift_max, random_shift_max) + else: + random_shift_x = 0.0 + random_shift_y = 0.0 + # --- alignment offset (same as tomo_scan_projection, no stitching) --- offsets = self.get_alignment_offset(angle) - sum_offset_x = offsets[0] + sum_offset_x = offsets[0] + random_shift_x sum_offset_y = ( offsets[1] - self.compute_additional_correction_y(angle) - self.compute_additional_correction_y_2(angle) + self.manual_shift_y + + random_shift_y ) sum_offset_z = offsets[2] - + # TODO this fix is while the tracker z is broken probe_propagation = -sum_offset_z * 1e-6 sum_offset_z = 0 @@ -2585,7 +2622,11 @@ class Flomni( n_frames = frames_per_trigger if frames_per_trigger is not None else self.frames_per_trigger self._current_scan_list = [bec.queue.next_scan_number] scans.acquire(exp_time=self.tomo_countingtime, frames_per_trigger=n_frames) - self.tomo_reconstruct(probe_propagation=probe_propagation) + self.tomo_reconstruct( + probe_propagation=probe_propagation, + random_offset_x=random_shift_x, + random_offset_y=random_shift_y, + ) def _tomo_type1_actual_grid(self) -> tuple[int, float, int]: """Compute the actual (achievable) tomo_type==1 grid from the @@ -2623,6 +2664,8 @@ class Flomni( print(f" _manual_shift_y = {self.manual_shift_y}") print(f"Frames per trigger (burst) = {self.frames_per_trigger}") print(f"Single point instead of fermat = {self.single_point_instead_of_fermat_scan}") + if self.single_point_instead_of_fermat_scan: + print(f"Single point random shift max = {self.single_point_random_shift_max}") print("") if self.tomo_type == 1: @@ -2710,6 +2753,12 @@ class Flomni( ) self.stitch_x = 0 self.stitch_y = 0 + if self.single_point_instead_of_fermat_scan: + self.single_point_random_shift_max = self._get_val( + " um (0 = off)", + self.single_point_random_shift_max, + float, + ) print("Tomography type:") print(" 1: 8 equally spaced sub-tomograms") @@ -2822,6 +2871,7 @@ class Flomni( "manual_shift_y", "frames_per_trigger", "single_point_instead_of_fermat_scan", + "single_point_random_shift_max", "tomo_type", "tomo_angle_range", "tomo_angle_stepsize", -- 2.54.0 From 1a5126096da6489290a2c8b80006ddc1f471a492 Mon Sep 17 00:00:00 2001 From: x12sa Date: Fri, 3 Jul 2026 13:28:34 +0200 Subject: [PATCH 12/32] random offset control added to gui, and json file writing --- .../plugins/omny/omny_general_tools.py | 46 ++++++++++++++----- .../widgets/tomo_params/tomo_params.py | 33 ++++++++----- 2 files changed, 55 insertions(+), 24 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 1f9d276..ca05a82 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 @@ -1,6 +1,7 @@ import builtins import datetime import fcntl +import json import os import socket import subprocess @@ -28,9 +29,12 @@ if builtins.__dict__.get("bec") is not None: def umv(*args): return scans.umv(*args, relative=False) + + def umvr(*args): return scans.umv(*args, relative=True) + class OMNYToolsError(Exception): pass @@ -157,8 +161,10 @@ class OMNYTools: termios.tcsetattr(fd, termios.TCSADRAIN, old_term) fcntl.fcntl(fd, fcntl.F_SETFL, old_flags) + import socket + class PtychoReconstructor: """Writes ptychography reconstruction queue files after each scan projection. @@ -195,6 +201,8 @@ class PtychoReconstructor: next_scan_number: int, base_path: str = "~/data/raw/analysis/", probe_file_propagation: float | None = None, + random_offset_x: float | None = None, + random_offset_y: float | None = None, ): """Write a reconstruction queue file for the given scan list. @@ -211,19 +219,25 @@ class PtychoReconstructor: omitted entirely otherwise, matching the old reconstruction.mac behavior of only writing this parameter when progagateprobeinsteadofsample==1. + random_offset_x (float, optional): Random x shift [um] applied for + this single-point acquisition. Written to the JSON sidecar as + ``random_offset_x`` when given; omitted otherwise. + random_offset_y (float, optional): Random y shift [um] applied for + this single-point acquisition. Written to the JSON sidecar as + ``random_offset_y`` when given; omitted otherwise. """ if not self._accounts_match(): - logger.warning("Active BEC account does not match system user — skipping queue file write.") + logger.warning( + "Active BEC account does not match system user — skipping queue file write." + ) return - + base_path = os.path.expanduser(base_path) queue_path = Path(os.path.join(base_path, self.folder_name)) queue_path.mkdir(parents=True, exist_ok=True) last_scan_number = next_scan_number - 1 - queue_file = os.path.abspath( - os.path.join(queue_path, f"scan_{last_scan_number:05d}.dat") - ) + queue_file = os.path.abspath(os.path.join(queue_path, f"scan_{last_scan_number:05d}.dat")) with open(queue_file, "w") as f: scans = " ".join(str(s) for s in scan_list) f.write(f"p.scan_number {scans}\n") @@ -231,6 +245,17 @@ class PtychoReconstructor: f.write(f"p.probe_file_propagation {probe_file_propagation:.6f}\n") f.write("p.check_nextscan_started 1\n") + json_file = os.path.abspath(os.path.join(queue_path, f"{last_scan_number:06d}.json")) + json_content = {"scan_id": last_scan_number} + if probe_file_propagation is not None: + json_content["probe_file_propagation"] = round(probe_file_propagation, 6) + if random_offset_x is not None: + json_content["random_offset_x"] = round(random_offset_x, 6) + if random_offset_y is not None: + json_content["random_offset_y"] = round(random_offset_y, 6) + with open(json_file, "w") as f: + json.dump(json_content, f, indent=4) + class TomoIDManager: """Registers a tomography measurement in the OMNY sample database @@ -249,7 +274,7 @@ class TomoIDManager: ) """ - #OMNY_URL = "https://omny.web.psi.ch/samples/newmeasurement.php" + # OMNY_URL = "https://omny.web.psi.ch/samples/newmeasurement.php" OMNY_URL = "https://v1p0zyg2w9n2k9c1.myfritz.net/samples/newmeasurement.php" OMNY_USER = "" OMNY_PASSWORD = "" @@ -294,12 +319,9 @@ class TomoIDManager: # f" -q -O {self.TMP_FILE} '{url}'", # shell=True, # ) - #print(url) + # print(url) tmp_file = os.path.expanduser(self.TMP_FILE) - result = subprocess.run( - f"wget -q -O {tmp_file} '{url}'", - shell=True, - ) + result = subprocess.run(f"wget -q -O {tmp_file} '{url}'", shell=True) if result.returncode != 0: raise OMNYToolsError( f"wget failed (exit code {result.returncode}) fetching tomo ID from {self.OMNY_URL}" @@ -313,4 +335,4 @@ class TomoIDManager: except ValueError as exc: raise OMNYToolsError( f"Unexpected response from tomo ID server, got: {content!r}" - ) from exc + ) from exc \ No newline at end of file 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 9e1b9a8..201050a 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -104,6 +104,7 @@ QUEUE_PARAM_NAMES = ( "manual_shift_y", "frames_per_trigger", "single_point_instead_of_fermat_scan", + "single_point_random_shift_max", "tomo_type", "tomo_angle_range", "tomo_angle_stepsize", @@ -126,6 +127,7 @@ DEFAULTS: dict[str, Any] = { "manual_shift_y": 0.0, "frames_per_trigger": 1, "single_point_instead_of_fermat_scan": False, + "single_point_random_shift_max": 0.0, "tomo_type": 1, "corridor_size": -1.0, "tomo_angle_range": 180, @@ -364,6 +366,14 @@ class TomoParamsWidget(BECWidget, QWidget): self._add_int(common_form, "frames_per_trigger", "Frames / trigger", min_=1, max_=100) sp = self._add_bool(common_form, "single_point_instead_of_fermat_scan", "Single-point scan") sp.stateChanged.connect(self._on_single_point_changed) + self._add_double( + common_form, + "single_point_random_shift_max", + "Random shift max (µm)", + min_=0.0, + max_=10.0, + decimals=3, + ) vbox.addLayout(common_form) # type-1 section @@ -590,6 +600,9 @@ class TomoParamsWidget(BECWidget, QWidget): return "tomo_type must be 1, 2, or 3" if params.get("tomo_angle_range") not in (180, 360): return "tomo_angle_range must be 180 or 360" + 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" return None # ── type visibility ─────────────────────────────────────────────────────── @@ -659,6 +672,8 @@ class TomoParamsWidget(BECWidget, QWidget): self._pw["stitch_y"].setValue(0) self._pw["stitch_x"].setEnabled(self._edit_mode and not checked) self._pw["stitch_y"].setEnabled(self._edit_mode and not checked) + # random shift only applies to single-point acquisitions + self._pw["single_point_random_shift_max"].setEnabled(self._edit_mode and checked) def _on_poll(self) -> None: """Periodic refresh: params (guarded against edit mode) + sample name.""" @@ -882,10 +897,13 @@ class TomoParamsWidget(BECWidget, QWidget): if key in skip: continue widget.setEnabled(enabled) - # stitch locked when single_point is active - if enabled and self._pw["single_point_instead_of_fermat_scan"].isChecked(): + # stitch locked when single_point is active; random shift is the + # inverse -- only editable while single_point is active + single_point = self._pw["single_point_instead_of_fermat_scan"].isChecked() + if enabled and single_point: self._pw["stitch_x"].setEnabled(False) self._pw["stitch_y"].setEnabled(False) + self._pw["single_point_random_shift_max"].setEnabled(enabled and single_point) # projection requested total only meaningful in edit mode self._pw["_requested_total"].setEnabled(enabled) @@ -981,16 +999,7 @@ class TomoQueueDialog(QDialog): self._table = QTableWidget(0, 8) self._table.setHorizontalHeaderLabels( - [ - "#", - "Label", - "Status", - "Type", - "Projections", - "Exp (s)", - "Step (µm)", - "Added at", - ] + ["#", "Label", "Status", "Type", "Projections", "Exp (s)", "Step (µm)", "Added at"] ) self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) -- 2.54.0 From da3d417e59ee7d723055a22aaff4e7a1392e1b7c Mon Sep 17 00:00:00 2001 From: x12sa Date: Fri, 3 Jul 2026 13:32:31 +0200 Subject: [PATCH 13/32] docs updated --- docs/user/ptychography/flomni.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index ebee457..ab49836 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -90,7 +90,7 @@ A __single projection__ is to be repeated use To continue an __interrupted tomography scan__, run `flomni.tomo_scan_resume()` -It picks up automatically from wherever the scan was interrupted, no need to look up the exact subtomogram/angle by hand. +It picks up automatically from wherever the scan was interrupted, no need to look up the exact subtomogram/angle by hand. Do not use this command when running from the tomo queuing system. Instead use `flomni.tomo_queue_execute()`. Alternatively, the same kind of parameters used internally by `tomo_scan_resume()` can be given to `flomni.tomo_scan()` directly, e.g. to resume from a different point than where it actually stopped: | tomo type | parameters and their defaults | @@ -322,7 +322,7 @@ For the "8 sub-tomograms" mode, `flomni.tomo_parameters()` also offers a `zero_d The parameters above can be used to __restart an interrupted acquisition__ manually, or - more conveniently - by running `flomni.tomo_scan_resume()` -which reads the last recorded progress and resumes automatically at the exact point (subtomogram/angle, or projection for the golden ratio modes) the scan was interrupted at, without needing to look up the values by hand. +which reads the last recorded progress and resumes automatically at the exact point (subtomogram/angle, or projection for the golden ratio modes) the scan was interrupted at, without needing to look up the values by hand. When running from the tomo scan queuing system use `flomni.tomo_queue_execute()` instead! In case of eight equally spaced sub-tomograms, an individual sub tomogram can be scanned by flomni.sub_tomo_scan(subtomo_number, start_angle). If the start angle is not specified, it will be computed depending on the subtomo_number, which is ranging from 1 to 8. -- 2.54.0 From 225a4e6f65539006e4956e4bfb560943aa2afd73 Mon Sep 17 00:00:00 2001 From: x12sa Date: Fri, 3 Jul 2026 15:05:55 +0200 Subject: [PATCH 14/32] feat(flomni): log per-projection and per-tomogram scan timing AND guard tomo_scan_projection against single-point mode Add JSONL timing logs under ~/data/raw/logs/timing_statistics/ for building a scan-time prediction model. Times each projection in _tomo_scan_at_angle (final successful attempt only, retries excluded) and each tomogram in tomo_scan, capturing raw scan parameters plus elapsed/idle/active durations. tomo_scan_projection always runs a fermat scan; warn and confirm when called directly with single_point_instead_of_fermat_scan set, pointing to tomo_acquire_at_angle instead. Internal callers pass _internal=True to skip the prompt. --- .../plugins/flomni/flomni.py | 153 +++++++++++++++++- 1 file changed, 150 insertions(+), 3 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index d34af24..d9f2f8b 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -1,5 +1,6 @@ import builtins import datetime +import json import os import random import subprocess @@ -1867,7 +1868,7 @@ class Flomni( while not successful: try: start_scan_number = bec.queue.next_scan_number - self.tomo_scan_projection(angle) + self.tomo_scan_projection(angle, _internal=True) error_caught = False except AlarmBase as exc: if exc.alarm_type == "TimeoutError": @@ -2103,13 +2104,25 @@ class Flomni( else: num_repeats = 1 start_scan_number = bec.queue.next_scan_number + projection_start = time.perf_counter() for i in range(num_repeats): self._at_each_angle(angle) + projection_duration = time.perf_counter() - projection_start end_scan_number = bec.queue.next_scan_number for scan_nr in range(start_scan_number, end_scan_number): self._write_tomo_scan_number(scan_nr, angle, subtomo_number) + # Only reached when the projection completed without @scan_repeat + # re-raising, so this is the final successful attempt's duration. + self._log_projection_timing( + angle=angle, + subtomo_number=subtomo_number, + duration_s=projection_duration, + start_scan_number=start_scan_number, + end_scan_number=end_scan_number, + ) + def tomo_scan(self, subtomo_start=1, start_angle=None, projection_number=None): """start a tomo scan""" @@ -2284,6 +2297,7 @@ class Flomni( self.progress["projection"] = self.progress["total_projections"] self.progress["subtomo_projection"] = self.progress["subtomo_total_projections"] self._print_progress() + self._log_tomogram_timing() self.OMNYTools.printgreenbold("Tomoscan finished") print( f"Total measurement time lost to detected gaps: {self._format_duration(self.progress.get('accumulated_idle_time', 0.0))}" @@ -2409,7 +2423,7 @@ class Flomni( self.tomo_acquire_at_angle(angle) return - self.tomo_scan_projection(angle) + self.tomo_scan_projection(angle, _internal=True) def _golden(self, ii, howmany_sorted, maxangle, reverse=False): """returns the iis golden ratio angle of sorted bunches of howmany_sorted and its subtomo number""" @@ -2477,6 +2491,117 @@ class Flomni( random_offset_y=random_offset_y, ) + _TIMING_LOG_DIR = "~/data/raw/logs/timing_statistics" + _TIMING_SETUP = "flomni" + _PROJECTION_TIMING_LOG = "projection_timing_log.jsonl" + _TOMOGRAM_TIMING_LOG = "tomogram_timing_log.jsonl" + + def _append_timing_record(self, filename: str, record: dict) -> None: + """Append one JSON record as a line to a timing-statistics log file. + + Deliberately best-effort: a failure to write a timing record must + never abort or interfere with an in-progress measurement, so any + exception here is caught and logged rather than propagated. The + files live in a dedicated subfolder (self._TIMING_LOG_DIR), separate + from tomography_scannumbers.txt and any other existing log, and are + pure append-only JSONL (one JSON object per line) so they are + trivial to load later with pandas.read_json(..., lines=True) for the + eventual scan-time prediction model. + """ + try: + log_dir = os.path.expanduser(self._TIMING_LOG_DIR) + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, filename) + with open(log_file, "a+") as out_file: + out_file.write(json.dumps(record) + "\n") + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Failed to write timing record to {filename}: {exc}") + + def _log_projection_timing( + self, + angle: float, + subtomo_number: int, + duration_s: float, + start_scan_number: int, + end_scan_number: int, + ) -> None: + """Write one per-projection timing record. + + A "projection" here is one completed _at_each_angle() call (all the + stitch tiles / the single-point acquisition for one angle). Only + successfully completed projections reach this point: because + _tomo_scan_at_angle is wrapped in @scan_repeat, a failed attempt + re-invokes the whole method and never returns to the logging call, + so retried attempts are naturally excluded and only the final + successful duration is recorded. + + The parameters logged are exactly the scan settings that plausibly + drive the duration (FOV, step, counting time, burst frames, stitch + tiling, corridor, single-point mode) -- the raw feature set for the + prediction model. The actual fermat-scan point count is deliberately + not resolved here; if it turns out to be needed it can be added + later once real data shows the raw settings aren't enough. + """ + record = { + "setup": self._TIMING_SETUP, + "timestamp": datetime.datetime.now().isoformat(), + "duration_s": duration_s, + "angle": angle, + "subtomo_number": subtomo_number, + "start_scan_number": start_scan_number, + "end_scan_number": end_scan_number, + "n_scans": end_scan_number - start_scan_number, + "tomo_type": self.tomo_type, + "single_point_instead_of_fermat_scan": self.single_point_instead_of_fermat_scan, + "fovx": self.fovx, + "fovy": self.fovy, + "tomo_shellstep": self.tomo_shellstep, + "tomo_countingtime": self.tomo_countingtime, + "frames_per_trigger": self.frames_per_trigger, + "stitch_x": self.stitch_x, + "stitch_y": self.stitch_y, + "tomo_stitch_overlap": self.tomo_stitch_overlap, + "corridor_size": self.corridor_size, + } + self._append_timing_record(self._PROJECTION_TIMING_LOG, record) + + def _log_tomogram_timing(self) -> None: + """Write one per-tomogram timing record at the end of a tomo_scan(). + + Captures a full snapshot of the scan parameters (reusing + _TOMO_QUEUE_PARAM_NAMES -- the single source of truth for "every + parameter that affects how the scan runs") together with the + tomogram-level wall-clock timing already tracked in self.progress: + total elapsed, the accumulated idle time detected from inter- + projection gaps, and the active (elapsed-minus-idle) measurement + time. Standalone by design -- no tomo_id / sample-database cross- + reference for now. + """ + start_str = self.progress.get("tomo_start_time") + now = datetime.datetime.now() + elapsed_s = None + if start_str is not None: + try: + elapsed_s = (now - datetime.datetime.fromisoformat(start_str)).total_seconds() + except (ValueError, TypeError): + elapsed_s = None + + idle_s = self.progress.get("accumulated_idle_time", 0.0) + active_s = elapsed_s - idle_s if elapsed_s is not None else None + + record = { + "setup": self._TIMING_SETUP, + "timestamp": now.isoformat(), + "tomo_start_time": start_str, + "elapsed_s": elapsed_s, + "accumulated_idle_time_s": idle_s, + "active_s": active_s, + "total_projections": self.progress.get("total_projections"), + "tomo_type_label": self.progress.get("tomo_type"), + "params": {name: getattr(self, name) for name in self._TOMO_QUEUE_PARAM_NAMES}, + } + self._append_timing_record(self._TOMOGRAM_TIMING_LOG, record) + def _write_tomo_scan_number(self, scan_number: int, angle: float, subtomo_number: int) -> None: tomo_scan_numbers_file = os.path.expanduser("~/data/raw/logs/tomography_scannumbers.txt") with open(tomo_scan_numbers_file, "a+") as out_file: @@ -2485,7 +2610,29 @@ class Flomni( f"{scan_number} {angle} {dev.fsamroy.read()['fsamroy']['value']:.5f} {self.tomo_id} {subtomo_number} {0} {self.sample_name}\n" ) - def tomo_scan_projection(self, angle: float): + def tomo_scan_projection(self, angle: float, _internal: bool = False): + """Acquire one fermat-scan projection at `angle`. + + Note: this ALWAYS runs a fermat scan, regardless of + single_point_instead_of_fermat_scan. That flag is honored by the + normal tomo flow (tomo_scan -> _at_each_angle) and by + tomo_acquire_at_angle, not here -- a single named command does one + kind of acquisition. If you call this directly at the CLI with the + single-point flag set, you probably meant tomo_acquire_at_angle(); + the guard below points that out. Internal callers that legitimately + want the fermat path regardless (the fermat branch of + _at_each_angle, and tomo_alignment_scan, which needs real ptycho + data) pass _internal=True to skip the prompt. + """ + if not _internal and self.single_point_instead_of_fermat_scan: + print( + "\x1b[93mWarning: single_point_instead_of_fermat_scan is set, but" + " tomo_scan_projection always runs a FERMAT scan. For a single-point" + " acquisition at this angle use tomo_acquire_at_angle(angle) instead.\x1b[0m" + ) + if not self.OMNYTools.yesno("Run a fermat scan anyway?", "n"): + print("Aborted. Use tomo_acquire_at_angle(angle) for a single-point acquisition.") + return dev.rtx.controller.laser_tracker_check_signalstrength() -- 2.54.0 From 41e63b0ba4502e1fd8d6fbca1110e39ea4ef8c68 Mon Sep 17 00:00:00 2001 From: x12sa Date: Fri, 3 Jul 2026 15:53:44 +0200 Subject: [PATCH 15/32] updated osa parameters for abes fzp --- csaxs_bec/device_configs/ptycho_flomni.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index 4e22c13..7159572 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -324,7 +324,7 @@ fosax: # in: 8.731922 # out: 5.1 #250 micron, 30 nm, Abe structures - in: 8.755141 + in: 8.7392 out: 5.1 #250 micron, 30 nm, Tomas structures # in: 9.420798 @@ -352,7 +352,7 @@ fosay: #170 micron, 60 nm, 7.6 kev #in: -0.0422 #250 micron, 30 nm, Abe structures - in: -2.357436 + in: -2.3684 #250 micron, 30 nm, Tomas structures # in: -2.383993 fosaz: @@ -378,8 +378,8 @@ fosaz: #170 micron, 60 nm, 7.9 kev, foptz 15.9 # in: 11.9 # out: 6 - # micron, 30 nm, 7.9 kev, foptz 32 //abe's fzp's - in: -2 + # micron, 30 nm, 7.9 kev, very close to the sample. make sure foptz is 32.02 or smaller //abe's fzp's + in: 0.5 out: -5 ############################################################ -- 2.54.0 From c514cbfb79adc5e608d8eb731fc7fe18ba5f627b Mon Sep 17 00:00:00 2001 From: x12sa Date: Sat, 4 Jul 2026 07:16:25 +0200 Subject: [PATCH 16/32] added umv fsamy tracked command to docs --- docs/user/ptychography/flomni.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index ab49836..9af9aaa 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -51,7 +51,7 @@ After the sample transfer the sample stage moved to the measurement position wit If you see your sample already at the approximately correct height, you can skip steps 1 to 3. Otherwise adjust the height: 1. `flomni.feedback_disable()` disable the closed loop operation to allow movement of coarse stages -1. `umvr(dev.fsamy, 0.01)`, attention: unit , move the sample stage relative up (positive) or down (negative) until the sample is approximately vertically centered in xray eye screen +1. `umvr_fsamy_tracked(0.01)`, attention: unit , move the sample stage relative up (positive) or down (negative) until the sample is approximately vertically centered in xray eye screen 1. `flomni.xrayeye_update_frame()` will update the current image on the xray eye screen 1. `flomni.xrayeye_alignment_start()` start the coarse alignment of the sample by measuring (clicking in the X-ray eye software) the sample position at its height and angles of 0, 45, 90, 135, 180 degrees. The GUI will present a fit of this data, which is automatically loaded to BEC for aligning the sample. -- 2.54.0 From 89df83286f034f4b546df03fd056c5299bc6e4d1 Mon Sep 17 00:00:00 2001 From: x12sa Date: Sat, 4 Jul 2026 07:50:01 +0200 Subject: [PATCH 17/32] feat/webpage_generator: make tomo-queue and tomo-type formula setup-configurable - Add HAS_TOMO_QUEUE and TOMO_TYPES capability flags to WebpageGeneratorBase, defaulting to flomni's current behavior (type1 grid-8, type2/3 golden-capped) so this is a no-op for flomni. - Gate the tomo_queue global-var read in _cycle() behind HAS_TOMO_QUEUE; unaffected: queue_status/queue_locks/beamline_states, which come from BEC's own primary scan queue and stay common to all setups. - _render_html() now conditionally emits the 'Tomo queue' card and its DEFAULT_ORDER slot, and drives the projection-count formula from a TOMO_TYPES JSON blob (generic calcProjections()) instead of a hardcoded per-instrument JS branch. - LamniWebpageGenerator: HAS_TOMO_QUEUE=False, TOMO_TYPES with 2-subtomo and 8-subtomo equally_spaced_grid entries (placeholder keys pending real tomo_type values from the LamNI producer side). --- .../plugins/LamNI/LamNI_webpage_generator.py | 18 ++- .../flomni/flomni_webpage_generator.py | 122 +++++++++++++----- 2 files changed, 107 insertions(+), 33 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py index a77e77c..a5a1708 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py @@ -49,6 +49,22 @@ class LamniWebpageGenerator(WebpageGeneratorBase): sample name, and measurement settings. """ + # LamNI has no persisted parameter-snapshot job queue (unlike flomni + # and, later, omny) — the "Tomo queue" card and its global-var read + # are skipped entirely for this setup. + HAS_TOMO_QUEUE = False + + # LamNI's tomo scans come in two flavours: a 2-subtomo and an + # 8-subtomo variant, both using the same equally-spaced-grid formula + # as flomni's type 1 (just with a different sub-tomo count). + # TODO: replace the placeholder keys (1, 2) with whatever values + # progress["tomo_type"] actually takes for LamNI once the producer + # side (LamNI equivalent of flomni.py) defines them. + TOMO_TYPES = { + 1: {"kind": "equally_spaced_grid", "n_subtomos": 2}, + 2: {"kind": "equally_spaced_grid", "n_subtomos": 8}, + } + # TODO: fill in LamNI-specific device paths # label -> dotpath under device_manager.devices _TEMP_MAP = { @@ -86,4 +102,4 @@ class LamniWebpageGenerator(WebpageGeneratorBase): return { "type": "lamni", # LamNI-specific data here - } + } \ No newline at end of file diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 6825d73..103acaf 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -574,8 +574,32 @@ class WebpageGeneratorBase: Common webpage generator. Subclass and override: _collect_setup_data() -- return dict of instrument-specific data _logo_path() -- return Path to logo PNG, or None for text fallback + + Setup capability flags (override on subclass as needed): + HAS_TOMO_QUEUE -- whether this instrument exposes the persisted + parameter-snapshot job queue (global var + "tomo_queue" + the "Tomo queue" UI card). + NOT to be confused with BEC's own primary scan + queue (queue_status / queue_locks in _cycle()), + which every setup has and which stays common. + TOMO_TYPES -- dict describing this instrument's tomography + scan types, keyed by the value progress["tomo_type"] + takes. Used by the UI to compute total-projection + counts for queued jobs without hardcoding a + per-instrument formula in JS. Recognised "kind"s: + "equally_spaced_grid" (params: n_subtomos) + total = floor(angle_range / angle_stepsize) * n_subtomos + "golden_capped" + total = golden_max_number_of_projections """ + HAS_TOMO_QUEUE = True + TOMO_TYPES = { + 1: {"kind": "equally_spaced_grid", "n_subtomos": 8}, + 2: {"kind": "golden_capped"}, + 3: {"kind": "golden_capped"}, + } + def __init__( self, bec_client, @@ -647,7 +671,9 @@ class WebpageGeneratorBase: # Copy logo once at startup — HTML is also written once here, # not every cycle, since it is a static shell that only loads status.json. self._copy_logo() - (self._output_dir / "status.html").write_text(_render_html(_PHONE_NUMBERS)) + (self._output_dir / "status.html").write_text( + _render_html(_PHONE_NUMBERS, self.HAS_TOMO_QUEUE, self.TOMO_TYPES) + ) # Check whether the active BEC account matches the session user on the # remote server. If not, clear session.htpasswd so the old user loses @@ -1028,11 +1054,16 @@ class WebpageGeneratorBase: else max(0.0, _epoch() - self._last_active_time) ) - # ── Tomo queue ─────────────────────────────────────────────── + # ── Tomo queue (setup-specific; see HAS_TOMO_QUEUE) ───────────── # Persisted list of parameter-snapshot jobs (global var "tomo_queue"). # Each job: {"label": str, "params": {...}, # "status": pending|running|incomplete|done, "added_at": ISO str} - tomo_queue = self._bec.get_global_var("tomo_queue") or [] + # Instruments without this feature (e.g. LamNI) never touch the + # global var and always report an empty queue. + if self.HAS_TOMO_QUEUE: + tomo_queue = self._bec.get_global_var("tomo_queue") or [] + else: + tomo_queue = [] # ── Reconstruction queue ────────────────────────────────────── recon = self._collect_recon_data() @@ -1453,7 +1484,7 @@ def make_webpage_generator(bec_client, **kwargs): # Theme is stored in localStorage and applied via data-theme on . # --------------------------------------------------------------------------- -def _render_html(phone_numbers: list) -> str: +def _render_html(phone_numbers: list, has_tomo_queue: bool = True, tomo_types: dict = None) -> str: phones_html = "\n".join( f'
' f'{label}' @@ -1461,6 +1492,30 @@ def _render_html(phone_numbers: list) -> str: for label, num in phone_numbers ) + # Tomo queue card + its slot in the default card order are only emitted + # for setups that actually have the feature (see HAS_TOMO_QUEUE). + if has_tomo_queue: + tomo_queue_card_html = """ + +
+
⋮⋮
+
Tomo queue
+
+
+""" + else: + tomo_queue_card_html = "" + + default_order = ['audio', 'recon-queue', 'ptycho', 'instrument', 'blstates', 'contacts'] + if has_tomo_queue: + default_order.insert(default_order.index('contacts'), 'tomo-queue') + default_order_json = json.dumps(default_order) + + # Declarative tomo-type -> projection-count formula, consumed by the + # generic calcProjections() in JS instead of a hardcoded per-instrument + # branch. See WebpageGeneratorBase.TOMO_TYPES for the schema. + tomo_types_json = json.dumps(tomo_types or {}) + return f""" @@ -2139,13 +2194,7 @@ def _render_html(phone_numbers: list) -> str:
- -
-
⋮⋮
-
Tomo queue
-
-
- +{tomo_queue_card_html}
⋮⋮
@@ -2184,7 +2233,7 @@ function setTheme(t) {{ // ── Drag-and-drop card ordering ────────────────────────────────────────── const CARD_ORDER_KEY = 'cardOrder'; -const DEFAULT_ORDER = ['audio','recon-queue','ptycho','instrument','blstates','tomo-queue','contacts']; +const DEFAULT_ORDER = {default_order_json}; let _dragSrc = null; function savedOrder() {{ @@ -2642,8 +2691,32 @@ function fmtTqParam(key, val){{ return String(val); }} +// Declarative per-instrument tomo-type definitions (see +// WebpageGeneratorBase.TOMO_TYPES on the Python side for the schema). +// Adding or changing a tomo type on any setup is a config change here, +// not a new branch of formula code. +const TOMO_TYPES = {tomo_types_json}; + +function calcProjections(params){{ + const def=TOMO_TYPES[params.tomo_type]; + if(!def) return null; + if(def.kind==='golden_capped'){{ + const gmax=params.golden_max_number_of_projections; + return (gmax!=null&&gmax>0)?gmax:null; + }} + if(def.kind==='equally_spaced_grid'){{ + const range=params.tomo_angle_range, step=params.tomo_angle_stepsize; + if(range>0&&step>0){{ + const N=Math.floor(range/step); + return N*(def.n_subtomos||1); + }} + }} + return null; +}} + function renderTomoQueue(jobs){{ const el=document.getElementById('tomo-queue-content'); + if(!el) return; // setup has no tomo-queue card (HAS_TOMO_QUEUE=False) // Snapshot which job indices are currently open before replacing innerHTML const openIndices=new Set(); el.querySelectorAll('details.tq-job').forEach((det,i)=>{{ @@ -2665,27 +2738,12 @@ function renderTomoQueue(jobs){{ if(key in params){{ paramRows+=''+dispLabel+''+esc(fmtTqParam(key,params[key]))+''; }} - // After angle step, inject a computed projection count row. - // Formula must match _tomo_type1_actual_grid() / tomo_parameters() exactly: - // type 1: N = int(range / stepsize), corrected_step = range / N, total = N * 8 - // type 2/3: golden_max_number_of_projections directly + // After angle step, inject a computed projection count row, driven by + // this instrument's TOMO_TYPES config (must match the corresponding + // Python-side formula, e.g. _tomo_type1_actual_grid() for flomni's + // "equally_spaced_grid" type). if(key==='tomo_angle_stepsize'){{ - const ttype=params.tomo_type; - let nproj=null; - if(ttype===2||ttype===3){{ - // golden ratio / equally-spaced golden: capped at golden_max_number_of_projections - const gmax=params.golden_max_number_of_projections; - nproj=(gmax!=null&&gmax>0)?gmax:null; - }}else{{ - // type 1: 8 equally-spaced sub-tomograms. - // int(range/stepsize) = N per sub-tomo; total = N * 8 - // (mirrors _tomo_type1_actual_grid: N=int(range/step), total=N*8) - const range=params.tomo_angle_range, step=params.tomo_angle_stepsize; - if(range>0&&step>0){{ - const N=Math.floor(range/step); - nproj=N*8; - }} - }} + const nproj=calcProjections(params); if(nproj!=null) paramRows+='Projections'+nproj+''; }} }}); -- 2.54.0 From 625a9f1063d6278d223879169ca660524a713672 Mon Sep 17 00:00:00 2001 From: x12sa Date: Sat, 4 Jul 2026 08:09:23 +0200 Subject: [PATCH 18/32] fix(flomni): warn on OSA position vs IN-position clearance mismatch in ffzp_info fosaz_info previously only reported remaining collision clearance for the live fosaz position, giving no indication of clearance once OSA is moved back to its defined IN position if foptz has shifted while OSA was OUT. Now compares live fosaz against fosaz_in (10 um tolerance): - if live fosaz is further IN than nominal (closer to collision), warn and show current remaining space (the number that matters right now) - if OSA is OUT/away from IN, no warning, but also report remaining space at the IN position using current foptz, as a heads-up before moving OSA in --- .../plugins/flomni/flomni_optics_mixin.py | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py index 1b825e7..a7caa26 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py @@ -261,10 +261,34 @@ class FlomniOpticsMixin: console.print(table) fosaz_val = dev.fosaz.readback.get() + foptz_val = dev.foptz.readback.get() + fosaz_in = self._get_user_param_safe("fosaz", "in") + + tol_osa = 0.010 # mm, 10 microns + + remaining_current = -fosaz_val + (33 - foptz_val) + remaining_at_in = -fosaz_in + (33 - foptz_val) print("\nOSA Information:") print(f" Current fosaz {fosaz_val:.1f}") - print( - f" The OSA will collide with a normal OMNY pin at fosaz \033[1m{(33-fosaz_val):.1f}\033[0m" - ) - print(f" Remaining space: \033[1m{-fosaz_val+(33-foptz_val):.1f}\033[0m") + print(f" The OSA will collide with a normal OMNY pin at fosaz \033[1m{(33-fosaz_val):.1f}\033[0m") + print(f" Remaining space (right now): \033[1m{remaining_current:.1f}\033[0m") + + diff = fosaz_val - fosaz_in # >0: OSA is currently more "in" than nominal -> worse + + if abs(diff) > tol_osa: + if diff > 0: + # current position is already more collision-prone than the defined IN position + print( + f" \033[91mWarning: current fosaz ({fosaz_val:.4f}) is {diff*1000:.1f} um " + f"further IN than the defined IN position ({fosaz_in:.4f}) -- closer to collision " + f"than normal operation.\033[0m" + ) + else: + # OSA is out (parked further away than its nominal in-position) -- not urgent now, + # but flag what clearance will be once it's moved to IN + print( + f" Note: OSA is {(-diff)*1000:.1f} um away from its IN position (likely parked OUT)." + ) + print(f" Remaining space if OSA is moved to its IN position: \033[1m{remaining_at_in:.1f}\033[0m") + \ No newline at end of file -- 2.54.0 From dab331f9d0631b50a365c72eb1458db99c5e3fbb Mon Sep 17 00:00:00 2001 From: x12sa Date: Sat, 4 Jul 2026 08:16:03 +0200 Subject: [PATCH 19/32] docs update --- docs/user/ptychography/flomni.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index 9af9aaa..a7d8970 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -47,13 +47,16 @@ Manually move the gripper to a transfer position #### Coarse alignment -After the sample transfer the sample stage moved to the measurement position with your new sample. The Xray eye will automatically move in and the shutter will open. You may already see the sample in the omny xeye interface running on the windows computer. -If you see your sample already at the approximately correct height, you can skip steps 1 to 3. Otherwise adjust the height: +After the sample transfer the sample stage moved to the measurement position with your new sample. The Xray eye will automatically move in, the alignment GUI opens and shows a current snapshot. +If you see your sample already at the approximately correct height: + +1. `flomni.xrayeye_alignment_start()` start the coarse alignment of the sample by measuring (clicking in the X-ray eye software) the sample position at its height and then angles of 0, 45, 90, 135, 180 degrees. The GUI will present a fit of this data, which is automatically loaded to BEC for aligning the sample. + +Otherwise adjust the height manually: 1. `flomni.feedback_disable()` disable the closed loop operation to allow movement of coarse stages 1. `umvr_fsamy_tracked(0.01)`, attention: unit , move the sample stage relative up (positive) or down (negative) until the sample is approximately vertically centered in xray eye screen 1. `flomni.xrayeye_update_frame()` will update the current image on the xray eye screen -1. `flomni.xrayeye_alignment_start()` start the coarse alignment of the sample by measuring (clicking in the X-ray eye software) the sample position at its height and angles of 0, 45, 90, 135, 180 degrees. The GUI will present a fit of this data, which is automatically loaded to BEC for aligning the sample. #### Fine alignment -- 2.54.0 From daf527b8df5ff2cb500c7fea533f6de083e0530a Mon Sep 17 00:00:00 2001 From: x12sa Date: Sun, 5 Jul 2026 05:20:51 +0200 Subject: [PATCH 20/32] webpage_generator: make tomo-queue and tomo-type formula setup-configurable webpage_generator: report blocked on interlock even when scan is interrupted - Add HAS_TOMO_QUEUE and TOMO_TYPES capability flags to WebpageGeneratorBase, defaulting to flomni's current behavior (type1 grid-8, type2/3 golden-capped) so this is a no-op for flomni. - Gate the tomo_queue global-var read in _cycle() behind HAS_TOMO_QUEUE; unaffected: queue_status/queue_locks/beamline_states, which come from BEC's own primary scan queue and stay common to all setups. - _render_html() now conditionally emits the 'Tomo queue' card and its DEFAULT_ORDER slot, and drives the projection-count formula from a TOMO_TYPES JSON blob (generic calcProjections()) instead of a hardcoded per-instrument JS branch. - LamniWebpageGenerator: HAS_TOMO_QUEUE=False, TOMO_TYPES with 2-subtomo and 8-subtomo equally_spaced_grid entries (placeholder keys pending real tomo_type values from the LamNI producer side). A block interrupts the running scan, clearing active_request_block, so the old _derive_status() (which only returned 'blocked' inside the queue_has_active_scan branch) fell through to 'idle' with the blocked LED off, despite watched beamline states being out of spec. - _derive_status: return 'blocked' on beamline_blocking OR queue_locks, independent of active scan; only fresh-heartbeat 'scanning' outranks it. - _cycle: compute beamline_blocking = any(state mismatched), defined identically to the beamline-states card so pill and card can't disagree; also count it as activity so the idle clock restarts once cleared. - status-detail JS: describe blocking beamline states when no explicit queue lock is present. Update help text." --- .../flomni/flomni_webpage_generator.py | 68 ++++++++++++++----- 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 103acaf..7613c76 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -284,21 +284,27 @@ def _derive_status( last_active_time, had_activity: bool, queue_locks: list | None = None, + beamline_blocking: bool = False, ) -> str: """ Returns one of: scanning -- tomo heartbeat fresh (< _TOMO_HEARTBEAT_STALE_S), OR heartbeat recently seen AND queue still has active scan (handles long individual projections > heartbeat timeout) - blocked -- queue has active scan, heartbeat stale (beyond the long- - projection grace window), AND the primary queue has at - least one lock applied (e.g. by BEC's ScanInterlockActor - when a watched beamline state goes out of spec). Only - reported when it is actually preventing a queued/active - scan from progressing. + blocked -- the experiment is being held up externally: EITHER a + watched beamline state is out of its accepted range while + the scan interlock would act on it (beamline_blocking), + OR the primary queue has at least one explicit lock + applied (queue_locks). Reported regardless of whether a + scan is currently executing: when a block hits, the + running scan is interrupted and its repeat waits for the + block to clear, so active_request_block (and hence + queue_has_active_scan) may already be cleared even though + the beamline is still blocking. Only 'scanning' (fresh + heartbeat = data still flowing) takes precedence. running -- queue has active scan but heartbeat has never been seen, - and the queue is not locked - idle -- not scanning, last_active_time known + and nothing is blocking + idle -- not scanning, not blocked, last_active_time known unknown -- no activity ever seen since generator started 'unknown' is ONLY returned before any scan activity has been observed. @@ -309,14 +315,19 @@ def _derive_status( hb_age = _heartbeat_age_s(progress.get("heartbeat")) if hb_age < _TOMO_HEARTBEAT_STALE_S: return "scanning" + # External block takes precedence over the remaining scan/idle states. + # A blocked scan is interrupted (active_request_block cleared) while its + # repeat waits for the block to clear, so we must NOT require an active + # scan here — that was the previous behaviour and it made a blocked-but- + # interrupted experiment fall through to 'idle'. + if beamline_blocking or queue_locks: + return "blocked" # Heartbeat stale but queue still active and heartbeat was seen recently # enough (within 10× the stale window) — likely a long projection, not idle. # Report as 'scanning' so audio system does not trigger a false alarm. if queue_has_active_scan and hb_age < _TOMO_HEARTBEAT_STALE_S * 10: return "scanning" if queue_has_active_scan: - if queue_locks: - return "blocked" return "running" if last_active_time is not None or had_activity: return "idle" @@ -1003,8 +1014,13 @@ class WebpageGeneratorBase: queue_has_active_scan = False queue_locks = [] - # ── Beamline states (diagnostic display only; not used for the - # 'blocked' experiment_status decision — see _read_queue_locks) ── + # ── Beamline states ────────────────────────────────────────── + # Diagnostic display AND (via beamline_blocking below) one of the two + # signals that drive the 'blocked' experiment_status; the other is + # queue_locks. A watched state that is out of its accepted range + # (mismatched) means the scan interlock is / would be holding the + # queue, even if the running scan has already been interrupted and + # active_request_block cleared. try: beamline_states_info = _read_beamline_states_info(self._bec) except Exception as exc: @@ -1012,6 +1028,14 @@ class WebpageGeneratorBase: f"beamline_states read error: {exc}", level="warning") beamline_states_info = {"enabled": None, "states": []} + # True if any watched state is out of spec. Defined identically to the + # "N watched states currently blocking" count shown on the beamline + # states card (renderBeamlineStates: states.filter(s => s.mismatched)), + # so the top status pill and that card can never disagree. + beamline_blocking = any( + s.get("mismatched") for s in beamline_states_info.get("states", []) + ) + # Current scan number (the BEC scan in progress right now, e.g. for # comparison against ptycho reconstruction filenames like S06770). # Only meaningful while a scan is actually active; None otherwise. @@ -1040,14 +1064,15 @@ class WebpageGeneratorBase: ) self._last_queue_id = latest_queue_id - if tomo_active or queue_has_active_scan or history_changed: + if (tomo_active or queue_has_active_scan or history_changed + or beamline_blocking): self._last_active_time = _epoch() self._had_activity = True exp_status = _derive_status( progress, queue_has_active_scan, self._last_active_time, self._had_activity, - queue_locks, + queue_locks, beamline_blocking, ) idle_for_s = ( None if self._last_active_time is None @@ -2099,7 +2124,7 @@ def _render_html(phone_numbers: list, has_tomo_queue: bool = True, tomo_types: d
  • Scanning — a tomography scan is actively running (a heartbeat from the scan loop has been seen recently).
  • Running — the scan queue has an active item, but it is not a tomo scan with a heartbeat (e.g. an alignment scan).
  • -
  • Blocked — a scan is queued or active, but the scan queue itself has been locked (for example by BEC's scan interlock when a watched beamline condition, such as the shutter or ring current, is out of spec). This is usually external and self-resolves once the condition clears.
  • +
  • Blocked — the experiment is being held up by a watched beamline condition that is out of spec (for example the shutter or ring current, see the Beamline states card), or by an explicit lock on the scan queue. A running scan is interrupted when this happens and its repeat waits for the condition to clear, so this shows even when no scan is momentarily executing. Usually external and self-resolves once the condition clears.
  • Idle — nothing is running or queued right now.
  • Unknown — shown only before any activity has been observed since the generator started.
@@ -2599,8 +2624,17 @@ const DETAILS={{ scanning: d=>'Tomo scan in progress · projection '+(d.progress.projection||0)+' of '+(d.progress.total_projections||0)+' · '+(d.progress.tomo_type||''), running: d=>'Queue active · outside tomo heartbeat window', idle: d=>'Idle for '+d.idle_for_human+'', - blocked: d=>'Queue locked'+((d.queue_locks&&d.queue_locks.length>1)?' by multiple locks':'')+': ' - +(d.queue_locks||[]).map(l=>esc(l.reason||l.identifier)).join('; ')+'', + blocked: d=>{{ + const locks=d.queue_locks||[]; + if(locks.length>0) + return 'Queue locked'+(locks.length>1?' by multiple locks':'')+': ' + +locks.map(l=>esc(l.reason||l.identifier)).join('; ')+''; + const bad=(((d.beamline_states||{{}}).states)||[]).filter(s=>s.mismatched) + .map(s=>esc(s.label||s.name)); + if(bad.length>0) + return 'Beamline interlock blocking: '+bad.join('; ')+''; + return 'Blocked'; + }}, error: d=>'Queue stopped unexpectedly · idle for '+(d.idle_for_human||'?')+'', unknown: d=>'Waiting for first data\u2026', }}; -- 2.54.0 From 4975b3e3cade4cc63dea3fc4af7db89af101f15f Mon Sep 17 00:00:00 2001 From: x12sa Date: Sun, 5 Jul 2026 08:53:30 +0200 Subject: [PATCH 21/32] webpage_generator: report blocked on interlock even when scan is interrupted A block interrupts the running scan, clearing active_request_block, so the old _derive_status() (which only returned 'blocked' inside the queue_has_active_scan branch) fell through to 'idle' with the blocked LED off, despite watched beamline states being out of spec. - _derive_status: return 'blocked' on beamline_blocking OR queue_locks, independent of active scan; only fresh-heartbeat 'scanning' outranks it. - _cycle: beamline_blocking = enabled is True AND any(state mismatched). Gated on enabled so a disabled/unknown interlock with an out-of-spec state does not raise BLOCKED. Also counted as activity so the idle clock restarts once cleared. - beamline-states card summary updated in lock-step: mismatched states are only called "currently blocking" when enabled, else "out of spec (interlock disabled/unknown, not blocking)", so pill and card can't disagree. - status-detail JS: describe blocking beamline states when no explicit queue lock is present. Update help text. --- .../flomni/flomni_webpage_generator.py | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 7613c76..8db13a1 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -1028,12 +1028,16 @@ class WebpageGeneratorBase: f"beamline_states read error: {exc}", level="warning") beamline_states_info = {"enabled": None, "states": []} - # True if any watched state is out of spec. Defined identically to the - # "N watched states currently blocking" count shown on the beamline - # states card (renderBeamlineStates: states.filter(s => s.mismatched)), + # True if the scan interlock is enabled AND at least one watched state + # is out of its accepted range. Gated on `enabled`: a mismatched state + # while the interlock is disabled does not actually block a scan, so it + # must not raise the 'blocked' status. Kept in lock-step with the + # beamline-states card summary (renderBeamlineStates), which likewise + # only calls mismatched states "blocking" when the interlock is enabled, # so the top status pill and that card can never disagree. - beamline_blocking = any( - s.get("mismatched") for s in beamline_states_info.get("states", []) + beamline_blocking = bool( + beamline_states_info.get("enabled") is True + and any(s.get("mismatched") for s in beamline_states_info.get("states", [])) ) # Current scan number (the BEC scan in progress right now, e.g. for @@ -2684,10 +2688,19 @@ function renderBeamlineStates(bl){{ return; }} - summary.innerHTML = 'Scan interlock '+enabledTxt+'' - + (mismatched.length>0 - ? ' · '+mismatched.length+' watched state'+(mismatched.length>1?'s':'')+' currently blocking' - : ' · all watched states OK'); + // "Blocking" only applies when the interlock is enabled — a mismatched + // state while disabled/unknown is out of spec but does not block a scan. + // Kept in lock-step with the Python beamline_blocking gate so this card and + // the top status pill never disagree. + let blockNote; + if(mismatched.length===0){{ + blockNote=' · all watched states OK'; + }} else if(bl&&bl.enabled===true){{ + blockNote=' · '+mismatched.length+' watched state'+(mismatched.length>1?'s':'')+' currently blocking'; + }} else {{ + blockNote=' · '+mismatched.length+' watched state'+(mismatched.length>1?'s':'')+' out of spec (interlock '+enabledTxt+', not blocking)'; + }} + summary.innerHTML = 'Scan interlock '+enabledTxt+''+blockNote; let html=''; states.forEach(s=>{{ -- 2.54.0 From 11e5dbe5ce193deb333f328a891eadba4bbdb5b1 Mon Sep 17 00:00:00 2001 From: x12sa Date: Sun, 5 Jul 2026 09:06:12 +0200 Subject: [PATCH 22/32] webpage_generator: tomo queue - don't reopen a manually collapsed running job renderTomoQueue auto-opened the running job whenever the open-state snapshot was empty, so collapsing the only open job (the running one) made the snapshot empty and re-opened it on the next refresh. Open-state was also keyed by positional index, which mis-assigns the flag when jobs are added/removed/ reordered. - Snapshot open jobs by stable key (added_at|label) via a data-key attribute instead of positional index. - Auto-open a running job only once, on first appearance, tracked in window.__tqAutoOpened; afterwards honor the DOM snapshot so a manual collapse sticks. Prune the set when jobs disappear. --- .../flomni/flomni_webpage_generator.py | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 8db13a1..a34ad90 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -2764,22 +2764,39 @@ function calcProjections(params){{ function renderTomoQueue(jobs){{ const el=document.getElementById('tomo-queue-content'); if(!el) return; // setup has no tomo-queue card (HAS_TOMO_QUEUE=False) - // Snapshot which job indices are currently open before replacing innerHTML - const openIndices=new Set(); - el.querySelectorAll('details.tq-job').forEach((det,i)=>{{ - if(det.open) openIndices.add(i); + // Snapshot which jobs are currently open, keyed by a stable identity + // (added_at|label) rather than positional index, so add/remove/reorder + // does not carry the open flag onto the wrong job. This reflects the + // user's manual expand/collapse since the last render (the DOM persists + // across refreshes; only innerHTML is replaced). + const openKeys=new Set(); + el.querySelectorAll('details.tq-job').forEach(det=>{{ + if(det.open && det.dataset.key) openKeys.add(det.dataset.key); }}); if(!jobs||jobs.length===0){{ el.innerHTML='
No jobs queued
'; return; }} + // Running jobs are auto-opened once, on first appearance. We remember which + // we have already auto-opened so a manual collapse is not undone on the next + // refresh (previously, collapsing the only open job made the snapshot empty + // and re-triggered the auto-open). + const autoOpened=window.__tqAutoOpened||(window.__tqAutoOpened=new Set()); + const presentKeys=new Set(); let html=''; jobs.forEach((job,i)=>{{ const status=job.status||'pending'; const label=job.label||('Job '+(i+1)); const params=job.params||{{}}; - // Auto-open running jobs on first render; preserve user-opened state after that - const shouldOpen=openIndices.size>0?openIndices.has(i):(status==='running'); + const key=(job.added_at||'')+'|'+label; + presentKeys.add(key); + // Open if the user currently has it open, or it is a running job we have + // not auto-opened before (its first appearance). Auto-open fires once. + let shouldOpen=openKeys.has(key); + if(status==='running' && !autoOpened.has(key)){{ + shouldOpen=true; + autoOpened.add(key); + }} let paramRows=''; TQ_PARAM_DISPLAY.forEach(([key,dispLabel])=>{{ if(key in params){{ @@ -2797,7 +2814,7 @@ function renderTomoQueue(jobs){{ const addedAt=job.added_at ?'Added '+new Date(job.added_at).toLocaleString([],{{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}}) :''; - html+='
' + html+='
' +'' +'#'+(i+1)+'' +''+esc(label)+'' @@ -2809,6 +2826,9 @@ function renderTomoQueue(jobs){{ +'
' +''; }}); + // Drop remembered auto-open keys for jobs that are gone, so the set stays + // small and a job that later reappears is treated as new. + autoOpened.forEach(k=>{{ if(!presentKeys.has(k)) autoOpened.delete(k); }}); el.innerHTML=html; }} -- 2.54.0 From c131bccada20ac86f595df8a37d2b8ac855fbb3f Mon Sep 17 00:00:00 2001 From: x12sa Date: Sun, 5 Jul 2026 10:24:00 +0200 Subject: [PATCH 23/32] fix: add timeout to MCS card status checks to prevent deadlock --- csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py index b4455a2..fc2e75d 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -383,14 +383,14 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): # NOTE First we wait that the MCS card is not acquiring. We add here a timeout of 5s to avoid # a deadlock in case the MCS card is stuck for some reason. This should not happen normally. - status = CompareStatus(mcs.acquiring, ACQUIRING.DONE) + status = CompareStatus(mcs.acquiring, ACQUIRING.DONE, timeout=5) self.cancel_on_stop(status) - status.wait(timeout=5) + status.wait() # NOTE Clear the '_omit_mca_callbacks' flag. This makes sure that data received from the mca1...mca3 # counters are forwarded to BEC. Once the flag is set, we create a TransitionStatus DONE->ACQUIRING # and start the acquisition through erase_start.put(1). Finally, we wait for the card to go to ACQUIRING state. - status_acquiring = CompareStatus(mcs.acquiring, ACQUIRING.ACQUIRING) + status_acquiring = CompareStatus(mcs.acquiring, ACQUIRING.ACQUIRING, timeout=5) with suppress_mca_callbacks(mcs): mcs.erase_start.put(1) mcs._omit_mca_callbacks.clear() # pylint: disable=protected-access @@ -614,7 +614,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): # ) if not status_mcs.done: mcs.acquiring.get(use_monitor=False) - status_mcs.wait(timeout=3) + status_mcs.wait() except Exception as exc: if ( mcs.acquiring.get(use_monitor=False) != ACQUIRING.ACQUIRING @@ -625,7 +625,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): raise exc # mcs.erase_start.put(1) # status_mcs.wait(timeout=3) - status = CompareStatus(mcs.acquiring, ACQUIRING.DONE) + status = CompareStatus(mcs.acquiring, ACQUIRING.DONE, timeout=5) logger.info(f"Finished preparing mcs card {time.time()-start_time}") # Send trigger -- 2.54.0 From 7f3259d541481a704fbb679c46c7206b0f31d831 Mon Sep 17 00:00:00 2001 From: x12sa Date: Sun, 5 Jul 2026 11:33:07 +0200 Subject: [PATCH 24/32] fix(flomni): use _flomnigui_is_missing guard in show_cameras Match the deleted-widget detection used by all other flomniGuiTools methods. The previous _flomnigui_check_attribute_not_exists path relied on hasattr(self.gui.flomni, "Image"/"Image_0"), which returns stale/ mismatched results when docks are recreated, leaving an empty window after a user closed the GUI mid sample-transfer. _is_deleted() checks registry membership and reliably triggers a rebuild. --- csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py index f1fc522..eb0ff80 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py @@ -90,9 +90,9 @@ class flomniGuiTools: def flomnigui_show_cameras(self): self.flomnigui_show_gui() - if self._flomnigui_check_attribute_not_exists( - "cam_flomni_gripper" - ) or self._flomnigui_check_attribute_not_exists("cam_flomni_overview"): + if self._flomnigui_is_missing("camera_gripper_image") or self._flomnigui_is_missing( + "camera_overview_image" + ): self.flomnigui_remove_all_docks() self.camera_gripper_image = self.gui.flomni.new("Image") if self._flomnicam_check_device_exists(dev.cam_flomni_gripper): -- 2.54.0 From 5c57ec25153d3a0ca9b151672642ebb4be48febb Mon Sep 17 00:00:00 2001 From: x12sa Date: Sun, 5 Jul 2026 15:28:31 +0200 Subject: [PATCH 25/32] fix timeout at end of acqusition --- csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py index fc2e75d..05d1054 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -625,7 +625,8 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): raise exc # mcs.erase_start.put(1) # status_mcs.wait(timeout=3) - status = CompareStatus(mcs.acquiring, ACQUIRING.DONE, timeout=5) + timeout = 5 + self.scan_parameters.exp_time * self.scan_parameters.frames_per_trigger + status = CompareStatus(mcs.acquiring, ACQUIRING.DONE, timeout=timeout) logger.info(f"Finished preparing mcs card {time.time()-start_time}") # Send trigger -- 2.54.0 From a8fb13ca96b84901e54d66816defb5a780372e9f Mon Sep 17 00:00:00 2001 From: x12sa Date: Sun, 5 Jul 2026 20:42:39 +0200 Subject: [PATCH 26/32] feat(flomni): guard umvr_fsamy_tracked against enabled rt feedback umvr_fsamy_tracked/umv_fsamy_tracked require feedback OFF but did not enforce it. Add an _internal flag; for user/CLI calls, warn that feedback must be off (active alignment may be lost), prompt (default yes), and disable feedback on confirm. Internal callers pass _internal=True since they already manage the feedback lifecycle (ftransfer_flomni_stage_out, x_ray_eye_align height-centering and mvy jog). --- .../plugins/flomni/flomni.py | 22 +++++++++++++++---- .../plugins/flomni/x_ray_eye_align.py | 4 ++-- docs/user/ptychography/flomni.md | 1 - 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index d9f2f8b..c44ec45 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -465,7 +465,7 @@ class FlomniSampleTransferMixin: def laser_tracker_off(self): dev.rtx.controller.laser_tracker_off() - def umvr_fsamy_tracked(self, shift: float, step: float = 0.01): + def umvr_fsamy_tracked(self, shift: float, step: float = 0.01, _internal: bool = False): """ Relative move of fsamy by `shift` mm, broken into steps of at most `step` mm (default 0.01 mm = 10 um), calling laser_tracker_on() @@ -487,10 +487,22 @@ class FlomniSampleTransferMixin: other direction). step: maximum step size in mm per tracker re-acquisition. Must be positive. Default 0.01 mm (10 um). + _internal: set True by internal callers that already manage the + feedback lifecycle themselves, to skip the feedback guard + below. User/CLI calls leave this False. """ if step <= 0: raise ValueError("step must be a positive number of mm.") + if not _internal and dev.rtx.controller.feedback_is_running(): + print( + "\x1b[91mThis move requires rt feedback OFF; any active alignment " + "may be lost.\x1b[0m" + ) + if not self.OMNYTools.yesno("Disable feedback and continue?", "y"): + return + self.feedback_disable() + direction = 1 if shift >= 0 else -1 remaining = abs(shift) while remaining > 0: @@ -499,7 +511,7 @@ class FlomniSampleTransferMixin: dev.rtx.controller.laser_tracker_on() remaining -= this_step - def umv_fsamy_tracked(self, target: float, step: float = 0.01): + def umv_fsamy_tracked(self, target: float, step: float = 0.01, _internal: bool = False): """ Absolute move of fsamy to `target` mm (same units as dev.fsamy's readback / dev.fsamy.user_parameter.get("in")), broken into steps of @@ -510,10 +522,12 @@ class FlomniSampleTransferMixin: step: maximum step size in mm per tracker re-acquisition. Must be positive (validated in umvr_fsamy_tracked). Default 0.01 mm (10 um). + _internal: forwarded to umvr_fsamy_tracked() to skip the feedback + guard for callers that manage feedback themselves. """ current = dev.fsamy.readback.get() shift_mm = target - current - self.umvr_fsamy_tracked(shift_mm, step) + self.umvr_fsamy_tracked(shift_mm, step, _internal=_internal) def show_signal_strength_interferometer(self): dev.rtx.controller.show_signal_strength_interferometer() @@ -567,7 +581,7 @@ class FlomniSampleTransferMixin: "Could not find an 'IN' position for fsamy. Please check your config." ) - self.umv_fsamy_tracked(fsamy_in) + self.umv_fsamy_tracked(fsamy_in, _internal=True) time.sleep(0.05) self.laser_tracker_off() time.sleep(0.05) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py index a1e2717..fe057f4 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py @@ -307,7 +307,7 @@ class XrayEyeAlign: self.flomni.feedback_disable() if not self.test_wo_movements and delta_y_mm != 0: - self.flomni.umvr_fsamy_tracked(delta_y_mm) + self.flomni.umvr_fsamy_tracked(delta_y_mm, _internal=True) time.sleep(2) self.flomni.feedback_enable_with_reset() @@ -359,7 +359,7 @@ class XrayEyeAlign: if _xrayeyalignmvy != 0: self.flomni.feedback_disable() if not self.test_wo_movements: - self.flomni.umvr_fsamy_tracked(_xrayeyalignmvy / 1000) + self.flomni.umvr_fsamy_tracked(_xrayeyalignmvy / 1000, _internal=True) time.sleep(2) dev.omny_xray_gui.mvy.set(0) self.flomni.feedback_enable_with_reset() diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index a7d8970..7b365af 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -54,7 +54,6 @@ If you see your sample already at the approximately correct height: Otherwise adjust the height manually: -1. `flomni.feedback_disable()` disable the closed loop operation to allow movement of coarse stages 1. `umvr_fsamy_tracked(0.01)`, attention: unit , move the sample stage relative up (positive) or down (negative) until the sample is approximately vertically centered in xray eye screen 1. `flomni.xrayeye_update_frame()` will update the current image on the xray eye screen -- 2.54.0 From ac1ade2fafd5b1a1cf9e11246769e07780d3ecef Mon Sep 17 00:00:00 2001 From: x12sa Date: Mon, 6 Jul 2026 08:53:15 +0200 Subject: [PATCH 27/32] webpage_generator: instrument pane shows full live measurement parameters The instrument-details pane showed only a hand-built 6-row settings summary, far less than the tomo queue shows per job, and nothing useful for a tomo started outside the queue. - Flomni _collect_setup_data now returns current_params: the tomo parameters read live from global vars, keyed by the same names the queue stores in job.params (_CURRENT_PARAM_KEYS, matching TQ_PARAM_DISPLAY). Values left raw; the page formats them. Replaces the old settings dict. - Extract the queue's param-table builder into a shared buildParamRows() JS helper (carries the computed Projections row), used by both the queue and the new instrument 'Current measurement' section, so they can't drift. - renderInstrument renders current_params via buildParamRows (Sample name row first); card hides only when there are neither params nor temperatures. - Base _collect_setup_data contract updated: settings -> current_params. --- .../flomni/flomni_webpage_generator.py | 135 +++++++++++------- 1 file changed, 83 insertions(+), 52 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index a34ad90..0802302 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -1373,10 +1373,21 @@ class WebpageGeneratorBase: by the HTML page as the 'Instrument details' section. Expected keys (all optional): - type (str) -- setup identifier, e.g. "flomni" - sample_name (str) -- current sample name - temperatures (dict) -- label -> float (degrees C) or None - settings (dict) -- label -> formatted string + type (str) -- setup identifier, e.g. "flomni" + sample_name (str) -- current sample name + temperatures (dict) -- label -> float (degrees C) or None + current_params (dict) -- raw tomo parameters read live (keyed by + the same global-var names the tomo queue + stores in job.params, e.g. tomo_countingtime, + fovx, tomo_angle_stepsize, ...). Rendered by + the page through the same param-table path + as a queue job (buildParamRows), so the + 'Current measurement' section always matches + the queue's level of detail -- and shows the + details even for a scan started outside the + queue. Values are left raw; the page formats + them (fmtTqParam) and computes the projection + count (calcProjections). """ return {} @@ -1402,10 +1413,31 @@ class WebpageGeneratorBase: class FlomniWebpageGenerator(WebpageGeneratorBase): """ flOMNI-specific webpage generator. - Adds: temperatures, sample name, measurement settings from global vars. + Adds: temperatures, sample name, and live current-measurement parameters + from global vars. Logo: flOMNI.png from the same directory as this file. """ + # Global-var names of the tomo parameters to read live for the + # 'Current measurement' section. Kept in the same order as, and matching, + # the tomo queue's TQ_PARAM_DISPLAY keys so the two render identically. + # (Any name that is not actually a global var is skipped silently and just + # does not appear as a row.) + _CURRENT_PARAM_KEYS = [ + "tomo_type", + "tomo_countingtime", + "fovx", + "fovy", + "tomo_angle_stepsize", + "tomo_angle_range", + "tomo_shellstep", + "stitch_x", + "stitch_y", + "frames_per_trigger", + "single_point_instead_of_fermat_scan", + "ptycho_reconstruct_foldername", + ] + # label -> dotpath under device_manager.devices _TEMP_MAP = { "Heater": "flomni_temphum.temperature_heater", @@ -1429,40 +1461,28 @@ class FlomniWebpageGenerator(WebpageGeneratorBase): for label, path in self._TEMP_MAP.items() } - # ── Settings from global vars ───────────────────────────────── + # ── Current measurement parameters (read live) ──────────────── + # Same keys the tomo queue stores in job.params, so the 'Current + # measurement' section on the page renders through the identical + # param-table path (buildParamRows -> fmtTqParam / calcProjections) + # and always matches the queue's level of detail. Reading these live + # means a tomo started OUTSIDE the queue still shows full parameters. + # Values are left raw; the page does the formatting. g = self._bec - - def _fmt2(v): + current_params = {} + for key in self._CURRENT_PARAM_KEYS: try: - return f"{float(v):.2f}" + val = g.get_global_var(key) except Exception: - return "N/A" - - fovx = g.get_global_var("fovx") - fovy = g.get_global_var("fovy") - stx = g.get_global_var("stitch_x") - sty = g.get_global_var("stitch_y") - - def _fmt_int(v): - try: - return str(int(v)) - except (TypeError, ValueError): - return "N/A" - - settings = { - "Sample name": sample_name, - "FOV x / y": f"{_fmt2(fovx)} / {_fmt2(fovy)} \u00b5m", - "Step size": _gvar(g, "tomo_shellstep", ".2f", " \u00b5m"), - "Exposure time": _gvar(g, "tomo_countingtime", ".3f", " s"), - "Angle step": _gvar(g, "tomo_angle_stepsize", ".2f", "\u00b0"), - "Stitch x / y": f"{_fmt_int(stx)} / {_fmt_int(sty)}", - } + val = None + if val is not None: + current_params[key] = val return { - "type": "flomni", - "sample_name": sample_name, - "temperatures": temperatures, - "settings": settings, + "type": "flomni", + "sample_name": sample_name, + "temperatures": temperatures, + "current_params": current_params, } @@ -2656,12 +2676,18 @@ function esc(s){{return String(s==null?'N/A':s).replace(/&/g,'&').replace(/< function renderInstrument(setup){{ const card=document.getElementById('instrument-card'),grid=document.getElementById('instrument-grid'); - if(!setup||(!setup.temperatures&&!setup.settings)){{card.style.display='none';return;}} + const cp=(setup&&setup.current_params)||null; + const hasParams=cp&&Object.keys(cp).length>0; + if(!setup||(!setup.temperatures&&!hasParams)){{card.style.display='none';return;}} card.style.display=''; let html=''; - if(setup.settings){{ - html+='

Measurement settings

'; - for(const[k,v] of Object.entries(setup.settings)) html+=''; + if(hasParams){{ + // Same param-table path as a tomo queue job (buildParamRows), so the live + // 'Current measurement' matches the queue's level of detail — and shows + // full parameters even for a scan started outside the queue. + html+='

Current measurement

'+esc(k)+''+esc(v)+'
'; + if(setup.sample_name) html+=''; + html+=buildParamRows(cp); html+='
Sample name'+esc(setup.sample_name)+'
'; }} if(setup.temperatures){{ @@ -2761,6 +2787,24 @@ function calcProjections(params){{ return null; }} +// Build the tomo-parameter table rows for a params object, in TQ_PARAM_DISPLAY +// order, injecting the computed Projections row after the angle step. Shared by +// the tomo queue (per job) and the instrument 'Current measurement' section, so +// both always show the same parameters, formatted the same way. +function buildParamRows(params){{ + let rows=''; + TQ_PARAM_DISPLAY.forEach(([key,dispLabel])=>{{ + if(key in params){{ + rows+=''+dispLabel+''+esc(fmtTqParam(key,params[key]))+''; + }} + if(key==='tomo_angle_stepsize'){{ + const nproj=calcProjections(params); + if(nproj!=null) rows+='Projections'+nproj+''; + }} + }}); + return rows; +}} + function renderTomoQueue(jobs){{ const el=document.getElementById('tomo-queue-content'); if(!el) return; // setup has no tomo-queue card (HAS_TOMO_QUEUE=False) @@ -2797,20 +2841,7 @@ function renderTomoQueue(jobs){{ shouldOpen=true; autoOpened.add(key); }} - let paramRows=''; - TQ_PARAM_DISPLAY.forEach(([key,dispLabel])=>{{ - if(key in params){{ - paramRows+=''+dispLabel+''+esc(fmtTqParam(key,params[key]))+''; - }} - // After angle step, inject a computed projection count row, driven by - // this instrument's TOMO_TYPES config (must match the corresponding - // Python-side formula, e.g. _tomo_type1_actual_grid() for flomni's - // "equally_spaced_grid" type). - if(key==='tomo_angle_stepsize'){{ - const nproj=calcProjections(params); - if(nproj!=null) paramRows+='Projections'+nproj+''; - }} - }}); + const paramRows=buildParamRows(params); const addedAt=job.added_at ?'Added '+new Date(job.added_at).toLocaleString([],{{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}}) :''; -- 2.54.0 From b686ef24eb8786c285940a74e822daa616c27a9b Mon Sep 17 00:00:00 2001 From: x12sa Date: Mon, 6 Jul 2026 09:41:00 +0200 Subject: [PATCH 28/32] webpage_generator: success chime on finish + trim mobile empty scroll space - Audio: a normal finish (scanning -> idle) now plays a rising success arpeggio (C5-E5-G5) instead of the falling warning chime, so a completed measurement is audibly distinct from a stop to any other state (which keeps the falling 660->440 warning). startWarning(finished) selects the chime for both the initial tone and the 30s repeats; the Watch LED text reads "Measurement finished" vs "Measurement stopped" accordingly. Help text updated. - Layout: body uses min-height:100dvh (with 100vh fallback) so the page no longer extends below the visible viewport on mobile, where iOS 100vh counts the retracted-toolbar height and leaves empty scroll room at the bottom. --- .../flomni/flomni_webpage_generator.py | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 0802302..4c59eff 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -1633,7 +1633,11 @@ def _render_html(phone_numbers: list, has_tomo_queue: bool = True, tomo_types: d body {{ background: var(--bg); color: var(--text); font-family: var(--sans); font-weight: 300; - min-height: 100vh; padding: 1.5rem; + min-height: 100vh; /* fallback for browsers without dvh */ + min-height: 100dvh; /* dynamic viewport: avoids extra scroll room + below content on mobile (iOS 100vh counts the + retracted-toolbar height and overshoots) */ + padding: 1.5rem; transition: background 0.4s, color 0.4s; }} body::before {{ @@ -2157,7 +2161,7 @@ def _render_html(phone_numbers: list, has_tomo_queue: bool = True, tomo_types: d

Audio warnings

  • System LED — on when audio is enabled on this device.
  • -
  • Watch LED — armed (green) while a scan is running; pulses orange if a scan stops unexpectedly, with a chime every 30s until confirmed.
  • +
  • Watch LED — armed (green) while a scan is running; pulses orange when a scan stops, with a chime every 30s until confirmed. A normal finish (scan → idle) plays a rising success tone; a stop to any other state plays the falling warning tone, so you can tell them apart by ear.
  • Blocked LED — pulses orange while the queue is locked. A chime fires automatically after 60 continuous minutes blocked, then repeats hourly until cleared. No confirmation needed — it clears itself once the block resolves.
  • Live LED — green while this page's data feed is fresh; pulses orange if the feed goes stale or a fetch fails.
@@ -2353,6 +2357,7 @@ initDrag(); let audioCtx=null, audioEnabled=false; let audioArmed=false, warningActive=false, warningTimer=null, lastStatus=null; +let warningWasSuccess=false; // true when the active warning is a normal finish (scanning -> idle) let staleActive=false, staleTimer=null, staleConfirmed=false; let blockedSince=null, blockedWarningActive=false, blockedChimeTimer=null; const BLOCKED_WARNING_DELAY_MS=60*60*1000; // first chime after 60 min continuously blocked @@ -2387,6 +2392,13 @@ function beep(freq,dur,vol){{ function warningChime(){{ beep(660,0.3,0.4); setTimeout(()=>beep(440,0.5,0.4),350); }} +function successChime(){{ + // Reverse of warningChime: a rising major arpeggio (C5-E5-G5) so a normal + // finish (scanning -> idle) is audibly a happy "done" rather than an alert. + beep(523,0.22,0.4); + setTimeout(()=>beep(659,0.22,0.4),240); + setTimeout(()=>beep(784,0.45,0.4),480); +}} function staleChime(){{ beep(1200,0.12,0.35); setTimeout(()=>beep(1200,0.12,0.35),180); @@ -2432,12 +2444,14 @@ function confirmWarning(){{ updateAudioUI(); }} -function startWarning(){{ +function startWarning(finished){{ // Not a gesture handler — context already unlocked by Enable button click. if(warningActive) return; warningActive=true; - if(audioEnabled) warningChime(); - warningTimer=setInterval(()=>{{ if(audioEnabled) warningChime(); }},30000); + warningWasSuccess=!!finished; // scanning -> idle == successful finish + const chime = warningWasSuccess ? successChime : warningChime; + if(audioEnabled) chime(); + warningTimer=setInterval(()=>{{ if(audioEnabled) chime(); }},30000); document.getElementById('btn-confirm').style.display='inline-block'; updateAudioUI(); }} @@ -2537,10 +2551,10 @@ function updateAudioUI(){{ txt.textContent='Audio disabled \u2014 enable to receive warnings'; }}else if(warningActive && staleActive){{ ledWatch.className='led led-warning'; - txt.textContent='Measurement stopped & live feed lost \u2014 confirm each'; + txt.textContent=(warningWasSuccess?'Measurement finished':'Measurement stopped')+' & live feed lost \u2014 confirm each'; }}else if(warningActive){{ ledWatch.className='led led-warning'; - txt.textContent='Measurement stopped \u2014 confirm to silence'; + txt.textContent=(warningWasSuccess?'Measurement finished':'Measurement stopped')+' \u2014 confirm to silence'; }}else if(staleActive){{ ledWatch.className='led'; txt.textContent='Live feed lost \u2014 confirm to silence'; @@ -2573,7 +2587,7 @@ function handleAudioForStatus(status, prevStatus){{ }} if(audioArmed && wasScanning && !isScanning){{ - startWarning(); + startWarning(status==='idle'); // idle == finished successfully -> success chime }} if(isScanning && warningActive){{ -- 2.54.0 From bda617428d99970e31175b561f61bede5bf12b4a Mon Sep 17 00:00:00 2001 From: x12sa Date: Mon, 6 Jul 2026 09:54:59 +0200 Subject: [PATCH 29/32] config update --- csaxs_bec/device_configs/ptycho_flomni.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index 7159572..103c3a8 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -403,8 +403,8 @@ rtx: readoutPriority: on_request connectionTimeout: 20 userParameter: - low_signal: 9500 - min_signal: 8500 + low_signal: 8500 + min_signal: 8000 rt_pid_voltage: -0.06219 rty: description: flomni rt -- 2.54.0 From 5e51f7d7e4cad636ce11908ca612b0671401c7c7 Mon Sep 17 00:00:00 2001 From: x12sa Date: Mon, 6 Jul 2026 10:30:24 +0200 Subject: [PATCH 30/32] webpage_generator: keep scanning->blocked silent (no scan-stopped warning) A block is external and self-resolving, so the immediate "measurement stopped, confirm" warning chime was misleading there - the user cannot act on it and should just wait. Suppress startWarning when the destination status is blocked; the blocked track still handles it (silent until the 60-min blockedChime, then hourly). scanning->idle (success tone) and scanning->other (warning tone) are unchanged. Help text updated. --- .../plugins/omny/omny_webpage_generator.py | 3010 ++++++++++++++++- 1 file changed, 2943 insertions(+), 67 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_webpage_generator.py index 1a04a77..b1e74f7 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_webpage_generator.py @@ -1,96 +1,2972 @@ """ -omny/webpage_generator.py -========================== -OMNY-specific webpage generator subclass. +flomni/webpage_generator.py +============================ +Background thread that reads tomo progress from the BEC global variable store +and writes status.json (every cycle) + status.html (once at startup) to a +staging directory. An optional HttpUploader sends those files to a web host +after every cycle, running in a separate daemon thread so uploads never block +the generator cycle. A built-in LocalHttpServer always serves the output +directory locally (default port 8080) so the page can be accessed on the +lab network without any extra setup. -Integration (inside the OMNY __init__ / startup): --------------------------------------------------- - from csaxs_bec.bec_ipython_client.plugins.omny.webpage_generator import ( - OmnyWebpageGenerator, +Architecture +------------ +LocalHttpServer -- built-in HTTP server; serves output_dir on port 8080. + Always started at _launch(); URL printed to console. +HttpUploader -- non-blocking HTTP uploader (fire-and-forget thread). + Tracks file mtimes; only uploads changed files. + Sends a cleanup request to the server when the + ptycho scan ID changes (removes old S*_*.png/jpg). +WebpageGeneratorBase -- all common logic: queue, progress, idle detection, + reconstruction queue, HTML, audio, phone numbers, + outdated-page warning. Import this in subclasses. +FlomniWebpageGenerator -- flomni-specific: temperatures, sample name, settings, + flOMNI logo. +make_webpage_generator() -- factory; selects the right class by session name. + Lazy-imports LamNI / omny subclasses to avoid + circular dependencies. + +Integration (inside Flomni.__init__, after self._progress_proxy.reset()): +-------------------------------------------------------------------------- + from csaxs_bec.bec_ipython_client.plugins.flomni.webpage_generator import ( + FlomniWebpageGenerator, ) - self._webpage_gen = OmnyWebpageGenerator( + self._webpage_gen = FlomniWebpageGenerator( bec_client=client, output_dir="~/data/raw/webpage/", + upload_url="http://omny.online/upload.php", # optional + local_port=8080, # optional, default 8080 ) self._webpage_gen.start() + # On start(), the console prints: + # ➜ Status page: http://hostname:8080/status.html -Or use the factory (auto-selects by session name "omny"): ---------------------------------------------------------- - from csaxs_bec.bec_ipython_client.plugins.flomni.webpage_generator import ( - make_webpage_generator, - ) - self._webpage_gen = make_webpage_generator(bec, output_dir="~/data/raw/webpage/") - self._webpage_gen.start() +Interactive helpers (optional, in the iPython session): +------------------------------------------------------- + flomni._webpage_gen.status() # print current status + local URL + flomni._webpage_gen.verbosity = 2 # VERBOSE: one-line summary per cycle + flomni._webpage_gen.verbosity = 3 # DEBUG: full JSON per cycle + flomni._webpage_gen.stop() # release lock, stop local server + flomni._webpage_gen.start() # restart after stop() + flomni.webpage_gen.set_web_password("pw") # set web password for current e-account -Interactive helpers: --------------------- - omny._webpage_gen.status() - omny._webpage_gen.verbosity = 2 - omny._webpage_gen.stop() - omny._webpage_gen.start() +Session authentication: +----------------------- +Two htpasswd files are used on the remote server: + users.htpasswd -- static, managed manually with htpasswd -B + session.htpasswd -- managed by the generator; one entry for the current e-account + +At _launch(), the generator queries session_query.php to find which account was +previously active. If the account has changed the session.htpasswd is cleared +immediately (old user loses access) and uploaded. The new user calls +set_web_password() to set their own password and gain access. """ +import datetime +import functools +import http.server +import json +import os +import shutil +import socket +import threading +import time from pathlib import Path -from csaxs_bec.bec_ipython_client.plugins.flomni.webpage_generator import ( - WebpageGeneratorBase, - _safe_get, - _safe_float, - _gvar, -) +try: + from PIL import Image as _PILImage + _PIL_AVAILABLE = True +except ImportError: + _PIL_AVAILABLE = False + +from bec_lib import bec_logger + +logger = bec_logger.logger + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_LOCK_VAR_KEY = "webpage_generator_lock" +_LOCK_STALE_AFTER_S = 35 # just over 2 missed cycles (2×15s+5s margin) +_CYCLE_INTERVAL_S = 15 +_TOMO_HEARTBEAT_STALE_S = 90 + +# Path where online ptycho reconstructions are written by the analysis pipeline. +# Subfolders follow the pattern dset_NNNNN and contain S#####_*_phase.png etc. +_PTYCHO_ONLINE_DIR = "~/data/raw/analysis/online/ptycho" +_PTYCHO_THUMB_PX = 200 # longest edge of generated thumbnail in pixels + +VERBOSITY_SILENT = 0 +VERBOSITY_NORMAL = 1 +VERBOSITY_VERBOSE = 2 +VERBOSITY_DEBUG = 3 + +_PHONE_NUMBERS = [ + ("Beamline", "+41 56 310 5845"), + ("Control room", "+41 56 310 5503"), +] -class OmnyWebpageGenerator(WebpageGeneratorBase): +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _now_iso() -> str: + return datetime.datetime.now().isoformat(timespec="seconds") + +def _epoch() -> float: + return time.time() + +def _heartbeat_age_s(iso_str) -> float: + if iso_str is None: + return float("inf") + try: + ts = datetime.datetime.fromisoformat(iso_str) + return (datetime.datetime.now() - ts).total_seconds() + except Exception: + return float("inf") + +def _format_duration(seconds) -> str: + if seconds is None: + return "N/A" + try: + seconds = int(float(seconds)) + except (TypeError, ValueError): + return "N/A" + h, remainder = divmod(seconds, 3600) + m, s = divmod(remainder, 60) + if h > 0: + return f"{h}h {m:02d}m {s:02d}s" + if m > 0: + return f"{m}m {s:02d}s" + return f"{s}s" + +def _check_account_match(bec_client) -> bool: + try: + active = bec_client.active_account + system_user = os.getenv("USER") or os.getlogin() + return active[1:] == system_user[1:] + except Exception: + return True + +def _safe_get(device_manager, dotpath: str): + """Navigate a dotted device path and call .get(), returning None on any error.""" + try: + obj = device_manager.devices + for part in dotpath.split("."): + obj = getattr(obj, part) + return obj.get(cached=True) + except Exception: + return None + +def _safe_float(val, ndigits: int = 2): + try: + return round(float(val), ndigits) + except (TypeError, ValueError): + return None + +def _gvar(bec_client, key, fmt=None, suffix=""): + val = bec_client.get_global_var(key) + if fmt is None: + return val + if val is None: + return "N/A" + try: + return f"{val:{fmt}}{suffix}" + except Exception: + return str(val) + +def _read_beamline_states_info(bec_client) -> dict: + """Return a diagnostic snapshot of all configured beamline states, for + display only (NOT used to derive the 'blocked' experiment status — that + is computed from the queue's own locks via _read_queue_locks(), which is + the authoritative source; see its docstring for why). + + Returns: + { + "enabled": bool | None, # bec.builtin_actors.scan_interlock.enabled + "states": [ + { + "name": str, + "status": str, # 'valid' | 'invalid' | 'warning' | 'unknown' + "label": str, + "watched": bool, # True if present in states_watched + "accepted": list[str] | None, # accepted statuses if watched + "mismatched": bool, # True if watched AND status not in accepted + }, + ... + ], + } + + Beamline states are registered dynamically and can differ from + experiment to experiment, so names are read from the manager's _states + dict (the same one show_all() iterates over internally — there is no + public "list names" method as of this BEC version). states_watched and + enabled are read through the public scan_interlock HLI properties. """ - OMNY-specific webpage generator. - Logo: OMNY.png from the same directory as this file. + manager = getattr(bec_client, "beamline_states", None) + if manager is None: + return {"enabled": None, "states": []} - Override _collect_setup_data() to add OMNY-specific temperatures, - sample name, and measurement settings. + try: + names = list(getattr(manager, "_states", {}).keys()) + except Exception: + names = [] - The old OMNY spec webpage showed: - - Cryo temperatures (XOMNY-TEMP-CRYO-A/B) - - Per-channel temperatures (XOMNY-TEMP1..48) - - Dewar pressure / LN2 flow - - Interferometer strengths (OINTERF) - Map these to BEC device paths below once available. + try: + interlock = bec_client.builtin_actors.scan_interlock + enabled = interlock.enabled + states_watched = interlock.states_watched or {} + except Exception: + enabled = None + states_watched = {} + + states = [] + for name in names: + try: + state_obj = getattr(manager, name, None) + if state_obj is None: + continue + info = state_obj.get() # {"status": ..., "label": ...} + status = info.get("status", "unknown") + label = info.get("label", name) + except Exception: + status, label = "unknown", name + + accepted = states_watched.get(name) + watched = accepted is not None + mismatched = watched and status not in accepted + + states.append({ + "name": name, + "status": status, + "label": label, + "watched": watched, + "accepted": accepted, + "mismatched": mismatched, + }) + + return {"enabled": enabled, "states": states} + + +def _read_queue_locks(primary) -> list: + """Return a list of {"identifier": str, "reason": str} for every lock + currently applied to the primary scan queue. + + BEC's scan_queue.status becomes "LOCKED" (saving the prior status to + restore later) whenever any lock is added, e.g. by the ScanInterlockActor + when a watched beamline state goes out of spec (see scan_interlock.py: + add_queue_lock(queue="primary", reason=..., lock_id="ScanInterlockActor")). + Reading the lock list directly is more robust than re-deriving "blocked" + from beamline states ourselves: it reflects whatever is actually holding + the queue, regardless of which actor (interlock or otherwise) caused it, + and survives changes to which beamline states are configured/watched. + """ + if primary is None: + return [] + try: + return [ + {"identifier": lock.identifier, "reason": lock.reason} + for lock in (primary.locks or []) + ] + except Exception: + return [] + + +# --------------------------------------------------------------------------- +# Status derivation +# --------------------------------------------------------------------------- + +def _derive_status( + progress: dict, + queue_has_active_scan: bool, + last_active_time, + had_activity: bool, + queue_locks: list | None = None, + beamline_blocking: bool = False, +) -> str: + """ + Returns one of: + scanning -- tomo heartbeat fresh (< _TOMO_HEARTBEAT_STALE_S), OR + heartbeat recently seen AND queue still has active scan + (handles long individual projections > heartbeat timeout) + blocked -- the experiment is being held up externally: EITHER a + watched beamline state is out of its accepted range while + the scan interlock would act on it (beamline_blocking), + OR the primary queue has at least one explicit lock + applied (queue_locks). Reported regardless of whether a + scan is currently executing: when a block hits, the + running scan is interrupted and its repeat waits for the + block to clear, so active_request_block (and hence + queue_has_active_scan) may already be cleared even though + the beamline is still blocking. Only 'scanning' (fresh + heartbeat = data still flowing) takes precedence. + running -- queue has active scan but heartbeat has never been seen, + and nothing is blocking + idle -- not scanning, not blocked, last_active_time known + unknown -- no activity ever seen since generator started + + 'unknown' is ONLY returned before any scan activity has been observed. + Once activity has been seen the status goes directly: + scanning/running/blocked -> idle + never through 'unknown'. + """ + hb_age = _heartbeat_age_s(progress.get("heartbeat")) + if hb_age < _TOMO_HEARTBEAT_STALE_S: + return "scanning" + # External block takes precedence over the remaining scan/idle states. + # A blocked scan is interrupted (active_request_block cleared) while its + # repeat waits for the block to clear, so we must NOT require an active + # scan here — that was the previous behaviour and it made a blocked-but- + # interrupted experiment fall through to 'idle'. + if beamline_blocking or queue_locks: + return "blocked" + # Heartbeat stale but queue still active and heartbeat was seen recently + # enough (within 10× the stale window) — likely a long projection, not idle. + # Report as 'scanning' so audio system does not trigger a false alarm. + if queue_has_active_scan and hb_age < _TOMO_HEARTBEAT_STALE_S * 10: + return "scanning" + if queue_has_active_scan: + return "running" + if last_active_time is not None or had_activity: + return "idle" + return "unknown" + + +# --------------------------------------------------------------------------- +# HTTP Uploader (non-blocking, fire-and-forget) +# --------------------------------------------------------------------------- + +class HttpUploader: + """ + Uploads files from a local directory to a remote server via HTTP POST. + + Uploads run in a daemon thread so they never block the generator cycle. + If an upload is already in progress when the next cycle fires, the new + upload request is dropped (logged at DEBUG level) rather than queuing up. + + File tracking: + - Tracks mtime of each file; only re-uploads files that have changed. + - upload_dir() -- uploads ALL eligible files (called once at start). + - upload_changed() -- uploads only files whose mtime has changed. + + Scan change cleanup: + - cleanup_ptycho_images() -- sends action=cleanup POST to the server, + asking it to delete all S*_*.png and S*_*.jpg files. + Called automatically by the generator when the ptycho scan ID changes. + + The server-side upload.php must: + - Accept POST with multipart file upload (field name 'file'). + - Accept POST with action=cleanup to delete ptycho image files. + - Enforce IP-based access control (129.129.122.x). + - Validate filename with regex to block path traversal. """ - # TODO: fill in OMNY-specific device paths + _UPLOAD_SUFFIXES = {".html", ".json", ".jpg", ".png", ".pdf", ".txt"} + + def __init__(self, url: str, timeout: float = 20.0): + self._url = url + self._timeout = timeout + self._uploaded: dict[str, float] = {} # abs path -> mtime at last upload + self._lock = threading.Lock() + self._busy = False # True while an upload thread is running + self._warn_at: dict[str, float] = {} # key -> epoch of last warning + + _WARN_COOLDOWN_S = 600 # only repeat the same warning once per minute + + def _warn(self, key: str, msg: str) -> None: + """Log a warning at most once per _WARN_COOLDOWN_S for a given key.""" + now = _epoch() + if now - self._warn_at.get(key, 0) >= self._WARN_COOLDOWN_S: + self._warn_at[key] = now + logger.warning(msg) + + # ── Public API ────────────────────────────────────────────────────────── + + def upload_dir_async(self, directory: Path) -> None: + """Upload ALL eligible files in directory, in a background thread.""" + files = self._eligible_files(directory) + self._dispatch(self._upload_files, files, True) + + def upload_changed_async(self, directory: Path) -> None: + """Upload only changed files in directory, in a background thread.""" + files = self._changed_files(directory) + if files: + self._dispatch(self._upload_files, files, False) + + def upload_file_async(self, path: Path) -> None: + """Upload a single specific file, bypassing suffix whitelist and mtime check.""" + self._dispatch(self._upload_files, [Path(path)], True) + + def cleanup_ptycho_images_async(self) -> None: + """Ask the server to delete all S*_*.png / S*_*.jpg files (background).""" + self._dispatch(self._do_cleanup) + + # ── Internal ──────────────────────────────────────────────────────────── + + def _dispatch(self, fn, *args) -> None: + """Run fn(*args) in a daemon thread. Drops the request if already busy.""" + with self._lock: + if self._busy: + logger.debug("HttpUploader: previous upload still running, skipping") + return + self._busy = True + + def _run(): + try: + fn(*args) + finally: + with self._lock: + self._busy = False + + t = threading.Thread(target=_run, name="HttpUploader", daemon=True) + t.start() + + def _eligible_files(self, directory: Path) -> list: + result = [] + for path in Path(directory).iterdir(): + if path.is_file() and path.suffix in self._UPLOAD_SUFFIXES: + result.append(path) + return result + + def _changed_files(self, directory: Path) -> list: + result = [] + for path in self._eligible_files(directory): + try: + mtime = path.stat().st_mtime + except OSError: + continue + if self._uploaded.get(str(path)) != mtime: + result.append(path) + return result + + def _upload_files(self, files: list, force: bool = False) -> None: + try: + import requests as _requests + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + except ImportError: + self._warn("no_requests", "HttpUploader: 'requests' library not installed") + return + + for path in files: + try: + mtime = path.stat().st_mtime + except OSError: + continue + # Double-check mtime unless forced (upload_dir uses force=True) + if not force and self._uploaded.get(str(path)) == mtime: + continue + try: + with open(path, "rb") as f: + r = _requests.post( + self._url, + files={"file": (path.name, f)}, + timeout=self._timeout, + verify=False, # accept self-signed / untrusted certs + ) + if r.status_code == 200: + self._uploaded[str(path)] = mtime + self._warn_at.pop(f"upload_{path.name}", None) # clear on success + logger.debug(f"HttpUploader: OK {path.name}") + else: + self._warn( + f"upload_{path.name}", + f"HttpUploader: {path.name} -> HTTP {r.status_code}: " + f"{r.text[:120]}" + ) + except Exception as exc: + self._warn(f"upload_{path.name}", f"HttpUploader: {path.name} failed: {exc}") + + def _do_cleanup(self) -> None: + try: + import requests as _requests + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + except ImportError: + return + try: + r = _requests.post( + self._url, + data={"action": "cleanup"}, + timeout=self._timeout, + verify=False, # accept self-signed / untrusted certs + ) + logger.info(f"HttpUploader cleanup: {r.text[:120]}") + self._warn_at.pop("cleanup", None) # clear on success + # Forget mtime records for ptycho files so they get re-uploaded + with self._lock: + to_remove = [k for k in self._uploaded if "/S" in k or "\\S" in k] + for k in to_remove: + self._uploaded.pop(k, None) + except Exception as exc: + self._warn("cleanup", f"HttpUploader cleanup failed: {exc}") + + +# --------------------------------------------------------------------------- +# Local HTTP server (serves output_dir over http://hostname:port/) +# --------------------------------------------------------------------------- + +class LocalHttpServer: + """ + Serves the generator's output directory over plain HTTP in a daemon thread. + + Uses Python's built-in http.server — no extra dependencies. + Request logging is suppressed so the BEC console stays clean. + The server survives stop()/start() cycles: _launch() creates a fresh + instance each time start() is called. + + Usage: + srv = LocalHttpServer(output_dir, port=8080) + srv.start() + print(srv.url) # http://hostname:8080/status.html + srv.stop() + """ + + def __init__(self, directory: Path, port: int = 8080): + self._directory = Path(directory) + self._port = port + self._server = None + self._thread = None + + # ── silence the per-request log lines in the iPython console ────────── + class _QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *args): + pass + # handle_error here does nothing — wrong class + + class _QuietHTTPServer(http.server.HTTPServer): + def handle_error(self, request, client_address): + pass # suppress BrokenPipeError and all other per-connection noise + + def start(self) -> None: + Handler = functools.partial( + self._QuietHandler, + directory=str(self._directory), + ) + try: + self._server = self._QuietHTTPServer(("", self._port), Handler) + except OSError as exc: + raise RuntimeError( + f"LocalHttpServer: cannot bind port {self._port}: {exc}" + ) from exc + self._thread = threading.Thread( + target=self._server.serve_forever, + name="LocalHttpServer", + daemon=True, + ) + self._thread.start() + + def stop(self) -> None: + if self._server is not None: + self._server.shutdown() # blocks until serve_forever() returns + self._server = None + + def is_alive(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + @property + def port(self) -> int: + return self._port + + @property + def url(self) -> str: + """Best-guess URL for printing. Uses the machine's hostname.""" + return f"http://{socket.gethostname()}:{self._port}/status.html" + + +# --------------------------------------------------------------------------- +# Base generator +# --------------------------------------------------------------------------- + +class WebpageGeneratorBase: + """ + Common webpage generator. Subclass and override: + _collect_setup_data() -- return dict of instrument-specific data + _logo_path() -- return Path to logo PNG, or None for text fallback + + Setup capability flags (override on subclass as needed): + HAS_TOMO_QUEUE -- whether this instrument exposes the persisted + parameter-snapshot job queue (global var + "tomo_queue" + the "Tomo queue" UI card). + NOT to be confused with BEC's own primary scan + queue (queue_status / queue_locks in _cycle()), + which every setup has and which stays common. + TOMO_TYPES -- dict describing this instrument's tomography + scan types, keyed by the value progress["tomo_type"] + takes. Used by the UI to compute total-projection + counts for queued jobs without hardcoding a + per-instrument formula in JS. Recognised "kind"s: + "equally_spaced_grid" (params: n_subtomos) + total = floor(angle_range / angle_stepsize) * n_subtomos + "golden_capped" + total = golden_max_number_of_projections + """ + + HAS_TOMO_QUEUE = True + TOMO_TYPES = { + 1: {"kind": "equally_spaced_grid", "n_subtomos": 8}, + 2: {"kind": "golden_capped"}, + 3: {"kind": "golden_capped"}, + } + + def __init__( + self, + bec_client, + output_dir: str = "~/data/raw/webpage/", + cycle_interval: float = _CYCLE_INTERVAL_S, + verbosity: int = VERBOSITY_NORMAL, + upload_url: str = None, + local_port: int = 8080, + ): + self._bec = bec_client + self._output_dir = Path(output_dir).expanduser().resolve() + self._cycle_interval = cycle_interval + self._verbosity = verbosity + self._uploader = HttpUploader(upload_url) if upload_url else None + self._local_port = local_port + self._local_server = None # created fresh each _launch() + + # Derive companion URLs from upload_url + if upload_url is not None: + self._session_query_url = upload_url.replace("upload.php", "session_query.php") + self._set_password_url = upload_url.replace("upload.php", "set_password.php") + else: + self._session_query_url = None + self._set_password_url = None + + self._thread = None + self._stop_event = threading.Event() + self._last_active_time = None # epoch of last tomo/queue activity + self._last_ptycho_scan = None # scan id of last copied ptycho images + self._had_activity = False # True once any activity has been observed + self._last_queue_id = None # tracks queue history changes between cycles + self._owner_id = f"{socket.gethostname()}:{os.getpid()}" + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def start(self) -> None: + """Start the generator. + + If this session already holds the thread, does nothing. + If another session holds a fresh lock, a background watcher thread + is launched that polls every 3 s and takes over as soon as the lock + goes stale — no blocking, no second call to start() needed. + Progress messages during the wait are printed at verbosity >= 2 only. + """ + if not _check_account_match(self._bec): + self._log(VERBOSITY_NORMAL, + "WebpageGenerator: BEC account does not match system user. " + "Not starting.", level="warning") + return + + if self._thread is not None and self._thread.is_alive(): + self._log(VERBOSITY_NORMAL, "WebpageGenerator already running in this session.") + return + + if not self._acquire_lock(): + # Lock is fresh — _acquire_lock() has spawned a background watcher + # that will call _launch() automatically once the lock expires. + return + + self._launch() + + def _launch(self) -> None: + """Actually start the worker thread and write static files. Called by + start() on the normal path, or by the lock-watcher thread after takeover.""" + self._output_dir.mkdir(parents=True, exist_ok=True) + + # Copy logo once at startup — HTML is also written once here, + # not every cycle, since it is a static shell that only loads status.json. + self._copy_logo() + (self._output_dir / "status.html").write_text( + _render_html(_PHONE_NUMBERS, self.HAS_TOMO_QUEUE, self.TOMO_TYPES) + ) + + # Check whether the active BEC account matches the session user on the + # remote server. If not, clear session.htpasswd so the old user loses + # web access immediately. The new user then calls set_web_password(). + self._check_session_user() + + # Start local HTTP server (always on; a fresh instance per _launch). + if self._local_server is not None and self._local_server.is_alive(): + self._local_server.stop() + self._local_server = LocalHttpServer(self._output_dir, self._local_port) + try: + self._local_server.start() + local_url_msg = f" local={self._local_server.url}" + except RuntimeError as exc: + local_url_msg = f" local=ERROR({exc})" + self._log(VERBOSITY_NORMAL, str(exc), level="warning") + + # Upload static files (html + logo) once at startup + if self._uploader is not None: + self._uploader.upload_dir_async(self._output_dir) + + self._stop_event.clear() + self._thread = threading.Thread( + target=self._run, name="WebpageGenerator", daemon=True + ) + self._thread.start() + self._log(VERBOSITY_NORMAL, + f"WebpageGenerator started owner={self._owner_id} " + f"output={self._output_dir} interval={self._cycle_interval}s" + + (f" upload={self._uploader._url}" if self._uploader else " upload=disabled") + + f"\n ➜ Status page:{local_url_msg}") + + def stop(self) -> None: + """Stop the generator thread, local HTTP server, and release the singleton lock.""" + self._stop_event.set() + if self._thread is not None: + self._thread.join(timeout=self._cycle_interval + 5) + # Always clear the reference so start() gets a clean slate, + # even if the thread did not exit within the join timeout. + self._thread = None + if self._local_server is not None: + self._local_server.stop() + self._local_server = None + self._release_lock() + self._log(VERBOSITY_NORMAL, "WebpageGenerator stopped.") + + @property + def verbosity(self) -> int: + return self._verbosity + + @verbosity.setter + def verbosity(self, val: int) -> None: + self._verbosity = val + self._log(VERBOSITY_NORMAL, f"WebpageGenerator verbosity -> {val}") + + def status(self) -> None: + """Print a human-readable status summary to the console.""" + lock = self._read_lock() + running = self._thread is not None and self._thread.is_alive() + local = self._local_server.url if (self._local_server and self._local_server.is_alive()) else "stopped" + remote_user = self._get_remote_session_user() if self._session_query_url else "n/a" + try: + active_account = self._bec.active_account + except Exception: + active_account = "unknown" + print( + f"WebpageGenerator\n" + f" This session running : {running}\n" + f" Lock owner : {lock.get('owner_id', 'none')}\n" + f" Lock heartbeat : {lock.get('heartbeat', 'never')}\n" + f" Output dir : {self._output_dir}\n" + f" Local URL : {local}\n" + f" Cycle interval : {self._cycle_interval}s\n" + f" Upload URL : {self._uploader._url if self._uploader else 'disabled'}\n" + f" Session query URL : {self._session_query_url or 'disabled'}\n" + f" BEC active account : {active_account}\n" + f" Server session user : {remote_user}\n" + f" Verbosity : {self._verbosity}\n" + ) + + # ------------------------------------------------------------------ + # Logo + # ------------------------------------------------------------------ + + def _logo_path(self): + """ + Return a Path to the logo PNG for this setup, or None for text fallback. + Override in subclasses. + """ + return None + + def _copy_logo(self) -> None: + """Copy the setup logo to output_dir/logo.png if available.""" + src = self._logo_path() + if src is None: + return + src = Path(src) + if not src.exists(): + self._log(VERBOSITY_VERBOSE, + f"Logo not found at {src}, using text fallback.", level="warning") + return + try: + shutil.copy2(src, self._output_dir / "logo.png") + self._log(VERBOSITY_VERBOSE, f"Logo copied from {src}") + except Exception as exc: + self._log(VERBOSITY_NORMAL, + f"Failed to copy logo: {exc}", level="warning") + + # ------------------------------------------------------------------ + # Session authentication + # ------------------------------------------------------------------ + + def _get_remote_session_user(self) -> "str | None": + """GET session_query.php and return the username in session.htpasswd, or None.""" + if self._session_query_url is None: + return None + try: + import requests as _requests + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + r = _requests.get(self._session_query_url, timeout=10, verify=False) + if r.status_code == 200: + return r.json().get("user") # str or None + self._log(VERBOSITY_VERBOSE, + f"session_query: HTTP {r.status_code}", level="warning") + except Exception as exc: + self._log(VERBOSITY_VERBOSE, + f"session_query failed: {exc}", level="warning") + return None + + def _check_session_user(self) -> None: + """Compare BEC active account with the server session user. + + - Match: silent, nothing to do. + - No remote user: current user has no web auth yet — print reminder. + - Mismatch: clear session.htpasswd immediately (old user loses access), + then print reminder that current user has no web auth yet. + """ + if self._session_query_url is None: + return # no remote server configured + + try: + current_account = self._bec.active_account # e.g. 'p23092' + except Exception as exc: + self._log(VERBOSITY_VERBOSE, + f"session check: cannot read active_account: {exc}", level="warning") + return + + remote_user = self._get_remote_session_user() + + if remote_user == current_account: + return # all good, stay silent + + if remote_user is not None: + # Different user still has web access — remove it immediately. + print(f"WebpageGenerator: account changed " + f"'{remote_user}' -> '{current_account}' " + f"— removing web access for '{remote_user}'") + self._clear_session_htpasswd() + + # Either no previous user or a different user — either way the + # current account has no web authentication yet. + _BOLD_RED = "\033[1;31m" + _RESET = "\033[0m" + print(f"{_BOLD_RED}***\nWebpageGenerator: '{current_account}' has no web authentication yet.{_RESET}") + print(f"{_BOLD_RED} To set a password run: flomni.set_web_password(\"your_password\")\n***{_RESET}") + + def _clear_session_htpasswd(self) -> None: + """Write an empty session.htpasswd locally and upload it.""" + session_path = self._output_dir / "session.htpasswd" + session_path.write_text("") + if self._uploader is not None: + self._uploader.upload_file_async(session_path) + + def set_web_password(self, password: str) -> None: + """Set the web password for the current BEC account. + + Sends the plaintext password to set_password.php on the server + (IP-restricted, HTTPS). PHP generates the bcrypt hash and writes + session.htpasswd directly — no Python bcrypt package needed. + + The username is taken from bec.active_account (e.g. 'p23092'). + + Example:: + flomni.webpage_gen.set_web_password("my_secret_password") + """ + if self._set_password_url is None: + print("set_web_password: no upload URL configured — cannot reach server") + return + + try: + account = self._bec.active_account + except Exception as exc: + print(f"set_web_password: cannot determine active account: {exc}") + return + + try: + import requests as _requests + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + r = _requests.post( + self._set_password_url, + data={"username": account, "password": password}, + timeout=15, + verify=False, + ) + if r.status_code == 200: + resp = r.json() + if resp.get("ok"): + print(f"set_web_password: password set for '{account}' on server") + else: + print(f"set_web_password: server error: {resp.get('error', '?')}") + else: + print(f"set_web_password: HTTP {r.status_code}: {r.text[:120]}") + except Exception as exc: + print(f"set_web_password: request failed: {exc}") + + # ------------------------------------------------------------------ + # Singleton lock + # ------------------------------------------------------------------ + + def _acquire_lock(self) -> bool: + """Try to acquire the singleton lock. + + Returns True immediately if the lock is free or stale. + Returns False and launches a background watcher thread if the lock is + held by another session — the watcher will call _launch() automatically + once the lock expires (no user action needed). + """ + _POLL = 3 # seconds between retries in the watcher thread + + lock = self._read_lock() + owner = lock.get("owner_id", "unknown") if lock else "none" + age = _heartbeat_age_s(lock.get("heartbeat")) if lock else float("inf") + + if age >= _LOCK_STALE_AFTER_S: + if lock: + self._log(VERBOSITY_NORMAL, + f"Stale lock (owner: '{owner}', {age:.0f}s ago). Taking over.") + self._write_lock() + return True + + # Lock is fresh — spin up a background watcher and return immediately. + self._log(VERBOSITY_NORMAL, + f"WebpageGenerator: lock held by '{owner}' ({age:.0f}s ago). " + f"Will take over automatically when it expires.") + + def _watcher(): + while True: + time.sleep(_POLL) + lk = self._read_lock() + age = _heartbeat_age_s(lk.get("heartbeat")) if lk else float("inf") + self._log(VERBOSITY_VERBOSE, + f" …waiting for lock, age {age:.0f}s / {_LOCK_STALE_AFTER_S}s") + if age >= _LOCK_STALE_AFTER_S: + break + owner2 = lk.get("owner_id", "unknown") if lk else "none" + self._log(VERBOSITY_VERBOSE, + f"Lock expired (was '{owner2}'). Taking over.") + self._write_lock() + self._launch() + + t = threading.Thread(target=_watcher, name="WebpageGeneratorWatcher", daemon=True) + t.start() + return False + + def _write_lock(self) -> None: + self._bec.set_global_var(_LOCK_VAR_KEY, { + "owner_id": self._owner_id, + "heartbeat": _now_iso(), + "pid": os.getpid(), + "hostname": socket.gethostname(), + }) + + def _read_lock(self) -> dict: + val = self._bec.get_global_var(_LOCK_VAR_KEY) + return val if isinstance(val, dict) else {} + + def _release_lock(self) -> None: + lock = self._read_lock() + if lock.get("owner_id") == self._owner_id: + self._bec.delete_global_var(_LOCK_VAR_KEY) + + # ------------------------------------------------------------------ + # Main loop + # ------------------------------------------------------------------ + + def _run(self) -> None: + while not self._stop_event.is_set(): + t0 = _epoch() + try: + self._cycle() + except Exception as exc: + self._log(VERBOSITY_NORMAL, + f"WebpageGenerator cycle error: {exc}", level="warning") + try: + self._write_lock() + except Exception: + pass + self._stop_event.wait(max(0.0, self._cycle_interval - (_epoch() - t0))) + + def _cycle(self) -> None: + """One generator cycle: read state -> derive status -> write files -> upload.""" + + # ── Progress ───────────────────────────────────────────────── + progress = self._bec.get_global_var("tomo_progress") or {} + + # ── Queue ──────────────────────────────────────────────────── + # queue_status reflects BEC's ScanQueueStatus: 'RUNNING', 'PAUSED', + # or 'LOCKED' (the latter set whenever any lock — e.g. from + # ScanInterlockActor — is applied to the queue; the prior status is + # restored once the lock is released). + # A scan is actually executing only when info is non-empty AND + # active_request_block is set on the first entry. + try: + qi = self._bec.queue.queue_storage.current_scan_queue + primary = qi.get("primary") + queue_status = primary.status if primary is not None else "unknown" + queue_has_active_scan = ( + primary is not None + and len(primary.info) > 0 + and primary.info[0].active_request_block is not None + ) + queue_locks = _read_queue_locks(primary) + except Exception: + queue_status = "unknown" + queue_has_active_scan = False + queue_locks = [] + + # ── Beamline states ────────────────────────────────────────── + # Diagnostic display AND (via beamline_blocking below) one of the two + # signals that drive the 'blocked' experiment_status; the other is + # queue_locks. A watched state that is out of its accepted range + # (mismatched) means the scan interlock is / would be holding the + # queue, even if the running scan has already been interrupted and + # active_request_block cleared. + try: + beamline_states_info = _read_beamline_states_info(self._bec) + except Exception as exc: + self._log(VERBOSITY_VERBOSE, + f"beamline_states read error: {exc}", level="warning") + beamline_states_info = {"enabled": None, "states": []} + + # True if the scan interlock is enabled AND at least one watched state + # is out of its accepted range. Gated on `enabled`: a mismatched state + # while the interlock is disabled does not actually block a scan, so it + # must not raise the 'blocked' status. Kept in lock-step with the + # beamline-states card summary (renderBeamlineStates), which likewise + # only calls mismatched states "blocking" when the interlock is enabled, + # so the top status pill and that card can never disagree. + beamline_blocking = bool( + beamline_states_info.get("enabled") is True + and any(s.get("mismatched") for s in beamline_states_info.get("states", [])) + ) + + # Current scan number (the BEC scan in progress right now, e.g. for + # comparison against ptycho reconstruction filenames like S06770). + # Only meaningful while a scan is actually active; None otherwise. + current_scan_number = None + if queue_has_active_scan: + try: + current_scan_number = primary.info[0].active_request_block.scan_number + except Exception: + current_scan_number = None + + # ── Idle tracking ──────────────────────────────────────────── + hb_age = _heartbeat_age_s(progress.get("heartbeat")) + tomo_active = hb_age < _TOMO_HEARTBEAT_STALE_S + + # Detect scans that start and finish entirely between two polls + # by watching for a new queue_id at the top of history. + try: + history = self._bec.queue.queue_storage.queue_history + latest_queue_id = history[0].info.queue_id if history else None + except Exception: + latest_queue_id = None + + history_changed = ( + latest_queue_id is not None + and latest_queue_id != self._last_queue_id + ) + self._last_queue_id = latest_queue_id + + if (tomo_active or queue_has_active_scan or history_changed + or beamline_blocking): + self._last_active_time = _epoch() + self._had_activity = True + + exp_status = _derive_status( + progress, queue_has_active_scan, + self._last_active_time, self._had_activity, + queue_locks, beamline_blocking, + ) + idle_for_s = ( + None if self._last_active_time is None + else max(0.0, _epoch() - self._last_active_time) + ) + + # ── Tomo queue (setup-specific; see HAS_TOMO_QUEUE) ───────────── + # Persisted list of parameter-snapshot jobs (global var "tomo_queue"). + # Each job: {"label": str, "params": {...}, + # "status": pending|running|incomplete|done, "added_at": ISO str} + # Instruments without this feature (e.g. LamNI) never touch the + # global var and always report an empty queue. + if self.HAS_TOMO_QUEUE: + tomo_queue = self._bec.get_global_var("tomo_queue") or [] + else: + tomo_queue = [] + + # ── Reconstruction queue ────────────────────────────────────── + recon = self._collect_recon_data() + + # ── Ptychography images ─────────────────────────────────────── + # Snapshot scan id BEFORE collecting so we can detect changes + prev_ptycho_scan = self._last_ptycho_scan + + ptycho = {} + try: + ptycho = self._collect_ptycho_images() + except Exception as exc: + self._log(VERBOSITY_VERBOSE, f"ptycho image error: {exc}", level="warning") + + # ── Setup-specific data (subclass hook) ─────────────────────── + setup = {} + try: + setup = self._collect_setup_data() + except Exception as exc: + self._log(VERBOSITY_VERBOSE, f"setup data error: {exc}", level="warning") + + # ── Payload ─────────────────────────────────────────────────── + payload = { + "generated_at": _now_iso(), + "generated_at_epoch": _epoch(), + "experiment_status": exp_status, + "queue_status": queue_status, + "queue_has_active_scan": queue_has_active_scan, + "idle_for_s": idle_for_s, + "idle_for_human": _format_duration(idle_for_s), + "queue_locks": queue_locks, + "beamline_states": beamline_states_info, + "tomo_queue": tomo_queue, + "progress": { + "tomo_type": progress.get("tomo_type", "N/A"), + "projection": progress.get("projection", 0), + "total_projections": progress.get("total_projections", 0), + "subtomo": progress.get("subtomo", 0), + "subtomo_projection": progress.get("subtomo_projection", 0), + "subtomo_total_projections": progress.get("subtomo_total_projections", 1), + "angle": progress.get("angle", 0), + "tomo_start_time": progress.get("tomo_start_time"), + "tomo_start_scan_number": progress.get("tomo_start_scan_number"), + "current_scan_number": current_scan_number, + "estimated_remaining_s": progress.get("estimated_remaining_time"), + "estimated_remaining_human": _format_duration( + progress.get("estimated_remaining_time") + ), + "estimated_finish_time": progress.get("estimated_finish_time"), + "tomo_heartbeat": progress.get("heartbeat"), + "tomo_heartbeat_age_s": round(hb_age, 1) if hb_age != float("inf") else None, + }, + "recon": recon, + "ptycho": ptycho, + "setup": setup, + "generator": { + "owner_id": self._owner_id, + "cycle_interval_s": self._cycle_interval, + "active_account": getattr(self._bec, "active_account", None) or "", + }, + } + + # ── Write status.json ───────────────────────────────────────── + # (HTML is static, written once at _launch()) + (self._output_dir / "status.json").write_text( + json.dumps(payload, indent=2, default=str) + ) + + # ── Upload ──────────────────────────────────────────────────── + # Uploads run in a background daemon thread — never blocks this cycle. + # If the server is unreachable the cycle continues normally; failures + # are logged as warnings. If an upload is still in progress from the + # previous cycle the new request is dropped (logged at DEBUG level). + if self._uploader is not None: + new_scan = ptycho.get("scan_id") + if new_scan and new_scan != prev_ptycho_scan: + # Scan changed: ask server to delete old S*_*.png/jpg first, + # then upload all current files (including new ptycho images). + self._log(VERBOSITY_VERBOSE, + f"Ptycho scan changed {prev_ptycho_scan} -> {new_scan}, " + f"triggering remote cleanup + full upload") + self._uploader.cleanup_ptycho_images_async() + # Small delay so cleanup runs before the file uploads start. + # Both are async, so we schedule the full upload on a short timer. + def _delayed_full_upload(): + time.sleep(2) + self._uploader.upload_dir_async(self._output_dir) + threading.Thread(target=_delayed_full_upload, + name="HttpUploaderDelayed", daemon=True).start() + else: + # Normal cycle: only upload files that have changed since last cycle. + self._uploader.upload_changed_async(self._output_dir) + + # ── Console feedback ────────────────────────────────────────── + blocked_summary = ( + f" locked_by={','.join(l['identifier'] for l in queue_locks)}" + if queue_locks else "" + ) + self._log(VERBOSITY_VERBOSE, + f"[{_now_iso()}] {exp_status:<12} active={queue_has_active_scan} " + f"proj={payload['progress']['projection']}/" + f"{payload['progress']['total_projections']} " + f"hb={payload['progress']['tomo_heartbeat_age_s']}s " + f"idle={_format_duration(idle_for_s)}" + blocked_summary) + self._log(VERBOSITY_DEBUG, + f" payload:\n{json.dumps(payload, indent=4, default=str)}") + + # ------------------------------------------------------------------ + # Reconstruction queue + # ------------------------------------------------------------------ + + def _collect_recon_data(self) -> dict: + foldername = ( + self._bec.get_global_var("ptycho_reconstruct_foldername") or "ptycho_reconstruct" + ) + base = Path("~/data/raw/logs/reconstruction_queue").expanduser() + queue_dir = base / foldername + failed_dir = queue_dir / "failed" + + waiting = failed = 0 + try: + if queue_dir.exists(): + waiting = len(list(queue_dir.glob("*.dat"))) + except Exception: + pass + try: + if failed_dir.exists(): + failed = len(list(failed_dir.glob("*.dat"))) + except Exception: + pass + + return { + "foldername": foldername, + "folder_path": str(queue_dir), + "waiting": waiting, + "failed": failed, + } + + # ------------------------------------------------------------------ + # Ptychography images + # ------------------------------------------------------------------ + + def _collect_ptycho_images(self) -> dict: + """Find the latest ptycho reconstruction, copy/thumbnail changed images. + + Scans _PTYCHO_ONLINE_DIR for the most-recently-modified subfolder whose + name contains an underscore (e.g. dset_00005). Within that folder looks + for PNG files matching S*_phase.png, S*_probe.png, S*_amplitude.png, + S*_err.png, S*_spectrum.png. + + When the scan changes (new S##### prefix detected): + - Thumbnails are generated with Pillow and saved to output_dir + - Full-size PNGs are also copied to output_dir (for click-to-full) + - Old ptycho files from previous scans are removed from output_dir + + Returns a dict consumed by the JS ptycho renderer: + scan_id str e.g. "S06666" + images list [{"role": "phase", "thumb": "..._thumb.jpg", + "full": "..._phase.png"}, ...] + folder str source folder path (for diagnostics) + """ + base = Path(_PTYCHO_ONLINE_DIR).expanduser() + if not base.exists(): + return {} + + # Find latest subfolder by modification time. + candidates = sorted( + [d for d in base.iterdir() if d.is_dir()], + key=lambda d: d.stat().st_mtime, + ) + if not candidates: + return {} + latest_dir = candidates[-1] + + # Roles we care about — phase and probe are primary (always shown), + # amplitude/err/spectrum are secondary (shown on expand) + _ROLES = [ + ("phase", "S*phase.png", True), + ("probe", "S*probe.png", True), + ("amplitude", "S*amplitude.png", False), + ("error", "S*err.png", False), + ("spectrum", "S*spectrum.png", False), + ] + + # Collect available source files + found = {} + scan_id = None + for role, pattern, _primary in _ROLES: + matches = sorted(latest_dir.glob(pattern)) + if matches: + src = matches[-1] + stem = src.stem # e.g. S06666_400x400_b0_run_1_phase + sid = stem.split("_")[0] + if scan_id is None: + scan_id = sid + found[role] = src + + if not scan_id: + return {} + + # If scan unchanged, thumbs exist, and source hasn't changed, skip all work + thumb_key = f"{scan_id}_phase_thumb.jpg" + thumb_path = self._output_dir / thumb_key + src_phase = found.get("phase") + thumb_fresh = ( + thumb_path.exists() + and src_phase is not None + and thumb_path.stat().st_mtime >= src_phase.stat().st_mtime + ) + if (scan_id == self._last_ptycho_scan and thumb_fresh): + # Rebuild result dict from existing files without doing I/O + images = [] + for role, _pat, primary in _ROLES: + thumb = self._output_dir / f"{scan_id}_{role}_thumb.jpg" + full = self._output_dir / f"{scan_id}_{role}.png" + if thumb.exists() and full.exists(): + images.append({ + "role": role, + "thumb": thumb.name, + "full": full.name, + "primary": primary, + }) + return {"scan_id": scan_id, "images": images, "folder": str(latest_dir)} + + # Scan changed (or first run) — clean up old ptycho files in output_dir + self._log(VERBOSITY_VERBOSE, + f"New ptycho scan detected: {scan_id} in {latest_dir.name}") + for old_file in self._output_dir.glob("S*_*_thumb.jpg"): + if not old_file.name.startswith(scan_id + "_"): + old_file.unlink(missing_ok=True) + for old_file in self._output_dir.glob("S*_*.png"): + if not old_file.name.startswith(scan_id + "_"): + for role, _, _ in _ROLES: + if f"_{role}." in old_file.name or f"_{role}_" in old_file.name: + old_file.unlink(missing_ok=True) + break + + # Generate thumbnails and copy full-size for each found role + images = [] + for role, _pat, primary in _ROLES: + src = found.get(role) + if src is None: + continue + thumb_name = f"{scan_id}_{role}_thumb.jpg" + full_name = f"{scan_id}_{role}.png" + thumb_path = self._output_dir / thumb_name + full_path = self._output_dir / full_name + try: + if _PIL_AVAILABLE: + img = _PILImage.open(src) + img.thumbnail((_PTYCHO_THUMB_PX, _PTYCHO_THUMB_PX)) + img.save(thumb_path, "JPEG", quality=85) + else: + shutil.copy2(src, thumb_path) + shutil.copy2(src, full_path) + images.append({ + "role": role, + "thumb": thumb_name, + "full": full_name, + "primary": primary, + }) + self._log(VERBOSITY_VERBOSE, f" ptycho {role}: thumb={thumb_name}") + except Exception as exc: + self._log(VERBOSITY_VERBOSE, + f" ptycho {role} failed: {exc}", level="warning") + + self._last_ptycho_scan = scan_id + return {"scan_id": scan_id, "images": images, "folder": str(latest_dir)} + + # ------------------------------------------------------------------ + # Subclass hooks + # ------------------------------------------------------------------ + + def _collect_setup_data(self) -> dict: + """ + Override in subclasses to return a dict of setup-specific data. + The dict is embedded in the JSON payload as 'setup' and rendered + by the HTML page as the 'Instrument details' section. + + Expected keys (all optional): + type (str) -- setup identifier, e.g. "flomni" + sample_name (str) -- current sample name + temperatures (dict) -- label -> float (degrees C) or None + current_params (dict) -- raw tomo parameters read live (keyed by + the same global-var names the tomo queue + stores in job.params, e.g. tomo_countingtime, + fovx, tomo_angle_stepsize, ...). Rendered by + the page through the same param-table path + as a queue job (buildParamRows), so the + 'Current measurement' section always matches + the queue's level of detail -- and shows the + details even for a scan started outside the + queue. Values are left raw; the page formats + them (fmtTqParam) and computes the projection + count (calcProjections). + """ + return {} + + # ------------------------------------------------------------------ + # Logging + # ------------------------------------------------------------------ + + def _log(self, min_verbosity: int, msg: str, level: str = "info") -> None: + if self._verbosity < min_verbosity: + return + if level == "warning": + logger.warning(msg) + elif level == "error": + logger.error(msg) + else: + print(msg) + + +# --------------------------------------------------------------------------- +# flOMNI subclass +# --------------------------------------------------------------------------- + +class FlomniWebpageGenerator(WebpageGeneratorBase): + """ + flOMNI-specific webpage generator. + Adds: temperatures, sample name, and live current-measurement parameters + from global vars. + Logo: flOMNI.png from the same directory as this file. + """ + + # Global-var names of the tomo parameters to read live for the + # 'Current measurement' section. Kept in the same order as, and matching, + # the tomo queue's TQ_PARAM_DISPLAY keys so the two render identically. + # (Any name that is not actually a global var is skipped silently and just + # does not appear as a row.) + _CURRENT_PARAM_KEYS = [ + "tomo_type", + "tomo_countingtime", + "fovx", + "fovy", + "tomo_angle_stepsize", + "tomo_angle_range", + "tomo_shellstep", + "stitch_x", + "stitch_y", + "frames_per_trigger", + "single_point_instead_of_fermat_scan", + "ptycho_reconstruct_foldername", + ] + # label -> dotpath under device_manager.devices _TEMP_MAP = { - # "Sample (cryo A)": "omny_temp.cryo_a", - # "Cryo head (B)": "omny_temp.cryo_b", + "Heater": "flomni_temphum.temperature_heater", + "Heater setpoint": "flomni_temphum.temperature_heaterset_rb", + "OSA": "flomni_temphum.temperature_osa", + "Mirror": "flomni_temphum.temperature_mirror", } def _logo_path(self): - return Path(__file__).parent / "OMNY.png" + return Path(__file__).parent / "flOMNI.png" def _collect_setup_data(self) -> dict: - # ── OMNY-specific data goes here ────────────────────────────── - # Uncomment and adapt when device names are known: - # - # dm = self._bec.device_manager - # sample_name = _safe_get(dm, "omny_samples.sample_names.sample0") or "N/A" - # temperatures = { - # label: _safe_float(_safe_get(dm, path)) - # for label, path in self._TEMP_MAP.items() - # } - # settings = { - # "Sample name": sample_name, - # "FOV x / y": ..., - # "Exposure time": _gvar(self._bec, "tomo_countingtime", ".3f", " s"), - # "Angle step": _gvar(self._bec, "tomo_angle_stepsize", ".2f", "\u00b0"), - # } - # return { - # "type": "omny", - # "sample_name": sample_name, - # "temperatures": temperatures, - # "settings": settings, - # } + dm = self._bec.device_manager - # Placeholder — returns minimal info until implemented - return { - "type": "omny", - # OMNY-specific data here + # ── Sample name ─────────────────────────────────────────────── + sample_name = _safe_get(dm, "flomni_samples.sample_names.sample0") or "N/A" + + # ── Temperatures ────────────────────────────────────────────── + temperatures = { + label: _safe_float(_safe_get(dm, path)) + for label, path in self._TEMP_MAP.items() } + + # ── Current measurement parameters (read live) ──────────────── + # Same keys the tomo queue stores in job.params, so the 'Current + # measurement' section on the page renders through the identical + # param-table path (buildParamRows -> fmtTqParam / calcProjections) + # and always matches the queue's level of detail. Reading these live + # means a tomo started OUTSIDE the queue still shows full parameters. + # Values are left raw; the page does the formatting. + g = self._bec + current_params = {} + for key in self._CURRENT_PARAM_KEYS: + try: + val = g.get_global_var(key) + except Exception: + val = None + if val is not None: + current_params[key] = val + + return { + "type": "flomni", + "sample_name": sample_name, + "temperatures": temperatures, + "current_params": current_params, + } + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + +def make_webpage_generator(bec_client, **kwargs): + """ + Select the appropriate WebpageGenerator subclass based on the BEC session + name (bec._ip.prompts.session_name) and return a ready-to-start instance. + + Usage (generic startup script): + from csaxs_bec.bec_ipython_client.plugins.flomni.webpage_generator import ( + make_webpage_generator, + ) + gen = make_webpage_generator(bec, output_dir="~/data/raw/webpage/", + upload_url="http://omny.online/upload.php") + gen.start() + """ + try: + session = bec_client._ip.prompts.session_name + except Exception: + session = "unknown" + + if session == "flomni": + return FlomniWebpageGenerator(bec_client, **kwargs) + + elif session == "lamni": + from csaxs_bec.bec_ipython_client.plugins.LamNI.webpage_generator import ( + LamniWebpageGenerator, + ) + return LamniWebpageGenerator(bec_client, **kwargs) + + elif session == "omny": + from csaxs_bec.bec_ipython_client.plugins.omny.webpage_generator import ( + OmnyWebpageGenerator, + ) + return OmnyWebpageGenerator(bec_client, **kwargs) + + else: + return WebpageGeneratorBase(bec_client, **kwargs) + + +# --------------------------------------------------------------------------- +# HTML template (written once at start(), not every cycle) +# Three themes: dark (default), light, auto (follows OS preference). +# Theme is stored in localStorage and applied via data-theme on . +# --------------------------------------------------------------------------- + +def _render_html(phone_numbers: list, has_tomo_queue: bool = True, tomo_types: dict = None) -> str: + phones_html = "\n".join( + f'
' + f'{label}' + f'{num}
' + for label, num in phone_numbers + ) + + # Tomo queue card + its slot in the default card order are only emitted + # for setups that actually have the feature (see HAS_TOMO_QUEUE). + if has_tomo_queue: + tomo_queue_card_html = """ + +
+
⋮⋮
+
Tomo queue
+
+
+""" + else: + tomo_queue_card_html = "" + + default_order = ['audio', 'recon-queue', 'ptycho', 'instrument', 'blstates', 'contacts'] + if has_tomo_queue: + default_order.insert(default_order.index('contacts'), 'tomo-queue') + default_order_json = json.dumps(default_order) + + # Declarative tomo-type -> projection-count formula, consumed by the + # generic calcProjections() in JS instead of a hardcoded per-instrument + # branch. See WebpageGeneratorBase.TOMO_TYPES for the schema. + tomo_types_json = json.dumps(tomo_types or {}) + + return f""" + + + + + Experiment Status + + + +
+ +
+ ⚠ Status page data is outdated — generator may have stopped +
+ +
+
+ + + · STATUS + +
+
+ +
+ Theme + + + +
+
updating…
+
+
+ + +
+
-
+
Loading…
+
+ +
+
Tomography progress
+
+ + + + + + +
+ 0% + OVERALL +
+
+
+
Projection-
+
Sub-tomo-
+
Angle-
+
Tomo type-
+
Remaining-
+
Finish time-
+
Started-
+ +
+ +
+ + + +
+ +
+ + + +
+ About this page + +
+
+

Measurement status

+

The coloured pill at the top reflects what the instrument is currently doing:

+
    +
  • Scanning — a tomography scan is actively running (a heartbeat from the scan loop has been seen recently).
  • +
  • Running — the scan queue has an active item, but it is not a tomo scan with a heartbeat (e.g. an alignment scan).
  • +
  • Blocked — the experiment is being held up by a watched beamline condition that is out of spec (for example the shutter or ring current, see the Beamline states card), or by an explicit lock on the scan queue. A running scan is interrupted when this happens and its repeat waits for the condition to clear, so this shows even when no scan is momentarily executing. Usually external and self-resolves once the condition clears.
  • +
  • Idle — nothing is running or queued right now.
  • +
  • Unknown — shown only before any activity has been observed since the generator started.
  • +
+

Tomography progress

+

The rings show overall progress (outer) and progress within the current sub-tomogram (inner). Remaining is the estimated duration left; Finish time is the estimated wall-clock time the measurement will complete. The projection count is followed by the BEC scan number(s) in parentheses, e.g. (S06650→S06770), for direct comparison with reconstruction filenames.

+

Audio warnings

+
    +
  • System LED — on when audio is enabled on this device.
  • +
  • Watch LED — armed (green) while a scan is running; pulses orange when a scan stops, with a chime every 30s until confirmed. A normal finish (scan → idle) plays a rising success tone; other stops play the falling warning tone. A transition into blocked stays silent (see Blocked LED) since it is external and self-resolving.
  • +
  • Blocked LED — pulses orange while the queue is locked. A chime fires automatically after 60 continuous minutes blocked, then repeats hourly until cleared. No confirmation needed — it clears itself once the block resolves.
  • +
  • Live LED — green while this page's data feed is fresh; pulses orange if the feed goes stale or a fetch fails.
  • +
+

On iPhone/iPad, tap Enable once to unlock audio — this is a browser requirement and only needs to be done once per visit.

+

Beamline states

+

Lists every beamline condition currently registered with BEC, its live status (valid/invalid/warning/unknown), and whether the scan interlock is watching it. A watched state that is not in its accepted status is what actually causes the Blocked status above — states that are not watched (like an unrelated simulated shutter) can be invalid without blocking anything.

+

Other features

+

Cards below the fixed status/progress section can be dragged by their ⋮⋮ handle to reorder; the order is remembered on this device. The theme switcher (top right) follows your OS by default, or can be forced to light/dark.

+
+
+ + +
+ +
+
⋮⋮
+
+
+
+
+ System +
+
+
+ Watch +
+
+
+ Blocked +
+
+
+ Live +
+
+ Audio disabled +
+
+ + + + +
+
+ + +
+
⋮⋮
+
Reconstruction queue
+
+
Waiting-
+
Failed-
+
Queue name-
+
+
+
+ +
+
⋮⋮
+
Ptychography reconstructions
+
+
No reconstruction found
+
+
+ + + + + +
+
⋮⋮
+
Beamline states
+
+ + + + + +
StateStatusWatched
+
+ +{tomo_queue_card_html} + +
+
⋮⋮
+
Contacts
+
+{phones_html} +
+
+ +
+ +
+ generator: - + tomo_heartbeat: - +
+ +
+ + + +""" \ No newline at end of file -- 2.54.0 From 0b019b0490ebac3b9e667f0006c4aab979fa0a8d Mon Sep 17 00:00:00 2001 From: x12sa Date: Mon, 6 Jul 2026 11:20:30 +0200 Subject: [PATCH 31/32] flomni: add laser_parameters CLI and laser_tweak tool Expose the ConstEmitter runtime constants through the high-level flomni interface, alongside the existing laser_tracker_* methods, and add an interactive tweak tool for the two tracking target positions. New methods on FlomniSampleTransferMixin (thin wrappers delegating to dev.rtx.controller, same pattern as laser_tracker_show_all): laser_parameters_show_all() laser_parameters_set_targety(val) laser_parameters_set_targetz(val) laser_parameters_set_intensity_threshold_laser(val) laser_parameters_set_psd_intensity_threshold_tracking_low(val) laser_tweak(): interactive 2D raw-terminal loop for the two tracking targets, modeled on OMNYTools.tweak_cursor. left/right : target y -/+ (fixed 0.01 step, absolute set of current +/- step) up/down : target z +/- s : return both targets to their launch positions q : quit (terminal state restored in finally) On launch and after every change it prints the current y/z targets and the fzp x interferometer signal (read_ssi_interferometer channel 1), so the signal response can be watched while tweaking. termios/tty/fcntl/sys are imported locally in laser_tweak to avoid touching module-level imports. Additive change; no existing behaviour modified. Requires the ConstEmitter emitter_* methods on RtFlomniController. --- .../plugins/flomni/flomni.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index c44ec45..44401cc 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -465,6 +465,116 @@ class FlomniSampleTransferMixin: def laser_tracker_off(self): dev.rtx.controller.laser_tracker_off() + def laser_parameters_show_all(self): + dev.rtx.controller.emitter_show_all() + + def laser_parameters_set_targety(self, val: float): + dev.rtx.controller.emitter_set_tracking_target_y(val) + + def laser_parameters_set_targetz(self, val: float): + dev.rtx.controller.emitter_set_tracking_target_z(val) + + def laser_parameters_set_intensity_threshold_laser(self, val: float): + dev.rtx.controller.emitter_set_intensity_threshold_laser(val) + + def laser_parameters_set_psd_intensity_threshold_tracking_low(self, val: float): + dev.rtx.controller.emitter_set_psd_intensity_threshold_tracking_low(val) + + def laser_tweak(self): + """Interactive tweak of the two laser tracking target positions. + + Arrow keys nudge the ConstEmitter tracking targets by a fixed step of + 0.01 (absolute set of current +/- step): + left / right : target y -/+ + up / down : target z +/- + Keys: + s : return both targets to the positions they had at launch + q : quit + + After every change the current y/z targets and the fzp x interferometer + signal strength (channel 1) are printed, so one can watch the signal + respond while tweaking. + """ + import fcntl + import sys + import termios + import tty + + step = 0.01 + controller = dev.rtx.controller + + def read_targets(): + values = controller.emitter_get() + return ( + values["targetpos_tracking_y"], + values["targetpos_tracking_z"], + ) + + def signal_fzpx(): + # second channel (index 1) of show_signal_strength_interferometer, + # i.e. the fzp x channel / tracker signal + return controller.read_ssi_interferometer(1) + + def print_status(): + target_y, target_z = read_targets() + print( + f"\rtarget y: {target_y:.4f} target z: {target_z:.4f} " + f"fzp x signal: {signal_fzpx():.1f}\r" + ) + + # capture launch positions for the 's' (return to start) command + start_y, start_z = read_targets() + + print( + "Laser tweak. " + + self.OMNYTools.BOLD + + self.OMNYTools.OKBLUE + + "left/right: target y, up/down: target z, s: start pos, q: quit\r" + + self.OMNYTools.ENDC + ) + print_status() + + fd = sys.stdin.fileno() + old_term = termios.tcgetattr(fd) + try: + tty.setraw(fd) + old_flags = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, old_flags | os.O_NONBLOCK) + while True: + try: + key = sys.stdin.read(1) + if key == "q": + print("\n\rExiting tweak mode\r") + break + elif key == "s": + controller.emitter_set_tracking_target_y(start_y) + controller.emitter_set_tracking_target_z(start_z) + print("\rReturned to start positions\r") + print_status() + elif key == "\x1b": # escape sequences for arrow keys + next1, next2 = sys.stdin.read(2) + if next1 == "[": + target_y, target_z = read_targets() + if next2 == "A": # up: target z + + controller.emitter_set_tracking_target_z(target_z + step) + print_status() + elif next2 == "B": # down: target z - + controller.emitter_set_tracking_target_z(target_z - step) + print_status() + elif next2 == "C": # right: target y + + controller.emitter_set_tracking_target_y(target_y + step) + print_status() + elif next2 == "D": # left: target y - + controller.emitter_set_tracking_target_y(target_y - step) + print_status() + except IOError: + pass + + time.sleep(0.02) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_term) + fcntl.fcntl(fd, fcntl.F_SETFL, old_flags) + def umvr_fsamy_tracked(self, shift: float, step: float = 0.01, _internal: bool = False): """ Relative move of fsamy by `shift` mm, broken into steps of at most -- 2.54.0 From 608708448f5664525a3b6097f2c0a37ad5df6a5d Mon Sep 17 00:00:00 2001 From: x12sa Date: Mon, 6 Jul 2026 11:23:30 +0200 Subject: [PATCH 32/32] rt_flomni: add ConstEmitter client access to RtFlomniController Expose the flOMNI ConstEmitter runtime constants (served over the control-loop TCP server, command letter 'e') to BEC users. These were previously baked into Orchestra module parameter files; they can now be read and set live. New RtFlomniController methods (all added to USER_ACCESS): emitter_get() -> dict of the four values emitter_show_all() -> PrettyTable printout emitter_set_tracking_target_y(val) -> "ey" emitter_set_tracking_target_z(val) -> "ez" emitter_set_intensity_threshold_laser(val) -> "et" emitter_set_psd_intensity_threshold_tracking_low(val) -> "el" emitter_get issues "es" and parses the CSV reply (targetpos_tracking_y, targetpos_tracking_z, intensity_threshold_laser, PSD_intensity_threshold_tracking_low), same pattern as the Ts/sr readouts. Setters use socket_put with a fixed %.4f value, same pattern as the pa setpoint command; they are fire-and-forget (the server does not ack sets). The rotation-loop laser threshold (intensity_threshold_rot_laser) is a startup constant on the server side and is intentionally neither read back nor settable here. Named methods were chosen over a single opaque command to match the existing laser_tracker_* / feedback_* convention and to self-document each value. Requires the corresponding flOMNI control-loop change (ConstEmitter module + 'e' command in CommunicationServer). Additive change; no existing behaviour modified. --- csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py | 65 +++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py index 4f8f183..c482ceb 100644 --- a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py +++ b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py @@ -45,6 +45,12 @@ class RtFlomniController(Controller): "laser_tracker_check_signalstrength", "laser_tracker_check_enabled", "is_axis_moving", + "emitter_show_all", + "emitter_get", + "emitter_set_tracking_target_y", + "emitter_set_tracking_target_z", + "emitter_set_intensity_threshold_laser", + "emitter_set_psd_intensity_threshold_tracking_low", ] def __init__( @@ -391,6 +397,63 @@ class RtFlomniController(Controller): return True return False + def emitter_get(self) -> dict: + """Read the runtime constants served by the ConstEmitter Orchestra module. + + These are values that used to live in individual module parameter files + (tracking targets and intensity thresholds) and are now emitted centrally + so they can be tweaked live. The rotation-loop laser threshold + (intensity_threshold_rot_laser) is a pure startup constant and is + intentionally not reported here. + + Returns: + dict: current emitter values. + """ + ret = self.socket_put_and_receive("es") + # remove trailing \n + ret = ret.split("\n")[0] + values = [float(val) for val in ret.split(",")] + if len(values) != 4: + raise RtCommunicationError( + f"Expected to receive 4 emitter values. Instead received {values}" + ) + return { + "targetpos_tracking_y": values[0], + "targetpos_tracking_z": values[1], + "intensity_threshold_laser": values[2], + "PSD_intensity_threshold_tracking_low": values[3], + } + + def emitter_show_all(self): + """Print all ConstEmitter runtime constants as a table.""" + t = PrettyTable() + t.title = "ConstEmitter Runtime Constants" + t.field_names = ["Name", "Value"] + for key, val in self.emitter_get().items(): + t.add_row([key, val]) + print(t) + + def emitter_set_tracking_target_y(self, val: float) -> None: + """Set the y tracking target position (PID_tracking_y).""" + self.socket_put(f"ey{val:.4f}") + + def emitter_set_tracking_target_z(self, val: float) -> None: + """Set the z tracking target position (PID_tracking_z).""" + self.socket_put(f"ez{val:.4f}") + + def emitter_set_intensity_threshold_laser(self, val: float) -> None: + """Set the shared laser intensity threshold. + + Wired into the slew-rate limiters (x/y/z) and the PID loops + (x/y/z and the fast FZP x/y loops). Does not affect the + rotation loops, which use a separate fixed threshold. + """ + self.socket_put(f"et{val:.4f}") + + def emitter_set_psd_intensity_threshold_tracking_low(self, val: float) -> None: + """Set the low PSD intensity threshold used by the tracking PIDs (y and z).""" + self.socket_put(f"el{val:.4f}") + def pid_y(self) -> float: ret = float(self.socket_put_and_receive("G").strip()) return ret @@ -929,4 +992,4 @@ if __name__ == "__main__": socket_cls=SocketIO, socket_host="mpc2844.psi.ch", socket_port=2222, device_manager=None ) rtcontroller.on() - rtcontroller.laser_tracker_on() + rtcontroller.laser_tracker_on() \ No newline at end of file -- 2.54.0