-
Notifications
You must be signed in to change notification settings - Fork 179
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(engine): thermocycler run profile #10921
Changes from 4 commits
a70aba1
aba3a51
069d361
b92ef7d
c8044e1
606d968
a1d1557
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
"""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: float = Field( | ||
..., | ||
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 = [] | ||
for profile_step in params.profile: | ||
target_temperature = thermocycler_state.validate_target_block_temperature( | ||
profile_step.celsius | ||
) | ||
steps.append( | ||
{ | ||
"temperature": target_temperature, | ||
"hold_time_seconds": profile_step.holdSeconds, | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would love for the hardware control API to accept a dataclass or NamedTuple rather than a dict. Might add it to the confluence doc |
||
) | ||
jbleon95 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
target_volume = thermocycler_state.validate_max_block_volume( | ||
params.blockMaxVolumeUl | ||
) | ||
|
||
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -843,7 +843,7 @@ | |
"commandType": { "enum": ["thermocycler/runProfile"] }, | ||
"params": { | ||
"type": "object", | ||
"required": ["moduleId", "profile", "volume"], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. removing volume (renamed to |
||
"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" | ||
} | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@shlokamin or @sanni-t, do either of y'all know why this is a required parameter of
runProfile
but optional forsetTargetBlockTemperature
? My gut reaction is that it should be optional inrunProfile
, too.It's optional once you hit the hardware control API, FWIW
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AFAIK it should be optional, not sure why it's required in the JSON schema. im fine with updating the schema to make it optional as part of this PR (i can do that as part of the frontend side). that cool with you @sanni-t ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
also note to self, i need to add to the JSON schema migration to make sure <v6 protocols get these parameters updated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, volume should be optional. It defaults to 25uL in firmware
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unless we want to make sure that PD protocols always specify the volume for ideal heating. I'm guessing that since PD does volume tracking, the volume will always be specified anyway?
The volume parameter was added to
setTargetBlockTemperature
only in v6. Hence that one definitely needs to stay optional to allow older protocols to run as expected.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good point, the thermocycler step form in PD does require volume, but thats a PD concern (not a step generation/schema concern), so ill go ahead and mark it as optional