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 new file mode 100644 index 000000000..406da3d4d --- /dev/null +++ b/mxcubeweb/core/adapter/dose_estimator_adapter.py @@ -0,0 +1,50 @@ +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. + """ + + SUPPORTED_TYPES: ClassVar[list[object]] = [AbstractDoseEstimator] + + def __init__( + self, + ho: AbstractDoseEstimator, + role: str, + app: Flask, + ) -> None: + 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..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) { @@ -72,7 +73,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 {