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 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-15 10:18:38 +02:00
co-authored by Claude Sonnet 5
parent 278364e650
commit aebfccc5f3
4 changed files with 227 additions and 132 deletions
@@ -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.")
@@ -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);
}}
}}
@@ -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
"""
@@ -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