From b358ebe90e4e8fd549add81566c6ab1c408fa77a Mon Sep 17 00:00:00 2001 From: Jeremy Leon Date: Fri, 12 Apr 2024 09:54:16 -0400 Subject: [PATCH] feat(robot-server): add runtime parameter definitions to run summary (#14866) Adds the runtime parameter definitions to the run summary for both current and non current runs, accessible via the GET /runs and /runs/{run_id} endpoints. --- .../persistence/_migrations/v3_to_v4.py | 6 + .../robot_server/persistence/pydantic.py | 19 +- .../persistence/tables/schema_4.py | 7 + .../robot_server/runs/run_controller.py | 1 + .../robot_server/runs/run_data_manager.py | 20 +- robot-server/robot_server/runs/run_models.py | 20 +- robot-server/robot_server/runs/run_store.py | 41 +++- .../test_json_v6_protocol_run.tavern.yaml | 1 + .../test_json_v7_protocol_run.tavern.yaml | 1 + .../runs/test_protocol_run.tavern.yaml | 2 + ...t_run_queued_protocol_commands.tavern.yaml | 1 + ...t_run_with_run_time_parameters.tavern.yaml | 203 ++++++++++++++++++ robot-server/tests/persistence/test_tables.py | 1 + .../tests/runs/test_run_controller.py | 18 +- .../tests/runs/test_run_data_manager.py | 73 ++++++- robot-server/tests/runs/test_run_store.py | 109 +++++++++- 16 files changed, 509 insertions(+), 14 deletions(-) create mode 100644 robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml diff --git a/robot-server/robot_server/persistence/_migrations/v3_to_v4.py b/robot-server/robot_server/persistence/_migrations/v3_to_v4.py index 8b4445aaec3..b67d11d34ec 100644 --- a/robot-server/robot_server/persistence/_migrations/v3_to_v4.py +++ b/robot-server/robot_server/persistence/_migrations/v3_to_v4.py @@ -3,6 +3,7 @@ Summary of changes from schema 3: - Adds a new "run_time_parameter_values_and_defaults" column to analysis table +- Adds a new "run_time_parameters" column to run table """ from pathlib import Path @@ -50,3 +51,8 @@ def add_column( schema_4.analysis_table.name, schema_4.analysis_table.c.run_time_parameter_values_and_defaults, ) + add_column( + dest_engine, + schema_4.run_table.name, + schema_4.run_table.c.run_time_parameters, + ) diff --git a/robot-server/robot_server/persistence/pydantic.py b/robot-server/robot_server/persistence/pydantic.py index c3486394ad4..c56312ec166 100644 --- a/robot-server/robot_server/persistence/pydantic.py +++ b/robot-server/robot_server/persistence/pydantic.py @@ -1,7 +1,8 @@ """Store Pydantic objects in the SQL database.""" -from typing import Type, TypeVar -from pydantic import BaseModel, parse_raw_as +import json +from typing import Type, TypeVar, List, Sequence +from pydantic import BaseModel, parse_raw_as, parse_obj_as _BaseModelT = TypeVar("_BaseModelT", bound=BaseModel) @@ -17,6 +18,16 @@ def pydantic_to_json(obj: BaseModel) -> str: ) -def json_to_pydantic(model: Type[_BaseModelT], json: str) -> _BaseModelT: +def pydantic_list_to_json(obj_list: Sequence[BaseModel]) -> str: + """Serialize a list of Pydantic objects for storing in the SQL database.""" + return json.dumps([obj.dict(by_alias=True, exclude_none=True) for obj in obj_list]) + + +def json_to_pydantic(model: Type[_BaseModelT], json_str: str) -> _BaseModelT: """Parse a Pydantic object stored in the SQL database.""" - return parse_raw_as(model, json) + return parse_raw_as(model, json_str) + + +def json_to_pydantic_list(model: Type[_BaseModelT], json_str: str) -> List[_BaseModelT]: + """Parse a list of Pydantic objects stored in the SQL database.""" + return [parse_obj_as(model, obj_dict) for obj_dict in json.loads(json_str)] diff --git a/robot-server/robot_server/persistence/tables/schema_4.py b/robot-server/robot_server/persistence/tables/schema_4.py index 47d29d3d8f3..d1662bf7adc 100644 --- a/robot-server/robot_server/persistence/tables/schema_4.py +++ b/robot-server/robot_server/persistence/tables/schema_4.py @@ -85,6 +85,13 @@ sqlalchemy.Column("engine_status", sqlalchemy.String, nullable=True), # column added in schema v1 sqlalchemy.Column("_updated_at", UTCDateTime, nullable=True), + # column added in schema v4 + sqlalchemy.Column( + "run_time_parameters", + # Stores a JSON string. See RunStore. + sqlalchemy.String, + nullable=True, + ), ) action_table = sqlalchemy.Table( diff --git a/robot-server/robot_server/runs/run_controller.py b/robot-server/robot_server/runs/run_controller.py index 782754c1da6..923c9cfa64e 100644 --- a/robot-server/robot_server/runs/run_controller.py +++ b/robot-server/robot_server/runs/run_controller.py @@ -106,4 +106,5 @@ async def _run_protocol_and_insert_result( run_id=self._run_id, summary=result.state_summary, commands=result.commands, + run_time_parameters=result.parameters, ) diff --git a/robot-server/robot_server/runs/run_data_manager.py b/robot-server/robot_server/runs/run_data_manager.py index 570537a135c..154a1584823 100644 --- a/robot-server/robot_server/runs/run_data_manager.py +++ b/robot-server/robot_server/runs/run_data_manager.py @@ -22,13 +22,14 @@ from .run_store import RunResource, RunStore, BadRunResource, BadStateSummary from .run_models import Run, BadRun, RunDataError -from opentrons.protocol_engine.types import DeckConfigurationType +from opentrons.protocol_engine.types import DeckConfigurationType, RunTimeParameter def _build_run( run_resource: Union[RunResource, BadRunResource], state_summary: Union[StateSummary, BadStateSummary], current: bool, + run_time_parameters: List[RunTimeParameter], ) -> Union[Run, BadRun]: # TODO(mc, 2022-05-16): improve persistence strategy # such that this default summary object is not needed @@ -49,6 +50,7 @@ def _build_run( completedAt=state_summary.completedAt, startedAt=state_summary.startedAt, liquids=state_summary.liquids, + runTimeParameters=run_time_parameters, ) errors: List[EnumeratedError] = [] @@ -102,6 +104,7 @@ def _build_run( completedAt=state.completedAt, startedAt=state.startedAt, liquids=state.liquids, + runTimeParameters=run_time_parameters, ) @@ -172,6 +175,7 @@ async def create( run_id=prev_run_id, summary=prev_run_result.state_summary, commands=prev_run_result.commands, + run_time_parameters=prev_run_result.parameters, ) state_summary = await self._engine_store.create( run_id=run_id, @@ -196,6 +200,7 @@ async def create( run_resource=run_resource, state_summary=state_summary, current=True, + run_time_parameters=[], ) def get(self, run_id: str) -> Union[Run, BadRun]: @@ -215,9 +220,10 @@ def get(self, run_id: str) -> Union[Run, BadRun]: """ run_resource = self._run_store.get(run_id=run_id) state_summary = self._get_state_summary(run_id=run_id) + parameters = self._get_run_time_parameters(run_id=run_id) current = run_id == self._engine_store.current_run_id - return _build_run(run_resource, state_summary, current) + return _build_run(run_resource, state_summary, current, parameters) def get_run_loaded_labware_definitions( self, run_id: str @@ -260,6 +266,7 @@ def get_all(self, length: Optional[int]) -> List[Union[Run, BadRun]]: run_resource=run_resource, state_summary=self._get_state_summary(run_resource.run_id), current=run_resource.run_id == self._engine_store.current_run_id, + run_time_parameters=self._get_run_time_parameters(run_resource.run_id), ) for run_resource in self._run_store.get_all(length) ] @@ -310,15 +317,18 @@ async def update(self, run_id: str, current: Optional[bool]) -> Union[Run, BadRu run_id=run_id, summary=state_summary, commands=commands, + run_time_parameters=parameters, ) else: state_summary = self._engine_store.engine.state_view.get_summary() + parameters = self._engine_store.runner.run_time_parameters run_resource = self._run_store.get(run_id=run_id) return _build_run( run_resource=run_resource, state_summary=state_summary, current=next_current, + run_time_parameters=parameters, ) def get_commands_slice( @@ -385,3 +395,9 @@ def _get_state_summary(self, run_id: str) -> Union[StateSummary, BadStateSummary def _get_good_state_summary(self, run_id: str) -> Optional[StateSummary]: summary = self._get_state_summary(run_id) return summary if isinstance(summary, StateSummary) else None + + def _get_run_time_parameters(self, run_id: str) -> List[RunTimeParameter]: + if run_id == self._engine_store.current_run_id: + return self._engine_store.runner.run_time_parameters + else: + return self._run_store.get_run_time_parameters(run_id=run_id) diff --git a/robot-server/robot_server/runs/run_models.py b/robot-server/robot_server/runs/run_models.py index 7da6e0b0a5d..c93049bfef4 100644 --- a/robot-server/robot_server/runs/run_models.py +++ b/robot-server/robot_server/runs/run_models.py @@ -18,7 +18,7 @@ Liquid, CommandNote, ) -from opentrons.protocol_engine.types import RunTimeParamValuesType +from opentrons.protocol_engine.types import RunTimeParameter, RunTimeParamValuesType from opentrons_shared_data.errors import GeneralError from robot_server.service.json_api import ResourceModel from robot_server.errors.error_responses import ErrorDetails @@ -121,6 +121,15 @@ class Run(ResourceModel): ..., description="Labware offsets to apply as labware are loaded.", ) + runTimeParameters: List[RunTimeParameter] = Field( + default_factory=list, + description=( + "Run time parameters used during the run." + " These are the parameters that are defined in the protocol, with values" + " specified either in the run creation request or default values from the protocol" + " if none are specified in the request." + ), + ) protocolId: Optional[str] = Field( None, description=( @@ -185,6 +194,15 @@ class BadRun(ResourceModel): ..., description="Labware offsets to apply as labware are loaded.", ) + runTimeParameters: List[RunTimeParameter] = Field( + default_factory=list, + description=( + "Run time parameters used during the run." + " These are the parameters that are defined in the protocol, with values" + " specified either in the run creation request or default values from the protocol" + " if none are specified in the request." + ), + ) protocolId: Optional[str] = Field( None, description=( diff --git a/robot-server/robot_server/runs/run_store.py b/robot-server/robot_server/runs/run_store.py index 5aa6dbae96b..b86ec8e19ea 100644 --- a/robot-server/robot_server/runs/run_store.py +++ b/robot-server/robot_server/runs/run_store.py @@ -12,6 +12,7 @@ from opentrons.util.helpers import utc_now from opentrons.protocol_engine import StateSummary, CommandSlice from opentrons.protocol_engine.commands import Command +from opentrons.protocol_engine.types import RunTimeParameter from opentrons_shared_data.errors.exceptions import ( EnumeratedError, @@ -25,7 +26,12 @@ run_command_table, action_table, ) -from robot_server.persistence.pydantic import json_to_pydantic, pydantic_to_json +from robot_server.persistence.pydantic import ( + json_to_pydantic, + pydantic_to_json, + json_to_pydantic_list, + pydantic_list_to_json, +) from robot_server.protocols.protocol_store import ProtocolNotFoundError from .action_models import RunAction, RunActionType @@ -102,6 +108,7 @@ def update_run_state( run_id: str, summary: StateSummary, commands: List[Command], + run_time_parameters: List[RunTimeParameter], ) -> RunResource: """Update the run's state summary and commands list. @@ -109,6 +116,7 @@ def update_run_state( run_id: The run to update summary: The run's equipment and status summary. commands: The run's commands. + run_time_parameters: The run's run time parameters, if any. Returns: The run resource. @@ -124,6 +132,7 @@ def update_run_state( run_id=run_id, state_summary=summary, engine_status=summary.status, + run_time_parameters=run_time_parameters, ) ) ) @@ -346,6 +355,33 @@ def get_state_summary(self, run_id: str) -> Union[StateSummary, BadStateSummary] ) ) + @lru_cache(maxsize=_CACHE_ENTRIES) + def get_run_time_parameters(self, run_id: str) -> List[RunTimeParameter]: + """Get the archived run time parameters. + + This is a list of the run's parameter definitions (if any), + including the values used in the run itself, along with the default value, + constraints and associated names and descriptions. + """ + select_run_data = sqlalchemy.select(run_table.c.run_time_parameters).where( + run_table.c.id == run_id + ) + + with self._sql_engine.begin() as transaction: + row = transaction.execute(select_run_data).one() + + try: + return ( + json_to_pydantic_list(RunTimeParameter, row.run_time_parameters) # type: ignore[arg-type] + if row.run_time_parameters is not None + else [] + ) + except ValidationError: + log.warning( + f"Error retrieving run time parameters for {run_id}", exc_info=True + ) + return [] + def get_commands_slice( self, run_id: str, @@ -476,6 +512,7 @@ def _clear_caches(self) -> None: self.get_all.cache_clear() self.get_state_summary.cache_clear() self.get_command.cache_clear() + self.get_run_time_parameters.cache_clear() # The columns that must be present in a row passed to _convert_row_to_run(). @@ -552,9 +589,11 @@ def _convert_state_to_sql_values( run_id: str, state_summary: StateSummary, engine_status: str, + run_time_parameters: List[RunTimeParameter], ) -> Dict[str, object]: return { "state_summary": pydantic_to_json(state_summary), "engine_status": engine_status, "_updated_at": utc_now(), + "run_time_parameters": pydantic_list_to_json(run_time_parameters), } diff --git a/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml index 4ff631bf277..e7ac3483dd7 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v6_protocol_run.tavern.yaml @@ -50,6 +50,7 @@ stages: displayName: Water description: Liquid H2O displayColor: '#7332a8' + runTimeParameters: [] protocolId: '{protocol_id}' - name: Execute a setup command diff --git a/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml index 317d339fbbf..bdc4ad4a66d 100644 --- a/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_json_v7_protocol_run.tavern.yaml @@ -45,6 +45,7 @@ stages: definitionUri: opentrons/opentrons_1_trash_1100ml_fixed/1 location: !anydict labwareOffsets: [] + runTimeParameters: [] liquids: - id: waterId displayName: Water diff --git a/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml index 48dc570d6c9..67d1511a666 100644 --- a/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_protocol_run.tavern.yaml @@ -42,6 +42,7 @@ stages: definitionUri: opentrons/opentrons_1_trash_1100ml_fixed/1 location: !anydict labwareOffsets: [] + runTimeParameters: [] protocolId: '{protocol_id}' liquids: [] save: @@ -237,6 +238,7 @@ stages: createdAt: !re_search "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+\\+\\d{2}:\\d{2}$" startedAt: !re_search "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+\\+\\d{2}:\\d{2}$" liquids: [] + runTimeParameters: [] completedAt: !re_search "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d+\\+\\d{2}:\\d{2}$" errors: [] pipettes: [] diff --git a/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml index cc8cea69356..0d4a0010281 100644 --- a/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml +++ b/robot-server/tests/integration/http_api/runs/test_run_queued_protocol_commands.tavern.yaml @@ -94,6 +94,7 @@ stages: labware: [] labwareOffsets: [] liquids: [] + runTimeParameters: [] modules: [] pipettes: [] status: 'idle' diff --git a/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml new file mode 100644 index 00000000000..d7f075b18cb --- /dev/null +++ b/robot-server/tests/integration/http_api/runs/test_run_with_run_time_parameters.tavern.yaml @@ -0,0 +1,203 @@ +test_name: Test the run endpoints with run time parameters + +marks: + - usefixtures: + - ot2_server_base_url + +stages: + - name: Upload a protocol + request: + url: '{ot2_server_base_url}/protocols' + method: POST + files: + files: 'tests/integration/protocols/basic_transfer_with_run_time_parameters.py' + response: + status_code: 201 + save: + json: + protocol_id: data.id + + - name: Create run from protocol + request: + url: '{ot2_server_base_url}/runs' + method: POST + json: + data: + protocolId: '{protocol_id}' + runTimeParameterValues: + sample_count: 4 + volume: 10.23 + dry_run: True + pipette: flex_8channel_50 + response: + status_code: 201 + save: + json: + run_id: data.id + json: + data: + id: !anystr + ok: True + createdAt: !anystr + status: idle + current: True + actions: [] + errors: [] + pipettes: [] + modules: [] + labware: [] + labwareOffsets: [] + runTimeParameters: [] + liquids: [] + protocolId: '{protocol_id}' + + - name: Play the run + request: + url: '{ot2_server_base_url}/runs/{run_id}/actions' + method: POST + json: + data: + actionType: play + response: + status_code: 201 + json: + data: + id: !anystr + actionType: play + createdAt: !anystr + + - name: Wait for the protocol to complete + max_retries: 10 + delay_after: 0.1 + request: + url: '{ot2_server_base_url}/runs/{run_id}' + method: GET + response: + status_code: 200 + strict: + - json:off + json: + data: + status: succeeded + + - name: Verify the run contains the set run time parameters + request: + url: '{ot2_server_base_url}/runs/{run_id}' + method: GET + response: + status_code: 200 + strict: + - json:off + json: + data: + id: !anystr + ok: True + createdAt: !anystr + status: succeeded + current: True + runTimeParameters: + - displayName: Sample count + variableName: sample_count + type: int + default: 6.0 + min: 1.0 + max: 12.0 + value: 4.0 + description: How many samples to process. + - displayName: Pipette volume + variableName: volume + type: float + default: 20.1 + choices: + - displayName: Low Volume + value: 10.23 + - displayName: Medium Volume + value: 20.1 + - displayName: High Volume + value: 50.5 + value: 10.23 + description: How many microliters to pipette of each sample. + - displayName: Dry Run + variableName: dry_run + type: bool + default: false + value: true + description: Skip aspirate and dispense steps. + - displayName: Pipette Name + variableName: pipette + type: str + choices: + - displayName: Single channel 50µL + value: flex_1channel_50 + - displayName: Eight Channel 50µL + value: flex_8channel_50 + default: flex_1channel_50 + value: flex_8channel_50 + description: What pipette to use during the protocol. + protocolId: '{protocol_id}' + + - name: Mark the run as not-current + request: + url: '{ot2_server_base_url}/runs/{run_id}' + method: PATCH + json: + data: + current: False + response: + status_code: 200 + + - name: Verify the archived run still contains the set run time parameters + request: + url: '{ot2_server_base_url}/runs/{run_id}' + method: GET + response: + status_code: 200 + strict: + - json:off + json: + data: + id: !anystr + ok: True + createdAt: !anystr + status: succeeded + current: False + runTimeParameters: + - displayName: Sample count + variableName: sample_count + type: int + default: 6.0 + min: 1.0 + max: 12.0 + value: 4.0 + description: How many samples to process. + - displayName: Pipette volume + variableName: volume + type: float + default: 20.1 + choices: + - displayName: Low Volume + value: 10.23 + - displayName: Medium Volume + value: 20.1 + - displayName: High Volume + value: 50.5 + value: 10.23 + description: How many microliters to pipette of each sample. + - displayName: Dry Run + variableName: dry_run + type: bool + default: false + value: true + description: Skip aspirate and dispense steps. + - displayName: Pipette Name + variableName: pipette + type: str + choices: + - displayName: Single channel 50µL + value: flex_1channel_50 + - displayName: Eight Channel 50µL + value: flex_8channel_50 + default: flex_1channel_50 + value: flex_8channel_50 + description: What pipette to use during the protocol. + protocolId: '{protocol_id}' diff --git a/robot-server/tests/persistence/test_tables.py b/robot-server/tests/persistence/test_tables.py index eaa2824ce75..5f3c45adcaa 100644 --- a/robot-server/tests/persistence/test_tables.py +++ b/robot-server/tests/persistence/test_tables.py @@ -56,6 +56,7 @@ state_summary VARCHAR, engine_status VARCHAR, _updated_at DATETIME, + run_time_parameters VARCHAR, PRIMARY KEY (id), FOREIGN KEY(protocol_id) REFERENCES protocol (id) ) diff --git a/robot-server/tests/runs/test_run_controller.py b/robot-server/tests/runs/test_run_controller.py index 5bf5778c486..a844cdcc6d5 100644 --- a/robot-server/tests/runs/test_run_controller.py +++ b/robot-server/tests/runs/test_run_controller.py @@ -11,6 +11,7 @@ commands as pe_commands, errors as pe_errors, ) +from opentrons.protocol_engine.types import RunTimeParameter, BooleanParameter from opentrons.protocol_runner import RunResult, JsonRunner, PythonAndLegacyRunner from robot_server.service.task_runner import TaskRunner @@ -60,6 +61,19 @@ def engine_state_summary() -> StateSummary: ) +@pytest.fixture() +def run_time_parameters() -> List[RunTimeParameter]: + """Get a RunTimeParameter list.""" + return [ + BooleanParameter( + displayName="Display Name", + variableName="variable_name", + value=False, + default=True, + ) + ] + + @pytest.fixture def protocol_commands() -> List[pe_commands.Command]: """Get a StateSummary value object.""" @@ -122,6 +136,7 @@ async def test_create_play_action_to_start( mock_run_store: RunStore, mock_task_runner: TaskRunner, engine_state_summary: StateSummary, + run_time_parameters: List[RunTimeParameter], protocol_commands: List[pe_commands.Command], run_id: str, subject: RunController, @@ -153,7 +168,7 @@ async def test_create_play_action_to_start( RunResult( commands=protocol_commands, state_summary=engine_state_summary, - parameters=[], + parameters=run_time_parameters, ) ) @@ -164,6 +179,7 @@ async def test_create_play_action_to_start( run_id=run_id, summary=engine_state_summary, commands=protocol_commands, + run_time_parameters=run_time_parameters, ), times=1, ) diff --git a/robot-server/tests/runs/test_run_data_manager.py b/robot-server/tests/runs/test_run_data_manager.py index ba4ceec8799..547ec0a7b74 100644 --- a/robot-server/tests/runs/test_run_data_manager.py +++ b/robot-server/tests/runs/test_run_data_manager.py @@ -1,5 +1,5 @@ """Tests for RunDataManager.""" -from typing import Optional +from typing import Optional, List import pytest from datetime import datetime @@ -85,6 +85,19 @@ def engine_state_summary() -> StateSummary: ) +@pytest.fixture() +def run_time_parameters() -> List[pe_types.RunTimeParameter]: + """Get a RunTimeParameter list.""" + return [ + pe_types.BooleanParameter( + displayName="Display Name", + variableName="variable_name", + value=False, + default=True, + ) + ] + + @pytest.fixture def run_resource() -> RunResource: """Get a StateSummary value object.""" @@ -299,6 +312,7 @@ async def test_get_current_run( mock_run_store: RunStore, subject: RunDataManager, engine_state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], run_resource: RunResource, ) -> None: """It should get the current run from the engine.""" @@ -309,6 +323,9 @@ async def test_get_current_run( decoy.when(mock_engine_store.engine.state_view.get_summary()).then_return( engine_state_summary ) + decoy.when(mock_engine_store.runner.run_time_parameters).then_return( + run_time_parameters + ) result = subject.get(run_id=run_id) @@ -325,6 +342,7 @@ async def test_get_current_run( pipettes=engine_state_summary.pipettes, modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, + runTimeParameters=run_time_parameters, ) assert subject.current_run_id == run_id @@ -335,6 +353,7 @@ async def test_get_historical_run( mock_run_store: RunStore, subject: RunDataManager, engine_state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], run_resource: RunResource, ) -> None: """It should get a historical run from the store.""" @@ -344,6 +363,9 @@ async def test_get_historical_run( decoy.when(mock_run_store.get_state_summary(run_id=run_id)).then_return( engine_state_summary ) + decoy.when(mock_run_store.get_run_time_parameters(run_id=run_id)).then_return( + run_time_parameters + ) decoy.when(mock_engine_store.current_run_id).then_return("some other id") result = subject.get(run_id=run_id) @@ -361,6 +383,7 @@ async def test_get_historical_run( pipettes=engine_state_summary.pipettes, modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, + runTimeParameters=run_time_parameters, ) @@ -370,6 +393,7 @@ async def test_get_historical_run_no_data( mock_run_store: RunStore, subject: RunDataManager, run_resource: RunResource, + run_time_parameters: List[pe_types.RunTimeParameter], ) -> None: """It should get a historical run from the store.""" run_id = "hello world" @@ -380,6 +404,9 @@ async def test_get_historical_run_no_data( decoy.when(mock_run_store.get_state_summary(run_id=run_id)).then_return( BadStateSummary(dataError=state_exc) ) + decoy.when(mock_run_store.get_run_time_parameters(run_id=run_id)).then_return( + run_time_parameters + ) decoy.when(mock_engine_store.current_run_id).then_return("some other id") result = subject.get(run_id=run_id) @@ -398,6 +425,7 @@ async def test_get_historical_run_no_data( pipettes=[], modules=[], liquids=[], + runTimeParameters=run_time_parameters, ) @@ -417,6 +445,14 @@ async def test_get_all_runs( modules=[LoadedModule.construct(id="current-module-id")], # type: ignore[call-arg] liquids=[Liquid(id="some-liquid-id", displayName="liquid", description="desc")], ) + current_run_time_parameters: List[pe_types.RunTimeParameter] = [ + pe_types.BooleanParameter( + displayName="Current Bool", + variableName="current bool", + value=False, + default=True, + ) + ] historical_run_data = StateSummary( status=EngineStatus.STOPPED, @@ -427,6 +463,14 @@ async def test_get_all_runs( modules=[LoadedModule.construct(id="old-module-id")], # type: ignore[call-arg] liquids=[], ) + historical_run_time_parameters: List[pe_types.RunTimeParameter] = [ + pe_types.BooleanParameter( + displayName="Old Bool", + variableName="Old bool", + value=True, + default=False, + ) + ] current_run_resource = RunResource( ok=True, @@ -448,9 +492,15 @@ async def test_get_all_runs( decoy.when(mock_engine_store.engine.state_view.get_summary()).then_return( current_run_data ) + decoy.when(mock_engine_store.runner.run_time_parameters).then_return( + current_run_time_parameters + ) decoy.when(mock_run_store.get_state_summary("historical-run")).then_return( historical_run_data ) + decoy.when(mock_run_store.get_run_time_parameters("historical-run")).then_return( + historical_run_time_parameters + ) decoy.when(mock_run_store.get_all(length=20)).then_return( [historical_run_resource, current_run_resource] ) @@ -471,6 +521,7 @@ async def test_get_all_runs( pipettes=historical_run_data.pipettes, modules=historical_run_data.modules, liquids=historical_run_data.liquids, + runTimeParameters=historical_run_time_parameters, ), Run( current=True, @@ -485,6 +536,7 @@ async def test_get_all_runs( pipettes=current_run_data.pipettes, modules=current_run_data.modules, liquids=current_run_data.liquids, + runTimeParameters=current_run_time_parameters, ), ] @@ -526,6 +578,7 @@ async def test_delete_historical_run( async def test_update_current( decoy: Decoy, engine_state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], run_resource: RunResource, run_command: commands.Command, mock_engine_store: EngineStore, @@ -537,7 +590,9 @@ async def test_update_current( decoy.when(mock_engine_store.current_run_id).then_return(run_id) decoy.when(await mock_engine_store.clear()).then_return( RunResult( - commands=[run_command], state_summary=engine_state_summary, parameters=[] + commands=[run_command], + state_summary=engine_state_summary, + parameters=run_time_parameters, ) ) @@ -546,6 +601,7 @@ async def test_update_current( run_id=run_id, summary=engine_state_summary, commands=[run_command], + run_time_parameters=run_time_parameters, ) ).then_return(run_resource) @@ -564,6 +620,7 @@ async def test_update_current( pipettes=engine_state_summary.pipettes, modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, + runTimeParameters=run_time_parameters, ) @@ -571,6 +628,7 @@ async def test_update_current( async def test_update_current_noop( decoy: Decoy, engine_state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], run_resource: RunResource, run_command: commands.Command, mock_engine_store: EngineStore, @@ -584,6 +642,9 @@ async def test_update_current_noop( decoy.when(mock_engine_store.engine.state_view.get_summary()).then_return( engine_state_summary ) + decoy.when(mock_engine_store.runner.run_time_parameters).then_return( + run_time_parameters + ) decoy.when(mock_run_store.get(run_id=run_id)).then_return(run_resource) result = await subject.update(run_id=run_id, current=current) @@ -594,6 +655,7 @@ async def test_update_current_noop( run_id=run_id, summary=matchers.Anything(), commands=matchers.Anything(), + run_time_parameters=matchers.Anything(), ), times=0, ) @@ -611,6 +673,7 @@ async def test_update_current_noop( pipettes=engine_state_summary.pipettes, modules=engine_state_summary.modules, liquids=engine_state_summary.liquids, + runTimeParameters=run_time_parameters, ) @@ -634,6 +697,7 @@ async def test_update_current_not_allowed( async def test_create_archives_existing( decoy: Decoy, engine_state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], run_resource: RunResource, run_command: commands.Command, mock_engine_store: EngineStore, @@ -647,7 +711,9 @@ async def test_create_archives_existing( decoy.when(mock_engine_store.current_run_id).then_return(run_id_old) decoy.when(await mock_engine_store.clear()).then_return( RunResult( - commands=[run_command], state_summary=engine_state_summary, parameters=[] + commands=[run_command], + state_summary=engine_state_summary, + parameters=run_time_parameters, ) ) @@ -685,6 +751,7 @@ async def test_create_archives_existing( run_id=run_id_old, summary=engine_state_summary, commands=[run_command], + run_time_parameters=run_time_parameters, ) ) diff --git a/robot-server/tests/runs/test_run_store.py b/robot-server/tests/runs/test_run_store.py index 31cabbe56bd..c6108cf5407 100644 --- a/robot-server/tests/runs/test_run_store.py +++ b/robot-server/tests/runs/test_run_store.py @@ -120,6 +120,41 @@ def state_summary() -> StateSummary: ) +@pytest.fixture() +def run_time_parameters() -> List[pe_types.RunTimeParameter]: + """Get a RunTimeParameter list.""" + return [ + pe_types.BooleanParameter( + displayName="Display Name 1", + variableName="variable_name_1", + value=False, + default=True, + ), + pe_types.NumberParameter( + displayName="Display Name 2", + variableName="variable_name_2", + type="int", + min=123.0, + max=456.0, + value=333.0, + default=222.0, + ), + pe_types.EnumParameter( + displayName="Display Name 3", + variableName="variable_name_3", + type="str", + choices=[ + pe_types.EnumChoice( + displayName="Choice Name", + value="cool choice", + ) + ], + default="cooler choice", + value="coolest choice", + ), + ] + + @pytest.fixture def invalid_state_summary() -> StateSummary: """Should fail pydantic validation.""" @@ -164,6 +199,7 @@ def test_update_run_state( subject: RunStore, state_summary: StateSummary, protocol_commands: List[pe_commands.Command], + run_time_parameters: List[pe_types.RunTimeParameter], mock_runs_publisher: mock.Mock, ) -> None: """It should be able to update a run state to the store.""" @@ -184,8 +220,10 @@ def test_update_run_state( run_id="run-id", summary=state_summary, commands=protocol_commands, + run_time_parameters=run_time_parameters, ) run_summary_result = subject.get_state_summary(run_id="run-id") + parameters_result = subject.get_run_time_parameters(run_id="run-id") commands_result = subject.get_commands_slice( run_id="run-id", length=len(protocol_commands), @@ -200,6 +238,7 @@ def test_update_run_state( actions=[action], ) assert run_summary_result == state_summary + assert parameters_result == run_time_parameters assert commands_result.commands == protocol_commands mock_runs_publisher.publish_runs_advise_refetch.assert_called_once_with( run_id="run-id" @@ -217,6 +256,7 @@ def test_update_state_run_not_found( run_id="run-not-found", summary=state_summary, commands=protocol_commands, + run_time_parameters=[], ) @@ -436,7 +476,9 @@ def test_get_state_summary( protocol_id=None, created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), ) - subject.update_run_state(run_id="run-id", summary=state_summary, commands=[]) + subject.update_run_state( + run_id="run-id", summary=state_summary, commands=[], run_time_parameters=[] + ) result = subject.get_state_summary(run_id="run-id") assert result == state_summary mock_runs_publisher.publish_runs_advise_refetch.assert_called_once_with( @@ -454,7 +496,10 @@ def test_get_state_summary_failure( created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), ) subject.update_run_state( - run_id="run-id", summary=invalid_state_summary, commands=[] + run_id="run-id", + summary=invalid_state_summary, + commands=[], + run_time_parameters=[], ) result = subject.get_state_summary(run_id="run-id") assert isinstance(result, BadStateSummary) @@ -473,6 +518,62 @@ def test_get_state_summary_none(subject: RunStore) -> None: assert result.dataError.code == ErrorCodes.INVALID_STORED_DATA +def test_get_run_time_parameters( + subject: RunStore, + state_summary: StateSummary, + run_time_parameters: List[pe_types.RunTimeParameter], +) -> None: + """It should be able to get store run time parameters.""" + subject.insert( + run_id="run-id", + protocol_id=None, + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ) + subject.update_run_state( + run_id="run-id", + summary=state_summary, + commands=[], + run_time_parameters=run_time_parameters, + ) + result = subject.get_run_time_parameters(run_id="run-id") + assert result == run_time_parameters + + +def test_get_run_time_parameters_invalid( + subject: RunStore, + state_summary: StateSummary, +) -> None: + """It should return an empty list if there invalid parameters.""" + bad_parameters = [pe_types.BooleanParameter.construct(foo="bar")] # type: ignore[call-arg] + subject.insert( + run_id="run-id", + protocol_id=None, + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ) + subject.update_run_state( + run_id="run-id", + summary=state_summary, + commands=[], + run_time_parameters=bad_parameters, # type: ignore[arg-type] + ) + result = subject.get_run_time_parameters(run_id="run-id") + assert result == [] + + +def test_get_run_time_parameters_none( + subject: RunStore, + state_summary: StateSummary, +) -> None: + """It should return an empty list if there are no run time parameters associated.""" + subject.insert( + run_id="run-id", + protocol_id=None, + created_at=datetime(year=2021, month=1, day=1, tzinfo=timezone.utc), + ) + result = subject.get_run_time_parameters(run_id="run-id") + assert result == [] + + def test_has_run_id(subject: RunStore) -> None: """It should tell us if a given ID is in the store.""" subject.insert( @@ -503,6 +604,7 @@ def test_get_command( run_id="run-id", summary=state_summary, commands=protocol_commands, + run_time_parameters=[], ) result = subject.get_command(run_id="run-id", command_id="pause-2") @@ -532,6 +634,7 @@ def test_get_command_raise_exception( run_id="run-id", summary=state_summary, commands=protocol_commands, + run_time_parameters=[], ) with pytest.raises(expected_exception): subject.get_command(run_id=input_run_id, command_id=input_command_id) @@ -552,6 +655,7 @@ def test_get_command_slice( run_id="run-id", summary=state_summary, commands=protocol_commands, + run_time_parameters=[], ) result = subject.get_commands_slice( run_id="run-id", cursor=0, length=len(protocol_commands) @@ -598,6 +702,7 @@ def test_get_commands_slice_clamping( run_id="run-id", summary=state_summary, commands=protocol_commands, + run_time_parameters=[], ) result = subject.get_commands_slice( run_id="run-id", cursor=input_cursor, length=input_length