Skip to content
Closed
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ For custom protocols, declare typed sample metadata with `protocol.add_sample(sa

`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`.

## Biological designs and provenance

`lab.provenance` provides immutable SBOL3 designs, material implementations, activities, qualified usages and associations, agents, and plans. Author a `Document`, explicitly add its objects, and call `freeze()` to validate references and obtain a reproducible snapshot. Import and export local Turtle with `Document.read()` and `snapshot.write()`, or exchange a detached pySBOL3 document with `from_sbol3()` and `to_sbol3()`.

See the [provenance guide](docs/provenance.md) and run `uv run --no-sync python -m examples.provenance` for an authoring and round-trip example.

## Describe a deck

This OT-2 deck places two 96-well plates in slots 1 and 2, a 300 µL tip rack in slot 3, and a P300 pipette on the left mount. It uses the same equipment and placement types as the [deck layouts example](https://github.com/the-lab-compiler/lab-py/blob/master/examples/deck_layouts.py).
Expand Down
123 changes: 123 additions & 0 deletions docs/provenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Biological designs and provenance

`lab.provenance` represents SBOL3 designs and the activities, agents, plans, and material realizations associated with them. Its objects are immutable Python dataclasses. A `Document` collects explicitly added objects, and `freeze()` produces a validated `DocumentSnapshot` with stable identities for owned children. The module imports and exports SBOL3 Turtle and interoperates with pySBOL3 without changing its global namespace or builder registry.

## Author a document

```python
from lab.provenance import Component, Document, Sequence
from lab.provenance.vocabulary import DNA, IUPAC_DNA

document = Document(namespace="https://example.org/my_project")
sequence = Sequence(
identity=document.iri("sequence"),
elements="ACGTACGT",
encoding=IUPAC_DNA,
)
design = Component(
identity=document.iri("design"),
types=(DNA,),
sequences=(sequence.ref,),
)
document.add(sequence, design)
snapshot = document.freeze()
snapshot.write("build/design.ttl")
```

`Ref[T]` carries an absolute IRI and a Python target type. `design.ref` is a `Ref[Component]`; `snapshot.resolve(design.ref)` returns the corresponding component. `snapshot.get(identity, Component)` also checks the requested type at runtime. References never fetch remote resources. Adding an object does not automatically add its references.

Collections use tuples. SBOL multi-valued properties are unordered RDF sets, so freezing sorts them deterministically. Represent biological order with locations and constraints. Tuple position does not specify an assembly recipe.

`document.iri("design/feature")` constructs an IRI from slash-separated SBOL display IDs. It does not use a process-wide namespace. Top-level objects require explicit identities. Owned children such as `Usage`, `Association`, and `SubComponent` may omit theirs; freezing assigns names such as `planned_build/Usage1` in a new snapshot. Explicit child identities are preserved, and generated identities avoid existing ones. Give a child an explicit identity when another object needs to reference it before freezing.

`Document.add()` accepts top-level objects and rejects conflicting definitions of an existing IRI. Adding an identical definition is idempotent. Edits use `dataclasses.replace()` and a new identity where they describe a new design or record. `Document.from_snapshot(snapshot)` creates an independent authoring document containing an existing snapshot's objects and annotations.

## Model designs, materials, and activities

| Objects | Purpose |
| --- | --- |
| `Sequence`, `Component` | Sequence data and structural or functional designs |
| `SubComponent`, `SequenceFeature`, `LocalSubComponent`, `ExternallyDefined`, `ComponentReference` | Features owned by a component |
| `Range`, `Cut`, `EntireSequence` | Locations on a referenced sequence |
| `Constraint`, `Interaction`, `Participation`, `Interface` | Structural relationships and functional interactions |
| `CombinatorialDerivation`, `VariableFeature`, `Collection` | Design families and grouped objects |
| `Implementation` | A planned, recorded, or simulated material realization |
| `Activity`, `Usage`, `Association` | Work, qualified input roles, and qualified agent roles |
| `Agent`, `Plan` | Who or what is involved, and the method identity |
| `Attachment`, `ExperimentalData`, `Experiment`, `Model`, `Measure` | Linked evidence, datasets, computational models, and quantities |

Objects share `name`, `description`, `derived_from`, `generated_by`, and owned `measures`. Top-level objects also have `namespace` and attachment references. `Usage` and `Association` belong to an `Activity`; agents, plans, activities, and implementations are top-level objects.

```python
from lab.provenance import (
Activity,
Agent,
AgentKind,
Association,
EvidenceState,
Implementation,
Plan,
Usage,
)

planner = Agent(
identity=document.iri("planner"),
kind=AgentKind.SOFTWARE,
software_version="0.1.0",
)
method = Plan(identity=document.iri("method"))
activity = Activity(
identity=document.iri("planned_build"),
usage=(Usage(entity=design.ref),),
association=(Association(agent=planner.ref, plan=method.ref),),
evidence_state=EvidenceState.PLANNED,
)
output = Implementation(
identity=document.iri("planned_output"),
derived_from=(design.ref,),
generated_by=(activity.ref,),
evidence_state=EvidenceState.PLANNED,
)
document.add(planner, method, activity, output)
```

`Implementation.derived_from` can identify the intended design. `built` describes the realized structure when that assertion is available. Planned implementations must leave `built` unset. An implementation's material inputs belong in the generating activity's usages; the model does not infer genetic lineage from every physical reagent contribution.

Evidence states are `UNKNOWN`, `PLANNED`, `RECORDED`, and `SIMULATED`. A recorded inventory assertion does not imply sequence verification or a successful experiment. Imported SBOL without an evidence state remains unknown. Planned activities cannot have execution timestamps. Recorded and simulated activities can include timezone-aware `datetime` values, and end times must not precede start times.

`Activity.types` contains ontology classifications encoded as `sbol:type`. `Activity.informed_by` references predecessor activities through `prov:wasInformedBy`; multiple activities can share a predecessor. `Plan.protocol` optionally links to a separately specified protocol IRI, including a LabOP protocol. The plan itself does not contain or execute a method body.

## Import, validate, and export

```python
document = Document.read("build/design.ttl")
document.validate().raise_for_errors()
snapshot = document.freeze()

native = snapshot.to_sbol3() # A new, mutable pySBOL3 Document
native_report = native.validate() # pySBOL3's additional SBOL/SHACL checks
restored = Document.from_sbol3(native).freeze()
assert restored.digest == snapshot.digest
```

Input is local SBOL3 Turtle. SBOL2 input and unsupported SBOL classes or properties fail explicitly. Foreign RDF annotations, including language-tagged values and nested blank nodes, survive import and export. Unknown annotations remain RDF rather than becoming inferred Python fields. A mixed-namespace or empty import needs an explicit `namespace=` for authoring new objects.

Lab checks field types, required values, unique identities and ownership, reference closure and target types, sequence bounds, feature reference scope, component containment and activity dependency cycles, and evidence assertions. `validate()` returns diagnostics with identity, field path, code, and message. `freeze()` raises `ProvenanceError` containing that report when checks fail. These checks are not a complete implementation of every SBOL specification rule; use the detached pySBOL3 document's validator for additional checks.

Use `freeze(allow_external=True)` for intentionally incomplete graphs. References to objects that are present still undergo type validation; unresolved references remain unresolved. The module never creates placeholder objects or downloads referenced attachments.

Turtle output is deterministic, with full IRIs and canonical blank-node identifiers. `snapshot.digest` is SHA-256 over that serialization, including retained annotations. This identifies the provenance document, independently of the compiler artifact digest. `write()` accepts an identical existing file and refuses to replace different contents.

The adapter pins pySBOL3 `1.2.0.post0`. It handles `Activity.informed_by` at the RDF boundary because the upstream property uses ownership. It also corrects that release's mappings of `Cut.at` and `SubComponent.role_integration` on detached returned instances, while preserving the standard `sbol:at` and `sbol:roleIntegration` predicates. Importing an existing pySBOL3 document normalizes those known mappings without mutating the original. No global classes, namespaces, or builders are patched.

The small Lab extension vocabulary is packaged at `lab/provenance/resources/lab.ttl`. It defines evidence state, agent kind, software version, and the link from a plan to a protocol. The namespace is an identifier; using it does not require a network lookup.

## Run the example

```sh
uv run --no-sync python -m examples.provenance --out build/provenance.ttl
```

[The example](../examples/provenance.py) builds a design, an inventory assertion, a prospective activity, and a planned output; checks the SBOL3 export; writes it; and verifies its round trip. It specifies no executable cloning method.

The mappings follow the [SBOL 3.1 specification](https://sbolstandard.org/docs/SBOL3.1.0.pdf) and [pySBOL3's provenance model](https://raw.githubusercontent.com/SynBioDex/pySBOL3/main/sbol3/provenance.py).
94 changes: 94 additions & 0 deletions examples/provenance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Author and round-trip an SBOL3 design and prospective build provenance.

Run ``python -m examples.provenance --out build/provenance.ttl``. The short
sequence is illustrative; this example specifies no executable cloning method.
"""

import argparse
from pathlib import Path

from lab import __version__
from lab.provenance import (
Activity,
Agent,
AgentKind,
Association,
Component,
Document,
EvidenceState,
Implementation,
Plan,
Sequence,
SubComponent,
Usage,
)
from lab.provenance.vocabulary import DNA, IUPAC_DNA, LAB


def build_document() -> Document:
document = Document(namespace="https://example.org/lab/provenance_example")
sequence = Sequence(
identity=document.iri("input_sequence"),
elements="ACGTACGT",
encoding=IUPAC_DNA,
)
part = Component(
identity=document.iri("input_design"),
name="Illustrative input design",
types=(DNA,),
sequences=(sequence.ref,),
)
product = Component(
identity=document.iri("product_design"),
name="Intended product design",
types=(DNA,),
features=(SubComponent(instance_of=part.ref),),
)
stock = Implementation(
identity=document.iri("input_stock"),
derived_from=(part.ref,),
evidence_state=EvidenceState.RECORDED,
description="An author-supplied inventory assertion; sequence verification unspecified.",
)
planner = Agent(
identity=document.iri("lab_compiler"),
name="Lab Compiler",
kind=AgentKind.SOFTWARE,
software_version=__version__,
)
method = Plan(
identity=document.iri("method"),
description="Prospective method identity; detailed protocol specification is separate.",
)
activity = Activity(
identity=document.iri("planned_build"),
evidence_state=EvidenceState.PLANNED,
usage=(Usage(entity=stock.ref, roles=(LAB + "inputMaterial",)),),
association=(Association(agent=planner.ref, plan=method.ref, roles=(LAB + "planner",)),),
)
output = Implementation(
identity=document.iri("planned_output"),
derived_from=(product.ref,),
generated_by=(activity.ref,),
evidence_state=EvidenceState.PLANNED,
)
document.add(sequence, part, product, stock, planner, method, activity, output)
return document


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path, default=Path("build/provenance.ttl"))
args = parser.parse_args()
frozen = build_document().freeze()
report = frozen.to_sbol3().validate()
if report.errors:
raise ValueError(str(report))
output = frozen.write(args.out)
restored = Document.read(output).freeze()
assert restored.digest == frozen.digest
print(f"Wrote {output}; {len(frozen.objects)} top-level objects; SHA-256 {frozen.digest}")


if __name__ == "__main__":
main()
8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ classifiers = [
"Topic :: Scientific/Engineering",
"Typing :: Typed",
]
dependencies = ["pint>=0.24,<0.27"]
dependencies = [
"pint>=0.24,<0.27",
"sbol3==1.2.0.post0",
"rdflib>=6.1.1,<7",
]

[project.urls]
Homepage = "https://github.com/the-lab-compiler/lab-py"
Expand Down Expand Up @@ -87,5 +91,5 @@ check_untyped_defs = true
disallow_untyped_defs = true

[[tool.mypy.overrides]]
module = ["opentrons.*", "pylabrobot.*"]
module = ["opentrons.*", "pylabrobot.*", "sbol3.*"]
ignore_missing_imports = true
10 changes: 10 additions & 0 deletions scripts/check_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from tempfile import TemporaryDirectory

import lab
from lab.provenance import Activity, Document
from lab.targets import Manual


Expand All @@ -17,12 +18,14 @@ def main() -> None:
import_module("lab.experiments.cloning")
import_module("lab.part")
import_module("lab.samples")
import_module("lab.provenance")

package = distribution("lab-compiler")
assert package.version == lab.__version__
assert package.metadata["Name"] == "lab-compiler"
assert set(package.metadata.get_all("Provides-Extra", [])) == {"opentrons", "star"}
assert files("lab").joinpath("py.typed").is_file()
assert files("lab.provenance").joinpath("resources/lab.ttl").is_file()
assert any(str(path).endswith("licenses/LICENSE") for path in package.files or ())
assert lab.__file__ is not None
assert not Path(lab.__file__).resolve().is_relative_to(Path(__file__).resolve().parents[1])
Expand All @@ -40,6 +43,13 @@ def main() -> None:
assert plan["compiler_version"] == package.version
assert (output / "protocol.html").stat().st_size > 0

provenance = Document(namespace="https://example.org/install_check")
provenance.add(Activity(identity=provenance.iri("activity")))
snapshot = provenance.freeze()
snapshot.write(output / "provenance.ttl")
assert Document.read(output / "provenance.ttl").freeze().digest == snapshot.digest
assert not snapshot.to_sbol3().validate().errors

assert not any(name.split(".")[0] in {"opentrons", "pylabrobot"} for name in sys.modules)
print(f"lab-compiler {package.version}: installed package check passed")

Expand Down
83 changes: 83 additions & 0 deletions src/lab/provenance/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""SBOL3-native biological designs and planned or recorded provenance."""

from lab.provenance.document import Document, DocumentSnapshot
from lab.provenance.types import (
Activity,
Agent,
Association,
Attachment,
Collection,
CombinatorialDerivation,
Component,
ComponentReference,
Constraint,
Cut,
EntireSequence,
Experiment,
ExperimentalData,
ExternallyDefined,
Feature,
Identified,
Implementation,
Interaction,
Interface,
LocalSubComponent,
Measure,
Model,
Participation,
Plan,
Range,
Ref,
Sequence,
SequenceFeature,
SequenceLocation,
SubComponent,
TopLevel,
Usage,
VariableFeature,
)
from lab.provenance.validation import Diagnostic, ProvenanceError, ValidationReport
from lab.provenance.vocabulary import AgentKind, EvidenceState

__all__ = [
"Activity",
"Agent",
"AgentKind",
"Association",
"Attachment",
"Collection",
"CombinatorialDerivation",
"Component",
"ComponentReference",
"Constraint",
"Cut",
"Diagnostic",
"Document",
"DocumentSnapshot",
"EntireSequence",
"EvidenceState",
"Experiment",
"ExperimentalData",
"ExternallyDefined",
"Feature",
"Identified",
"Implementation",
"Interaction",
"Interface",
"LocalSubComponent",
"Measure",
"Model",
"Participation",
"Plan",
"ProvenanceError",
"Range",
"Ref",
"Sequence",
"SequenceFeature",
"SequenceLocation",
"SubComponent",
"TopLevel",
"Usage",
"ValidationReport",
"VariableFeature",
]
Loading
Loading