From fbf0e9a2ccabb4802b505bb64f4111e6fb52981e Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 2 Oct 2025 17:16:03 -0700 Subject: [PATCH 1/6] lh.{aspirate,dispense} mix parameter --- .../backends/opentrons_backend.py | 26 +++++++++++++++++++ pylabrobot/liquid_handling/liquid_handler.py | 11 ++++++-- pylabrobot/liquid_handling/standard.py | 13 ++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/opentrons_backend.py b/pylabrobot/liquid_handling/backends/opentrons_backend.py index b17c75b0822..86656df1b76 100644 --- a/pylabrobot/liquid_handling/backends/opentrons_backend.py +++ b/pylabrobot/liquid_handling/backends/opentrons_backend.py @@ -420,6 +420,19 @@ async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[ pipette_id=pipette_id, ) + if op.mix is not None: + for _ in range(op.mix.repetitions): + ot_api.lh.aspirate_in_place( + volume=op.mix.volume, + flow_rate=op.mix.flow_rate, + pipette_id=pipette_id, + ) + ot_api.lh.dispense_in_place( + volume=op.mix.volume, + flow_rate=op.mix.flow_rate, + pipette_id=pipette_id, + ) + ot_api.lh.aspirate_in_place( volume=volume, flow_rate=flow_rate, @@ -490,6 +503,19 @@ async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[in pipette_id=pipette_id, ) + if op.mix is not None: + for _ in range(op.mix.repetitions): + ot_api.lh.aspirate_in_place( + volume=op.mix.volume, + flow_rate=op.mix.flow_rate, + pipette_id=pipette_id, + ) + ot_api.lh.dispense_in_place( + volume=op.mix.volume, + flow_rate=op.mix.flow_rate, + pipette_id=pipette_id, + ) + traversal_location = op.resource.get_absolute_location("c", "c", "cavity_bottom") + op.offset traversal_location.z = self.traversal_height await self.move_pipette_head( diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index 8f5281c59a7..9188e3c6802 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -69,6 +69,7 @@ Drop, DropTipRack, GripDirection, + Mix, MultiHeadAspirationContainer, MultiHeadAspirationPlate, MultiHeadDispenseContainer, @@ -816,6 +817,7 @@ async def aspirate( liquid_height: Optional[List[Optional[float]]] = None, blow_out_air_volume: Optional[List[Optional[float]]] = None, spread: Literal["wide", "tight", "custom"] = "wide", + mix: Optional[List[Mix]] = None, **backend_kwargs, ): """Aspirate liquid from the specified wells. @@ -963,8 +965,9 @@ async def aspirate( tip=t, blow_out_air_volume=bav, liquids=lvs, + mix=m, ) - for r, v, o, fr, lh, t, bav, lvs in zip( + for r, v, o, fr, lh, t, bav, lvs, m in zip( resources, vols, offsets, @@ -973,6 +976,7 @@ async def aspirate( tips, blow_out_air_volume, liquids, + mix or [None] * len(use_channels), ) ] @@ -1039,6 +1043,7 @@ async def dispense( liquid_height: Optional[List[Optional[float]]] = None, blow_out_air_volume: Optional[List[Optional[float]]] = None, spread: Literal["wide", "tight", "custom"] = "wide", + mix: Optional[List[Mix]] = None, **backend_kwargs, ): """Dispense liquid to the specified channels. @@ -1193,8 +1198,9 @@ async def dispense( tip=t, liquids=lvs, blow_out_air_volume=bav, + mix=m, ) - for r, v, o, fr, lh, t, bav, lvs in zip( + for r, v, o, fr, lh, t, bav, lvs, m in zip( resources, vols, offsets, @@ -1203,6 +1209,7 @@ async def dispense( tips, blow_out_air_volume, liquids, + mix or [None] * len(use_channels), ) ] diff --git a/pylabrobot/liquid_handling/standard.py b/pylabrobot/liquid_handling/standard.py index bca3971f094..ce94c420df7 100644 --- a/pylabrobot/liquid_handling/standard.py +++ b/pylabrobot/liquid_handling/standard.py @@ -59,6 +59,7 @@ class SingleChannelAspiration: liquid_height: Optional[float] blow_out_air_volume: Optional[float] liquids: List[Tuple[Optional[Liquid], float]] + mix: Optional[Mix] = None @dataclass(frozen=True) @@ -71,6 +72,18 @@ class SingleChannelDispense: liquid_height: Optional[float] blow_out_air_volume: Optional[float] liquids: List[Tuple[Optional[Liquid], float]] + mix: Optional[Mix] = None + + +@dataclass(frozen=True) +class Mix: + # resource: Container + # offset: Coordinate + # tip: Tip + volume: float + repetitions: int + flow_rate: float + # liquid_height: float @dataclass(frozen=True) From aa8db18a911999f2a5a0f2362049cb96067f031f Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 2 Oct 2025 17:24:22 -0700 Subject: [PATCH 2/6] support on {STAR,Vantage}.{aspirate,dispense} --- .../backends/hamilton/STAR_backend.py | 32 +++++++++---------- .../backends/hamilton/vantage_backend.py | 22 +++++++++---- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 2434204f118..0b01bdcee94 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1712,6 +1712,11 @@ async def aspirate( and 360. Defaults to well bottom + liquid height. Should use absolute z. """ + if mix_volume is not None or mix_cycles is not None or mix_speed is not None: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.aspirate instead." + ) + x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) n = len(ops) @@ -1817,17 +1822,12 @@ async def aspirate( hlc.aspiration_settling_time if hlc is not None else 0.0 for hlc in hamilton_liquid_classes ], ) - mix_volume = _fill_in_defaults(mix_volume, [0.0] * n) - mix_cycles = _fill_in_defaults(mix_cycles, [0] * n) + mix_volume = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles = [op.mix.repetitions if op.mix is not None else 0 for op in ops] mix_position_from_liquid_surface = _fill_in_defaults( mix_position_from_liquid_surface, [0.0] * n ) - mix_speed = _fill_in_defaults( - mix_speed, - default=[ - hlc.aspiration_mix_flow_rate if hlc is not None else 50.0 for hlc in hamilton_liquid_classes - ], - ) + mix_speed = [op.mix.flow_rate if op.mix is not None else 0.0 for op in ops] mix_surface_following_distance = _fill_in_defaults(mix_surface_following_distance, [0.0] * n) limit_curve_index = _fill_in_defaults(limit_curve_index, [0] * n) @@ -2007,6 +2007,11 @@ async def dispense( documentation. Dispense mode 4. """ + if mix_volume is not None or mix_cycles is not None or mix_speed is not None: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense instead." + ) + x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) n = len(ops) @@ -2113,17 +2118,12 @@ async def dispense( hlc.dispense_settling_time if hlc is not None else 0.0 for hlc in hamilton_liquid_classes ], ) - mix_volume = _fill_in_defaults(mix_volume, [0.0] * n) - mix_cycles = _fill_in_defaults(mix_cycles, [0] * n) + mix_volume = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles = [op.mix.repetitions if op.mix is not None else 0 for op in ops] mix_position_from_liquid_surface = _fill_in_defaults( mix_position_from_liquid_surface, [0.0] * n ) - mix_speed = _fill_in_defaults( - mix_speed, - default=[ - hlc.dispense_mix_flow_rate if hlc is not None else 50.0 for hlc in hamilton_liquid_classes - ], - ) + mix_speed = [op.mix.flow_rate if op.mix is not None else 0.0 for op in ops] mix_surface_following_distance = _fill_in_defaults(mix_surface_following_distance, [0.0] * n) limit_curve_index = _fill_in_defaults(limit_curve_index, [0] * n) diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py b/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py index d33cf346d81..92650354566 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py @@ -622,6 +622,11 @@ async def aspirate( determined automatically based on the tip and liquid used. """ + if mix_volume is not None or mix_cycles is not None or mix_speed is not None: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense instead." + ) + x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) if jet is None: @@ -730,12 +735,12 @@ async def aspirate( ], swap_speed=[round(ss * 10) for ss in swap_speed or [2] * len(ops)], settling_time=[round(st * 10) for st in settling_time or [1] * len(ops)], - mix_volume=[round(mv * 100) for mv in mix_volume or [0] * len(ops)], - mix_cycles=mix_cycles or [0] * len(ops), + mix_volume=[round(op.mix.volume * 100) if op.mix is not None else 0 for op in ops], + mix_cycles=[op.mix.repetitions if op.mix is not None else 0 for op in ops], mix_position_in_z_direction_from_liquid_surface=[ round(mp) for mp in mix_position_in_z_direction_from_liquid_surface or [0] * len(ops) ], - mix_speed=[round(ms * 10) for ms in mix_speed or [250] * len(ops)], + mix_speed=[round(op.mix.flow_rate * 10) if op.mix is not None else 0 for op in ops], surface_following_distance_during_mixing=[ round(sfdm * 10) for sfdm in surface_following_distance_during_mixing or [0] * len(ops) ], @@ -808,6 +813,11 @@ async def dispense( documentation. Dispense mode 4. """ + if mix_volume is not None or mix_cycles is not None or mix_speed is not None: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense instead." + ) + x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) if jet is None: @@ -922,12 +932,12 @@ async def dispense( pressure_lld_sensitivity=pressure_lld_sensitivity or [1] * len(ops), swap_speed=[round(ss * 10) for ss in swap_speed or [1] * len(ops)], settling_time=[round(st * 10) for st in settling_time or [0] * len(ops)], - mix_volume=[round(mv * 100) for mv in mix_volume or [0] * len(ops)], - mix_cycles=mix_cycles or [0] * len(ops), + mix_volume=[round(op.mix.volume * 100) if op.mix is not None else 0 for op in ops], + mix_cycles=[op.mix.repetitions if op.mix is not None else 0 for op in ops], mix_position_in_z_direction_from_liquid_surface=[ round(mp) for mp in mix_position_in_z_direction_from_liquid_surface or [0] * len(ops) ], - mix_speed=[round(ms * 10) for ms in mix_speed or [1] * len(ops)], + mix_speed=[round(op.mix.flow_rate * 10) if op.mix is not None else 0 for op in ops], surface_following_distance_during_mixing=[ round(sfdm * 10) for sfdm in surface_following_distance_during_mixing or [0] * len(ops) ], From 5ca0f47e93a9bd3b916a55266b308415ef58f413 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 2 Oct 2025 17:46:21 -0700 Subject: [PATCH 3/6] {aspirate,dispene}96 --- .../backends/hamilton/STAR_backend.py | 24 +++++++---- .../backends/hamilton/vantage_backend.py | 24 +++++++---- pylabrobot/liquid_handling/liquid_handler.py | 41 +++++++++---------- pylabrobot/liquid_handling/standard.py | 4 ++ 4 files changed, 58 insertions(+), 35 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 0b01bdcee94..04270d71939 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -2327,6 +2327,11 @@ async def aspirate96( limit_curve_index: The index of the limit curve to use. """ + if mix_volume != 0 or mix_cycles != 0 or speed_of_mix != 0: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.aspirate96 instead." + ) + assert self.core96_head_installed, "96 head must be installed" # get the first well and tip as representatives @@ -2444,11 +2449,11 @@ async def aspirate96( gamma_lld_sensitivity=gamma_lld_sensitivity, swap_speed=round(swap_speed * 10), settling_time=round(settling_time * 10), - mix_volume=round(mix_volume * 10), - mix_cycles=mix_cycles, + mix_volume=round(aspiration.mix.volume * 10) if aspiration.mix is not None else 0, + mix_cycles=aspiration.mix.repetitions if aspiration.mix is not None else 0, mix_position_from_liquid_surface=round(mix_position_from_liquid_surface * 10), surface_following_distance_during_mix=round(surface_following_distance_during_mix * 10), - speed_of_mix=round(speed_of_mix * 10), + speed_of_mix=round(aspiration.mix.flow_rate * 10) if aspiration.mix is not None else 0, channel_pattern=channel_pattern, limit_curve_index=limit_curve_index, tadm_algorithm=False, @@ -2482,7 +2487,7 @@ async def dispense96( mixing_cycles: int = 0, mixing_position_from_liquid_surface: float = 0, surface_following_distance_during_mixing: float = 0, - speed_of_mixing: float = 120.0, + speed_of_mixing: float = 0.0, limit_curve_index: int = 0, cut_off_speed: float = 5.0, stop_back_volume: float = 0, @@ -2523,6 +2528,11 @@ async def dispense96( stop_back_volume: Unknown. """ + if mixing_volume != 0 or mixing_cycles != 0 or speed_of_mixing != 0: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense instead." + ) + assert self.core96_head_installed, "96 head must be installed" # get the first well and tip as representatives @@ -2628,11 +2638,11 @@ async def dispense96( gamma_lld_sensitivity=gamma_lld_sensitivity, swap_speed=round(swap_speed * 10), settling_time=round(settling_time * 10), - mixing_volume=round(mixing_volume * 10), - mixing_cycles=mixing_cycles, + mixing_volume=round(dispense.mix.volume * 10) if dispense.mix is not None else 0, + mixing_cycles=dispense.mix.repetitions if dispense.mix is not None else 0, mixing_position_from_liquid_surface=round(mixing_position_from_liquid_surface * 10), surface_following_distance_during_mixing=round(surface_following_distance_during_mixing * 10), - speed_of_mixing=round(speed_of_mixing * 10), + speed_of_mixing=round(dispense.mix.flow_rate * 10) if dispense.mix is not None else 0, channel_pattern=channel_pattern, limit_curve_index=limit_curve_index, tadm_algorithm=False, diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py b/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py index 92650354566..6d9d42d51be 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py @@ -1038,7 +1038,7 @@ async def aspirate96( mix_cycles: int = 0, mix_position_in_z_direction_from_liquid_surface: float = 0, surface_following_distance_during_mixing: float = 0, - mix_speed: float = 2, + mix_speed: float = 0, limit_curve_index: int = 0, tadm_channel_pattern: Optional[List[bool]] = None, tadm_algorithm_on_off: int = 0, @@ -1056,6 +1056,11 @@ async def aspirate96( """ # assert self.core96_head_installed, "96 head must be installed" + if mix_volume != 0 or mix_cycles != 0 or mix_speed != 0: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense96 instead." + ) + if isinstance(aspiration, MultiHeadAspirationPlate): plate = aspiration.wells[0].parent assert isinstance(plate, Plate), "MultiHeadAspirationPlate well parent must be a Plate" @@ -1151,15 +1156,15 @@ async def aspirate96( lld_sensitivity=lld_sensitivity, swap_speed=round(swap_speed * 10), settling_time=round(settling_time * 10), - mix_volume=round(mix_volume * 100), - mix_cycles=mix_cycles, + mix_volume=round(aspiration.mix.volume * 100) if aspiration.mix is not None else 0, + mix_cycles=aspiration.mix.repetitions if aspiration.mix is not None else 0, mix_position_in_z_direction_from_liquid_surface=round( mix_position_in_z_direction_from_liquid_surface * 100 ), surface_following_distance_during_mixing=round( surface_following_distance_during_mixing * 100 ), - mix_speed=round(mix_speed * 10), + mix_speed=round(aspiration.mix.flow_rate * 10) if aspiration.mix is not None else 0, limit_curve_index=limit_curve_index, tadm_channel_pattern=tadm_channel_pattern, tadm_algorithm_on_off=tadm_algorithm_on_off, @@ -1215,6 +1220,11 @@ async def dispense96( determined based on the jet, blow_out, and empty parameters. """ + if mix_volume != 0 or mix_cycles != 0 or mix_speed is not None: + raise NotImplementedError( + "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense96 instead." + ) + if isinstance(dispense, MultiHeadDispensePlate): plate = dispense.wells[0].parent assert isinstance(plate, Plate), "MultiHeadDispensePlate well parent must be a Plate" @@ -1313,13 +1323,13 @@ async def dispense96( side_touch_off_distance=round(side_touch_off_distance * 10), swap_speed=round(swap_speed * 10), settling_time=round(settling_time * 10), - mix_volume=round(mix_volume * 10), - mix_cycles=mix_cycles, + mix_volume=round(dispense.mix.volume * 100) if dispense.mix is not None else 0, + mix_cycles=dispense.mix.repetitions if dispense.mix is not None else 0, mix_position_in_z_direction_from_liquid_surface=round( mix_position_in_z_direction_from_liquid_surface * 10 ), surface_following_distance_during_mixing=round(surface_following_distance_during_mixing * 10), - mix_speed=round(mix_speed * 10), + mix_speed=round(dispense.mix.flow_rate * 10) if dispense.mix is not None else 0, limit_curve_index=limit_curve_index, tadm_channel_pattern=tadm_channel_pattern, tadm_algorithm_on_off=tadm_algorithm_on_off, diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index 9188e3c6802..9aee1fc3913 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -1624,6 +1624,7 @@ async def aspirate96( flow_rate: Optional[float] = None, liquid_height: Optional[float] = None, blow_out_air_volume: Optional[float] = None, + mix: Optional[Mix] = None, **backend_kwargs, ): """Aspirate from all wells in a plate or from a container of a sufficient size. @@ -1635,17 +1636,14 @@ async def aspirate96( >>> await lh.aspirate96(container, volume=50) Args: - resource (Union[Plate, Container, List[Well]]): Resource object or list of wells. - volume (float): The volume to aspirate through each channel - offset (Coordinate): Adjustment to where the 96 head should go to aspirate relative to where - the plate or container is defined to be. Added to :attr:`default_offset_head96`. - Defaults to :func:`Coordinate.zero`. - flow_rate ([Optional[float]]): The flow rate to use when aspirating, in ul/s. If `None`, the + resource: Resource object or list of wells. + volume: The volume to aspirate through each channel + offset: Adjustment to where the 96 head should go to aspirate relative to where the plate or container is defined to be. Added to :attr:`default_offset_head96`. Defaults to :func:`Coordinate.zero`. + flow_rate: The flow rate to use when aspirating, in ul/s. If `None`, the backend default will be used. - liquid_height ([Optional[float]]): The height of the liquid in the well wrt the bottom, in - mm. If `None`, the backend default will be used. - blow_out_air_volume ([Optional[float]]): The volume of air to aspirate after the liquid, in - ul. If `None`, the backend default will be used. + liquid_height: The height of the liquid in the well wrt the bottom, in mm. If `None`, the backend default will be used. + blow_out_air_volume: The volume of air to aspirate after the liquid, in ul. If `None`, the backend default will be used. + mix: A mix operation to perform after the aspiration, optional. backend_kwargs: Additional keyword arguments for the backend, optional. """ @@ -1659,6 +1657,7 @@ async def aspirate96( flow_rate=flow_rate, liquid_height=liquid_height, blow_out_air_volume=blow_out_air_volume, + mix=mix, ) if not ( @@ -1724,6 +1723,7 @@ async def aspirate96( liquid_height=liquid_height, blow_out_air_volume=blow_out_air_volume, liquids=cast(List[List[Tuple[Optional[Liquid], float]]], all_liquids), # stupid + mix=mix, ) else: # multiple containers # ensure that wells are all in the same plate @@ -1761,6 +1761,7 @@ async def aspirate96( liquid_height=liquid_height, blow_out_air_volume=blow_out_air_volume, liquids=cast(List[List[Tuple[Optional[Liquid], float]]], all_liquids), # stupid + mix=mix, ) try: @@ -1789,6 +1790,7 @@ async def dispense96( flow_rate: Optional[float] = None, liquid_height: Optional[float] = None, blow_out_air_volume: Optional[float] = None, + mix: Optional[Mix] = None, **backend_kwargs, ): """Dispense to all wells in a plate. @@ -1799,17 +1801,13 @@ async def dispense96( >>> await lh.dispense96(plate, volume=50) Args: - resource (Union[Plate, Container, List[Well]]): Resource object or list of wells. - volume (float): The volume to dispense through each channel - offset (Coordinate): Adjustment to where the 96 head should go to aspirate relative to where - the plate or container is defined to be. Added to :attr:`default_offset_head96`. - Defaults to :func:`Coordinate.zero`. - flow_rate ([Optional[float]]): The flow rate to use when dispensing, in ul/s. If `None`, the - backend default will be used. - liquid_height ([Optional[float]]): The height of the liquid in the well wrt the bottom, in - mm. If `None`, the backend default will be used. - blow_out_air_volume ([Optional[float]]): The volume of air to dispense after the liquid, in - ul. If `None`, the backend default will be used. + resource: Resource object or list of wells. + volume: The volume to dispense through each channel + offset: Adjustment to where the 96 head should go to aspirate relative to where the plate or container is defined to be. Added to :attr:`default_offset_head96`. Defaults to :func:`Coordinate.zero`. + flow_rate: The flow rate to use when dispensing, in ul/s. If `None`, the backend default will be used. + liquid_height: The height of the liquid in the well wrt the bottom, in mm. If `None`, the backend default will be used. + blow_out_air_volume: The volume of air to dispense after the liquid, in ul. If `None`, the backend default will be used. + mix: If provided, the tip will mix after dispensing. backend_kwargs: Additional keyword arguments for the backend, optional. """ @@ -1823,6 +1821,7 @@ async def dispense96( flow_rate=flow_rate, liquid_height=liquid_height, blow_out_air_volume=blow_out_air_volume, + mix=mix, ) if not ( diff --git a/pylabrobot/liquid_handling/standard.py b/pylabrobot/liquid_handling/standard.py index ce94c420df7..3f43830cc45 100644 --- a/pylabrobot/liquid_handling/standard.py +++ b/pylabrobot/liquid_handling/standard.py @@ -96,6 +96,7 @@ class MultiHeadAspirationPlate: liquid_height: Optional[float] blow_out_air_volume: Optional[float] liquids: List[List[Tuple[Optional[Liquid], float]]] + mix: Optional[Mix] @dataclass(frozen=True) @@ -108,6 +109,7 @@ class MultiHeadDispensePlate: liquid_height: Optional[float] blow_out_air_volume: Optional[float] liquids: List[List[Tuple[Optional[Liquid], float]]] + mix: Optional[Mix] @dataclass(frozen=True) @@ -120,6 +122,7 @@ class MultiHeadAspirationContainer: liquid_height: Optional[float] blow_out_air_volume: Optional[float] liquids: List[List[Tuple[Optional[Liquid], float]]] + mix: Optional[Mix] @dataclass(frozen=True) @@ -132,6 +135,7 @@ class MultiHeadDispenseContainer: liquid_height: Optional[float] blow_out_air_volume: Optional[float] liquids: List[List[Tuple[Optional[Liquid], float]]] + mix: Optional[Mix] class GripDirection(enum.Enum): From c653f7006e56fbe675edb2124c6c2887c086d790 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 2 Oct 2025 18:23:10 -0700 Subject: [PATCH 4/6] fix tests --- .../backends/hamilton/STAR_backend.py | 12 +++++------- .../backends/hamilton/vantage_backend.py | 9 ++++----- .../liquid_handling/backends/serializing_backend.py | 2 ++ .../liquid_handling/backends/tecan/EVO_tests.py | 2 ++ pylabrobot/liquid_handling/liquid_handler.py | 2 ++ pylabrobot/liquid_handling/liquid_handler_tests.py | 4 ++++ pylabrobot/liquid_handling/standard.py | 8 ++------ pylabrobot/server/liquid_handling_api_tests.py | 1 + pylabrobot/server/liquid_handling_server.py | 5 +++++ 9 files changed, 27 insertions(+), 18 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 04270d71939..44aafa6b28e 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1827,7 +1827,7 @@ async def aspirate( mix_position_from_liquid_surface = _fill_in_defaults( mix_position_from_liquid_surface, [0.0] * n ) - mix_speed = [op.mix.flow_rate if op.mix is not None else 0.0 for op in ops] + mix_speed = [op.mix.flow_rate if op.mix is not None else 100.0 for op in ops] mix_surface_following_distance = _fill_in_defaults(mix_surface_following_distance, [0.0] * n) limit_curve_index = _fill_in_defaults(limit_curve_index, [0] * n) @@ -2123,7 +2123,7 @@ async def dispense( mix_position_from_liquid_surface = _fill_in_defaults( mix_position_from_liquid_surface, [0.0] * n ) - mix_speed = [op.mix.flow_rate if op.mix is not None else 0.0 for op in ops] + mix_speed = [op.mix.flow_rate if op.mix is not None else 1.0 for op in ops] mix_surface_following_distance = _fill_in_defaults(mix_surface_following_distance, [0.0] * n) limit_curve_index = _fill_in_defaults(limit_curve_index, [0] * n) @@ -2281,7 +2281,7 @@ async def aspirate96( mix_cycles: int = 0, mix_position_from_liquid_surface: float = 0, surface_following_distance_during_mix: float = 0, - speed_of_mix: float = 120.0, + speed_of_mix: float = 0.0, limit_curve_index: int = 0, ): """Aspirate using the Core96 head. @@ -2400,7 +2400,6 @@ async def aspirate96( flow_rate = aspiration.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 250) swap_speed = swap_speed or (hlc.aspiration_swap_speed if hlc is not None else 100) settling_time = settling_time or (hlc.aspiration_settling_time if hlc is not None else 0.5) - speed_of_mix = speed_of_mix or (hlc.aspiration_mix_flow_rate if hlc is not None else 10.0) channel_pattern = [True] * 12 * 8 @@ -2453,7 +2452,7 @@ async def aspirate96( mix_cycles=aspiration.mix.repetitions if aspiration.mix is not None else 0, mix_position_from_liquid_surface=round(mix_position_from_liquid_surface * 10), surface_following_distance_during_mix=round(surface_following_distance_during_mix * 10), - speed_of_mix=round(aspiration.mix.flow_rate * 10) if aspiration.mix is not None else 0, + speed_of_mix=round(aspiration.mix.flow_rate * 10) if aspiration.mix is not None else 1200, channel_pattern=channel_pattern, limit_curve_index=limit_curve_index, tadm_algorithm=False, @@ -2603,7 +2602,6 @@ async def dispense96( flow_rate = dispense.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 120) swap_speed = swap_speed or (hlc.dispense_swap_speed if hlc is not None else 100) settling_time = settling_time or (hlc.dispense_settling_time if hlc is not None else 5) - speed_of_mixing = speed_of_mixing or (hlc.dispense_mix_flow_rate if hlc is not None else 100) channel_pattern = [True] * 12 * 8 @@ -2642,7 +2640,7 @@ async def dispense96( mixing_cycles=dispense.mix.repetitions if dispense.mix is not None else 0, mixing_position_from_liquid_surface=round(mixing_position_from_liquid_surface * 10), surface_following_distance_during_mixing=round(surface_following_distance_during_mixing * 10), - speed_of_mixing=round(dispense.mix.flow_rate * 10) if dispense.mix is not None else 0, + speed_of_mixing=round(dispense.mix.flow_rate * 10) if dispense.mix is not None else 1200, channel_pattern=channel_pattern, limit_curve_index=limit_curve_index, tadm_algorithm=False, diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py b/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py index 6d9d42d51be..8b716a92283 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py @@ -740,7 +740,7 @@ async def aspirate( mix_position_in_z_direction_from_liquid_surface=[ round(mp) for mp in mix_position_in_z_direction_from_liquid_surface or [0] * len(ops) ], - mix_speed=[round(op.mix.flow_rate * 10) if op.mix is not None else 0 for op in ops], + mix_speed=[round(op.mix.flow_rate * 10) if op.mix is not None else 2500 for op in ops], surface_following_distance_during_mixing=[ round(sfdm * 10) for sfdm in surface_following_distance_during_mixing or [0] * len(ops) ], @@ -937,7 +937,7 @@ async def dispense( mix_position_in_z_direction_from_liquid_surface=[ round(mp) for mp in mix_position_in_z_direction_from_liquid_surface or [0] * len(ops) ], - mix_speed=[round(op.mix.flow_rate * 10) if op.mix is not None else 0 for op in ops], + mix_speed=[round(op.mix.flow_rate * 100) if op.mix is not None else 10 for op in ops], surface_following_distance_during_mixing=[ round(sfdm * 10) for sfdm in surface_following_distance_during_mixing or [0] * len(ops) ], @@ -1164,7 +1164,7 @@ async def aspirate96( surface_following_distance_during_mixing=round( surface_following_distance_during_mixing * 100 ), - mix_speed=round(aspiration.mix.flow_rate * 10) if aspiration.mix is not None else 0, + mix_speed=round(aspiration.mix.flow_rate * 10) if aspiration.mix is not None else 20, limit_curve_index=limit_curve_index, tadm_channel_pattern=tadm_channel_pattern, tadm_algorithm_on_off=tadm_algorithm_on_off, @@ -1287,7 +1287,6 @@ async def dispense96( flow_rate = dispense.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 250) swap_speed = swap_speed or (hlc.dispense_swap_speed if hlc is not None else 100) settling_time = settling_time or (hlc.dispense_settling_time if hlc is not None else 5) - mix_speed = mix_speed or (hlc.dispense_mix_flow_rate if hlc is not None else 100) type_of_dispensing_mode = type_of_dispensing_mode or _get_dispense_mode( jet=jet, empty=empty, blow_out=blow_out ) @@ -1329,7 +1328,7 @@ async def dispense96( mix_position_in_z_direction_from_liquid_surface * 10 ), surface_following_distance_during_mixing=round(surface_following_distance_during_mixing * 10), - mix_speed=round(dispense.mix.flow_rate * 10) if dispense.mix is not None else 0, + mix_speed=round(dispense.mix.flow_rate * 10) if dispense.mix is not None else 10, limit_curve_index=limit_curve_index, tadm_channel_pattern=tadm_channel_pattern, tadm_algorithm_on_off=tadm_algorithm_on_off, diff --git a/pylabrobot/liquid_handling/backends/serializing_backend.py b/pylabrobot/liquid_handling/backends/serializing_backend.py index 01823eb2347..9c987fffd10 100644 --- a/pylabrobot/liquid_handling/backends/serializing_backend.py +++ b/pylabrobot/liquid_handling/backends/serializing_backend.py @@ -108,6 +108,7 @@ async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[ "liquid_height": serialize(op.liquid_height), "blow_out_air_volume": serialize(op.blow_out_air_volume), "liquids": serialize(op.liquids), + "mix": serialize(op.mix), } for op in ops ] @@ -127,6 +128,7 @@ async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[in "liquid_height": serialize(op.liquid_height), "blow_out_air_volume": serialize(op.blow_out_air_volume), "liquids": serialize(op.liquids), + "mix": serialize(op.mix), } for op in ops ] diff --git a/pylabrobot/liquid_handling/backends/tecan/EVO_tests.py b/pylabrobot/liquid_handling/backends/tecan/EVO_tests.py index 488d7ccb128..b58c57e1897 100644 --- a/pylabrobot/liquid_handling/backends/tecan/EVO_tests.py +++ b/pylabrobot/liquid_handling/backends/tecan/EVO_tests.py @@ -143,6 +143,7 @@ async def test_aspirate(self): liquid_height=10, blow_out_air_volume=0, liquids=[(None, 100)], + mix=None, ) await self.evo.aspirate([op], use_channels=[0]) self.evo.send_command.assert_has_calls( # type: ignore[attr-defined] @@ -284,6 +285,7 @@ async def test_dispense(self): liquid_height=10, blow_out_air_volume=0, liquids=[(None, 100)], + mix=None, ) await self.evo.dispense([op], use_channels=[0]) self.evo.send_command.assert_has_calls( # type: ignore[attr-defined] diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index 9aee1fc3913..8543f6e71a8 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -1882,6 +1882,7 @@ async def dispense96( liquid_height=liquid_height, blow_out_air_volume=blow_out_air_volume, liquids=cast(List[List[Tuple[Optional[Liquid], float]]], all_liquids), # stupid + mix=mix, ) else: # ensure that wells are all in the same plate @@ -1916,6 +1917,7 @@ async def dispense96( liquid_height=liquid_height, blow_out_air_volume=blow_out_air_volume, liquids=all_liquids, + mix=mix, ) try: diff --git a/pylabrobot/liquid_handling/liquid_handler_tests.py b/pylabrobot/liquid_handling/liquid_handler_tests.py index 328443654c4..ac1880b4db7 100644 --- a/pylabrobot/liquid_handling/liquid_handler_tests.py +++ b/pylabrobot/liquid_handling/liquid_handler_tests.py @@ -76,6 +76,7 @@ def _make_asp( liquid_height=None, blow_out_air_volume=None, liquids=[(None, vol)], + mix=None, ) @@ -94,6 +95,7 @@ def _make_disp( liquid_height=None, blow_out_air_volume=None, liquids=[(None, vol)], + mix=None, ) @@ -841,6 +843,7 @@ async def test_stamp(self): liquid_height=None, blow_out_air_volume=None, liquids=[[(None, 10)]] * 96, + mix=None, ) }, }, @@ -860,6 +863,7 @@ async def test_stamp(self): liquid_height=None, blow_out_air_volume=None, liquids=[[(None, 10)]] * 96, + mix=None, ) }, }, diff --git a/pylabrobot/liquid_handling/standard.py b/pylabrobot/liquid_handling/standard.py index 3f43830cc45..419502d2cae 100644 --- a/pylabrobot/liquid_handling/standard.py +++ b/pylabrobot/liquid_handling/standard.py @@ -59,7 +59,7 @@ class SingleChannelAspiration: liquid_height: Optional[float] blow_out_air_volume: Optional[float] liquids: List[Tuple[Optional[Liquid], float]] - mix: Optional[Mix] = None + mix: Optional[Mix] @dataclass(frozen=True) @@ -72,18 +72,14 @@ class SingleChannelDispense: liquid_height: Optional[float] blow_out_air_volume: Optional[float] liquids: List[Tuple[Optional[Liquid], float]] - mix: Optional[Mix] = None + mix: Optional[Mix] @dataclass(frozen=True) class Mix: - # resource: Container - # offset: Coordinate - # tip: Tip volume: float repetitions: int flow_rate: float - # liquid_height: float @dataclass(frozen=True) diff --git a/pylabrobot/server/liquid_handling_api_tests.py b/pylabrobot/server/liquid_handling_api_tests.py index 57bc98b121e..1016ede84f9 100644 --- a/pylabrobot/server/liquid_handling_api_tests.py +++ b/pylabrobot/server/liquid_handling_api_tests.py @@ -220,6 +220,7 @@ def test_aspirate(self): "use_channels": [0], }, ) + print(task) response = _wait_for_task_done(self.base_url, client, task.json.get("id")) self.assertEqual(response.json.get("status"), "succeeded") self.assertEqual(response.status_code, 200) diff --git a/pylabrobot/server/liquid_handling_server.py b/pylabrobot/server/liquid_handling_server.py index 486cacc2a9b..4882f8ccfd7 100644 --- a/pylabrobot/server/liquid_handling_server.py +++ b/pylabrobot/server/liquid_handling_server.py @@ -26,6 +26,7 @@ ) from pylabrobot.liquid_handling.standard import ( Drop, + Mix, Pickup, SingleChannelAspiration, SingleChannelDispense, @@ -234,6 +235,7 @@ async def aspirate(): List[Tuple[Optional[Liquid], float]], deserialize(sc["liquids"]), ) + mix = Mix(**sc["mix"]) if sc.get("mix") is not None else None aspirations.append( SingleChannelAspiration( resource=resource, @@ -244,6 +246,7 @@ async def aspirate(): liquid_height=liquid_height, blow_out_air_volume=blow_out_air_volume, liquids=liquids, + mix=mix, ) ) use_channels = data["use_channels"] @@ -290,6 +293,7 @@ async def dispense(): List[Tuple[Optional[Liquid], float]], deserialize(sc["liquids"]), ) + mix = Mix(**sc["mix"]) if sc.get("mix") is not None else None dispenses.append( SingleChannelDispense( resource=resource, @@ -300,6 +304,7 @@ async def dispense(): liquid_height=liquid_height, blow_out_air_volume=blow_out_air_volume, liquids=liquids, + mix=mix, ) ) use_channels = data["use_channels"] From 9d0993eb8b07db0c378bc60f842af702c9276f96 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 2 Oct 2025 22:24:16 -0700 Subject: [PATCH 5/6] x --- .../liquid_handling/backends/serializing_backend_tests.py | 2 ++ pylabrobot/liquid_handling/liquid_handler.py | 4 ++-- pylabrobot/plate_reading/imager.py | 2 +- pylabrobot/plate_reading/standard.py | 2 +- pylabrobot/server/liquid_handling_api_tests.py | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/serializing_backend_tests.py b/pylabrobot/liquid_handling/backends/serializing_backend_tests.py index 0884f219b95..3063a8d7c62 100644 --- a/pylabrobot/liquid_handling/backends/serializing_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/serializing_backend_tests.py @@ -105,6 +105,7 @@ async def test_aspirate(self): "liquid_height": None, "blow_out_air_volume": None, "liquids": [[None, 10]], + "mix": None, } ], "use_channels": [0], @@ -133,6 +134,7 @@ async def test_dispense(self): "liquid_height": None, "blow_out_air_volume": None, "liquids": [[None, 10]], + "mix": None, } ], "use_channels": [0], diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index 8543f6e71a8..767e7cd0a22 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -976,7 +976,7 @@ async def aspirate( tips, blow_out_air_volume, liquids, - mix or [None] * len(use_channels), + mix or [None] * len(use_channels), # type: ignore ) ] @@ -1209,7 +1209,7 @@ async def dispense( tips, blow_out_air_volume, liquids, - mix or [None] * len(use_channels), + mix or [None] * len(use_channels), # type: ignore ) ] diff --git a/pylabrobot/plate_reading/imager.py b/pylabrobot/plate_reading/imager.py index d67ecb6b887..f933a572551 100644 --- a/pylabrobot/plate_reading/imager.py +++ b/pylabrobot/plate_reading/imager.py @@ -29,7 +29,7 @@ _CV2_IMPORT_ERROR = e try: - import numpy as np + import numpy as np # type: ignore except ImportError: np = None # type: ignore[assignment] diff --git a/pylabrobot/plate_reading/standard.py b/pylabrobot/plate_reading/standard.py index a85d44a7e16..263f8b6e398 100644 --- a/pylabrobot/plate_reading/standard.py +++ b/pylabrobot/plate_reading/standard.py @@ -3,7 +3,7 @@ from typing import Awaitable, Callable, List, Literal, Union try: - import numpy.typing as npt + import numpy.typing as npt # type: ignore Image = npt.NDArray except ImportError: diff --git a/pylabrobot/server/liquid_handling_api_tests.py b/pylabrobot/server/liquid_handling_api_tests.py index 1016ede84f9..f42846ef614 100644 --- a/pylabrobot/server/liquid_handling_api_tests.py +++ b/pylabrobot/server/liquid_handling_api_tests.py @@ -203,7 +203,7 @@ def test_aspirate(self): "channels": [ { "resource_name": well.name, - "volume": 10, + "volume": 10.0, "tip": serialize(tip), "offset": { "type": "Coordinate", From bf4eddee1b862945691b5176598ee391c8e4fc8d Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 2 Oct 2025 22:34:33 -0700 Subject: [PATCH 6/6] wtf --- pylabrobot/plate_reading/standard.py | 10 ++++++++-- pylabrobot/thermocycling/opentrons_backend_usb.py | 10 +++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/pylabrobot/plate_reading/standard.py b/pylabrobot/plate_reading/standard.py index 263f8b6e398..f2d3f731a01 100644 --- a/pylabrobot/plate_reading/standard.py +++ b/pylabrobot/plate_reading/standard.py @@ -1,13 +1,19 @@ import enum +import sys from dataclasses import dataclass from typing import Awaitable, Callable, List, Literal, Union +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + from typing_extensions import TypeAlias + try: import numpy.typing as npt # type: ignore - Image = npt.NDArray + Image: TypeAlias = npt.NDArray except ImportError: - Image = object # type: ignore + Image: TypeAlias = object # type: ignore class Objective(enum.Enum): diff --git a/pylabrobot/thermocycling/opentrons_backend_usb.py b/pylabrobot/thermocycling/opentrons_backend_usb.py index c6c8ec1910d..41daf9a002c 100644 --- a/pylabrobot/thermocycling/opentrons_backend_usb.py +++ b/pylabrobot/thermocycling/opentrons_backend_usb.py @@ -13,9 +13,9 @@ try: import serial.tools.list_ports - from opentrons.drivers.thermocycler import ThermocyclerDriverFactory - from opentrons.drivers.thermocycler.abstract import AbstractThermocyclerDriver - from opentrons.drivers.types import ThermocyclerLidStatus + from opentrons.drivers.thermocycler import ThermocyclerDriverFactory # type: ignore + from opentrons.drivers.thermocycler.abstract import AbstractThermocyclerDriver # type: ignore + from opentrons.drivers.types import ThermocyclerLidStatus # type: ignore USE_OPENTRONS_DRIVER = True _import_error = None @@ -274,7 +274,7 @@ async def deactivate_lid(self): async def get_device_info(self) -> dict: assert self._driver is not None - return await self._driver.get_device_info() + return await self._driver.get_device_info() # type: ignore async def get_block_current_temperature(self) -> List[float]: assert self._driver is not None @@ -304,7 +304,7 @@ async def get_lid_open(self) -> bool: """Return True if the lid is open.""" assert self._driver is not None lid_status = await self._driver.get_lid_status() - return lid_status == ThermocyclerLidStatus.OPEN + return lid_status == ThermocyclerLidStatus.OPEN # type: ignore async def get_lid_status(self) -> LidStatus: assert self._driver is not None