From 2e05d1e4566823aa9ccd9ce29effd63a62882ded Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 29 Sep 2026 13:49:17 +0200 Subject: [PATCH 1/6] feat(webcam): add exposure, auto-exposure, gain and brightness control to WebcamViewer Settings are posted to the camera server's /camera/settings endpoint and read back on startup in a background thread, so an unreachable server cannot block device loading. set_exposure_time() disables auto exposure first. Co-Authored-By: Claude Sonnet 5.5 --- csaxs_bec/devices/omny/webcam_viewer.py | 136 +++++++++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/csaxs_bec/devices/omny/webcam_viewer.py b/csaxs_bec/devices/omny/webcam_viewer.py index a150b27c..d4b3e1d5 100644 --- a/csaxs_bec/devices/omny/webcam_viewer.py +++ b/csaxs_bec/devices/omny/webcam_viewer.py @@ -5,13 +5,32 @@ import numpy as np from ophyd import Device, Component as Cpt, Kind, Signal from ophyd_devices import PreviewSignal import traceback +from urllib.parse import urlsplit from bec_lib.logger import bec_logger logger = bec_logger.logger +# The camera server passes "exposure" straight to V4L2 exposure_time_absolute, +# which is in units of 100 us. +EXPOSURE_UNIT_MS = 0.1 +# V4L2 auto_exposure menu values used by the camera server. +AUTO_EXPOSURE_ON = 3 # aperture priority +AUTO_EXPOSURE_OFF = 1 # manual +SETTINGS_TIMEOUT_S = 3 + + class WebcamViewer(Device): - USER_ACCESS = ["start_live_mode", "stop_live_mode"] + USER_ACCESS = [ + "start_live_mode", + "stop_live_mode", + "get_exposure_time", + "set_exposure_time", + "set_auto_exposure_enabled", + "set_gain", + "set_brightness", + "refresh_settings", + ] preview = Cpt(PreviewSignal, ndim=2, num_rotation_90=0, transpose=False) live_mode_enabled = Cpt( Signal, @@ -20,6 +39,24 @@ class WebcamViewer(Device): doc="Enable or disable live mode.", kind=Kind.config, ) + exposure_time = Cpt( + Signal, + name="exposure_time", + value=0.0, + doc="Camera exposure time (ms). Only effective with auto exposure disabled.", + kind=Kind.config, + ) + auto_exposure_enabled = Cpt( + Signal, + name="auto_exposure_enabled", + value=True, + doc="Enable/disable camera auto exposure.", + kind=Kind.config, + ) + gain = Cpt(Signal, name="gain", value=0.0, doc="Camera gain.", kind=Kind.config) + brightness = Cpt( + Signal, name="brightness", value=0.0, doc="Camera brightness.", kind=Kind.config + ) def __init__(self, url:str, name:str, num_rotation_90=0, transpose=False, **kwargs) -> None: super().__init__(name=name, **kwargs) @@ -31,7 +68,17 @@ class WebcamViewer(Device): self._live_mode_lock = threading.RLock() self.preview.num_rotation_90 = num_rotation_90 self.preview.transpose = transpose + parts = urlsplit(url) + self._settings_url = f"{parts.scheme}://{parts.netloc}/camera/settings" + self._refreshing = False self.live_mode_enabled.subscribe(self._on_live_mode_enabled_changed, run=False) + self.exposure_time.subscribe(self._on_exposure_time_changed, run=False) + self.auto_exposure_enabled.subscribe(self._on_auto_exposure_enabled_changed, run=False) + self.gain.subscribe(self._on_gain_changed, run=False) + self.brightness.subscribe(self._on_brightness_changed, run=False) + # Load the camera's current settings in the background, so an unreachable + # camera server cannot block the device from loading. + threading.Thread(target=self._initial_refresh, daemon=True).start() def start_live_mode(self) -> None: self.live_mode_enabled.put(True) @@ -39,6 +86,93 @@ class WebcamViewer(Device): def stop_live_mode(self) -> None: self.live_mode_enabled.put(False) + # ---- camera settings (HTTP /camera/settings on the camera server) ---- + + def _post_settings(self, payload: dict) -> None: + """POST settings to the camera server; log instead of raising on failure.""" + try: + response = requests.post( + self._settings_url, json=payload, timeout=SETTINGS_TIMEOUT_S + ) + response.raise_for_status() + applied = response.json().get("applied", {}) + bad = {k: v for k, v in applied.items() if isinstance(v, str)} + if bad: + logger.error(f"{self.name}: camera server rejected settings {bad}") + except Exception: # pylint: disable=broad-except + logger.error( + f"{self.name}: failed to apply {payload} at {self._settings_url}: " + f"{traceback.format_exc()}" + ) + + def _on_exposure_time_changed(self, *args, value, **kwargs) -> None: + if self._refreshing: + return + payload = {"exposure": float(value) / EXPOSURE_UNIT_MS} + if self.auto_exposure_enabled.get(): + logger.warning( + f"{self.name}: exposure_time set while auto exposure is enabled; " + "it has no effect until set_auto_exposure_enabled(False)." + ) + self._post_settings(payload) + + def _on_auto_exposure_enabled_changed(self, *args, value, **kwargs) -> None: + if self._refreshing: + return + self._post_settings({"auto_exposure": AUTO_EXPOSURE_ON if value else AUTO_EXPOSURE_OFF}) + + def _on_gain_changed(self, *args, value, **kwargs) -> None: + if not self._refreshing: + self._post_settings({"gain": float(value)}) + + def _on_brightness_changed(self, *args, value, **kwargs) -> None: + if not self._refreshing: + self._post_settings({"brightness": float(value)}) + + def get_exposure_time(self) -> float: + """Get the cached exposure time (ms). Use refresh_settings() to re-read the camera.""" + return float(self.exposure_time.get()) + + def set_exposure_time(self, value: float) -> None: + """Set the exposure time (ms). Disables auto exposure first, otherwise the camera ignores it.""" + if self.auto_exposure_enabled.get(): + self.auto_exposure_enabled.put(False) + self.exposure_time.put(value) + + def set_auto_exposure_enabled(self, enable: bool) -> None: + self.auto_exposure_enabled.put(bool(enable)) + + def set_gain(self, value: float) -> None: + self.gain.put(value) + + def set_brightness(self, value: float) -> None: + self.brightness.put(value) + + def _initial_refresh(self) -> None: + try: + self.refresh_settings() + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"{self.name}: could not read camera settings from {self._settings_url}: {exc}") + + def refresh_settings(self) -> dict: + """Read the current settings from the camera server into the Signals.""" + response = requests.get(self._settings_url, timeout=SETTINGS_TIMEOUT_S) + response.raise_for_status() + data = response.json() + self._refreshing = True + try: + if "exposure" in data: + self.exposure_time.put(float(data["exposure"]) * EXPOSURE_UNIT_MS) + if "auto_exposure" in data: + self.auto_exposure_enabled.put(float(data["auto_exposure"]) >= 2.0) + if "gain" in data: + self.gain.put(float(data["gain"])) + if "brightness" in data: + self.brightness.put(float(data["brightness"])) + finally: + self._refreshing = False + return data + def _on_live_mode_enabled_changed(self, *args, value, **kwargs) -> None: self._apply_live_mode(bool(value)) From c8f96d59425628c339aae3e482e8d754d0f131af Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 29 Sep 2026 14:00:56 +0200 Subject: [PATCH 2/6] feat(config): add EH1 endstation shutter A open status (shutter_eh1) Co-Authored-By: Claude Sonnet 5.5 --- csaxs_bec/device_configs/bl_endstation.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/csaxs_bec/device_configs/bl_endstation.yaml b/csaxs_bec/device_configs/bl_endstation.yaml index 51e78567..572d9fc5 100644 --- a/csaxs_bec/device_configs/bl_endstation.yaml +++ b/csaxs_bec/device_configs/bl_endstation.yaml @@ -78,6 +78,20 @@ shutter_es: deviceTags: - bl_endstation +shutter_eh1: + description: X12SA EH1 endstation shutter A open status + deviceClass: ophyd_devices.EpicsSignalRO + deviceConfig: + read_pv: X12SA-EH1-PSYS:SH-A-OPEN + auto_monitor: true + onFailure: buffer + enabled: true + readoutPriority: baseline + readOnly: true + softwareTrigger: false + deviceTags: + - bl_endstation + ########################################################################## ######################## SMARACT STAGES ################################## ########################################################################## From 7ebc33c3e1b51a87a7dcab61e150f958d624ae96 Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 29 Sep 2026 14:04:34 +0200 Subject: [PATCH 3/6] refactor(config): rename shutter_es to shutter_oh (optics hutch shutter) Co-Authored-By: Claude Sonnet 5.5 --- csaxs_bec/device_configs/bl_endstation.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/csaxs_bec/device_configs/bl_endstation.yaml b/csaxs_bec/device_configs/bl_endstation.yaml index 572d9fc5..e63f8d28 100644 --- a/csaxs_bec/device_configs/bl_endstation.yaml +++ b/csaxs_bec/device_configs/bl_endstation.yaml @@ -64,8 +64,8 @@ fsh: ########################### FAST SHUTTER ################################# ########################################################################## -shutter_es: - description: X12SA ES shutter status +shutter_oh: + description: X12SA OH (optics hutch) shutter status deviceClass: ophyd_devices.EpicsSignalRO deviceConfig: read_pv: X12SA-OP-PSH1-EMLS-7010:OPEN From b15b236673934f639b5f7ab4f77a33dd1748699c Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 29 Sep 2026 14:06:36 +0200 Subject: [PATCH 4/6] revert(config): drop shutter_eh1 and restore shutter_es name Reverts 7ebc33c and c8f96d5. Co-Authored-By: Claude Sonnet 5.5 --- csaxs_bec/device_configs/bl_endstation.yaml | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/csaxs_bec/device_configs/bl_endstation.yaml b/csaxs_bec/device_configs/bl_endstation.yaml index e63f8d28..51e78567 100644 --- a/csaxs_bec/device_configs/bl_endstation.yaml +++ b/csaxs_bec/device_configs/bl_endstation.yaml @@ -64,8 +64,8 @@ fsh: ########################### FAST SHUTTER ################################# ########################################################################## -shutter_oh: - description: X12SA OH (optics hutch) shutter status +shutter_es: + description: X12SA ES shutter status deviceClass: ophyd_devices.EpicsSignalRO deviceConfig: read_pv: X12SA-OP-PSH1-EMLS-7010:OPEN @@ -78,20 +78,6 @@ shutter_oh: deviceTags: - bl_endstation -shutter_eh1: - description: X12SA EH1 endstation shutter A open status - deviceClass: ophyd_devices.EpicsSignalRO - deviceConfig: - read_pv: X12SA-EH1-PSYS:SH-A-OPEN - auto_monitor: true - onFailure: buffer - enabled: true - readoutPriority: baseline - readOnly: true - softwareTrigger: false - deviceTags: - - bl_endstation - ########################################################################## ######################## SMARACT STAGES ################################## ########################################################################## From b3830dc3e576e9f821f73f50603e7682151dff28 Mon Sep 17 00:00:00 2001 From: x12sa Date: Thu, 1 Oct 2026 12:33:49 +0200 Subject: [PATCH 5/6] feat(flomni): option to run all type-1 sub-tomos forward (0->180) Add tomo_type1_always_forward (180 deg range only, default off). Every sub-tomo sweeps 0->180 with an unchanged angle set, so the 0 deg reference (zero_deg_reference_at_each_subtomo) is taken before all 8 sub-tomos instead of every second one. Exposed in tomo_parameters(), queue params and the tomo params widget; resume/reacquire use the same plan. Tests and docs updated. Co-Authored-By: Claude Sonnet 5.5 --- .../plugins/flomni/flomni.py | 65 ++++++++++++++++--- .../widgets/tomo_params/tomo_params.py | 24 ++++++- docs/user/ptychography/flomni.md | 2 + .../test_flomni_tomo_angles.py | 31 +++++++++ 4 files changed, 113 insertions(+), 9 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 5d83d2e7..aa216a19 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -1911,6 +1911,7 @@ class Flomni( self.tomo_angle_range = 180 self.golden_projections_at_0_deg_for_damage_estimation = 0 self.zero_deg_reference_at_each_subtomo = False + self.tomo_type1_always_forward = False self.golden_ratio_bunch_size = 20 self.frames_per_trigger = 1 self.single_point_instead_of_fermat_scan = False @@ -2170,7 +2171,7 @@ class Flomni( def zero_deg_reference_at_each_subtomo(self): """If True (tomo_type == 1 only), an additional projection at exactly 0 degrees is acquired at the start of every odd (forward) - sub-tomogram - i.e. every time the rotation passes back through + sub-tomogram (every sub-tomogram if tomo_type1_always_forward) - i.e. every time the rotation passes back through 0 degrees - and once more after the final (8th) sub-tomogram completes. Together with sub-tomogram 1's own first projection (which already lands exactly on 0 degrees), this gives a 0-degree @@ -2187,6 +2188,24 @@ class Flomni( def zero_deg_reference_at_each_subtomo(self, val: bool): self.client.set_global_var("zero_deg_reference_at_each_subtomo", val) + @property + def tomo_type1_always_forward(self): + """If True (tomo_type == 1, tomo_angle_range == 180 only), every + sub-tomogram sweeps forward (0 -> 180) instead of alternating + forward/reverse. Each sub-tomogram covers the same angle set either + way, so the combined grid is unchanged; the rotation just returns to + ~0 between sub-tomograms. Because every sub-tomogram then starts at + 0 degrees, zero_deg_reference_at_each_subtomo takes its reference + shot before every sub-tomogram instead of every second one.""" + val = self.client.get_global_var("tomo_type1_always_forward") + if val is None: + return False + return val + + @tomo_type1_always_forward.setter + def tomo_type1_always_forward(self, val: bool): + self.client.set_global_var("tomo_type1_always_forward", bool(val)) + golden_ratio_bunch_size = _GlobalVarParam(20) @property @@ -2348,7 +2367,9 @@ class Flomni( f.write(" ".join(map(str, zeros)) + "\n") @staticmethod - def _subtomo_angle_plan(subtomo_number, tomo_angle_range, tomo_angle_stepsize, start_angle=None): + def _subtomo_angle_plan( + subtomo_number, tomo_angle_range, tomo_angle_stepsize, start_angle=None, always_forward=False + ): """ Pure angle-generation logic for one sub-tomogram of an equally spaced 8-sub-tomogram tomography scan (tomo_type == 1). No device @@ -2393,6 +2414,10 @@ class Flomni( scan would produce, at the same total projection count, with zero redundant measurements anywhere. + always_forward (bool): 180 mode only - sweep every sub-tomogram + forward instead of alternating direction (same angle set per + sub-tomogram, only the order changes). Ignored in 360 mode. + Returns: angles (np.ndarray): the N angles (degrees) for this sub-tomogram. subtomo_offset (int): index of this sub-tomogram's first angle @@ -2422,7 +2447,7 @@ class Flomni( if tomo_angle_range == 180: base = 0.0 - forward = bool(subtomo_number % 2) + forward = True if always_forward else bool(subtomo_number % 2) else: mod4 = subtomo_number % 4 base = 0.0 if mod4 in (1, 0) else 180.0 @@ -2503,7 +2528,10 @@ class Flomni( ) subtomo_number = projection_number // N + 1 angles, _, _, _ = self._subtomo_angle_plan( - subtomo_number, self.tomo_angle_range, self.tomo_angle_stepsize + subtomo_number, + self.tomo_angle_range, + self.tomo_angle_stepsize, + always_forward=self.tomo_type1_always_forward, ) return subtomo_number, float(angles[projection_number % N]) @@ -2519,7 +2547,11 @@ class Flomni( print(f"Sub tomo scan with start angle {start_angle} requested.") angles, subtomo_offset, N, step = self._subtomo_angle_plan( - subtomo_number, self.tomo_angle_range, self.tomo_angle_stepsize, start_angle=start_angle + subtomo_number, + self.tomo_angle_range, + self.tomo_angle_stepsize, + start_angle=start_angle, + always_forward=self.tomo_type1_always_forward, ) for i, angle in enumerate(angles): @@ -2552,7 +2584,7 @@ class Flomni( shot: forcing that shot before a sub-tomogram that doesn't actually start near 0 would mean a large, wasted detour.""" if self.tomo_angle_range == 180: - return bool(subtomo_number % 2) + return self.tomo_type1_always_forward or bool(subtomo_number % 2) return subtomo_number % 4 == 1 @staticmethod @@ -3827,8 +3859,15 @@ class Flomni( ) if self.tomo_angle_range == 360: print("There are no duplicate angles.") + if self.tomo_angle_range == 180: + print(f"All sub-tomos forward (0->180) = {self.tomo_type1_always_forward}") + zero_deg_where = ( + "every sub-tomo start + end" + if self.tomo_angle_range == 180 and self.tomo_type1_always_forward + else "odd sub-tomo start + end" + ) print( - "0-deg reference shots (odd sub-tomo start + end) =" + f"0-deg reference shots ({zero_deg_where}) =" f" {self.zero_deg_reference_at_each_subtomo}" ) if self.tomo_type == 2: @@ -3976,9 +4015,18 @@ class Flomni( ) if self.tomo_angle_range == 360: print("There are no duplicate angles.") + if self.tomo_angle_range == 180: + self.tomo_type1_always_forward = bool( + self._get_val( + "Run all sub-tomos forward (0->180) instead of alternating 1/0?", + int(self.tomo_type1_always_forward), + int, + ) + ) self.zero_deg_reference_at_each_subtomo = bool( self._get_val( - "Take 0-deg reference shots (start of each odd sub-tomo + end) for" + "Take 0-deg reference shots (start of each odd sub-tomo, or every" + " sub-tomo if run forward, + end) for" " damage estimation 1/0?", int(self.zero_deg_reference_at_each_subtomo), int, @@ -4073,6 +4121,7 @@ class Flomni( "golden_max_number_of_projections", "golden_projections_at_0_deg_for_damage_estimation", "zero_deg_reference_at_each_subtomo", + "tomo_type1_always_forward", "corridor_size", "at_each_angle_hook", ) 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 f6b22a81..6051687c 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -91,7 +91,11 @@ TOMO_TYPES = { # _job_tooltip() so a queued job's hover details don't show fields that are # meaningless for its own tomo_type (e.g. golden-ratio settings on a type-1 # job), on both flomni and lamni. -_TYPE1_ONLY_PARAMS = {"tomo_angle_range", "zero_deg_reference_at_each_subtomo"} +_TYPE1_ONLY_PARAMS = { + "tomo_angle_range", + "zero_deg_reference_at_each_subtomo", + "tomo_type1_always_forward", +} _TYPE2_ONLY_PARAMS = {"golden_ratio_bunch_size"} _TYPE23_PARAMS = { "golden_max_number_of_projections", @@ -145,6 +149,7 @@ QUEUE_PARAM_NAMES = ( "golden_max_number_of_projections", "golden_projections_at_0_deg_for_damage_estimation", "zero_deg_reference_at_each_subtomo", + "tomo_type1_always_forward", "corridor_size", "at_each_angle_hook", ) @@ -168,6 +173,7 @@ DEFAULTS: dict[str, Any] = { "tomo_angle_range": 180, "tomo_angle_stepsize": 10.0, "zero_deg_reference_at_each_subtomo": False, + "tomo_type1_always_forward": False, "golden_ratio_bunch_size": 20, "golden_max_number_of_projections": 1000.0, "golden_projections_at_0_deg_for_damage_estimation": 0, @@ -648,6 +654,9 @@ class OMNY_TomoParams(BECWidget, QWidget): self._pw["tomo_angle_range"].currentIndexChanged.connect( self._update_projection_preview ) + self._pw["tomo_angle_range"].currentIndexChanged.connect( + self._update_always_forward_enabled + ) form.addRow("Angle range:", self._pw["tomo_angle_range"]) # requested total projections (user entry in edit mode) @@ -666,6 +675,8 @@ class OMNY_TomoParams(BECWidget, QWidget): if self._profile["has_zero_deg_reference"]: self._add_bool(form, "zero_deg_reference_at_each_subtomo", "0° reference each sub-tomo") + if self._profile.get("has_always_forward"): + self._add_bool(form, "tomo_type1_always_forward", "Run all sub-tomos forward") return box def _build_type23_section(self) -> QGroupBox: @@ -1331,6 +1342,14 @@ class OMNY_TomoParams(BECWidget, QWidget): self, "Added to queue", f"Added '{jobs[-1]['label']}' to the queue." ) + def _update_always_forward_enabled(self, *_args) -> None: + """'Run all sub-tomos forward' only applies to the 180 degree range.""" + cb = self._pw.get("tomo_type1_always_forward") + combo = self._pw.get("tomo_angle_range") + if cb is None or combo is None: + return + cb.setEnabled(self._edit_mode and combo.currentData() == 180) + def _set_fields_enabled(self, enabled: bool) -> None: skip = {"_requested_total"} if not enabled else set() for key, widget in self._pw.items(): @@ -1347,6 +1366,8 @@ class OMNY_TomoParams(BECWidget, QWidget): 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) + if enabled: + self._update_always_forward_enabled() # ── queue dialog ────────────────────────────────────────────────────────── @@ -2330,6 +2351,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { "has_180_mode": True, "has_single_point": True, "has_zero_deg_reference": True, + "has_always_forward": True, "golden_bunch_size_min": 1, "fov_fields": [ ("fovx", "FOV x (µm)", 0.1, 220.0, 2), diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index 0ae5a53f..a776deee 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -379,6 +379,8 @@ Three modes for angular sampling are implemented and they have different optiona For the "8 sub-tomograms" mode, `flomni.tomo_parameters()` also offers a `zero_deg_reference_at_each_subtomo` option: when enabled, an additional reference projection at exactly 0 degrees is acquired at the start of every odd (forward) sub-tomogram and once more after the final sub-tomogram, useful for tracking radiation damage across the full measurement. +By default odd sub-tomograms sweep 0→180° and even ones 180→0°, so the reference is taken before every second sub-tomogram. With the `tomo_type1_always_forward` option ("Run all sub-tomos forward", 180° range only), every sub-tomogram sweeps 0→180°; the rotation returns to 0° between sub-tomograms and a 0° reference is taken before each of the 8 (plus one at the end). The angle set of every sub-tomogram, and thus the combined angular grid, is unchanged. + `flomni.tomo_parameters()` also offers `stitch_x`/`stitch_y` (integers, default 0, up to 50 each) for a field of view larger than a single Fermat scan can cover: when nonzero, every projection angle is acquired as a `(2*stitch_x+1) × (2*stitch_y+1)` grid of separate Fermat scans instead of a single one, tiled around the normal alignment-corrected center and spaced by `fovx`/`fovy` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(2*stitch_x+1) × fovx` by `(2*stitch_y+1) × fovy`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. Stitching is forced to 0 (disabled) whenever `single_point_instead_of_fermat_scan` is enabled. The parameters above can be used to __restart an interrupted acquisition__ manually, or - more conveniently - by running diff --git a/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py b/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py index 7f0a1edb..636c1de1 100644 --- a/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py +++ b/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py @@ -133,6 +133,37 @@ def test_resume_round_trip(tomo_angle_range, stepsize): assert offset == i, f"n={n} i={i} range={tomo_angle_range} stepsize={stepsize}" +@pytest.mark.parametrize("stepsize", STEPSIZES) +def test_always_forward_180_mode(stepsize): + """always_forward: every sub-tomogram ascends, covers exactly the same + angle set as the default alternating scheme, and resume round-trips.""" + for n in range(1, 9): + default, _, N, _ = plan(n, 180, stepsize) + fwd, _, _, _ = plan(n, 180, stepsize, always_forward=True) + assert np.all(np.diff(fwd) > 0) + assert np.allclose(np.sort(default), np.sort(fwd), atol=1e-9) + for i in range(N): + _, offset, _, _ = plan(n, 180, stepsize, start_angle=fwd[i], always_forward=True) + assert offset == i + + +def test_always_forward_ignored_in_360_mode(): + for n in range(1, 9): + a, _, _, _ = plan(n, 360, 10.0) + b, _, _, _ = plan(n, 360, 10.0, always_forward=True) + assert np.array_equal(a, b) + + +@pytest.mark.parametrize("always_forward", [False, True]) +@pytest.mark.parametrize("n", range(1, 9)) +def test_subtomo_starts_near_zero_180(n, always_forward): + flomni = Flomni.__new__(Flomni) + flomni.client = types.SimpleNamespace( + get_global_var=lambda name: {"tomo_angle_range": 180, "tomo_type1_always_forward": always_forward}.get(name) + ) + assert flomni._subtomo_starts_near_zero(n) is (always_forward or bool(n % 2)) + + def _actual_grid(stepsize): N = int(180.0 / stepsize) step = 180.0 / N From b4cec422114f2550128ea3b6d12e84749735a17f Mon Sep 17 00:00:00 2001 From: x12sa Date: Thu, 1 Oct 2026 12:54:28 +0200 Subject: [PATCH 6/6] fix(tomo_params): enable 'run all sub-tomos forward' on entering edit mode _enter_edit_mode_with() called _set_fields_enabled(True) before setting _edit_mode, so the always-forward checkbox was evaluated with _edit_mode=False and stayed disabled until the angle range was toggled. Co-Authored-By: Claude Sonnet 5.5 --- csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index 6051687c..d31c9fb8 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -1159,8 +1159,10 @@ class OMNY_TomoParams(BECWidget, QWidget): def _enter_edit_mode_with(self, params: dict[str, Any]) -> None: self._populate_fields(params) - self._set_fields_enabled(True) + # _edit_mode must be set first: _set_fields_enabled() consults it + # (e.g. for the 'run all sub-tomos forward' checkbox). self._edit_mode = True + self._set_fields_enabled(True) self._btn_edit.setVisible(False) self._btn_submit.setVisible(True) self._btn_add_edited_to_queue.setVisible(True)