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): use runtime parameter files set in protocols and runs to set in-protocol values #15855

Merged
merged 15 commits into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/src/opentrons/cli/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ async def _do_analyze(protocol_source: ProtocolSource) -> RunResult:
protocol_source=protocol_source,
parse_mode=ParseMode.NORMAL,
run_time_param_values=None,
run_time_param_files=None,
run_time_param_paths=None,
)
except Exception as error:
err_id = "analysis-setup-error"
Expand Down
2 changes: 2 additions & 0 deletions api/src/opentrons/protocol_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from opentrons.protocols.parameters.exceptions import (
RuntimeParameterRequired as RuntimeParameterRequiredError,
)
from opentrons.protocols.parameters.types import CSVParameter

from .protocol_context import ProtocolContext
from .deck import Deck
Expand Down Expand Up @@ -72,6 +73,7 @@
"ALL",
"OFF_DECK",
"RuntimeParameterRequiredError",
"CSVParameter",
Copy link
Contributor

Choose a reason for hiding this comment

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

What's this reexport for? Are we expecting users of the Python Protocol API to import this in their scripts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was tacked on but via a discussion with Ed we want this easily exposed since this interface is a first class object in our API now.

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh, I see now, that makes sense. We should aim to define the class inside opentrons.protocol_api instead of opentrons.protocols.parameters.types, then. Until we do that, mind leaving a note on the class definition so it's obvious that it's user-facing?

# For internal Opentrons use only:
"create_protocol_context",
"ProtocolEngineCoreRequiredError",
Expand Down
39 changes: 31 additions & 8 deletions api/src/opentrons/protocol_api/_parameter_context.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Parameter context for python protocols."""

import tempfile
from typing import List, Optional, Union, Dict

from opentrons.protocols.api_support.types import APIVersion
Expand All @@ -19,7 +19,7 @@
from opentrons.protocol_engine.types import (
RunTimeParameter,
PrimitiveRunTimeParamValuesType,
CSVRunTimeParamFilesType,
CSVRuntimeParamPaths,
FileInfo,
)

Expand Down Expand Up @@ -218,15 +218,15 @@ def set_parameters(
parameter.value = validated_value

def initialize_csv_files(
self, run_time_param_file_overrides: CSVRunTimeParamFilesType
self, run_time_param_file_overrides: CSVRuntimeParamPaths
) -> None:
"""Initializes the files for CSV parameters.

:meta private:

This is intended for Opentrons internal use only and is not a guaranteed API.
"""
for variable_name, file_id in run_time_param_file_overrides.items():
for variable_name, file_path in run_time_param_file_overrides.items():
try:
parameter = self._parameters[variable_name]
except KeyError:
Expand All @@ -240,11 +240,34 @@ def initialize_csv_files(
f"File Id was provided for the parameter '{variable_name}',"
f" but '{variable_name}' is not a CSV parameter."
)
file_id = file_path.parent.name
file_name = file_path.name
with file_path.open() as csv_file:
contents = csv_file.read()

temporary_file = tempfile.NamedTemporaryFile("r+")
temporary_file.write(contents)
temporary_file.flush()

parameter_file = open(temporary_file.name, "r")
temporary_file.close()
Copy link
Contributor

Choose a reason for hiding this comment

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

If I'm reading this correctly:

  1. We are given a path, the "original file," which we want to treat as read-only.
  2. We open the file at that path and copy its contents to a new, temporary file. This makes sense.
  3. We close that temporary file and then reopen it, and this reopened file is what we use for general use during the protocol.

Why are we doing step 3?


Also, I know you're not supporting this now, but heads up for the future: Any kind of open() at this stage is making an assumption about the file's encoding before the user's Python has a chance to run and tell us what encoding it expects. If you ever support custom encodings, the open() will have to move somewhere else, fundamentally. So let's not bake it too deep into the structure.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Step 3 is to enforce read-only permissions on the file, since to write to the temporary file we need to make it read-write privileges. Since it's a temporary file there's no inherent danger in giving write privileges but it seemed the more "correct" thing to enforce.

As for encoding you can actually change the encoding of an active file even after opening it via reconfigure, but again with the previous reply to your comment this code may end up moving closer to where the user is using it, more easily allowing this for future types of files (CSV files I feel are pretty safe to assume UTF-8 text)


parameter.file_info = FileInfo(id=file_id, name="")
# TODO (spp, 2024-07-16): set the file name and assign the file as parameter.value.
# Most likely, we will be creating a temporary file copy of the original
# to pass onto the protocol context
parameter.file_info = FileInfo(id=file_id, name=file_name)
parameter.value = parameter_file

def close_csv_files(self) -> None:
"""Close all file handlers for CSV parameters.

:meta private:

This is intended for Opentrons internal use only and is not a guaranteed API.
"""
for parameter in self._parameters.values():
if (
isinstance(parameter, csv_parameter_definition.CSVParameterDefinition)
and parameter.value is not None
):
parameter.value.close()

def export_parameters_for_analysis(self) -> List[RunTimeParameter]:
"""Exports all parameters into a protocol engine models for reporting in analysis.
Expand Down
2 changes: 2 additions & 0 deletions api/src/opentrons/protocol_engine/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from datetime import datetime
from enum import Enum
from dataclasses import dataclass
from pathlib import Path
from pydantic import (
BaseModel,
Field,
Expand Down Expand Up @@ -1069,3 +1070,4 @@ class CSVParameter(RTPBase):
] # update value types as more RTP types are added

CSVRunTimeParamFilesType = Mapping[StrictStr, StrictStr]
CSVRuntimeParamPaths = Dict[str, Path]
11 changes: 6 additions & 5 deletions api/src/opentrons/protocol_runner/protocol_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
DeckConfigurationType,
RunTimeParameter,
PrimitiveRunTimeParamValuesType,
CSVRunTimeParamFilesType,
CSVRuntimeParamPaths,
)
from ..protocols.types import PythonProtocol

Expand Down Expand Up @@ -186,7 +186,7 @@ async def load(
protocol_source: ProtocolSource,
python_parse_mode: PythonParseMode,
run_time_param_values: Optional[PrimitiveRunTimeParamValuesType],
run_time_param_files: Optional[CSVRunTimeParamFilesType],
run_time_param_paths: Optional[CSVRuntimeParamPaths],
) -> None:
"""Load a Python or JSONv5(& older) ProtocolSource into managed ProtocolEngine."""
labware_definitions = await protocol_reader.extract_labware_definitions(
Expand All @@ -209,7 +209,7 @@ async def load(
protocol=protocol,
parameter_context=self._parameter_context,
run_time_param_overrides=run_time_param_values,
run_time_param_file_overrides=run_time_param_files,
run_time_param_file_overrides=run_time_param_paths,
)
)
else:
Expand Down Expand Up @@ -244,6 +244,7 @@ async def run_func() -> None:
await self._protocol_executor.execute(
protocol=protocol,
context=context,
parameter_context=self._parameter_context,
run_time_parameters_with_overrides=run_time_parameters_with_overrides,
)

Expand All @@ -254,7 +255,7 @@ async def run( # noqa: D102
deck_configuration: DeckConfigurationType,
protocol_source: Optional[ProtocolSource] = None,
run_time_param_values: Optional[PrimitiveRunTimeParamValuesType] = None,
run_time_param_files: Optional[CSVRunTimeParamFilesType] = None,
run_time_param_paths: Optional[CSVRuntimeParamPaths] = None,
python_parse_mode: PythonParseMode = PythonParseMode.NORMAL,
) -> RunResult:
# TODO(mc, 2022-01-11): move load to runner creation, remove from `run`
Expand All @@ -264,7 +265,7 @@ async def run( # noqa: D102
protocol_source=protocol_source,
python_parse_mode=python_parse_mode,
run_time_param_values=run_time_param_values,
run_time_param_files=run_time_param_files,
run_time_param_paths=run_time_param_paths,
)

self.play(deck_configuration=deck_configuration)
Expand Down
11 changes: 8 additions & 3 deletions api/src/opentrons/protocol_runner/python_protocol_wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from opentrons.protocol_engine import ProtocolEngine
from opentrons.protocol_engine.types import (
PrimitiveRunTimeParamValuesType,
CSVRunTimeParamFilesType,
CSVRuntimeParamPaths,
)
from opentrons.protocol_reader import ProtocolSource, ProtocolFileRole
from opentrons.util.broker import Broker
Expand Down Expand Up @@ -153,19 +153,24 @@ class PythonProtocolExecutor:
async def execute(
protocol: Protocol,
context: ProtocolContext,
parameter_context: Optional[ParameterContext],
run_time_parameters_with_overrides: Optional[Parameters],
) -> None:
"""Execute a PAPIv2 protocol with a given ProtocolContext in a child thread."""
await to_thread.run_sync(
run_protocol, protocol, context, run_time_parameters_with_overrides
run_protocol,
protocol,
context,
parameter_context,
run_time_parameters_with_overrides,
)

@staticmethod
def extract_run_parameters(
protocol: PythonProtocol,
parameter_context: ParameterContext,
run_time_param_overrides: Optional[PrimitiveRunTimeParamValuesType],
run_time_param_file_overrides: Optional[CSVRunTimeParamFilesType],
run_time_param_file_overrides: Optional[CSVRuntimeParamPaths],
) -> Optional[Parameters]:
"""Extract the parameters defined in the protocol, overridden with values for the run."""
return exec_add_parameters(
Expand Down
6 changes: 3 additions & 3 deletions api/src/opentrons/protocol_runner/run_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
DeckConfigurationType,
RunTimeParameter,
PrimitiveRunTimeParamValuesType,
CSVRunTimeParamFilesType,
CSVRuntimeParamPaths,
)
from ..protocol_engine.error_recovery_policy import ErrorRecoveryPolicy

Expand Down Expand Up @@ -340,7 +340,7 @@ async def load(
self,
protocol_source: ProtocolSource,
run_time_param_values: Optional[PrimitiveRunTimeParamValuesType],
run_time_param_files: Optional[CSVRunTimeParamFilesType],
run_time_param_paths: Optional[CSVRuntimeParamPaths],
parse_mode: ParseMode,
) -> None:
"""Load a json/python protocol."""
Expand All @@ -356,7 +356,7 @@ async def load(
# doesn't conform to the new rules.
python_parse_mode=python_parse_mode,
run_time_param_values=run_time_param_values,
run_time_param_files=run_time_param_files,
run_time_param_paths=run_time_param_paths,
)

def get_is_okay_to_clear(self) -> bool:
Expand Down
28 changes: 19 additions & 9 deletions api/src/opentrons/protocols/execution/execute.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging
from typing import Optional

from opentrons.protocol_api import ProtocolContext
from opentrons.protocol_api import ProtocolContext, ParameterContext
from opentrons.protocol_api._parameters import Parameters
from opentrons.protocols.execution.execute_python import exec_run
from opentrons.protocols.execution.json_dispatchers import (
Expand All @@ -21,25 +21,35 @@
def run_protocol(
protocol: Protocol,
context: ProtocolContext,
parameter_context: Optional[ParameterContext] = None,
run_time_parameters_with_overrides: Optional[Parameters] = None,
) -> None:
"""Run a protocol.

:param protocol: The :py:class:`.protocols.types.Protocol` to execute
:param context: The protocol context to use.
:param parameter_context: The parameter context to use if running with runtime parameters.
:param run_time_parameters_with_overrides: Run time parameters defined in the protocol,
updated with the run's RTP override values. When we are running either simulate
or execute, this will be None (until RTP is supported in cli commands)
"""
if isinstance(protocol, PythonProtocol):
if protocol.api_level >= APIVersion(2, 0):
exec_run(
proto=protocol,
context=context,
run_time_parameters_with_overrides=run_time_parameters_with_overrides,
)
else:
raise RuntimeError(f"Unsupported python API version: {protocol.api_level}")
try:
if protocol.api_level >= APIVersion(2, 0):
exec_run(
proto=protocol,
context=context,
run_time_parameters_with_overrides=run_time_parameters_with_overrides,
)
else:
raise RuntimeError(
f"Unsupported python API version: {protocol.api_level}"
)
except Exception:
raise
finally:
if parameter_context is not None:
parameter_context.close_csv_files()
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm glad we're thinking about closing the files when we're done with them, but is this the correct place to do it? Where are the files opened?

For example, it looks like one route for them to be opened is ProtocolExecutor.extract_run_parameters(), when a protocol is loaded, and that does not necessarily seem paired with any parameter_context.close_csv_files().

(The guideline that I'm aiming to follow is that every open has exactly one close, and the thing that does the open is the same as the thing that does the close.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, this was probably my biggest ❓ with this PR. I didn't immediately see a good place to tightly couple the two as you suggest and so I put it in the place where the majority of protocols would enter in the first place, but I agree it's imperfect. I think this is probably a sign that these should be opened in a different location which would also tie into the next comment you have about file opening. Again, another TODO for next week so science can be unblocked from a functional version of this.

Copy link
Contributor

Choose a reason for hiding this comment

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

Makes sense. I would look into doing the open() and close() in exec_run() or one of exec_run()'s callers. I haven't looked at them too closely, so big grain of salt.

else:
if protocol.contents["schemaVersion"] == 3:
ins = execute_json_v3.load_pipettes_from_json(context, protocol.contents)
Expand Down
6 changes: 3 additions & 3 deletions api/src/opentrons/protocols/execution/execute_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from opentrons.protocols.types import PythonProtocol, MalformedPythonProtocolError
from opentrons.protocol_engine.types import (
PrimitiveRunTimeParamValuesType,
CSVRunTimeParamFilesType,
CSVRuntimeParamPaths,
)


Expand Down Expand Up @@ -71,7 +71,7 @@ def _raise_pretty_protocol_error(exception: Exception, filename: str) -> None:
def _parse_and_set_parameters(
parameter_context: ParameterContext,
run_time_param_overrides: Optional[PrimitiveRunTimeParamValuesType],
run_time_param_file_overrides: Optional[CSVRunTimeParamFilesType],
run_time_param_file_overrides: Optional[CSVRuntimeParamPaths],
new_globs: Dict[Any, Any],
filename: str,
) -> Parameters:
Expand Down Expand Up @@ -111,7 +111,7 @@ def exec_add_parameters(
protocol: PythonProtocol,
parameter_context: ParameterContext,
run_time_param_overrides: Optional[PrimitiveRunTimeParamValuesType],
run_time_param_file_overrides: Optional[CSVRunTimeParamFilesType],
run_time_param_file_overrides: Optional[CSVRuntimeParamPaths],
) -> Optional[Parameters]:
"""Exec the add_parameters function and get the final run time parameters with overrides."""
new_globs: Dict[Any, Any] = {}
Expand Down
8 changes: 5 additions & 3 deletions api/tests/opentrons/protocol_runner/test_protocol_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,7 @@ async def test_load_legacy_python(
legacy_protocol_source,
python_parse_mode=PythonParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS,
run_time_param_values=None,
run_time_param_files=None,
run_time_param_paths=None,
)

run_func_captor = matchers.Captor()
Expand All @@ -661,6 +661,7 @@ async def test_load_legacy_python(
await python_protocol_executor.execute(
protocol=legacy_protocol,
context=protocol_context,
parameter_context=python_runner_subject._parameter_context,
run_time_parameters_with_overrides=None,
),
)
Expand Down Expand Up @@ -720,7 +721,7 @@ async def test_load_python_with_pe_papi_core(
protocol_source,
python_parse_mode=PythonParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS,
run_time_param_values=None,
run_time_param_files=None,
run_time_param_paths=None,
)

decoy.verify(protocol_engine.add_plugin(matchers.IsA(LegacyContextPlugin)), times=0)
Expand Down Expand Up @@ -783,7 +784,7 @@ async def test_load_legacy_json(
legacy_protocol_source,
python_parse_mode=PythonParseMode.ALLOW_LEGACY_METADATA_AND_REQUIREMENTS,
run_time_param_values=None,
run_time_param_files=None,
run_time_param_paths=None,
)

run_func_captor = matchers.Captor()
Expand All @@ -804,6 +805,7 @@ async def test_load_legacy_json(
await python_protocol_executor.execute(
protocol=legacy_protocol,
context=protocol_context,
parameter_context=None,
run_time_parameters_with_overrides=None,
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ async def test_load_json(
await json_protocol_subject.load(
protocol_source=protocol_source,
run_time_param_values=None,
run_time_param_files=None,
run_time_param_paths=None,
parse_mode=ParseMode.NORMAL,
)

Expand All @@ -364,15 +364,15 @@ async def test_load_python(
protocol_source=protocol_source,
parse_mode=ParseMode.NORMAL,
run_time_param_values=None,
run_time_param_files=None,
run_time_param_paths=None,
)

decoy.verify(
await mock_protocol_python_runner.load(
protocol_source=protocol_source,
python_parse_mode=PythonParseMode.NORMAL,
run_time_param_values=None,
run_time_param_files=None,
run_time_param_paths=None,
)
)

Expand All @@ -396,7 +396,7 @@ async def test_load_json_raises_no_protocol(
await live_protocol_subject.load(
protocol_source=protocol_source,
run_time_param_values=None,
run_time_param_files=None,
run_time_param_paths=None,
parse_mode=ParseMode.NORMAL,
)

Expand Down
Loading
Loading