Parameter Sets
A parameter set is the pipetting recipe for a single step — how much, how deep, how fast, with which liquid class — expressed as a Python dataclass instead of a pile of keyword arguments. It exists so you can write the recipe once, keep it in version control, and hand the same object to a Hamilton MicrolabSTAR, a Tecan Freedom EVO, or an Agilent Bravo.
The device-independent sets AspirateParameterSet and DispenseParameterSet describe what should happen. Each device has its own subclass — CoRe96AspirateParameterSet, MCAAspirateParameterSet, BravoAspirateParameterSet — that adds the fields only that hardware has. When you pass a generic set to a pipetting method, the method converts it to its own subclass and rejects anything the hardware cannot express, rather than silently dropping it.
Every field is optional
MISSING is the default for every field. A MISSING field is not "zero" — it means "decide this from the deck state at run time". volume=MISSING resolves to the smaller of the tip's free volume and the source's available volume; liquid_class=MISSING resolves to the best match for the mounted tip and the tracked liquid.
That is what makes one recipe portable. You pin the values that must not drift and leave the rest to the device.
from unitelabs.liquid_handling import AspirateParameterSet, AspiratePipetteMode
from unitelabs.labware import Vector
transfer = AspirateParameterSet(
volume=100,
pipette_mode=AspiratePipetteMode.SURFACE,
liquid_offset=1.0,
offset=Vector(x=0.5, y=0.5, z=0),
)
AspiratePipetteMode on an aspirate set and DispensePipetteMode on a dispense set. The base PipetteMode enum is accepted by the constructor but is not a member of either narrowed type, so dumps() and adopt() fail on it later with a TypeError.The four stages
Every aspirate and dispense runs the same pipeline. Understanding where a call failed tells you what kind of problem you have.
adopt validate compile apply
│ │ │ │
generic set is this safe device wire update tracked
→ device set on this deck? parameters volumes
│ │ │ │
ValueError: ValueError: dict sent to tips and wells
field not warnings from api.aspirate() reflect the move
representable the resolvers
show_defaults is the introspection tool alongside this pipeline: it runs the same resolvers as compile but returns a parameter set instead of a wire dictionary, so you can see what the device decided.
The examples below use a Standard96Plate half-filled with water (150 µL per well, ~3.94 mm liquid height) on both a MicrolabSTARMock CO-RE 96 head with 300 µL tips and a TecanFreedomEvoMock MCA96 head with 200 µL tips.
Stage 1 — adopt: does this device have these knobs?
adopt is a serialization round trip: the source set is dumped to a dictionary and loaded into the target class. Fields with the same name and a compatible type carry over; fields the target does not declare raise. Unset fields stay unset, so the target's own defaulting still applies.
from unitelabs.liquid_handling.hamilton.modules.core96 import CoRe96AspirateParameterSet
from unitelabs.liquid_handling.tecan.modules import MCAAspirateParameterSet
CoRe96AspirateParameterSet.adopt(transfer) # → CoRe96AspirateParameterSet
MCAAspirateParameterSet.adopt(transfer) # → MCAAspirateParameterSet
Both succeed, because every device set is a strict superset of the generic set:
| Field | Generic | CO-RE 96 | MCA96 | Bravo |
|---|---|---|---|---|
pipette_mode, liquid_offset, follow_liquid, volume, liquid_class, offset, pull_out_distance, end_z_offset | ✓ | ✓ | ✓ | ✓ |
min_traverse_height, mixing | ✓ | ✓ | ✓ | |
well_offset | ✓ | ✓ | ||
lld_mode, tadm | ✓ | |||
touch_side | ✓ (dispense) | ✓ (dispense) | ✓ (dispense) | |
touch_side_direction | ✓ (dispense) | |||
velocity, start_z_position, min_z_position, touch_tip_* | ✓ |
Generic → device therefore always adopts. Device → device only adopts when the source left the target's missing fields unset.
Failure: the target has no such field
The CO-RE 96 head has capacitive liquid-level detection; the MCA96 has none. A set that pins lld_mode cannot be honoured on the EVO, so it is refused instead of quietly losing the safety behaviour you asked for.
star_set = CoRe96AspirateParameterSet(volume=50, lld_mode=True)
MCAAspirateParameterSet.adopt(star_set)
ValueError: Cannot adopt CoRe96AspirateParameterSet as MCAAspirateParameterSet:
lld_mode: Unknown field.
It fails in the other direction too — the MCA96 can pick the compass direction of its tip touch, the CO-RE 96 cannot:
ValueError: Cannot adopt MCADispenseParameterSet as CoRe96DispenseParameterSet:
touch_side_direction: Unknown field.
Failure: the liquid class belongs to another vendor
Liquid classes are not portable — Hamilton models pipetting as plunger flow rates, the EVO as plunger speeds plus air gaps (see Liquid Classes). Each device set narrows the liquid_class field to its own base class, so a foreign class is caught at adoption:
from unitelabs.labware.tecan import EvoLiquidClass
evo_set = MCAAspirateParameterSet(volume=50, liquid_class=EvoLiquidClass())
CoRe96AspirateParameterSet.adopt(evo_set)
ValueError: Cannot adopt MCAAspirateParameterSet as CoRe96AspirateParameterSet:
liquid_class: Invalid type: EvoLiquidClass. No liquid class found.
A generic set that leaves liquid_class unset adopts everywhere and lets each device select its own. That is the portable choice.
Stage 2 — validate: is this safe on this deck right now?
validate runs compile with warnings captured. Every UserWarning a resolver emits becomes one line of a single ValueError; other warning categories are ignored. It changes no state and touches no hardware, so it is safe to call in a dry run.
Pipetting methods call validate for you before compiling. Call it yourself when you want to check a set against a deck before committing to a run.
from unitelabs.liquid_handling.hamilton.modules.core96 import CoRe96Context
context = CoRe96Context(module=star.core96, target=plate)
assert CoRe96AspirateParameterSet.adopt(transfer).validate(context) # True
Passing on both machines
The recipe above validates on the CO-RE 96 and the MCA96 unchanged: 100 µL fits both the 300 µL and the 200 µL tip, the source has 150 µL, and a 1 mm surface offset is shallower than the 3.94 mm liquid column.
Failing on both, with different reasoning
Ask for a 100 mm immersion depth in a well holding 3.94 mm of liquid and both refuse — but each explains it in its own terms, because each resolver knows a different thing about the hardware:
deep = AspirateParameterSet(volume=50, pipette_mode=AspiratePipetteMode.SURFACE, liquid_offset=100)
CoRe96AspirateParameterSet.adopt(deep).validate(star_context)
# ValueError: Parameter set CoRe96AspirateParameterSet failed validation due to the
# following warnings:
# - Provided liquid offset 100.0 would result in the pipette crashing into the
# labware bottom with a current liquid height of 3.94.
MCAAspirateParameterSet.adopt(deep).validate(evo_context)
# ValueError: Parameter set MCAAspirateParameterSet failed validation due to the
# following warnings:
# - Liquid offset must be less than or equal to the liquid level
# 3.942601642189242663732223653, received 100.0.
The CO-RE 96 message is framed around liquid-level detection; the MCA96 has none and reasons purely from container geometry and tracked volume. Same rejection, different physics.
Failing on one machine only
The MCA96 head has no jet mode. The CO-RE 96 does, so the same dispense recipe splits:
from unitelabs.liquid_handling import DispenseParameterSet, DispensePipetteMode
jet = DispenseParameterSet(volume=50, pipette_mode=DispensePipetteMode.JET)
CoRe96DispenseParameterSet.adopt(jet).validate(star_context) # True
MCADispenseParameterSet.adopt(jet).validate(evo_context)
# ValueError: JET mode is not supported by the MCA96 head; use SURFACE or BOTTOM.
Note the shape of that last error: it is raised directly by the resolver, not collected from a warning, so it carries no "failed validation due to the following warnings" preamble. A hard constraint aborts; a soft one is reported and clamped.
Where the state matters more than the recipe
Two calls with an identical set can disagree, because validate reads live deck state. Asking for 5000 µL from a plate well passes — the resolver clamps the request to the 150 µL actually present, which fits the tip. Asking for the same 5000 µL from a trough fails, because a trough really can supply it and the 300 µL tip cannot hold it:
big = AspirateParameterSet(volume=5000)
CoRe96AspirateParameterSet.adopt(big).validate(plate_context) # True — clamped to 150 µL
CoRe96AspirateParameterSet.adopt(big).validate(trough_context)
# ValueError: ... Volume must be less than or equal to the free tip volume 300,
# received 5000.0.
A set that validates against one deck says nothing about another. Pin the values that must stay constant.
Stage 3 — show_defaults: what did the device decide?
show_defaults returns a copy of the set with every MISSING field replaced by the value it would resolve to, and every field you set explicitly passed through its resolver so you see the effective value. It recurses into nested sets such as mixing and tadm. Compare a field before and after: MISSING before and populated after means it was inferred.
resolved = CoRe96AspirateParameterSet.adopt(transfer).show_defaults(star_context)
resolved.lld_mode # LLDMode.CAPACITIVE ← inferred from SURFACE mode
resolved.pull_out_distance # Decimal('5') ← Hamilton default
resolved.min_traverse_height # Decimal('245') ← from the instrument configuration
resolved.liquid_class # HamiltonTip_10_CoRe96_Water_DispenseSurface_Empty
resolved.mixing.cycles # 0 ← nested, resolved to "no mixing"
resolved.volume # Decimal('100.0') ← you set this; unchanged
The original is never mutated, and the result is always a fresh instance of the same class. Fields with no meaningful resolved form opt out and stay MISSING: end_z_offset everywhere (its resolver yields an absolute Z, not an offset) and follow_liquid on the Bravo (the head ignores it).
Resolution emits the same warnings as compile, so wrap the call in warnings.catch_warnings if you want to inspect them rather than let them print.
show_defaults currently raises TypeError on MCAAspirateParameterSet and MCADispenseParameterSet. Use compile to inspect resolved values on the EVO MCA96 in the meantime.Stage 4 — compile: the wire parameters
compile turns the resolved set into the keyword arguments for the device connector call. This is where the abstraction ends: the two heads share four keys out of roughly thirty.
28 keys, framed as positions plus flow rates:
{
"aspirate_location": Vector(x=253.50, y=530.50, z=216.69),
"immersion_depth": Decimal("1.0"),
"liquid_volume": Decimal("106.500"), # includes the liquid class's over-aspirate
"lld_mode": LLDMode.CAPACITIVE,
"lld_sensitivity": 4,
"flow_rate": Decimal("25"), # µL/s, from the liquid class
"swap_speed": Decimal("2"),
"settling_time": Decimal("0"),
"min_z_position": Decimal("212.75"),
"start_z_position": Decimal("226.10"),
"end_z_position": Decimal("245"),
"min_traverse_height": Decimal("245"),
"tadm_mode": TADMMode.OFF,
...
}
27 keys, framed as a plunger program plus air gaps:
{
"x": Decimal("24.90"), "y": Decimal("278.50"), "z": Decimal("9.94"),
"plunger_movement": Decimal("100.00"), # calibrated volume
"aspirate_speed": Decimal("50.0"), # µL/s, from the liquid class
"aspirate_delay": 500,
"leading_air_gap": Decimal("5.0"),
"leading_air_gap_speed": Decimal("10.0"),
"trailing_air_gap": Decimal("5.0"),
"system_trailing_air_gap": Decimal("0.0"),
"relative_tracking_distance": Decimal("2.63"),
"plunger_start_ramp": 5760,
"plunger_stop_ramp": 16000,
"z_retract": Decimal("210"),
...
}
Shared keys: min_traverse_height, mix_cycles, mix_volume, pull_out_distance. Everything else is vendor vocabulary. Compiled dictionaries are not portable — the parameter set is the portable artifact, and adopt is what makes it so.
After the connector call returns, apply writes the transfer back into the liquid model: the source well loses the volume, the tip gains it. That is what keeps a later validate on the same deck honest.
Broadcasting to independent channels
The Hamilton channel arm pipettes each channel independently, so its sets are per-channel: scalars broadcast, lists must match the length of channels. A generic set is spread identically across every selected channel by from_parameters:
from unitelabs.liquid_handling.hamilton.modules.pipettes import (
ChannelsAspirateParameterSet,
ChannelsContext,
)
channels_set = ChannelsAspirateParameterSet.from_parameters(transfer, channels=[0, 1, 2, 3])
channels_set.volume # [100.0, 100.0, 100.0, 100.0]
Passing the generic set straight to pipettes.aspirate does the same thing implicitly. Validation then runs per channel and names the offender:
uneven = ChannelsAspirateParameterSet(
channels=[0, 1, 2, 3],
volume=100,
liquid_offset=[1.0, 1.0, 100.0, 1.0],
)
uneven.validate(channels_context)
# ValueError: Parameter set ChannelsAspirateParameterSet failed validation due to
# the following warnings:
# - [CHANNEL 2]: Provided liquid offset 100.0 would result in the pipette crashing
# into the labware bottom with a current liquid height of 3.94.
Reusing and composing sets
Parameter sets round-trip through plain dictionaries, so they can live in configuration files or a database next to the run that used them:
saved = transfer.dumps()
# {'pipette_mode': 'SURFACE', 'liquid_offset': 1.0, 'volume': 100.0,
# 'offset': {'x': '0.5', 'y': '0.5', 'z': '0'}}
restored = AspirateParameterSet.loads(saved)
assert restored == transfer
copy gives you a deep copy. update merges another set of the same class in place — by default the incoming values win; with missing_only=True only your unset fields are filled, which is how you layer a site-wide default under a step-specific override:
step = AspirateParameterSet(volume=100, liquid_offset=1.0)
step.update(AspirateParameterSet(liquid_offset=2.0, follow_liquid=True), missing_only=True)
step.liquid_offset # 1.0 — yours was set, so it stands
step.follow_liquid # True — was MISSING, so the default filled it
update requires the same class on both sides; use adopt to cross device boundaries. Nested sets merge recursively, but lists and other containers are replaced wholesale rather than merged element-wise.
Practical guidance
- Keep protocol-level recipes generic. Build
AspirateParameterSet/DispenseParameterSetand let each device adopt them. Reach for a device subclass only when you need a knob that exists on one machine. - Leave
liquid_classunset in portable sets. It is the one field guaranteed not to travel. - Pin what must not drift, leave the rest
MISSING. Resolvers read live deck state, so an unpinned value legitimately differs between runs. validateearly on a mock. Every device ships one, so the whole pipeline up to the connector call runs without hardware. See Simulation.- A
ValueErrorfromadoptis a portability problem; aValueErrorfromvalidateis a deck problem. The first needs a different recipe, the second needs a different deck state.
Where to go next
- Advanced Pipetting — the procedural how-to, factory methods, and the Hamilton compiled-parameter reference
- Liquid Classes — why the one field that does not travel does not travel
- Complex Mixing — parameter sets that carry a list of alternating steps
- Tecan Freedom EVO Basic Pipetting and Agilent Bravo Basic Pipetting — the per-device field references