Opentrons importer — architecture (contributor reference)¶
Audience: anyone adding a command, a module kind, a Flex hardware feature, or a new entry point. Pairs with opentrons.md (user reference) and plans/opentrons-importer.md (implementation phasing).
Four modules, one data contract¶
┌────────────────────────┐
.py source ─►│ simulator │──┐
│ (stubbed ProtocolContext)│ │
└────────────────────────┘ │
▼
┌─────────────────┐
│ list[CommandEvent]
└─────────────────┘
▲
┌────────────────────────┐ │
.py source ─►│ ast_parser │──┘ (fallback on simulator failure;
│ (static AST walk) │ emits a WARNING event)
└────────────────────────┘
┌─────────────────┐
│ duration_model │ CommandEvent → seconds (pure lookup)
└────────┬────────┘
│
▼
list[CommandEvent] ─► program_builder ─► Rhylthyme program JSON
The data contract between the four modules is a single dataclass — CommandEvent. Everything else is implementation detail behind that boundary.
@dataclass(frozen=True)
class CommandEvent:
command_type: str # 'aspirate' / 'heater_shaker.shake' / 'WARNING' / ...
index: int # monotonically increasing within a single parse
args: Dict[str, Any] # free-form per-command kwargs (volume, rpm, ...)
mount: Optional[str] # 'left' / 'right' / 'flex_96' / 'gripper' / None
module_id: Optional[str] # e.g. 'heater-shaker-slot-7' (None for non-module work)
line: Optional[int] # source line, when known
Growth here is purely additive — mount='flex_96', mount='gripper', and module_id were each added without touching the duration model or the AST parser. Don't break this shape: every consumer is fine reading a new field as a None default.
Per-module responsibilities¶
simulator.py¶
Runs the protocol against a stubbed ProtocolContext. Each stub class (pipette / labware / module kind) implements the methods we recognise and records them via a shared _Recorder. Unknown method calls are chain-returning no-ops (lambda *a, **kw: self) so a protocol with one unsupported step doesn't crash the whole parse.
The full module surface lives in five stub classes: _InstrumentStub, _HeaterShakerStub, _MagneticStub, _TemperatureStub, _ThermocyclerStub, _AbsorbanceStub. Each inherits from _ModuleBase (for the module stubs) or uses _InstrumentStub (for pipettes). The _resolve_module_class helper maps Opentrons load_module(...) names to the right class.
ast_parser.py¶
Fallback when the simulator raises. Walks the AST, recognises bare thing.method(...) patterns, emits the corresponding CommandEvent without mount/module awareness. Helper methods (transfer, etc.) are NOT expanded here — the parser can't know runtime arg shapes. A trailing WARNING event is emitted whenever ANY non-setup, non- recognised call appears so the UI can render its "parsed statically" banner.
duration_model.py¶
Two public symbols: the DURATION_SECONDS dict (single source of truth, regenerated into docs/opentrons.md via make docs) and seconds_for(event). Special-case rules live in the function: - delay reads event.args['seconds']. - mix returns base × event.args['repetitions']. - thermocycler.execute_profile reads its precomputed seconds (computed at parse time from the protocol's step list).
To add a command, add an entry to DURATION_SECONDS and update _NOTES in docs_gen.py if you want a hint cell.
program_builder.py¶
Owns the Rhylthyme program-JSON shape. Groups events by mount → pipette tracks, by module_id → module tracks, falls everything else onto a shared Protocol track. Emits one resourceConstraints entry per pipette mount AND per module, except when a 96-channel pipette is loaded (then a single shared gantry constraint replaces the per-mount ones — see flex_96_present in build_program).
To add a new track type, extend _track_label and add the mount- ordering rank to _mount_order.
importer.py¶
Thin wrapper. Tries the simulator, falls back to AST, threads the mount → model map into the builder so track labels can carry pipette models (Left: P300 single-channel). Public surface is just OpentronsImporter (a BaseImporter subclass) plus the helpers it needs.
Data flow for a typical protocol¶
- CLI / web upload / MCP hands source bytes to
OpentronsImporter.import_from_source(source). - The importer runs the simulator. The simulator's stubs record every recognised method call into a flat
list[CommandEvent]. Setup calls (load_labware / load_instrument / load_module) emit nothing. - If the simulator raises, the AST parser runs against the same source and emits the subset it can statically recognise. A
WARNINGevent is prepended so downstream consumers (and the UI) know. - The importer reverse-engineers mount → model mappings from the source via a tiny regex (
_extract_mounts) so the builder can render model-decorated track labels even if no events fired on a given mount. - The builder groups events by mount + module_id, looks up each event's duration via
seconds_for, and emits the program JSON. - The result rides back as an
ImportResult— the same shape every importer uses.
Adding a new command¶
- Simulator: add a method on the relevant stub class that emits a
CommandEventwith the newcommand_type. The simplest pattern is_emit('foo.bar'); for module stubs use_emit(...)on the shared base class. - AST parser: add the method name to
_PIPETTE_COMMANDSor_MODULE_COMMANDS_BY_KIND. If the name is unique across modules, it lands in_MODULE_METHOD_TO_TYPEautomatically. - Duration model: add a
DURATION_SECONDS[command_type]entry. Optionally add a note indocs_gen.py::_NOTES. - Program builder: if the new command needs a non-default task slug, add it to
_MODULE_TASKS(or extend_task_for). Most pipette-side additions need no builder changes. - Tests: add a
tests/test_opentrons_importer.pycase asserting the simulator emits the expected event and (if user-visible) the builder renders it as a step. - Docs: run
make docsto regenerate the duration table. Update the user-facing matrix indocs/opentrons.mdto describe the new command.
Adding a new module kind¶
- Subclass
_ModuleBaseinsimulator.pywith the methods that matter. Each method callsself._emit(...). - Add an entry to
_resolve_module_classmatching the Opentronsload_module(...)name. - Add the method names to
_MODULE_COMMANDS_BY_KINDin the AST parser, and bump_MODULE_TASKSin the builder so each new command_type maps to the right task slug. - Add entries to
DURATION_SECONDSfor every command_type. - Add a fixture under
tests/fixtures/opentrons/exercising the new module + a small test class intest_opentrons_importer.py.
CI guardrails¶
make docs-checkregenerates the duration-model table intodocs/opentrons.mdand exits non-zero if the file changes. Wire this into CI so doc drift fails the build.- The importer test suite covers every recognised command + entry point. Don't merge a change that removes coverage without replacing it.