diff --git a/src/aare/gui/panels/smart_rotation_panel.py b/src/aare/gui/panels/smart_rotation_panel.py
index ff3cfae9..6c3d034f 100644
--- a/src/aare/gui/panels/smart_rotation_panel.py
+++ b/src/aare/gui/panels/smart_rotation_panel.py
@@ -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-1", 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("Å2", 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 109 ph s-1", 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("um2", parent=self), 20, 4)
+ self._layout.addWidget(self.beam_size_label, 11, 1, 1, 3)
+ self._layout.addWidget(QLabel("um2", 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-1", 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"{s.geom.beam_size_mm.x * 1000.0} x {s.geom.beam_size_mm.y * 1000.0}"
)
- self.flux_ph_s = s.bl.flux_ph_s
- self.flux_label.setText(f"{(s.bl.flux_ph_s / 1e9):.2f}")
- if s.diffraction.wavelength_angstrom is None:
- self.wavelength_label.setText("N/A")
+ 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"{s.diffraction.wavelength_angstrom:.3f}")
+ 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} (no raster)"""
+ )
+ 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"""-""")
+ return
+
+ snapped = _floor_to(requested, DTZ_STEP_MM)
+ if snapped < dtz_min:
+ self.dtz = dtz_min
+ self.dtz_label.setText(
+ f"""{snapped:.0f}"""
+ )
+ elif snapped > dtz_max:
+ self.dtz = dtz_max
+ self.dtz_label.setText(
+ f"""{snapped:.0f}"""
+ )
+ 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"{self.xtal_size_dose_rate_MGy_s * self.total_time_s:.2f}"
- )
- 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"""-""")
- elif self.dtz < self._d.bl.dtz_min:
- self.dtz_label.setText(
- f"""{self.dtz:.2f}"""
+ 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""""""
+ f"""{self.delivered_dose_MGy:.2f}"""
)
- self.dtz = self._d.bl.dtz_min
else:
- self.dtz_label.setText(f"{self.dtz:.2f}")
+ self.calculated_dose_label.setText(f"{self.delivered_dose_MGy:.2f}")
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()