Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 23 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,46 +32,48 @@ Use `"lab-compiler[opentrons,star]"` to install both SDKs.

## Write a protocol

You define the strain, chassis, and plasmids, then compile that transformation for the selected target. This example compiles a heat-shock transformation using material identifiers supplied by the user.
Describe biological designs with native pySBOL3 components. An experiment builder records their laboratory operations in a `Protocol`, and `compile()` validates and compiles that protocol for the selected target.

```python
import sbol3
from lab import compile
from lab.equipment import LiquidHandler
from lab.experiments.cloning import (
Transformation,
transformation_deck,
from lab.experiments.cloning import Transformation, build_transformation, transformation_deck

designs = sbol3.Document()
strain = sbol3.Component("https://example.org/my_strain", sbol3.SBO_FUNCTIONAL_ENTITY)
chassis = sbol3.Component("https://example.org/my_cells", sbol3.SBO_FUNCTIONAL_ENTITY)
plasmid = sbol3.Component("https://example.org/my_plasmid", sbol3.SBO_DNA)
designs.add([strain, chassis, plasmid])

protocol = build_transformation(
[Transformation(strain=strain, chassis=chassis, plasmids=[plasmid])],
name="transformation",
)
from lab.part import Part

compile(
Transformation(
id="transformation-1",
strain=Part("https://example.org/my-strain/1"),
chassis=Part("https://example.org/my-cells/1"),
plasmids=[Part("https://example.org/my-plasmid/1")],
),
compiled = compile(
protocol,
deck=transformation_deck(on_module=True),
liquid_handler=LiquidHandler.OT2,
)
```

`compile` accepts an `Assembly`, an `AssemblyRequest`, a `Transformation`, a `TransformationRequest`, or a `PlatingRequest`. One assembly is a protocol named by `assembly.id`. One transformation is a protocol named by `transformation.id`. An `AssemblyRequest` names a protocol that holds several assemblies, and a `TransformationRequest` does the same for several transformations. `Transformation` names an output strain, its chassis, and its plasmids as `Part` IRIs. Compilation assigns logical wells and records the transfers, heat shock, and recovery steps. The result includes an output manifest for downstream stages. The [cloning example](https://github.com/the-lab-compiler/lab-py/blob/master/examples/cloning.py) defines its materials, assemblies, and transformations directly and links assembly, transformation, and plating.
`lab.compile()` accepts a `Protocol`. It captures an immutable snapshot, validates resources and volumes, prepares the target, and returns one `Compilation`. Inspect `compiled.protocol` for the captured snapshot. All targets and the document renderer consume those same recorded operations.

`transformation_deck(on_module=True)` names the 24-well DNA block, the cell tubes, and the reaction plate. This is an Opentrons preset; the example uses `LiquidHandler.OT2` and writes the bundle to `~/.lab/transformation-1/OT-2/`. Leave out `deck` and `liquid_handler` for a document with no robot. That writes `~/.lab/transformation-1/Manual/`.
`build_assembly()` takes a sequence of `Assembly` recipes; `build_transformation()` takes a sequence of `Transformation` recipes. Their design fields are native `sbol3.Component` objects, including components loaded from an SBOL file with `designs.read()`. Recipe fields specify the materials used by the procedure; SBOL owns their biological descriptions. Builders copy identities into protocol samples so later edits to SBOL objects cannot change the recorded plan.

`lab.compile()` lays out a cloning request, then snapshots its operations, samples, lineage, and output placements, validates them, and produces one `Compilation`. A `Protocol` compiles the same way. Inspect `compiled.protocol` for that recorded snapshot. Hardware targets consume the same recorded operations used by the document renderer. `build_assembly`, `build_transformation`, and `build_plating` return the `Protocol` when you want it before choosing a target.
`transformation_deck(on_module=True)` names the DNA block, cell tubes, and reaction plate. The example writes to `~/.lab/transformation/OT-2/`. Omit `deck` and `liquid_handler` for a manual document. Set `to` to choose an output directory, or `to=None` to compile without writing. The [transformation example](examples/transformation.py) uses native SBOL designs and explicit procedure quantities.

## Samples and protocol outputs

`compiled.manifest` is an `OutputManifest` containing the declared output samples and their logical placements. Pass an assembly's manifest as `inputs` when compiling a `TransformationRequest`, then pass the transformation's manifest as `inputs` when compiling a `PlatingRequest`. The [cloning example](https://github.com/the-lab-compiler/lab-py/blob/master/examples/cloning.py) compiles each stage separately.
`compiled.manifest` contains declared output samples and logical placements. Pass it as `inputs=` to `build_transformation()`, then pass the resulting transformation manifest to `build_plating()`. The [cloning example](examples/cloning.py) shows assembly, transformation, and plating as separately authored and compiled protocols. Plating processes the manifest's samples in their declared order. Both downstream builders require their source samples to occupy one logical container.

The core cloning types live in `lab.experiments.cloning.types` and are exported from `lab.experiments.cloning`. `Assembly` describes a product and its constituent parts; `Transformation` describes a strain, its chassis, and its plasmids. `AssemblyRequest` groups assemblies, `TransformationRequest` groups transformations, and `PlatingRequest` selects and orders source samples by id.
For custom protocols, use `protocol.add_sample(sample, at=well, is_input=True)` or `is_output=True`. `Sample.design` and `Sample.implementation` hold optional SBOL identity strings. Parent IDs identify contributions in the same protocol; imported samples identify their upstream protocol and sample separately. Compilation checks references, locations, and cyclic lineage. Manifests describe planned outputs.

A plating request's sample ids cover the entire input manifest. Transformation and plating each validate and interpret their input manifest for their own layout, which accepts a single source container. Their requests can set `source_stage_id` to assert the expected input protocol. `Assembly` and `AssemblyRequest` have no upstream input.
`lab.samples` also defines `Location(resource, well)`, `SamplePlacement`, and `OutputManifest`. Recorded operations, samples, target bindings, and volume accounting share that logical location type. Native SBOL documents remain caller-owned and can be written alongside the compilation artifacts.

For custom protocols, declare typed sample metadata with `protocol.add_sample(sample, at=well, is_input=True)` or `is_output=True`, using `Sample` from `lab.samples`. Loads and operations own volume accounting. Parent ids refer to samples in the same protocol; imported samples identify their upstream protocol and sample separately. Compilation checks sample references and locations and rejects cyclic lineage. Manifests describe planned outputs, not completed execution.
## SBOL provenance

`lab.samples` also defines `Location(resource, well)`, `SamplePlacement`, and `OutputManifest`. Recorded operations, sample placements, target bindings, and final volume accounting use the same logical `Location` type. For example, an output placement's `location` can be used directly as a key in `dict(compiled.final_volumes)`. `lab.part.Part` identifies a biological part by its SBOL IRI; cloning types and stage builders live under `lab.experiments.cloning`.
Protocol samples can link to native pySBOL3 designs and implementations by identity. The [SBOL provenance guide](docs/sbol-provenance.md) covers authoring, validation, and annotation propagation. Run `uv run python -m examples.sbol_provenance` for an example.

## Describe a deck

Expand Down
49 changes: 49 additions & 0 deletions docs/sbol-provenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# SBOL designs and provenance

Use pySBOL3 to author, read, validate, and write biological designs and provenance. `Component` and `Sequence` describe designs; `Activity`, `Usage`, `Association`, `Agent`, and `Plan` describe their provenance. A collection of designs is an ordinary `sbol3.Document`, named `designs` in the examples.

## Provide designs to an experiment

Assembly and transformation recipes take native `sbol3.Component` objects. They can come from Python authoring or an existing SBOL file:

```python
import sbol3
from lab import compile
from lab.experiments.cloning import Transformation, build_transformation

designs = sbol3.Document()
designs.read("designs.ttl")
protocol = build_transformation(
[Transformation(
strain=designs.find("https://example.org/my_strain"),
chassis=designs.find("https://example.org/my_cells"),
plasmids=[designs.find("https://example.org/my_plasmid")],
)],
name="transformation",
)
compiled = compile(protocol, to=None)
```

An `Assembly` names the product, backbone, parts, and restriction enzyme used by that procedure. A `Transformation` names the strain, chassis, and plasmids. These recipes specify procedure inputs; their components retain the full native SBOL API for sequences, features, roles, and provenance. Use `designs.validate()` to check the SBOL graph.

`build_assembly()` and `build_transformation()` copy component identities and names into protocol samples. `build_plating()` consumes a preceding output manifest and preserves design links on its products. `lab.compile()` accepts only the resulting `Protocol`; it captures the immutable snapshot internally. The compiler does not select an experiment from a biological design.

## Preserve identity and provenance

`Sample.design` identifies the intended design and must match `material_identity`. `Sample.implementation` optionally identifies a supplied material record. Both fields contain absolute IRI strings and are included in the plan and output manifest. Builders retain no mutable SBOL objects in the protocol snapshot.

An imported sample retains its upstream annotations. A new product carries its intended design without asserting a physical implementation. Parent sample IDs describe material contributions, not genetic ancestry. Native SBOL provenance stays in the caller-owned `designs` container; references do not automatically fetch or embed other documents.

Use full SBOL3 identities when authoring objects, such as `https://example.org/dna_1`, to avoid global namespace settings. The final segment must be a valid SBOL display ID. Write designs alongside compilation artifacts with `designs.write("designs.ttl", sbol3.TURTLE)`.

## Examples

[transformation.py](../examples/transformation.py) compiles native SBOL designs with explicit procedure quantities. [cloning.py](../examples/cloning.py) carries those identities through assembly, transformation, and plating. Both save the native SBOL graph alongside their artifacts.

[sbol_provenance.py](../examples/sbol_provenance.py) records computational design creation with SBOL provenance objects, then authors a planned aliquot protocol. Its sequence and quantities are synthetic inputs; its activity describes design authoring, not laboratory execution.

```sh
uv run python -m examples.sbol_provenance --out build/sbol_provenance
uv run python -m examples.cloning --target manual --out build/cloning
uv run --extra opentrons python -m examples.transformation
```
88 changes: 54 additions & 34 deletions examples/cloning.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,68 @@
import argparse
from pathlib import Path

import sbol3

from lab import compile
from lab.experiments.cloning import (
BSAI,
Assembly,
AssemblyRequest,
PlatingRequest,
Transformation,
TransformationRequest,
assembly_deck,
build_assembly,
build_plating,
build_transformation,
plating_deck,
transformation_deck,
)
from lab.part import Part
from lab.targets import LiquidHandler

PSB1C3 = Part("https://sbolcanvas.org/pSB1C3/1")
J23101 = Part("https://sbolcanvas.org/J23101/1")
J23106 = Part("https://sbolcanvas.org/J23106/1")
B0034 = Part("https://sbolcanvas.org/B0034/1")
GFP = Part("https://sbolcanvas.org/GFP/1")
RFP = Part("https://sbolcanvas.org/RFP/1")
B0015 = Part("https://sbolcanvas.org/B0015/1")
DH5ALPHA = Part("https://sbolcanvas.org/DH5alpha/1")
BL21 = Part("https://sbolcanvas.org/BL21/1")
PLASMID_1 = Part("https://SBOL2Build.org/composite_plasmid_1/1")
PLASMID_2 = Part("https://SBOL2Build.org/composite_plasmid_2/1")
STRAIN_1 = Part("https://SBOL2Build.org/composite_strain_1/1")
STRAIN_2 = Part("https://SBOL2Build.org/composite_strain_2/1")
STRAIN_3 = Part("https://SBOL2Build.org/composite_strain_3/1")
STRAIN_4 = Part("https://SBOL2Build.org/composite_strain_4/1")
PSB1C3 = sbol3.Component("https://sbolcanvas.org/pSB1C3", sbol3.SBO_DNA)
J23101 = sbol3.Component("https://sbolcanvas.org/J23101", sbol3.SBO_DNA)
J23106 = sbol3.Component("https://sbolcanvas.org/J23106", sbol3.SBO_DNA)
B0034 = sbol3.Component("https://sbolcanvas.org/B0034", sbol3.SBO_DNA)
GFP = sbol3.Component("https://sbolcanvas.org/GFP", sbol3.SBO_DNA)
RFP = sbol3.Component("https://sbolcanvas.org/RFP", sbol3.SBO_DNA)
B0015 = sbol3.Component("https://sbolcanvas.org/B0015", sbol3.SBO_DNA)
DH5ALPHA = sbol3.Component("https://sbolcanvas.org/DH5alpha", sbol3.SBO_FUNCTIONAL_ENTITY)
BL21 = sbol3.Component("https://sbolcanvas.org/BL21", sbol3.SBO_FUNCTIONAL_ENTITY)
PLASMID_1 = sbol3.Component("https://SBOL2Build.org/composite_plasmid_1", sbol3.SBO_DNA)
PLASMID_2 = sbol3.Component("https://SBOL2Build.org/composite_plasmid_2", sbol3.SBO_DNA)
STRAIN_1 = sbol3.Component("https://SBOL2Build.org/composite_strain_1", sbol3.SBO_FUNCTIONAL_ENTITY)
STRAIN_2 = sbol3.Component("https://SBOL2Build.org/composite_strain_2", sbol3.SBO_FUNCTIONAL_ENTITY)
STRAIN_3 = sbol3.Component("https://SBOL2Build.org/composite_strain_3", sbol3.SBO_FUNCTIONAL_ENTITY)
STRAIN_4 = sbol3.Component("https://SBOL2Build.org/composite_strain_4", sbol3.SBO_FUNCTIONAL_ENTITY)

designs = sbol3.Document()
designs.add(
[
BSAI,
PSB1C3,
J23101,
J23106,
B0034,
GFP,
RFP,
B0015,
DH5ALPHA,
BL21,
PLASMID_1,
PLASMID_2,
STRAIN_1,
STRAIN_2,
STRAIN_3,
STRAIN_4,
]
)

ASSEMBLIES = (
Assembly(
id="assembly-1",
product=PLASMID_1,
backbone=PSB1C3,
parts=[J23101, B0034, GFP, B0015],
restriction_enzyme=BSAI,
),
Assembly(
id="assembly-2",
product=PLASMID_2,
backbone=PSB1C3,
parts=[J23106, B0034, RFP, B0015],
Expand All @@ -53,25 +74,21 @@

STRAINS = (
Transformation(
id="transformation-1",
strain=STRAIN_1,
chassis=DH5ALPHA,
plasmids=[PLASMID_1],
),
Transformation(
id="transformation-2",
strain=STRAIN_2,
chassis=DH5ALPHA,
plasmids=[PLASMID_2],
),
Transformation(
id="transformation-3",
strain=STRAIN_3,
chassis=BL21,
plasmids=[PLASMID_1],
),
Transformation(
id="transformation-4",
strain=STRAIN_4,
chassis=BL21,
plasmids=[PLASMID_2],
Expand All @@ -90,28 +107,31 @@ def main() -> None:
parser.add_argument("--out", default=None)
args = parser.parse_args()
liquid_handler = None if args.target == "manual" else LiquidHandler(args.target)
protocol = build_assembly(ASSEMBLIES, name="sbol-loop-assembly")
assembled = compile(
AssemblyRequest(id="sbol-loop-assembly", assemblies=ASSEMBLIES),
protocol,
deck=None if liquid_handler is None else assembly_deck(),
liquid_handler=liquid_handler,
to=None,
)
protocol = build_transformation(STRAINS, inputs=assembled.manifest, name="heat-shock")
transformed = compile(
TransformationRequest(id="heat-shock", transformations=STRAINS),
protocol,
deck=None if liquid_handler is None else transformation_deck(),
liquid_handler=liquid_handler,
inputs=assembled.manifest,
to=None,
)
protocol = build_plating(transformed.manifest)
plated = compile(
PlatingRequest(
id="plating",
sample_ids=tuple(sample.id for sample in transformed.manifest.samples),
source_stage_id=transformed.manifest.protocol_id,
),
protocol,
deck=None if liquid_handler is None else plating_deck(),
liquid_handler=liquid_handler,
inputs=transformed.manifest,
to=None,
)
out = Path(args.out or f"build/cloning/{args.target}")
out.mkdir(parents=True, exist_ok=True)
assert not designs.validate().errors
designs.write(str(out / "designs.ttl"), sbol3.TURTLE)
for name, compiled in (
("assembly", assembled),
("transformation", transformed),
Expand Down
66 changes: 66 additions & 0 deletions examples/sbol_provenance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Native SBOL design provenance and an annotated aliquot protocol."""

import argparse
from pathlib import Path

import sbol3

import lab
from lab.samples import Sample


def inputs() -> tuple[sbol3.Document, lab.Protocol]:
namespace = "https://example.org/aliquot"
sequence = sbol3.Sequence(
f"{namespace}/sequence", elements="ACGTACGT", encoding=sbol3.IUPAC_DNA_ENCODING
)
author = sbol3.Agent(f"{namespace}/author", name="SBOL example script")
activity = sbol3.Activity(
f"{namespace}/design_creation",
usage=[sbol3.Usage(sequence.identity)],
association=[sbol3.Association(author)],
)
design = sbol3.Component(
f"{namespace}/design",
sbol3.SBO_DNA,
sequences=[sequence],
generated_by=[activity],
)
designs = sbol3.Document()
designs.add([sequence, author, activity, design])
protocol = lab.Protocol("Annotated aliquot")
plate = protocol.plate("plate", capacity=100 * lab.uL)
protocol.load(plate["A1"], design.identity, volume=20 * lab.uL)
protocol.add_sample(
Sample(id="source", material_identity=design.identity, label="DNA", design=design.identity),
at=plate["A1"],
is_input=True,
)
protocol.add_sample(
Sample(
id="aliquot",
material_identity=design.identity,
label="Planned aliquot",
design=design.identity,
parent_ids=("source",),
),
at=plate["A2"],
is_output=True,
)
protocol.transfer(plate["A1"], plate["A2"], volume=2 * lab.uL)
return designs, protocol


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path, default=Path("build/sbol_provenance"))
args = parser.parse_args()
designs, protocol = inputs()
assert not designs.validate().errors
output = lab.compile(protocol, to=None).write(args.out)
designs.write(str(output / "designs.ttl"), sbol3.TURTLE)
print(output)


if __name__ == "__main__":
main()
Loading
Loading