Skip to content
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(api, robot-server): Plate Reader CSV output functionality #16495

Merged
merged 12 commits into from
Oct 19, 2024
Merged
1 change: 1 addition & 0 deletions api/src/opentrons/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,7 @@ def _create_live_context_pe(
hardware_api=hardware_api_wrapped,
config=_get_protocol_engine_config(),
deck_configuration=entrypoint_util.get_deck_configuration(),
file_provider=None,
error_recovery_policy=error_recovery_policy.never_recover,
drop_tips_after_run=False,
post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE,
Expand Down
8 changes: 5 additions & 3 deletions api/src/opentrons/protocol_api/core/engine/module_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -586,11 +586,13 @@ def initialize(
)
self._initialized_value = wavelengths

def read(self) -> Optional[Dict[int, Dict[str, float]]]:
"""Initiate a read on the Absorbance Reader, and return the results. During Analysis, this will return None."""
def read(self, filename: Optional[str]) -> Optional[Dict[int, Dict[str, float]]]:
"""Initiate a read on the Absorbance Reader, and return the results. During Analysis, this will return a measurement of zero for all wells."""
if self._initialized_value:
self._engine_client.execute_command(
cmd.absorbance_reader.ReadAbsorbanceParams(moduleId=self.module_id)
cmd.absorbance_reader.ReadAbsorbanceParams(
moduleId=self.module_id, fileName=filename
)
)
if not self._engine_client.state.config.use_virtual_modules:
read_result = (
Expand Down
2 changes: 1 addition & 1 deletion api/src/opentrons/protocol_api/core/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ def initialize(
"""Initialize the Absorbance Reader by taking zero reading."""

@abstractmethod
def read(self) -> Optional[Dict[int, Dict[str, float]]]:
def read(self, filename: Optional[str]) -> Optional[Dict[int, Dict[str, float]]]:
"""Get an absorbance reading from the Absorbance Reader."""

@abstractmethod
Expand Down
9 changes: 6 additions & 3 deletions api/src/opentrons/protocol_api/module_contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -1035,6 +1035,9 @@ def initialize(
)

@requires_version(2, 21)
def read(self) -> Optional[Dict[int, Dict[str, float]]]:
"""Initiate read on the Absorbance Reader. Returns a dictionary of wavelengths to dictionary of values ordered by well name."""
return self._core.read()
def read(self, filename: Optional[str]) -> Optional[Dict[int, Dict[str, float]]]:
"""Initiate read on the Absorbance Reader. Returns a dictionary of wavelengths to dictionary of values ordered by well name.

:param filename: Optional, if a filename is provided a CSV file will be saved as a result of the read action containing measurement data.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that if filename isn't specified…

  1. The file will get a default name like Plate Reader output [timestamp].csv
  2. No file will be written 🚮

My understanding was that we'd always write a file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No file will be written. There is a limit (currently) of 40 files written during a protocol run. The user can do unlimited reads so long as they are not writing a file. They can do 40 reads where they save a file.

"""
return self._core.read(filename=filename)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Command models to read absorbance."""
from __future__ import annotations
from datetime import datetime
from typing import Optional, Dict, TYPE_CHECKING
from typing_extensions import Literal, Type

Expand All @@ -9,6 +10,9 @@
from ...errors import CannotPerformModuleAction
from ...errors.error_occurrence import ErrorOccurrence

from ...resources.file_provider import PlateReaderDataTransform, ReadData
from ...resources import FileProvider

if TYPE_CHECKING:
from opentrons.protocol_engine.state.state import StateView
from opentrons.protocol_engine.execution import EquipmentHandler
Expand All @@ -21,6 +25,10 @@ class ReadAbsorbanceParams(BaseModel):
"""Input parameters for an absorbance reading."""

moduleId: str = Field(..., description="Unique ID of the Absorbance Reader.")
fileName: Optional[str] = Field(
None,
description="Optional file name to use when storing the results of a measurement.",
)


class ReadAbsorbanceResult(BaseModel):
Expand All @@ -40,10 +48,12 @@ def __init__(
self,
state_view: StateView,
equipment: EquipmentHandler,
file_provider: FileProvider,
**unused_dependencies: object,
) -> None:
self._state_view = state_view
self._equipment = equipment
self._file_provider = file_provider

async def execute(
self, params: ReadAbsorbanceParams
Expand All @@ -63,17 +73,45 @@ async def execute(
)

if abs_reader is not None:
start_time = datetime.now()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You probably want to use ModelUtils.get_timestamp() instead of a raw datetime.now(), for two reasons:

  1. Dependency injection (testability).
  2. Consistent timezone.

results = await abs_reader.start_measure()
finish_time = datetime.now()
if abs_reader._measurement_config is not None:
asbsorbance_result: Dict[int, Dict[str, float]] = {}
sample_wavelengths = abs_reader._measurement_config.sample_wavelengths
transform_results = []
for wavelength, result in zip(sample_wavelengths, results):
converted_values = (
self._state_view.modules.convert_absorbance_reader_data_points(
data=result
)
)
asbsorbance_result[wavelength] = converted_values
transform_results.append(
ReadData.build(wavelength=wavelength, data=converted_values)
)

# Begin interfacing with the file provider if the user provided a filename
if params.fileName is not None and abs_reader.serial_number is not None:
plate_read_result = PlateReaderDataTransform.build(
read_results=transform_results,
reference_wavelength=abs_reader_substate.reference_wavelength,
start_time=start_time,
finish_time=finish_time,
serial_number=abs_reader.serial_number,
)

if isinstance(plate_read_result, PlateReaderDataTransform):
# Write a CSV file for each of the measurements taken
for measurement in plate_read_result.read_results:
await self._file_provider.write_csv(
write_data=plate_read_result.build_generic_csv(
filename=params.fileName,
measurement=measurement,
)
)

# Return success data to api
return SuccessData(
public=ReadAbsorbanceResult(data=asbsorbance_result),
private=None,
Expand Down
1 change: 1 addition & 0 deletions api/src/opentrons/protocol_engine/commands/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ def __init__(
state_view: StateView,
hardware_api: HardwareControlAPI,
equipment: execution.EquipmentHandler,
file_provider: execution.FileProvider,
movement: execution.MovementHandler,
gantry_mover: execution.GantryMover,
labware_movement: execution.LabwareMovementHandler,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from opentrons_shared_data.robot import load as load_robot

from .protocol_engine import ProtocolEngine
from .resources import DeckDataProvider, ModuleDataProvider
from .resources import DeckDataProvider, ModuleDataProvider, FileProvider
from .state.config import Config
from .state.state import StateStore
from .types import PostRunHardwareState, DeckConfigurationType
Expand All @@ -26,6 +26,7 @@ async def create_protocol_engine(
error_recovery_policy: ErrorRecoveryPolicy,
load_fixed_trash: bool = False,
deck_configuration: typing.Optional[DeckConfigurationType] = None,
file_provider: typing.Optional[FileProvider] = None,
notify_publishers: typing.Optional[typing.Callable[[], None]] = None,
) -> ProtocolEngine:
"""Create a ProtocolEngine instance.
Expand All @@ -47,6 +48,7 @@ async def create_protocol_engine(

module_calibration_offsets = ModuleDataProvider.load_module_calibrations()
robot_definition = load_robot(config.robot_type)

state_store = StateStore(
config=config,
deck_definition=deck_definition,
Expand All @@ -62,6 +64,7 @@ async def create_protocol_engine(
return ProtocolEngine(
state_store=state_store,
hardware_api=hardware_api,
file_provider=file_provider,
)


Expand All @@ -70,6 +73,7 @@ def create_protocol_engine_in_thread(
hardware_api: HardwareControlAPI,
config: Config,
deck_configuration: typing.Optional[DeckConfigurationType],
file_provider: typing.Optional[FileProvider],
error_recovery_policy: ErrorRecoveryPolicy,
drop_tips_after_run: bool,
post_run_hardware_state: PostRunHardwareState,
Expand Down Expand Up @@ -97,6 +101,7 @@ def create_protocol_engine_in_thread(
with async_context_manager_in_thread(
_protocol_engine(
hardware_api,
file_provider,
config,
deck_configuration,
error_recovery_policy,
Expand All @@ -114,6 +119,7 @@ def create_protocol_engine_in_thread(
@contextlib.asynccontextmanager
async def _protocol_engine(
hardware_api: HardwareControlAPI,
file_provider: typing.Optional[FileProvider],
config: Config,
deck_configuration: typing.Optional[DeckConfigurationType],
error_recovery_policy: ErrorRecoveryPolicy,
Expand All @@ -123,6 +129,7 @@ async def _protocol_engine(
) -> typing.AsyncGenerator[ProtocolEngine, None]:
protocol_engine = await create_protocol_engine(
hardware_api=hardware_api,
file_provider=file_provider,
config=config,
error_recovery_policy=error_recovery_policy,
load_fixed_trash=load_fixed_trash,
Expand Down
4 changes: 3 additions & 1 deletion api/src/opentrons/protocol_engine/engine_support.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Support for create_protocol_engine module."""
from . import ProtocolEngine
from ..hardware_control import HardwareControlAPI
from .resources import FileProvider

from opentrons.protocol_runner import protocol_runner, RunOrchestrator


def create_run_orchestrator(
hardware_api: HardwareControlAPI, protocol_engine: ProtocolEngine
hardware_api: HardwareControlAPI,
protocol_engine: ProtocolEngine,
) -> RunOrchestrator:
"""Create a RunOrchestrator instance."""
return RunOrchestrator(
Expand Down
2 changes: 2 additions & 0 deletions api/src/opentrons/protocol_engine/execution/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from .hardware_stopper import HardwareStopper
from .door_watcher import DoorWatcher
from .status_bar import StatusBarHandler
from ..resources.file_provider import FileProvider

# .thermocycler_movement_flagger omitted from package's public interface.

Expand All @@ -45,4 +46,5 @@
"DoorWatcher",
"RailLightsHandler",
"StatusBarHandler",
"FileProvider",
]
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from opentrons.protocol_engine.commands.command import SuccessData

from ..state.state import StateStore
from ..resources import ModelUtils
from ..resources import ModelUtils, FileProvider
from ..commands import CommandStatus
from ..actions import (
ActionDispatcher,
Expand Down Expand Up @@ -72,6 +72,7 @@ class CommandExecutor:
def __init__(
self,
hardware_api: HardwareControlAPI,
file_provider: FileProvider,
state_store: StateStore,
action_dispatcher: ActionDispatcher,
equipment: EquipmentHandler,
Expand All @@ -88,6 +89,7 @@ def __init__(
) -> None:
"""Initialize the CommandExecutor with access to its dependencies."""
self._hardware_api = hardware_api
self._file_provider = file_provider
self._state_store = state_store
self._action_dispatcher = action_dispatcher
self._equipment = equipment
Expand Down Expand Up @@ -116,6 +118,7 @@ async def execute(self, command_id: str) -> None:
command_impl = queued_command._ImplementationCls(
state_view=self._state_store,
hardware_api=self._hardware_api,
file_provider=self._file_provider,
equipment=self._equipment,
movement=self._movement,
gantry_mover=self._gantry_mover,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from ..state.state import StateStore
from ..actions import ActionDispatcher
from ..resources import FileProvider
from .equipment import EquipmentHandler
from .movement import MovementHandler
from .gantry_mover import create_gantry_mover
Expand All @@ -20,6 +21,7 @@

def create_queue_worker(
hardware_api: HardwareControlAPI,
file_provider: FileProvider,
state_store: StateStore,
action_dispatcher: ActionDispatcher,
command_generator: Callable[[], AsyncGenerator[str, None]],
Expand Down Expand Up @@ -78,6 +80,7 @@ def create_queue_worker(

command_executor = CommandExecutor(
hardware_api=hardware_api,
file_provider=file_provider,
state_store=state_store,
action_dispatcher=action_dispatcher,
equipment=equipment_handler,
Expand Down
5 changes: 4 additions & 1 deletion api/src/opentrons/protocol_engine/protocol_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from .errors import ProtocolCommandFailedError, ErrorOccurrence, CommandNotAllowedError
from .errors.exceptions import EStopActivatedError
from . import commands, slot_standardization
from .resources import ModelUtils, ModuleDataProvider
from .resources import ModelUtils, ModuleDataProvider, FileProvider
from .types import (
LabwareOffset,
LabwareOffsetCreate,
Expand Down Expand Up @@ -95,6 +95,7 @@ def __init__(
hardware_stopper: Optional[HardwareStopper] = None,
door_watcher: Optional[DoorWatcher] = None,
module_data_provider: Optional[ModuleDataProvider] = None,
file_provider: Optional[FileProvider] = None,
) -> None:
"""Initialize a ProtocolEngine instance.

Expand All @@ -104,6 +105,7 @@ def __init__(
Prefer the `create_protocol_engine()` factory function.
"""
self._hardware_api = hardware_api
self._file_provider = file_provider or FileProvider()
self._state_store = state_store
self._model_utils = model_utils or ModelUtils()
self._action_dispatcher = action_dispatcher or ActionDispatcher(
Expand Down Expand Up @@ -616,6 +618,7 @@ def set_and_start_queue_worker(
assert self._queue_worker is None
self._queue_worker = create_queue_worker(
hardware_api=self._hardware_api,
file_provider=self._file_provider,
state_store=self._state_store,
action_dispatcher=self._action_dispatcher,
command_generator=command_generator,
Expand Down
2 changes: 2 additions & 0 deletions api/src/opentrons/protocol_engine/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .deck_data_provider import DeckDataProvider, DeckFixedLabware
from .labware_data_provider import LabwareDataProvider
from .module_data_provider import ModuleDataProvider
from .file_provider import FileProvider
from .ot3_validation import ensure_ot3_hardware


Expand All @@ -18,6 +19,7 @@
"DeckDataProvider",
"DeckFixedLabware",
"ModuleDataProvider",
"FileProvider",
"ensure_ot3_hardware",
"pipette_data_provider",
"labware_validation",
Expand Down
Loading
Loading