From d40b2732b79e188f36d5d4635e3fbb37dbce12ce Mon Sep 17 00:00:00 2001 From: pmockoocy Date: Thu, 7 May 2026 16:45:58 +0200 Subject: [PATCH 1/2] Move dose estimation calculations to mxcubecore. Since each site may define their own way to estimate dose absorbed by the crystal, it is important to move this logic to the backend. This way it becomes much easier to make it accessible for all the members of the collaboration and reduces the friction during subsequent rebases. It has another upside of making it easier for scientists to tinker with the calculations. --- .../core/adapter/dose_estimator_adapter.py | 52 +++++++ ui/src/actions/taskForm.js | 25 ++++ ui/src/components/Tasks/DataCollection.jsx | 134 +++++++++++++++++- .../DoseEstimation/DoseEstimate.module.css | 15 ++ .../Tasks/DoseEstimation/DoseLimitInput.jsx | 119 ++++++++++++++++ .../DoseEstimation/DoseLimitInput.module.css | 30 ++++ ui/src/components/Tasks/asyncValidate.js | 1 - ui/src/components/Tasks/warning.js | 8 +- ui/src/reducers/taskForm.js | 6 +- 9 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 mxcubeweb/core/adapter/dose_estimator_adapter.py create mode 100644 ui/src/components/Tasks/DoseEstimation/DoseEstimate.module.css create mode 100644 ui/src/components/Tasks/DoseEstimation/DoseLimitInput.jsx create mode 100644 ui/src/components/Tasks/DoseEstimation/DoseLimitInput.module.css diff --git a/mxcubeweb/core/adapter/dose_estimator_adapter.py b/mxcubeweb/core/adapter/dose_estimator_adapter.py new file mode 100644 index 000000000..7a827f638 --- /dev/null +++ b/mxcubeweb/core/adapter/dose_estimator_adapter.py @@ -0,0 +1,52 @@ +from typing import ClassVar + +from flask import Flask +from mxcubecore.HardwareObjects.abstract.AbstractDoseEstimator import ( + AbstractDoseEstimator, + DoseEstimateParameters, + DoseEstimation, +) + +from mxcubeweb.core.adapter.adapter_base import AdapterBase +from mxcubeweb.core.models.adaptermodels import HOModel +from mxcubeweb.core.models.configmodels import ResourceHandlerConfigModel + +resource_handler_config = ResourceHandlerConfigModel( + commands=["estimate_dose"], + attributes=["data", "experimental_goals"], +) + + +class DoseEstimatorAdapter(AdapterBase): + """Adapter for AbstractDoseEstimator hardware objects. + + Exposes dose estimation as a callable command and + dose presets as a readable attribute. + """ + + ATTRIBUTES: ClassVar[list[str]] = ["experimental_goals"] + SUPPORTED_TYPES: ClassVar[list[object]] = [AbstractDoseEstimator] + + def __init__( + self, + ho: AbstractDoseEstimator, + role: str, + app: Flask, + ) -> None: + self._type = "DOSEESTIMATOR" + super().__init__(ho, role, app, resource_handler_config) + + def estimate_dose( + self, + params: DoseEstimateParameters, + ) -> DoseEstimation: + return self._ho.estimate_dose(params) + + # Plain `dict` return, type parametrization, i.e. + # dict[str, ExperimentalGoal] would not pass as serialization + # logic for adapter does not support these. + def experimental_goals(self) -> dict: + return self._ho.experimental_goals + + def data(self) -> HOModel: + return HOModel(**self._dict_repr()) diff --git a/ui/src/actions/taskForm.js b/ui/src/actions/taskForm.js index 3783bd19c..40c443fa0 100644 --- a/ui/src/actions/taskForm.js +++ b/ui/src/actions/taskForm.js @@ -1,3 +1,5 @@ +import { sendExecuteCommand } from '../api/hardware-object'; + export function showTaskForm( formName, sampleQueueID = -1, @@ -22,3 +24,26 @@ export function hideTaskParametersForm() { type: 'HIDE_FORM', }; } + +/** + * @typedef {Object} DoseEstimationParameters + * @property {number} num_images + * @property {number} exp_time_s + * @property {number} energy_kev + * @property {number} transmission_pct + * @property {string} experimental_goal + * @property {number} resolution_a + * + * @param {DoseEstimationParameters} doseParams + */ +export function estimateDose(doseParams) { + return async (dispatch) => { + const result = await sendExecuteCommand( + 'doseestimator', + 'dose_estimator', + 'estimate_dose', + doseParams, + ); + dispatch({ type: 'SET_DOSE_ESTIMATE', data: result }); + }; +} diff --git a/ui/src/components/Tasks/DataCollection.jsx b/ui/src/components/Tasks/DataCollection.jsx index 94eb22fac..9218eb6d4 100644 --- a/ui/src/components/Tasks/DataCollection.jsx +++ b/ui/src/components/Tasks/DataCollection.jsx @@ -2,11 +2,15 @@ import React from 'react'; import { Button, ButtonToolbar, Col, Form, Modal, Row } from 'react-bootstrap'; import { connect } from 'react-redux'; -import { formValueSelector, reduxForm } from 'redux-form'; +import { change, formValueSelector, reduxForm } from 'redux-form'; +import { estimateDose } from '../../actions/taskForm'; import { SPACE_GROUPS } from '../../constants'; import { DraggableModal } from '../DraggableModal'; +import TooltipTrigger from '../TooltipTrigger'; import asyncValidate from './asyncValidate'; +import doseStyles from './DoseEstimation/DoseEstimate.module.css'; +import { computeLimit, DoseLimitInput } from './DoseEstimation/DoseLimitInput'; import { CollapsableRows, FieldsHeader, @@ -278,6 +282,55 @@ class DataCollection extends React.Component { /> )} + {this.props.canEstimateDose && ( + + + + + Estimated dose (MGy) + + + {this.props.doseEstimate?.status === 'ok' && ( + + this.props.doseLimit + ? doseStyles.over + : doseStyles.under + } + > + {this.props.doseEstimate.dose_mgy?.toFixed(2)} + + )} + {this.props.doseEstimate?.status === 'error' && ( + + N/A + + )} + {!this.props.doseEstimate && ( + N/A + )} + + + + { + this.props.change('dose_limit', limit); + }} + onGoalChange={(goal) => + this.props.change('exp_goal', goal ?? null) + } + /> + + )} @@ -352,12 +405,78 @@ const DataCollectionForm = reduxForm({ validate, asyncValidate, warn, + shouldWarn: ({ values, nextProps, props, initialRender, structure }) => { + // Default behaviour + recomputing warnings on estimate changes. + if (initialRender) { + return true; + } + if (!structure.deepEqual(values, nextProps.values)) { + return true; + } + return props.doseEstimate !== nextProps.doseEstimate; + }, + onChange: (values, dispatch, props, previousValues) => { + // when using dose estimation, changes to form fields + // may trigger a need to reestimate. + // this handled computations of dependent fields for + // dose and dose limit. + if (!props.canEstimateDose) { + return; + } + const estimateTriggers = [ + 'energy', + 'transmission', + 'num_images', + 'exp_time', + 'resolution', + 'exp_goal', + 'dose_limit', + ]; + const estimateChanged = estimateTriggers.some( + (field) => values[field] !== previousValues[field], + ); + if (estimateChanged) { + dispatch( + estimateDose({ + num_images: Number.parseInt(values.num_images, 10), + energy_kev: Number.parseFloat(values.energy), + transmission_pct: Number.parseFloat(values.transmission), + exp_time_s: Number.parseFloat(values.exp_time), + resolution_a: Number.parseFloat(values.resolution), + experimental_goal: values?.exp_goal, + dose_limit_mgy: Number.parseFloat(values?.dose_limit), + }), + ); + } + + const currentGoal = props.goals?.[values.exp_goal]; + if ( + values.resolution !== previousValues.resolution && + currentGoal?.type === 'resolution_dependent' + ) { + dispatch( + change( + 'datacollection', + 'dose_limit', + computeLimit(currentGoal, values.resolution), + ), + ); + } + }, })(DataCollection); const selector = formValueSelector('datacollection'); export default connect((state) => { + const { doseEstimate } = state.taskForm; + + const doseEstimatorHO = state.beamline.hardwareObjects?.dose_estimator; const subdir = selector(state, 'subdir'); + const canEstimateDose = !!doseEstimatorHO; + + const goals = doseEstimatorHO?.attributes?.experimental_goals || {}; + + const resolution = Number.parseFloat(toFixed(state, 'resolution')); let position = state.taskForm.pointID === '' ? 'PX' : state.taskForm.pointID; if (typeof position === 'object') { @@ -379,6 +498,9 @@ export default connect((state) => { state.taskForm.defaultParameters[type.toLowerCase()]; const { parameters } = state.taskForm.taskData; + const doseLimit = selector(state, 'dose_limit'); + const currentGoalId = selector(state, 'exp_goal'); + if (Number.parseFloat(parameters.osc_range) === 0) { parameters.osc_range = state.taskForm.defaultParameters[ @@ -404,11 +526,21 @@ export default connect((state) => { filename: fname, acqParametersLimits: limits, beamline: state.beamline, + canEstimateDose, + goals, + resolution, + doseEstimate, + doseLimit, + currentGoalId, detector_mode_list: acq_parameters.detector_mode_list, components: state.uiproperties.sample_view_motors.components, initialValues: { ...parameters, beam_size: state.sampleview.currentAperture, + dose_limit: Object.values(goals)[0] + ? computeLimit(Object.values(goals)[0], resolution) + : undefined, + exp_goal: Object.keys(goals)[0], resolution: toFixed(state, 'resolution'), energy: toFixed(state, 'energy'), transmission: toFixed(state, 'transmission'), diff --git a/ui/src/components/Tasks/DoseEstimation/DoseEstimate.module.css b/ui/src/components/Tasks/DoseEstimation/DoseEstimate.module.css new file mode 100644 index 000000000..15ba36295 --- /dev/null +++ b/ui/src/components/Tasks/DoseEstimation/DoseEstimate.module.css @@ -0,0 +1,15 @@ +.unavailable { + text-decoration: underline dashed; + cursor: help; + color: var(--bs-danger); +} + +.over { + color: var(--bs-danger); + font-weight: 600; +} + +.under { + color: var(--bs-success); + font-weight: 600; +} diff --git a/ui/src/components/Tasks/DoseEstimation/DoseLimitInput.jsx b/ui/src/components/Tasks/DoseEstimation/DoseLimitInput.jsx new file mode 100644 index 000000000..ee7353f96 --- /dev/null +++ b/ui/src/components/Tasks/DoseEstimation/DoseLimitInput.jsx @@ -0,0 +1,119 @@ +import { useState } from 'react'; +import { Col, Form, ListGroup, Row } from 'react-bootstrap'; + +import styles from './DoseLimitInput.module.css'; + +/** + * @typedef {Object} StaticGoal + * @property {'static'} type + * @property {string} label + * @property {number} mgy - Fixed dose-limit value in MGy. + */ + +/** + * @typedef {Object} ResolutionDependentGoal + * @property {'resolution_dependent'} type + * @property {string} label + * @property {number} mgy_per_angstrom - Multiplied by the current resolution + * (Å) to derive the dose-limit in MGy. + */ + +/** @typedef {StaticGoal | ResolutionDependentGoal} ExperimentalGoal */ + +/** + * Resolves a goal's dose-limit (MGy). + * + * Resolution-dependent goals scale linearly with the user's resolution + * input. + * + * @param {ExperimentalGoal} goal + * @param {string | number} resolution - Current resolution in Å. + * @returns {number} + */ +export function computeLimit(goal, resolution) { + if (goal.type === 'static') { + return goal.mgy; + } + return Number.parseFloat(resolution) * goal.mgy_per_angstrom; +} + +/** + * @typedef {Object} DoseLimitInputProps + * @property {string} currentGoalId - id of the current experimental goal. + * @property {Object.} goals - dose limit presets. + * @property {(string | null) => void} onGoalChange - callback for experimental goal changes. + * @property {number | undefined} value - Current dose-limit value + * from the form. + * @property {(limitMgy: number | undefined) => void} onLimitChange - Invoked on change of + * dose limit value. + * @property {string | number} resolution - Current resolution (Å), used to + * compute the limit for `resolution_dependent` goals. + */ + +/** + * Combobox for selecting an experimental dose-limit preset. + * + * @param {DoseLimitInputProps} props + */ +export function DoseLimitInput({ + currentGoalId, + goals, + onGoalChange, + value, + onLimitChange, + resolution, +}) { + const [showList, setShowList] = useState(false); + + return ( + + + +
Dose limit (MGy)
+ {goals[currentGoalId] && ( +
{goals[currentGoalId].label}
+ )} +
+ +
+ { + onLimitChange(event.target.value); + onGoalChange(null); + }} + onClick={() => setShowList(true)} + onFocus={() => setShowList(true)} + onBlur={() => { + setShowList(false); + }} + /> + {showList && ( + + {Object.entries(goals).map(([goalId, goal]) => ( + event.preventDefault()} + onClick={() => { + const newLimit = computeLimit(goal, resolution); + onLimitChange(newLimit); + onGoalChange(goalId); + }} + > +
+
{goal.label}
+
+ {computeLimit(goal, resolution).toFixed(2)} +
+
+
+ ))} +
+ )} +
+ +
+
+ ); +} diff --git a/ui/src/components/Tasks/DoseEstimation/DoseLimitInput.module.css b/ui/src/components/Tasks/DoseEstimation/DoseLimitInput.module.css new file mode 100644 index 000000000..d1631bba4 --- /dev/null +++ b/ui/src/components/Tasks/DoseEstimation/DoseLimitInput.module.css @@ -0,0 +1,30 @@ +.wrapper { + position: relative; +} + +.list { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 10; +} + +.list :global(.list-group-item) { + padding: 0.25rem; +} + +.list :global(.list-group-item):hover { + background-color: var(--bs-primary); + cursor: pointer; +} + +.goalLabel { + font-size: 0.75rem; + color: var(--bs-secondary-color); +} + +.goalValue { + font-size: 1rem; + color: inherit; +} diff --git a/ui/src/components/Tasks/asyncValidate.js b/ui/src/components/Tasks/asyncValidate.js index d899c4a72..829f23448 100644 --- a/ui/src/components/Tasks/asyncValidate.js +++ b/ui/src/components/Tasks/asyncValidate.js @@ -15,7 +15,6 @@ async function get_resolution_limits_for_energy(energy) { async function asyncValidate(values, _d, props) { const errors = {}; - if (!props.beamline) { // for some reason redux-form is loaded before the initial status return errors; diff --git a/ui/src/components/Tasks/warning.js b/ui/src/components/Tasks/warning.js index c502bbc7f..574acab84 100644 --- a/ui/src/components/Tasks/warning.js +++ b/ui/src/components/Tasks/warning.js @@ -72,7 +72,13 @@ function warn(values, props) { 5 ) { warnings.osc_range = - 'The given oscillation range might be to large for this centring'; + 'The given oscillation range might be to large for this centering'; + } + + const numImages = Number.parseInt(values.num_images, 10); + const maxImages = props.doseEstimate?.max_images; + if (Number.isFinite(maxImages) && numImages > maxImages) { + warnings.num_images = `Exceeds max images (${maxImages}) at current settings`; } if (!values.prefix) { diff --git a/ui/src/reducers/taskForm.js b/ui/src/reducers/taskForm.js index 6a3725347..646d3fa0e 100644 --- a/ui/src/reducers/taskForm.js +++ b/ui/src/reducers/taskForm.js @@ -5,6 +5,7 @@ const INITIAL_STATE = { showForm: '', path: '', fileSuffix: '', + doseEstimate: null, defaultParameters: { datacollection: {}, characterisation: {}, @@ -29,7 +30,10 @@ function taskFormReducer(state = INITIAL_STATE, action = {}) { }; } case 'HIDE_FORM': { - return { ...state, showForm: '' }; + return { ...state, showForm: '', doseEstimate: null }; + } + case 'SET_DOSE_ESTIMATE': { + return { ...state, doseEstimate: action.data }; } case 'SET_INITIAL_STATE': { return { From 7173b00fc39fcffa232cff2aaacd3e488b0cfb49 Mon Sep 17 00:00:00 2001 From: mockoocy Date: Mon, 3 Aug 2026 13:22:46 +0200 Subject: [PATCH 2/2] Plug MockupDoseEstimator into demo beamline. Includes removal of unused properties from dose estimation adapter. --- demo.yaml/beamline.yaml | 1 + demo.yaml/dose_estimator.yaml | 14 ++++++++++++++ mxcubeweb/core/adapter/dose_estimator_adapter.py | 2 -- ui/src/components/Tasks/warning.js | 1 + 4 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 demo.yaml/dose_estimator.yaml diff --git a/demo.yaml/beamline.yaml b/demo.yaml/beamline.yaml index b86530c90..fb1fa6aa3 100644 --- a/demo.yaml/beamline.yaml +++ b/demo.yaml/beamline.yaml @@ -48,6 +48,7 @@ objects: - sample_view: sample_view.yaml - queue_manager: queue_manager.yaml - queue_model: queue_model.yaml + - dose_estimator: dose_estimator.yaml # Procedures: - beamline_actions: beamline_actions.yaml diff --git a/demo.yaml/dose_estimator.yaml b/demo.yaml/dose_estimator.yaml new file mode 100644 index 000000000..987dae629 --- /dev/null +++ b/demo.yaml/dose_estimator.yaml @@ -0,0 +1,14 @@ +class: mxcubecore.HardwareObjects.mockup.dose_estimator.MockupDoseEstimator +configuration: + dose_rate_mgy_per_s: 1.0 + error_probability: 0.1 + experimental_goals: + cryo_highres: + label: Cryo High Resolution + type: resolution_dependent + mgy_per_angstrom: 10 + room: + label: Room Temperature + type: static + mgy: 0.2 + diff --git a/mxcubeweb/core/adapter/dose_estimator_adapter.py b/mxcubeweb/core/adapter/dose_estimator_adapter.py index 7a827f638..406da3d4d 100644 --- a/mxcubeweb/core/adapter/dose_estimator_adapter.py +++ b/mxcubeweb/core/adapter/dose_estimator_adapter.py @@ -24,7 +24,6 @@ class DoseEstimatorAdapter(AdapterBase): dose presets as a readable attribute. """ - ATTRIBUTES: ClassVar[list[str]] = ["experimental_goals"] SUPPORTED_TYPES: ClassVar[list[object]] = [AbstractDoseEstimator] def __init__( @@ -33,7 +32,6 @@ def __init__( role: str, app: Flask, ) -> None: - self._type = "DOSEESTIMATOR" super().__init__(ho, role, app, resource_handler_config) def estimate_dose( diff --git a/ui/src/components/Tasks/warning.js b/ui/src/components/Tasks/warning.js index 574acab84..1988b98b4 100644 --- a/ui/src/components/Tasks/warning.js +++ b/ui/src/components/Tasks/warning.js @@ -1,3 +1,4 @@ +/* eslint-disable complexity*/ function warn(values, props) { const warnings = {}; if (!props.beamline.hardwareObjects) {