Capability mixins for pressure-over-liquid fluid-handling devices.
Each mixin contributes a single public operation (or a cohesive group of
operations) and delegates into the engine primitives provided by
PressureOverLiquidControl (e.g.
_handle_liquid, _require_arm, _disable_lateral_axes). Mixins are pure
object subclasses at runtime and are never instantiated directly; concrete
devices compose them onto the engine.
At type-check time the mixins are treated as subclasses of
PressureOverLiquidControl so that references to engine members resolve,
while at runtime they remain lightweight traits with no base of their own.
AspirateMixin
Bases: _EngineBase
Adds pressure-over-liquid aspiration to a device.
Source code in src/fluid_control/capabilities.py
| class AspirateMixin(_EngineBase):
"""Adds pressure-over-liquid aspiration to a device."""
def aspirate(self, aspirate_dict: dict[int, ChannelCommand]) -> None:
"""
Aspirate liquid across one or more valve controller channels.
Args:
aspirate_dict (dict): Mapping of channel IDs to channel-operation parameters.
Each value is a [`ChannelCommand`][fluid_control.fluid_control.ChannelCommand],
i.e. ``{"volume": <uL>, "liquid_class": <str>}``.
Examples:
Aspirate 50 uL of water on channel 1:
>>> pipettor.aspirate({1: {"volume": 50.0, "liquid_class": "water"}})
"""
logger.info(f"ASPIRATE START: {aspirate_dict}")
# TODO: Enable ability to set timing PER CLASS
self._handle_liquid(aspirate_dict, process="aspirate")
|
aspirate(aspirate_dict)
Aspirate liquid across one or more valve controller channels.
Parameters:
| Name |
Type |
Description |
Default |
aspirate_dict
|
dict
|
Mapping of channel IDs to channel-operation parameters.
Each value is a ChannelCommand,
i.e. {"volume": <uL>, "liquid_class": <str>}.
|
required
|
Examples:
Aspirate 50 uL of water on channel 1:
| >>> pipettor.aspirate({1: {"volume": 50.0, "liquid_class": "water"}})
|
Source code in src/fluid_control/capabilities.py
| def aspirate(self, aspirate_dict: dict[int, ChannelCommand]) -> None:
"""
Aspirate liquid across one or more valve controller channels.
Args:
aspirate_dict (dict): Mapping of channel IDs to channel-operation parameters.
Each value is a [`ChannelCommand`][fluid_control.fluid_control.ChannelCommand],
i.e. ``{"volume": <uL>, "liquid_class": <str>}``.
Examples:
Aspirate 50 uL of water on channel 1:
>>> pipettor.aspirate({1: {"volume": 50.0, "liquid_class": "water"}})
"""
logger.info(f"ASPIRATE START: {aspirate_dict}")
# TODO: Enable ability to set timing PER CLASS
self._handle_liquid(aspirate_dict, process="aspirate")
|
DispenseMixin
Bases: _EngineBase
Adds pressure-over-liquid dispensing to a device.
Source code in src/fluid_control/capabilities.py
| class DispenseMixin(_EngineBase):
"""Adds pressure-over-liquid dispensing to a device."""
def dispense(self, dispense_dict: dict[int, ChannelCommand]) -> None:
"""
Dispense liquid across one or more valve controller channels.
Args:
dispense_dict (dict): Mapping of channel IDs to channel-operation parameters.
Each value is a [`ChannelCommand`][fluid_control.fluid_control.ChannelCommand],
i.e. ``{"volume": <uL>, "liquid_class": <str>}``.
Examples:
Dispense 25 uL of water on channel 1:
>>> dispenser.dispense({1: {"volume": 25.0, "liquid_class": "water"}})
Dispense on two channels in parallel (armed together, fired once):
>>> dispenser.dispense(
... {
... 1: {"volume": 10.0, "liquid_class": "water"},
... 2: {"volume": 15.0, "liquid_class": "water"},
... }
... )
"""
logger.info(f"DISPENSE START: {dispense_dict}")
# TODO: Enable ability to set timing PER CLASS
self._handle_liquid(dispense_dict, process="dispense")
|
dispense(dispense_dict)
Dispense liquid across one or more valve controller channels.
Parameters:
| Name |
Type |
Description |
Default |
dispense_dict
|
dict
|
Mapping of channel IDs to channel-operation parameters.
Each value is a ChannelCommand,
i.e. {"volume": <uL>, "liquid_class": <str>}.
|
required
|
Examples:
Dispense 25 uL of water on channel 1:
| >>> dispenser.dispense({1: {"volume": 25.0, "liquid_class": "water"}})
|
Dispense on two channels in parallel (armed together, fired once):
| >>> dispenser.dispense(
... {
... 1: {"volume": 10.0, "liquid_class": "water"},
... 2: {"volume": 15.0, "liquid_class": "water"},
... }
... )
|
Source code in src/fluid_control/capabilities.py
| def dispense(self, dispense_dict: dict[int, ChannelCommand]) -> None:
"""
Dispense liquid across one or more valve controller channels.
Args:
dispense_dict (dict): Mapping of channel IDs to channel-operation parameters.
Each value is a [`ChannelCommand`][fluid_control.fluid_control.ChannelCommand],
i.e. ``{"volume": <uL>, "liquid_class": <str>}``.
Examples:
Dispense 25 uL of water on channel 1:
>>> dispenser.dispense({1: {"volume": 25.0, "liquid_class": "water"}})
Dispense on two channels in parallel (armed together, fired once):
>>> dispenser.dispense(
... {
... 1: {"volume": 10.0, "liquid_class": "water"},
... 2: {"volume": 15.0, "liquid_class": "water"},
... }
... )
"""
logger.info(f"DISPENSE START: {dispense_dict}")
# TODO: Enable ability to set timing PER CLASS
self._handle_liquid(dispense_dict, process="dispense")
|
MixMixin
Bases: _EngineBase
Adds mixing (repeated aspirate/dispense) to a device.
Source code in src/fluid_control/capabilities.py
| class MixMixin(_EngineBase):
"""Adds mixing (repeated aspirate/dispense) to a device."""
def mix(self, mix_dict: dict[int, ChannelCommand], cycles: int) -> None:
"""
Aspirate and dispense repeatedly to mix liquid in the channels.
Args:
mix_dict (dict): Mapping of channel IDs to channel-operation parameters.
cycles (int): Number of aspirate/dispense cycles to execute.
Examples:
Mix 20 uL up and down for 3 cycles on channel 1:
>>> pipettor.mix({1: {"volume": 20.0, "liquid_class": "water"}}, cycles=3)
"""
logger.info(f"MIX START: {mix_dict}")
for _ in range(cycles):
self._handle_liquid(mix_dict, "aspirate")
# TODO: Raise fluid_control arms so no bubbles
self._handle_liquid(
mix_dict, "dispense"
) # TODO: This needs to be an instance attribute dictionary that ensures all liquid is clear.
|
mix(mix_dict, cycles)
Aspirate and dispense repeatedly to mix liquid in the channels.
Parameters:
| Name |
Type |
Description |
Default |
mix_dict
|
dict
|
Mapping of channel IDs to channel-operation parameters.
|
required
|
cycles
|
int
|
Number of aspirate/dispense cycles to execute.
|
required
|
Examples:
Mix 20 uL up and down for 3 cycles on channel 1:
| >>> pipettor.mix({1: {"volume": 20.0, "liquid_class": "water"}}, cycles=3)
|
Source code in src/fluid_control/capabilities.py
| def mix(self, mix_dict: dict[int, ChannelCommand], cycles: int) -> None:
"""
Aspirate and dispense repeatedly to mix liquid in the channels.
Args:
mix_dict (dict): Mapping of channel IDs to channel-operation parameters.
cycles (int): Number of aspirate/dispense cycles to execute.
Examples:
Mix 20 uL up and down for 3 cycles on channel 1:
>>> pipettor.mix({1: {"volume": 20.0, "liquid_class": "water"}}, cycles=3)
"""
logger.info(f"MIX START: {mix_dict}")
for _ in range(cycles):
self._handle_liquid(mix_dict, "aspirate")
# TODO: Raise fluid_control arms so no bubbles
self._handle_liquid(
mix_dict, "dispense"
) # TODO: This needs to be an instance attribute dictionary that ensures all liquid is clear.
|
TipHandlingMixin
Bases: _EngineBase
Adds tip pickup and ejection to a device that has a motion axis.
Source code in src/fluid_control/capabilities.py
| class TipHandlingMixin(_EngineBase):
"""Adds tip pickup and ejection to a device that has a motion axis."""
def eject_tips(self) -> OperationResult:
"""
Eject tips from the fluid control module.
Requires a mounted (non-static) device with a configured motion axis.
Returns:
OperationResult: ``(code, message)`` describing the outcome, where
``code`` is ``0`` (clear), ``1`` (error), or ``2`` (busy).
Examples:
>>> result = pipettor.eject_tips()
>>> result.code
0
>>> result.message
'Tips ejected successfully'
"""
# TODO: How to consider static, mechanical, deck-based, fixed-point ejection mode
# Make optional?
# class Ejector
# if ejector is not None:
# ....
# TODO: Minimum: Add warning that this class assumes a co-mounted, dynamic tip ejection mechanism. Others are not yet supported.
logger.info("EJECT TIPS START")
self._require_arm()
self.fluid_control_status.set_busy()
self._disable_lateral_axes()
try:
for cycle in range(_EJECT_ACTUATION_CYCLES):
logger.debug(
f"Eject tips: actuation cycle {cycle + 1}/{_EJECT_ACTUATION_CYCLES}"
) # TODO: Parameterize the total number of eject cycles for testing
self._wait_output_pressure(
_EJECT_MAX_PRESSURE_MBAR
) # TODO: Change to Pressure Control Library max pressure. Will need slight modificiation of PGVA library
self.pressure_control.trigger_actuation_valve(10)
self.pressure_control.trigger_actuation_valve(1000)
self._wait_output_pressure(
_EJECT_MIN_PRESSURE_MBAR
) # TODO: Change to Pressure Control Library min pressure. Will need slight modificiation of PGVA library
self.pressure_control.trigger_actuation_valve(10)
self.pressure_control.trigger_actuation_valve(2000)
self.pressure_control.set_output_pressure(0)
self.fluid_control_status.set_clear()
self._enable_lateral_axes()
logger.info("EJECT TIPS COMPLETE")
return OperationResult(self.fluid_control_status.get_status(), "Tips ejected successfully")
except Exception as e:
logger.error(f"EJECT TIPS FAILED: {e}")
self.fluid_control_status.set_error()
self._enable_lateral_axes()
return OperationResult(self.fluid_control_status.get_status(), str(e))
def _pickup_action(self, duration: float) -> None:
"""Jog the mount arm downward until tip engagement stalls its motion."""
arm = self._require_arm()
delta = _PICKUP_STALL_DELTA_MM # mm — FestoAxis.current_position() returns mm
arm.acknowledge_faults() # TODO: We need a way to NOT dig this deep into internals of the edcon library
# self.mount_arm.disable_powerstage()
current_position = arm.current_position()
logger.debug(f"_pickup_action: start position={current_position}, duration={duration}, delta={delta}")
repeat = True
count = 0
self._disable_lateral_axes()
while repeat:
arm.acknowledge_faults()
arm.enable_powerstage()
arm.jog_task(
True, False, duration=duration
) # TODO: Passing all the way to jog_task defeats the purpose of the axis class and make the parameters passed / API confusing. Think of a way to fix this.
# self.mount_arm.position_task(position=5000, velocity=duration, absolute=False, nonblocking=False)
# self.mount_arm.jog_task(True, False, duration=0.5)
new_position = arm.current_position()
movement = abs(new_position - current_position)
logger.debug(f"_pickup_action: new_position={new_position}, movement={movement}, stall_count={count}")
if movement <= delta:
count += 1
current_position = new_position
else:
current_position = new_position
if count >= _PICKUP_STALL_CONSECUTIVE: # TODO: Parameterize this for testing
logger.debug("_pickup_action: stall detected — tip engagement complete")
repeat = False
self._enable_lateral_axes()
arm.acknowledge_faults()
def pickup_tips(self, duration: float) -> OperationResult:
"""
Pick up tips with the fluid_control.
Requires a mounted (non-static) device with a configured motion axis.
Args:
duration (float): Duration in seconds of each downward jog toward the tips.
Returns:
OperationResult: ``(code, message)`` describing the outcome, where
``code`` is ``0`` (clear), ``1`` (error), or ``2`` (busy).
Examples:
>>> result = pipettor.pickup_tips(duration=0.5)
>>> result.code
0
>>> result.message
'Tips picked up successfully'
"""
logger.info(f"PICKUP TIPS START: duration={duration}")
self._require_arm()
self.fluid_control_status.set_busy()
try:
self._pickup_action(duration=duration)
self.fluid_control_status.set_clear()
logger.info("PICKUP TIPS COMPLETE")
return OperationResult(self.fluid_control_status.get_status(), "Tips picked up successfully")
except Exception as e:
logger.error(f"PICKUP TIPS FAILED: {e}")
self.fluid_control_status.set_error()
return OperationResult(self.fluid_control_status.get_status(), str(e))
|
eject_tips()
Eject tips from the fluid control module.
Requires a mounted (non-static) device with a configured motion axis.
Returns:
| Name | Type |
Description |
OperationResult |
OperationResult
|
(code, message) describing the outcome, where
|
|
OperationResult
|
code is 0 (clear), 1 (error), or 2 (busy).
|
Examples:
| >>> result = pipettor.eject_tips()
>>> result.code
0
>>> result.message
'Tips ejected successfully'
|
Source code in src/fluid_control/capabilities.py
| def eject_tips(self) -> OperationResult:
"""
Eject tips from the fluid control module.
Requires a mounted (non-static) device with a configured motion axis.
Returns:
OperationResult: ``(code, message)`` describing the outcome, where
``code`` is ``0`` (clear), ``1`` (error), or ``2`` (busy).
Examples:
>>> result = pipettor.eject_tips()
>>> result.code
0
>>> result.message
'Tips ejected successfully'
"""
# TODO: How to consider static, mechanical, deck-based, fixed-point ejection mode
# Make optional?
# class Ejector
# if ejector is not None:
# ....
# TODO: Minimum: Add warning that this class assumes a co-mounted, dynamic tip ejection mechanism. Others are not yet supported.
logger.info("EJECT TIPS START")
self._require_arm()
self.fluid_control_status.set_busy()
self._disable_lateral_axes()
try:
for cycle in range(_EJECT_ACTUATION_CYCLES):
logger.debug(
f"Eject tips: actuation cycle {cycle + 1}/{_EJECT_ACTUATION_CYCLES}"
) # TODO: Parameterize the total number of eject cycles for testing
self._wait_output_pressure(
_EJECT_MAX_PRESSURE_MBAR
) # TODO: Change to Pressure Control Library max pressure. Will need slight modificiation of PGVA library
self.pressure_control.trigger_actuation_valve(10)
self.pressure_control.trigger_actuation_valve(1000)
self._wait_output_pressure(
_EJECT_MIN_PRESSURE_MBAR
) # TODO: Change to Pressure Control Library min pressure. Will need slight modificiation of PGVA library
self.pressure_control.trigger_actuation_valve(10)
self.pressure_control.trigger_actuation_valve(2000)
self.pressure_control.set_output_pressure(0)
self.fluid_control_status.set_clear()
self._enable_lateral_axes()
logger.info("EJECT TIPS COMPLETE")
return OperationResult(self.fluid_control_status.get_status(), "Tips ejected successfully")
except Exception as e:
logger.error(f"EJECT TIPS FAILED: {e}")
self.fluid_control_status.set_error()
self._enable_lateral_axes()
return OperationResult(self.fluid_control_status.get_status(), str(e))
|
pickup_tips(duration)
Pick up tips with the fluid_control.
Requires a mounted (non-static) device with a configured motion axis.
Parameters:
| Name |
Type |
Description |
Default |
duration
|
float
|
Duration in seconds of each downward jog toward the tips.
|
required
|
Returns:
| Name | Type |
Description |
OperationResult |
OperationResult
|
(code, message) describing the outcome, where
|
|
OperationResult
|
code is 0 (clear), 1 (error), or 2 (busy).
|
Examples:
| >>> result = pipettor.pickup_tips(duration=0.5)
>>> result.code
0
>>> result.message
'Tips picked up successfully'
|
Source code in src/fluid_control/capabilities.py
| def pickup_tips(self, duration: float) -> OperationResult:
"""
Pick up tips with the fluid_control.
Requires a mounted (non-static) device with a configured motion axis.
Args:
duration (float): Duration in seconds of each downward jog toward the tips.
Returns:
OperationResult: ``(code, message)`` describing the outcome, where
``code`` is ``0`` (clear), ``1`` (error), or ``2`` (busy).
Examples:
>>> result = pipettor.pickup_tips(duration=0.5)
>>> result.code
0
>>> result.message
'Tips picked up successfully'
"""
logger.info(f"PICKUP TIPS START: duration={duration}")
self._require_arm()
self.fluid_control_status.set_busy()
try:
self._pickup_action(duration=duration)
self.fluid_control_status.set_clear()
logger.info("PICKUP TIPS COMPLETE")
return OperationResult(self.fluid_control_status.get_status(), "Tips picked up successfully")
except Exception as e:
logger.error(f"PICKUP TIPS FAILED: {e}")
self.fluid_control_status.set_error()
return OperationResult(self.fluid_control_status.get_status(), str(e))
|