Feat: Improve smart data collection panel
CI / lint (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / test-with-beamline-plugins (pxi_bec) (push) Skipped
CI / test-with-beamline-plugins (pxii_bec) (push) Skipped
CI / test-with-beamline-plugins (pxiii_bec) (push) Skipped

This commit is contained in:
2026-09-14 16:02:42 +02:00
parent c692cd048c
commit 766ca8b71e
+313 -186
View File
@@ -1,18 +1,101 @@
import math
from aarecommon.config.beamline import mx_beamline
from aarecommon.config.logger import setup_logger
from aarecommon.models.beamline import MXBeamline
from aarecommon.models.models import CrystalSize, DAQStatusModel, SimpleScanParameters
from aarecommon.models.rotation_scan import RotationScanRequest
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QSizePolicy, QSpacerItem, QWidget
from aare.gui.constants import LOGGER_NAME
from aare.gui.panels.rotation_data_collection import add_data_to_path
from aare.gui.panels.rotation_data_collection import (
MAX_OMEGA_SPEED_DEG_S,
MIN_EXP_TIME_S,
add_data_to_path,
)
from aare.gui.styles import ABORT_TEXT, GO_TEXT, STATUS_ALERT
from aare.gui.widgets.number_line_edit import NumberLineEdit
logger = setup_logger(LOGGER_NAME)
# --- Dose model -----------------------------------------------------------
# kdose: photons um^-2 needed per Gy at lambda = 1 A, from Holton (2009)
# J. Synchrotron Rad. 16, 133-142. The lambda^2 scaling below is the
# photoelectric limit (mu_en/rho ~ lambda^3, photon energy ~ 1/lambda) and
# assumes negligible heavy-metal content; Se-Met and metalloprotein crystals
# absorb more than this, so use RADDOSE-3D for those.
KDOSE_1A_PH_UM2_GY = 2000.0
# Howells et al. put the half-dose at 10 MGy per A of resolution; we budget
# half of that, so the target resolution keeps roughly 70% of its intensity.
CRYO_DOSE_PER_ANGSTROM_MGY = 10.0
# Above the glass transition the crystal dies ~2 orders of magnitude faster.
# Owen et al. measured D(1/2) ~ 0.38 MGy at room temperature and Holton's
# lifetime calculator defaults to 0.2 MGy; 0.1 MGy/A with the same safety
# factor sits just below both.
RT_DOSE_PER_ANGSTROM_MGY = 0.1
DOSE_SAFETY_FACTOR = 2.0
# Protein-solvent glass transition. Warkentin & Thorne fit two Arrhenius
# regimes either side of ~200 K: below it damage is athermal (Ea ~ 1 kJ/mol),
# above it diffusion-dominated (Ea ~ 18 kJ/mol) and the cryo budget no longer
# applies. Switching at 250 K would apply the cryo budget in the 200-250 K
# band and overdose there by close to an order of magnitude.
GLASS_TRANSITION_K = 200.0
# --- Strategy quantisation ------------------------------------------------
# Detector frame rate we pin on the flux-rich beamline (X10SA), where the
# strategy solves for transmission instead of exposure time.
FIXED_FRAME_RATE_HZ = 100.0
DEFAULT_MIN_EXP_TIME_S = 0.0005
# Shortest frame the strategy will ask a beamline for, where that is longer
# than the detector's own floor in MIN_EXP_TIME_S. X06DA's detector reaches
# 1/900 s, but 2 ms is the shortest usable frame for a strategy collection
# there. Stated explicitly so it does not silently drop to 1.2 ms if
# EXP_TIME_STEP_S is ever made finer.
STRATEGY_MIN_EXP_TIME_S = {MXBeamline.X06DA: 0.002}
# Entry limits for the visible-resolution field. Named because the clamp on
# the DAQ value has to use the same numbers as the widget's validator.
VISIBLE_RES_MIN_A = 0.8
VISIBLE_RES_MAX_A = 10.0
VISIBLE_RES_DEFAULT_A = 2.0
# Two DAQ resolutions closer than this are the same raster result reported
# again, not a new one.
DAQ_RES_EPS_A = 0.001
VISIBLE_RES_LABEL = "Visible resolution"
DTZ_STEP_MM = 10.0
EXP_TIME_STEP_S = 0.001
TRANSMISSION_SIG_DIGITS = 2
# Grid steps are not representable in binary (0.001, 0.1, ...), so an exact
# multiple can land a hair either side of the integer. Without this tolerance
# _floor_to(1.7, 0.1) returns 1.6 and _ceil_to(0.011, 0.001) returns 0.012.
_QUANT_EPS = 1e-9
def _floor_to(value: float, step: float) -> float:
# Rounded back to 12 dp so the result is a clean grid value: 17 * 0.1 is
# 1.7000000000000002, which would be displayed and emitted verbatim.
return round(math.floor(value / step + _QUANT_EPS) * step, 12)
def _ceil_to(value: float, step: float) -> float:
return round(math.ceil(value / step - _QUANT_EPS) * step, 12)
def _floor_significant(value: float, digits: int) -> float:
"""Round down to `digits` significant digits.
Down, not nearest: every quantisation in the strategy is allowed to spend
less than the dose budget but never more.
"""
if value <= 0.0:
return 0.0
step = 10.0 ** (math.floor(math.log10(value) + _QUANT_EPS) - (digits - 1))
return _floor_to(value, step)
class SimpleRotationSettingsPanel(QWidget):
rotation_scan = Signal(RotationScanRequest)
@@ -23,12 +106,14 @@ class SimpleRotationSettingsPanel(QWidget):
super().__init__(parent)
self._beamline = mx_beamline()
self._daq_best_res = None
# Distinguishes "no DAQ resolution yet" from "DAQ reports none", so the
# first status tick always paints the label.
self._daq_res_applied = False
self.n_images = 1
self.xtal_size_dose_rate_MGy_s = None
self.xtal_size = CrystalSize(x=0, y=0, z=0)
self.xtal_x = None
self.xtal_y = None
self.xtal_z = None
self.transmission = 1.0
self.delivered_dose_MGy = 0.0
self.target_dose_MGy = None
self.dose_rate_MGy_s = None
self._filename = ""
@@ -37,8 +122,6 @@ class SimpleRotationSettingsPanel(QWidget):
self._d = None
self._temperature = 100
self._omega = 0
self._wilson_b = None
self.flux_ph_s = None
self.total_time_s = 0
self.parameters = SimpleScanParameters()
self._prev_params = SimpleScanParameters()
@@ -49,10 +132,17 @@ class SimpleRotationSettingsPanel(QWidget):
m = self._layout.contentsMargins()
self._layout.setContentsMargins(m.left(), 0, m.right(), 3)
# Visible resolution (entry)
self._layout.addWidget(QLabel("Visible resolution", parent=self), 0, 0)
# Visible resolution (entry). The label carries the provenance of the
# value: silence means it came from the DAQ, otherwise it says so.
self.visible_res_label = QLabel(VISIBLE_RES_LABEL, parent=self)
self._layout.addWidget(self.visible_res_label, 0, 0)
self.visible_res_enter = NumberLineEdit(
0.8, 10.0, decimals=2, default=2.0, parent=self, track_pending=True
VISIBLE_RES_MIN_A,
VISIBLE_RES_MAX_A,
decimals=2,
default=VISIBLE_RES_DEFAULT_A,
parent=self,
track_pending=True,
)
self._layout.addWidget(self.visible_res_enter, 0, 1, 1, 3)
self._layout.addWidget(QLabel("Å", parent=self), 0, 4)
@@ -69,6 +159,7 @@ class SimpleRotationSettingsPanel(QWidget):
self.omega_button.setFixedWidth(30)
self.omega_button.clicked.connect(self.update_omega_start)
self._layout.addWidget(self.omega_button, 1, 5)
self.start_angle_enter.newValue.connect(self.set_start_angle)
# Angular range (entry)
self._layout.addWidget(QLabel("Total angle", parent=self), 2, 0)
@@ -77,7 +168,7 @@ class SimpleRotationSettingsPanel(QWidget):
)
self._layout.addWidget(self.angular_range_enter, 2, 1, 1, 3)
self._layout.addWidget(QLabel("°", parent=self), 2, 4)
self.visible_res_enter.newValue.connect(self.set_total_angle)
self.angular_range_enter.newValue.connect(self.set_total_angle)
self._layout.addWidget(QLabel("Image angle", parent=self), 3, 0)
self.image_angle_enter = NumberLineEdit(
@@ -137,137 +228,105 @@ class SimpleRotationSettingsPanel(QWidget):
self._layout.addWidget(self.dtz_label, 10, 1, 1, 3)
self._layout.addWidget(QLabel("mm", parent=self), 10, 4)
self._layout.addWidget(QLabel("Target Dose", parent=self), 11, 0)
self.target_dose_label = QLabel("--", parent=self)
self.target_dose_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.target_dose_label, 11, 1, 1, 3)
self._layout.addWidget(QLabel("MGy", parent=self), 11, 4)
self._layout.addWidget(QLabel("Calculated Dose Rate", parent=self), 12, 0)
self.calculated_dose_rate_label = QLabel("--", parent=self)
self.calculated_dose_rate_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.calculated_dose_rate_label, 12, 1, 1, 3)
self._layout.addWidget(QLabel("MGy s<sup>-1</sup>", parent=self), 12, 4)
self._layout.addWidget(QLabel("Wilson B Factor", parent=self), 13, 0)
self.wilson_b_label = QLabel("--", parent=self)
self.wilson_b_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.wilson_b_label, 13, 1, 1, 3)
self._layout.addWidget(QLabel("Å<sup>2</sup>", parent=self), 13, 4)
self._layout.addWidget(QLabel("Crystal Size x", parent=self), 14, 0)
self.xtal_x_label = QLabel("--", parent=self)
self.xtal_x_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.xtal_x_label, 14, 1, 1, 3)
self._layout.addWidget(QLabel("um", parent=self), 14, 4)
self._layout.addWidget(QLabel("Crystal Size y", parent=self), 15, 0)
self.xtal_y_label = QLabel("--", parent=self)
self.xtal_y_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.xtal_y_label, 15, 1, 1, 3)
self._layout.addWidget(QLabel("um", parent=self), 15, 4)
self._layout.addWidget(QLabel("Crystal Size z", parent=self), 16, 0)
self.xtal_z_label = QLabel("--", parent=self)
self.xtal_z_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.xtal_z_label, 16, 1, 1, 3)
self._layout.addWidget(QLabel("um", parent=self), 16, 4)
self._layout.addWidget(QLabel("Calculated Dose (xtal size)", parent=self), 17, 0)
self.xtal_size_dose_label = QLabel("--", parent=self)
self.xtal_size_dose_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.xtal_size_dose_label, 17, 1, 1, 3)
self._layout.addWidget(QLabel("MGy", parent=self), 17, 4)
self._layout.addWidget(QLabel("X-ray Wavelength", parent=self), 18, 0)
self.wavelength_label = QLabel("--", parent=self)
self.wavelength_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.wavelength_label, 18, 1, 1, 3)
self._layout.addWidget(QLabel("Å", parent=self), 18, 4)
self._layout.addWidget(QLabel("Flux", parent=self), 19, 0)
self.flux_label = QLabel("--", parent=self)
self.flux_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.flux_label, 19, 1, 1, 3)
self._layout.addWidget(QLabel("x 10<sup>9</sup> ph s<sup>-1</sup>", parent=self), 19, 4)
self._layout.addWidget(QLabel("Beam Size", parent=self), 20, 0)
self._layout.addWidget(QLabel("Beam Size", parent=self), 11, 0)
self.beam_size_label = QLabel("--", parent=self)
self.beam_size_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.beam_size_label, 20, 1, 1, 3)
self._layout.addWidget(QLabel("um<sup>2</sup>", parent=self), 20, 4)
self._layout.addWidget(self.beam_size_label, 11, 1, 1, 3)
self._layout.addWidget(QLabel("um<sup>2</sup>", parent=self), 11, 4)
self._layout.addWidget(QLabel("Calculated Dose", parent=self), 21, 0)
self._layout.addWidget(QLabel("Target Dose", parent=self), 12, 0)
self.target_dose_label = QLabel("--", parent=self)
self.target_dose_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.target_dose_label, 12, 1, 1, 3)
self._layout.addWidget(QLabel("MGy", parent=self), 12, 4)
self._layout.addWidget(QLabel("Calculated Dose Rate", parent=self), 13, 0)
self.calculated_dose_rate_label = QLabel("--", parent=self)
self.calculated_dose_rate_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.calculated_dose_rate_label, 13, 1, 1, 3)
self._layout.addWidget(QLabel("MGy s<sup>-1</sup>", parent=self), 12, 4)
self._layout.addWidget(QLabel("Calculated Dose", parent=self), 14, 0)
self.calculated_dose_label = QLabel("--", parent=self)
self.calculated_dose_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._layout.addWidget(self.calculated_dose_label, 21, 1, 1, 3)
self._layout.addWidget(QLabel("MGy", parent=self), 21, 4)
self._layout.addWidget(self.calculated_dose_label, 14, 1, 1, 3)
self._layout.addWidget(QLabel("MGy", parent=self), 14, 4)
self._layout.addWidget(QLabel("Total measurement time", parent=self), 22, 0)
self._layout.addWidget(QLabel("Total measurement time", parent=self), 15, 0)
self.total_time = QLabel(f"{self.total_time_s} min 0 s")
self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._layout.addWidget(self.total_time, 22, 1, 1, 3)
self._layout.addWidget(self.total_time, 15, 1, 1, 3)
# Vertical stretch below everything (surplus space sink).
self._layout.addItem(
QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding), 23, 0, 1, 6
QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding), 16, 0, 1, 6
)
@Slot(DAQStatusModel)
def update_daq_status(self, s: DAQStatusModel):
self._d = s
# TODO only update best_res after raster finished otherwise ask to update. or have toggle to overwrite with user value
# TODO only take best res from flat face scan
# TODO identify flat face!
best_res = s.last_best_res
self._omega = s.geom.omega_deg
if best_res is not None:
# clamp to control limits and update field; this will also trigger recalculation
lo, hi = 0.8, 10.0
v = max(lo, min(hi, float(best_res)))
self.visible_res_enter.update_value(v)
self.set_visible_resolution(v)
self._wilson_b = s.last_best_b_factor
if self._wilson_b is not None:
self.wilson_b_label.setText(f"{self._wilson_b:.2f}")
self.xtal_size = s.crystal_size
if self.xtal_size is not None:
# clamp to control limits and update field; this will also trigger recalculation
self.xtal_x = self.xtal_size.x
self.xtal_y = self.xtal_size.y
self.xtal_z = self.xtal_size.z
self.xtal_x_label.setText(f"{self.xtal_x:.2f}")
self.xtal_y_label.setText(f"{self.xtal_y:.2f}")
self.xtal_z_label.setText(f"{self.xtal_z:.2f}")
self._apply_daq_resolution(s.last_best_res)
# self.omega.new_value(s.geom.omega_deg)
self.beam_size_label.setText(
f"<b>{s.geom.beam_size_mm.x * 1000.0}</b> x <b>{s.geom.beam_size_mm.y * 1000.0}</b>"
)
self.flux_ph_s = s.bl.flux_ph_s
self.flux_label.setText(f"<b>{(s.bl.flux_ph_s / 1e9):.2f}</b>")
if s.diffraction.wavelength_angstrom is None:
self.wavelength_label.setText("<b>N/A</b>")
self.update_calculated_labels()
def _apply_daq_resolution(self, best_res: float | None) -> None:
"""Track ``DAQStatusModel.last_best_res`` in the resolution field.
Written on every *change* of the DAQ value rather than on every 2 Hz
tick, so a fresh raster result always lands while a repeat of the same
value leaves a manual override alone.
Two things this deliberately does not use:
* ``update_value`` compares against ``saved_value``, which
``on_editing_finished`` never updates (it self-assigns), so it only
ever sees programmatic writes. Once the operator has typed over a
pushed value, the same DAQ value could never re-assert itself.
``set_committed_value`` compares against what the field actually
shows, which is the question being asked here.
* A bare ``is not None`` guard. The DAQ clears ``last_best_res`` on
mount, and skipping the update then leaves the previous crystal's
resolution in the field with nothing to say it is stale.
"""
if not self._daq_res_applied:
unchanged = False
elif best_res is None or self._daq_best_res is None:
unchanged = best_res is None and self._daq_best_res is None
else:
self.wavelength_label.setText(f"<b>{s.diffraction.wavelength_angstrom:.3f}</b>")
unchanged = abs(best_res - self._daq_best_res) <= DAQ_RES_EPS_A
if unchanged:
return
self._daq_res_applied = True
self._daq_best_res = best_res
if best_res is None:
# No raster result for this sample: either none has run yet, or the
# pgroup gate in the status endpoint stripped it. Either way the
# field is showing a default, and the strategy below is built on a
# number nothing measured -- say so instead of looking authoritative.
value = self.visible_res_enter.get_default()
self.visible_res_label.setText(
f"""{VISIBLE_RES_LABEL} <span style="color: {STATUS_ALERT} ; ">(no raster)</span>"""
)
else:
value = max(VISIBLE_RES_MIN_A, min(VISIBLE_RES_MAX_A, float(best_res)))
self.visible_res_label.setText(VISIBLE_RES_LABEL)
self.visible_res_enter.set_committed_value(value)
self.update_calculated_labels()
@Slot(float)
@@ -286,6 +345,10 @@ class SimpleRotationSettingsPanel(QWidget):
def set_temperature(self, v: float):
self.update_calculated_labels()
@Slot(float)
def set_start_angle(self, v: float):
self.update_calculated_labels()
@Slot(str)
def update_filename(self, filename: str):
self._filename = filename
@@ -302,85 +365,144 @@ class SimpleRotationSettingsPanel(QWidget):
secs = 0
self.total_time.setText(f"{mins} min {secs} s")
def _solve_transmission(self, budget_time_s: float, image_time_s: float) -> float:
"""Transmission that spends the dose budget over ``n_images`` frames.
Snapped down to TRANSMISSION_SIG_DIGITS so quantisation can only
under-spend the budget, and capped at 100%: when the crystal cannot
absorb its budget at full beam we accept the under-dose rather than
stretching the exposure.
"""
beam_on_s = self.n_images * image_time_s
if beam_on_s <= 0.0 or budget_time_s <= 0.0:
return 1.0
# Only ever clamped down: clamping up to a floor would push the
# delivered dose back above the budget.
return min(1.0, _floor_significant(budget_time_s / beam_on_s, TRANSMISSION_SIG_DIGITS))
def _update_dtz(self, d_tar: float) -> None:
"""Detector distance for ``d_tar``, snapped to DTZ_STEP_MM and clamped
to the beamline travel limits.
The snap goes *down*: calc_dtz_mm returns the largest distance that
still puts d_tar on the detector edge, so rounding up would drop the
target resolution off the edge.
"""
requested = self._d.diffraction.calc_dtz_mm(d_tar)
dtz_min = _ceil_to(self._d.bl.dtz_min, DTZ_STEP_MM)
dtz_max = _floor_to(self._d.bl.dtz_max, DTZ_STEP_MM)
if requested <= 0.0:
# d_tar is not reachable at this wavelength at any distance.
self.dtz = dtz_min
self.dtz_label.setText(f"""<span style="color: {STATUS_ALERT} ; ">-</span>""")
return
snapped = _floor_to(requested, DTZ_STEP_MM)
if snapped < dtz_min:
self.dtz = dtz_min
self.dtz_label.setText(
f"""<span style="color: {STATUS_ALERT} ; ">{snapped:.0f}</span>"""
)
elif snapped > dtz_max:
self.dtz = dtz_max
self.dtz_label.setText(
f"""<span style="color: {STATUS_ALERT} ; ">{snapped:.0f}</span>"""
)
else:
self.dtz = snapped
self.dtz_label.setText(f"{self.dtz:.0f}")
def update_calculated_labels(self):
if self._d is None:
return
flux = 2.5e11 # TODO link flux
# TODO add start angle
# TODO link beam energy
# TODO read resolution estiamtion from jfjoch
total_angle = self.angular_range_enter.value
d_vis = self.visible_res_enter.value
image_angle = self.image_angle_enter.value
d_vis = d_vis - 0.2 # additional fudge factor that weights more towards high_res
if d_vis <= 0.0:
d_vis = 1.3
d_tar = 1 / (1 / d_vis + 0.1)
total_angle = self.angular_range_enter.value
image_angle = max(self.image_angle_enter.value, 0.001)
# The DAQ resolution estimate is unbiased, so it is the target as-is:
# no additive fudge and no reciprocal-space shift.
d_tar = self.visible_res_enter.value
self.target_res_label.setText(f"{d_tar:.2f}")
Kdose = 2000 / (self._d.diffraction.wavelength_angstrom**2)
kdose = KDOSE_1A_PH_UM2_GY / self._d.diffraction.wavelength_angstrom**2
beam_size_um_y = self._d.geom.beam_size_mm.y * 1000
beam_size_um_x = self._d.geom.beam_size_mm.x * 1000
self.dose_rate_MGy_s = (flux / (beam_size_um_x * beam_size_um_y * Kdose)) / 1e6
if (
self.xtal_y is None
or self.xtal_z is None
or beam_size_um_x is None
or beam_size_um_y is None
):
self.xtal_size_dose_rate_MGy_s = self.dose_rate_MGy_s
elif self.xtal_y > beam_size_um_y or self.xtal_z > beam_size_um_y:
multiplier_1 = max(self.xtal_y, beam_size_um_y)
multiplier_2 = max(self.xtal_z, beam_size_um_y)
new_beam_um_y = math.sqrt(multiplier_1 * multiplier_2)
self.xtal_size_dose_rate_MGy_s = (flux / (beam_size_um_x * new_beam_um_y * Kdose)) / 1e6
beam_size_um_x = self._d.geom.beam_size_mm.x * 1000.0
beam_size_um_y = self._d.geom.beam_size_mm.y * 1000.0
beam_area_um2 = beam_size_um_x * beam_size_um_y
# Flux is reported by the DAQ at 100% transmission.
flux_ph_s = self._d.bl.flux_ph_s
if beam_area_um2 > 0.0 and flux_ph_s > 0.0:
self.dose_rate_MGy_s = flux_ph_s / (beam_area_um2 * kdose) / 1e6
else:
self.xtal_size_dose_rate_MGy_s = self.dose_rate_MGy_s
self.dose_rate_MGy_s = 0.0
if self.temp_enter.value > 250:
self.target_dose_MGy = 0.1 * d_tar / 2.0 # TODO add user input
if self.temp_enter.value > GLASS_TRANSITION_K:
dose_per_angstrom = RT_DOSE_PER_ANGSTROM_MGY
else:
self.target_dose_MGy = 10 * d_tar / 2.0
dose_per_angstrom = CRYO_DOSE_PER_ANGSTROM_MGY
self.target_dose_MGy = dose_per_angstrom * d_tar / DOSE_SAFETY_FACTOR
self.target_dose_label.setText(f"{self.target_dose_MGy:.2f}")
self.calculated_dose_rate_label.setText(f"{self.dose_rate_MGy_s:.2f}")
self.xtal_size_dose_label.setText(f"{self.xtal_size_dose_rate_MGy_s:.2f}")
self.n_images = max(1, round(total_angle / image_angle))
self.total_time_s = self.target_dose_MGy / self.dose_rate_MGy_s
# Beam-on time at 100% transmission that spends exactly the budget.
if self.dose_rate_MGy_s > 0.0:
budget_time_s = self.target_dose_MGy / self.dose_rate_MGy_s
else:
budget_time_s = 0.0
# Floors on a single frame: the detector frame time raised to the
# strategy floor where one is set, and the goniometer top speed at the
# requested oscillation width.
detector_min_s = MIN_EXP_TIME_S.get(self._beamline, DEFAULT_MIN_EXP_TIME_S)
min_exp_time_s = max(
STRATEGY_MIN_EXP_TIME_S.get(self._beamline, detector_min_s),
image_angle / MAX_OMEGA_SPEED_DEG_S,
)
if self._beamline is MXBeamline.X10SA:
# Flux-rich: pin the detector at FIXED_FRAME_RATE_HZ and spend the
# dose budget through transmission.
frame_time_s = max(1.0 / FIXED_FRAME_RATE_HZ, min_exp_time_s)
image_time_s = _ceil_to(frame_time_s, EXP_TIME_STEP_S)
transmission = self._solve_transmission(budget_time_s, image_time_s)
else:
# Flux-limited: hold the beam at 100% and solve for exposure time.
transmission = 1.0
image_time_s = _floor_to(budget_time_s / self.n_images, EXP_TIME_STEP_S)
if image_time_s < min_exp_time_s:
# Cannot expose any shorter, so the balance comes out of
# transmission instead.
image_time_s = _ceil_to(min_exp_time_s, EXP_TIME_STEP_S)
transmission = self._solve_transmission(budget_time_s, image_time_s)
self.image_time_s = image_time_s
self.transmission = transmission
# Wall-clock, not the beam-on budget: this is what the operator waits
# for, and the two differ whenever transmission is below 100%.
self.total_time_s = self.n_images * self.image_time_s
self.update_total_time_label()
self.calculated_dose_label.setText(
f"<b>{self.xtal_size_dose_rate_MGy_s * self.total_time_s:.2f}</b>"
)
if image_angle == 0.0:
image_angle = 0.001
self.n_images = round(total_angle / image_angle)
self.image_time_s = self.total_time_s / self.n_images
if self.image_time_s < 0.0011:
self.transmission = self.image_time_s / 0.0011
self.image_time_s = 0.0011
else:
self.transmission = 1.0
self.delivered_dose_MGy = self.dose_rate_MGy_s * self.transmission * self.total_time_s
self.dtz = self._d.diffraction.calc_dtz_mm(d_tar)
self.dtz = max(self.dtz, 108)
self._update_dtz(d_tar)
self.transmission_label.setText(f"{self.transmission * 100:.1f}")
self.image_time_label.setText(f"{self.image_time_s:.4f}")
self.target_dose_label.setText(f"{self.target_dose_MGy:.2f}")
self.calculated_dose_rate_label.setText(f"{(self.dose_rate_MGy_s * self.transmission):.3f}")
self.transmission_label.setText(f"{self.transmission * 100:.3g}")
self.image_time_label.setText(f"{self.image_time_s:.3f}")
if self.dtz <= 0.0:
self.dtz_label.setText(f"""<span style="color: {STATUS_ALERT} ; ">-</span>""")
elif self.dtz < self._d.bl.dtz_min:
self.dtz_label.setText(
f"""<span style="color: {STATUS_ALERT} ; ">{self.dtz:.2f}</span>"""
if self.delivered_dose_MGy < 0.95 * self.target_dose_MGy:
# Capped at 100% transmission: the crystal cannot absorb its
# budget at this flux, so the data will be weaker than planned.
self.calculated_dose_label.setText(
f"""<span style="color: {STATUS_ALERT} ; ">"""
f"""<b>{self.delivered_dose_MGy:.2f}</b></span>"""
)
self.dtz = self._d.bl.dtz_min
else:
self.dtz_label.setText(f"{self.dtz:.2f}")
self.calculated_dose_label.setText(f"<b>{self.delivered_dose_MGy:.2f}</b>")
self.parameters = SimpleScanParameters(
dtz=round(self.dtz),
@@ -389,15 +511,16 @@ class SimpleRotationSettingsPanel(QWidget):
incr_omega_deg=image_angle,
steps=self.n_images,
transmission=self.transmission,
last_best_b_factor=self._wilson_b,
crystal_size=self.xtal_size,
flux_ph_s=None,
calculated_dose_rate_MGy_s=None,
xtal_size_dose_rate_MGy_s=None,
target_dose_MGy=None,
last_best_res=d_tar,
crystal_size=CrystalSize(x=0, y=0, z=0),
flux_ph_s=flux_ph_s,
calculated_dose_Mgy=self.delivered_dose_MGy,
calculated_dose_rate_MGy_s=self.dose_rate_MGy_s,
target_dose_MGy=self.target_dose_MGy,
dose_rate_MGy_s=self.dose_rate_MGy_s,
beam_size_x_um=beam_size_um_x,
beam_size_y_um=beam_size_um_y,
d_vis=d_vis,
d_vis=d_tar,
d_tar=d_tar,
)
@@ -407,15 +530,19 @@ class SimpleRotationSettingsPanel(QWidget):
@Slot()
def run_measurement(self):
# Send exactly what the panel last calculated and displayed, rather
# than re-reading the widgets: dtz, exposure and transmission are a
# single consistent solution and must not be mixed with a newer entry.
p = self.parameters
r = RotationScanRequest(
file_prefix=str(add_data_to_path(self._filename)),
start_omega_deg=self.start_angle_enter.value,
steps=self.n_images,
incr_omega_deg=self.image_angle_enter.value,
dtz=self.dtz,
transmission=self.transmission,
start_omega_deg=p.start_omega_deg,
steps=p.steps,
incr_omega_deg=p.incr_omega_deg,
dtz=p.dtz,
transmission=p.transmission,
screening=False,
exp_time_s=self.image_time_s,
exp_time_s=p.exp_time_s,
)
self.rotation_scan.emit(r)
self.viewer_track_online.emit()