diff --git a/api/src/opentrons/config/defaults_ot3.py b/api/src/opentrons/config/defaults_ot3.py index 7bb2e0c8f30..96aa483cc30 100644 --- a/api/src/opentrons/config/defaults_ot3.py +++ b/api/src/opentrons/config/defaults_ot3.py @@ -1,8 +1,8 @@ -from typing import Any, Dict, cast, List, Iterable, Tuple +from typing import Any, Dict, cast, List, Iterable, Tuple, Optional from typing_extensions import Final from dataclasses import asdict -from opentrons.hardware_control.types import OT3AxisKind +from opentrons.hardware_control.types import OT3AxisKind, InstrumentProbeType from .types import ( OT3Config, ByGantryLoad, @@ -33,7 +33,7 @@ aspirate_while_sensing=False, auto_zero_sensor=True, num_baseline_reads=10, - data_file="/var/pressure_sensor_data.csv", + data_files={InstrumentProbeType.PRIMARY: "/data/pressure_sensor_data.csv"}, ) DEFAULT_CALIBRATION_SETTINGS: Final[OT3CalibrationSettings] = OT3CalibrationSettings( @@ -193,6 +193,49 @@ ) +def _build_output_option_with_default( + from_conf: Any, default: OutputOptions +) -> OutputOptions: + if from_conf is None: + return default + else: + if isinstance(from_conf, OutputOptions): + return from_conf + else: + try: + enumval = OutputOptions[from_conf] + except KeyError: # not an enum entry + return default + else: + return enumval + + +def _build_log_files_with_default( + from_conf: Any, + default: Optional[Dict[InstrumentProbeType, str]], +) -> Optional[Dict[InstrumentProbeType, str]]: + print(f"from_conf {from_conf} default {default}") + if not isinstance(from_conf, dict): + if default is None: + return None + else: + return {k: v for k, v in default.items()} + else: + validated: Dict[InstrumentProbeType, str] = {} + for k, v in from_conf.items(): + if isinstance(k, InstrumentProbeType): + validated[k] = v + else: + try: + enumval = InstrumentProbeType[k] + except KeyError: # not an enum entry + pass + else: + validated[enumval] = v + print(f"result {validated}") + return validated + + def _build_dict_with_default( from_conf: Any, default: Dict[OT3AxisKind, float], @@ -277,6 +320,17 @@ def _build_default_cap_pass( def _build_default_liquid_probe( from_conf: Any, default: LiquidProbeSettings ) -> LiquidProbeSettings: + output_option = _build_output_option_with_default( + from_conf.get("output_option", None), default.output_option + ) + data_files: Optional[Dict[InstrumentProbeType, str]] = None + if ( + output_option is OutputOptions.sync_buffer_to_csv + or output_option is OutputOptions.stream_to_csv + ): + data_files = _build_log_files_with_default( + from_conf.get("data_files", {}), default.data_files + ) return LiquidProbeSettings( max_z_distance=from_conf.get("max_z_distance", default.max_z_distance), min_z_distance=from_conf.get("min_z_distance", default.min_z_distance), @@ -298,7 +352,7 @@ def _build_default_liquid_probe( num_baseline_reads=from_conf.get( "num_baseline_reads", default.num_baseline_reads ), - data_file=from_conf.get("data_file", default.data_file), + data_files=data_files, ) @@ -408,7 +462,7 @@ def build_with_defaults(robot_settings: Dict[str, Any]) -> OT3Config: def serialize(config: OT3Config) -> Dict[str, Any]: def _build_dict(pairs: Iterable[Tuple[Any, Any]]) -> Dict[str, Any]: def _normalize_key(key: Any) -> Any: - if isinstance(key, OT3AxisKind): + if isinstance(key, OT3AxisKind) or isinstance(key, InstrumentProbeType): return key.name return key diff --git a/api/src/opentrons/config/types.py b/api/src/opentrons/config/types.py index ab97233f797..15a5cceddc8 100644 --- a/api/src/opentrons/config/types.py +++ b/api/src/opentrons/config/types.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, asdict, fields from typing import Dict, Tuple, TypeVar, Generic, List, cast, Optional from typing_extensions import TypedDict, Literal -from opentrons.hardware_control.types import OT3AxisKind +from opentrons.hardware_control.types import OT3AxisKind, InstrumentProbeType class AxisDict(TypedDict): @@ -138,7 +138,7 @@ class LiquidProbeSettings: aspirate_while_sensing: bool auto_zero_sensor: bool num_baseline_reads: int - data_file: Optional[str] + data_files: Optional[Dict[InstrumentProbeType, str]] @dataclass(frozen=True) diff --git a/api/src/opentrons/hardware_control/backends/flex_protocol.py b/api/src/opentrons/hardware_control/backends/flex_protocol.py index 1a63ec04f08..53efde79a23 100644 --- a/api/src/opentrons/hardware_control/backends/flex_protocol.py +++ b/api/src/opentrons/hardware_control/backends/flex_protocol.py @@ -147,7 +147,7 @@ async def liquid_probe( plunger_speed: float, threshold_pascals: float, output_format: OutputOptions = OutputOptions.can_bus_only, - data_file: Optional[str] = None, + data_files: Optional[Dict[InstrumentProbeType, str]] = None, auto_zero_sensor: bool = True, num_baseline_reads: int = 10, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index 1a880075d1e..17411cc4ee5 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -1357,7 +1357,7 @@ async def liquid_probe( plunger_speed: float, threshold_pascals: float, output_option: OutputOptions = OutputOptions.can_bus_only, - data_file: Optional[str] = None, + data_files: Optional[Dict[InstrumentProbeType, str]] = None, auto_zero_sensor: bool = True, num_baseline_reads: int = 10, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, @@ -1378,6 +1378,14 @@ async def liquid_probe( can_bus_only_output = bool( output_option.value & OutputOptions.can_bus_only.value ) + data_files_transposed = ( + None + if data_files is None + else { + sensor_id_for_instrument(probe): data_files[probe] + for probe in data_files.keys() + } + ) positions = await liquid_probe( messenger=self._messenger, tool=tool, @@ -1389,7 +1397,7 @@ async def liquid_probe( csv_output=csv_output, sync_buffer_output=sync_buffer_output, can_bus_only_output=can_bus_only_output, - data_file=data_file, + data_files=data_files_transposed, auto_zero_sensor=auto_zero_sensor, num_baseline_reads=num_baseline_reads, sensor_id=sensor_id_for_instrument(probe), diff --git a/api/src/opentrons/hardware_control/backends/ot3simulator.py b/api/src/opentrons/hardware_control/backends/ot3simulator.py index 741018adc52..b96be54026e 100644 --- a/api/src/opentrons/hardware_control/backends/ot3simulator.py +++ b/api/src/opentrons/hardware_control/backends/ot3simulator.py @@ -346,7 +346,7 @@ async def liquid_probe( plunger_speed: float, threshold_pascals: float, output_format: OutputOptions = OutputOptions.can_bus_only, - data_file: Optional[str] = None, + data_files: Optional[Dict[InstrumentProbeType, str]] = None, auto_zero_sensor: bool = True, num_baseline_reads: int = 10, probe: InstrumentProbeType = InstrumentProbeType.PRIMARY, diff --git a/api/src/opentrons/hardware_control/backends/ot3utils.py b/api/src/opentrons/hardware_control/backends/ot3utils.py index d585a48f99d..a9108c2365e 100644 --- a/api/src/opentrons/hardware_control/backends/ot3utils.py +++ b/api/src/opentrons/hardware_control/backends/ot3utils.py @@ -544,6 +544,7 @@ def sensor_node_for_pipette(mount: OT3Mount) -> PipetteProbeTarget: _instr_sensor_id_lookup: Dict[InstrumentProbeType, SensorId] = { InstrumentProbeType.PRIMARY: SensorId.S0, InstrumentProbeType.SECONDARY: SensorId.S1, + InstrumentProbeType.BOTH: SensorId.BOTH, } diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 5c0941f95bc..0c02ff07727 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -2623,7 +2623,7 @@ async def liquid_probe( (probe_settings.plunger_speed * plunger_direction), probe_settings.sensor_threshold_pascals, probe_settings.output_option, - probe_settings.data_file, + probe_settings.data_files, probe_settings.auto_zero_sensor, probe_settings.num_baseline_reads, probe=probe if probe else InstrumentProbeType.PRIMARY, diff --git a/api/src/opentrons/hardware_control/types.py b/api/src/opentrons/hardware_control/types.py index 9a153a447d5..1ea79652f34 100644 --- a/api/src/opentrons/hardware_control/types.py +++ b/api/src/opentrons/hardware_control/types.py @@ -624,6 +624,7 @@ class GripperJawState(enum.Enum): class InstrumentProbeType(enum.Enum): PRIMARY = enum.auto() SECONDARY = enum.auto() + BOTH = enum.auto() class GripperProbe(enum.Enum): diff --git a/api/tests/opentrons/config/ot3_settings.py b/api/tests/opentrons/config/ot3_settings.py index e9f840486af..3cfa9b7c34c 100644 --- a/api/tests/opentrons/config/ot3_settings.py +++ b/api/tests/opentrons/config/ot3_settings.py @@ -129,7 +129,7 @@ "aspirate_while_sensing": False, "auto_zero_sensor": True, "num_baseline_reads": 10, - "data_file": "/var/pressure_sensor_data.csv", + "data_files": {"PRIMARY": "/data/pressure_sensor_data.csv"}, }, "calibration": { "z_offset": { diff --git a/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py b/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py index 12743993d33..ed639444b3d 100644 --- a/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py +++ b/api/tests/opentrons/hardware_control/backends/test_ot3_controller.py @@ -61,6 +61,7 @@ UpdateState, EstopState, CurrentConfig, + InstrumentProbeType, ) from opentrons.hardware_control.errors import ( InvalidPipetteName, @@ -185,7 +186,7 @@ def fake_liquid_settings() -> LiquidProbeSettings: aspirate_while_sensing=False, auto_zero_sensor=False, num_baseline_reads=8, - data_file="fake_data_file", + data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index b10628cf99e..7ab0a2f1c00 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_api.py +++ b/api/tests/opentrons/hardware_control/test_ot3_api.py @@ -124,7 +124,7 @@ def fake_liquid_settings() -> LiquidProbeSettings: aspirate_while_sensing=False, auto_zero_sensor=False, num_baseline_reads=10, - data_file="fake_file_name", + data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) @@ -809,7 +809,7 @@ async def test_liquid_probe( aspirate_while_sensing=True, auto_zero_sensor=False, num_baseline_reads=10, - data_file="fake_file_name", + data_files={InstrumentProbeType.PRIMARY: "fake_file_name"}, ) await ot3_hardware.liquid_probe(mount, fake_settings_aspirate) mock_move_to_plunger_bottom.assert_called_once() @@ -820,7 +820,7 @@ async def test_liquid_probe( (fake_settings_aspirate.plunger_speed * -1), fake_settings_aspirate.sensor_threshold_pascals, fake_settings_aspirate.output_option, - fake_settings_aspirate.data_file, + fake_settings_aspirate.data_files, fake_settings_aspirate.auto_zero_sensor, fake_settings_aspirate.num_baseline_reads, probe=InstrumentProbeType.PRIMARY, diff --git a/hardware-testing/Makefile b/hardware-testing/Makefile index a48b794977f..afe2a57c2ee 100755 --- a/hardware-testing/Makefile +++ b/hardware-testing/Makefile @@ -257,9 +257,11 @@ scp $(ssh_helper_ot3) $(4) root@$(1):/tmp/ ssh $(ssh_helper_ot3) root@$(1) \ "function cleanup () { (rm -rf /tmp/$(4) || true) && mount -o remount,ro / ; } ;\ mount -o remount,rw / &&\ -(unzip -o /tmp/$(4) -d /usr/lib/firmware || cleanup) &&\ +(unzip -o /tmp/$(5) -d /usr/lib/firmware || cleanup) &&\ python3 -m json.tool /usr/lib/firmware/opentrons-firmware.json &&\ -cleanup" +cleanup &&\ +echo "Restarting robot server" &&\ +systemctl restart opentrons-robot-server" endef .PHONY: sync-sw-ot3 @@ -284,7 +286,7 @@ remove-patches-fixture: .PHONY: sync-fw-ot3 sync-fw-ot3: - $(call push-and-update-fw,$(host),$(ssh_key),$(ssh_opts),$(zip)) + $(call push-and-update-fw,$(host),$(ssh_key),$(ssh_opts),$(zip),$(notdir $(zip))) .PHONY: sync-ot3 sync-ot3: sync-sw-ot3 sync-fw-ot3 diff --git a/hardware-testing/hardware_testing/gravimetric/config.py b/hardware-testing/hardware_testing/gravimetric/config.py index 993e8716a92..f80d87d7124 100644 --- a/hardware-testing/hardware_testing/gravimetric/config.py +++ b/hardware-testing/hardware_testing/gravimetric/config.py @@ -5,6 +5,7 @@ from enum import Enum from opentrons.config.types import LiquidProbeSettings, OutputOptions from opentrons.protocol_api.labware import Well +from opentrons.hardware_control.types import InstrumentProbeType class ConfigType(Enum): @@ -197,7 +198,7 @@ def _get_liquid_probe_settings( aspirate_while_sensing=False, auto_zero_sensor=True, num_baseline_reads=10, - data_file="/data/testing_data/pressure.csv", + data_files={InstrumentProbeType.PRIMARY: "/data/testing_data/pressure.csv"}, ) diff --git a/hardware-testing/hardware_testing/liquid_sense/__main__.py b/hardware-testing/hardware_testing/liquid_sense/__main__.py index 10db70e67c8..fae4f502315 100644 --- a/hardware-testing/hardware_testing/liquid_sense/__main__.py +++ b/hardware-testing/hardware_testing/liquid_sense/__main__.py @@ -270,6 +270,7 @@ def build_run_args(cls, args: argparse.Namespace) -> "RunArgs": args = parser.parse_args() run_args = RunArgs.build_run_args(args) + exit_error = os.EX_OK try: if not run_args.ctx.is_simulating(): data_dir = get_testing_data_directory() @@ -292,6 +293,7 @@ def build_run_args(cls, args: argparse.Namespace) -> "RunArgs": except Exception as e: ui.print_info(f"got error {e}") ui.print_info(traceback.format_exc()) + exit_error = 1 finally: if run_args.recorder is not None: ui.print_info("ending recording") @@ -314,4 +316,4 @@ def build_run_args(cls, args: argparse.Namespace) -> "RunArgs": run_args.ctx.cleanup() if not args.simulate: helpers_ot3.restart_server_ot3() - os._exit(os.EX_OK) + os._exit(exit_error) diff --git a/hardware-testing/hardware_testing/liquid_sense/execute.py b/hardware-testing/hardware_testing/liquid_sense/execute.py index 1fc95d62d44..9ce6f71b2a8 100644 --- a/hardware-testing/hardware_testing/liquid_sense/execute.py +++ b/hardware-testing/hardware_testing/liquid_sense/execute.py @@ -177,14 +177,15 @@ def run(tip: int, run_args: RunArgs) -> None: run_args.pipette._retract() def _get_baseline() -> float: - run_args.pipette.pick_up_tip(tips.pop(0)) + run_args.pipette.pick_up_tip(tips[0]) + del tips[: run_args.pipette_channels] liquid_height = _jog_to_find_liquid_height( run_args.ctx, run_args.pipette, test_well ) target_height = test_well.bottom(liquid_height).point.z run_args.pipette._retract() - # tip_offset = 0.0 + tip_offset = 0.0 if run_args.dial_indicator is not None: run_args.pipette.move_to(dial_well.top()) tip_offset = run_args.dial_indicator.read_stable() @@ -214,7 +215,8 @@ def _get_baseline() -> float: tip_offset = _get_baseline() ui.print_info(f"Picking up {tip}ul tip") - run_args.pipette.pick_up_tip(tips.pop(0)) + run_args.pipette.pick_up_tip(tips[0]) + del tips[: run_args.pipette_channels] run_args.pipette.move_to(test_well.top()) start_pos = hw_api.current_position_ot3(OT3Mount.LEFT) @@ -274,9 +276,17 @@ def _run_trial(run_args: RunArgs, tip: int, well: Well, trial: int) -> float: run_args.pipette_channels ][tip] data_dir = get_testing_data_directory() - data_filename = f"pressure_sensor_data-trial{trial}-tip{tip}.csv" - data_file = f"{data_dir}/{run_args.name}/{run_args.run_id}/{data_filename}" - ui.print_info(f"logging pressure data to {data_file}") + probes: List[InstrumentProbeType] = [InstrumentProbeType.PRIMARY] + probe_target: InstrumentProbeType = InstrumentProbeType.PRIMARY + if run_args.pipette_channels > 1: + probes.append(InstrumentProbeType.SECONDARY) + probe_target = InstrumentProbeType.BOTH + data_files: Dict[InstrumentProbeType, str] = {} + for probe in probes: + data_filename = f"pressure_sensor_data-trial{trial}-tip{tip}-{probe.name}.csv" + data_file = f"{data_dir}/{run_args.name}/{run_args.run_id}/{data_filename}" + ui.print_info(f"logging pressure data to {data_file}") + data_files[probe] = data_file plunger_speed = ( lqid_cfg["plunger_speed"] @@ -295,13 +305,13 @@ def _run_trial(run_args: RunArgs, tip: int, well: Well, trial: int) -> float: aspirate_while_sensing=run_args.aspirate, auto_zero_sensor=True, num_baseline_reads=10, - data_file=data_file, + data_files=data_files, ) hw_mount = OT3Mount.LEFT if run_args.pipette.mount == "left" else OT3Mount.RIGHT run_args.recorder.set_sample_tag(f"trial-{trial}-{tip}ul") # TODO add in stuff for secondary probe - height = hw_api.liquid_probe(hw_mount, lps, InstrumentProbeType.PRIMARY) + height = hw_api.liquid_probe(hw_mount, lps, probe_target) ui.print_info(f"Trial {trial} complete") run_args.recorder.clear_sample_tag() return height diff --git a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py index 1ec595974b4..5e482afa6e7 100644 --- a/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py +++ b/hardware-testing/hardware_testing/production_qc/pipette_assembly_qc_ot3/__main__.py @@ -1386,7 +1386,7 @@ async def _test_liquid_probe( aspirate_while_sensing=False, # FIXME: I heard this doesn't work auto_zero_sensor=True, # TODO: when would we want to adjust this? num_baseline_reads=10, # TODO: when would we want to adjust this? - data_file="", # FIXME: remove + data_files=None, ) end_z = await api.liquid_probe(mount, probe_settings, probe=probe) if probe == InstrumentProbeType.PRIMARY: diff --git a/hardware/opentrons_hardware/firmware_bindings/constants.py b/hardware/opentrons_hardware/firmware_bindings/constants.py index 5c9ec46d806..cd91ced91b7 100644 --- a/hardware/opentrons_hardware/firmware_bindings/constants.py +++ b/hardware/opentrons_hardware/firmware_bindings/constants.py @@ -338,6 +338,8 @@ class SensorId(int, Enum): S0 = 0x0 S1 = 0x1 + UNUSED = 0x2 + BOTH = 0x3 @unique diff --git a/hardware/opentrons_hardware/hardware_control/motion.py b/hardware/opentrons_hardware/hardware_control/motion.py index 5d38a763ca1..4b482cf01a3 100644 --- a/hardware/opentrons_hardware/hardware_control/motion.py +++ b/hardware/opentrons_hardware/hardware_control/motion.py @@ -1,5 +1,5 @@ """A collection of motions that define a single move.""" -from typing import List, Dict, Iterable, Union +from typing import List, Dict, Iterable, Union, Optional from dataclasses import dataclass import numpy as np from logging import getLogger @@ -8,6 +8,7 @@ NodeId, PipetteTipActionType, MoveStopCondition as MoveStopCondition, + SensorId, ) LOG = getLogger(__name__) @@ -52,6 +53,7 @@ class MoveGroupSingleAxisStep: acceleration_mm_sec_sq: np.float64 = np.float64(0) stop_condition: MoveStopCondition = MoveStopCondition.none move_type: MoveType = MoveType.linear + sensor_id: Optional[SensorId] = None def is_moving_step(self) -> bool: """Check if this step involves any actual movement.""" @@ -131,6 +133,7 @@ def create_step( duration: np.float64, present_nodes: Iterable[NodeId], stop_condition: MoveStopCondition = MoveStopCondition.none, + sensor_to_use: Optional[SensorId] = None, ) -> MoveGroupStep: """Create a move from a block. @@ -157,6 +160,7 @@ def create_step( duration_sec=duration, stop_condition=stop_condition, move_type=MoveType.get_move_type(stop_condition), + sensor_id=sensor_to_use, ) return step diff --git a/hardware/opentrons_hardware/hardware_control/move_group_runner.py b/hardware/opentrons_hardware/hardware_control/move_group_runner.py index b5ab03db8fc..4b7f409b38b 100644 --- a/hardware/opentrons_hardware/hardware_control/move_group_runner.py +++ b/hardware/opentrons_hardware/hardware_control/move_group_runner.py @@ -24,7 +24,6 @@ GearMotorId, MoveAckId, MotorDriverErrorCode, - SensorId, ) from opentrons_hardware.drivers.can_bus.can_messenger import CanMessenger from opentrons_hardware.firmware_bindings.messages import MessageDefinition @@ -308,6 +307,7 @@ def _get_stepper_motor_message( return HomeRequest(payload=home_payload) elif step.move_type == MoveType.sensor: # stop_condition = step.stop_condition.value + assert step.sensor_id is not None stop_condition = MoveStopCondition.sync_line sensor_move_payload = AddSensorLinearMoveBasePayload( request_stop_condition=MoveStopConditionField(stop_condition), @@ -328,7 +328,7 @@ def _get_stepper_motor_message( velocity_mm=Int32Field( int((step.velocity_mm_sec / interrupts_per_sec) * (2**31)) ), - sensor_id=SensorIdField(SensorId.S0), + sensor_id=SensorIdField(step.sensor_id), ) return AddSensorLinearMoveRequest(payload=sensor_move_payload) else: diff --git a/hardware/opentrons_hardware/hardware_control/tool_sensors.py b/hardware/opentrons_hardware/hardware_control/tool_sensors.py index 67e85a1554b..ee1bc46c676 100644 --- a/hardware/opentrons_hardware/hardware_control/tool_sensors.py +++ b/hardware/opentrons_hardware/hardware_control/tool_sensors.py @@ -77,6 +77,7 @@ def _build_pass_step( distance: Dict[NodeId, float], speed: Dict[NodeId, float], stop_condition: MoveStopCondition = MoveStopCondition.sync_line, + sensor_to_use: Optional[SensorId] = None, ) -> MoveGroupStep: pipette_nodes = [ i for i in movers if i in [NodeId.pipette_left, NodeId.pipette_right] @@ -105,6 +106,7 @@ def _build_pass_step( duration=float64(abs(distance[movers[0]] / speed[movers[0]])), present_nodes=pipette_nodes, stop_condition=MoveStopCondition.sensor_report, + sensor_to_use=sensor_to_use, ) for node in pipette_nodes: move_group[node] = pipette_move[node] @@ -114,82 +116,176 @@ def _build_pass_step( async def run_sync_buffer_to_csv( messenger: CanMessenger, sensor_driver: SensorDriver, - pressure_sensor: PressureSensor, mount_speed: float, plunger_speed: float, threshold_pascals: float, head_node: NodeId, move_group: MoveGroupRunner, - log_file: str, + log_files: Dict[SensorId, str], tool: PipetteProbeTarget, - sensor_id: SensorId, ) -> Dict[NodeId, MotorPositionStatus]: """Runs the sensor pass move group and creates a csv file with the results.""" sensor_metadata = [0, 0, mount_speed, plunger_speed, threshold_pascals] - sensor_capturer = LogListener( - mount=head_node, - data_file=log_file, - file_heading=pressure_output_file_heading, - sensor_metadata=sensor_metadata, - ) - async with sensor_capturer: - print("starting move group runner") - positions = await move_group.run(can_messenger=messenger) - messenger.add_listener(sensor_capturer, None) + positions = await move_group.run(can_messenger=messenger) + for sensor_id in log_files.keys(): + sensor_capturer = LogListener( + mount=head_node, + data_file=log_files[sensor_id], + file_heading=pressure_output_file_heading, + sensor_metadata=sensor_metadata, + ) + async with sensor_capturer: + messenger.add_listener(sensor_capturer, None) + await messenger.send( + node_id=tool, + message=SendAccumulatedPressureDataRequest( + payload=SendAccumulatedPressureDataPayload( + sensor_id=SensorIdField(sensor_id) + ) + ), + ) + await asyncio.sleep(10) + messenger.remove_listener(sensor_capturer) await messenger.send( node_id=tool, - message=SendAccumulatedPressureDataRequest( - payload=SendAccumulatedPressureDataPayload( - sensor_id=SensorIdField(sensor_id) + message=BindSensorOutputRequest( + payload=BindSensorOutputRequestPayload( + sensor=SensorTypeField(SensorType.pressure), + sensor_id=SensorIdField(sensor_id), + binding=SensorOutputBindingField(SensorOutputBinding.none), ) ), ) - await asyncio.sleep(10) - messenger.remove_listener(sensor_capturer) - await messenger.send( - node_id=tool, - message=BindSensorOutputRequest( - payload=BindSensorOutputRequestPayload( - sensor=SensorTypeField(SensorType.pressure), - sensor_id=SensorIdField(sensor_id), - binding=SensorOutputBindingField(SensorOutputBinding.none), - ) - ), - ) return positions async def run_stream_output_to_csv( messenger: CanMessenger, sensor_driver: SensorDriver, - pressure_sensor: PressureSensor, + pressure_sensors: Dict[SensorId, PressureSensor], mount_speed: float, plunger_speed: float, threshold_pascals: float, head_node: NodeId, move_group: MoveGroupRunner, - log_file: str, + log_files: Dict[SensorId, str], ) -> Dict[NodeId, MotorPositionStatus]: """Runs the sensor pass move group and creates a csv file with the results.""" sensor_metadata = [0, 0, mount_speed, plunger_speed, threshold_pascals] sensor_capturer = LogListener( mount=head_node, - data_file=log_file, + data_file=log_files[ + next(iter(log_files)) + ], # hardcode to the first file, need to think more on this file_heading=pressure_output_file_heading, sensor_metadata=sensor_metadata, ) binding = [SensorOutputBinding.sync, SensorOutputBinding.report] + binding_field = SensorOutputBindingField.from_flags(binding) + for sensor_id in pressure_sensors.keys(): + sensor_info = pressure_sensors[sensor_id].sensor + await messenger.send( + node_id=sensor_info.node_id, + message=BindSensorOutputRequest( + payload=BindSensorOutputRequestPayload( + sensor=SensorTypeField(sensor_info.sensor_type), + sensor_id=SensorIdField(sensor_info.sensor_id), + binding=binding_field, + ) + ), + ) - async with sensor_driver.bind_output(messenger, pressure_sensor, binding): - messenger.add_listener(sensor_capturer, None) - - async with sensor_capturer: - positions = await move_group.run(can_messenger=messenger) - messenger.remove_listener(sensor_capturer) + messenger.add_listener(sensor_capturer, None) + async with sensor_capturer: + positions = await move_group.run(can_messenger=messenger) + messenger.remove_listener(sensor_capturer) + for sensor_id in pressure_sensors.keys(): + sensor_info = pressure_sensors[sensor_id].sensor + await messenger.send( + node_id=sensor_info.node_id, + message=BindSensorOutputRequest( + payload=BindSensorOutputRequestPayload( + sensor=SensorTypeField(sensor_info.sensor_type), + sensor_id=SensorIdField(sensor_info.sensor_id), + binding=SensorOutputBindingField(SensorOutputBinding.none), + ) + ), + ) return positions +async def _setup_pressure_sensors( + messenger: CanMessenger, + sensor_id: SensorId, + tool: PipetteProbeTarget, + num_baseline_reads: int, + threshold_fixed_point: float, + sensor_driver: SensorDriver, + auto_zero_sensor: bool, +) -> Dict[SensorId, PressureSensor]: + sensors: List[SensorId] = [] + result: Dict[SensorId, PressureSensor] = {} + if sensor_id == SensorId.BOTH: + sensors.append(SensorId.S0) + sensors.append(SensorId.S1) + else: + sensors.append(sensor_id) + + for sensor in sensors: + pressure_sensor = PressureSensor.build( + sensor_id=sensor_id, + node_id=tool, + stop_threshold=threshold_fixed_point, + ) + + if auto_zero_sensor: + pressure_baseline = await sensor_driver.get_baseline( + messenger, pressure_sensor, num_baseline_reads + ) + LOG.debug(f"found baseline pressure: {pressure_baseline} pascals") + + await sensor_driver.send_stop_threshold(messenger, pressure_sensor) + result[sensor] = pressure_sensor + return result + + +async def _run_with_binding( + messenger: CanMessenger, + pressure_sensors: Dict[SensorId, PressureSensor], + sensor_runner: MoveGroupRunner, + binding: List[SensorOutputBinding], +) -> Dict[NodeId, MotorPositionStatus]: + binding_field = SensorOutputBindingField.from_flags(binding) + for sensor_id in pressure_sensors.keys(): + sensor_info = pressure_sensors[sensor_id].sensor + await messenger.send( + node_id=sensor_info.node_id, + message=BindSensorOutputRequest( + payload=BindSensorOutputRequestPayload( + sensor=SensorTypeField(sensor_info.sensor_type), + sensor_id=SensorIdField(sensor_info.sensor_id), + binding=binding_field, + ) + ), + ) + + result = await sensor_runner.run(can_messenger=messenger) + for sensor_id in pressure_sensors.keys(): + sensor_info = pressure_sensors[sensor_id].sensor + await messenger.send( + node_id=sensor_info.node_id, + message=BindSensorOutputRequest( + payload=BindSensorOutputRequestPayload( + sensor=SensorTypeField(sensor_info.sensor_type), + sensor_id=SensorIdField(sensor_info.sensor_id), + binding=SensorOutputBindingField(SensorOutputBinding.none), + ) + ), + ) + return result + + async def liquid_probe( messenger: CanMessenger, tool: PipetteProbeTarget, @@ -201,82 +297,68 @@ async def liquid_probe( csv_output: bool = False, sync_buffer_output: bool = False, can_bus_only_output: bool = False, - data_file: Optional[str] = None, + data_files: Optional[Dict[SensorId, str]] = None, auto_zero_sensor: bool = True, num_baseline_reads: int = 10, sensor_id: SensorId = SensorId.S0, ) -> Dict[NodeId, MotorPositionStatus]: """Move the mount and pipette simultaneously while reading from the pressure sensor.""" + log_files: Dict[SensorId, str] = {} if not data_files else data_files sensor_driver = SensorDriver() threshold_fixed_point = threshold_pascals * sensor_fixed_point_conversion - pressure_sensor = PressureSensor.build( - sensor_id=sensor_id, - node_id=tool, - stop_threshold=threshold_fixed_point, + pressure_sensors = await _setup_pressure_sensors( + messenger, + sensor_id, + tool, + num_baseline_reads, + threshold_fixed_point, + sensor_driver, + auto_zero_sensor, ) - if auto_zero_sensor: - pressure_baseline = await sensor_driver.get_baseline( - messenger, pressure_sensor, num_baseline_reads - ) - LOG.debug(f"found baseline pressure: {pressure_baseline} pascals") - - await sensor_driver.send_stop_threshold(messenger, pressure_sensor) - sensor_group = _build_pass_step( movers=[head_node, tool], distance={head_node: max_z_distance, tool: max_z_distance}, speed={head_node: mount_speed, tool: plunger_speed}, stop_condition=MoveStopCondition.sync_line, + sensor_to_use=sensor_id, ) sensor_runner = MoveGroupRunner(move_groups=[[sensor_group]]) - log_file: str = "/data/pressure_sensor_data.csv" if not data_file else data_file if csv_output: return await run_stream_output_to_csv( messenger, sensor_driver, - pressure_sensor, + pressure_sensors, mount_speed, plunger_speed, threshold_pascals, head_node, sensor_runner, - log_file, + log_files, ) elif sync_buffer_output: return await run_sync_buffer_to_csv( messenger, sensor_driver, - pressure_sensor, mount_speed, plunger_speed, threshold_pascals, head_node, sensor_runner, - log_file, - tool=tool, - sensor_id=sensor_id, + log_files, + tool, ) elif can_bus_only_output: - async with sensor_driver.bind_output( - messenger, - pressure_sensor, - [ - SensorOutputBinding.sync, - SensorOutputBinding.report, - ], - ): - return await sensor_runner.run(can_messenger=messenger) + binding = [SensorOutputBinding.sync, SensorOutputBinding.report] + return await _run_with_binding( + messenger, pressure_sensors, sensor_runner, binding + ) else: # none - async with sensor_driver.bind_output( - messenger, - pressure_sensor, - [ - SensorOutputBinding.sync, - ], - ): - return await sensor_runner.run(can_messenger=messenger) + binding = [SensorOutputBinding.sync] + return await _run_with_binding( + messenger, pressure_sensors, sensor_runner, binding + ) async def check_overpressure( diff --git a/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py b/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py index 5db17d16cb4..ba391da2c14 100644 --- a/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py +++ b/hardware/tests/opentrons_hardware/hardware_control/test_tool_sensors.py @@ -50,7 +50,7 @@ SensorOutputBinding, ) from opentrons_hardware.sensors.scheduler import SensorScheduler -from opentrons_hardware.sensors.sensor_driver import LogListener, SensorDriver +from opentrons_hardware.sensors.sensor_driver import SensorDriver from opentrons_hardware.sensors.types import SensorDataType from opentrons_hardware.sensors.sensor_types import SensorInformation from opentrons_hardware.sensors.utils import SensorThresholdInformation @@ -193,35 +193,6 @@ def move_responder( data=SensorDataType.build(threshold_pascals * 65536, sensor_info.sensor_type), mode=SensorThresholdMode.absolute, ) - mock_bind_output.assert_called_once() - assert mock_bind_output.call_args_list[0][0][3] == [SensorOutputBinding.sync] - - with patch( - "opentrons_hardware.hardware_control.tool_sensors", LogListener - ) as mock_log: - - mock_log.__aenter__ = AsyncMock(return_value=mock_log) # type: ignore - mock_log.__aexit__ = AsyncMock(return_value=None) # type: ignore - - await liquid_probe( - messenger=mock_messenger, - tool=target_node, - head_node=motor_node, - max_z_distance=40, - mount_speed=10, - plunger_speed=8, - threshold_pascals=threshold_pascals, - csv_output=False, - sync_buffer_output=False, - can_bus_only_output=False, - auto_zero_sensor=True, - num_baseline_reads=8, - sensor_id=SensorId.S0, - ) - mock_bind_output.assert_called() - assert mock_bind_output.call_args_list[1][0][3] == [ - SensorOutputBinding.sync, - ] @pytest.mark.parametrize(