diff --git a/api-client/src/runs/getRunCurrentState.ts b/api-client/src/runs/getRunCurrentState.ts new file mode 100644 index 00000000000..6a64d9b9e0f --- /dev/null +++ b/api-client/src/runs/getRunCurrentState.ts @@ -0,0 +1,17 @@ +import { GET, request } from '../request' + +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' +import type { RunCurrentState } from './types' + +export function getRunCurrentState( + config: HostConfig, + runId: string +): ResponsePromise { + return request( + GET, + `/runs/${runId}/currentState`, + null, + config + ) +} diff --git a/api-client/src/runs/index.ts b/api-client/src/runs/index.ts index 9f314f4b025..183b8f7e4d4 100644 --- a/api-client/src/runs/index.ts +++ b/api-client/src/runs/index.ts @@ -10,6 +10,7 @@ export { getCommands } from './commands/getCommands' export { getCommandsAsPreSerializedList } from './commands/getCommandsAsPreSerializedList' export { createRunAction } from './createRunAction' export { getRunCommandErrors } from './commands/getRunCommandErrors' +export { getRunCurrentState } from './getRunCurrentState' export * from './createLabwareOffset' export * from './createLabwareDefinition' export * from './constants' diff --git a/api-client/src/runs/types.ts b/api-client/src/runs/types.ts index 5998259ae50..241a3892622 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -87,10 +87,24 @@ export interface Run { data: RunData } +export interface RunCurrentState { + data: RunCurrentStateData + links: RunCommandLink +} + export interface RunsLinks { current?: ResourceLink } +export interface RunCommandLink { + current: CommandLinkNoMeta +} + +export interface CommandLinkNoMeta { + id: string + href: string +} + export interface GetRunsParams { pageLength?: number // the number of items to include } @@ -100,6 +114,10 @@ export interface Runs { links: RunsLinks } +export interface RunCurrentStateData { + activeNozzleLayouts: Record // keyed by pipetteId +} + export const RUN_ACTION_TYPE_PLAY: 'play' = 'play' export const RUN_ACTION_TYPE_PAUSE: 'pause' = 'pause' export const RUN_ACTION_TYPE_STOP: 'stop' = 'stop' @@ -173,3 +191,13 @@ export interface UpdateErrorRecoveryPolicyRequest { } export type UpdateErrorRecoveryPolicyResponse = Record + +/** + * Current Run State Data + */ +export interface NozzleLayoutValues { + startingNozzle: string + activeNozzles: string[] + config: NozzleLayoutConfig +} +export type NozzleLayoutConfig = 'column' | 'row' | 'full' | 'subrect' diff --git a/api/src/opentrons/protocol_engine/state/tips.py b/api/src/opentrons/protocol_engine/state/tips.py index a2c75ba2af4..7b50b291f4d 100644 --- a/api/src/opentrons/protocol_engine/state/tips.py +++ b/api/src/opentrons/protocol_engine/state/tips.py @@ -486,6 +486,10 @@ def get_pipette_nozzle_map(self, pipette_id: str) -> NozzleMap: """Get the current nozzle map the given pipette's configuration.""" return self._state.nozzle_map_by_pipette_id[pipette_id] + def get_pipette_nozzle_maps(self) -> Dict[str, NozzleMap]: + """Get current nozzle maps keyed by pipette id.""" + return self._state.nozzle_map_by_pipette_id + def has_clean_tip(self, labware_id: str, well_name: str) -> bool: """Get whether a well in a labware has a clean tip. diff --git a/api/src/opentrons/protocol_runner/run_orchestrator.py b/api/src/opentrons/protocol_runner/run_orchestrator.py index 9bb5c330788..697e4a14e3a 100644 --- a/api/src/opentrons/protocol_runner/run_orchestrator.py +++ b/api/src/opentrons/protocol_runner/run_orchestrator.py @@ -14,6 +14,7 @@ from . import protocol_runner, RunResult, JsonRunner, PythonAndLegacyRunner from ..hardware_control import HardwareControlAPI from ..hardware_control.modules import AbstractModule as HardwareModuleAPI +from ..hardware_control.nozzle_manager import NozzleMap from ..protocol_engine import ( ProtocolEngine, CommandCreate, @@ -397,6 +398,10 @@ def get_deck_type(self) -> DeckType: """Get engine deck type.""" return self._protocol_engine.state_view.config.deck_type + def get_nozzle_maps(self) -> Dict[str, NozzleMap]: + """Get current nozzle maps keyed by pipette id.""" + return self._protocol_engine.state_view.tips.get_pipette_nozzle_maps() + def set_error_recovery_policy(self, policy: ErrorRecoveryPolicy) -> None: """Create error recovery policy for the run.""" self._protocol_engine.set_error_recovery_policy(policy) diff --git a/app/src/molecules/OddModal/OddModalHeader.tsx b/app/src/molecules/OddModal/OddModalHeader.tsx index 5544a14ecbc..5f2a403a8af 100644 --- a/app/src/molecules/OddModal/OddModalHeader.tsx +++ b/app/src/molecules/OddModal/OddModalHeader.tsx @@ -34,7 +34,7 @@ export function OddModalHeader(props: OddModalHeaderBaseProps): JSX.Element { borderRadius={`${BORDERS.borderRadius12} ${BORDERS.borderRadius12} 0px 0px`} {...styleProps} > - + {iconName != null && iconColor != null ? ( @@ -297,30 +295,6 @@ const SHARED_BUTTON_STYLE_ODD = css` width: 29rem; height: 13.5rem; ` -const BTN_STYLE_DISABLED_ODD = css` - ${SHARED_BUTTON_STYLE_ODD} - - background-color: ${COLORS.grey35}; - color: ${COLORS.grey50}; - border: none; - box-shadow: none; - - #btn-icon: { - color: ${COLORS.grey50}; - } - - &:active, - &:focus, - &:hover { - background-color: ${COLORS.grey35}; - color: ${COLORS.grey50}; - } - &:active, - &:focus, - &:hover #btn-icon { - color: ${COLORS.grey50}; - } -` const PRIMARY_BTN_STYLES_DESKTOP = css` background-color: ${COLORS.red50}; diff --git a/components/src/atoms/buttons/LargeButton.tsx b/components/src/atoms/buttons/LargeButton.tsx index 28beb378495..28f3f555373 100644 --- a/components/src/atoms/buttons/LargeButton.tsx +++ b/components/src/atoms/buttons/LargeButton.tsx @@ -1,11 +1,10 @@ import type * as React from 'react' import { css } from 'styled-components' -import { Box, Btn } from '../../primitives' +import { Btn } from '../../primitives' import { BORDERS, COLORS } from '../../helix-design-system' import { RESPONSIVENESS, SPACING, TYPOGRAPHY } from '../../ui-style-constants' -import { LegacyStyledText } from '../../atoms/StyledText' -import { fontSizeBodyLargeSemiBold } from '../../helix-design-system/product/typography' +import { StyledText } from '../StyledText' import { ALIGN_CENTER, ALIGN_FLEX_START, @@ -49,6 +48,8 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { ...buttonProps } = props + const computedDisabled = disabled || ariaDisabled + const LARGE_BUTTON_PROPS_BY_TYPE: Record< LargeButtonTypes, { @@ -155,6 +156,26 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { ? `color: ${LARGE_BUTTON_PROPS_BY_TYPE[style].activeIconColor}` : '' + // In order to keep button sizes consistent and expected, all large button types need an outline. + // The outline color is always the same as the background color unless the background color is uniquely different + // from the outline. + const computedBorderStyle = (): string => { + const borderColor = (): string => { + if (computedDisabled) { + return LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledColor + } else if (buttonType === 'alertStroke') { + return LARGE_BUTTON_PROPS_BY_TYPE[buttonType].defaultColor + } else { + return LARGE_BUTTON_PROPS_BY_TYPE[buttonType].defaultBackgroundColor + } + } + + const calculatedBorderRadius = + buttonType === 'stroke' ? BORDERS.borderRadius2 : BORDERS.borderRadius4 + + return `${calculatedBorderRadius} solid ${borderColor()}` + } + const LARGE_BUTTON_STYLE = css` color: ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType].defaultColor}; background-color: ${ @@ -162,10 +183,10 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { }; cursor: ${CURSOR_POINTER}; padding: ${SPACING.spacing16} ${SPACING.spacing24}; - text-align: ${TYPOGRAPHY.textAlignCenter}; + text-align: ${TYPOGRAPHY.textAlignLeft}; border-radius: ${BORDERS.borderRadiusFull}; align-items: ${ALIGN_CENTER}; - border: ${buttonType === 'stroke' ? `2px solid ${COLORS.blue50}` : 'none'}; + border: ${computedBorderStyle()}; &:active { background-color: ${ @@ -184,7 +205,9 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { }; border: ${ - buttonType === 'stroke' ? `2px solid ${COLORS.blue55}` : 'none' + buttonType === 'stroke' + ? `2px solid ${COLORS.blue55}` + : `${computedBorderStyle()}` }; } @@ -215,33 +238,17 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { padding: ${SPACING.spacing24}; line-height: ${TYPOGRAPHY.lineHeight20}; gap: ${SPACING.spacing60}; - outline: ${BORDERS.borderRadius4} solid - ${ - buttonType === 'alertStroke' && !disabled - ? LARGE_BUTTON_PROPS_BY_TYPE[buttonType].defaultColor - : 'none' - }; - - ${TYPOGRAPHY.pSemiBold} - - #btn-icon: { - color: ${ - disabled - ? LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledIconColor - : LARGE_BUTTON_PROPS_BY_TYPE[buttonType].iconColor - }; - } &:active { background-color: ${ - disabled + computedDisabled ? LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledBackgroundColor : LARGE_BUTTON_PROPS_BY_TYPE[buttonType].activeBackgroundColor }; - ${!disabled && activeColorFor(buttonType)}; + ${!computedDisabled && activeColorFor(buttonType)}; outline: ${BORDERS.borderRadius4} solid ${ - disabled + computedDisabled ? LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledBackgroundColor : LARGE_BUTTON_PROPS_BY_TYPE[buttonType].activeBackgroundColor }; @@ -252,15 +259,15 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { &:focus-visible { background-color: ${ - disabled + computedDisabled ? LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledBackgroundColor : LARGE_BUTTON_PROPS_BY_TYPE[buttonType].focusVisibleBackgroundColor }; - ${!disabled && activeColorFor(buttonType)}; + ${!computedDisabled && activeColorFor(buttonType)}; padding: calc(${SPACING.spacing24} + ${SPACING.spacing2}); - border: ${SPACING.spacing2} solid ${COLORS.transparent}; + border: ${computedBorderStyle()}; outline: ${ - disabled + computedDisabled ? 'none' : `3px solid ${LARGE_BUTTON_PROPS_BY_TYPE[buttonType].focusVisibleOutlineColor}` @@ -276,6 +283,11 @@ export function LargeButton(props: LargeButtonProps): JSX.Element { }; } ` + + const appliedIconColor = computedDisabled + ? LARGE_BUTTON_PROPS_BY_TYPE[buttonType].disabledIconColor + : LARGE_BUTTON_PROPS_BY_TYPE[buttonType].iconColor + return ( - {buttonText} - + {iconName ? ( - - - + ) : null} ) } + +const ICON_STYLE = css` + width: 1.5rem; + height: 1.5rem; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + width: 5rem; + height: 5rem; + } +` diff --git a/components/src/organisms/Toolbox/index.tsx b/components/src/organisms/Toolbox/index.tsx index 566bcf1e4bf..1a6cb435a9e 100644 --- a/components/src/organisms/Toolbox/index.tsx +++ b/components/src/organisms/Toolbox/index.tsx @@ -119,6 +119,7 @@ export function Toolbox(props: ToolboxProps): JSX.Element { + diff --git a/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx b/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx index d945b66e713..29977196a3d 100644 --- a/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx +++ b/protocol-designer/src/molecules/ToggleExpandStepFormField/index.tsx @@ -1,5 +1,6 @@ import { ALIGN_CENTER, + COLORS, DIRECTION_COLUMN, Flex, JUSTIFY_SPACE_BETWEEN, @@ -21,6 +22,7 @@ interface ToggleExpandStepFormFieldProps extends FieldProps { offLabel: string toggleUpdateValue: (value: unknown) => void toggleValue: unknown + caption?: string } export function ToggleExpandStepFormField( props: ToggleExpandStepFormFieldProps @@ -34,9 +36,19 @@ export function ToggleExpandStepFormField( units, toggleUpdateValue, toggleValue, + caption, ...restProps } = props + const onToggleUpdateValue = (): void => { + if (typeof toggleValue === 'boolean') { + toggleUpdateValue(!toggleValue) + } else if (toggleValue === 'engage' || toggleValue === 'disengage') { + const newToggleValue = toggleValue === 'engage' ? 'disengage' : 'engage' + toggleUpdateValue(newToggleValue) + } + } + return ( {title} { - toggleUpdateValue(!toggleValue) + onToggleUpdateValue() }} label={isSelected ? onLabel : offLabel} toggledOn={isSelected} /> - {isSelected ? ( - - ) : null} + + {isSelected ? ( + + ) : null} + {isSelected && caption != null ? ( + + {caption} + + ) : null} + ) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx index 1d268ea0549..b3236eb1e0e 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx @@ -90,7 +90,13 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { getTimelineWarningsForSelectedStep ) const timeline = useSelector(getRobotStateTimeline) - const [toolboxStep, setToolboxStep] = useState(0) + const [toolboxStep, setToolboxStep] = useState( + // progress to step 2 if thermocycler form is populated + formData.thermocyclerFormType === 'thermocyclerProfile' || + formData.thermocyclerFormType === 'thermocyclerState' + ? 1 + : 0 + ) const icon = stepIconsByType[formData.stepType] const ToolsComponent: typeof STEP_FORM_MAP[keyof typeof STEP_FORM_MAP] = get( diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx index 660343de340..7f7afd9702a 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MagnetTools/index.tsx @@ -1,3 +1,95 @@ -export function MagnetTools(): JSX.Element { - return
TODO: wire this up
+import { useSelector } from 'react-redux' +import { useTranslation } from 'react-i18next' +import { + COLORS, + DIRECTION_COLUMN, + Divider, + Flex, + ListItem, + SPACING, + StyledText, +} from '@opentrons/components' +import { MAGNETIC_MODULE_V1 } from '@opentrons/shared-data' +import { + MAX_ENGAGE_HEIGHT_V1, + MAX_ENGAGE_HEIGHT_V2, + MIN_ENGAGE_HEIGHT_V1, + MIN_ENGAGE_HEIGHT_V2, +} from '../../../../../../constants' +import { + getMagnetLabwareEngageHeight, + getMagneticLabwareOptions, +} from '../../../../../../ui/modules/selectors' +import { ToggleExpandStepFormField } from '../../../../../../molecules' +import { getModuleEntities } from '../../../../../../step-forms/selectors' + +import type { StepFormProps } from '../../types' + +export function MagnetTools(props: StepFormProps): JSX.Element { + const { propsForFields, formData } = props + const { t } = useTranslation(['application', 'form', 'protocol_steps']) + const moduleLabwareOptions = useSelector(getMagneticLabwareOptions) + const moduleEntities = useSelector(getModuleEntities) + const defaultEngageHeight = useSelector(getMagnetLabwareEngageHeight) + const moduleModel = moduleEntities[formData.moduleId].model + + const mmUnits = t('units.millimeter') + const isGen1 = moduleModel === MAGNETIC_MODULE_V1 + const engageHeightMinMax = isGen1 + ? t('magnet_height_caption', { + low: MIN_ENGAGE_HEIGHT_V1, + high: MAX_ENGAGE_HEIGHT_V1, + }) + : t('magnet_height_caption', { + low: `${MIN_ENGAGE_HEIGHT_V2} ${mmUnits}`, + high: `${MAX_ENGAGE_HEIGHT_V2} ${mmUnits}`, + }) + const engageHeightDefault = + defaultEngageHeight != null + ? isGen1 + ? t('magnet_recommended', { default: defaultEngageHeight }) + : t('magnet_recommended', { + default: `${defaultEngageHeight} ${mmUnits}`, + }) + : '' + const engageHeightCaption = `${engageHeightMinMax} ${engageHeightDefault}` + + return ( + + + + {t('protocol_steps:module')} + + + + + {moduleLabwareOptions[0].name} + + + + + + + + + + ) } diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLabwareTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLabwareTools/index.tsx index 1d654ad0cec..fa71de6a2b4 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLabwareTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/MoveLabwareTools/index.tsx @@ -40,7 +40,6 @@ export function MoveLabwareTools(props: StepFormProps): JSX.Element { } /> ) : null} - > + setShowCreateNewCycle: React.Dispatch> + step?: ThermocyclerCycleType + backgroundColor?: string + readOnly?: boolean + setIsInEdit: React.Dispatch> +} + +export function ThermocyclerCycle(props: ThermocyclerCycleProps): JSX.Element { + const { + setShowCreateNewCycle, + step, + steps, + setSteps, + backgroundColor = COLORS.grey30, + setIsInEdit, + readOnly = true, + } = props + const { i18n, t } = useTranslation(['application', 'form']) + const [hover, setHover] = useState(false) + const [showEdit, setShowEditCurrentCycle] = useState(!readOnly) + + const [orderedCycleStepIds, setOrderedCycleStepIds] = useState( + step?.steps.map(cycleStep => cycleStep.id) ?? [] + ) + const [cycleStepsById, setCycleStepsById] = useState( + step?.steps.reduce>( + (acc, { id, title, temperature, durationMinutes, durationSeconds }) => { + return { + ...acc, + [id]: { + name: { value: title ?? null, error: null }, + temp: { + value: temperature ?? null, + error: null, + wasAccessed: false, + }, + time: { + value: + durationMinutes != null && durationSeconds != null + ? `${durationMinutes}:${durationSeconds}` + : null, + error: null, + wasAccessed: false, + }, + }, + } + }, + {} + ) ?? {} + ) + const [repetitions, setRepetitions] = useState( + step?.repetitions + ) + + const cycleId = step?.id ?? null + const isStepStateError = + Object.values(cycleStepsById).some(cycleStep => + Object.values(cycleStep).some( + ({ value, error }) => value == null || value === '' || error != null + ) + ) || + repetitions == null || + repetitions === '' + + const blankStep: CycleStepType = { + name: { + value: null, + error: null, + }, + temp: { + value: null, + error: null, + }, + time: { + value: null, + error: null, + }, + } + + useEffect(() => { + if (orderedCycleStepIds.length === 0) { + // prepopulate with blank step on mount if not editing + handleAddCycleStep() + setIsInEdit(true) + } + }, []) + + const handleAddCycleStep = (): void => { + const newStepId = uuid() + setOrderedCycleStepIds([...orderedCycleStepIds, newStepId]) + setCycleStepsById({ ...cycleStepsById, [newStepId]: blankStep }) + } + + const handleDeleteStep = (stepId: string): void => { + const filteredOrdredCycleStepIds = orderedCycleStepIds.filter( + id => id !== stepId + ) + setOrderedCycleStepIds(filteredOrdredCycleStepIds) + setCycleStepsById( + filteredOrdredCycleStepIds.reduce((acc, id) => { + return id !== stepId + ? { + ...acc, + [id]: cycleStepsById[id], + } + : acc + }, {}) + ) + } + const handleDeleteCycle = (): void => { + if (cycleId != null) { + setSteps( + steps.filter((s: any) => { + return s.id !== cycleId + }) + ) + } else { + setShowCreateNewCycle(false) + } + setIsInEdit(false) + } + const handleValueUpdate = ( + stepId: string, + field: 'name' | 'temp' | 'time', + value: string, + errorCheck?: (value: any) => string | null + ): void => { + setCycleStepsById({ + ...cycleStepsById, + [stepId]: { + ...cycleStepsById[stepId], + [field]: { + value, + error: errorCheck?.(value) ?? null, + }, + }, + }) + } + const handleSaveCycle = (): void => { + const orderedCycleSteps = orderedCycleStepIds.map(cycleStepId => { + const step = cycleStepsById[cycleStepId] + const { minutes, seconds } = getTimeFromString(step.time.value ?? '') + const cycleStepData: ThermocyclerStepType = { + durationMinutes: minutes, + durationSeconds: seconds, + id: cycleStepId, + temperature: step.temp.value ?? '', + title: step.name.value ?? '', + type: 'profileStep', + } + return cycleStepData + }) + const cycleData: ThermocyclerCycleType = { + id: cycleId ?? uuid(), + title: '', + steps: orderedCycleSteps, + type: 'profileCycle', + repetitions: repetitions ?? '', + } + const existingCycleIndex = steps.findIndex(step => step.id === cycleId) + if (existingCycleIndex >= 0) { + // editing a cycle that was already created + setSteps([ + ...steps.slice(0, existingCycleIndex), + cycleData, + ...steps.slice(existingCycleIndex + 1), + ]) + } else { + // append to end of steps + setSteps([...steps, cycleData]) + } + setShowCreateNewCycle(false) + setShowEditCurrentCycle(false) + setIsInEdit(false) + } + + const header = showEdit ? ( + + + + {cycleId != null ? getStepIndex(steps, cycleId) : steps.length + 1} + + + {i18n.format( + t('form:step_edit_form.field.thermocyclerProfile.cycle'), + 'capitalize' + )} + + + + + + {i18n.format( + t('form:step_edit_form.field.thermocyclerProfile.delete'), + 'capitalize' + )} + + + + + {i18n.format(t('save'), 'capitalize')} + + + + + ) : ( + { + setHover(true) + }} + onMouseLeave={() => { + setHover(false) + }} + > + + + {getStepIndex(steps, cycleId ?? '')} + + + {i18n.format( + t('form:step_edit_form.field.thermocyclerProfile.cycles', { + repetitions, + }), + 'capitalize' + )} + + + + {hover ? ( + { + setShowEditCurrentCycle(true) + setIsInEdit(true) + }} + > + + {i18n.format(t('edit'), 'capitalize')} + + + ) : null} + + + + + + ) + const bodyContent = ( + { + setHover(true) + }} + onMouseLeave={() => { + setHover(false) + }} + > + + {orderedCycleStepIds.map((cycleStepId, cycleStepIndex) => { + const stepState = cycleStepsById[cycleStepId] + return showEdit ? ( + + + ) => { + handleValueUpdate( + cycleStepId, + 'name', + e.target.value as string + ) + }} + /> + + + ) => { + handleValueUpdate( + cycleStepId, + 'temp', + maskToFloat(e.target.value), + temperatureRangeFieldValue(4, 96) + ) + }} + onBlur={() => { + setCycleStepsById({ + ...cycleStepsById, + [cycleStepId]: { + ...stepState, + temp: { + ...stepState.temp, + wasAccessed: true, + }, + }, + }) + }} + error={ + stepState.temp.wasAccessed ? stepState.temp.error : null + } + /> + + + ) => { + handleValueUpdate( + cycleStepId, + 'time', + maskToTime(e.target.value), + isTimeFormatMinutesSeconds + ) + }} + onBlur={() => { + setCycleStepsById({ + ...cycleStepsById, + [cycleStepId]: { + ...stepState, + time: { + ...stepState.time, + wasAccessed: true, + }, + }, + }) + }} + error={ + stepState.time.wasAccessed ? stepState.time.error : null + } + /> + + { + handleDeleteStep(cycleStepId) + }} + alignSelf={ALIGN_CENTER} + > + + + + ) : ( + + {`${getStepIndex(steps, cycleId ?? '')}.${ + cycleStepIndex + 1 + }`} + {`${ + stepState.name.value + }, ${stepState.temp.value}${t('units.degrees')}, ${ + stepState.time.value + }, `} + + ) + })} + + {showEdit ? ( + <> + + + {i18n.format( + t( + 'form:step_edit_form.field.thermocyclerProfile.add_cycle_step' + ), + 'capitalize' + )} + + + { + setRepetitions(maskToInteger(e.target.value)) + }} + /> + + ) : null} + + ) + + return ( + + {header} + {bodyContent} + + ) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerProfileModal.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerProfileModal.tsx index 328125317e2..cb816041010 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerProfileModal.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerProfileModal.tsx @@ -13,12 +13,18 @@ import { StyledText, } from '@opentrons/components' +import { ThermocyclerCycle } from './ThermocyclerCycle' import { ThermocyclerStep } from './ThermocyclerStep' import type { FormData } from '../../../../../../form-types' import type { FieldPropsByName } from '../../types' +import type { ThermocyclerCycleType } from './ThermocyclerCycle' import type { ThermocyclerStepType } from './ThermocyclerStep' +export type ThermocyclerStepTypeGeneral = + | ThermocyclerCycleType + | ThermocyclerStepType + interface ThermocyclerModalProps { formData: FormData propsForFields: FieldPropsByName @@ -33,10 +39,11 @@ export function ThermocyclerProfileModal( const [showCreateNewStep, setShowCreateNewStep] = useState(false) const [showCreateNewCycle, setShowCreateNewCycle] = useState(false) - const [steps, setSteps] = useState( + const [isInEdit, setIsInEdit] = useState(false) + const [steps, setSteps] = useState( formData.orderedProfileItems.map( (id: string) => formData.profileItemsById[id] - ) as ThermocyclerStepType[] + ) as ThermocyclerStepTypeGeneral[] ) const canAddStepOrProfile = !(showCreateNewCycle || showCreateNewStep) @@ -71,7 +78,10 @@ export function ThermocyclerProfileModal( {i18n.format(t('cancel'), 'capitalize')} - + {i18n.format(t('save'), 'capitalize')} @@ -111,24 +121,43 @@ export function ThermocyclerProfileModal( ) : ( // TODO (nd: 10/1/2024): add add profile cycle component - <>TODO: wire up cycle + ) })} {showCreateNewStep ? ( + ) : null} + {showCreateNewCycle ? ( + ) : null} - {showCreateNewCycle ? <>TODO: wire up cycle : null} ) : ( > - setShowCreateNewStep: (_: boolean) => void + steps: ThermocyclerStepTypeGeneral[] + setSteps: React.Dispatch> + setShowCreateNewStep: React.Dispatch> + setIsInEdit: React.Dispatch> step?: ThermocyclerStepType backgroundColor?: string - showHeader?: boolean readOnly?: boolean } export function ThermocyclerStep(props: ThermocyclerStepProps): JSX.Element { @@ -54,21 +56,22 @@ export function ThermocyclerStep(props: ThermocyclerStepProps): JSX.Element { steps, setSteps, backgroundColor = COLORS.grey30, - showHeader = true, readOnly = true, + setIsInEdit, } = props const { i18n, t } = useTranslation(['application', 'form']) const [hover, setHover] = useState(false) const [showEdit, setShowEditCurrentStep] = useState(!readOnly) const [stepState, setStepState] = useState({ name: { value: step?.title, error: null }, - temp: { value: step?.temperature, error: null }, + temp: { value: step?.temperature, error: null, wasAccessed: false }, time: { value: step?.durationMinutes != null && step?.durationSeconds != null ? `${step.durationMinutes}:${step.durationSeconds}` : undefined, error: null, + wasAccessed: false, }, }) const id = step?.id ?? null @@ -86,6 +89,7 @@ export function ThermocyclerStep(props: ThermocyclerStepProps): JSX.Element { } else { setShowCreateNewStep(false) } + setIsInEdit(false) } const handleValueUpdate = ( field: 'name' | 'temp' | 'time', @@ -101,14 +105,13 @@ export function ThermocyclerStep(props: ThermocyclerStepProps): JSX.Element { }) } const handleSaveStep = (): void => { - const stepId = uuid() const { minutes, seconds } = getTimeFromString(stepState.time.value ?? '') - const stepBaseData = { + const stepBaseData: ThermocyclerStepType = { durationMinutes: minutes, durationSeconds: seconds, - id: stepId, - temperature: stepState.temp.value, - title: stepState.name.value, + id: id ?? '', + temperature: stepState.temp.value ?? '', + title: stepState.name.value ?? '', type: 'profileStep', } @@ -117,14 +120,15 @@ export function ThermocyclerStep(props: ThermocyclerStepProps): JSX.Element { // editing a step already in steps setSteps([ ...steps.slice(0, existingStepIndex), - { ...stepBaseData, id }, + { ...stepBaseData, id: id ?? uuid() }, ...steps.slice(existingStepIndex + 1), ]) } else { - setSteps([...steps, { ...stepBaseData, id: stepId }]) + setSteps([...steps, { ...stepBaseData, id: uuid() }]) } setShowCreateNewStep(false) setShowEditCurrentStep(false) + setIsInEdit(false) } const header = showEdit ? ( @@ -207,6 +211,7 @@ export function ThermocyclerStep(props: ThermocyclerStepProps): JSX.Element { textDecoration={TYPOGRAPHY.textDecorationUnderline} onClick={() => { setShowEditCurrentStep(true) + setIsInEdit(true) }} > @@ -269,7 +274,13 @@ export function ThermocyclerStep(props: ThermocyclerStepProps): JSX.Element { temperatureRangeFieldValue(4, 96) ) }} - error={stepState.temp.error} + onBlur={() => { + setStepState({ + ...stepState, + temp: { ...stepState.temp, wasAccessed: true }, + }) + }} + error={stepState.temp.wasAccessed ? stepState.temp.error : null} /> { + setStepState({ + ...stepState, + time: { ...stepState.time, wasAccessed: true }, + }) + }} + error={stepState.time.wasAccessed ? stepState.time.error : null} /> @@ -303,7 +320,7 @@ export function ThermocyclerStep(props: ThermocyclerStepProps): JSX.Element { backgroundColor={backgroundColor} borderRadius={BORDERS.borderRadius4} > - {showHeader ? header : null} + {header} {showEdit ? editContent : null} ) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/index.tsx index 071006c4d3f..834306b7b76 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/index.tsx @@ -15,18 +15,13 @@ import { ThermocyclerState } from './ThermocyclerState' import type { StepFormProps } from '../../types' -type ThermocyclerContentType = - | 'thermocyclerState' - | 'thermocyclerProfile' - | null +type ThermocyclerContentType = 'thermocyclerState' | 'thermocyclerProfile' export function ThermocyclerTools(props: StepFormProps): JSX.Element { const { propsForFields, formData, toolboxStep } = props const { t } = useTranslation('form') - console.log(formData) const [contentType, setContentType] = useState( - // (formData.thermocyclerFormType ?? null) as ThermocyclerContentType (formData.thermocyclerFormType as ThermocyclerContentType) ?? 'thermocyclerState' ) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/MagnetTools.test.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/MagnetTools.test.tsx new file mode 100644 index 00000000000..968c523977e --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/__tests__/MagnetTools.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, vi, beforeEach, expect } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' +import { renderWithProviders } from '../../../../../../__testing-utils__' +import { i18n } from '../../../../../../assets/localization' +import { + getMagneticLabwareOptions, + getMagnetLabwareEngageHeight, +} from '../../../../../../ui/modules/selectors' +import { getModuleEntities } from '../../../../../../step-forms/selectors' +import { MagnetTools } from '../MagnetTools' +import type { ComponentProps } from 'react' +import type * as ModulesSelectors from '../../../../../../ui/modules/selectors' + +vi.mock('../../../../../../step-forms/selectors') + +vi.mock('../../../../../../ui/modules/selectors', async importOriginal => { + const actualFields = await importOriginal() + return { + ...actualFields, + getMagnetLabwareEngageHeight: vi.fn(), + getMagneticLabwareOptions: vi.fn(), + } +}) +const render = (props: ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('MagnetTools', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + formData: { + id: 'magnet', + stepType: 'magnet', + moduleId: 'magnetId', + magnetAction: 'engage', + } as any, + focusHandlers: { + blur: vi.fn(), + focus: vi.fn(), + dirtyFields: [], + focusedField: null, + }, + toolboxStep: 1, + propsForFields: { + magnetAction: { + onFieldFocus: vi.fn(), + onFieldBlur: vi.fn(), + errorToShow: null, + disabled: false, + name: 'magnetAction', + updateValue: vi.fn(), + value: 'engage', + }, + engageHeight: { + onFieldFocus: vi.fn(), + onFieldBlur: vi.fn(), + errorToShow: null, + disabled: false, + name: 'engage height', + updateValue: vi.fn(), + value: 10, + }, + }, + } + vi.mocked(getMagneticLabwareOptions).mockReturnValue([ + { name: 'mock name', value: 'mockValue' }, + ]) + vi.mocked(getModuleEntities).mockReturnValue({ + magnetId: { + id: 'magnetId', + model: 'magneticModuleV2', + type: 'magneticModuleType', + }, + }) + vi.mocked(getMagnetLabwareEngageHeight).mockReturnValue(null) + }) + + it('renders the text and a switch button for v2', () => { + render(props) + screen.getByText('Module') + screen.getByText('mock name') + screen.getByText('Magnet action') + screen.getByLabelText('Engage') + const toggleButton = screen.getByRole('switch') + screen.getByText('Engage height') + screen.getByText('Must be between -2.5 mm to 25 mm.') + + fireEvent.click(toggleButton) + expect(props.propsForFields.magnetAction.updateValue).toHaveBeenCalled() + }) + it('renders the input text for v1', () => { + vi.mocked(getModuleEntities).mockReturnValue({ + magnetId: { + id: 'magnetId', + model: 'magneticModuleV1', + type: 'magneticModuleType', + }, + }) + render(props) + screen.getByText('Must be between 0 to 45.') + }) +}) diff --git a/react-api-client/src/runs/index.ts b/react-api-client/src/runs/index.ts index 4b04d913487..71e3360a5f9 100644 --- a/react-api-client/src/runs/index.ts +++ b/react-api-client/src/runs/index.ts @@ -1,5 +1,6 @@ export { useAllRunsQuery } from './useAllRunsQuery' export { useRunQuery } from './useRunQuery' +export { useRunCurrentState } from './useRunCurrentState' export { useCreateRunMutation } from './useCreateRunMutation' export { useDeleteRunMutation } from './useDeleteRunMutation' export { useCreateCommandMutation } from './useCreateCommandMutation' diff --git a/react-api-client/src/runs/useRunCurrentState.ts b/react-api-client/src/runs/useRunCurrentState.ts new file mode 100644 index 00000000000..6eb46589e87 --- /dev/null +++ b/react-api-client/src/runs/useRunCurrentState.ts @@ -0,0 +1,28 @@ +import { useQuery } from 'react-query' +import type { AxiosError } from 'axios' +import type { RunCurrentState, HostConfig } from '@opentrons/api-client' +import type { UseQueryOptions, UseQueryResult } from 'react-query' +import { useHost } from '../api' +import { getRunCurrentState } from '@opentrons/api-client' + +export function useRunCurrentState( + runId: string | null, + options: UseQueryOptions = {}, + hostOverride?: HostConfig +): UseQueryResult { + const contextHost = useHost() + const host = + hostOverride != null ? { ...contextHost, ...hostOverride } : contextHost + + return useQuery( + [host, 'runs', runId, 'currentState'], + () => + getRunCurrentState(host as HostConfig, runId as string).then( + response => response.data + ), + { + enabled: host != null && runId != null && options.enabled !== false, + ...options, + } + ) +} diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index 429097c24ab..3b0e7040e02 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -37,6 +37,7 @@ MultiBodyMeta, ResourceLink, PydanticResponse, + Body, ) from robot_server.protocols.dependencies import get_protocol_store @@ -46,11 +47,20 @@ ) from robot_server.protocols.router import ProtocolNotFound -from ..run_models import RunNotFoundError +from ..run_models import ( + RunNotFoundError, + ActiveNozzleLayout, + RunCurrentState, + CommandLinkNoMeta, + NozzleLayoutConfig, +) from ..run_auto_deleter import RunAutoDeleter from ..run_models import Run, BadRun, RunCreate, RunUpdate from ..run_orchestrator_store import RunConflictError -from ..run_data_manager import RunDataManager, RunNotCurrentError +from ..run_data_manager import ( + RunDataManager, + RunNotCurrentError, +) from ..dependencies import ( get_run_data_manager, get_run_auto_deleter, @@ -115,6 +125,15 @@ class AllRunsLinks(BaseModel): ) +class CurrentStateLinks(BaseModel): + """Links returned with the current state of a run.""" + + current: Optional[CommandLinkNoMeta] = Field( + None, + description="Path to the current command when current state was reported, if any.", + ) + + async def get_run_data_from_url( runId: str, run_data_manager: Annotated[RunDataManager, Depends(get_run_data_manager)], @@ -523,3 +542,64 @@ async def get_run_commands_error( ), status_code=status.HTTP_200_OK, ) + + +@PydanticResponse.wrap_route( + base_router.get, + path="/runs/{runId}/currentState", + summary="Get a run's current state.", + description=dedent( + """ + Get current state associated with a run if the run is current. + "\n\n" + Note that this endpoint is experimental and subject to change. + """ + ), + responses={ + status.HTTP_200_OK: {"model": SimpleBody[RunCurrentState]}, + status.HTTP_409_CONFLICT: {"model": ErrorBody[RunStopped]}, + }, +) +async def get_current_state( + runId: str, + run_data_manager: Annotated[RunDataManager, Depends(get_run_data_manager)], +) -> PydanticResponse[Body[RunCurrentState, CurrentStateLinks]]: + """Get current state associated with a run if the run is current. + + Arguments: + runId: Run ID pulled from URL. + run_data_manager: Run data retrieval interface. + """ + try: + active_nozzle_maps = run_data_manager.get_nozzle_maps(run_id=runId) + + nozzle_layouts = { + pipetteId: ActiveNozzleLayout.construct( + startingNozzle=nozzle_map.starting_nozzle, + activeNozzles=list(nozzle_map.map_store.keys()), + config=NozzleLayoutConfig(nozzle_map.configuration.value.lower()), + ) + for pipetteId, nozzle_map in active_nozzle_maps.items() + } + + current_command = run_data_manager.get_current_command(run_id=runId) + except RunNotCurrentError as e: + raise RunStopped(detail=str(e)).as_error(status.HTTP_409_CONFLICT) + + # TODO(jh, 03-11-24): Use `last_completed_command` instead of `current_command` to avoid concurrency gotchas. + links = CurrentStateLinks.construct( + current=CommandLinkNoMeta.construct( + id=current_command.command_id, + href=f"/runs/{runId}/commands/{current_command.command_id}", + ) + if current_command is not None + else None + ) + + return await PydanticResponse.create( + content=Body.construct( + data=RunCurrentState.construct(activeNozzleLayouts=nozzle_layouts), + links=links, + ), + status_code=status.HTTP_200_OK, + ) diff --git a/robot-server/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py index c8d0db1b0d3..b756b185bf1 100644 --- a/robot-server/robot_server/runs/run_data_manager.py +++ b/robot-server/robot_server/runs/run_data_manager.py @@ -1,9 +1,12 @@ """Manage current and historical run data.""" from datetime import datetime -from typing import List, Optional, Callable, Union +from typing import List, Optional, Callable, Union, Dict from opentrons_shared_data.labware.labware_definition import LabwareDefinition from opentrons_shared_data.errors.exceptions import InvalidStoredData, EnumeratedError + +from opentrons.hardware_control.nozzle_manager import NozzleMap + from opentrons.protocol_engine import ( EngineStatus, LabwareOffsetCreate, @@ -473,6 +476,13 @@ def get_command_errors(self, run_id: str) -> list[ErrorOccurrence]: # TODO(tz, 8-5-2024): Change this to return the error list from the DB when we implement https://opentrons.atlassian.net/browse/EXEC-655. raise RunNotCurrentError() + def get_nozzle_maps(self, run_id: str) -> Dict[str, NozzleMap]: + """Get current nozzle maps keyed by pipette id.""" + if run_id == self._run_orchestrator_store.current_run_id: + return self._run_orchestrator_store.get_nozzle_maps() + + raise RunNotCurrentError() + def get_all_commands_as_preserialized_list( self, run_id: str, include_fixit_commands: bool ) -> List[str]: diff --git a/robot-server/robot_server/runs/run_models.py b/robot-server/robot_server/runs/run_models.py index 1860e86d3e8..962c3ab51e7 100644 --- a/robot-server/robot_server/runs/run_models.py +++ b/robot-server/robot_server/runs/run_models.py @@ -1,7 +1,9 @@ """Request and response models for run resources.""" from datetime import datetime + +from enum import Enum from pydantic import BaseModel, Field -from typing import List, Optional, Literal +from typing import List, Optional, Literal, Dict from opentrons.protocol_engine import ( CommandStatus, @@ -280,6 +282,44 @@ class LabwareDefinitionSummary(BaseModel): ) +class NozzleLayoutConfig(str, Enum): + """Possible valid nozzle configurations.""" + + COLUMN = "column" + ROW = "row" + SINGLE = "single" + FULL = "full" + SUBRECT = "subrect" + + +class ActiveNozzleLayout(BaseModel): + """Details about the active nozzle layout for a pipette used in the current run.""" + + startingNozzle: str = Field( + ..., description="The nozzle used when issuing pipette commands." + ) + activeNozzles: List[str] = Field( + ..., + description="A map of all the pipette nozzles active in the current configuration.", + ) + config: NozzleLayoutConfig = Field( + ..., description="The active nozzle configuration." + ) + + +class RunCurrentState(BaseModel): + """Current details about a run.""" + + activeNozzleLayouts: Dict[str, ActiveNozzleLayout] = Field(..., description="") + + +class CommandLinkNoMeta(BaseModel): + """A link to a command resource without a meta field.""" + + id: str = Field(..., description="The ID of the command.") + href: str = Field(..., description="The HTTP API path to the command.") + + class RunNotFoundError(GeneralError): """Error raised when a given Run ID is not found in the store.""" diff --git a/robot-server/robot_server/runs/run_orchestrator_store.py b/robot-server/robot_server/runs/run_orchestrator_store.py index 2e8d8146dd8..46a384b96ea 100644 --- a/robot-server/robot_server/runs/run_orchestrator_store.py +++ b/robot-server/robot_server/runs/run_orchestrator_store.py @@ -1,7 +1,7 @@ """In-memory storage of ProtocolEngine instances.""" import asyncio import logging -from typing import List, Optional, Callable +from typing import List, Optional, Callable, Dict from opentrons.protocol_engine.errors.exceptions import EStopActivatedError from opentrons.protocol_engine.types import ( @@ -16,6 +16,7 @@ from opentrons.config import feature_flags from opentrons.hardware_control import HardwareControlAPI +from opentrons.hardware_control.nozzle_manager import NozzleMap from opentrons.hardware_control.types import ( EstopState, HardwareEvent, @@ -324,6 +325,10 @@ def get_loaded_labware_definitions(self) -> List[LabwareDefinition]: """Get loaded labware definitions.""" return self.run_orchestrator.get_loaded_labware_definitions() + def get_nozzle_maps(self) -> Dict[str, NozzleMap]: + """Get the current nozzle map keyed by pipette id.""" + return self.run_orchestrator.get_nozzle_maps() + def get_run_time_parameters(self) -> List[RunTimeParameter]: """Parameter definitions defined by protocol, if any. Will always be empty before execution.""" return self.run_orchestrator.get_run_time_parameters() diff --git a/robot-server/tests/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py index 5f23d459e2a..37e5cd6dd3d 100644 --- a/robot-server/tests/runs/router/test_base_router.py +++ b/robot-server/tests/runs/router/test_base_router.py @@ -1,18 +1,23 @@ """Tests for base /runs routes.""" +from typing import Dict + import pytest from datetime import datetime from decoy import Decoy from pathlib import Path -from opentrons.types import DeckSlotName +from opentrons.types import DeckSlotName, Point from opentrons.protocol_engine import ( LabwareOffsetCreate, types as pe_types, errors as pe_errors, CommandErrorSlice, + CommandPointer, ) from opentrons.protocol_reader import ProtocolSource, JsonProtocolConfig +from opentrons.hardware_control.nozzle_manager import NozzleConfigurationType, NozzleMap + from robot_server.data_files.data_files_store import DataFilesStore, DataFileInfo from robot_server.errors.error_responses import ApiError @@ -34,9 +39,20 @@ from robot_server.runs.run_auto_deleter import RunAutoDeleter -from robot_server.runs.run_models import Run, RunCreate, RunUpdate +from robot_server.runs.run_models import ( + Run, + RunCreate, + RunUpdate, + RunCurrentState, + ActiveNozzleLayout, + CommandLinkNoMeta, + NozzleLayoutConfig, +) from robot_server.runs.run_orchestrator_store import RunConflictError -from robot_server.runs.run_data_manager import RunDataManager, RunNotCurrentError +from robot_server.runs.run_data_manager import ( + RunDataManager, + RunNotCurrentError, +) from robot_server.runs.run_models import RunNotFoundError from robot_server.runs.router.base_router import ( AllRunsLinks, @@ -48,6 +64,8 @@ update_run, put_error_recovery_policy, get_run_commands_error, + get_current_state, + CurrentStateLinks, ) from robot_server.deck_configuration.store import DeckConfigurationStore @@ -84,6 +102,23 @@ def labware_offset_create() -> LabwareOffsetCreate: ) +@pytest.fixture +def mock_nozzle_maps() -> Dict[str, NozzleMap]: + """Get mock NozzleMaps.""" + return { + "mock-pipette-id": NozzleMap( + configuration=NozzleConfigurationType.FULL, + columns={"1": ["A1"]}, + rows={"A": ["A1"]}, + map_store={"A1": Point(0, 0, 0)}, + starting_nozzle="A1", + valid_map_key="mock-key", + full_instrument_map_store={}, + full_instrument_rows={}, + ) + } + + async def test_create_run( decoy: Decoy, mock_run_data_manager: RunDataManager, @@ -803,3 +838,67 @@ async def test_get_run_commands_errors_defualt_cursor( cursor=expected_cursor_result, totalLength=3 ) assert result.status_code == 200 + + +async def test_get_current_state_success( + decoy: Decoy, + mock_run_data_manager: RunDataManager, + mock_nozzle_maps: Dict[str, NozzleMap], +) -> None: + """It should return the active nozzle layout for a specific pipette.""" + run_id = "test-run-id" + + decoy.when(mock_run_data_manager.get_nozzle_maps(run_id=run_id)).then_return( + mock_nozzle_maps + ) + decoy.when(mock_run_data_manager.get_current_command(run_id=run_id)).then_return( + CommandPointer( + command_id="current-command-id", + command_key="current-command-key", + created_at=datetime(year=2024, month=4, day=4), + index=101, + ) + ) + + result = await get_current_state( + runId=run_id, + run_data_manager=mock_run_data_manager, + ) + + assert result.status_code == 200 + assert result.content.data == RunCurrentState.construct( + activeNozzleLayouts={ + "mock-pipette-id": ActiveNozzleLayout( + startingNozzle="A1", + activeNozzles=["A1"], + config=NozzleLayoutConfig.FULL, + ) + } + ) + assert result.content.links == CurrentStateLinks( + current=CommandLinkNoMeta( + href="/runs/test-run-id/commands/current-command-id", + id="current-command-id", + ) + ) + + +async def test_get_current_state_run_not_current( + decoy: Decoy, + mock_run_data_manager: RunDataManager, +) -> None: + """It should raise RunStopped when the run is not current.""" + run_id = "non-current-run-id" + + decoy.when(mock_run_data_manager.get_nozzle_maps(run_id=run_id)).then_raise( + RunNotCurrentError("Run is not current") + ) + + with pytest.raises(ApiError) as exc_info: + await get_current_state( + runId=run_id, + run_data_manager=mock_run_data_manager, + ) + + assert exc_info.value.status_code == 409 + assert exc_info.value.content["errors"][0]["id"] == "RunStopped" diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py index 801f09f080f..49399447597 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -1,6 +1,6 @@ """Tests for RunDataManager.""" from datetime import datetime -from typing import Optional, List +from typing import Optional, List, Dict import pytest from decoy import Decoy, matchers @@ -26,6 +26,8 @@ from opentrons.protocol_runner import RunResult from opentrons.types import DeckSlotName +from opentrons.hardware_control.nozzle_manager import NozzleMap + from opentrons_shared_data.errors.exceptions import InvalidStoredData from opentrons_shared_data.labware.labware_definition import LabwareDefinition @@ -113,6 +115,13 @@ def run_time_parameters() -> List[pe_types.RunTimeParameter]: ] +@pytest.fixture +def mock_nozzle_maps(decoy: Decoy) -> Dict[str, NozzleMap]: + """Get a mock NozzleMap.""" + mock_nozzle_map = decoy.mock(cls=NozzleMap) + return {"mock-pipette-id": mock_nozzle_map} + + @pytest.fixture def run_resource() -> RunResource: """Get a StateSummary value object.""" @@ -1146,3 +1155,38 @@ async def test_create_policies_translates_and_calls_orchestrator( decoy.when(mock_run_orchestrator_store.current_run_id).then_return("run-id") subject.set_policies(run_id="run-id", policies=input_rules) decoy.verify(mock_run_orchestrator_store.set_error_recovery_policy(expected_output)) + + +def test_get_nozzle_map_current_run( + decoy: Decoy, + mock_run_orchestrator_store: RunOrchestratorStore, + subject: RunDataManager, + mock_nozzle_maps: Dict[str, NozzleMap], +) -> None: + """It should return the nozzle map for the current run.""" + run_id = "current-run-id" + + decoy.when(mock_run_orchestrator_store.current_run_id).then_return(run_id) + decoy.when(mock_run_orchestrator_store.get_nozzle_maps()).then_return( + mock_nozzle_maps + ) + + result = subject.get_nozzle_maps(run_id=run_id) + + assert result == mock_nozzle_maps + + +def test_get_nozzle_map_not_current_run( + decoy: Decoy, + mock_run_orchestrator_store: RunOrchestratorStore, + subject: RunDataManager, +) -> None: + """It should raise RunNotCurrentError for a non-current run.""" + run_id = "non-current-run-id" + + decoy.when(mock_run_orchestrator_store.current_run_id).then_return( + "different-run-id" + ) + + with pytest.raises(RunNotCurrentError): + subject.get_nozzle_maps(run_id=run_id) diff --git a/shared-data/labware/definitions/2/opentrons_flex_lid_absorbance_plate_reader_module/1.json b/shared-data/labware/definitions/2/opentrons_flex_lid_absorbance_plate_reader_module/1.json index 82b5a1f54d8..a477991f629 100644 --- a/shared-data/labware/definitions/2/opentrons_flex_lid_absorbance_plate_reader_module/1.json +++ b/shared-data/labware/definitions/2/opentrons_flex_lid_absorbance_plate_reader_module/1.json @@ -33,8 +33,8 @@ "version": 1, "schemaVersion": 2, "allowedRoles": ["fixture"], - "gripForce": 20.0, - "gripHeightFromLabwareBottom": 53.0, + "gripForce": 21.0, + "gripHeightFromLabwareBottom": 48.0, "cornerOffsetFromSlot": { "x": 0, "y": 0, @@ -43,12 +43,12 @@ "gripperOffsets": { "default": { "pickUpOffset": { - "x": 14, + "x": 9, "y": 0, "z": 0 }, "dropOffset": { - "x": 14, + "x": 9, "y": 0, "z": 0 }