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()