From aebfccc5f355d995c2f3e772e35fcffcfa42634c Mon Sep 17 00:00:00 2001 From: x01dc Date: Wed, 15 Jul 2026 10:18:38 +0200 Subject: [PATCH] fix(flomni): clear busy heartbeat on scan end, fix duplicated tomo math Two independent fixes: - GUI "beamline busy" indicators (TomoParamsWidget's banner, FlomniWebpageGenerator's status page) purely wait for progress["heartbeat"] to go stale (120s / 90s respectively) - there was no explicit "scan finished" signal to react to instead, since tomo_scan() wrote a heartbeat at the start of every projection but never cleared it on exit. Wrapped the scan loop in try/finally so heartbeat is cleared on every exit path (normal completion, exception, or interrupt/abort), not just at the next scan's start - both busy-detectors now see this on their very next poll instead of waiting out the timeout. Live-verified: heartbeat is None immediately after tomo_scan() returns. - Found while investigating a related report ("angular step of the final combined tomogram shown different between 180 and 360 mode, although it has to be identical" + "GUI shows projection count doubling when switching 180->360"): TomoParamsWidget's _compute_type1()/ _requested_to_stepsize() (tomo_params.py) and the generated status webpage's calcProjections() JS (flomni_webpage_generator.py) are both independent, un-synced duplicates of the exact old N=int(angle_range/ stepsize) formula fixed in flomni.py's own _tomo_type1_actual_grid() two commits ago - they were never updated when that fix landed, so the GUI and webpage kept reporting double the projection count for 360 mode. Fixed both to match flomni.py: N/step/total are always computed against a fixed 180 degrees, independent of angle_range. Also fixed the "angular step of the final (combined) tomogram" CLI/wizard lines in flomni.py itself, which used tomo_angle_range/total_projections (correct for 180 mode by coincidence, wrong for 360 mode - the true combined resolution is always 180/total_projections, identical between modes). Added a regression test asserting the widget's formulas match flomni.py's for both modes, to catch this exact kind of drift if it recurs. Live-verified against the running sim: both modes now report identical total_projections and combined-tomogram step for the same stepsize. Co-Authored-By: Claude Sonnet 5 --- .../plugins/flomni/flomni.py | 268 ++++++++++-------- .../flomni/flomni_webpage_generator.py | 18 +- .../widgets/tomo_params/tomo_params.py | 25 +- .../test_tomo_params_widget_math.py | 48 ++++ 4 files changed, 227 insertions(+), 132 deletions(-) create mode 100644 tests/tests_bec_ipython_client/test_tomo_params_widget_math.py diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 1e276b0..57d7e93 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -2664,132 +2664,149 @@ class Flomni( self.progress["heartbeat"] = None self.collect_empty_frames() - with scans.dataset_id_on_hold: - if self.tomo_type == 1: - # 8 equally spaced sub-tomograms - self.progress["tomo_type"] = "Equally spaced sub-tomograms" - for ii in range(subtomo_start, 9): - if ( - start_angle is None - and self._subtomo_starts_near_zero(ii) - and self.zero_deg_reference_at_each_subtomo - ): - # Dedicated reference shot at exactly 0 degrees, taken - # every time the rotation passes back through 0, for - # tracking radiation damage over the full tomogram. - # In 180 mode that's the start of every odd/forward - # sub-tomogram; in 360 mode only sub-tomograms 1 and 5 - # (the "low half, forward" group) actually start near - # 0 - sub-tomograms 2/3/6/7 operate entirely in the - # [180,360) half and forcing a detour to 0 before them - # would defeat the point of the 360-mode boustrophedon - # path. Skipped when resuming mid-sub-tomogram - # (start_angle given explicitly) since we're not - # actually passing through 0 deg at that moment. - self._tomo_scan_at_angle(0, ii) - self.sub_tomo_scan(ii, start_angle=start_angle) - start_angle = None - - if self.zero_deg_reference_at_each_subtomo: - # Final reference shot at exactly 0 degrees once the whole - # tomogram is complete, giving a clean "before vs after" - # pair together with sub-tomogram 1's own first projection - # (angle 0) for radiation-damage comparison across the - # full acquisition. - self._tomo_scan_at_angle(0, 8) - - elif self.tomo_type == 2: - # Golden ratio tomography - previous_subtomo_number = -1 - if projection_number == None: - ii = 0 - else: - ii = projection_number - while True: - angle, subtomo_number = self._golden(ii, self.golden_ratio_bunch_size, 180, 1) - if previous_subtomo_number != subtomo_number: + try: + with scans.dataset_id_on_hold: + if self.tomo_type == 1: + # 8 equally spaced sub-tomograms + self.progress["tomo_type"] = "Equally spaced sub-tomograms" + for ii in range(subtomo_start, 9): if ( - subtomo_number % 2 == 1 - and ii > 10 - and self.golden_projections_at_0_deg_for_damage_estimation == 1 + start_angle is None + and self._subtomo_starts_near_zero(ii) + and self.zero_deg_reference_at_each_subtomo ): - self._tomo_scan_at_angle(0, subtomo_number) - previous_subtomo_number = subtomo_number - self.progress["tomo_type"] = "Golden ratio tomography" - self.progress["subtomo"] = subtomo_number - self.progress["projection"] = ii - self.progress["angle"] = angle - if self.golden_ratio_bunch_size > 0: - self.progress["subtomo_total_projections"] = self.golden_ratio_bunch_size + # Dedicated reference shot at exactly 0 degrees, taken + # every time the rotation passes back through 0, for + # tracking radiation damage over the full tomogram. + # In 180 mode that's the start of every odd/forward + # sub-tomogram; in 360 mode only sub-tomograms 1 and 5 + # (the "low half, forward" group) actually start near + # 0 - sub-tomograms 2/3/6/7 operate entirely in the + # [180,360) half and forcing a detour to 0 before them + # would defeat the point of the 360-mode boustrophedon + # path. Skipped when resuming mid-sub-tomogram + # (start_angle given explicitly) since we're not + # actually passing through 0 deg at that moment. + self._tomo_scan_at_angle(0, ii) + self.sub_tomo_scan(ii, start_angle=start_angle) + start_angle = None + + if self.zero_deg_reference_at_each_subtomo: + # Final reference shot at exactly 0 degrees once the whole + # tomogram is complete, giving a clean "before vs after" + # pair together with sub-tomogram 1's own first projection + # (angle 0) for radiation-damage comparison across the + # full acquisition. + self._tomo_scan_at_angle(0, 8) + + elif self.tomo_type == 2: + # Golden ratio tomography + previous_subtomo_number = -1 + if projection_number == None: + ii = 0 + else: + ii = projection_number + while True: + angle, subtomo_number = self._golden( + ii, self.golden_ratio_bunch_size, 180, 1 + ) + if previous_subtomo_number != subtomo_number: + if ( + subtomo_number % 2 == 1 + and ii > 10 + and self.golden_projections_at_0_deg_for_damage_estimation == 1 + ): + self._tomo_scan_at_angle(0, subtomo_number) + previous_subtomo_number = subtomo_number + self.progress["tomo_type"] = "Golden ratio tomography" + self.progress["subtomo"] = subtomo_number + self.progress["projection"] = ii + self.progress["angle"] = angle + if self.golden_ratio_bunch_size > 0: + self.progress["subtomo_total_projections"] = ( + self.golden_ratio_bunch_size + ) + self.progress["subtomo_projection"] = ( + ii - (subtomo_number - 1) * self.golden_ratio_bunch_size + ) + else: + self.progress["subtomo_total_projections"] = 0 + self.progress["subtomo_projection"] = 0 + + if self.golden_max_number_of_projections > 0: + self.progress["total_projections"] = ( + self.golden_max_number_of_projections + ) + else: + self.progress["total_projections"] = 0 + + self._tomo_scan_at_angle(angle, subtomo_number) + ii += 1 + if ( + ii > self.golden_max_number_of_projections + and self.golden_max_number_of_projections > 0 + ): + print( + f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections" + ) + break + elif self.tomo_type == 3: + # Equally spaced tomography, golden ratio starting angle + previous_subtomo_number = -1 + if projection_number == None: + ii = 0 + else: + ii = projection_number + while True: + angle, subtomo_number = self._golden_equally_spaced( + ii, int(180 / self.tomo_angle_stepsize), 180, 1, 0 + ) + if previous_subtomo_number != subtomo_number: + if ( + subtomo_number % 2 == 1 + and ii > 10 + and self.golden_projections_at_0_deg_for_damage_estimation == 1 + ): + self._tomo_scan_at_angle(0, subtomo_number) + previous_subtomo_number = subtomo_number + self.progress["tomo_type"] = ( + "Equally spaced tomography, golden ratio starting angle" + ) + self.progress["subtomo"] = subtomo_number + self.progress["projection"] = ii + self.progress["angle"] = angle + + self.progress["subtomo_total_projections"] = 180 / self.tomo_angle_stepsize self.progress["subtomo_projection"] = ( - ii - (subtomo_number - 1) * self.golden_ratio_bunch_size + ii - (subtomo_number - 1) * self.progress["subtomo_total_projections"] ) - else: - self.progress["subtomo_total_projections"] = 0 - self.progress["subtomo_projection"] = 0 - if self.golden_max_number_of_projections > 0: - self.progress["total_projections"] = self.golden_max_number_of_projections - else: - self.progress["total_projections"] = 0 - - self._tomo_scan_at_angle(angle, subtomo_number) - ii += 1 - if ( - ii > self.golden_max_number_of_projections - and self.golden_max_number_of_projections > 0 - ): - print( - f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections" - ) - break - elif self.tomo_type == 3: - # Equally spaced tomography, golden ratio starting angle - previous_subtomo_number = -1 - if projection_number == None: - ii = 0 - else: - ii = projection_number - while True: - angle, subtomo_number = self._golden_equally_spaced( - ii, int(180 / self.tomo_angle_stepsize), 180, 1, 0 - ) - if previous_subtomo_number != subtomo_number: + if self.golden_max_number_of_projections > 0: + self.progress["total_projections"] = ( + self.golden_max_number_of_projections + ) + else: + self.progress["total_projections"] = 0 + self._tomo_scan_at_angle(angle, subtomo_number) + ii += 1 if ( - subtomo_number % 2 == 1 - and ii > 10 - and self.golden_projections_at_0_deg_for_damage_estimation == 1 + ii > self.golden_max_number_of_projections + and self.golden_max_number_of_projections > 0 ): - self._tomo_scan_at_angle(0, subtomo_number) - previous_subtomo_number = subtomo_number - self.progress["tomo_type"] = ( - "Equally spaced tomography, golden ratio starting angle" - ) - self.progress["subtomo"] = subtomo_number - self.progress["projection"] = ii - self.progress["angle"] = angle - - self.progress["subtomo_total_projections"] = 180 / self.tomo_angle_stepsize - self.progress["subtomo_projection"] = ( - ii - (subtomo_number - 1) * self.progress["subtomo_total_projections"] - ) - - if self.golden_max_number_of_projections > 0: - self.progress["total_projections"] = self.golden_max_number_of_projections - else: - self.progress["total_projections"] = 0 - self._tomo_scan_at_angle(angle, subtomo_number) - ii += 1 - if ( - ii > self.golden_max_number_of_projections - and self.golden_max_number_of_projections > 0 - ): - print( - f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections" - ) - break - else: - raise FlomniError("undefined tomo type") + print( + f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections" + ) + break + else: + raise FlomniError("undefined tomo type") + finally: + # Cleared on every exit path (normal completion, exception, or + # interrupt/abort) - not just on the next scan's start (line + # ~2664) - so the GUI's busy-detectors (TomoParamsWidget, + # FlomniWebpageGenerator) see this immediately on their next + # poll instead of waiting out their heartbeat-staleness timeout + # (120s / 90s) after a tomogram that's actually already done. + self.progress["heartbeat"] = None self.progress["projection"] = self.progress["total_projections"] self.progress["subtomo_projection"] = self.progress["subtomo_total_projections"] @@ -3643,9 +3660,15 @@ class Flomni( _, achievable_step, total_projections = self._tomo_type1_actual_grid() print(f"Total number of projections: {total_projections}") print(f"Angular step within sub-tomogram: {achievable_step:.3f} degrees") + # Always 180/total_projections, never tomo_angle_range/total_projections: + # in 360 mode the low/high halves are complementary (see + # _subtomo_angle_plan()'s docstring), so the combined tomogram's + # true resolution matches 180 mode exactly for the same total + # count - it is not tomo_angle_range/total_projections, which + # would (wrongly) show a different, coarser number for 360 mode. print( "Angular step of the final (combined) tomogram:" - f" {self.tomo_angle_range / total_projections:.3f} degrees" + f" {180.0 / total_projections:.3f} degrees" ) if self.tomo_angle_range == 360: print("There are no duplicate angles.") @@ -3772,9 +3795,12 @@ class Flomni( print( f"The angular step within a sub-tomogram will be {achievable_step:.3f} degrees" ) + # Always 180/actual_total - see the same line in + # tomo_parameters() for why tomo_angle_range/actual_total + # would be wrong here. print( "The angular step of the final (combined) tomogram will be" - f" {self.tomo_angle_range / actual_total:.3f} degrees" + f" {180.0 / actual_total:.3f} degrees" ) if self.tomo_angle_range == 360: print("There are no duplicate angles.") 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 697cb74..d39ebe5 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 @@ -615,7 +615,13 @@ class WebpageGeneratorBase: 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 + total = floor(180 / angle_stepsize) * n_subtomos + (independent of angle_range - see + Flomni._subtomo_angle_plan()'s docstring for + why: in 360-degree mode, angle_range only + splits the n_subtomos into complementary + low/high halves, it does not change the + total count) "golden_capped" total = golden_max_number_of_projections """ @@ -2842,9 +2848,13 @@ function calcProjections(params){{ 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); + // Always floor(180/step), never floor(angle_range/step): in 360-degree + // mode angle_range only splits the sub-tomograms into complementary + // low/high halves, it does not change the total projection count - + // see Flomni._subtomo_angle_plan()'s docstring. + const step=params.tomo_angle_stepsize; + if(step>0){{ + const N=Math.floor(180/step); return N*(def.n_subtomos||1); }} }} 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 55ee053..fa6812c 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -1823,24 +1823,34 @@ class CommandJobBuilderDialog(QDialog): def _requested_to_stepsize(angle_range: int, requested_total: int) -> float: - """Convert a desired total projection count to tomo_angle_stepsize.""" + """Convert a desired total projection count to tomo_angle_stepsize. + + angle_range is accepted for call-site compatibility but NOT used here: + N/step/total are always computed against a fixed 180 degrees, + independent of tomo_angle_range (tomo_angle_range only splits the 8 + sub-tomograms into low/high halves in 360 mode -- it doesn't change + the total projection count or per-subtomo step). Mirrors + Flomni._subtomo_angle_plan()'s docstring / _tomo_type1_actual_grid(). + """ if requested_total <= 0: - return float(angle_range) - return (angle_range / requested_total) * 8 + return 180.0 + return (180.0 / requested_total) * 8 def _compute_type1(angle_range: int, stepsize: float) -> tuple[int, float, float]: """ Given stored tomo_angle_stepsize, return: (actual_total, achievable_step, stepsize) - Mirrors the CLI's internal computation exactly. + Mirrors the CLI's internal computation exactly -- see + _requested_to_stepsize()'s docstring for why angle_range is accepted + but not used in the N/step/total math. """ if stepsize <= 0: return 0, 0.0, 0.0 - N = int(angle_range / stepsize) + N = int(180.0 / stepsize) if N <= 0: return 0, 0.0, 0.0 - achievable_step = angle_range / N + achievable_step = 180.0 / N actual_total = N * 8 return actual_total, achievable_step, stepsize @@ -1849,7 +1859,8 @@ 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 1: int(180 / stepsize) * 8 (8 equally spaced sub-tomos, + independent of angle_range) - type 3: int(180 / stepsize) * 8 (equally spaced, golden start) - type 2: golden ratio has no fixed count -> configured max, or ∞ if unset """ diff --git a/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py new file mode 100644 index 0000000..018c98f --- /dev/null +++ b/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py @@ -0,0 +1,48 @@ +"""Regression test for TomoParamsWidget's independent (duplicated) +projection-count math: it must stay in sync with Flomni's own +_tomo_type1_actual_grid()/_subtomo_angle_plan() -- this exact drift (the +widget kept using the old, angle-range-dependent formula after flomni.py +was fixed to be mode-independent) is what caused the GUI to show the +projection count doubling when switching from 180 to 360 mode. +""" + +import pytest + +from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni +from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import ( + _compute_type1, + _requested_to_stepsize, +) + +STEPSIZES = [10.0, 7.0, 25.0, 12.5] + + +@pytest.mark.parametrize("stepsize", STEPSIZES) +@pytest.mark.parametrize("angle_range", [180, 360]) +def test_compute_type1_matches_flomni(stepsize, angle_range): + total, step, N = _flomni_reference(stepsize) + actual_total, achievable_step, _ = _compute_type1(angle_range, stepsize) + assert actual_total == total + assert achievable_step == pytest.approx(step) + + +@pytest.mark.parametrize("requested_total", [24, 48, 96, 144, 200]) +@pytest.mark.parametrize("angle_range", [180, 360]) +def test_requested_to_stepsize_matches_flomni_wizard_formula(requested_total, angle_range): + """Mirrors the exact line in Flomni.tomo_parameters()'s wizard: + self.tomo_angle_stepsize = (180.0 / tomo_numberofprojections) * 8""" + stepsize = _requested_to_stepsize(angle_range, requested_total) + assert stepsize == pytest.approx((180.0 / requested_total) * 8) + + +def test_total_projections_identical_between_modes_for_same_stepsize(): + for stepsize in STEPSIZES: + total_180, _, _ = _compute_type1(180, stepsize) + total_360, _, _ = _compute_type1(360, stepsize) + assert total_180 == total_360 + + +def _flomni_reference(stepsize): + N = int(180.0 / stepsize) + step = 180.0 / N + return N * 8, step, N