12  Errors

PLR raises errors from two places.

Frontend errors are raised from the resource tree and the trackers, before the backend is called. They are portable: the same mistake raises the same class on every backend.

Backend errors come from the machine and are vendor-specific — STARFirmwareError, TecanError, VantageFirmwareError. Of the 137 exception classes in 0.2.2, about 60 belong to the STAR backend.

from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.liquid_handling.backends import LiquidHandlerChatterboxBackend
from pylabrobot.resources import (
    STARLetDeck, PLT_CAR_L5AC_A00, cor_96_wellplate_360uL_Fb,
    opentrons_96_filtertiprack_200ul, TIP_CAR_288_C00,
    set_volume_tracking,
)

lh = LiquidHandler(backend=LiquidHandlerChatterboxBackend(), deck=STARLetDeck())
await lh.setup()

carrier = PLT_CAR_L5AC_A00(name="carrier")
lh.deck.assign_child_resource(carrier, rails=10)
carrier[0] = plate = cor_96_wellplate_360uL_Fb(name="plate")

tips = TIP_CAR_288_C00(name="tip_carrier")
lh.deck.assign_child_resource(tips, rails=2)
tips[0] = tip_rack = opentrons_96_filtertiprack_200ul(name="tips")

set_volume_tracking(True)
1
Volume tracking is off by default (chapter 1). The recipes below need it on.

12.1 The portable errors

Look up which error a given mistake raises, and what kind of error it is.

The classes sort into four groups by where they are raised, which also determines whether they are portable and whether a tracking flag can suppress them.

Tree errors — raised by the resource tree, from a name or a location that does not resolve.

Error Raised at Message
ResourceNotFoundError resources/deck.py, resources/resource.py Resource 'x' not found (deck) / Resource with name 'x' does not exist. (resource)
NoLocationError resources/resource.py Resource 'x' has no location.

Tracker errors — raised by the volume and tip trackers, from the model’s record of what is where. These are the only ones the no_*_tracking() hatches suppress (below).

Error Raised at Raised when
TooLittleLiquidError resources/volume_tracker.py aspirating more than the container holds
TooLittleVolumeError resources/volume_tracker.py dispensing more than it can take
NoTipError resources/tip_tracker.py a tip was expected, none found
HasTipError resources/tip_tracker.py the tip spot already holds a tip

Frontend guards — raised by LiquidHandler itself while planning, before the backend is called. Not suppressible.

Error Raised at Raised when
HasTipError liquid_handling/liquid_handler.py the channel already has a tip
BlowOutVolumeError liquid_handling/liquid_handler.py blow-out air exceeds the aspirated volume
ChannelsDoNotFitError liquid_handling/channel_positioning.py channels cannot be spaced in the resource

Backend-raised — these two live in shared modules but are constructed only inside one backend, so they do not appear on other machines.

Error Only raised by
ChannelizedError the STAR backend (below)
NoChannelError the Opentrons backend

Two further classes are defined and never raised anywhere in 0.2.2: CrossContaminationError (below) and ResourceDefinitionIncompleteError.

from pylabrobot.resources.errors import ResourceNotFoundError, NoLocationError

try:
    lh.deck.get_resource("no_such_plate")
except ResourceNotFoundError as e:
    print("ResourceNotFoundError:", e)

orphan = cor_96_wellplate_360uL_Fb(name="orphan")
try:
    orphan.get_absolute_location()
except NoLocationError as e:
    print("NoLocationError:", e)
1
Constructed but never assigned, so it has no position in the tree.
ResourceNotFoundError: Resource 'no_such_plate' not found
NoLocationError: Resource 'orphan' has no location.

Backend errors.

Everything not in the table is vendor-specific: STARModuleError and its ~60 subclasses, TecanError, CytomatBusyError, and the Liconic and Molecular Devices families. Catch these by base class (except STARFirmwareError), not by leaf. Chapter 12 covers backend error families.

See also: ResourceNotFoundError is the failure mode of get_resource in chapter 2; ChannelsDoNotFitError is the no-go-zone case in chapter 4.


12.2 Continue a worklist past a bad row

Run a list of transfers where some rows fail, and complete the rest.

print("completed:", len(worklist) - len(failed), "of", len(worklist))
for row in failed:
    print("  failed:", row)
completed: 3 of 5
  failed: ('A1', 'B1', 100, 'TooLittleVolumeError')
  failed: ('A2', 'B2', 50, 'TooLittleLiquidError')
from pylabrobot.resources.errors import TooLittleLiquidError, TooLittleVolumeError

plate["A1"][0].set_volume(1000)

worklist = [("A1", "B1", 100),
            ("A1", "B1", 100),
            ("A1", "B1", 100),
            ("A1", "B1", 100),
            ("A2", "B2", 50)]

await lh.pick_up_tips(tip_rack["A1"])
failed = []
for src, dst, vol in worklist:
    try:
        await lh.aspirate(plate[src], vols=[vol])
        await lh.dispense(plate[dst], vols=[vol])
    except (TooLittleLiquidError, TooLittleVolumeError) as e:
        failed.append((src, dst, vol, type(e).__name__))
1
A1 is the only well with liquid. A2 is empty.
2
B1 holds 360 µL, so the first three rows fit.
3
The fourth would bring B1 to 400 µL. The aspirate succeeds and the dispense raises TooLittleVolumeError, which leaves 100 µL in the tip.
4
Aspirating from empty A2 raises TooLittleLiquidError on the aspirate, so no dispense is attempted.
5
Catching the two volume errors specifically leaves other exceptions to propagate.

Gotcha: a tip holding liquid cannot be dropped.

If an aspirate succeeds and its dispense fails, the tip keeps the liquid, and drop_tips raises:

RuntimeError: Cannot drop tip with volume 50.0

This is a bare RuntimeError, not one of the portable classes, so except TooLittleVolumeError does not catch it. Dispense to waste before dropping, or handle the drop separately.

Python you need here

except (A, B) as e catches a tuple of classes in one clause. Python takes the first matching except, so list subclasses before their parents.

See also: the same rows checked before the run in chapter 7.


12.3 Read a partial multichannel failure

Determine which channels failed and which succeeded after a multichannel operation raises.

from pylabrobot.liquid_handling.errors import ChannelizedError
from pylabrobot.resources.errors import NoTipError, TooLittleLiquidError

error = ChannelizedError(errors={
    2: NoTipError("Channel 2 does not have a tip."),
    5: TooLittleLiquidError("Not enough liquid in container: 50.0uL > 0uL."),
})

used_channels = list(range(8))
survivors = [ch for ch in used_channels if ch not in error.errors]

print("failed channels: ", sorted(error.errors))
print("survived:        ", survivors)
print("failure count:   ", len(error))
for channel, cause in sorted(error.errors.items()):
    print(f"  channel {channel}: {type(cause).__name__} - {cause}")
1
Constructed here, not provoked: the chatterbox does not raise one (see below).
2
error.errors is a Dict[int, Exception] keyed by channel index. Channels absent from it succeeded.
3
ChannelizedError implements __len__.
4
Each value is that channel’s individual error.
failed channels:  [2, 5]
survived:         [0, 1, 3, 4, 6, 7]
failure count:    2
  channel 2: NoTipError - Channel 2 does not have a tip.
  channel 5: TooLittleLiquidError - Not enough liquid in container: 50.0uL > 0uL.

Gotcha: only the STAR backend raises this.

In 0.2.2, ChannelizedError is constructed in one place — liquid_handling/backends/hamilton/STAR_backend.py. The chatterbox never produces one, so a partial-failure handler cannot be exercised in simulation. The frontend logic that consumes it is backend-agnostic.

See also: lh.probe_tip_presence_via_pickup() catches ChannelizedError, marks the failed channels’ spots as empty, and re-raises if a channel it did not ask about appears in errors.


12.4 Suppress tracking for a block

Aspirate from a well whose contents PLR does not know about, without disabling tracking for the rest of the run.

print("before:", before, "| inside:", inside, "| after:", after)
before: True | inside: False | after: True
from pylabrobot.resources import no_volume_tracking, does_volume_tracking

before = does_volume_tracking()

with no_volume_tracking():
    inside = does_volume_tracking()
    await lh.aspirate(plate["D1"], vols=[10])

after = does_volume_tracking()
1
A context manager, not a flag to reset by hand.
2
D1 was never filled. With tracking on this raises TooLittleLiquidError.
3
Restored when the block exits normally — but see the gotcha below.
WarningGotcha: an exception inside the block leaves tracking off

Neither context manager wraps its yield in try/finally:

@contextlib.contextmanager
def no_volume_tracking():
  old_value = this.volume_tracking_enabled
  this.volume_tracking_enabled = False
  yield                                       # <-- an exception here skips the line below
  this.volume_tracking_enabled = old_value

If the body raises, the restore never runs and volume tracking stays off for the rest of the session, including in code that never asked to suppress it:

try:
    with no_volume_tracking():
        raise ValueError("something failed mid-block")
except ValueError:
    pass

print("volume tracking still on?", does_volume_tracking())

set_volume_tracking(True)      # put it back for the rest of this chapter
volume tracking still on? False

no_tip_tracking() has the same shape and the same behaviour. To keep the suppression scoped, restore it yourself:

from pylabrobot.resources import set_volume_tracking

was_on = does_volume_tracking()
try:
    with no_volume_tracking():
        ...
finally:
    set_volume_tracking(was_on)

What the tracker knows.

A container’s tracked volume is only what PLR was told — by set_volume(), or by an aspirate or dispense it performed. Liquid that arrived any other way is not in the model: a trough filled by hand before the run, a plate returned from a machine PLR does not model, or a well whose starting volume was never seeded. In each case the tracked volume is 0 and an aspirate raises TooLittleLiquidError while the well is physically full.

WarningGotcha: no_tip_tracking() does not suppress every HasTipError

There are two, and only one is suppressible:

Message Raised by Suppressible
"Channel has tip" liquid_handler.py, on the channel head no
"<spot> already has a tip." the tip spot’s tracker yes

In pick_up_tips, the channel-head check sits one line above the does_tip_tracking() guard, so it runs unconditionally. Picking up a second tip on an occupied channel fails inside no_tip_tracking() exactly as it does outside.

See also: set_volume_tracking(True) / set_tip_tracking(True) set the flags globally, as this chapter’s setup does; the no_* context managers scope the suppression instead.


12.5 CrossContaminationError is never raised

CrossContaminationError imports and is never raised.

import warnings
from pylabrobot.resources.errors import CrossContaminationError

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    CrossContaminationError("tip touched two liquids")
    print(caught[0].category.__name__, "->", caught[0].message)
1
Constructing one emits a DeprecationWarning from its __init__. Nothing in the package raises it.
DeprecationWarning -> Cross contamination tracking is deprecated and will be removed in a future version. 

Related removals.

set_cross_contamination_tracking() and tracker.liquid_history are gone entirely in 0.2.2. Code from older PLR that calls them raises ImportError or AttributeError at the import line.


12.6 What to remember

  • Frontend errors are portable; backend errors are not. Eight classes cover the portable liquid-handling surface, in three groups: tree, tracker, and frontend guard. Only the tracker group is suppressible.
  • ChannelizedError (STAR) and NoChannelError (Opentrons) sit in shared modules but are raised by one backend each. CrossContaminationError and ResourceDefinitionIncompleteError are never raised at all.
  • A caught frontend error means nothing moved — trackers rolled back, the loop can continue.
  • ChannelizedError.errors maps channel → cause; channels not listed succeeded and are already committed. Only the STAR backend raises it.
  • no_volume_tracking() / no_tip_tracking() are scoped context managers.
  • "Channel has tip" is unconditional; the tip-spot HasTipError is suppressible.
  • CrossContaminationError is never raised.