Example #1
0
def plan_arc(
        origin_point: Point,
        dest_point: Point,
        z_height: float,
        origin_cp: CriticalPoint = None,
        dest_cp: CriticalPoint = None,
        extra_waypoints: List[Tuple[float, float]] = None)\
        -> List[Tuple[Point, Optional[CriticalPoint]]]:

    assert z_height >= max(origin_point.z, dest_point.z)
    checked_wp = extra_waypoints or []
    return [(origin_point._replace(z=z_height), origin_cp)]\
        + [(Point(x=wp[0], y=wp[1], z=z_height), dest_cp)
           for wp in checked_wp]\
        + [(dest_point._replace(z=z_height), dest_cp),
           (dest_point, dest_cp)]
Example #2
0
def check_arc_basic(arc: List[Point], from_pt: Point, to_pt: Point):
    """ Check the tests that should always be true for different-well moves
    - we should always go only up, then only xy, then only down
    - we should have three moves
    """
    # first move should move only up
    assert arc[0]._replace(z=0) == from_pt._replace(z=0)
    # second move should move only in xy
    assert arc[0].z == arc[1].z
    # second-to-last move should always end at the dest in xy
    # so the last move is z-only
    assert arc[-2]._replace(z=0) == to_pt._replace(z=0)
    # final move should arrive precisely at the dest
    assert arc[-1] == to_pt
    # first move should be up
    assert arc[0].z >= from_pt.z
    # last move should be down
    assert arc[-2].z >= to_pt.z
Example #3
0
async def move(request):
    """
    Moves the robot to the specified position as provided by the `control.info`
    endpoint response

    Post body must include the following keys:
    - 'target': either 'mount' or 'pipette'
    - 'point': a tuple of 3 floats for x, y, z
    - 'mount': must be 'left' or 'right'

    If 'target' is 'pipette', body must also contain:
    - 'model': must be a valid pipette model (as defined in `pipette_config`)
    """
    hw = hw_from_req(request)
    req = await request.text()
    data = json.loads(req)

    target, point, mount, model, message, error = _validate_move_data(data)
    if error:
        status = 400
    else:
        status = 200
        if ff.use_protocol_api_v2():
            await hw.cache_instruments()
            if target == 'mount':
                critical_point: Optional[CriticalPoint] = CriticalPoint.MOUNT
            else:
                critical_point = None
            mount = Mount[mount.upper()]
            target = Point(*point)
            await hw.home_z()
            pos = await hw.gantry_position(mount, critical_point)
            await hw.move_to(mount, target._replace(z=pos.z),
                             critical_point=critical_point)
            await hw.move_to(mount, target,
                             critical_point=critical_point)
            pos = await hw.gantry_position(mount)
            message = 'Move complete. New position: {}'.format(pos)
        else:
            if target == 'mount':
                message = _move_mount(hw, mount, point)
            elif target == 'pipette':
                message = _move_pipette(hw, mount, model, point)

    return web.json_response({"message": message}, status=status)
Example #4
0
def test_critical_points(model):
    loaded = pipette_config.load(model)
    pip = pipette.Pipette(loaded, {
        'single': [0, 0, 0],
        'multi': [0, 0, 0]
    }, 'testID')
    mod_offset = Point(*loaded.model_offset)
    assert pip.critical_point() == mod_offset
    assert pip.critical_point(types.CriticalPoint.NOZZLE) == mod_offset
    assert pip.critical_point(types.CriticalPoint.TIP) == mod_offset
    tip_length = 25.0
    pip.add_tip(tip_length)
    new = mod_offset._replace(z=mod_offset.z - tip_length)
    assert pip.critical_point() == new
    assert pip.critical_point(types.CriticalPoint.NOZZLE) == mod_offset
    assert pip.critical_point(types.CriticalPoint.TIP) == new
    pip.remove_tip()
    assert pip.critical_point() == mod_offset
    assert pip.critical_point(types.CriticalPoint.NOZZLE) == mod_offset
    assert pip.critical_point(types.CriticalPoint.TIP) == mod_offset
Example #5
0
class Pipette:
    """ A class to gather and track pipette state and configs.

    This class should not touch hardware or call back out to the hardware
    control API. Its only purpose is to gather state.
    """

    DictType = Dict[str, Union[str, float, bool]]

    #: The type of this data class as a dict

    def __init__(self,
                 model: str,
                 inst_offset_config: Dict[str, Tuple[float, float, float]],
                 pipette_id: str = None) -> None:
        self._config = pipette_config.load(model, pipette_id)
        self._name = pipette_config.name_for_model(model)
        self._model = model
        self._model_offset = self._config.model_offset
        self._current_volume = 0.0
        self._working_volume = self._config.max_volume
        self._current_tip_length = 0.0
        self._current_tiprack_diameter = 0.0
        self._fallback_tip_length = self._config.tip_length
        self._tip_overlap_map = self._config.tip_overlap
        self._has_tip = False
        self._pipette_id = pipette_id
        pip_type = 'multi' if self._config.channels > 1 else 'single'
        self._instrument_offset = Point(*inst_offset_config[pip_type])
        self._log = mod_log.getChild(
            self._pipette_id if self._pipette_id else '<unknown>')
        self._log.info("loaded: {}, instr offset {}".format(
            model, self._instrument_offset))
        self.ready_to_aspirate = False
        #: True if ready to aspirate

    def update_instrument_offset(self, new_offset: Point):
        self._log.info("updated instrument offset to {}".format(new_offset))
        self._instrument_offset = new_offset

    @property
    def config(self) -> pipette_config.pipette_config:
        return self._config

    @property
    def model_offset(self):
        return self._model_offset

    def update_config_item(self, elem_name: str, elem_val: Any):
        self._log.info("updated config: {}={}".format(elem_name, elem_val))
        self._config = self._config._replace(**{elem_name: elem_val})

    @property
    def name(self) -> str:
        return self._name

    @property
    def model(self) -> str:
        return self._model

    @property
    def pipette_id(self) -> Optional[str]:
        return self._pipette_id

    def critical_point(self, cp_override: CriticalPoint = None) -> Point:
        """
        The vector from the pipette's origin to its critical point. The
        critical point for a pipette is the end of the nozzle if no tip is
        attached, or the end of the tip if a tip is attached.

        If `cp_override` is specified and valid - so is either
        :py:attr:`CriticalPoint.NOZZLE` or :py:attr:`CriticalPoint.TIP` when
        we have a tip, or :py:attr:`CriticalPoint.XY_CENTER` - the specified
        critical point will be used.
        """
        if not self.has_tip or cp_override == CriticalPoint.NOZZLE:
            cp_type = CriticalPoint.NOZZLE
            tip_length = 0.0
        else:
            cp_type = CriticalPoint.TIP
            tip_length = self.current_tip_length
        if cp_override == CriticalPoint.XY_CENTER:
            mod_offset_xy = [0, 0, self.model_offset[2]]
            cp_type = CriticalPoint.XY_CENTER
        elif cp_override == CriticalPoint.FRONT_NOZZLE:
            mod_offset_xy = [0, -self.model_offset[1], self.model_offset[2]]
            cp_type = CriticalPoint.FRONT_NOZZLE
        else:
            mod_offset_xy = self.model_offset
        mod_and_tip = Point(mod_offset_xy[0], mod_offset_xy[1],
                            mod_offset_xy[2] - tip_length)
        cp = mod_and_tip + self._instrument_offset._replace(z=0)
        if self._log.isEnabledFor(logging.DEBUG):
            mo = 'model offset: {} + '.format(self.model_offset)\
                if cp_type != CriticalPoint.XY_CENTER else ''
            info_str = 'cp: {}{}: {}=({}instr offset xy: {}'\
                .format(cp_type, '(from override)' if cp_override else '',
                        cp, mo,
                        self._instrument_offset._replace(z=0))
            if cp_type == CriticalPoint.TIP:
                info_str += '- current_tip_length: {}=(true tip length: {}'\
                    ' - inst z: {}) (z only)'.format(
                        self.current_tip_length, self._current_tip_length,
                        self._instrument_offset.z)
            info_str += ')'
            self._log.debug(info_str)
        return cp

    @property
    def current_volume(self) -> float:
        """ The amount of liquid currently aspirated """
        return self._current_volume

    @property
    def current_tip_length(self) -> float:
        """ The length of the current tip attached (0.0 if no tip) """
        return (self._current_tip_length - self._instrument_offset.z)

    @property
    def current_tiprack_diameter(self) -> float:
        """ The diameter of the current tip rack (0.0 if no tip) """
        return self._current_tiprack_diameter

    @current_tiprack_diameter.setter
    def current_tiprack_diameter(self, diameter: float):
        self._current_tiprack_diameter = diameter

    @property
    def working_volume(self) -> float:
        """ The working volume of the pipette """
        return self._working_volume

    @working_volume.setter
    def working_volume(self, tip_volume: float):
        """ The working volume is the current tip max volume """
        self._working_volume = min(self.config.max_volume, tip_volume)

    @property
    def available_volume(self) -> float:
        """ The amount of liquid possible to aspirate """
        return self.working_volume - self.current_volume

    def set_current_volume(self, new_volume: float):
        assert new_volume >= 0
        assert new_volume <= self.working_volume
        self._current_volume = new_volume

    def add_current_volume(self, volume_incr: float):
        assert self.ok_to_add_volume(volume_incr)
        self._current_volume += volume_incr

    def remove_current_volume(self, volume_incr: float):
        assert self._current_volume >= volume_incr
        self._current_volume -= volume_incr

    def ok_to_add_volume(self, volume_incr: float) -> bool:
        return self.current_volume + volume_incr <= self.working_volume

    def add_tip(self, tip_length: float) -> None:
        """
        Add a tip to the pipette for position tracking and validation
        (effectively updates the pipette's critical point)

        :param tip_length: a positive, non-zero float presenting the distance
            in Z from the end of the pipette nozzle to the end of the tip
        :return:
        """
        assert tip_length > 0.0, "tip_length must be greater than 0"
        assert not self.has_tip
        self._has_tip = True
        self._current_tip_length = tip_length

    def remove_tip(self) -> None:
        """
        Remove the tip from the pipette (effectively updates the pipette's
        critical point)
        """
        assert self.has_tip
        self._has_tip = False
        self._current_tip_length = 0.0

    @property
    def has_tip(self) -> bool:
        return self._has_tip

    def ul_per_mm(self, ul: float, action: str) -> float:
        sequence = self._config.ul_per_mm[action]
        return pipette_config.piecewise_volume_conversion(ul, sequence)

    def __str__(self) -> str:
        return '{} current volume {}ul critical point: {} at {}'\
            .format(self._config.display_name,
                    self.current_volume,
                    'tip end' if self.has_tip else 'nozzle end',
                    0)

    def __repr__(self) -> str:
        return '<{}: {} {}>'.format(self.__class__.__name__,
                                    self._config.display_name, id(self))

    def as_dict(self) -> 'Pipette.DictType':
        config_dict = self.config._asdict()
        config_dict.update({
            'current_volume': self.current_volume,
            'available_volume': self.available_volume,
            'name': self.name,
            'model': self.model,
            'pipette_id': self.pipette_id,
            'has_tip': self.has_tip,
            'working_volume': self.working_volume
        })
        return config_dict
Example #6
0
async def test_moves_to_hotspot(hardware_api, monkeypatch, mount,
                                pipette_model):
    move_calls = []
    rel_calls = []
    probe_calls = []

    old_move_to = hardware_api.move_to
    old_move_rel = hardware_api.move_rel
    old_probe = hardware_api._backend.probe

    async def fake_move_to(which_mount, point):
        move_calls.append((which_mount, point))
        return await old_move_to(which_mount, point)

    async def fake_move_rel(which_mount, delta):
        rel_calls.append((which_mount, delta))
        return await old_move_rel(which_mount, delta)

    def fake_probe(ax, dist):
        probe_calls.append((ax, dist))
        return old_probe(ax, dist)

    monkeypatch.setattr(hardware_api, 'move_to', fake_move_to)
    monkeypatch.setattr(hardware_api, 'move_rel', fake_move_rel)
    monkeypatch.setattr(hardware_api._backend, 'probe', fake_probe)

    await hardware_api.cache_instruments(
        {mount: name_for_model(pipette_model)})
    await hardware_api.home()

    center = await hardware_api.locate_tip_probe_center(mount, 30)
    hotspots = robot_configs.calculate_tip_probe_hotspots(
        30, hardware_api._config.tip_probe)
    assert len(move_calls) == len(hotspots) * 4
    assert len(rel_calls) == len(hotspots)
    move_iter = iter(move_calls)
    rel_iter = iter(rel_calls)
    probe_iter = iter(probe_calls)
    bounce_base = hardware_api._config.tip_probe.bounce_distance
    old_center = hardware_api._config.tip_probe.center
    for hs in hotspots:
        x0 = old_center[0] + hs.x_start_offs
        y0 = old_center[1] + hs.y_start_offs
        z0 = hs.z_start_abs
        next(move_iter)
        next(move_iter)
        rel = next(rel_iter)
        if hs.probe_distance < 0:
            bounce = bounce_base
        else:
            bounce = -bounce_base
        assert rel[1] == Point(**{hs.axis: bounce})
        prep_point = next(move_iter)
        assert prep_point[1] == Point(x0, y0, z0)
        probe = next(probe_iter)
        assert probe[0] == hs.axis if hs.axis != 'z' else 'a'
        assert probe[1] == hs.probe_distance
        next(move_iter)

    targ = Point(*hardware_api._config.tip_probe.center)
    # The x and y are the same because the offsets from our naive mock of
    # probe() should cancel out, but the z only has one side so we need
    # to figure out what it will be
    targ = targ._replace(z=hotspots[-1][3] + hotspots[-1][4])
    assert list(center) == pytest.approx(targ)
Example #7
0
class Pipette:
    """ A class to gather and track pipette state and configs.

    This class should not touch hardware or call back out to the hardware
    control API. Its only purpose is to gather state.
    """

    DictType = Dict[str, Union[str, float, bool]]

    #: The type of this data class as a dict

    def __init__(self,
                 config: pipette_config.PipetteConfig,
                 inst_offset_config: Union[InstrumentOffsetConfig, Point],
                 pipette_id: str = None) -> None:
        self._config = config
        self._acting_as = self._config.name
        self._name = self._config.name
        self._model = self._config.model
        self._model_offset = self._config.model_offset
        self._current_volume = 0.0
        self._working_volume = self._config.max_volume
        self._current_tip_length = 0.0
        self._current_tiprack_diameter = 0.0
        self._fallback_tip_length = self._config.tip_length
        self._tip_overlap_map = self._config.tip_overlap
        self._has_tip = False
        self._pipette_id = pipette_id
        if isinstance(inst_offset_config, dict):
            pip_type = 'multi' if self._config.channels == 8 else 'single'
            self._instrument_offset = Point(*inst_offset_config[pip_type])
        else:
            self._instrument_offset = inst_offset_config
        self._log = mod_log.getChild(
            self._pipette_id if self._pipette_id else '<unknown>')
        self._log.info("loaded: {}, instr offset {}".format(
            config.model, self._instrument_offset))
        self.ready_to_aspirate = False
        #: True if ready to aspirate
        self._aspirate_flow_rate\
            = self._config.default_aspirate_flow_rates['2.0']
        self._dispense_flow_rate\
            = self._config.default_dispense_flow_rates['2.0']
        self._blow_out_flow_rate\
            = self._config.default_blow_out_flow_rates['2.0']

    def act_as(self, name: PipetteName):
        """ Reconfigure to act as ``name``. ``name`` must be either the
        actual name of the pipette, or a name in its back-compatibility
        config.
        """
        if name == self._acting_as:
            return

        assert name in self._config.back_compat_names + [self.name],\
            f'{self._name} is not back-compatible with {name}'
        name_conf = pipette_config.name_config()
        bc_conf = name_conf[name]
        self.working_volume = bc_conf['maxVolume']
        self.update_config_item('min_volume', bc_conf['minVolume'])
        self.update_config_item('max_volume', bc_conf['maxVolume'])

    @property
    def acting_as(self) -> PipetteName:
        return self._acting_as

    def update_instrument_offset(self, new_offset: Point):
        self._log.info("updated instrument offset to {}".format(new_offset))
        self._instrument_offset = new_offset

    @property
    def config(self) -> pipette_config.PipetteConfig:
        return self._config

    @property
    def model_offset(self):
        return self._model_offset

    def update_config_item(self, elem_name: str, elem_val: Any):
        self._log.info("updated config: {}={}".format(elem_name, elem_val))
        self._config = replace(self._config, **{elem_name: elem_val})

    @property
    def name(self) -> PipetteName:
        return self._name

    @property
    def model(self) -> PipetteModel:
        return self._model

    @property
    def pipette_id(self) -> Optional[str]:
        return self._pipette_id

    def critical_point(self, cp_override: CriticalPoint = None) -> Point:
        """
        The vector from the pipette's origin to its critical point. The
        critical point for a pipette is the end of the nozzle if no tip is
        attached, or the end of the tip if a tip is attached.

        If `cp_override` is specified and valid - so is either
        :py:attr:`CriticalPoint.NOZZLE` or :py:attr:`CriticalPoint.TIP` when
        we have a tip, or :py:attr:`CriticalPoint.XY_CENTER` - the specified
        critical point will be used.
        """
        if not self.has_tip or cp_override == CriticalPoint.NOZZLE:
            cp_type = CriticalPoint.NOZZLE
            tip_length = 0.0
        else:
            cp_type = CriticalPoint.TIP
            tip_length = self.current_tip_length
        if cp_override == CriticalPoint.XY_CENTER:
            mod_offset_xy = [0, 0, self.model_offset[2]]
            cp_type = CriticalPoint.XY_CENTER
        elif cp_override == CriticalPoint.FRONT_NOZZLE:
            mod_offset_xy = [0, -self.model_offset[1], self.model_offset[2]]
            cp_type = CriticalPoint.FRONT_NOZZLE
        else:
            mod_offset_xy = self.model_offset
        mod_and_tip = Point(mod_offset_xy[0], mod_offset_xy[1],
                            mod_offset_xy[2] - tip_length)

        if enable_calibration_overhaul():
            instr = self._instrument_offset
        else:
            instr = self._instrument_offset._replace(z=0)

        cp = mod_and_tip + instr

        if self._log.isEnabledFor(logging.DEBUG):
            info_str = 'cp: {}{}: {} (from: '\
                .format(cp_type,
                        ' (from override)' if cp_override else '',
                        cp)
            info_str += 'model offset: {} + instrument offset: {}'\
                .format(mod_offset_xy, instr)
            info_str += ' - tip_length: {}'.format(tip_length)
            info_str += ')'
            self._log.debug(info_str)

        return cp

    @property
    def current_volume(self) -> float:
        """ The amount of liquid currently aspirated """
        return self._current_volume

    @property
    def current_tip_length(self) -> float:
        """ The length of the current tip attached (0.0 if no tip) """
        if enable_calibration_overhaul():
            return self._current_tip_length
        else:
            return (self._current_tip_length - self._instrument_offset.z)

    @property
    def current_tiprack_diameter(self) -> float:
        """ The diameter of the current tip rack (0.0 if no tip) """
        return self._current_tiprack_diameter

    @current_tiprack_diameter.setter
    def current_tiprack_diameter(self, diameter: float):
        self._current_tiprack_diameter = diameter

    @property
    def aspirate_flow_rate(self) -> float:
        """ Current active flow rate (not config value)"""
        return self._aspirate_flow_rate

    @aspirate_flow_rate.setter
    def aspirate_flow_rate(self, new_flow_rate: float):
        assert new_flow_rate > 0
        self._aspirate_flow_rate = new_flow_rate

    @property
    def dispense_flow_rate(self) -> float:
        """ Current active flow rate (not config value)"""
        return self._dispense_flow_rate

    @dispense_flow_rate.setter
    def dispense_flow_rate(self, new_flow_rate: float):
        assert new_flow_rate > 0
        self._dispense_flow_rate = new_flow_rate

    @property
    def blow_out_flow_rate(self) -> float:
        """ Current active flow rate (not config value)"""
        return self._blow_out_flow_rate

    @blow_out_flow_rate.setter
    def blow_out_flow_rate(self, new_flow_rate: float):
        assert new_flow_rate > 0
        self._blow_out_flow_rate = new_flow_rate

    @property
    def working_volume(self) -> float:
        """ The working volume of the pipette """
        return self._working_volume

    @working_volume.setter
    def working_volume(self, tip_volume: float):
        """ The working volume is the current tip max volume """
        self._working_volume = min(self.config.max_volume, tip_volume)

    @property
    def available_volume(self) -> float:
        """ The amount of liquid possible to aspirate """
        return self.working_volume - self.current_volume

    def set_current_volume(self, new_volume: float):
        assert new_volume >= 0
        assert new_volume <= self.working_volume
        self._current_volume = new_volume

    def add_current_volume(self, volume_incr: float):
        assert self.ok_to_add_volume(volume_incr)
        self._current_volume += volume_incr

    def remove_current_volume(self, volume_incr: float):
        assert self._current_volume >= volume_incr
        self._current_volume -= volume_incr

    def ok_to_add_volume(self, volume_incr: float) -> bool:
        return self.current_volume + volume_incr <= self.working_volume

    def add_tip(self, tip_length: float) -> None:
        """
        Add a tip to the pipette for position tracking and validation
        (effectively updates the pipette's critical point)

        :param tip_length: a positive, non-zero float presenting the distance
            in Z from the end of the pipette nozzle to the end of the tip
        :return:
        """
        assert tip_length > 0.0, "tip_length must be greater than 0"
        assert not self.has_tip
        self._has_tip = True
        self._current_tip_length = tip_length

    def remove_tip(self) -> None:
        """
        Remove the tip from the pipette (effectively updates the pipette's
        critical point)
        """
        assert self.has_tip
        self._has_tip = False
        self._current_tip_length = 0.0

    @property
    def has_tip(self) -> bool:
        return self._has_tip

    def ul_per_mm(self, ul: float, action: UlPerMmAction) -> float:
        sequence = self._config.ul_per_mm[action]
        return pipette_config.piecewise_volume_conversion(ul, sequence)

    def __str__(self) -> str:
        return '{} current volume {}ul critical point: {} at {}'\
            .format(self._config.display_name,
                    self.current_volume,
                    'tip end' if self.has_tip else 'nozzle end',
                    0)

    def __repr__(self) -> str:
        return '<{}: {} {}>'.format(self.__class__.__name__,
                                    self._config.display_name, id(self))

    def as_dict(self) -> 'Pipette.DictType':
        config_dict = asdict(self.config)
        config_dict.update({
            'current_volume': self.current_volume,
            'available_volume': self.available_volume,
            'name': self.name,
            'model': self.model,
            'pipette_id': self.pipette_id,
            'has_tip': self.has_tip,
            'working_volume': self.working_volume,
            'aspirate_flow_rate': self.aspirate_flow_rate,
            'dispense_flow_rate': self.dispense_flow_rate,
            'blow_out_flow_rate': self.blow_out_flow_rate
        })
        return config_dict