Module pipettin-piper.piper.plugins.tools.pipette

Classes

class PipetteTool (tool_data: dict, gcode_builder: GcodeBuilder)
Expand source code
class PipetteTool(Tool):
    """Tool object for pipettes
    Inheriting from Tool and TrackedDict (which wraps UserDict really) allows
    getting data with the usual dictionary syntax and methods.
    """
    def __init__(self, tool_data: dict, gcode_builder: GcodeBuilder):
        # Initialize the parent Tool class.
        super().__init__(tool_data, gcode_builder)

        # Initialize empty state.
        self.state = deepcopy(self.default_state)

    homed: bool = False
    """Indicates if the pipette tool is homed or not."""

    # TODO: Replace the key in the state dict with this property.
    lastdir = None
    """To track the direction of the last pipette move (for compensation)."""

    default_state = {
        # pipette axis position in mm (s) and microliters (vol).
        "s": 0, "vol": 0,
        "tip_vol": 0,
        "lastdir": None,
        "x": None,
        "y": None,
        "z": None,
        "platform": None,
        "tube": None,
        "paused": False,
        "alarm": False
    }
    """State tracking variables."""

    # Use a different feedrate for the extrusion/pipetting moves.
    tool_velocity_mm_s = 200
    tool_feedrate = tool_velocity_mm_s * 60    # Convert from mm/s to mm/min

    tip_loaded: dict = None
    """Either None or a dict with the currently placed tip's data."""

    @property
    def max_volume(self) -> float:
        """Current maximum volume limit."""
        # Get the maximum volume from the current stage.
        if self.tip_loaded:
            # Return the smallest limit, between the tip and the pipette.
            tip_container = self.get_tip_container(self.tip_loaded)
            return self.get_max_volume(tip_container)

        # If no tip is loaded, get the default tip-stage parameters.
        default_tip_stage = self.get_default_tip_stage()
        return default_tip_stage["vol_max"]

    def get_max_volume(self, tip_container: dict = None):
        """Return the maximum volume limit, between the tip and the pipette."""
        # Get the max volume of the pipette stage.
        tip_stage = self.get_tip_stage(tip_container)
        tip_stage_max_vol = tip_stage["vol_max"]

        # Get the max volume of the tip.
        tip_max_vol = tip_container["maxVolume"]

        # Return the smallest value.
        return min(tip_stage_max_vol, tip_max_vol)

    @property
    def flowrate(self) -> float:
        """Get the flowrate for the current tip, or get it from the default stage.
        TODO: Get the flowrate from the tip instead.
              This requires adding the property to the tip container data.
        """
        # Get the flowrate from the current stage.
        if self.tip_loaded:
            tip_stage = self.get_tip_stage()
            return tip_stage["flowrate"]

        # Get the default tip-stage parameters.
        default_tip_stage = self.get_default_tip_stage()

        return default_tip_stage["flowrate"]

    @property
    def shaft_diameter(self):
        """Physical diameter of the pipette's shaft
        This should be measured precisely with a micrometer,
        at several points along its length to ensure accuracy.
        """
        return float(self["shaft_diameter"])

    @property
    def scaler(self):
        """Ideal conversion factor from displacement (mm) to volume (uL)

        Divide the target volume by this factor to obtain the corresponding 
        linear displacement of a cyllinder with diameter set by `shaft_diameter`.
        
        Expression for a cyllinder's volume: pi * ((shaft_diameter/2)**2)

        Note: the scaler is ideal, and does not contemplate the corrections of the "scaler_adjust" property.
        """
        return math.pi * ((self.shaft_diameter/2)**2)

    @property
    def scaler_adjust(self):
        """Linear adjustment factor for the ideal scaler property
        This is meant to correct sistematic linear deviations from the ideal scaler
        due to, for example, the weight of the water column.
        """
        return float(self["scaler_adjust"])

    @property
    def feedrate(self) -> float:
        """Get the linear feedrate for the current tip-stage, or get the default
        Convert the volumetric flowrate (in microliters/second) to the speed (in mm/min)
        of a cylindrical shaft that displaces the volume.

        flowrate (float): Volumetric flowrate in microliters/second (µL/s).
        diameter (float): Diameter of the shaft in millimeters (mm).

        Returns:
        float: Speed of the shaft in millimeters per minute (mm/min).
        """

        # Convert flowrate from microliters/second to cubic millimeters/second.
        flowrate_mm3_per_s = self.flowrate  # 1 microliter = 1 cubic millimeter

        # Calculate the cross-sectional area of the cylindrical shaft.
        radius_mm = self.shaft_diameter / 2
        area_mm2 = math.pi * (radius_mm ** 2)

        # Calculate the speed in millimeters per second (mm/s).
        speed_mm_per_s = flowrate_mm3_per_s / area_mm2

        # Convert the speed to millimeters per minute (mm/min)
        speed_mm_per_min = speed_mm_per_s * 60  # In CNC-friendly units.

        return speed_mm_per_min

    @property
    def can_probe(self) -> bool:
        """Check if the pipette can do 'tip probing' during placement."""
        return bool(self["can_probe"])

    @property
    def probe_extra_dist(self):
        """Extra (downwards) probing distance, beyond the fitting depth.
        Harmless if the probe works.
        Requires a tip to be loaded.
        """
        return self.get_probe_extra_dist()  # Default container.

    @property
    def bare_z_offset(self):
        """Z offset for the tool, with no tips."""
        z = float(self.tool_offset["z"])
        return z

    @property
    def z_offset(self):
        """Vertical offset for the tool, as a computed property.
        Defined as the height at which the machine touches the baseplate when the tool is attached.
        This value may be context-dependent. For example, it may depend on wether a tip is placed on
        a (pipette) tool or not.
        It is used by GcodeBuilder to compute vertical clearance and the safe height.
        """
        # Start value for the calculation.
        z = self.bare_z_offset

        # Add tip offsets, only if a tip is loaded.
        if self.tip_loaded:
            # Get the associated container.
            tip_container = self.get_tip_container(self.tip_loaded)
            # Add the total length of the tip.
            z += tip_container["length"]
            # Subtract tip-stage z-offset.
            z -= self.get_tip_stage_offset(tip_container)
            # Subtract the fit distance.
            z -= self.get_tip_fit_distance(tip_container)

        # Done!
        return z

    def get_tip_stage_offset(self, tip_container: dict = None):
        """Calculate tip-stage z-offset for a target tip container and tool."""
        # Adjust by tip-stage offset, if needed.
        tip_stage = self.get_tip_stage(tip_container=tip_container)

        # NOTE: The value is subtracted because a "deeper" tip stage
        #       will make the Z-height at which the tool touches ground
        #       a bit lower.
        return tip_stage.get("z_offset")

    def get_tip_fit_distance(self, tip_container: dict = None):
        """Calculate 'tip_fit_distance' for a target tip container and tool."""
        # Adjust by tip-stage offset, if needed.
        tip_stage = self.get_tip_stage(tip_container=tip_container)

        stage_fit_distances = tip_stage.get("tip_fit_distance")
        tip_fit_distance = stage_fit_distances[tip_container["name"]]

        return  tip_fit_distance

    def get_probe_extra_dist(self, tip_container: dict = None):
        """Calculate 'probe_extra_dist' for a target tip container and tool."""
        # Adjust by tip-stage offset, if needed.
        tip_stage = self.get_tip_stage(tip_container=tip_container)
        return  tip_stage.get("probe_extra_dist")

    @property
    def tension_correction(self) -> dict:
        """Parameters to correct for surface tension effects at volumes lower than the tip's limits.
        This value may be dynamic. For example, it may depend on which tip is placed.
        Must contain keys:
        {
            "start_vol": 10,
            "max_correction": 0.1
        }
        Requires a tip to be loaded.
        """
        tip_stage = self.get_tip_stage()
        return tip_stage.get("tension_correction")

    def get_tip_container(self, tip: dict) -> dict:
        """Get the container data of a tip from the database."""
        container_name = tip.get("container")
        container = self.controller.database_tools.getContainerByName(container_name)
        return container

    def get_default_tip_stage(self) -> dict:
        """Get the default tip-stage's parameters"""
        default_tip_stage_name = self.tip_stages["default"]
        default_tip_stage = self.tip_stages[default_tip_stage_name]
        return default_tip_stage

    @property
    def tip_map(self) -> dict:
        """Correspondence of tip container names to tip stages.
        Example:
          "tip_map": {
            "20 uL Tip": "p20",
            "200 uL Tip": "p200",
            "200 uL Tip Tarsons": "p200",
            "1000 uL Tip": "p1000"
            }
        """
        return deepcopy(self["tip_map"])

    @property
    def tip_stages(self) -> dict:
        """Definition of tip-fitting stages on the pipette's tip holder.
        Example:
            "tip_stages": {
                "default": "p1000",
                "p200": {
                    "vol_max": 200,
                    "flowrate": 100,
                    "z_offset": 5,
                    "tip_fit_distance": { ... },
                    "probe_extra_dist": 2.7,
                    "eject_post": { ... },
                    "tension_correction": { ... }
                }
            }
        """
        return deepcopy(self["tip_stages"])

    def get_tip_stage(self, tip_container: dict=None) -> dict:
        """Get the tip stage parameters for a particular tip."""

        # Default tip_container to curent tip if not provided.
        if tip_container is None:
            # A tip is needed to inferr the relevant tip stage.
            if not self.tip_loaded:
                raise ValueError("No tip has been loaded yet, and no default container was provided.")
            # Get the currently loaded tip.
            tip_container = self.get_tip_container(self.tip_loaded)

        # Get the tip's "model" name.
        tip_container_name = tip_container["name"]

        # Get tip-stage parameters from the tool definition.
        tip_map = self.tip_map
        tip_stages = self.tip_stages
        default_tip_stage_name = tip_stages["default"]

        # Get the name of the tip stage.
        stage_name = tip_map.get(tip_container_name, default_tip_stage_name)

        # Get the tip stage.
        tip_stage = tip_stages[stage_name]

        return tip_stage

    def is_home(self):
        """Infer if the pipette is in the 'home' position from the state tracker."""
        return (
            self.homed and
            (self.state["vol"] == self.state["s"] == 0) and
            self.state["lastdir"] == self.builder.sign(-1)
        )

    def set_home(self):
        # Zero the volumes
        self.state["vol"] = 0
        self.state["s"] = 0
        # Update direction with the current move
        self.state["lastdir"] = self.builder.sign(-1)
        # Set the homedflag.
        self.homed = True

    #### GCODE GENERATORS ####

    def home(self) -> list:
        """Generate GCODE commands needed to home this tool.
        Then update the internal state accordingly.
        """
        logging.info(f"Homing tool '{self.name}'.")

        # List for homing GCODE commands.
        homing_commands = []

        # Activate tool.
        homing_commands += self.gcode_activate()

        # TODO: Fix this. It caused errors when the machine was idle for too long, or if the steppers were disabled.
        # if self.is_home():
        #     # Skip homing if the pipette is already homed.
        #     return [self.gcode.comment("Skipped homing. The tool's state indicates that it already is.")]

        # Build the command
        if not self.controller.machine.dry:
            # Add homing commands.
            homing_commands += self.homing['commands']
            # Add priming distance.
            travel_distance = self.homing['travel_distance']
            # TODO: Set priming move feedrate from a config parameter.
            homing_commands += self.gcode.G1(e=travel_distance, absolute=True, absolute_e=True)
            # Debug log.
            logging.debug(f"Homing commands for tool {self.name}: {homing_commands}")
        else:
            # Dry mode:
            msg = f"Simulating homed state for tool '{self.name}'. Dry mode enabled."
            logging.info(msg)
            homing_commands += [self.gcode.comment(msg)]

        # Update internal state.
        self.set_home()

        return homing_commands

    @property
    def eject_post(self):
        """Coordinates and parameters to eject tips using a fixed post.
        Minimal contents:
            eject_post:
                post_x: 185
                post_y: 301
                post_z_pre: 64
                post_z: 73
                eject_feed_rate: 600
        Notes:
        - post_x: X-axis coordinate of the carriage when the pipette's eject button is under the ejection post.
        - post_y: Y-axis coordinate of the carriage when the pipette's eject button is under the ejection post.
        - post_z_pre: Z-axis coordinate of the carriage when the pipette's eject button is under the ejection post, but not in contact with it.
        - post_z: Z-axis coordinate of the carriage when the pipette's eject button is fully pressed the ejection post, and a tip is ejected.
        - eject_feed_rate: Speed for the ejection motion. Should be "slow".

        Requires a tip to be loaded.
        """
        tip_stage = self.get_tip_stage()
        eject_post = tip_stage["eject_post"]
        return eject_post

    @property
    def eject_post_init_coords(self) -> dict:
        """Initial coordinates for the post-based tip ejection sequence."""
        x = self.eject_post["post_x"]
        safe_y = self.builder.getSafeY()
        z_pre = self.eject_post["post_z_pre"]
        return x, safe_y, z_pre

    def eject_tip_with_post(self, eject_feed_rate=None) -> list:
        """Make GCODE to eject the tip by elegantly crashing with a "post".
        This assumes that the tool is safely positioned such that it may
        move freely towards the eject-post, and then use it."""

        # Get tip ejector parameters.
        eject_post = self.eject_post
        y, z_pre, z = eject_post["post_y"], eject_post["post_z_pre"], eject_post["post_z"]

        # Set ejection move speed.
        if eject_feed_rate is None:
            eject_feed_rate = eject_post["eject_feed_rate"]

        # Start the command list.
        commands = []

        # Align to the ejector on Z (without tool-offsets).
        commands += self.gcode.G0(z=z_pre, absolute=True, add_comment="Approach the eject-post on Z.")

        # Align to the ejector on Y (without tool-offsets), inserting the tip holder in the ejector.
        commands += self.gcode.G0(y=y, absolute=True, add_comment="Approach the eject-post on Y.")

        # Move upwards to eject the tip (without tool-offsets).
        commands += self.gcode.G1(z=z, f=eject_feed_rate, absolute=True, add_comment="Eject the tip.")
        # BUG: The tip-probe is "erratic" in the reverse direction.
        #      A regular mechanical endstop would be better, because
        #      it has a small amount of hysteresis.
        #      Use a regular upwards move instead.

        # Compute clearance values.
        safe_y = self.builder.getSafeY()

        # Move to safe Y.
        commands += self.gcode.G0(
            y=safe_y, absolute=True,
            add_comment="Move away from the ejection post up to safe Y (avoid parked tools).")

        # The safe_z distance will actually change during this GCODE.
        # Let the "macroDiscardTip" function handle this.
        commands += [self.gcode.comment("END tip-ejection macro.")]

        return commands

    def eject_tip(self) -> list:
        """Generate GCODE for the ejection, and update the pipette's state."""
        # Get the current tip stage.
        tip_stage = self.get_tip_stage()

        # Make the GCODE.
        if self.can_eject and "tip_ejector" in tip_stage:
            # Use the integrated ejector.
            commands = self.eject_with_ejector()
        elif "eject_post" in tip_stage:
            # Use the fixed ejection post.
            commands = self.eject_tip_with_post()
        else:
            raise ProtocolError("Neither a tip ejector or a tip-ejection post are configured.")

        # Register no tip in the volume tracker
        self.tip_loaded = False
        self.state["tip_vol"] = None

        return commands

    @property
    def can_eject(self) -> bool:
        return bool(self["can_eject"])

    @property
    def tip_ejector(self) -> bool:
        """Coordinates and parameters to eject tips using a built-in tip ejector.
        The tip ejector is associated to a particular tip stage and container.
        This property uses the stage that matches the current tip.
        Example:
            >>> "tip_ejector": {
            >>>     "e_start": 22,
            >>>     "e_end": 27,
            >>>     "eject_feedrate": 200
            >>> }
        """
        tip_stage = self.get_tip_stage()
        eject_post = tip_stage["tip_ejector"]
        return deepcopy(eject_post)

    def eject_with_ejector(self) -> list:
        """Generate GCODE to eject a tip using the integrated tip-ejector.
        {
            "tip_ejector": {
                "e_start": 200,
                "e_end": 250,
                "eject_feedrate": 200
            },
        }
        """
        if not self.can_eject:
            raise ProtocolError("This pipette does not have a tip ejector.")

        commands = [self.gcode.comment("Ejecting tip move.")]
        commands += self.gcode.G1(
            e=self.tip_ejector["e_start"],
            f=self.tool_feedrate)
        commands += self.gcode.G1(
            e=self.tip_ejector["e_end"],
            f=self.tip_ejector["eject_feedrate"])
        return commands

    def pickup(self, old_tool, new_tool) -> list:
        """Pickup a tool.
        Assumes an empty toolhead, and a tool in its parking post.
        """
        commands = [self.gcode.comment(f"Picking up {self.name}.")]

        # Home tool axis
        commands += self.home()

        # Home the toolchanger remote.
        # TODO: "gcodeHomeP" is largely deprecated. Replace it.
        commands += self.gcode.gcodeHomeP(cmd_prefix="HOME_", which_axis="E0")

        # Generate commands for the pickup.
        commands += self.gcode_dock()

        # Mark the tool as not parked.
        self.parked = False

        # Mark a toolchange / activate the new tool.
        commands += self.gcode_activate()

        return commands

    def gcode_activate(self) -> list:
        """Signal the firmware to activate this tool.
        This usually means sending gcode commands equivalent to T0, T1, etc. which may switch the active extruder.
        The need for it is really up to the firmware and the machine.
        Example:
        - "activate": "T1"
        """
        return deepcopy(self["activate"])

    def gcode_deactivate(self) -> list:
        """Signal the firmware to deactivate this tool.
        Example:
        - "deactivate": "; dummy deactivate."
        """
        return deepcopy(self["deactivate"])

    def park(self, old_tool, new_tool) -> list:
        """Park a tool.
        Assumes that the tool is mounted, and that its parking post is empty.
        """
        # Generate commands for the pickup.
        parking_commands = self.gcode_park()

        # Mark the tool as parked.
        self.parked = True

        # TODO: Is a "deactivate" function needed for the pipettes?
        parking_commands += self.gcode_deactivate()

        return parking_commands

    @property
    def docking_commands(self):
        return deepcopy(self["docking_commands"])

    # Custom GCODE section
    def gcode_dock(self, commands: list = None) -> list:
        """Generate GCODE to dock a tool (assuming none is loaded).

        Required tool parameters:
        - tool_post: coordinates and offsets of the tool post.
        - docking_commands: list of GCODE commands.
        - parking_commands: list of GCODE commands.

        Returns:
            list: List of GCODES for docking.
        """

        if commands is None:
            commands = []

        # Initialize commands list.
        commands += [self.gcode.comment("START tool-change pickup macro.")]

        # Clearance moves.
        commands += self.gcode_safe_zy()

        # Parked tool coordinates.
        x, y, z = self.tool_post["x"], self.tool_post["y"], self.tool_post["z"]
        # Extra Y-axis distance needed to approach and touch the tool.
        y_docking_closeup = self.tool_post["y_docking_closeup"]
        # Extra Z-axis distance used to help align the tool.
        z_offset = self.tool_post["z_docking_offset"]

        # TODO: This clearance move may be trivial. Consider removing.
        commands += self.gcode_approach(x, self.safe_y_parked, z+z_offset)

        # Initial approach.
        # NOTE: Corrected for axis inversion.
        commands += self.gcode_approach(x, y+y_docking_closeup, z+z_offset)

        # Final approach moves.
        # NOTE: Corrected for axis inversion.
        commands += self.gcode_align(y=y, z=z+z_offset)

        # Tool-changer commands to lock the tool to the toolhead.
        commands += [self.gcode.comment("Locking moves.")]
        commands += self.docking_commands

        # End sequence.
        commands += self.gcode.G0(
            y=self.safe_y_loaded,
            absolute=True,
            add_comment="Move back to the Y coordinate clear of other parked tools.")

        # Done.
        commands += [self.gcode.comment("END tool-change macro.")]

        return commands

    @property
    def parking_commands(self):
        return deepcopy(self["parking_commands"])

    def gcode_park(self, commands: list = None) -> list:
        """Generate GCODE to park a tool (assuming none is loaded).
        TODO: Document this function. See 'gcode_dock' for deatils for now.
        """
        if commands is None:
            commands = []

        # Initialize commands list.
        commands += [self.gcode.comment("START tool-change parking macro.")]

        # Clearance moves.
        commands += self.gcode_safe_zy()

        # Parking post coordinates.
        x, y, z = self.tool_post["x"], self.tool_post["y"], self.tool_post["z"]
        # Extra Y-axis distance needed to approach the post and hang the tool.
        y_parking_closeup = self.tool_post["y_parking_closeup"]
        # Extra Z-axis distance used to help align the tool.
        z_offset = self.tool_post["z_parking_offset"]

        # TODO: This clearance move may be trivial. Consider removing.
        commands += self.gcode_approach(x, self.safe_y_loaded, z+z_offset)

        # Initial approach.
        commands += self.gcode_approach(x, y, z+z_offset)

        # Final approach moves.
        # NOTE: Corrected for axis inversion.
        commands += self.gcode_align(y=y-y_parking_closeup, z=z+z_offset)

        # Tool-changer commands to release the tool from the toolhead.
        commands += [self.gcode.comment("Unlocking moves.")]
        commands += self.parking_commands

        # End sequence.
        commands += self.gcode.G0(
            y=self.safe_y_parked,
            absolute=True,
            add_comment="Move back to the Y coordinate clear of other parked tools.")

        # Done.
        commands += [self.gcode.comment("END tool-change macro.")]

        return commands

    def gcode_safe_zy(self, commands: list = None) -> list:
        """Make G1 GCODE commands to move to safe Z and Y coordinates, in that order."""
        # Use a new command list if none was provided.
        if commands is None:
            commands = []
        # Get the absolute clearance coordinates
        safe_z = self.builder.getSafeHeight()
        safe_y = self.builder.getSafeY()

        # Clearance moves.
        commands += [self.gcode.comment("Safety clearance moves."),
                    "G90" + self.gcode.comment("Set absolute motion mode.")]
        commands += self.gcode.gcodeClearance(
            z=safe_z,
            f=self.builder.feedrate,
            add_comment="Move to the safe Z height.")
        commands += self.gcode.G0(
            y=safe_y,
            add_comment="Move to the Y coordinate clear of other parked tools.")

        return commands

    def gcode_approach(self, x, y, z) -> list:
        """Make G1 GCODE to move in front of a tool-post.
        Moves first in X, then in YZ.
        """
        # Start sequence: approach the tool.
        commands = [self.gcode.comment("Alignment moves.")]
        commands += self.gcode.G0(
            x=x,
            add_comment="Move to the X position in front of the parking post.")
        commands += self.gcode.G0(
            y=y,
            z=z,
            add_comment="Move to the Y-Z position in front of the parking post/tool.")

        return commands

    def gcode_align(self, y, z) -> list:
        """Make G1 GCODE for fine alignment to a tool-post.
        Moves first in Y (fine and slow approach) and then in Z (wobble compensation).
        """
        # Slow feedrates for the moves.
        tc_feedrate = self.tool_post["feedrate"]

        # Initial docking moves.
        commands = [self.gcode.comment("Alignment moves.")]
        commands += self.gcode.G1(
            y=y,
            f=tc_feedrate,
            add_comment="Partial approach for initial alignment.")
        commands += self.gcode.G1(
            z=z,
            f=tc_feedrate,
            add_comment="Compensate Z for looseness in the parking post.")

        return commands

Tool object for pipettes Inheriting from Tool and TrackedDict (which wraps UserDict really) allows getting data with the usual dictionary syntax and methods.

Initializes the TrackedDict instance.

Args

file_path : str
The path to the YAML file.
data : dict
Altertative (and sufficient) data source. Used to update the YAML file if any.

Ancestors

  • Tool
  • TrackedDict
  • YAMLReader
  • collections.UserDict
  • collections.abc.MutableMapping
  • collections.abc.Mapping
  • collections.abc.Collection
  • collections.abc.Sized
  • collections.abc.Iterable
  • collections.abc.Container

Class variables

var default_state

State tracking variables.

var homed : bool

Indicates if the pipette tool is homed or not.

var lastdir

To track the direction of the last pipette move (for compensation).

var tip_loaded : dict

Either None or a dict with the currently placed tip's data.

var tool_feedrate
var tool_velocity_mm_s

Instance variables

prop bare_z_offset
Expand source code
@property
def bare_z_offset(self):
    """Z offset for the tool, with no tips."""
    z = float(self.tool_offset["z"])
    return z

Z offset for the tool, with no tips.

prop can_eject : bool
Expand source code
@property
def can_eject(self) -> bool:
    return bool(self["can_eject"])
prop can_probe : bool
Expand source code
@property
def can_probe(self) -> bool:
    """Check if the pipette can do 'tip probing' during placement."""
    return bool(self["can_probe"])

Check if the pipette can do 'tip probing' during placement.

prop eject_post
Expand source code
@property
def eject_post(self):
    """Coordinates and parameters to eject tips using a fixed post.
    Minimal contents:
        eject_post:
            post_x: 185
            post_y: 301
            post_z_pre: 64
            post_z: 73
            eject_feed_rate: 600
    Notes:
    - post_x: X-axis coordinate of the carriage when the pipette's eject button is under the ejection post.
    - post_y: Y-axis coordinate of the carriage when the pipette's eject button is under the ejection post.
    - post_z_pre: Z-axis coordinate of the carriage when the pipette's eject button is under the ejection post, but not in contact with it.
    - post_z: Z-axis coordinate of the carriage when the pipette's eject button is fully pressed the ejection post, and a tip is ejected.
    - eject_feed_rate: Speed for the ejection motion. Should be "slow".

    Requires a tip to be loaded.
    """
    tip_stage = self.get_tip_stage()
    eject_post = tip_stage["eject_post"]
    return eject_post

Coordinates and parameters to eject tips using a fixed post. Minimal contents: eject_post: post_x: 185 post_y: 301 post_z_pre: 64 post_z: 73 eject_feed_rate: 600 Notes: - post_x: X-axis coordinate of the carriage when the pipette's eject button is under the ejection post. - post_y: Y-axis coordinate of the carriage when the pipette's eject button is under the ejection post. - post_z_pre: Z-axis coordinate of the carriage when the pipette's eject button is under the ejection post, but not in contact with it. - post_z: Z-axis coordinate of the carriage when the pipette's eject button is fully pressed the ejection post, and a tip is ejected. - eject_feed_rate: Speed for the ejection motion. Should be "slow".

Requires a tip to be loaded.

prop eject_post_init_coords : dict
Expand source code
@property
def eject_post_init_coords(self) -> dict:
    """Initial coordinates for the post-based tip ejection sequence."""
    x = self.eject_post["post_x"]
    safe_y = self.builder.getSafeY()
    z_pre = self.eject_post["post_z_pre"]
    return x, safe_y, z_pre

Initial coordinates for the post-based tip ejection sequence.

prop feedrate : float
Expand source code
@property
def feedrate(self) -> float:
    """Get the linear feedrate for the current tip-stage, or get the default
    Convert the volumetric flowrate (in microliters/second) to the speed (in mm/min)
    of a cylindrical shaft that displaces the volume.

    flowrate (float): Volumetric flowrate in microliters/second (µL/s).
    diameter (float): Diameter of the shaft in millimeters (mm).

    Returns:
    float: Speed of the shaft in millimeters per minute (mm/min).
    """

    # Convert flowrate from microliters/second to cubic millimeters/second.
    flowrate_mm3_per_s = self.flowrate  # 1 microliter = 1 cubic millimeter

    # Calculate the cross-sectional area of the cylindrical shaft.
    radius_mm = self.shaft_diameter / 2
    area_mm2 = math.pi * (radius_mm ** 2)

    # Calculate the speed in millimeters per second (mm/s).
    speed_mm_per_s = flowrate_mm3_per_s / area_mm2

    # Convert the speed to millimeters per minute (mm/min)
    speed_mm_per_min = speed_mm_per_s * 60  # In CNC-friendly units.

    return speed_mm_per_min

Get the linear feedrate for the current tip-stage, or get the default Convert the volumetric flowrate (in microliters/second) to the speed (in mm/min) of a cylindrical shaft that displaces the volume.

flowrate (float): Volumetric flowrate in microliters/second (µL/s). diameter (float): Diameter of the shaft in millimeters (mm).

Returns: float: Speed of the shaft in millimeters per minute (mm/min).

prop flowrate : float
Expand source code
@property
def flowrate(self) -> float:
    """Get the flowrate for the current tip, or get it from the default stage.
    TODO: Get the flowrate from the tip instead.
          This requires adding the property to the tip container data.
    """
    # Get the flowrate from the current stage.
    if self.tip_loaded:
        tip_stage = self.get_tip_stage()
        return tip_stage["flowrate"]

    # Get the default tip-stage parameters.
    default_tip_stage = self.get_default_tip_stage()

    return default_tip_stage["flowrate"]

Get the flowrate for the current tip, or get it from the default stage. TODO: Get the flowrate from the tip instead. This requires adding the property to the tip container data.

prop max_volume : float
Expand source code
@property
def max_volume(self) -> float:
    """Current maximum volume limit."""
    # Get the maximum volume from the current stage.
    if self.tip_loaded:
        # Return the smallest limit, between the tip and the pipette.
        tip_container = self.get_tip_container(self.tip_loaded)
        return self.get_max_volume(tip_container)

    # If no tip is loaded, get the default tip-stage parameters.
    default_tip_stage = self.get_default_tip_stage()
    return default_tip_stage["vol_max"]

Current maximum volume limit.

prop probe_extra_dist
Expand source code
@property
def probe_extra_dist(self):
    """Extra (downwards) probing distance, beyond the fitting depth.
    Harmless if the probe works.
    Requires a tip to be loaded.
    """
    return self.get_probe_extra_dist()  # Default container.

Extra (downwards) probing distance, beyond the fitting depth. Harmless if the probe works. Requires a tip to be loaded.

prop scaler
Expand source code
@property
def scaler(self):
    """Ideal conversion factor from displacement (mm) to volume (uL)

    Divide the target volume by this factor to obtain the corresponding 
    linear displacement of a cyllinder with diameter set by `shaft_diameter`.
    
    Expression for a cyllinder's volume: pi * ((shaft_diameter/2)**2)

    Note: the scaler is ideal, and does not contemplate the corrections of the "scaler_adjust" property.
    """
    return math.pi * ((self.shaft_diameter/2)**2)

Ideal conversion factor from displacement (mm) to volume (uL)

Divide the target volume by this factor to obtain the corresponding linear displacement of a cyllinder with diameter set by shaft_diameter.

Expression for a cyllinder's volume: pi * ((shaft_diameter/2)**2)

Note: the scaler is ideal, and does not contemplate the corrections of the "scaler_adjust" property.

prop scaler_adjust
Expand source code
@property
def scaler_adjust(self):
    """Linear adjustment factor for the ideal scaler property
    This is meant to correct sistematic linear deviations from the ideal scaler
    due to, for example, the weight of the water column.
    """
    return float(self["scaler_adjust"])

Linear adjustment factor for the ideal scaler property This is meant to correct sistematic linear deviations from the ideal scaler due to, for example, the weight of the water column.

prop shaft_diameter
Expand source code
@property
def shaft_diameter(self):
    """Physical diameter of the pipette's shaft
    This should be measured precisely with a micrometer,
    at several points along its length to ensure accuracy.
    """
    return float(self["shaft_diameter"])

Physical diameter of the pipette's shaft This should be measured precisely with a micrometer, at several points along its length to ensure accuracy.

prop tension_correction : dict
Expand source code
@property
def tension_correction(self) -> dict:
    """Parameters to correct for surface tension effects at volumes lower than the tip's limits.
    This value may be dynamic. For example, it may depend on which tip is placed.
    Must contain keys:
    {
        "start_vol": 10,
        "max_correction": 0.1
    }
    Requires a tip to be loaded.
    """
    tip_stage = self.get_tip_stage()
    return tip_stage.get("tension_correction")

Parameters to correct for surface tension effects at volumes lower than the tip's limits. This value may be dynamic. For example, it may depend on which tip is placed. Must contain keys: { "start_vol": 10, "max_correction": 0.1 } Requires a tip to be loaded.

prop tip_ejector : bool
Expand source code
@property
def tip_ejector(self) -> bool:
    """Coordinates and parameters to eject tips using a built-in tip ejector.
    The tip ejector is associated to a particular tip stage and container.
    This property uses the stage that matches the current tip.
    Example:
        >>> "tip_ejector": {
        >>>     "e_start": 22,
        >>>     "e_end": 27,
        >>>     "eject_feedrate": 200
        >>> }
    """
    tip_stage = self.get_tip_stage()
    eject_post = tip_stage["tip_ejector"]
    return deepcopy(eject_post)

Coordinates and parameters to eject tips using a built-in tip ejector. The tip ejector is associated to a particular tip stage and container. This property uses the stage that matches the current tip.

Example

>>> "tip_ejector": {
>>>     "e_start": 22,
>>>     "e_end": 27,
>>>     "eject_feedrate": 200
>>> }
prop tip_map : dict
Expand source code
@property
def tip_map(self) -> dict:
    """Correspondence of tip container names to tip stages.
    Example:
      "tip_map": {
        "20 uL Tip": "p20",
        "200 uL Tip": "p200",
        "200 uL Tip Tarsons": "p200",
        "1000 uL Tip": "p1000"
        }
    """
    return deepcopy(self["tip_map"])

Correspondence of tip container names to tip stages.

Example

"tip_map": { "20 uL Tip": "p20", "200 uL Tip": "p200", "200 uL Tip Tarsons": "p200", "1000 uL Tip": "p1000" }

prop tip_stages : dict
Expand source code
@property
def tip_stages(self) -> dict:
    """Definition of tip-fitting stages on the pipette's tip holder.
    Example:
        "tip_stages": {
            "default": "p1000",
            "p200": {
                "vol_max": 200,
                "flowrate": 100,
                "z_offset": 5,
                "tip_fit_distance": { ... },
                "probe_extra_dist": 2.7,
                "eject_post": { ... },
                "tension_correction": { ... }
            }
        }
    """
    return deepcopy(self["tip_stages"])

Definition of tip-fitting stages on the pipette's tip holder.

Example

"tip_stages": { "default": "p1000", "p200": { "vol_max": 200, "flowrate": 100, "z_offset": 5, "tip_fit_distance": { … }, "probe_extra_dist": 2.7, "eject_post": { … }, "tension_correction": { … } } }

prop z_offset
Expand source code
@property
def z_offset(self):
    """Vertical offset for the tool, as a computed property.
    Defined as the height at which the machine touches the baseplate when the tool is attached.
    This value may be context-dependent. For example, it may depend on wether a tip is placed on
    a (pipette) tool or not.
    It is used by GcodeBuilder to compute vertical clearance and the safe height.
    """
    # Start value for the calculation.
    z = self.bare_z_offset

    # Add tip offsets, only if a tip is loaded.
    if self.tip_loaded:
        # Get the associated container.
        tip_container = self.get_tip_container(self.tip_loaded)
        # Add the total length of the tip.
        z += tip_container["length"]
        # Subtract tip-stage z-offset.
        z -= self.get_tip_stage_offset(tip_container)
        # Subtract the fit distance.
        z -= self.get_tip_fit_distance(tip_container)

    # Done!
    return z

Vertical offset for the tool, as a computed property. Defined as the height at which the machine touches the baseplate when the tool is attached. This value may be context-dependent. For example, it may depend on wether a tip is placed on a (pipette) tool or not. It is used by GcodeBuilder to compute vertical clearance and the safe height.

Methods

def eject_tip(self) ‑> list
Expand source code
def eject_tip(self) -> list:
    """Generate GCODE for the ejection, and update the pipette's state."""
    # Get the current tip stage.
    tip_stage = self.get_tip_stage()

    # Make the GCODE.
    if self.can_eject and "tip_ejector" in tip_stage:
        # Use the integrated ejector.
        commands = self.eject_with_ejector()
    elif "eject_post" in tip_stage:
        # Use the fixed ejection post.
        commands = self.eject_tip_with_post()
    else:
        raise ProtocolError("Neither a tip ejector or a tip-ejection post are configured.")

    # Register no tip in the volume tracker
    self.tip_loaded = False
    self.state["tip_vol"] = None

    return commands

Generate GCODE for the ejection, and update the pipette's state.

def eject_tip_with_post(self, eject_feed_rate=None) ‑> list
Expand source code
def eject_tip_with_post(self, eject_feed_rate=None) -> list:
    """Make GCODE to eject the tip by elegantly crashing with a "post".
    This assumes that the tool is safely positioned such that it may
    move freely towards the eject-post, and then use it."""

    # Get tip ejector parameters.
    eject_post = self.eject_post
    y, z_pre, z = eject_post["post_y"], eject_post["post_z_pre"], eject_post["post_z"]

    # Set ejection move speed.
    if eject_feed_rate is None:
        eject_feed_rate = eject_post["eject_feed_rate"]

    # Start the command list.
    commands = []

    # Align to the ejector on Z (without tool-offsets).
    commands += self.gcode.G0(z=z_pre, absolute=True, add_comment="Approach the eject-post on Z.")

    # Align to the ejector on Y (without tool-offsets), inserting the tip holder in the ejector.
    commands += self.gcode.G0(y=y, absolute=True, add_comment="Approach the eject-post on Y.")

    # Move upwards to eject the tip (without tool-offsets).
    commands += self.gcode.G1(z=z, f=eject_feed_rate, absolute=True, add_comment="Eject the tip.")
    # BUG: The tip-probe is "erratic" in the reverse direction.
    #      A regular mechanical endstop would be better, because
    #      it has a small amount of hysteresis.
    #      Use a regular upwards move instead.

    # Compute clearance values.
    safe_y = self.builder.getSafeY()

    # Move to safe Y.
    commands += self.gcode.G0(
        y=safe_y, absolute=True,
        add_comment="Move away from the ejection post up to safe Y (avoid parked tools).")

    # The safe_z distance will actually change during this GCODE.
    # Let the "macroDiscardTip" function handle this.
    commands += [self.gcode.comment("END tip-ejection macro.")]

    return commands

Make GCODE to eject the tip by elegantly crashing with a "post". This assumes that the tool is safely positioned such that it may move freely towards the eject-post, and then use it.

def eject_with_ejector(self) ‑> list
Expand source code
def eject_with_ejector(self) -> list:
    """Generate GCODE to eject a tip using the integrated tip-ejector.
    {
        "tip_ejector": {
            "e_start": 200,
            "e_end": 250,
            "eject_feedrate": 200
        },
    }
    """
    if not self.can_eject:
        raise ProtocolError("This pipette does not have a tip ejector.")

    commands = [self.gcode.comment("Ejecting tip move.")]
    commands += self.gcode.G1(
        e=self.tip_ejector["e_start"],
        f=self.tool_feedrate)
    commands += self.gcode.G1(
        e=self.tip_ejector["e_end"],
        f=self.tip_ejector["eject_feedrate"])
    return commands

Generate GCODE to eject a tip using the integrated tip-ejector. { "tip_ejector": { "e_start": 200, "e_end": 250, "eject_feedrate": 200 }, }

def get_default_tip_stage(self) ‑> dict
Expand source code
def get_default_tip_stage(self) -> dict:
    """Get the default tip-stage's parameters"""
    default_tip_stage_name = self.tip_stages["default"]
    default_tip_stage = self.tip_stages[default_tip_stage_name]
    return default_tip_stage

Get the default tip-stage's parameters

def get_max_volume(self, tip_container: dict = None)
Expand source code
def get_max_volume(self, tip_container: dict = None):
    """Return the maximum volume limit, between the tip and the pipette."""
    # Get the max volume of the pipette stage.
    tip_stage = self.get_tip_stage(tip_container)
    tip_stage_max_vol = tip_stage["vol_max"]

    # Get the max volume of the tip.
    tip_max_vol = tip_container["maxVolume"]

    # Return the smallest value.
    return min(tip_stage_max_vol, tip_max_vol)

Return the maximum volume limit, between the tip and the pipette.

def get_probe_extra_dist(self, tip_container: dict = None)
Expand source code
def get_probe_extra_dist(self, tip_container: dict = None):
    """Calculate 'probe_extra_dist' for a target tip container and tool."""
    # Adjust by tip-stage offset, if needed.
    tip_stage = self.get_tip_stage(tip_container=tip_container)
    return  tip_stage.get("probe_extra_dist")

Calculate 'probe_extra_dist' for a target tip container and tool.

def get_tip_container(self, tip: dict) ‑> dict
Expand source code
def get_tip_container(self, tip: dict) -> dict:
    """Get the container data of a tip from the database."""
    container_name = tip.get("container")
    container = self.controller.database_tools.getContainerByName(container_name)
    return container

Get the container data of a tip from the database.

def get_tip_fit_distance(self, tip_container: dict = None)
Expand source code
def get_tip_fit_distance(self, tip_container: dict = None):
    """Calculate 'tip_fit_distance' for a target tip container and tool."""
    # Adjust by tip-stage offset, if needed.
    tip_stage = self.get_tip_stage(tip_container=tip_container)

    stage_fit_distances = tip_stage.get("tip_fit_distance")
    tip_fit_distance = stage_fit_distances[tip_container["name"]]

    return  tip_fit_distance

Calculate 'tip_fit_distance' for a target tip container and tool.

def get_tip_stage(self, tip_container: dict = None) ‑> dict
Expand source code
def get_tip_stage(self, tip_container: dict=None) -> dict:
    """Get the tip stage parameters for a particular tip."""

    # Default tip_container to curent tip if not provided.
    if tip_container is None:
        # A tip is needed to inferr the relevant tip stage.
        if not self.tip_loaded:
            raise ValueError("No tip has been loaded yet, and no default container was provided.")
        # Get the currently loaded tip.
        tip_container = self.get_tip_container(self.tip_loaded)

    # Get the tip's "model" name.
    tip_container_name = tip_container["name"]

    # Get tip-stage parameters from the tool definition.
    tip_map = self.tip_map
    tip_stages = self.tip_stages
    default_tip_stage_name = tip_stages["default"]

    # Get the name of the tip stage.
    stage_name = tip_map.get(tip_container_name, default_tip_stage_name)

    # Get the tip stage.
    tip_stage = tip_stages[stage_name]

    return tip_stage

Get the tip stage parameters for a particular tip.

def get_tip_stage_offset(self, tip_container: dict = None)
Expand source code
def get_tip_stage_offset(self, tip_container: dict = None):
    """Calculate tip-stage z-offset for a target tip container and tool."""
    # Adjust by tip-stage offset, if needed.
    tip_stage = self.get_tip_stage(tip_container=tip_container)

    # NOTE: The value is subtracted because a "deeper" tip stage
    #       will make the Z-height at which the tool touches ground
    #       a bit lower.
    return tip_stage.get("z_offset")

Calculate tip-stage z-offset for a target tip container and tool.

def is_home(self)
Expand source code
def is_home(self):
    """Infer if the pipette is in the 'home' position from the state tracker."""
    return (
        self.homed and
        (self.state["vol"] == self.state["s"] == 0) and
        self.state["lastdir"] == self.builder.sign(-1)
    )

Infer if the pipette is in the 'home' position from the state tracker.

def set_home(self)
Expand source code
def set_home(self):
    # Zero the volumes
    self.state["vol"] = 0
    self.state["s"] = 0
    # Update direction with the current move
    self.state["lastdir"] = self.builder.sign(-1)
    # Set the homedflag.
    self.homed = True

Inherited members