Defining a New Instrument

This page is for building support for a liquid handler that Pourfecto does not already include, typically in an instrument definition kept in a separate package. Forking Pourfecto is not necessary. A new InstrumentModel subtype defined in the new package, together with methods added to the exported generic functions of Pourfecto (Mask, write_instrument_files, packing_greedy, and others), is enough. Julia's multiple dispatch allows a package to add a method to a function from another package when one of the argument types belongs to the new package.

A complete instrument definition has four parts:

  1. A data model: an InstrumentModel subtype, a Head, a Deck, and a Configuration. Configurations describes them.
  2. A Mask method, which defines the valid (well, position, channel) combinations.
  3. Compiler hooks, which turn a solved plan into files that the instrument control software can run.
  4. Registration and tests, which make the instrument discoverable and verify it.

The Cobra instrument is a worked example of all four parts. Its source is Pourfecto/src/instruments/Cobra.jl, and it is a good template to read from start to finish. Cobra is specific to the Jensen Lab: it has a hardcoded lab file path, a lab-specific plate-name mapping, and a vendor XML format tied to the SoftLinx installation of the lab. It will not run correctly in another lab without changes. The caveats are documented on the Cobra type.

Masks

A Mask records, for a (Head, Labware) pair, which combinations of well, head position, and channel are physically valid for aspirating and dispensing. If an instrument has no Mask method, the default always returns false. This is not an error. It means the instrument cannot aspirate or dispense from anything yet.

The Mask entry in the API Reference gives its fields.

Mask rules

The recommended way to define a mask is a table of MaskRules, one for each combination of labware kinds, direction, and geometry archetype. build_mask_from_rules turns the table into a mask. Deriving both the mask and the deck admissibility (the labware field of ConstrainedPosition) from one table keeps them consistent, and the table is what the conformance test kit uses for automatic coverage testing. MaskRule, build_mask_from_rules, and mask_rules_for are described in the API Reference.

Example: mask rules for Cobra

const cobra_mask_rules = [
    MaskRule(Set([:WP96,:DeepWP96]), :aspirate, :sliding_window, (;)),
    MaskRule(Set([:WP96,:DeepWP96]), :dispense, :sliding_window, (;v_out=true)), # the mask can exit the plate vertically
    MaskRule(Set([:WP384]), :aspirate, :sliding_window, (;v_spacing=2)),
    MaskRule(Set([:WP384]), :dispense, :sliding_window, (;v_spacing=2,v_out=true)),
]
const cobra_wellplate_kinds = union((r.kinds for r in cobra_mask_rules)...)

The Cobra head aspirates from and dispenses into :WP96 and :DeepWP96 plates with the :sliding_window archetype, in which the channel grid of the head slides across the well grid of the labware. Dispensing can also overhang the plate edge vertically (v_out=true). :WP384 plates use the same archetype with v_spacing=2, because the 4-channel head of the Cobra touches only every other row at the finer pitch of a 384-well plate. cobra_wellplate_kinds, the set of labware kinds that the deck positions of the Cobra admit, is derived from the same table with union, so the deck and the mask stay consistent.

Two lines connect the rule table to the instrument:

Mask(h::Head{Cobra}, l::Labware) = build_mask_from_rules(h, l, cobra_mask_rules)
mask_rules_for(::Configuration{Cobra}) = cobra_mask_rules

The first line is required for scheduling. The second is optional but recommended. Without it, the mask-coverage check of the conformance test kit skips the instrument and shows a warning. A Mask method without a rule table is not wrong, only unverified.

An instrument whose geometry does not fit :sliding_window or :blanket can call the lower-level functions directly or use a custom predicate. The fields of Mask (asp, disp, asp_positions, disp_positions) are exported accessors for this purpose.

Pourfecto.sliding_window_mask — Function
sliding_window_mask(h::Head,l::Labware,direction::Symbol; v_spacing=1,h_spacing=1,v_out=false,h_out=false)

Build the (predicate,positions) pair for the sliding-window mask archetype – the head's channel grid (spaced v_spacing/h_spacing well-units apart, optionally overhanging the labware edge via v_out/h_out) slides across the labware's well grid. Covers every Mask branch except the undifferentiated-well case (see blanket_mask); a single-channel head reduces to a plain point match automatically (the spacing term vanishes when H[d]=1), so there is no separate case to hand-write.

Per dimension, if effective_head_size collapsed (the head is wider/taller than the labware in that dimension – only for a pairing already declared safe by the calling Mask branch's kind list), the channel term is dropped from the predicate: any channel is valid, matching the position-count that already clamped for the same reason.

source
Pourfecto.blanket_mask — Function
blanket_mask(h::Head,l::Labware,direction::Symbol)

Build the (predicate,positions) pair for the blanket mask archetype: any in-bounds (well,position,channel) combination is valid, with no further geometric discrimination – for a single, undifferentiated well (e.g. a deep reservoir) that multiple channels/positions may validly touch at once.

source
Pourfecto.effective_head_size — Function
effective_head_size(h::Head,l::Labware,direction::Symbol)

The effective channel-grid size to use when computing positions/masks for h/l in direction. Defaults to the raw head size (no collapsing). Some instruments (e.g. EightChannel/PlateMaster) are sometimes paired with a labware kind whose grid is smaller than the head's channel count in some dimension – only ever for a (head,labware kind) pairing already declared safe by the calling Mask branch's own kind list – and override this to clamp the effective size down to the labware's own extent in that dimension. compute_positions and sliding_window_mask both consult this single function, so the position-count and the mask predicate can never drift out of sync with each other.

source

Asymmetric aspirate and dispense topology

Most instruments aspirate and dispense with the same channel topology, but some do not. The 8 pistons of the Tempest share one intake channel while aspirating and fan out to 8 independent nozzles while dispensing. For these instruments, build the Head with the channel_routing keyword instead of the three-argument constructor:

Head{Tempest}(pistons, aspirate_channels, aspirate_mask, dispense_channels, dispense_mask; channel_routing)

The Head docstring describes channel_routing. The Tempest and Nimbus definitions in Pourfecto/src/instruments/ are worked examples.

The compiler pipeline

compile turns a solved Pourcast into protocol files on disk for each instrument configuration. Compiling Protocols describes running compile and reading its output. This section describes the same pipeline for someone extending it.

pourfecto(...) solves a Pourcast
  -> compile(directory, pourcast)
       -> per Configuration: slotting_requirements determines which source/target labware
          pairs must be co-slotted
       -> packing_method (default packing_greedy) produces one or more SlottingDict layouts
       -> for each layout: write_instrument_files(protocol_directory, design, sources, targets,
          config, slotting; kwargs...)

writeinstrumentfiles

write_instrument_files is the required extension point for a custom protocol file format:

write_instrument_files(directory::AbstractString, design::DataFrame,
                        source::Vector{<:Labware}, target::Vector{<:Labware},
                        config::Configuration{YourInstrument},
                        slotting::SlottingDict = slotting_greedy(vcat(source,target), config);
                        kwargs...) -> Nothing

Without an override, a generic fallback writes a plain transfer_table.csv. An instrument with a Configuration and a Mask and no custom compiler code therefore already produces valid, generic output. Instrument-specific formats, such as the SoftLinx XML of the Cobra, the .dl.txt file of the Mantis, and the .mdl.txt file of the Tempest, are optional and are dispatched on Configuration{YourInstrument}.

Existing instruments often move work into a private helper such as convert_design inside their write_instrument_files method. The name is a local convention, and Pourfecto does not dispatch on it.

packing_greedy

Most instruments do not need to override packing_greedy. It works with slotting_greedy to handle typical bin-packing slotting. Cobra overrides it because it has two deck slots and needs exactly one protocol for each source and target pairing, so that every change of labware pairing starts a new protocol. Override packing_greedy only for similarly unusual slotting constraints.

Pourfecto.write_instrument_files — Function
write_instrument_files(directory, design, source, target, config::Configuration{Nimbus}, slotting=slotting_greedy(...);
                        batch_ordering::Symbol=:greedy, volume_precision::Int=1, insert_blowouts::Bool=true,
                        waste_target=nimbus_waste_target, dead_volume_buffer::Real=20.0, aspirate_buffer::Real=0.01,
                        polish_clustering::Bool=false, polish_max_iterations::Int=1000, kwargs...)

Compile a design into a Nimbus protocol CSV written to directory. Unlike the generic compiler fallback, the Nimbus supports one-to-many aspirate/dispense: for each source well, as many destination dispenses as fit within the channel's capacity are drawn up in a single aspirate and dispensed together, rather than aspirating once per destination (see batch_design for the full batching/rounding/blowout pipeline). batch_ordering selects how dispenses within a batch are sequenced – :greedy (default, nearest-neighbor) or :exact (optimal, brute-force – only tractable for small batches, see order_exact) – and can be benchmarked against each other by calling pourfecto/compile with either value.

The written CSV uses a unified action-row schema: "Labware ID","Labware Position ID", "Volume (uL)","Action","Change Tip Before", Action being "Aspirate", "Dispense", or "Blowout". An aspirate row is always immediately followed by the rows it feeds (its dispenses, and a trailing blowout when insert_blowouts=true and a re-aspirate follows under the same tip), whose volumes sum to aspirate - aspirate_buffer exactly at volume_precision decimal places (see round_with_exact_sum) – the aspirate carries aspirate_buffer extra, a small unconditional physical safety margin (default 0.01 µL) against real-world pipetting inaccuracy on the last action in a cycle; see batch_design for the full precision/headroom reasoning. "Change Tip Before" is 1 on an aspirate row when the tip should be changed before that aspirate: always on a source change, and periodically thereafter per the instrument's "max_tip_use" setting (see tip_change_flags) – note "max_tip_use" counts aspirate batches, not individual dispense shots. It's always 0 on Dispense/Blowout rows.

insert_blowouts, waste_target, and dead_volume_buffer control the dead-volume Blowout path – see batch_design. insert_blowouts defaults to true, with dead_volume_buffer defaulting to 20.0 µL – compiling with none of these three kwargs supplied still inserts Blowout rows wherever a re-aspirate happens under the same tip. waste_target defaults to the permanently-reserved waste conical (nimbus_waste_target) automatically excluded from ordinary source/target slotting (see slotting_greedy's Configuration{Nimbus} override), so it doesn't need to be supplied explicitly either. Pass insert_blowouts=false to disable the path entirely.

polish_clustering (and polish_max_iterations) enable an exchange/local-search pass over cluster_batches's output to further reduce head-travel distance – see polish_batches and batch_design. Opt-in; default false preserves current behavior exactly.

source

Compiling function for Mantis

source
write_instrument_files(directory, design, source, target, config::Configuration{NimbusFourChannel},
                        slotting=slotting_greedy(...); n_channels, volume_precision,
                        insert_blowouts, waste_target, dead_volume_buffer, aspirate_buffer,
                        priming, priming_volume, priming_target, kwargs...)

Compile a design into a 4-channel Nimbus protocol CSV written to directory. See batch_design_four_channel for the full sweep/order/assembly pipeline (and for what each named keyword controls). This is a standalone compile entry point, independent of single-channel Nimbus's write_instrument_files (see this file's header comment).

Named keywords are forwarded to batch_design_four_channel; any other keyword (kwargs...) is accepted and silently dropped, not forwarded further – mirroring single-channel Nimbus's own write_instrument_files. This matters because pourfecto(directory,...)'s single-call form forwards the same keyword set to both the solve (e.g. optimizer=SCIP.Optimizer) and the compile stage (compile(directory,pourcast;kwargs...), Pourfecto/src/compiler/compile.jl:69) – without a catch-all here, a solve-only keyword like optimizer would otherwise reach batch_design_four_channel (which has no catch-all of its own) and error.

source
Pourfecto.packing_greedy — Function
packing_greedy(pairings::Vector{Tuple{Labware,Labware}}, config::Configuration; kwargs...) -> Vector{SlottingDict}

Produce one or more SlottingDict layouts covering every (source,target) pairing that must be co-slotted, by repeatedly calling slotting_greedy on the full remaining labware set and peeling off however many pairings that solution satisfies, until every pairing is covered. Each returned SlottingDict becomes one compiled protocol (see compile).

This is the default packing_method for compile and the extension point instruments should override only when they have unusual slotting constraints – e.g. Cobra overrides this to always produce one protocol per pairing (it only has two deck slots and deliberately wants a fresh protocol whenever the labware pairing changes), rather than greedily co-packing as many pairings as possible into each protocol the way this generic implementation does.

source
Pourfecto.slotting_greedy — Function
slotting_greedy(labware::Vector{<:Labware}, config::Configuration; pinned::SlottingDict=SlottingDict()) -> SlottingDict

Greedily assign each piece of labware to an open, admissible deck slot on config's deck. Labware sharing a name (e.g. the same physical plate used as both a source and a target) is slotted once and shares that slot. Errors if any labware can't be placed on config's deck at all (see can_place); labware that can be placed but for which no slot remains open is returned in the not_placed local (not currently surfaced – see packing_greedy, which calls this repeatedly to produce one or more slotting solutions covering everything).

pinned pre-seeds the result with fixed labware => (position,slot) assignments – e.g. a permanently-mounted fixture that always occupies the same physical deck slot – before the greedy loop runs; those slots are removed from consideration for every other piece of labware, and pinned labware is skipped by the ordinary placement loop. Empty by default, so existing callers see no behavior change. See Nimbus.jl's slotting_greedy(::Vector{<:Labware},::Configuration{Nimbus}) override for a worked example (a reserved waste-conical slot).

source
slotting_greedy(labware::Vector{<:Labware}, config::Configuration{Nimbus}) -> SlottingDict

Nimbus-specific override: pins nimbus_waste_conical to its fixed physical slot (TubeRack50ML_WellNames_0006, well A3, nearest the tip rack) before delegating everything else to the generic slotting_greedy. That slot is a permanent fixture on the real Nimbus deck – excluded from ordinary source/target slotting on every compile, independent of whether the protocol actually uses the Blowout path, exactly like Cobra's packing_greedy override reflects its own instrument-specific deck constraints.

source
slotting_greedy(labware::Vector{<:Labware}, config::Configuration{NimbusFourChannel}) -> SlottingDict

Pins nimbus_4ch_waste_conical to its fixed physical slot before delegating to the generic slotting_greedy, mirroring single-channel Nimbus's own override (reimplemented independently, per this module's design decisions).

source

Registering an instrument

Pourfecto.register_instrument! — Function
register_instrument!(config::Configuration; name::AbstractString, override::Bool=false)

Register config in the global configurations registry under name, making it discoverable via configurations["name"] and usable by name in pourfecto.

Errors if name is already registered unless override=true (in which case the previous entry is replaced and a warning is emitted) – this guards against two independently-authored instrument packages accidentally colliding on the same name. config.kind's capability-bearing validation already happens inside the Configuration{I} outer constructor (see Configuration); this function only handles registry-level name bookkeeping, not re-validation.

Third-party instrument packages should call this once per Configuration they define, typically at module top-level (or from __init__() if settings need to be read from Preferences.jl/environment state at load time) right after using Pourfecto.

Examples

julia> abstract type ExInstrument <: InstrumentModel end

julia> piston = Piston{ContinuousActuator,SingleRepeater}((20u"µL",200u"µL"),(20u"µL",200u"µL"),1);

julia> head = Head{ExInstrument}([piston],[Channel(220u"µL")],trues(1,1));

julia> deck = [UnconstrainedPosition("Deck",true,true,"square")];

julia> register_instrument!(Configuration{ExInstrument}(head,deck); name="ex_instrument");

julia> haskey(configurations,"ex_instrument")
true
source

Call register_instrument! once for each Configuration, usually at the top level of the package module right after the Configuration is built. If the settings read Preferences.jl or other state at load time, such as the lab-specific file path that cobra_path supplies (see set_cobra_path!), do so in the __init__() function of the package, which runs on every load.

Testing an instrument

Pourfecto.TestUtils is a submodule of reusable conformance checks. The test suite of Pourfecto runs the same checks on its seven built-in instruments. using Pourfecto.TestUtils loads them in another test suite.

Tier 1 needs no solver and is safe for any CI:

using Pourfecto, Pourfecto.TestUtils

@test test_instrument_interface(my_config)

It runs test_mask_coverage, which checks the Mask against the mask_rules_for table by brute force if the table exists, and test_json_roundtrip, which checks that the Configuration survives JSON serialization and deserialization.

Tier 2 needs a real solve:

pc = pourfecto(source_labware, target_labware, [my_config]; optimizer=SCIP.Optimizer)
test_pourcast_compilation("My Instrument Compilation", pc)

It compiles the solved Pourcast into a temporary directory and checks that the expected output structure exists. This verifies that write_instrument_files produces files, which the mask and deck checks do not. The test does not run a solver itself, so one must already be configured. SCIP is a Pourfecto dependency and works as a free default. Choosing a solver compares SCIP, HiGHS, and Gurobi.

Pourfecto.TestUtils.test_instrument_interface — Function
test_instrument_interface(config::Configuration; kwargs...) -> Bool

Convenience wrapper running all Tier-1 (no-solver) checks: test_mask_coverage and test_json_roundtrip. This is the recommended entry point for a quick, solver-free sanity check on a newly-registered Configuration – e.g. @test test_instrument_interface(my_config) in your own package's test suite. kwargs are forwarded to test_mask_coverage (e.g. kinds=...).

source
Pourfecto.TestUtils.test_mask_coverage — Function
test_mask_coverage(config::Configuration; kinds=default_test_labware_kinds()) -> Bool

Brute-force-verify that Mask(head(config), labware) for every kind in kinds produces exactly the per-well visit counts implied by mask_rules_for(config). Requires no optimizer.

If mask_rules_for(config) === nothing (no MaskRule table defined), emits a warning and returns true without testing anything – your Mask method exists and is used for scheduling, but hasn't opted into MaskRule-based coverage verification. Define mask_rules_for(::Configuration{YourInstrument}) to enable this check.

Uses @test internally (so it reports through any enclosing @testset), and also returns a Bool so it can be checked directly (e.g. @test test_mask_coverage(config)).

source
Pourfecto.TestUtils.test_json_roundtrip — Function
test_json_roundtrip(config::Configuration) -> Bool

Verify that config survives a JSON serialize/deserialize round trip (json_to_config(config_to_json(config))), catching serialization bugs in a custom instrument's types before they surface at Pourcast-save time. Requires no optimizer.

Configuration has no == method (its settings::Dict field defaults to reference equality), so this checks structural equivalence field-by-field instead of a single ==: instrument type, kind, deck (names/labware-kinds/slots per position), head (piston/channel counts and aspirate/dispense masks), and settings keys.

source
Pourfecto.TestUtils.test_pourcast_compilation — Function
test_pourcast_compilation(name::AbstractString, pourcast::Pourcast) -> Nothing

Solver-backed integration check: compiles pourcast to a temporary directory and asserts the expected output structure (pourcast.json, a directory per config type, at least one non-empty protocol subfolder per config with slotting requirements) – verifies your instrument's write_instrument_files method actually produces files, not just that its Mask/deck logic is internally consistent.

This function does not run a solver itself – pourcast must already be a solved Pourcast (i.e. you called pourfecto(...) yourself with a real optimizer beforehand, e.g. pourfecto(...; optimizer=SCIP.Optimizer) – SCIP is already a Pourfecto dependency, or any other JuMP-compatible MILP solver you have configured/licensed).

Runs inside its own @testset "$name" block, so wrap the call in your own enclosing @testset if you want it nested under a named group.

source

Example: Cobra

Pourfecto/src/instruments/Cobra.jl covers all four parts: the piston, head, deck, and settings, a MaskRule table, a packing_greedy override, and a write_instrument_files implementation that writes vendor XML. Its module docstring lists which parts are specific to the Jensen Lab and would change for another lab or another Cobra deployment.