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:
@@ -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
|
||||
Reference in New Issue
Block a user