From 8a70b53ed35d33951b130bb1299026f02cf81d7b Mon Sep 17 00:00:00 2001 From: Jeremy Leon Date: Thu, 30 Jun 2022 11:17:12 -0400 Subject: [PATCH] feat(engine): thermocycler run profile (#10921) Adds thermocycler runProfile command to Protocol Engine and jsonv6 Co-authored-by: Shlok Amin --- .../commands/command_unions.py | 6 + .../commands/thermocycler/__init__.py | 16 +++ .../commands/thermocycler/run_profile.py | 111 ++++++++++++++++++ .../commands/thermocycler/test_run_profile.py | 77 ++++++++++++ shared-data/protocol/schemas/6.json | 12 +- .../protocol/types/schemaV6/command/module.ts | 6 +- .../thermocyclerAtomicCommands.test.ts | 13 +- .../__tests__/thermocyclerProfileStep.test.ts | 14 +-- .../src/__tests__/thermocyclerUpdates.test.ts | 3 +- .../atomic/thermocyclerRunProfile.ts | 7 +- 10 files changed, 244 insertions(+), 21 deletions(-) create mode 100644 api/src/opentrons/protocol_engine/commands/thermocycler/run_profile.py create mode 100644 api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_profile.py diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index 250db7dcff1..ab1a5acd95a 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -189,6 +189,7 @@ thermocycler.DeactivateLid, thermocycler.OpenLid, thermocycler.CloseLid, + thermocycler.RunProfile, ] CommandParams = Union[ @@ -230,6 +231,8 @@ thermocycler.DeactivateLidParams, thermocycler.OpenLidParams, thermocycler.CloseLidParams, + thermocycler.RunProfileParams, + thermocycler.RunProfileStepParams, ] CommandType = Union[ @@ -271,6 +274,7 @@ thermocycler.DeactivateLidCommandType, thermocycler.OpenLidCommandType, thermocycler.CloseLidCommandType, + thermocycler.RunProfileCommandType, ] CommandCreate = Union[ @@ -311,6 +315,7 @@ thermocycler.DeactivateLidCreate, thermocycler.OpenLidCreate, thermocycler.CloseLidCreate, + thermocycler.RunProfileCreate, ] CommandResult = Union[ @@ -352,4 +357,5 @@ thermocycler.DeactivateLidResult, thermocycler.OpenLidResult, thermocycler.CloseLidResult, + thermocycler.RunProfileResult, ] diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/__init__.py b/api/src/opentrons/protocol_engine/commands/thermocycler/__init__.py index 99cd662dad0..b0ffdd53ce9 100644 --- a/api/src/opentrons/protocol_engine/commands/thermocycler/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/__init__.py @@ -64,6 +64,15 @@ CloseLidCreate, ) +from .run_profile import ( + RunProfileCommandType, + RunProfileParams, + RunProfileStepParams, + RunProfileResult, + RunProfile, + RunProfileCreate, +) + __all__ = [ # Set target block temperature command models @@ -114,4 +123,11 @@ "CloseLidResult", "CloseLid", "CloseLidCreate", + # Run profile command models, + "RunProfileCommandType", + "RunProfileParams", + "RunProfileStepParams", + "RunProfileResult", + "RunProfile", + "RunProfileCreate", ] diff --git a/api/src/opentrons/protocol_engine/commands/thermocycler/run_profile.py b/api/src/opentrons/protocol_engine/commands/thermocycler/run_profile.py new file mode 100644 index 00000000000..43f89ecbf87 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/thermocycler/run_profile.py @@ -0,0 +1,111 @@ +"""Command models to execute a Thermocycler profile.""" +from __future__ import annotations +from typing import List, Optional, TYPE_CHECKING +from typing_extensions import Literal, Type + +from pydantic import BaseModel, Field + +from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate + +if TYPE_CHECKING: + from opentrons.protocol_engine.state import StateView + from opentrons.protocol_engine.execution import EquipmentHandler + + +RunProfileCommandType = Literal["thermocycler/runProfile"] + + +class RunProfileStepParams(BaseModel): + """Input parameters for an individual Thermocycler profile step.""" + + celsius: float = Field(..., description="Target temperature in °C.") + holdSeconds: int = Field( + ..., description="Time to hold target temperature at in seconds." + ) + + +class RunProfileParams(BaseModel): + """Input parameters to run a Thermocycler profile.""" + + moduleId: str = Field(..., description="Unique ID of the Thermocycler.") + profile: List[RunProfileStepParams] = Field( + ..., + description="Array of profile steps with target temperature and temperature hold time.", + ) + blockMaxVolumeUl: Optional[float] = Field( + None, + description="Amount of liquid in uL of the most-full well" + " in labware loaded onto the thermocycler.", + ) + + +class RunProfileResult(BaseModel): + """Result data from running a Thermocycler profile.""" + + +class RunProfileImpl(AbstractCommandImpl[RunProfileParams, RunProfileResult]): + """Execution implementation of a Thermocycler's run profile command.""" + + def __init__( + self, + state_view: StateView, + equipment: EquipmentHandler, + **unused_dependencies: object, + ) -> None: + self._state_view = state_view + self._equipment = equipment + + async def execute(self, params: RunProfileParams) -> RunProfileResult: + """Run a Thermocycler profile.""" + thermocycler_state = self._state_view.modules.get_thermocycler_module_substate( + params.moduleId + ) + thermocycler_hardware = self._equipment.get_module_hardware_api( + thermocycler_state.module_id + ) + + steps = [ + { + "temperature": thermocycler_state.validate_target_block_temperature( + profile_step.celsius + ), + "hold_time_seconds": profile_step.holdSeconds, + } + for profile_step in params.profile + ] + + target_volume: Optional[float] + if params.blockMaxVolumeUl is not None: + target_volume = thermocycler_state.validate_max_block_volume( + params.blockMaxVolumeUl + ) + else: + target_volume = None + + if thermocycler_hardware is not None: + # TODO(jbl 2022-06-27) hardcoded constant 1 for `repetitions` should be + # moved from HardwareControlAPI to the Python ProtocolContext + await thermocycler_hardware.cycle_temperatures( + steps=steps, repetitions=1, volume=target_volume + ) + + return RunProfileResult() + + +class RunProfile(BaseCommand[RunProfileParams, RunProfileResult]): + """A command to execute a Thermocycler profile run.""" + + commandType: RunProfileCommandType = "thermocycler/runProfile" + params: RunProfileParams + result: Optional[RunProfileResult] + + _ImplementationCls: Type[RunProfileImpl] = RunProfileImpl + + +class RunProfileCreate(BaseCommandCreate[RunProfileParams]): + """A request to execute a Thermocycler profile run.""" + + commandType: RunProfileCommandType = "thermocycler/runProfile" + params: RunProfileParams + + _CommandCls: Type[RunProfile] = RunProfile diff --git a/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_profile.py b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_profile.py new file mode 100644 index 00000000000..277856444f7 --- /dev/null +++ b/api/tests/opentrons/protocol_engine/commands/thermocycler/test_run_profile.py @@ -0,0 +1,77 @@ +"""Test Thermocycler run profile command implementation.""" +from decoy import Decoy + +from opentrons.hardware_control.modules import Thermocycler + +from opentrons.protocol_engine.state import StateView +from opentrons.protocol_engine.state.module_substates import ( + ThermocyclerModuleSubState, + ThermocyclerModuleId, +) +from opentrons.protocol_engine.execution import EquipmentHandler +from opentrons.protocol_engine.commands import thermocycler as tc_commands +from opentrons.protocol_engine.commands.thermocycler.run_profile import ( + RunProfileImpl, +) + + +async def test_run_profile( + decoy: Decoy, + state_view: StateView, + equipment: EquipmentHandler, +) -> None: + """It should be able to execute the specified module's profile run.""" + subject = RunProfileImpl(state_view=state_view, equipment=equipment) + + step_data = [ + tc_commands.RunProfileStepParams(celsius=12.3, holdSeconds=45), + tc_commands.RunProfileStepParams(celsius=45.6, holdSeconds=78), + ] + data = tc_commands.RunProfileParams( + moduleId="input-thermocycler-id", + profile=step_data, + blockMaxVolumeUl=56.7, + ) + expected_result = tc_commands.RunProfileResult() + + tc_module_substate = decoy.mock(cls=ThermocyclerModuleSubState) + tc_hardware = decoy.mock(cls=Thermocycler) + + decoy.when( + state_view.modules.get_thermocycler_module_substate("input-thermocycler-id") + ).then_return(tc_module_substate) + + decoy.when(tc_module_substate.module_id).then_return( + ThermocyclerModuleId("thermocycler-id") + ) + + # Stub temperature validation from hs module view + decoy.when(tc_module_substate.validate_target_block_temperature(12.3)).then_return( + 32.1 + ) + decoy.when(tc_module_substate.validate_target_block_temperature(45.6)).then_return( + 65.4 + ) + + # Stub volume validation from hs module view + decoy.when(tc_module_substate.validate_max_block_volume(56.7)).then_return(76.5) + + # Get attached hardware modules + decoy.when( + equipment.get_module_hardware_api(ThermocyclerModuleId("thermocycler-id")) + ).then_return(tc_hardware) + + result = await subject.execute(data) + + decoy.verify( + await tc_hardware.cycle_temperatures( + steps=[ + {"temperature": 32.1, "hold_time_seconds": 45}, + {"temperature": 65.4, "hold_time_seconds": 78}, + ], + repetitions=1, + volume=76.5, + ), + times=1, + ) + assert result == expected_result diff --git a/shared-data/protocol/schemas/6.json b/shared-data/protocol/schemas/6.json index f88af4956ed..18e4d154c65 100644 --- a/shared-data/protocol/schemas/6.json +++ b/shared-data/protocol/schemas/6.json @@ -843,7 +843,7 @@ "commandType": { "enum": ["thermocycler/runProfile"] }, "params": { "type": "object", - "required": ["moduleId", "profile", "volume"], + "required": ["moduleId", "profile"], "properties": { "moduleId": { "type": "string", @@ -853,20 +853,20 @@ "type": "array", "items": { "type": "object", - "required": ["temperature", "holdTime"], + "required": ["celsius", "holdSeconds"], "properties": { - "temperature": { - "description": "Target temperature of profile step", + "celsius": { + "description": "Target temperature (in celsius) of profile step", "type": "number" }, - "holdTime": { + "holdSeconds": { "description": "Time (in seconds) to hold once temperature is reached", "type": "number" } } } }, - "volume": { + "blockMaxVolumeUl": { "type": "number" } } diff --git a/shared-data/protocol/types/schemaV6/command/module.ts b/shared-data/protocol/types/schemaV6/command/module.ts index 75b174ea57c..f8431799ab9 100644 --- a/shared-data/protocol/types/schemaV6/command/module.ts +++ b/shared-data/protocol/types/schemaV6/command/module.ts @@ -279,14 +279,14 @@ export interface ShakeSpeedParams { } export interface AtomicProfileStep { - holdTime: number - temperature: number + holdSeconds: number + celsius: number } export interface TCProfileParams { moduleId: string profile: AtomicProfileStep[] - volume: number + blockMaxVolumeUl?: number } export interface ModuleOnlyParams { diff --git a/step-generation/src/__tests__/thermocyclerAtomicCommands.test.ts b/step-generation/src/__tests__/thermocyclerAtomicCommands.test.ts index a644ac5a60c..a3286342bab 100644 --- a/step-generation/src/__tests__/thermocyclerAtomicCommands.test.ts +++ b/step-generation/src/__tests__/thermocyclerAtomicCommands.test.ts @@ -125,7 +125,6 @@ describe('thermocycler atomic commands', () => { const robotInitialState = getRobotInitialState() const result = commandCreator(params, invariantContext, robotInitialState) const res = getSuccessResult(result) - // delete this once params are changed to conform to v6 params const v6Params = { ...params, moduleId: params.module, @@ -133,6 +132,18 @@ describe('thermocycler atomic commands', () => { } delete v6Params.module delete v6Params.temperature + if (v6Params.profile != null) { + v6Params.profile = v6Params.profile.map( + (profileItem: { temperature: number; holdTime: number }) => ({ + celsius: profileItem.temperature, + holdSeconds: profileItem.holdTime, + }) + ) + } + if (v6Params.volume != null) { + v6Params.blockMaxVolumeUl = v6Params.volume + delete v6Params.volume + } expect(res.commands).toEqual([ { commandType: expectedType, diff --git a/step-generation/src/__tests__/thermocyclerProfileStep.test.ts b/step-generation/src/__tests__/thermocyclerProfileStep.test.ts index 3bec558dcd9..f39c3e8b2d0 100644 --- a/step-generation/src/__tests__/thermocyclerProfileStep.test.ts +++ b/step-generation/src/__tests__/thermocyclerProfileStep.test.ts @@ -58,7 +58,7 @@ describe('thermocyclerProfileStep', () => { params: { moduleId: 'thermocyclerId', profile: [], - volume: 42, + blockMaxVolumeUl: 42, }, }, { @@ -118,8 +118,8 @@ describe('thermocyclerProfileStep', () => { commandType: 'thermocycler/runProfile', params: { moduleId: 'thermocyclerId', - profile: [{ temperature: 61, holdTime: 99 }], - volume: 42, + profile: [{ celsius: 61, holdSeconds: 99 }], + blockMaxVolumeUl: 42, }, }, { @@ -185,8 +185,8 @@ describe('thermocyclerProfileStep', () => { commandType: 'thermocycler/runProfile', params: { moduleId: 'thermocyclerId', - profile: [{ temperature: 61, holdTime: 99 }], - volume: 42, + profile: [{ celsius: 61, holdSeconds: 99 }], + blockMaxVolumeUl: 42, }, }, { @@ -246,8 +246,8 @@ describe('thermocyclerProfileStep', () => { commandType: 'thermocycler/runProfile', params: { moduleId: 'thermocyclerId', - profile: [{ temperature: 61, holdTime: 99 }], - volume: 42, + profile: [{ celsius: 61, holdSeconds: 99 }], + blockMaxVolumeUl: 42, }, }, { diff --git a/step-generation/src/__tests__/thermocyclerUpdates.test.ts b/step-generation/src/__tests__/thermocyclerUpdates.test.ts index fb1294ee78b..2b90c2b6cc5 100644 --- a/step-generation/src/__tests__/thermocyclerUpdates.test.ts +++ b/step-generation/src/__tests__/thermocyclerUpdates.test.ts @@ -19,7 +19,6 @@ import { makeImmutableStateUpdater } from '../__utils__' import { makeContext, getInitialRobotStateStandard } from '../fixtures' import type { ModuleOnlyParams, - TCProfileParams, TemperatureParams, ThermocyclerSetTargetBlockTemperatureParams, } from '@opentrons/shared-data/protocol/types/schemaV6/command/module' @@ -206,7 +205,7 @@ describe('thermocycler state updaters', () => { testName: 'forThermocyclerOpenLid should set lidOpen to true', }, ] - const profileCases: TestCases = [ + const profileCases: TestCases = [ { params: { moduleId, diff --git a/step-generation/src/commandCreators/atomic/thermocyclerRunProfile.ts b/step-generation/src/commandCreators/atomic/thermocyclerRunProfile.ts index 6bdad73ada1..6ac9859eb29 100644 --- a/step-generation/src/commandCreators/atomic/thermocyclerRunProfile.ts +++ b/step-generation/src/commandCreators/atomic/thermocyclerRunProfile.ts @@ -12,8 +12,11 @@ export const thermocyclerRunProfile: CommandCreator = ( commandType: 'thermocycler/runProfile', params: { moduleId: module, - profile, - volume, + profile: profile.map(profileItem => ({ + holdSeconds: profileItem.holdTime, + celsius: profileItem.temperature, + })), + blockMaxVolumeUl: volume, }, }, ],