From ca7fbf5a7ceac33bb10760cd41bed7976da71722 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 1 Oct 2026 10:16:58 -0600 Subject: [PATCH 1/5] Export protocol plans directly as LabOP --- README.md | 4 + docs/labop.md | 38 ++ examples/labop_export.py | 25 ++ pyproject.toml | 1 + scripts/check_install.py | 2 + scripts/check_labop.py | 70 ++++ src/lab/compiler.py | 9 +- src/lab/labop/__init__.py | 5 + src/lab/labop/primitives.py | 65 ++++ src/lab/labop/protocol.py | 461 +++++++++++++++++++++++++ src/lab/labop/resources/LICENSE.txt | 7 + src/lab/labop/resources/lab.ttl | 52 +++ src/lab/labop/resources/primitives.ttl | 414 ++++++++++++++++++++++ src/lab/labop/resources/upstream.json | 36 ++ tests/test_labop.py | 187 ++++++++++ tests/test_labop_resources.py | 20 ++ uv.lock | 2 + 17 files changed, 1396 insertions(+), 2 deletions(-) create mode 100644 docs/labop.md create mode 100644 examples/labop_export.py create mode 100644 scripts/check_labop.py create mode 100644 src/lab/labop/__init__.py create mode 100644 src/lab/labop/primitives.py create mode 100644 src/lab/labop/protocol.py create mode 100644 src/lab/labop/resources/LICENSE.txt create mode 100644 src/lab/labop/resources/lab.ttl create mode 100644 src/lab/labop/resources/primitives.ttl create mode 100644 src/lab/labop/resources/upstream.json create mode 100644 tests/test_labop.py create mode 100644 tests/test_labop_resources.py diff --git a/README.md b/README.md index 56bb138..6bdecb6 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,10 @@ For custom protocols, use `protocol.add_sample(sample, at=well, is_input=True)` 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. +## LabOP export + +`lab.labop.export(protocol)` emits a static LabOP plan directly from an authored or frozen protocol. Every compilation includes the same `protocol.labop.ttl`. See the [LabOP guide](docs/labop.md) or run `uv run python -m examples.labop_export`. + ## 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). diff --git a/docs/labop.md b/docs/labop.md new file mode 100644 index 0000000..358200e --- /dev/null +++ b/docs/labop.md @@ -0,0 +1,38 @@ +# Export a protocol as LabOP + +`lab.labop.export()` projects an authored `Protocol` or its immutable `RecordedProtocol` snapshot into a static LabOP/UML graph. It uses the compiler's existing logical-resource and volume validation. Export runs offline and needs no hardware target or robot SDK. + +```python +import lab +from lab.labop import export + +protocol = lab.Protocol("Aliquot") +source = protocol.container("source", contents="water", volume=20 * lab.uL, capacity=100 * lab.uL) +destination = protocol.container("destination", capacity=100 * lab.uL) +protocol.transfer(source, destination, volume=2 * lab.uL) +compilation = lab.compile(protocol, to=None) +rdf = export(compilation.protocol) +print(rdf.text) +assert compilation.files["protocol.labop.ttl"] == rdf.text +compilation.write("build/aliquot") +``` + +Every compilation bundle includes `protocol.labop.ttl`. The same authored protocol yields identical LabOP for manual, OT-2, Flex, and STAR compilation. Exporting `compiled.protocol` uses the captured snapshot, so later authoring changes cannot affect it. Compilation uses the existing stage builders and target implementations. + +The returned `LabOPDocument` provides `protocol` (the protocol IRI), `text`, `digest` (SHA-256 of the Turtle), and `graph()` (a detached RDFLib graph). Automatic identities depend on protocol meaning, excluding authoring file paths and line numbers. `export(protocol, identity="https://example.org/protocols/aliquot")` supplies a chosen IRI; its last path segment must be a valid SBOL display ID. + +The graph describes initial contents, ordered actions, parameters, declared output collections, and optional SBOL design and implementation links. Loads describe starting conditions. Actions describe intended work. No execution records, completion status, or observed products are inferred. + +Lab uses upstream `Transfer`, `PipetteMix`, and `PlateCoordinates` definitions. Extensions describe waits, persistent temperatures, thermal profiles, ordered distribution, and operator instructions. Consumers must implement these extensions to interpret the complete plan. RDF validation does not establish executable compatibility with every LabOP interpreter. + +Only the three used upstream primitives and their owned definitions are packaged. [The manifest](../src/lab/labop/resources/upstream.json) records the upstream commit, original file hashes, selection rule, selected identities, and subset checksum. The upstream license is included. Full schemas are used separately for validation; they are not a runtime dependency. + +Run the example, then validate its RDF against the pinned full schemas with SBOLFactory in a separate interpreter: + +```sh +uv run python -m examples.labop_export --out build/labop_export +uv run --isolated --no-project --with sbol_factory==1.1.2 python scripts/check_labop.py \ + build/labop_export/protocol.labop.ttl --schemas /tmp/labop-schemas --fetch +``` + +`--fetch` downloads the pinned schema and primitive files and verifies their hashes. Subsequent checks can omit it. The validator also checks that the packaged primitive subset exactly matches the selection from upstream. SBOLFactory's global builders are confined to that validation process. diff --git a/examples/labop_export.py b/examples/labop_export.py new file mode 100644 index 0000000..ee444a7 --- /dev/null +++ b/examples/labop_export.py @@ -0,0 +1,25 @@ +"""Export a protocol plan directly and check the matching compilation artifact.""" + +import argparse +from pathlib import Path + +import lab +from examples.sbol_provenance import inputs +from lab.labop import export + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=Path("build/labop_export")) + args = parser.parse_args() + _, protocol = inputs() + compilation = lab.compile(protocol, to=None) + rdf = export(compilation.protocol) + assert compilation.files["protocol.labop.ttl"] == rdf.text + output = compilation.write(args.out) + print(f"Protocol: {rdf.protocol}") + print(f"LabOP: {output / 'protocol.labop.ttl'}") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 0d888ac..9403ad6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ classifiers = [ dependencies = [ "pint>=0.24,<0.27", "sbol3==1.2.0.post0", + "rdflib>=6.1.1,<7", ] [project.urls] diff --git a/scripts/check_install.py b/scripts/check_install.py index fd1bc79..8f5dfc7 100644 --- a/scripts/check_install.py +++ b/scripts/check_install.py @@ -11,6 +11,7 @@ import sbol3 import lab +from lab.labop import export from lab.targets import Manual @@ -44,6 +45,7 @@ def main() -> None: plan = json.loads((output / "plan.json").read_text()) assert plan["compiler_version"] == package.version assert (output / "protocol.html").stat().st_size > 0 + assert (output / "protocol.labop.ttl").read_text() == export(protocol).text assert not any(name.split(".")[0] in {"opentrons", "pylabrobot"} for name in sys.modules) print(f"lab-compiler {package.version}: installed package check passed") diff --git a/scripts/check_labop.py b/scripts/check_labop.py new file mode 100644 index 0000000..e5e5b91 --- /dev/null +++ b/scripts/check_labop.py @@ -0,0 +1,70 @@ +"""Validate a generated protocol with isolated SBOLFactory 1.1.2 / pySBOL3. + +Run this script in a separate interpreter: SBOLFactory registers global builders. +It is a validation tool, never a compiler dependency or execution engine. +""" + +import argparse +import hashlib +import json +from pathlib import Path + +import requests +import sbol3 +from rdflib import RDF, Graph, Namespace +from sbol_factory import SBOLFactory + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("protocol", type=Path) + parser.add_argument("--schemas", type=Path, required=True) + parser.add_argument("--fetch", action="store_true", help="Download pinned validation schemas") + args = parser.parse_args() + resources = Path(__file__).resolve().parents[1] / "src/lab/labop/resources" + manifest = json.loads((resources / "upstream.json").read_text()) + args.schemas.mkdir(parents=True, exist_ok=True) + for name, info in manifest["files"].items(): + path = args.schemas / name + if args.fetch: + url = f"https://raw.githubusercontent.com/Bioprotocols/labop/{manifest['commit']}/{info['path']}" + response = requests.get(url, timeout=30) + response.raise_for_status() + data = response.content + assert hashlib.sha256(data).hexdigest() == info["sha256"], name + path.write_bytes(data) + assert hashlib.sha256(path.read_bytes()).hexdigest() == info["sha256"], name + upstream = Graph() + for name in ("liquid_handling.ttl", "sample_arrays.ttl"): + upstream.parse(args.schemas / name, format="turtle") + selected = Graph() + for identity in manifest["subset"]["identities"]: + for triple in upstream: + if str(triple[0]) == identity or str(triple[0]).startswith(identity + "/"): + selected.add(triple) + assert set(selected) == set(Graph().parse(resources / "primitives.ttl", format="turtle")) + lab_namespace = "https://the-lab-compiler.github.io/lab-py/ns#" + SBOLFactory("lab", str(resources / "lab.ttl"), lab_namespace) + SBOLFactory("uml", str(args.schemas / "uml.ttl"), "http://bioprotocols.org/uml#") + SBOLFactory("labop", str(args.schemas / "labop.ttl"), "http://bioprotocols.org/labop#") + document = sbol3.Document() + document.read(str(args.protocol)) + report = document.validate() + assert not report.errors, tuple(str(error) for error in report.errors) + graph = Graph().parse(args.protocol, format="turtle") + namespace = Namespace(lab_namespace) + profiles = tuple(graph.subjects(RDF.type, namespace.ThermalProfile)) + for identity in profiles: + profile = document.find(str(identity)) + assert profile is not None and profile.holds + for hold in profile.holds: + assert hold.temperature.unit and hold.duration.unit + assert hold.index >= 0 + print( + f"{len(document.objects)} top-level objects, {len(profiles)} typed thermal profiles; " + f"{len(report.errors)} errors, {len(report.warnings)} warnings" + ) + + +if __name__ == "__main__": + main() diff --git a/src/lab/compiler.py b/src/lab/compiler.py index a9d71bb..ea8d09e 100644 --- a/src/lab/compiler.py +++ b/src/lab/compiler.py @@ -12,6 +12,7 @@ import lab.documents as documents from lab._version import __version__ from lab.deck import Deck +from lab.labop import export as export_labop from lab.model import Distribute, Mix, RecordedProtocol, TargetPlan, Transfer, encode from lab.protocol import Protocol from lab.samples import Location, OutputManifest @@ -73,7 +74,11 @@ def digest(self) -> str: @property def files(self) -> dict[str, str]: - result = {"plan.json": self.plan_json, "protocol.html": documents.render(self)} + result = { + "plan.json": self.plan_json, + "protocol.html": documents.render(self), + "protocol.labop.ttl": export_labop(self.protocol).text, + } if self.protocol.output_sample_ids: result["manifest.json"] = canonical_json(self.manifest.to_dict()) if self.target.source is not None: @@ -99,7 +104,7 @@ class _DefaultOutput: _DEFAULT_OUTPUT = _DefaultOutput() -_BUNDLE_FILES = ("protocol.html", "plan.json", "manifest.json", "protocol.py") +_BUNDLE_FILES = ("protocol.html", "plan.json", "manifest.json", "protocol.py", "protocol.labop.ttl") def _segment(value: str) -> str: diff --git a/src/lab/labop/__init__.py b/src/lab/labop/__init__.py new file mode 100644 index 0000000..3542969 --- /dev/null +++ b/src/lab/labop/__init__.py @@ -0,0 +1,5 @@ +"""Static LabOP interchange from authored or frozen protocols.""" + +from lab.labop.protocol import LabOPDocument, export + +__all__ = ["LabOPDocument", "export"] diff --git a/src/lab/labop/primitives.py b/src/lab/labop/primitives.py new file mode 100644 index 0000000..970afee --- /dev/null +++ b/src/lab/labop/primitives.py @@ -0,0 +1,65 @@ +"""Pinned LabOP primitives and explicit Lab extensions for unmatched semantics.""" + +from dataclasses import dataclass +from importlib.resources import files + +from rdflib import Graph, Namespace, URIRef + +UML = Namespace("http://bioprotocols.org/uml#") +LABOP = Namespace("http://bioprotocols.org/labop#") +SBOL = Namespace("http://sbols.org/v3#") +OM = Namespace("http://www.ontology-of-units-of-measure.org/resource/om-2/") +LAB = Namespace("https://the-lab-compiler.github.io/lab-py/ns#") +EXT = "https://the-lab-compiler.github.io/lab-py/labop/primitives/" +LIQUID = "https://bioprotocols.org/labop/primitives/liquid_handling/" + + +@dataclass(frozen=True) +class Parameter: + name: str + type: URIRef + required: bool = True + + +EXTENSIONS = { + "Wait": (Parameter("duration", OM.Measure),), + "SetTemperature": ( + Parameter("samples", LABOP.SampleCollection), + Parameter("temperature", OM.Measure), + ), + "Thermocycle": ( + Parameter("samples", LABOP.SampleCollection), + Parameter("profile", LAB.ThermalProfile), + Parameter("cycles", UML.ValueSpecification), + Parameter("lidTemperature", OM.Measure, False), + Parameter("blockVolume", OM.Measure, False), + ), + "Distribute": ( + Parameter("source", LABOP.SampleCollection), + Parameter("destinations", LABOP.SampleCollection), + Parameter("amount", OM.Measure), + Parameter("airGap", OM.Measure, False), + ), + "OperatorPause": (Parameter("instruction", UML.ValueSpecification),), +} + +DESCRIPTIONS = { + "Wait": "Wait for the specified duration without changing material.", + "SetTemperature": "Set a persistent temperature on the collection's controlling module. " + "The hold continues after the action completes.", + "Thermocycle": "Apply the ordered temperature and duration holds for the specified cycles " + "to the whole resource; then release temperature control. " + "No evaporation or yield change is inferred.", + "Distribute": "Transfer the same liquid amount from one source to each ordered destination, " + "reusing one tip. Air gap is air and does not change the liquid ledger. " + "A target may split aspirations within its capacity.", + "OperatorPause": "Pause for the stated operator instruction; no material change is modeled.", +} + + +def upstream_liquid_primitives() -> Graph: + graph = Graph() + graph.parse( + data=files("lab.labop").joinpath("resources/primitives.ttl").read_text(), format="turtle" + ) + return graph diff --git a/src/lab/labop/protocol.py b/src/lab/labop/protocol.py new file mode 100644 index 0000000..2fbe92b --- /dev/null +++ b/src/lab/labop/protocol.py @@ -0,0 +1,461 @@ +"""Static LabOP/UML projection of protocol plans.""" + +import hashlib +import json +import re +from dataclasses import dataclass +from decimal import Decimal +from urllib.parse import quote, urlsplit + +from rdflib import RDF, XSD, Graph, Literal, URIRef +from rdflib.compare import to_canonical_graph + +from lab._identifiers import require_iri +from lab.labop.primitives import ( + DESCRIPTIONS, + EXT, + EXTENSIONS, + LAB, + LABOP, + LIQUID, + OM, + SBOL, + UML, + upstream_liquid_primitives, +) +from lab.model import ( + Distribute, + ManualInstruction, + Mix, + RecordedProtocol, + SetTemperature, + Thermocycle, + Transfer, + Wait, + encode, +) +from lab.protocol import Protocol +from lab.samples import Location +from lab.validation import logical_bindings, validate + + +def turtle(graph: Graph) -> str: + """Sorted N-Triples is also valid Turtle and gives deterministic artifacts.""" + return ( + "\n".join( + sorted( + line + for line in to_canonical_graph(graph).serialize(format="nt").splitlines() + if line + ) + ) + + "\n" + ) + + +@dataclass(frozen=True) +class LabOPDocument: + protocol: str + text: str + + @property + def digest(self) -> str: + return hashlib.sha256(self.text.encode()).hexdigest() + + def graph(self) -> Graph: + return Graph().parse(data=self.text, format="turtle") + + +class _Writer: + def __init__(self) -> None: + self.graph = Graph() + self.upstream = upstream_liquid_primitives() + + def node( + self, identity: str, kind: URIRef, *, name: str | None = None, top: bool = False + ) -> URIRef: + node = URIRef(identity) + self.graph.add((node, RDF.type, kind)) + self.graph.add((node, RDF.type, SBOL.TopLevel if top else SBOL.Identified)) + self.graph.add((node, SBOL.displayId, Literal(identity.rsplit("/", 1)[-1]))) + if top: + self.graph.add((node, SBOL.hasNamespace, URIRef(identity.rsplit("/", 1)[0]))) + if name is not None: + self.graph.add((node, SBOL.name, Literal(name))) + return node + + def add(self, node: URIRef, predicate: URIRef, value: URIRef | Literal) -> None: + self.graph.add((node, predicate, value)) + + def one(self, node: URIRef, predicate: URIRef) -> URIRef: + value = self.graph.value(node, predicate, any=False) + if not isinstance(value, URIRef): + raise ValueError(f"Expected one URI value for {node} {predicate}") + return value + + def literal(self, identity: str, value: int | str) -> URIRef: + node = self.node( + identity, UML.LiteralInteger if isinstance(value, int) else UML.LiteralString + ) + self.add( + node, UML.integerValue if isinstance(value, int) else UML.stringValue, Literal(value) + ) + return node + + def measure(self, identity: str, value: Decimal | int, unit: URIRef) -> URIRef: + node = self.node(identity, OM.Measure) + self.add(node, OM.hasNumericalValue, Literal(str(value), datatype=XSD.decimal)) + self.add(node, OM.hasUnit, unit) + return node + + def parameter( + self, + behavior: URIRef, + name: str, + kind: URIRef, + index: int, + *, + direction: str = "in", + required: bool = True, + ) -> URIRef: + ordered = self.node(f"{behavior}/parameter_{index}", UML.OrderedPropertyValue) + parameter = self.node(f"{ordered}/parameter", UML.Parameter, name=name) + self.add(behavior, UML.ownedParameter, ordered) + self.add(ordered, UML.indexValue, Literal(index)) + self.add(ordered, UML.propertyValue, parameter) + self.add(parameter, UML.direction, UML[direction]) + self.add(parameter, UML.type, kind) + self.add(parameter, UML.isOrdered, Literal(True)) + self.add(parameter, UML.isUnique, Literal(True)) + for field, count in ((UML.lowerValue, int(required)), (UML.upperValue, 1)): + self.add( + parameter, field, self.literal(f"{parameter}/{str(field).split('#')[-1]}", count) + ) + return ordered + + def primitive(self, name: str, *, upstream: bool = False) -> URIRef: + base = ( + "https://bioprotocols.org/labop/primitives/sample_arrays/" + if name == "PlateCoordinates" + else LIQUID + ) + identity = URIRef((base if upstream else EXT) + name) + if (identity, RDF.type, LABOP.Primitive) in self.graph: + return identity + if upstream: + # Include this primitive and all of its owned definitions, unchanged. + for subject, predicate, obj in self.upstream: + if str(subject) == str(identity) or str(subject).startswith(str(identity) + "/"): + self.graph.add((subject, predicate, obj)) + else: + self.node(str(identity), LABOP.Primitive, name=name, top=True) + self.add(identity, SBOL.description, Literal(DESCRIPTIONS[name])) + for index, parameter in enumerate(EXTENSIONS[name]): + self.parameter( + identity, parameter.name, parameter.type, index, required=parameter.required + ) + return identity + + def flow(self, protocol: URIRef, source: URIRef, target: URIRef, *, control: bool) -> None: + index = len(tuple(self.graph.objects(protocol, UML.edge))) + edge = self.node(f"{protocol}/edge_{index}", UML.ControlFlow if control else UML.ObjectFlow) + self.add(protocol, UML.edge, edge) + self.add(edge, UML.source, source) + self.add(edge, UML.target, target) + + def pin( + self, + action: URIRef, + name: str, + value: URIRef | None = None, + *, + owned: bool = False, + output: bool = False, + ) -> URIRef: + identity = f"{action}/{'output' if output else 'input'}_{name}" + pin = self.node( + identity, + UML.OutputPin if output else UML.ValuePin if value is not None else UML.InputPin, + name=name, + ) + self.add(action, UML.output if output else UML.input, pin) + self.add(pin, UML.isOrdered, Literal(True)) + self.add(pin, UML.isUnique, Literal(True)) + if value is not None: + literal = self.node( + f"{pin}/value", UML.LiteralIdentified if owned else UML.LiteralReference + ) + self.add(literal, UML.identifiedValue if owned else UML.referenceValue, value) + self.add(pin, UML.value, literal) + return pin + + def chain(self, protocol: URIRef, actions: list[URIRef]) -> None: + initial = self.node(f"{protocol}/initial", UML.InitialNode) + final = self.node(f"{protocol}/final", UML.FlowFinalNode) + for node in (initial, *actions, final): + self.add(protocol, UML.node, node) + nodes = [initial, *actions, final] + for start, end in zip(nodes, nodes[1:], strict=False): + self.flow(protocol, start, end, control=True) + + def fork(self, protocol: URIRef, source: URIRef) -> URIRef: + node = self.node(f"{source}/fork", UML.ForkNode) + self.add(protocol, UML.node, node) + self.flow(protocol, source, node, control=False) + return node + + def scalar_pin(self, action: URIRef, name: str, value: int | str) -> URIRef: + pin = self.pin(action, name) + self.graph.remove((pin, RDF.type, UML.InputPin)) + self.add(pin, RDF.type, UML.ValuePin) + self.add(pin, UML.value, self.literal(f"{pin}/value", value)) + return pin + + def amount_pin(self, action: URIRef, name: str, value: Decimal | int, unit: URIRef) -> URIRef: + return self.pin( + action, + name, + self.measure( + f"{action}/input_{name}/value/measure", + value, + unit, + ), + owned=True, + ) + + +def _semantic(value: object) -> object: + """Encode protocol meaning without developer file paths or line numbers.""" + + def clean(item: object) -> object: + if isinstance(item, dict): + return {key: clean(val) for key, val in item.items() if key != "origin"} + if isinstance(item, list): + return [clean(val) for val in item] + return item + + return clean(encode(value)) + + +def export(protocol: Protocol | RecordedProtocol, *, identity: str | None = None) -> LabOPDocument: + """Project one validated protocol snapshot into static LabOP, without hardware.""" + recorded = protocol.snapshot() if isinstance(protocol, Protocol) else protocol + if not isinstance(recorded, RecordedProtocol): + raise TypeError("Export a Protocol or RecordedProtocol") + validate(recorded, logical_bindings(recorded)) + writer = _Writer() + content = json.dumps(_semantic(recorded), sort_keys=True, ensure_ascii=False, allow_nan=False) + digest = hashlib.sha256(content.encode()).hexdigest() + if identity is None: + identity = f"https://the-lab-compiler.github.io/lab-py/protocols/p_{digest}" + require_iri(identity) + parsed = urlsplit(identity) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.netloc + or not parsed.path.strip("/") + or parsed.query + or parsed.fragment + or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", identity.rsplit("/", 1)[-1]) + ): + raise ValueError( + "A LabOP identity needs an absolute HTTP(S) IRI ending in an SBOL display ID" + ) + _protocol(writer, recorded, identity, digest) + return LabOPDocument(str(identity), turtle(writer.graph)) + + +def _protocol(writer: _Writer, recorded: RecordedProtocol, identity: str, digest: str) -> URIRef: + protocol = writer.node(identity, LABOP.Protocol, name=recorded.name, top=True) + writer.add(protocol, SBOL.description, Literal(recorded.description)) + writer.add(protocol, LAB.planDigest, Literal(digest)) + writer.add(protocol, LAB.evidenceState, LAB.planned) + sources: dict[str, URIRef] = {} + samples = {sample.id: sample for sample in recorded.samples} + for index, resource in enumerate(recorded.resources): + name = f"collection_{index}" + ordered = writer.parameter(protocol, name, LABOP.SampleCollection, index) + parameter = writer.one(ordered, UML.propertyValue) + default = writer.node(f"{parameter}/default", UML.LiteralIdentified) + array = writer.node(f"{default}/samples", LABOP.SampleArray, name=resource.name) + writer.add(parameter, UML.defaultValue, default) + writer.add(default, UML.identifiedValue, array) + container = writer.node( + f"{protocol}/container_{index}", LABOP.ContainerSpec, name=resource.name, top=True + ) + writer.add(container, LAB.rows, Literal(resource.rows)) + writer.add(container, LAB.columns, Literal(resource.columns)) + writer.add( + container, + LAB.capacity, + writer.measure(f"{container}/capacity", resource.capacity, OM.microlitre), + ) + writer.add(array, LABOP.containerType, container) + writer.add(array, LAB.sampleFormat, Literal("json")) + fills = {fill.well: fill for fill in resource.fills} + writer.add( + array, + LABOP.initial_contents, + Literal( + quote( + json.dumps( + { + well: fills[well].material if well in fills else None + for well in resource.wells + } + ) + ) + ), + ) + writer.add(array, LAB.resource, Literal(resource.name)) + for fill in resource.fills: + node = writer.node(f"{array}/initial_{fill.well}", LAB.InitialMaterial) + writer.add(array, LAB.initialMaterial, node) + writer.add(node, LAB.coordinates, Literal(fill.well)) + writer.add(node, LAB.materialIdentity, Literal(fill.material)) + writer.add( + node, LAB.volume, writer.measure(f"{node}/volume", fill.volume, OM.microlitre) + ) + for place in recorded.placements: + if place.location.resource != resource.name: + continue + sample = samples[place.sample_id] + node = writer.node(f"{array}/sample_{place.location.well}", LAB.SampleAssertion) + writer.add(array, LAB.plannedSample, node) + writer.add(node, LAB.coordinates, Literal(place.location.well)) + writer.add(node, LAB.sampleIdentity, Literal(sample.id)) + writer.add(node, LAB.sampleRole, Literal(sample.role)) + for predicate, ref in ( + (LAB.design, sample.design), + (LAB.implementation, sample.implementation), + ): + if ref is not None: + writer.add(node, predicate, URIRef(ref)) + input_node = writer.node(f"{protocol}/input_{index}", UML.ActivityParameterNode) + writer.add(protocol, UML.node, input_node) + writer.add(input_node, UML.parameter, ordered) + sources[resource.name] = writer.fork(protocol, input_node) + + actions: list[URIRef] = [] + selections: dict[Location, URIRef] = {} + + def well_source(location: Location) -> URIRef: + if location not in selections: + action = writer.node(f"{protocol}/select_{len(selections)}", UML.CallBehaviorAction) + writer.add(action, UML.behavior, writer.primitive("PlateCoordinates", upstream=True)) + pin = writer.pin(action, "source") + writer.flow(protocol, sources[location.resource], pin, control=False) + writer.scalar_pin(action, "coordinates", location.well) + output = writer.pin(action, "samples", output=True) + selections[location] = writer.fork(protocol, output) + actions.append(action) + return selections[location] + + def collection_pin(action: URIRef, name: str, source: URIRef) -> None: + writer.flow(protocol, source, writer.pin(action, name), control=False) + + for index, step in enumerate(recorded.steps): + action = writer.node(f"{protocol}/step_{index + 1}", UML.CallBehaviorAction) + writer.add(action, LAB.semanticStep, Literal(json.dumps(_semantic(step), sort_keys=True))) + if isinstance(step, Transfer): + primitive = writer.primitive("Transfer", upstream=True) + collection_pin(action, "source", well_source(step.source)) + collection_pin(action, "destination", well_source(step.destination)) + writer.amount_pin(action, "amount", step.volume, OM.microlitre) + elif isinstance(step, Mix): + primitive = writer.primitive("PipetteMix", upstream=True) + collection_pin(action, "samples", well_source(step.location)) + writer.amount_pin(action, "amount", step.volume, OM.microlitre) + writer.amount_pin(action, "cycleCount", step.cycles, OM.one) + elif isinstance(step, Wait): + primitive = writer.primitive("Wait") + writer.amount_pin(action, "duration", step.seconds, OM.second) + elif isinstance(step, ManualInstruction): + primitive = writer.primitive("OperatorPause") + writer.scalar_pin(action, "instruction", step.text) + elif isinstance(step, Distribute): + primitive = writer.primitive("Distribute") + collection_pin(action, "source", well_source(step.source)) + members = tuple(well_source(location) for location in step.destinations) + # A pure structural primitive preserves order and aliases across plates. + group_type = URIRef(EXT + f"OrderedGroup_{len(members)}") + if (group_type, RDF.type, LABOP.Primitive) not in writer.graph: + writer.node(str(group_type), LABOP.Primitive, top=True) + writer.add( + group_type, + SBOL.description, + Literal( + "Return an ordered SampleCollection of the supplied sample aliases. " + "Preserve member order and duplicates; perform no laboratory operation." + ), + ) + for index in range(len(members)): + writer.parameter(group_type, f"member_{index}", LABOP.SampleCollection, index) + writer.parameter( + group_type, "samples", LABOP.SampleCollection, len(members), direction="out" + ) + group = writer.node(f"{protocol}/group_{len(actions)}", UML.CallBehaviorAction) + writer.add(group, UML.behavior, group_type) + for index, source in enumerate(members): + collection_pin(group, f"member_{index}", source) + writer.flow( + protocol, + writer.pin(group, "samples", output=True), + writer.pin(action, "destinations"), + control=False, + ) + actions.append(group) + writer.amount_pin(action, "amount", step.volume, OM.microlitre) + if step.air_gap is not None: + writer.amount_pin(action, "airGap", step.air_gap, OM.microlitre) + elif isinstance(step, (SetTemperature, Thermocycle)): + primitive = writer.primitive(type(step).__name__) + collection_pin(action, "samples", sources[step.resource]) + if isinstance(step, SetTemperature): + writer.amount_pin(action, "temperature", step.celsius, OM.degreeCelsius) + else: + profile = writer.node(f"{action}/input_profile/value/profile", LAB.ThermalProfile) + for index, hold in enumerate(step.profile): + node = writer.node(f"{profile}/hold_{index}", LAB.ThermalHold) + writer.add(profile, LAB.hold, node) + writer.add(node, LAB["index"], Literal(index)) + writer.add( + node, + LAB.temperature, + writer.measure(f"{node}/temperature", hold.celsius, OM.degreeCelsius), + ) + writer.add( + node, + LAB.duration, + writer.measure(f"{node}/duration", hold.seconds, OM.second), + ) + writer.pin(action, "profile", profile, owned=True) + writer.scalar_pin(action, "cycles", step.cycles) + if step.lid_celsius is not None: + writer.amount_pin(action, "lidTemperature", step.lid_celsius, OM.degreeCelsius) + if step.block_volume is not None: + writer.amount_pin(action, "blockVolume", step.block_volume, OM.microlitre) + else: + raise TypeError(f"No LabOP mapping for {type(step).__name__}") + writer.add(action, UML.behavior, primitive) + actions.append(action) + ports: dict[str, str] = {} + for place in recorded.placements: + resource_name = place.location.resource + if place.sample_id not in recorded.output_sample_ids or resource_name in ports: + continue + name = f"result_{len(ports)}" + parameter = writer.parameter( + protocol, + name, + LABOP.SampleCollection, + len(recorded.resources) + len(ports), + direction="out", + ) + node = writer.node(f"{protocol}/output_{len(ports)}", UML.ActivityParameterNode) + writer.add(protocol, UML.node, node) + writer.add(node, UML.parameter, parameter) + writer.flow(protocol, sources[resource_name], node, control=False) + ports[resource_name] = name + writer.chain(protocol, actions) + return protocol diff --git a/src/lab/labop/resources/LICENSE.txt b/src/lab/labop/resources/LICENSE.txt new file mode 100644 index 0000000..6a137d7 --- /dev/null +++ b/src/lab/labop/resources/LICENSE.txt @@ -0,0 +1,7 @@ +Copyright 2022 Smart Information Flow Technologies and Raytheon BBN + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/lab/labop/resources/lab.ttl b/src/lab/labop/resources/lab.ttl new file mode 100644 index 0000000..ed7ecec --- /dev/null +++ b/src/lab/labop/resources/lab.ttl @@ -0,0 +1,52 @@ +@prefix lab: . +@prefix labop: . +@prefix sbol: . +@prefix om: . +@prefix owl: . +@prefix rdfs: . +@prefix xsd: . + + a owl:Ontology ; + rdfs:comment "Lab's explicit parameter and material annotations for static LabOP interchange." . + +lab:ThermalProfile a owl:Class ; + rdfs:subClassOf sbol:Identified, + [ a owl:Restriction ; owl:onProperty lab:hold ; owl:allValuesFrom lab:ThermalHold ], + [ a owl:Restriction ; owl:onProperty lab:hold ; owl:minCardinality "1"^^xsd:nonNegativeInteger ] . +lab:ThermalHold a owl:Class ; + rdfs:subClassOf sbol:Identified, + [ a owl:Restriction ; owl:onProperty lab:temperature ; owl:allValuesFrom om:Measure ], + [ a owl:Restriction ; owl:onProperty lab:temperature ; owl:minCardinality "1"^^xsd:nonNegativeInteger ], + [ a owl:Restriction ; owl:onProperty lab:temperature ; owl:maxCardinality "1"^^xsd:nonNegativeInteger ], + [ a owl:Restriction ; owl:onProperty lab:duration ; owl:allValuesFrom om:Measure ], + [ a owl:Restriction ; owl:onProperty lab:duration ; owl:minCardinality "1"^^xsd:nonNegativeInteger ], + [ a owl:Restriction ; owl:onProperty lab:duration ; owl:maxCardinality "1"^^xsd:nonNegativeInteger ], + [ a owl:Restriction ; owl:onProperty lab:index ; owl:allValuesFrom xsd:integer ], + [ a owl:Restriction ; owl:onProperty lab:index ; owl:minCardinality "1"^^xsd:nonNegativeInteger ], + [ a owl:Restriction ; owl:onProperty lab:index ; owl:maxCardinality "1"^^xsd:nonNegativeInteger ] . +lab:InitialMaterial a owl:Class ; rdfs:subClassOf sbol:Identified . +lab:SampleAssertion a owl:Class ; rdfs:subClassOf sbol:Identified . + +lab:hold a owl:ObjectProperty ; rdfs:label "holds" ; + rdfs:domain lab:ThermalProfile ; rdfs:range lab:ThermalHold ; + rdfs:subPropertyOf sbol:directlyComprises . +lab:temperature a owl:ObjectProperty ; rdfs:label "temperature" ; + rdfs:domain lab:ThermalHold ; rdfs:range om:Measure ; + rdfs:subPropertyOf sbol:directlyComprises . +lab:duration a owl:ObjectProperty ; rdfs:label "duration" ; + rdfs:domain lab:ThermalHold ; rdfs:range om:Measure ; + rdfs:subPropertyOf sbol:directlyComprises . +lab:index a owl:DatatypeProperty ; rdfs:label "index" ; + rdfs:domain lab:ThermalHold ; rdfs:range xsd:integer . +lab:initialMaterial a owl:ObjectProperty ; rdfs:label "initial_materials" ; + rdfs:domain labop:SampleArray ; rdfs:range lab:InitialMaterial ; + rdfs:subPropertyOf sbol:directlyComprises . +lab:plannedSample a owl:ObjectProperty ; rdfs:label "planned_samples" ; + rdfs:domain labop:SampleArray ; rdfs:range lab:SampleAssertion ; + rdfs:subPropertyOf sbol:directlyComprises . +lab:volume a owl:ObjectProperty ; rdfs:label "volume" ; + rdfs:domain lab:InitialMaterial ; rdfs:range om:Measure ; + rdfs:subPropertyOf sbol:directlyComprises . +lab:capacity a owl:ObjectProperty ; rdfs:label "capacity" ; + rdfs:domain labop:ContainerSpec ; rdfs:range om:Measure ; + rdfs:subPropertyOf sbol:directlyComprises . diff --git a/src/lab/labop/resources/primitives.ttl b/src/lab/labop/resources/primitives.ttl new file mode 100644 index 0000000..56802e6 --- /dev/null +++ b/src/lab/labop/resources/primitives.ttl @@ -0,0 +1,414 @@ +@prefix ns1: . +@prefix om: . +@prefix sbol: . +@prefix xsd: . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + , + ; + sbol:description "Mix by cycling a measured volume of liquid in and out at an array of samples a fixed number of times" ; + sbol:displayId "PipetteMix" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + , + , + , + , + ; + sbol:description "Move a measured volume taken from a collection of source samples to a location whose shape can contain them in a destination locations" ; + sbol:displayId "Transfer" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + ; + sbol:description "Select only the samples with specified row/column combination from a sample collection" ; + sbol:displayId "PlateCoordinates" ; + sbol:hasNamespace . + + a ns1:OrderedPropertyValue, + sbol:Identified ; + ns1:indexValue 0 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue1" . + + a ns1:Parameter, + sbol:Identified ; + ns1:direction ns1:in ; + ns1:isOrdered true ; + ns1:isUnique true ; + ns1:lowerValue ; + ns1:type ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "samples" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger1" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger2" . + + a ns1:OrderedPropertyValue, + sbol:Identified ; + ns1:indexValue 1 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue2" . + + a ns1:Parameter, + sbol:Identified ; + ns1:direction ns1:in ; + ns1:isOrdered true ; + ns1:isUnique true ; + ns1:lowerValue ; + ns1:type om:Measure ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "amount" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger1" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger2" . + + a ns1:OrderedPropertyValue, + sbol:Identified ; + ns1:indexValue 2 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue3" . + + a ns1:Parameter, + sbol:Identified ; + ns1:direction ns1:in ; + ns1:isOrdered true ; + ns1:isUnique true ; + ns1:lowerValue ; + ns1:type om:Measure ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "dispenseVelocity" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger1" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 0 ; + sbol:displayId "LiteralInteger2" . + + a ns1:OrderedPropertyValue, + sbol:Identified ; + ns1:indexValue 3 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue4" . + + a ns1:Parameter, + sbol:Identified ; + ns1:direction ns1:in ; + ns1:isOrdered true ; + ns1:isUnique true ; + ns1:lowerValue ; + ns1:type om:Measure ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "cycleCount" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger1" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 0 ; + sbol:displayId "LiteralInteger2" . + + a ns1:OrderedPropertyValue, + sbol:Identified ; + ns1:indexValue 0 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue1" . + + a ns1:Parameter, + sbol:Identified ; + ns1:direction ns1:in ; + ns1:isOrdered true ; + ns1:isUnique true ; + ns1:lowerValue ; + ns1:type ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "source" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger1" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger2" . + + a ns1:OrderedPropertyValue, + sbol:Identified ; + ns1:indexValue 1 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue2" . + + a ns1:Parameter, + sbol:Identified ; + ns1:direction ns1:in ; + ns1:isOrdered true ; + ns1:isUnique true ; + ns1:lowerValue ; + ns1:type ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "destination" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger1" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger2" . + + a ns1:OrderedPropertyValue, + sbol:Identified ; + ns1:indexValue 2 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue3" . + + a ns1:Parameter, + sbol:Identified ; + ns1:direction ns1:in ; + ns1:isOrdered true ; + ns1:isUnique true ; + ns1:lowerValue ; + ns1:type ns1:ValueSpecification ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "coordinates" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger1" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 0 ; + sbol:displayId "LiteralInteger2" . + + a ns1:OrderedPropertyValue, + sbol:Identified ; + ns1:indexValue 3 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue4" . + + a ns1:Parameter, + sbol:Identified ; + ns1:direction ns1:in ; + ns1:isOrdered true ; + ns1:isUnique true ; + ns1:lowerValue ; + ns1:type ns1:ValueSpecification ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "replicates" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger1" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 0 ; + sbol:displayId "LiteralInteger2" . + + a ns1:OrderedPropertyValue, + sbol:Identified ; + ns1:indexValue 4 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue5" . + + a ns1:Parameter, + sbol:Identified ; + ns1:direction ns1:in ; + ns1:isOrdered true ; + ns1:isUnique true ; + ns1:lowerValue ; + ns1:type om:Measure ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "temperature" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger1" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 0 ; + sbol:displayId "LiteralInteger2" . + + a ns1:OrderedPropertyValue, + sbol:Identified ; + ns1:indexValue 5 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue6" . + + a ns1:Parameter, + sbol:Identified ; + ns1:direction ns1:in ; + ns1:isOrdered true ; + ns1:isUnique true ; + ns1:lowerValue ; + ns1:type om:Measure ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "amount" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger1" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger2" . + + a ns1:OrderedPropertyValue, + sbol:Identified ; + ns1:indexValue 6 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue7" . + + a ns1:Parameter, + sbol:Identified ; + ns1:direction ns1:in ; + ns1:isOrdered true ; + ns1:isUnique true ; + ns1:lowerValue ; + ns1:type om:Measure ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "dispenseVelocity" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger1" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 0 ; + sbol:displayId "LiteralInteger2" . + + a ns1:OrderedPropertyValue, + sbol:Identified ; + ns1:indexValue 0 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue1" . + + a ns1:Parameter, + sbol:Identified ; + ns1:direction ns1:in ; + ns1:isOrdered true ; + ns1:isUnique true ; + ns1:lowerValue ; + ns1:type ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "source" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger1" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger2" . + + a ns1:OrderedPropertyValue, + sbol:Identified ; + ns1:indexValue 1 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue2" . + + a ns1:Parameter, + sbol:Identified ; + ns1:direction ns1:in ; + ns1:isOrdered true ; + ns1:isUnique true ; + ns1:lowerValue ; + ns1:type ns1:ValueSpecification ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "coordinates" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger1" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger2" . + + a ns1:OrderedPropertyValue, + sbol:Identified ; + ns1:indexValue 2 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue3" . + + a ns1:Parameter, + sbol:Identified ; + ns1:direction ns1:out ; + ns1:isOrdered true ; + ns1:isUnique true ; + ns1:lowerValue ; + ns1:type ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "samples" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger1" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger2" . diff --git a/src/lab/labop/resources/upstream.json b/src/lab/labop/resources/upstream.json new file mode 100644 index 0000000..4c7fe3a --- /dev/null +++ b/src/lab/labop/resources/upstream.json @@ -0,0 +1,36 @@ +{ + "repository": "https://github.com/Bioprotocols/labop", + "commit": "2e2bd88150c71a440771fd369f40295dfd622324", + "files": { + "uml.ttl": { + "path": "uml/inner/uml.ttl", + "sha256": "698921e4482a4f43348f62e8e51e7c4bdc079010d910e9bc2f10e8a5c4586a2a" + }, + "labop.ttl": { + "path": "labop/inner/labop.ttl", + "sha256": "914ee4cce8abe1982aaa9cac2197f853a878bb3972c97a3528f850dbd32efb3a" + }, + "liquid_handling.ttl": { + "path": "labop/lib/liquid_handling.ttl", + "sha256": "f848017ab3366f6e453caa48b8325b77ffcdc0bdb729f30a476ade52864a3355" + }, + "LICENSE.txt": { + "path": "LICENSE.txt", + "sha256": "6b14a3ec0069fab52046c0f1a967e7ffd43bd756d60874ca1e0065789e0ec99d" + }, + "sample_arrays.ttl": { + "path": "labop/lib/sample_arrays.ttl", + "sha256": "826ceac438e9d54f16040ee1802db8a6f3b2a975674fc8ce3b9cfd26daaaf97e" + } + }, + "subset": { + "file": "primitives.ttl", + "sha256": "de30936d92248c4df7b75839b974a0379129d2f3a3f8011e95d9fbcefd4d1bf2", + "subjects": "Each selected primitive and subjects beneath its identity plus a slash.", + "identities": [ + "https://bioprotocols.org/labop/primitives/liquid_handling/Transfer", + "https://bioprotocols.org/labop/primitives/liquid_handling/PipetteMix", + "https://bioprotocols.org/labop/primitives/sample_arrays/PlateCoordinates" + ] + } +} diff --git a/tests/test_labop.py b/tests/test_labop.py new file mode 100644 index 0000000..b2804bf --- /dev/null +++ b/tests/test_labop.py @@ -0,0 +1,187 @@ +import json +from dataclasses import replace +from urllib.parse import unquote + +import pytest +import sbol3 +from rdflib import RDF, URIRef + +import lab +from examples.cloning import ASSEMBLIES +from examples.sbol_provenance import inputs +from lab import Protocol, celsius, seconds, uL +from lab.deck import Container, Deck, DeckSite +from lab.experiments.cloning import build_assembly +from lab.labop import export +from lab.labop.primitives import LAB, LABOP, LIQUID, OM, SBOL, UML +from lab.labware import PCR_PLATE_96 +from lab.model import Origin +from lab.targets import LiquidHandler, Manual + + +def artifact(*, operations="operations"): + if operations == "assembly": + protocol = build_assembly(ASSEMBLIES) + else: + protocol = Protocol("Operation vocabulary") + plate = protocol.plate("plate", capacity=100 * uL) + protocol.load(plate["A1"], "water", volume=50 * uL) + protocol.set_temperature(plate, celsius(25)) + protocol.distribute(plate["A1"], (plate["A2"], plate["A3"]), volume=2 * uL, air_gap=1 * uL) + protocol.transfer(plate["A1"], plate["A4"], volume=2 * uL) + protocol.mix(plate["A1"], volume=2 * uL, cycles=2) + protocol.wait(1 * seconds) + protocol.manual("Inspect the plate") + protocol.thermocycle( + plate, + ((celsius(25), 1 * seconds),), + lid_temperature=celsius(40), + block_volume=10 * uL, + ) + recorded = protocol.snapshot() + return recorded, export(recorded) + + +@pytest.mark.parametrize("operations", ["operations", "assembly"]) +def test_labop_has_protocols_exact_semantic_actions_and_no_execution_claims(operations): + experiment, result = artifact(operations=operations) + graph = result.graph() + assert len(tuple(graph.subjects(RDF.type, LABOP.Protocol))) == 1 + for kind in ( + LABOP.ProtocolExecution, + LABOP.BehaviorExecution, + LABOP.ActivityNodeExecution, + LABOP.CallBehaviorExecution, + LABOP.ActivityEdgeFlow, + ): + assert not tuple(graph.subjects(RDF.type, kind)) + for predicate in (LABOP.execution, LABOP.completedNormally): + assert not tuple(graph.triples((None, predicate, None))) + assert {str(node) for node in graph.subjects(LAB.semanticStep)} == { + f"{result.protocol}/step_{index + 1}" for index in range(len(experiment.steps)) + } + for node in graph.subjects(RDF.type, LABOP.SampleArray): + if str(graph.value(node, SBOL.name)) == "products": + assert all( + value is None + for value in json.loads( + unquote(str(graph.value(node, LABOP.initial_contents))) + ).values() + ) + assert not tuple(graph.subjects(UML.behavior, URIRef(LIQUID + "Provision"))) + amounts = tuple(graph.subjects(RDF.type, OM.Measure)) + assert amounts and all(graph.value(node, OM.hasUnit) for node in amounts) + + +@pytest.mark.parametrize("operations", ["operations", "assembly"]) +def test_calls_follow_upstream_required_parameter_names_and_directions(operations): + _, result = artifact(operations=operations) + graph = result.graph() + for action in graph.subjects(RDF.type, UML.CallBehaviorAction): + behavior = graph.value(action, UML.behavior, any=False) + parameters = [ + graph.value(item, UML.propertyValue) + for item in graph.objects(behavior, UML.ownedParameter) + ] + by_name = {str(graph.value(parameter, SBOL.name)): parameter for parameter in parameters} + supplied = {} + for direction, predicate in (("in", UML.input), ("out", UML.output)): + for pin in graph.objects(action, predicate): + name = str(graph.value(pin, SBOL.name)) + assert name in by_name, (behavior, name) + assert graph.value(by_name[name], UML.direction) == UML[direction] + assert name not in supplied + supplied[name] = pin + if direction == "in": + constant = graph.value(pin, UML.value) + incoming = tuple(graph.subjects(UML.target, pin)) + assert bool(constant) != bool(incoming) + for name, parameter in by_name.items(): + lower = graph.value(parameter, UML.lowerValue) + if graph.value(lower, UML.integerValue).toPython() == 1: + assert name in supplied, (behavior, name) + + +def test_export_snapshots_authoring_and_rejects_invalid_material_accounting(): + protocol = Protocol("Direct export") + source = protocol.container("source", contents="water", volume=10 * uL, capacity=20 * uL) + destination = protocol.container("destination", capacity=20 * uL) + protocol.transfer(source, destination, volume=2 * uL) + recorded = protocol.snapshot() + exported = export(protocol) + compilation = lab.compile(protocol, Manual(), to=None) + assert compilation.files["protocol.labop.ttl"] == exported.text == export(recorded).text + protocol.transfer(source, destination, volume=20 * uL) + assert export(recorded).text == exported.text + assert compilation.files["protocol.labop.ttl"] == exported.text + with pytest.raises(lab.CompileError): + export(protocol) + + +@pytest.mark.integration +@pytest.mark.parametrize("handler", list(LiquidHandler)) +def test_labop_is_identical_across_robot_and_manual_compilation(handler): + pytest.importorskip("pylabrobot" if handler is LiquidHandler.STAR else "opentrons") + protocol = Protocol("Shared target input") + plate = protocol.plate("plate", capacity=100 * uL) + protocol.load(plate["A1"], "water", volume=20 * uL) + protocol.transfer(plate["A1"], plate["A2"], volume=10 * uL) + manual = lab.compile(protocol, Manual(), to=None) + deck = Deck(containers=(Container(id="plate", labware=PCR_PLATE_96, site=DeckSite.PLATES),)) + robot = lab.compile(protocol, deck=deck, liquid_handler=handler, to=None) + assert ( + robot.files["protocol.labop.ttl"] + == manual.files["protocol.labop.ttl"] + == export(protocol).text + ) + + +def test_automatic_identities_are_accepted_without_rewriting_by_sbol3(): + protocol = Protocol("Automatic identity") + protocol.wait(1 * seconds) + graph = export(protocol).graph() + for kind in (SBOL.TopLevel, SBOL.Identified): + for identity in graph.subjects(RDF.type, kind): + parsed = sbol3.Identified(str(identity), type_uri=str(kind)) + assert parsed.identity == str(identity) + assert parsed.display_id == str(graph.value(identity, SBOL.displayId)) + + +def test_export_identity_depends_on_meaning_and_accepts_an_explicit_sbol_identity(): + protocol, original = artifact() + moved = replace( + protocol, + steps=tuple( + replace(step, origin=Origin("/other/computer.py", 999)) for step in protocol.steps + ), + ) + assert export(moved).text == original.text + identity = "https://example.org/protocols/aliquot" + custom = export(protocol, identity=identity) + assert custom.protocol == identity + assert (URIRef(identity), RDF.type, LABOP.Protocol) in custom.graph() + for invalid in ( + "", + "relative", + "https://example.org/bad-id", + "https://bad host/protocol", + "https://host_name", + "https://example.org/plan?x=/valid", + ): + with pytest.raises(ValueError): + export(protocol, identity=invalid) + + +def test_labop_links_native_sbol_designs_without_embedding_the_document(): + from_example, protocol = inputs() + recorded = protocol.snapshot() + stock = "https://example.org/aliquot/stock" + recorded = replace( + recorded, + samples=(replace(recorded.samples[0], implementation=stock), *recorded.samples[1:]), + ) + result = export(recorded) + assert tuple(result.graph().subjects(LAB.implementation, URIRef(stock))) + design = from_example.find("https://example.org/aliquot/design") + assert len(tuple(result.graph().subjects(LAB.design, URIRef(design.identity)))) == 2 + assert not tuple(result.graph().subjects(RDF.type, SBOL.Component)) diff --git a/tests/test_labop_resources.py b/tests/test_labop_resources.py new file mode 100644 index 0000000..398eec7 --- /dev/null +++ b/tests/test_labop_resources.py @@ -0,0 +1,20 @@ +import hashlib +import json +from importlib.resources import files + +from rdflib import RDF, Graph + +from lab.labop.primitives import LABOP + + +def test_selected_upstream_primitives_match_the_packaged_checksum(): + resources = files("lab.labop").joinpath("resources") + manifest = json.loads(resources.joinpath("upstream.json").read_text()) + subset = manifest["subset"] + data = resources.joinpath(subset["file"]).read_bytes() + assert hashlib.sha256(data).hexdigest() == subset["sha256"] + graph = Graph().parse(data=data, format="turtle") + assert {str(node) for node in graph.subjects(RDF.type, LABOP.Primitive)} == set( + subset["identities"] + ) + assert Graph().parse(data=resources.joinpath("lab.ttl").read_text(), format="turtle") diff --git a/uv.lock b/uv.lock index 50f150c..1cdfdd6 100644 --- a/uv.lock +++ b/uv.lock @@ -384,6 +384,7 @@ source = { editable = "." } dependencies = [ { name = "pint", version = "0.25.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "pint", version = "0.26.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "rdflib" }, { name = "sbol3" }, ] @@ -410,6 +411,7 @@ requires-dist = [ { name = "opentrons", marker = "extra == 'opentrons'", specifier = "==8.8.2" }, { name = "pint", specifier = ">=0.24,<0.27" }, { name = "pylabrobot", marker = "extra == 'star'", specifier = "==0.2.1" }, + { name = "rdflib", specifier = ">=6.1.1,<7" }, { name = "sbol3", specifier = "==1.2.0.post0" }, ] provides-extras = ["opentrons", "star"] From 8b65c67673a7bb9dbc6ada1e3d0186afeb5da147 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 1 Oct 2026 13:07:17 -0600 Subject: [PATCH 2/5] Use the labop library, but don't include it by default until it's on PyPi --- .github/workflows/ci.yml | 23 +- README.md | 2 +- docs/labop.md | 57 +-- examples/labop_export.py | 30 +- integrations/labop/check.py | 174 ++++++++++ integrations/labop/export.py | 236 +++++++++++++ integrations/labop/requirements.txt | 6 + pyproject.toml | 6 +- scripts/check_install.py | 6 +- scripts/check_labop.py | 70 ---- src/lab/compiler.py | 9 +- src/lab/labop/__init__.py | 5 - src/lab/labop/primitives.py | 65 ---- src/lab/labop/protocol.py | 461 ------------------------- src/lab/labop/resources/LICENSE.txt | 7 - src/lab/labop/resources/lab.ttl | 52 --- src/lab/labop/resources/primitives.ttl | 414 ---------------------- src/lab/labop/resources/upstream.json | 36 -- tests/test_labop.py | 187 ---------- tests/test_labop_resources.py | 20 -- uv.lock | 2 - 21 files changed, 496 insertions(+), 1372 deletions(-) create mode 100644 integrations/labop/check.py create mode 100644 integrations/labop/export.py create mode 100644 integrations/labop/requirements.txt delete mode 100644 scripts/check_labop.py delete mode 100644 src/lab/labop/__init__.py delete mode 100644 src/lab/labop/primitives.py delete mode 100644 src/lab/labop/protocol.py delete mode 100644 src/lab/labop/resources/LICENSE.txt delete mode 100644 src/lab/labop/resources/lab.ttl delete mode 100644 src/lab/labop/resources/primitives.ttl delete mode 100644 src/lab/labop/resources/upstream.json delete mode 100644 tests/test_labop.py delete mode 100644 tests/test_labop_resources.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2662206..a6e00b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,28 @@ jobs: - name: Run tests run: uv run --no-sync pytest -q + labop: + name: Optional upstream LabOP integration + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7 + with: + python-version: "3.12" + - run: uv sync --locked + - name: Generate compiler artifacts + run: | + uv run --no-sync python -m examples.labop_export --out build/labop_check/operations + uv run --no-sync python -m examples.cloning --target manual --out build/labop_check/cloning + - name: Install upstream in a separate environment + run: | + uv run --no-sync python -m venv integrations/labop/.venv + integrations/labop/.venv/bin/python -m pip install -r integrations/labop/requirements.txt + - name: Check exported plans with upstream + run: integrations/labop/.venv/bin/python integrations/labop/check.py build/labop_check + build: name: Build and check distributions needs: test @@ -81,4 +103,3 @@ jobs: name: distributions path: dist/ if-no-files-found: error - diff --git a/README.md b/README.md index 6bdecb6..72c7bb3 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Protocol samples can link to native pySBOL3 designs and implementations by ident ## LabOP export -`lab.labop.export(protocol)` emits a static LabOP plan directly from an authored or frozen protocol. Every compilation includes the same `protocol.labop.ttl`. See the [LabOP guide](docs/labop.md) or run `uv run python -m examples.labop_export`. +The optional [LabOP integration](docs/labop.md) converts a compiled `plan.json` into a prospective LabOP protocol using upstream's Python library in a separate environment. Lab's installed package has no LabOP dependency or bundled schemas. ## Describe a deck diff --git a/docs/labop.md b/docs/labop.md index 358200e..3629290 100644 --- a/docs/labop.md +++ b/docs/labop.md @@ -1,38 +1,39 @@ -# Export a protocol as LabOP - -`lab.labop.export()` projects an authored `Protocol` or its immutable `RecordedProtocol` snapshot into a static LabOP/UML graph. It uses the compiler's existing logical-resource and volume validation. Export runs offline and needs no hardware target or robot SDK. - -```python -import lab -from lab.labop import export - -protocol = lab.Protocol("Aliquot") -source = protocol.container("source", contents="water", volume=20 * lab.uL, capacity=100 * lab.uL) -destination = protocol.container("destination", capacity=100 * lab.uL) -protocol.transfer(source, destination, volume=2 * lab.uL) -compilation = lab.compile(protocol, to=None) -rdf = export(compilation.protocol) -print(rdf.text) -assert compilation.files["protocol.labop.ttl"] == rdf.text -compilation.write("build/aliquot") -``` - -Every compilation bundle includes `protocol.labop.ttl`. The same authored protocol yields identical LabOP for manual, OT-2, Flex, and STAR compilation. Exporting `compiled.protocol` uses the captured snapshot, so later authoring changes cannot affect it. Compilation uses the existing stage builders and target implementations. +# Export a compilation as LabOP -The returned `LabOPDocument` provides `protocol` (the protocol IRI), `text`, `digest` (SHA-256 of the Turtle), and `graph()` (a detached RDFLib graph). Automatic identities depend on protocol meaning, excluding authoring file paths and line numbers. `export(protocol, identity="https://example.org/protocols/aliquot")` supplies a chosen IRI; its last path segment must be a valid SBOL display ID. +The optional integration reads the compiler's `lab.plan.v1` JSON artifact and writes a prospective LabOP protocol. It uses upstream LabOP's classes, primitive libraries, and SBOL serialization. No LabOP implementation, schemas, or dependency is included in the `lab-compiler` wheel. -The graph describes initial contents, ordered actions, parameters, declared output collections, and optional SBOL design and implementation links. Loads describe starting conditions. Actions describe intended work. No execution records, completion status, or observed products are inferred. +Install the integration from a checkout, using a separate Python 3.11 or 3.12 environment: -Lab uses upstream `Transfer`, `PipetteMix`, and `PlateCoordinates` definitions. Extensions describe waits, persistent temperatures, thermal profiles, ordered distribution, and operator instructions. Consumers must implement these extensions to interpret the complete plan. RDF validation does not establish executable compatibility with every LabOP interpreter. +```sh +python3.12 -m venv integrations/labop/.venv +integrations/labop/.venv/bin/python -m pip install -r integrations/labop/requirements.txt +``` -Only the three used upstream primitives and their owned definitions are packaged. [The manifest](../src/lab/labop/resources/upstream.json) records the upstream commit, original file hashes, selection rule, selected identities, and subset checksum. The upstream license is included. Full schemas are used separately for validation; they are not a runtime dependency. +The upstream revision and direct compatibility dependencies are pinned in `requirements.txt`. This environment uses `pip` because upstream's container-ontology URL is not accepted by `uv`. Upstream's transitive dependencies are not fully locked. Installation needs network access; export uses the installed libraries. This environment is separate because upstream import modifies pySBOL3's registrations, classes, and process logging. -Run the example, then validate its RDF against the pinned full schemas with SBOLFactory in a separate interpreter: +Compile with Lab, then export the saved artifact: ```sh uv run python -m examples.labop_export --out build/labop_export -uv run --isolated --no-project --with sbol_factory==1.1.2 python scripts/check_labop.py \ - build/labop_export/protocol.labop.ttl --schemas /tmp/labop-schemas --fetch +integrations/labop/.venv/bin/python integrations/labop/export.py build/labop_export/plan.json ``` -`--fetch` downloads the pinned schema and primitive files and verifies their hashes. Subsequent checks can omit it. The validator also checks that the packaged primitive subset exactly matches the selection from upstream. SBOLFactory's global builders are confined to that validation process. +The second command writes `protocol.labop.ttl` beside the input. `--out path.ttl` selects another location. It refuses to replace a different existing artifact. Normal compilation emits `plan.json`, HTML, and target artifacts; LabOP is an explicit export step. + +The integration consumes only the protocol portion of `plan.json`. Target configuration and authoring file locations do not affect the exported identity. Samples retain SBOL design and implementation references. The RDF records the intended operations, initial resource metadata, and declared outputs; it records no execution or observed products. + +Upstream owns the LabOP/UML object model, RDF structure, and the `Transfer`, `PipetteMix`, and `PlateCoordinates` definitions. The adapter owns the mapping from Lab's operations. Small Lab extension declarations cover waits, persistent temperatures, thermal profiles, ordered distribution, and operator instructions. Consumers need support for those semantics; graph validation does not establish executable compatibility with every LabOP interpreter. + +Thermocycle's `profile` argument is an ordered JSON list of `{celsius, seconds}` holds from the compiler artifact. Resource metadata and sample placements are JSON annotations on upstream container and sample objects; design and implementation identities are also RDF URI annotations. This preserves Lab data without defining another object model or extension ontology. + +Checks validate the constructed upstream document, operation parameters, control-flow order, and RDF serialization round-trips. Upstream's behavior-enabled `Protocol` constructor adds an initial-to-final edge when reading a document; object-level readback can therefore change its graph. The adapter does not patch upstream or run its execution engine. + +`plan.json` is the integration boundary. The adapter imports no Lab Python modules, and Lab imports no adapter. A future supported PyPI release of LabOP can replace the Git requirement without changing compiler inputs, the artifact format, or adding a second protocol model. There is no bundled fallback implementation. + +To check the integration against fresh compiler output: + +```sh +uv run python -m examples.labop_export --out build/labop_check/operations +uv run python -m examples.cloning --target manual --out build/labop_check/cloning +integrations/labop/.venv/bin/python integrations/labop/check.py build/labop_check +``` diff --git a/examples/labop_export.py b/examples/labop_export.py index ee444a7..02299f3 100644 --- a/examples/labop_export.py +++ b/examples/labop_export.py @@ -1,24 +1,34 @@ -"""Export a protocol plan directly and check the matching compilation artifact.""" +"""Compile a protocol covering the operations supported by the optional LabOP exporter.""" import argparse from pathlib import Path import lab -from examples.sbol_provenance import inputs -from lab.labop import export def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--out", type=Path, default=Path("build/labop_export")) args = parser.parse_args() - _, protocol = inputs() - compilation = lab.compile(protocol, to=None) - rdf = export(compilation.protocol) - assert compilation.files["protocol.labop.ttl"] == rdf.text - output = compilation.write(args.out) - print(f"Protocol: {rdf.protocol}") - print(f"LabOP: {output / 'protocol.labop.ttl'}") + protocol = lab.Protocol("LabOP operations") + plate = protocol.plate("operations", capacity=100 * lab.uL) + protocol.load(plate["A1"], "water", volume=50 * lab.uL) + protocol.set_temperature(plate, lab.celsius(25)) + protocol.distribute( + plate["A1"], (plate["A2"], plate["A3"]), volume=2 * lab.uL, air_gap=1 * lab.uL + ) + protocol.transfer(plate["A1"], plate["A4"], volume=2 * lab.uL) + protocol.mix(plate["A1"], volume=2 * lab.uL, cycles=2) + protocol.wait(1 * lab.seconds) + protocol.manual("Inspect the plate") + protocol.thermocycle( + plate, + ((lab.celsius(25), 1 * lab.seconds),), + lid_temperature=lab.celsius(40), + block_volume=10 * lab.uL, + ) + output = lab.compile(protocol, to=None).write(args.out) + print(output / "plan.json") if __name__ == "__main__": diff --git a/integrations/labop/check.py b/integrations/labop/check.py new file mode 100644 index 0000000..aefa0ba --- /dev/null +++ b/integrations/labop/check.py @@ -0,0 +1,174 @@ +"""Check upstream export against compiler-generated fixtures, in the integration environment.""" + +import argparse +import copy +import json +from pathlib import Path +from typing import Any + +import sbol3 +from export import LAB, LABOP, OM, UML, export, semantic +from rdflib import RDF, Graph, Literal, Namespace, URIRef +from rdflib.compare import isomorphic +from rdflib.term import Node + +SBOL = Namespace("http://sbols.org/v3#") + + +def literal(graph: Graph, subject: Node | None, predicate: URIRef) -> Literal: + assert subject is not None + value = graph.value(subject, predicate, any=False) + assert isinstance(value, Literal), (subject, predicate, value) + return value + + +def check(plan: dict[str, Any]) -> Graph: + document = export(plan) + graph: Graph = document.graph() + recorded = semantic(plan["protocol"]) + protocols = list(graph.subjects(RDF.type, LABOP.Protocol)) + assert len(protocols) == 1 + assert graph.value(protocols[0], LAB.evidenceState) == LAB.planned + assert not any("Execution" in str(kind) for kind in graph.objects(None, RDF.type)) + actions = sorted( + graph.subjects(LAB.semanticStep), key=lambda node: int(literal(graph, node, LAB.stepIndex)) + ) + assert [json.loads(str(graph.value(node, LAB.semanticStep))) for node in actions] == recorded[ + "steps" + ] + for action, step in zip(actions, recorded["steps"], strict=True): + pins = {str(graph.value(pin, SBOL.name)): pin for pin in graph.objects(action, UML.input)} + if "volume" in step: + argument = graph.value(pins["amount"], UML.value) + amount = graph.value(argument, UML.identifiedValue) + assert graph.value(amount, OM.hasUnit) == OM.microlitre + assert float(literal(graph, amount, OM.hasNumericalValue)) == float(step["volume"]) + if step["kind"] == "Thermocycle": + argument = graph.value(pins["profile"], UML.value) + assert json.loads(str(literal(graph, argument, UML.stringValue))) == step["profile"] + argument = graph.value(pins["cycles"], UML.value) + assert int(literal(graph, argument, UML.integerValue)) == step["cycles"] + samples = {sample["id"]: sample for sample in recorded["samples"]} + for resource in recorded["resources"]: + container = next( + node + for node in graph.subjects(RDF.type, LABOP.ContainerSpec) + if str(graph.value(node, SBOL.name)) == resource["name"] + ) + assert json.loads(str(graph.value(container, LAB.resource))) == resource + array = next(graph.subjects(LABOP.containerType, container)) + expected = [ + {"well": place["location"]["well"], "sample": samples[place["sample_id"]]} + for place in recorded["placements"] + if place["location"]["resource"] == resource["name"] + ] + assert json.loads(str(graph.value(array, LAB.samples))) == expected + for node in graph.subjects(RDF.type, OM.Measure): + assert graph.value(node, OM.hasUnit) + assert graph.value(node, OM.hasNumericalValue) is not None + for name in ("design", "implementation"): + expected_refs = { + sample[name] + for sample in recorded["samples"] + if sample[name] is not None + and any(place["sample_id"] == sample["id"] for place in recorded["placements"]) + } + assert {str(value) for value in graph.objects(None, LAB[name])} == expected_refs + + for action in graph.subjects(RDF.type, UML.CallBehaviorAction): + behavior = graph.value(action, UML.behavior) + for item in graph.objects(behavior, UML.ownedParameter): + parameter = graph.value(item, UML.propertyValue) + name = str(graph.value(parameter, SBOL.name)) + direction = graph.value(parameter, UML.direction) + predicate = UML.output if direction == UML["out"] else UML.input + parameter_pins = [ + pin + for pin in graph.objects(action, predicate) + if str(graph.value(pin, SBOL.name)) == name + ] + assert len(parameter_pins) == 1, (behavior, name) + if direction == UML["in"]: + lower = graph.value(parameter, UML.lowerValue) + required = int(literal(graph, lower, UML.integerValue)) == 1 + if required: + assert graph.value(parameter_pins[0], UML.value) is not None or list( + graph.subjects(UML.target, parameter_pins[0]) + ) + + # There must be one sequential control path, including all selection/group actions. + first = next(graph.subjects(RDF.type, UML.InitialNode)) + last = next(graph.subjects(RDF.type, UML.FinalNode)) + successors: dict[Node, Node] = {} + for edge in graph.subjects(RDF.type, UML.ControlFlow): + source = graph.value(edge, UML.source) + target = graph.value(edge, UML.target) + assert source is not None and target is not None + assert source not in successors + successors[source] = target + visited: list[Node] = [] + node = first + while node != last: + assert node not in visited + visited.append(node) + node = successors[node] + assert set(visited[1:]) == set(graph.subjects(RDF.type, UML.CallBehaviorAction)) + assert [item for item in visited if item in actions] == actions + assert len(visited) == len(successors) + + # Check RDF serialization. Upstream Protocol's constructor mutates control flow on read. + restored = Graph().parse(data=document.write_string(sbol3.TURTLE), format="turtle") + assert isomorphic(graph, restored) + return graph + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("fixtures", type=Path) + args = parser.parse_args() + paths = sorted(args.fixtures.rglob("plan.json")) + if not paths: + raise ValueError("Generate the example compilation fixtures first") + kinds: set[str] = set() + for path in paths: + plan = json.loads(path.read_text()) + graph = check(plan) + kinds.update(step["kind"] for step in plan["protocol"]["steps"]) + print(f"{path}: validated and RDF round-tripped") + assert kinds == { + "Transfer", + "Mix", + "Distribute", + "Wait", + "SetTemperature", + "Thermocycle", + "ManualInstruction", + } + moved = copy.deepcopy(plan) + moved["target"] = {"name": "Different robot"} + for step in moved["protocol"]["steps"]: + step["origin"] = {"file": "/another/computer.py", "line": 999} + assert isomorphic(graph, export(moved).graph()) + moved["protocol"]["name"] += " changed" + assert set(graph.subjects(RDF.type, LABOP.Protocol)) != set( + export(moved).graph().subjects(RDF.type, LABOP.Protocol) + ) + for invalid, expected_error in ( + ({"format": "unknown"}, "Expected a lab.plan.v1"), + ({**plan, "units": {}}, "Unsupported compilation units"), + ( + {**plan, "protocol": {**plan["protocol"], "steps": [{"kind": "unknown"}]}}, + "No LabOP mapping for unknown", + ), + ): + try: + export(invalid) + except ValueError as error: + assert expected_error in str(error) + else: + raise AssertionError("Unsupported input must fail") + print("Operation mappings, control flow, SBOL links, round-trips, and identities passed") + + +if __name__ == "__main__": + main() diff --git a/integrations/labop/export.py b/integrations/labop/export.py new file mode 100644 index 0000000..43e8f29 --- /dev/null +++ b/integrations/labop/export.py @@ -0,0 +1,236 @@ +"""Export lab.plan.v1 with upstream LabOP in a separate Python environment.""" + +import argparse +import hashlib +import json +import math +from pathlib import Path +from typing import Any +from urllib.parse import quote + +import labop +import sbol3 +import uml +from rdflib import Namespace +from rdflib.compare import to_canonical_graph + +LAB = Namespace("https://the-lab-compiler.github.io/lab-py/ns#") +LABOP = Namespace("http://bioprotocols.org/labop#") +UML = Namespace("http://bioprotocols.org/uml#") +OM = Namespace("http://www.ontology-of-units-of-measure.org/resource/om-2/") +EXT = "https://the-lab-compiler.github.io/lab-py/labop/primitives/" + +# These describe Lab operations whose semantics differ from the upstream library. +EXTENSIONS = { + "Wait": (("duration", OM.Measure),), + "OperatorPause": (("instruction", UML.ValueSpecification),), + "SetTemperature": (("samples", LABOP.SampleCollection), ("temperature", OM.Measure)), + "Thermocycle": ( + ("samples", LABOP.SampleCollection), + ("profile", UML.ValueSpecification), + ("cycles", UML.ValueSpecification), + ), + "Distribute": ( + ("source", LABOP.SampleCollection), + ("destinations", LABOP.SampleCollection), + ("amount", OM.Measure), + ), +} +DESCRIPTIONS = { + "Wait": "Wait for the duration without changing material.", + "OperatorPause": "Pause for the operator instruction; no material change is modeled.", + "SetTemperature": "Set a persistent temperature; the hold continues after this action.", + "Thermocycle": "Repeat the ordered JSON profile of {celsius, seconds} holds, " + "then release temperature control.", + "Distribute": "Transfer equal amounts to ordered destinations, reusing one tip. " + "Air gap is air; a target may split aspirations within its capacity.", +} + + +def semantic(value: Any) -> Any: + if isinstance(value, dict): + return {key: semantic(item) for key, item in value.items() if key != "origin"} + if isinstance(value, list): + return [semantic(item) for item in value] + return value + + +def text(obj: sbol3.Identified, name: str, value: str) -> None: + setattr(obj, "lab_" + name, sbol3.TextProperty(obj, str(LAB[name]), 0, 1, initial_value=value)) + + +def measure(value: str | int | float, unit: str) -> sbol3.Measure: + return sbol3.Measure(float(value), str(OM[unit])) + + +def export(plan: dict[str, Any]) -> sbol3.Document: + """Map a compiler artifact to an upstream SBOL document; never execute the plan.""" + if plan.get("format") != "lab.plan.v1": + raise ValueError("Expected a lab.plan.v1 compilation artifact") + if plan["units"] != {"volume": "microliter", "duration": "second", "temperature": "celsius"}: + raise ValueError("Unsupported compilation units") + recorded = semantic(plan["protocol"]) + digest = hashlib.sha256(json.dumps(recorded, sort_keys=True).encode()).hexdigest() + identity = "https://the-lab-compiler.github.io/lab-py/protocols/p_" + digest + document = sbol3.Document() + protocol = labop.Protocol(identity, name=recorded["name"], description=recorded["description"]) + document.add(protocol) + # Upstream starts with an initial-to-final edge; replace it with the actual sequence. + protocol.edges.clear() + text(protocol, "planDigest", digest) + protocol.evidence = sbol3.URIProperty( + protocol, str(LAB.evidenceState), 1, 1, initial_value=str(LAB.planned) + ) + labop.import_library("liquid_handling") + labop.import_library("sample_arrays") + sources: dict[str, uml.ActivityParameterNode] = {} + selections: dict[tuple[str, str], uml.OutputPin] = {} + samples = {sample["id"]: sample for sample in recorded["samples"]} + for index, resource in enumerate(recorded["resources"]): + spec = labop.ContainerSpec(f"{identity}/container_{index}", name=resource["name"]) + document.add(spec) + text(spec, "resource", json.dumps(resource, sort_keys=True)) + array = labop.SampleArray(name=resource["name"], container_type=spec) + fills = {fill["well"]: fill["material"] for fill in resource["fills"]} + wells = { + f"{chr(65 + row)}{column}": fills.get(f"{chr(65 + row)}{column}") + for column in range(1, resource["columns"] + 1) + for row in range(resource["rows"]) + } + array.initial_contents = quote(json.dumps(wells, sort_keys=True)) + text(array, "sampleFormat", "json") + annotations = [ + {"well": place["location"]["well"], "sample": samples[place["sample_id"]]} + for place in recorded["placements"] + if place["location"]["resource"] == resource["name"] + ] + text(array, "samples", json.dumps(annotations, sort_keys=True)) + for name in ("design", "implementation"): + refs = sorted({item["sample"][name] for item in annotations if item["sample"][name]}) + setattr( + array, + name, + sbol3.URIProperty(array, str(LAB[name]), 0, math.inf, initial_value=refs), + ) + sources[resource["name"]] = protocol.input_value( + f"collection_{index}", + str(LABOP.SampleCollection), + default_value=uml.LiteralIdentified(value=array), + ) + + def well(location: dict[str, str]) -> uml.OutputPin: + key = location["resource"], location["well"] + if key not in selections: + selections[key] = protocol.primitive_step( + "PlateCoordinates", source=sources[key[0]], coordinates=key[1] + ).output_pin("samples") + return selections[key] + + def extension(name: str) -> labop.Primitive: + primitive = document.find(EXT + name) + if primitive is None: + primitive = labop.Primitive(EXT + name, description=DESCRIPTIONS[name]) + document.add(primitive) + for parameter, kind in EXTENSIONS[name]: + primitive.add_input(parameter, str(kind)) + optional = {"Thermocycle": ("lidTemperature", "blockVolume"), "Distribute": ("airGap",)} + for parameter in optional.get(name, ()): + primitive.add_input(parameter, str(OM.Measure), optional=True) + return primitive + + for index, step in enumerate(recorded["steps"]): + kind = step["kind"] + primitive: str | labop.Primitive + arguments: dict[str, object] + if kind == "Transfer": + primitive = "Transfer" + arguments = { + "source": well(step["source"]), + "destination": well(step["destination"]), + "amount": measure(step["volume"], "microlitre"), + } + elif kind == "Mix": + primitive = "PipetteMix" + arguments = { + "samples": well(step["location"]), + "amount": measure(step["volume"], "microlitre"), + "cycleCount": measure(step["cycles"], "one"), + } + elif kind == "Wait": + primitive, arguments = extension(kind), {"duration": measure(step["seconds"], "second")} + elif kind == "ManualInstruction": + primitive, arguments = extension("OperatorPause"), {"instruction": step["text"]} + elif kind == "Distribute": + members = [well(location) for location in step["destinations"]] + group = document.find(EXT + f"OrderedGroup_{len(members)}") + if group is None: + group = labop.Primitive( + EXT + f"OrderedGroup_{len(members)}", + description="Return the ordered sample aliases, including duplicates; " + "no laboratory operation.", + ) + document.add(group) + for member in range(len(members)): + group.add_input(f"member_{member}", str(LABOP.SampleCollection)) + group.add_output("samples", str(LABOP.SampleCollection)) + selected = protocol.primitive_step( + group, **{f"member_{i}": value for i, value in enumerate(members)} + ) + primitive = extension(kind) + arguments = { + "source": well(step["source"]), + "destinations": selected.output_pin("samples"), + "amount": measure(step["volume"], "microlitre"), + } + if step["air_gap"] is not None: + arguments["airGap"] = measure(step["air_gap"], "microlitre") + elif kind in ("SetTemperature", "Thermocycle"): + primitive, arguments = extension(kind), {"samples": sources[step["resource"]]} + if kind == "SetTemperature": + arguments["temperature"] = measure(step["celsius"], "degreeCelsius") + else: + arguments.update( + profile=json.dumps(step["profile"], sort_keys=True), cycles=step["cycles"] + ) + for field, name, unit in ( + ("lid_celsius", "lidTemperature", "degreeCelsius"), + ("block_volume", "blockVolume", "microlitre"), + ): + if step[field] is not None: + arguments[name] = measure(step[field], unit) + else: + raise ValueError(f"No LabOP mapping for {kind}") + action = protocol.primitive_step(primitive, **arguments) + text(action, "semanticStep", json.dumps(step, sort_keys=True)) + text(action, "stepIndex", str(index)) + outputs = { + place["location"]["resource"] + for place in recorded["placements"] + if place["sample_id"] in recorded["output_sample_ids"] + } + for index, name in enumerate(sorted(outputs)): + protocol.designate_output(f"result_{index}", str(LABOP.SampleCollection), sources[name]) + protocol.order(protocol.get_last_step(), protocol.final()) + report = document.validate() + if report.errors: + raise ValueError("\n".join(str(error) for error in report.errors)) + return document + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("plan", type=Path) + parser.add_argument("--out", type=Path) + args = parser.parse_args() + document = export(json.loads(args.plan.read_text())) + graph = to_canonical_graph(document.graph()) + output = "\n".join(sorted(graph.serialize(format="nt").splitlines())) + "\n" + path = args.out or args.plan.with_name("protocol.labop.ttl") + if path.exists() and path.read_text() != output: + raise FileExistsError(f"{path} already contains a different artifact") + path.write_text(output) + print(path) + + +if __name__ == "__main__": + main() diff --git a/integrations/labop/requirements.txt b/integrations/labop/requirements.txt new file mode 100644 index 0000000..7418027 --- /dev/null +++ b/integrations/labop/requirements.txt @@ -0,0 +1,6 @@ +# Installed only in this integration's separate virtual environment. +labop @ git+https://github.com/Bioprotocols/labop@2e2bd88150c71a440771fd369f40295dfd622324 +sbol3==1.2.0.post0 +sbol-factory==1.1.2 +# Upstream imports IPython from its Markdown converter during package import. +ipython==9.17.1 diff --git a/pyproject.toml b/pyproject.toml index 9403ad6..efef5ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,6 @@ classifiers = [ dependencies = [ "pint>=0.24,<0.27", "sbol3==1.2.0.post0", - "rdflib>=6.1.1,<7", ] [project.urls] @@ -57,6 +56,7 @@ include = [ "/tests", "/examples", "/scripts", + "/integrations", "/docs", "/README.md", "/LICENSE", @@ -86,10 +86,10 @@ asyncio_mode = "auto" markers = ["integration: requires the optional robot SDKs; never connects to hardware"] [tool.mypy] -files = ["src/lab"] +files = ["src/lab", "integrations/labop"] check_untyped_defs = true disallow_untyped_defs = true [[tool.mypy.overrides]] -module = ["opentrons.*", "pylabrobot.*", "sbol3.*"] +module = ["opentrons.*", "pylabrobot.*", "sbol3.*", "labop.*", "uml.*"] ignore_missing_imports = true diff --git a/scripts/check_install.py b/scripts/check_install.py index 8f5dfc7..2ce53e1 100644 --- a/scripts/check_install.py +++ b/scripts/check_install.py @@ -11,7 +11,6 @@ import sbol3 import lab -from lab.labop import export from lab.targets import Manual @@ -45,9 +44,10 @@ def main() -> None: plan = json.loads((output / "plan.json").read_text()) assert plan["compiler_version"] == package.version assert (output / "protocol.html").stat().st_size > 0 - assert (output / "protocol.labop.ttl").read_text() == export(protocol).text - assert not any(name.split(".")[0] in {"opentrons", "pylabrobot"} for name in sys.modules) + isolated = {"opentrons", "pylabrobot", "labop", "uml", "sbol_factory"} + assert not any(name.split(".")[0] in isolated for name in sys.modules) + assert not files("lab").joinpath("labop").is_dir() print(f"lab-compiler {package.version}: installed package check passed") diff --git a/scripts/check_labop.py b/scripts/check_labop.py deleted file mode 100644 index e5e5b91..0000000 --- a/scripts/check_labop.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Validate a generated protocol with isolated SBOLFactory 1.1.2 / pySBOL3. - -Run this script in a separate interpreter: SBOLFactory registers global builders. -It is a validation tool, never a compiler dependency or execution engine. -""" - -import argparse -import hashlib -import json -from pathlib import Path - -import requests -import sbol3 -from rdflib import RDF, Graph, Namespace -from sbol_factory import SBOLFactory - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("protocol", type=Path) - parser.add_argument("--schemas", type=Path, required=True) - parser.add_argument("--fetch", action="store_true", help="Download pinned validation schemas") - args = parser.parse_args() - resources = Path(__file__).resolve().parents[1] / "src/lab/labop/resources" - manifest = json.loads((resources / "upstream.json").read_text()) - args.schemas.mkdir(parents=True, exist_ok=True) - for name, info in manifest["files"].items(): - path = args.schemas / name - if args.fetch: - url = f"https://raw.githubusercontent.com/Bioprotocols/labop/{manifest['commit']}/{info['path']}" - response = requests.get(url, timeout=30) - response.raise_for_status() - data = response.content - assert hashlib.sha256(data).hexdigest() == info["sha256"], name - path.write_bytes(data) - assert hashlib.sha256(path.read_bytes()).hexdigest() == info["sha256"], name - upstream = Graph() - for name in ("liquid_handling.ttl", "sample_arrays.ttl"): - upstream.parse(args.schemas / name, format="turtle") - selected = Graph() - for identity in manifest["subset"]["identities"]: - for triple in upstream: - if str(triple[0]) == identity or str(triple[0]).startswith(identity + "/"): - selected.add(triple) - assert set(selected) == set(Graph().parse(resources / "primitives.ttl", format="turtle")) - lab_namespace = "https://the-lab-compiler.github.io/lab-py/ns#" - SBOLFactory("lab", str(resources / "lab.ttl"), lab_namespace) - SBOLFactory("uml", str(args.schemas / "uml.ttl"), "http://bioprotocols.org/uml#") - SBOLFactory("labop", str(args.schemas / "labop.ttl"), "http://bioprotocols.org/labop#") - document = sbol3.Document() - document.read(str(args.protocol)) - report = document.validate() - assert not report.errors, tuple(str(error) for error in report.errors) - graph = Graph().parse(args.protocol, format="turtle") - namespace = Namespace(lab_namespace) - profiles = tuple(graph.subjects(RDF.type, namespace.ThermalProfile)) - for identity in profiles: - profile = document.find(str(identity)) - assert profile is not None and profile.holds - for hold in profile.holds: - assert hold.temperature.unit and hold.duration.unit - assert hold.index >= 0 - print( - f"{len(document.objects)} top-level objects, {len(profiles)} typed thermal profiles; " - f"{len(report.errors)} errors, {len(report.warnings)} warnings" - ) - - -if __name__ == "__main__": - main() diff --git a/src/lab/compiler.py b/src/lab/compiler.py index ea8d09e..a9d71bb 100644 --- a/src/lab/compiler.py +++ b/src/lab/compiler.py @@ -12,7 +12,6 @@ import lab.documents as documents from lab._version import __version__ from lab.deck import Deck -from lab.labop import export as export_labop from lab.model import Distribute, Mix, RecordedProtocol, TargetPlan, Transfer, encode from lab.protocol import Protocol from lab.samples import Location, OutputManifest @@ -74,11 +73,7 @@ def digest(self) -> str: @property def files(self) -> dict[str, str]: - result = { - "plan.json": self.plan_json, - "protocol.html": documents.render(self), - "protocol.labop.ttl": export_labop(self.protocol).text, - } + result = {"plan.json": self.plan_json, "protocol.html": documents.render(self)} if self.protocol.output_sample_ids: result["manifest.json"] = canonical_json(self.manifest.to_dict()) if self.target.source is not None: @@ -104,7 +99,7 @@ class _DefaultOutput: _DEFAULT_OUTPUT = _DefaultOutput() -_BUNDLE_FILES = ("protocol.html", "plan.json", "manifest.json", "protocol.py", "protocol.labop.ttl") +_BUNDLE_FILES = ("protocol.html", "plan.json", "manifest.json", "protocol.py") def _segment(value: str) -> str: diff --git a/src/lab/labop/__init__.py b/src/lab/labop/__init__.py deleted file mode 100644 index 3542969..0000000 --- a/src/lab/labop/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Static LabOP interchange from authored or frozen protocols.""" - -from lab.labop.protocol import LabOPDocument, export - -__all__ = ["LabOPDocument", "export"] diff --git a/src/lab/labop/primitives.py b/src/lab/labop/primitives.py deleted file mode 100644 index 970afee..0000000 --- a/src/lab/labop/primitives.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Pinned LabOP primitives and explicit Lab extensions for unmatched semantics.""" - -from dataclasses import dataclass -from importlib.resources import files - -from rdflib import Graph, Namespace, URIRef - -UML = Namespace("http://bioprotocols.org/uml#") -LABOP = Namespace("http://bioprotocols.org/labop#") -SBOL = Namespace("http://sbols.org/v3#") -OM = Namespace("http://www.ontology-of-units-of-measure.org/resource/om-2/") -LAB = Namespace("https://the-lab-compiler.github.io/lab-py/ns#") -EXT = "https://the-lab-compiler.github.io/lab-py/labop/primitives/" -LIQUID = "https://bioprotocols.org/labop/primitives/liquid_handling/" - - -@dataclass(frozen=True) -class Parameter: - name: str - type: URIRef - required: bool = True - - -EXTENSIONS = { - "Wait": (Parameter("duration", OM.Measure),), - "SetTemperature": ( - Parameter("samples", LABOP.SampleCollection), - Parameter("temperature", OM.Measure), - ), - "Thermocycle": ( - Parameter("samples", LABOP.SampleCollection), - Parameter("profile", LAB.ThermalProfile), - Parameter("cycles", UML.ValueSpecification), - Parameter("lidTemperature", OM.Measure, False), - Parameter("blockVolume", OM.Measure, False), - ), - "Distribute": ( - Parameter("source", LABOP.SampleCollection), - Parameter("destinations", LABOP.SampleCollection), - Parameter("amount", OM.Measure), - Parameter("airGap", OM.Measure, False), - ), - "OperatorPause": (Parameter("instruction", UML.ValueSpecification),), -} - -DESCRIPTIONS = { - "Wait": "Wait for the specified duration without changing material.", - "SetTemperature": "Set a persistent temperature on the collection's controlling module. " - "The hold continues after the action completes.", - "Thermocycle": "Apply the ordered temperature and duration holds for the specified cycles " - "to the whole resource; then release temperature control. " - "No evaporation or yield change is inferred.", - "Distribute": "Transfer the same liquid amount from one source to each ordered destination, " - "reusing one tip. Air gap is air and does not change the liquid ledger. " - "A target may split aspirations within its capacity.", - "OperatorPause": "Pause for the stated operator instruction; no material change is modeled.", -} - - -def upstream_liquid_primitives() -> Graph: - graph = Graph() - graph.parse( - data=files("lab.labop").joinpath("resources/primitives.ttl").read_text(), format="turtle" - ) - return graph diff --git a/src/lab/labop/protocol.py b/src/lab/labop/protocol.py deleted file mode 100644 index 2fbe92b..0000000 --- a/src/lab/labop/protocol.py +++ /dev/null @@ -1,461 +0,0 @@ -"""Static LabOP/UML projection of protocol plans.""" - -import hashlib -import json -import re -from dataclasses import dataclass -from decimal import Decimal -from urllib.parse import quote, urlsplit - -from rdflib import RDF, XSD, Graph, Literal, URIRef -from rdflib.compare import to_canonical_graph - -from lab._identifiers import require_iri -from lab.labop.primitives import ( - DESCRIPTIONS, - EXT, - EXTENSIONS, - LAB, - LABOP, - LIQUID, - OM, - SBOL, - UML, - upstream_liquid_primitives, -) -from lab.model import ( - Distribute, - ManualInstruction, - Mix, - RecordedProtocol, - SetTemperature, - Thermocycle, - Transfer, - Wait, - encode, -) -from lab.protocol import Protocol -from lab.samples import Location -from lab.validation import logical_bindings, validate - - -def turtle(graph: Graph) -> str: - """Sorted N-Triples is also valid Turtle and gives deterministic artifacts.""" - return ( - "\n".join( - sorted( - line - for line in to_canonical_graph(graph).serialize(format="nt").splitlines() - if line - ) - ) - + "\n" - ) - - -@dataclass(frozen=True) -class LabOPDocument: - protocol: str - text: str - - @property - def digest(self) -> str: - return hashlib.sha256(self.text.encode()).hexdigest() - - def graph(self) -> Graph: - return Graph().parse(data=self.text, format="turtle") - - -class _Writer: - def __init__(self) -> None: - self.graph = Graph() - self.upstream = upstream_liquid_primitives() - - def node( - self, identity: str, kind: URIRef, *, name: str | None = None, top: bool = False - ) -> URIRef: - node = URIRef(identity) - self.graph.add((node, RDF.type, kind)) - self.graph.add((node, RDF.type, SBOL.TopLevel if top else SBOL.Identified)) - self.graph.add((node, SBOL.displayId, Literal(identity.rsplit("/", 1)[-1]))) - if top: - self.graph.add((node, SBOL.hasNamespace, URIRef(identity.rsplit("/", 1)[0]))) - if name is not None: - self.graph.add((node, SBOL.name, Literal(name))) - return node - - def add(self, node: URIRef, predicate: URIRef, value: URIRef | Literal) -> None: - self.graph.add((node, predicate, value)) - - def one(self, node: URIRef, predicate: URIRef) -> URIRef: - value = self.graph.value(node, predicate, any=False) - if not isinstance(value, URIRef): - raise ValueError(f"Expected one URI value for {node} {predicate}") - return value - - def literal(self, identity: str, value: int | str) -> URIRef: - node = self.node( - identity, UML.LiteralInteger if isinstance(value, int) else UML.LiteralString - ) - self.add( - node, UML.integerValue if isinstance(value, int) else UML.stringValue, Literal(value) - ) - return node - - def measure(self, identity: str, value: Decimal | int, unit: URIRef) -> URIRef: - node = self.node(identity, OM.Measure) - self.add(node, OM.hasNumericalValue, Literal(str(value), datatype=XSD.decimal)) - self.add(node, OM.hasUnit, unit) - return node - - def parameter( - self, - behavior: URIRef, - name: str, - kind: URIRef, - index: int, - *, - direction: str = "in", - required: bool = True, - ) -> URIRef: - ordered = self.node(f"{behavior}/parameter_{index}", UML.OrderedPropertyValue) - parameter = self.node(f"{ordered}/parameter", UML.Parameter, name=name) - self.add(behavior, UML.ownedParameter, ordered) - self.add(ordered, UML.indexValue, Literal(index)) - self.add(ordered, UML.propertyValue, parameter) - self.add(parameter, UML.direction, UML[direction]) - self.add(parameter, UML.type, kind) - self.add(parameter, UML.isOrdered, Literal(True)) - self.add(parameter, UML.isUnique, Literal(True)) - for field, count in ((UML.lowerValue, int(required)), (UML.upperValue, 1)): - self.add( - parameter, field, self.literal(f"{parameter}/{str(field).split('#')[-1]}", count) - ) - return ordered - - def primitive(self, name: str, *, upstream: bool = False) -> URIRef: - base = ( - "https://bioprotocols.org/labop/primitives/sample_arrays/" - if name == "PlateCoordinates" - else LIQUID - ) - identity = URIRef((base if upstream else EXT) + name) - if (identity, RDF.type, LABOP.Primitive) in self.graph: - return identity - if upstream: - # Include this primitive and all of its owned definitions, unchanged. - for subject, predicate, obj in self.upstream: - if str(subject) == str(identity) or str(subject).startswith(str(identity) + "/"): - self.graph.add((subject, predicate, obj)) - else: - self.node(str(identity), LABOP.Primitive, name=name, top=True) - self.add(identity, SBOL.description, Literal(DESCRIPTIONS[name])) - for index, parameter in enumerate(EXTENSIONS[name]): - self.parameter( - identity, parameter.name, parameter.type, index, required=parameter.required - ) - return identity - - def flow(self, protocol: URIRef, source: URIRef, target: URIRef, *, control: bool) -> None: - index = len(tuple(self.graph.objects(protocol, UML.edge))) - edge = self.node(f"{protocol}/edge_{index}", UML.ControlFlow if control else UML.ObjectFlow) - self.add(protocol, UML.edge, edge) - self.add(edge, UML.source, source) - self.add(edge, UML.target, target) - - def pin( - self, - action: URIRef, - name: str, - value: URIRef | None = None, - *, - owned: bool = False, - output: bool = False, - ) -> URIRef: - identity = f"{action}/{'output' if output else 'input'}_{name}" - pin = self.node( - identity, - UML.OutputPin if output else UML.ValuePin if value is not None else UML.InputPin, - name=name, - ) - self.add(action, UML.output if output else UML.input, pin) - self.add(pin, UML.isOrdered, Literal(True)) - self.add(pin, UML.isUnique, Literal(True)) - if value is not None: - literal = self.node( - f"{pin}/value", UML.LiteralIdentified if owned else UML.LiteralReference - ) - self.add(literal, UML.identifiedValue if owned else UML.referenceValue, value) - self.add(pin, UML.value, literal) - return pin - - def chain(self, protocol: URIRef, actions: list[URIRef]) -> None: - initial = self.node(f"{protocol}/initial", UML.InitialNode) - final = self.node(f"{protocol}/final", UML.FlowFinalNode) - for node in (initial, *actions, final): - self.add(protocol, UML.node, node) - nodes = [initial, *actions, final] - for start, end in zip(nodes, nodes[1:], strict=False): - self.flow(protocol, start, end, control=True) - - def fork(self, protocol: URIRef, source: URIRef) -> URIRef: - node = self.node(f"{source}/fork", UML.ForkNode) - self.add(protocol, UML.node, node) - self.flow(protocol, source, node, control=False) - return node - - def scalar_pin(self, action: URIRef, name: str, value: int | str) -> URIRef: - pin = self.pin(action, name) - self.graph.remove((pin, RDF.type, UML.InputPin)) - self.add(pin, RDF.type, UML.ValuePin) - self.add(pin, UML.value, self.literal(f"{pin}/value", value)) - return pin - - def amount_pin(self, action: URIRef, name: str, value: Decimal | int, unit: URIRef) -> URIRef: - return self.pin( - action, - name, - self.measure( - f"{action}/input_{name}/value/measure", - value, - unit, - ), - owned=True, - ) - - -def _semantic(value: object) -> object: - """Encode protocol meaning without developer file paths or line numbers.""" - - def clean(item: object) -> object: - if isinstance(item, dict): - return {key: clean(val) for key, val in item.items() if key != "origin"} - if isinstance(item, list): - return [clean(val) for val in item] - return item - - return clean(encode(value)) - - -def export(protocol: Protocol | RecordedProtocol, *, identity: str | None = None) -> LabOPDocument: - """Project one validated protocol snapshot into static LabOP, without hardware.""" - recorded = protocol.snapshot() if isinstance(protocol, Protocol) else protocol - if not isinstance(recorded, RecordedProtocol): - raise TypeError("Export a Protocol or RecordedProtocol") - validate(recorded, logical_bindings(recorded)) - writer = _Writer() - content = json.dumps(_semantic(recorded), sort_keys=True, ensure_ascii=False, allow_nan=False) - digest = hashlib.sha256(content.encode()).hexdigest() - if identity is None: - identity = f"https://the-lab-compiler.github.io/lab-py/protocols/p_{digest}" - require_iri(identity) - parsed = urlsplit(identity) - if ( - parsed.scheme not in {"http", "https"} - or not parsed.netloc - or not parsed.path.strip("/") - or parsed.query - or parsed.fragment - or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", identity.rsplit("/", 1)[-1]) - ): - raise ValueError( - "A LabOP identity needs an absolute HTTP(S) IRI ending in an SBOL display ID" - ) - _protocol(writer, recorded, identity, digest) - return LabOPDocument(str(identity), turtle(writer.graph)) - - -def _protocol(writer: _Writer, recorded: RecordedProtocol, identity: str, digest: str) -> URIRef: - protocol = writer.node(identity, LABOP.Protocol, name=recorded.name, top=True) - writer.add(protocol, SBOL.description, Literal(recorded.description)) - writer.add(protocol, LAB.planDigest, Literal(digest)) - writer.add(protocol, LAB.evidenceState, LAB.planned) - sources: dict[str, URIRef] = {} - samples = {sample.id: sample for sample in recorded.samples} - for index, resource in enumerate(recorded.resources): - name = f"collection_{index}" - ordered = writer.parameter(protocol, name, LABOP.SampleCollection, index) - parameter = writer.one(ordered, UML.propertyValue) - default = writer.node(f"{parameter}/default", UML.LiteralIdentified) - array = writer.node(f"{default}/samples", LABOP.SampleArray, name=resource.name) - writer.add(parameter, UML.defaultValue, default) - writer.add(default, UML.identifiedValue, array) - container = writer.node( - f"{protocol}/container_{index}", LABOP.ContainerSpec, name=resource.name, top=True - ) - writer.add(container, LAB.rows, Literal(resource.rows)) - writer.add(container, LAB.columns, Literal(resource.columns)) - writer.add( - container, - LAB.capacity, - writer.measure(f"{container}/capacity", resource.capacity, OM.microlitre), - ) - writer.add(array, LABOP.containerType, container) - writer.add(array, LAB.sampleFormat, Literal("json")) - fills = {fill.well: fill for fill in resource.fills} - writer.add( - array, - LABOP.initial_contents, - Literal( - quote( - json.dumps( - { - well: fills[well].material if well in fills else None - for well in resource.wells - } - ) - ) - ), - ) - writer.add(array, LAB.resource, Literal(resource.name)) - for fill in resource.fills: - node = writer.node(f"{array}/initial_{fill.well}", LAB.InitialMaterial) - writer.add(array, LAB.initialMaterial, node) - writer.add(node, LAB.coordinates, Literal(fill.well)) - writer.add(node, LAB.materialIdentity, Literal(fill.material)) - writer.add( - node, LAB.volume, writer.measure(f"{node}/volume", fill.volume, OM.microlitre) - ) - for place in recorded.placements: - if place.location.resource != resource.name: - continue - sample = samples[place.sample_id] - node = writer.node(f"{array}/sample_{place.location.well}", LAB.SampleAssertion) - writer.add(array, LAB.plannedSample, node) - writer.add(node, LAB.coordinates, Literal(place.location.well)) - writer.add(node, LAB.sampleIdentity, Literal(sample.id)) - writer.add(node, LAB.sampleRole, Literal(sample.role)) - for predicate, ref in ( - (LAB.design, sample.design), - (LAB.implementation, sample.implementation), - ): - if ref is not None: - writer.add(node, predicate, URIRef(ref)) - input_node = writer.node(f"{protocol}/input_{index}", UML.ActivityParameterNode) - writer.add(protocol, UML.node, input_node) - writer.add(input_node, UML.parameter, ordered) - sources[resource.name] = writer.fork(protocol, input_node) - - actions: list[URIRef] = [] - selections: dict[Location, URIRef] = {} - - def well_source(location: Location) -> URIRef: - if location not in selections: - action = writer.node(f"{protocol}/select_{len(selections)}", UML.CallBehaviorAction) - writer.add(action, UML.behavior, writer.primitive("PlateCoordinates", upstream=True)) - pin = writer.pin(action, "source") - writer.flow(protocol, sources[location.resource], pin, control=False) - writer.scalar_pin(action, "coordinates", location.well) - output = writer.pin(action, "samples", output=True) - selections[location] = writer.fork(protocol, output) - actions.append(action) - return selections[location] - - def collection_pin(action: URIRef, name: str, source: URIRef) -> None: - writer.flow(protocol, source, writer.pin(action, name), control=False) - - for index, step in enumerate(recorded.steps): - action = writer.node(f"{protocol}/step_{index + 1}", UML.CallBehaviorAction) - writer.add(action, LAB.semanticStep, Literal(json.dumps(_semantic(step), sort_keys=True))) - if isinstance(step, Transfer): - primitive = writer.primitive("Transfer", upstream=True) - collection_pin(action, "source", well_source(step.source)) - collection_pin(action, "destination", well_source(step.destination)) - writer.amount_pin(action, "amount", step.volume, OM.microlitre) - elif isinstance(step, Mix): - primitive = writer.primitive("PipetteMix", upstream=True) - collection_pin(action, "samples", well_source(step.location)) - writer.amount_pin(action, "amount", step.volume, OM.microlitre) - writer.amount_pin(action, "cycleCount", step.cycles, OM.one) - elif isinstance(step, Wait): - primitive = writer.primitive("Wait") - writer.amount_pin(action, "duration", step.seconds, OM.second) - elif isinstance(step, ManualInstruction): - primitive = writer.primitive("OperatorPause") - writer.scalar_pin(action, "instruction", step.text) - elif isinstance(step, Distribute): - primitive = writer.primitive("Distribute") - collection_pin(action, "source", well_source(step.source)) - members = tuple(well_source(location) for location in step.destinations) - # A pure structural primitive preserves order and aliases across plates. - group_type = URIRef(EXT + f"OrderedGroup_{len(members)}") - if (group_type, RDF.type, LABOP.Primitive) not in writer.graph: - writer.node(str(group_type), LABOP.Primitive, top=True) - writer.add( - group_type, - SBOL.description, - Literal( - "Return an ordered SampleCollection of the supplied sample aliases. " - "Preserve member order and duplicates; perform no laboratory operation." - ), - ) - for index in range(len(members)): - writer.parameter(group_type, f"member_{index}", LABOP.SampleCollection, index) - writer.parameter( - group_type, "samples", LABOP.SampleCollection, len(members), direction="out" - ) - group = writer.node(f"{protocol}/group_{len(actions)}", UML.CallBehaviorAction) - writer.add(group, UML.behavior, group_type) - for index, source in enumerate(members): - collection_pin(group, f"member_{index}", source) - writer.flow( - protocol, - writer.pin(group, "samples", output=True), - writer.pin(action, "destinations"), - control=False, - ) - actions.append(group) - writer.amount_pin(action, "amount", step.volume, OM.microlitre) - if step.air_gap is not None: - writer.amount_pin(action, "airGap", step.air_gap, OM.microlitre) - elif isinstance(step, (SetTemperature, Thermocycle)): - primitive = writer.primitive(type(step).__name__) - collection_pin(action, "samples", sources[step.resource]) - if isinstance(step, SetTemperature): - writer.amount_pin(action, "temperature", step.celsius, OM.degreeCelsius) - else: - profile = writer.node(f"{action}/input_profile/value/profile", LAB.ThermalProfile) - for index, hold in enumerate(step.profile): - node = writer.node(f"{profile}/hold_{index}", LAB.ThermalHold) - writer.add(profile, LAB.hold, node) - writer.add(node, LAB["index"], Literal(index)) - writer.add( - node, - LAB.temperature, - writer.measure(f"{node}/temperature", hold.celsius, OM.degreeCelsius), - ) - writer.add( - node, - LAB.duration, - writer.measure(f"{node}/duration", hold.seconds, OM.second), - ) - writer.pin(action, "profile", profile, owned=True) - writer.scalar_pin(action, "cycles", step.cycles) - if step.lid_celsius is not None: - writer.amount_pin(action, "lidTemperature", step.lid_celsius, OM.degreeCelsius) - if step.block_volume is not None: - writer.amount_pin(action, "blockVolume", step.block_volume, OM.microlitre) - else: - raise TypeError(f"No LabOP mapping for {type(step).__name__}") - writer.add(action, UML.behavior, primitive) - actions.append(action) - ports: dict[str, str] = {} - for place in recorded.placements: - resource_name = place.location.resource - if place.sample_id not in recorded.output_sample_ids or resource_name in ports: - continue - name = f"result_{len(ports)}" - parameter = writer.parameter( - protocol, - name, - LABOP.SampleCollection, - len(recorded.resources) + len(ports), - direction="out", - ) - node = writer.node(f"{protocol}/output_{len(ports)}", UML.ActivityParameterNode) - writer.add(protocol, UML.node, node) - writer.add(node, UML.parameter, parameter) - writer.flow(protocol, sources[resource_name], node, control=False) - ports[resource_name] = name - writer.chain(protocol, actions) - return protocol diff --git a/src/lab/labop/resources/LICENSE.txt b/src/lab/labop/resources/LICENSE.txt deleted file mode 100644 index 6a137d7..0000000 --- a/src/lab/labop/resources/LICENSE.txt +++ /dev/null @@ -1,7 +0,0 @@ -Copyright 2022 Smart Information Flow Technologies and Raytheon BBN - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/lab/labop/resources/lab.ttl b/src/lab/labop/resources/lab.ttl deleted file mode 100644 index ed7ecec..0000000 --- a/src/lab/labop/resources/lab.ttl +++ /dev/null @@ -1,52 +0,0 @@ -@prefix lab: . -@prefix labop: . -@prefix sbol: . -@prefix om: . -@prefix owl: . -@prefix rdfs: . -@prefix xsd: . - - a owl:Ontology ; - rdfs:comment "Lab's explicit parameter and material annotations for static LabOP interchange." . - -lab:ThermalProfile a owl:Class ; - rdfs:subClassOf sbol:Identified, - [ a owl:Restriction ; owl:onProperty lab:hold ; owl:allValuesFrom lab:ThermalHold ], - [ a owl:Restriction ; owl:onProperty lab:hold ; owl:minCardinality "1"^^xsd:nonNegativeInteger ] . -lab:ThermalHold a owl:Class ; - rdfs:subClassOf sbol:Identified, - [ a owl:Restriction ; owl:onProperty lab:temperature ; owl:allValuesFrom om:Measure ], - [ a owl:Restriction ; owl:onProperty lab:temperature ; owl:minCardinality "1"^^xsd:nonNegativeInteger ], - [ a owl:Restriction ; owl:onProperty lab:temperature ; owl:maxCardinality "1"^^xsd:nonNegativeInteger ], - [ a owl:Restriction ; owl:onProperty lab:duration ; owl:allValuesFrom om:Measure ], - [ a owl:Restriction ; owl:onProperty lab:duration ; owl:minCardinality "1"^^xsd:nonNegativeInteger ], - [ a owl:Restriction ; owl:onProperty lab:duration ; owl:maxCardinality "1"^^xsd:nonNegativeInteger ], - [ a owl:Restriction ; owl:onProperty lab:index ; owl:allValuesFrom xsd:integer ], - [ a owl:Restriction ; owl:onProperty lab:index ; owl:minCardinality "1"^^xsd:nonNegativeInteger ], - [ a owl:Restriction ; owl:onProperty lab:index ; owl:maxCardinality "1"^^xsd:nonNegativeInteger ] . -lab:InitialMaterial a owl:Class ; rdfs:subClassOf sbol:Identified . -lab:SampleAssertion a owl:Class ; rdfs:subClassOf sbol:Identified . - -lab:hold a owl:ObjectProperty ; rdfs:label "holds" ; - rdfs:domain lab:ThermalProfile ; rdfs:range lab:ThermalHold ; - rdfs:subPropertyOf sbol:directlyComprises . -lab:temperature a owl:ObjectProperty ; rdfs:label "temperature" ; - rdfs:domain lab:ThermalHold ; rdfs:range om:Measure ; - rdfs:subPropertyOf sbol:directlyComprises . -lab:duration a owl:ObjectProperty ; rdfs:label "duration" ; - rdfs:domain lab:ThermalHold ; rdfs:range om:Measure ; - rdfs:subPropertyOf sbol:directlyComprises . -lab:index a owl:DatatypeProperty ; rdfs:label "index" ; - rdfs:domain lab:ThermalHold ; rdfs:range xsd:integer . -lab:initialMaterial a owl:ObjectProperty ; rdfs:label "initial_materials" ; - rdfs:domain labop:SampleArray ; rdfs:range lab:InitialMaterial ; - rdfs:subPropertyOf sbol:directlyComprises . -lab:plannedSample a owl:ObjectProperty ; rdfs:label "planned_samples" ; - rdfs:domain labop:SampleArray ; rdfs:range lab:SampleAssertion ; - rdfs:subPropertyOf sbol:directlyComprises . -lab:volume a owl:ObjectProperty ; rdfs:label "volume" ; - rdfs:domain lab:InitialMaterial ; rdfs:range om:Measure ; - rdfs:subPropertyOf sbol:directlyComprises . -lab:capacity a owl:ObjectProperty ; rdfs:label "capacity" ; - rdfs:domain labop:ContainerSpec ; rdfs:range om:Measure ; - rdfs:subPropertyOf sbol:directlyComprises . diff --git a/src/lab/labop/resources/primitives.ttl b/src/lab/labop/resources/primitives.ttl deleted file mode 100644 index 56802e6..0000000 --- a/src/lab/labop/resources/primitives.ttl +++ /dev/null @@ -1,414 +0,0 @@ -@prefix ns1: . -@prefix om: . -@prefix sbol: . -@prefix xsd: . - - a , - sbol:TopLevel ; - ns1:ownedParameter , - , - , - ; - sbol:description "Mix by cycling a measured volume of liquid in and out at an array of samples a fixed number of times" ; - sbol:displayId "PipetteMix" ; - sbol:hasNamespace . - - a , - sbol:TopLevel ; - ns1:ownedParameter , - , - , - , - , - , - ; - sbol:description "Move a measured volume taken from a collection of source samples to a location whose shape can contain them in a destination locations" ; - sbol:displayId "Transfer" ; - sbol:hasNamespace . - - a , - sbol:TopLevel ; - ns1:ownedParameter , - , - ; - sbol:description "Select only the samples with specified row/column combination from a sample collection" ; - sbol:displayId "PlateCoordinates" ; - sbol:hasNamespace . - - a ns1:OrderedPropertyValue, - sbol:Identified ; - ns1:indexValue 0 ; - ns1:propertyValue ; - sbol:displayId "OrderedPropertyValue1" . - - a ns1:Parameter, - sbol:Identified ; - ns1:direction ns1:in ; - ns1:isOrdered true ; - ns1:isUnique true ; - ns1:lowerValue ; - ns1:type ; - ns1:upperValue ; - sbol:displayId "Parameter1" ; - sbol:name "samples" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger1" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger2" . - - a ns1:OrderedPropertyValue, - sbol:Identified ; - ns1:indexValue 1 ; - ns1:propertyValue ; - sbol:displayId "OrderedPropertyValue2" . - - a ns1:Parameter, - sbol:Identified ; - ns1:direction ns1:in ; - ns1:isOrdered true ; - ns1:isUnique true ; - ns1:lowerValue ; - ns1:type om:Measure ; - ns1:upperValue ; - sbol:displayId "Parameter1" ; - sbol:name "amount" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger1" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger2" . - - a ns1:OrderedPropertyValue, - sbol:Identified ; - ns1:indexValue 2 ; - ns1:propertyValue ; - sbol:displayId "OrderedPropertyValue3" . - - a ns1:Parameter, - sbol:Identified ; - ns1:direction ns1:in ; - ns1:isOrdered true ; - ns1:isUnique true ; - ns1:lowerValue ; - ns1:type om:Measure ; - ns1:upperValue ; - sbol:displayId "Parameter1" ; - sbol:name "dispenseVelocity" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger1" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 0 ; - sbol:displayId "LiteralInteger2" . - - a ns1:OrderedPropertyValue, - sbol:Identified ; - ns1:indexValue 3 ; - ns1:propertyValue ; - sbol:displayId "OrderedPropertyValue4" . - - a ns1:Parameter, - sbol:Identified ; - ns1:direction ns1:in ; - ns1:isOrdered true ; - ns1:isUnique true ; - ns1:lowerValue ; - ns1:type om:Measure ; - ns1:upperValue ; - sbol:displayId "Parameter1" ; - sbol:name "cycleCount" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger1" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 0 ; - sbol:displayId "LiteralInteger2" . - - a ns1:OrderedPropertyValue, - sbol:Identified ; - ns1:indexValue 0 ; - ns1:propertyValue ; - sbol:displayId "OrderedPropertyValue1" . - - a ns1:Parameter, - sbol:Identified ; - ns1:direction ns1:in ; - ns1:isOrdered true ; - ns1:isUnique true ; - ns1:lowerValue ; - ns1:type ; - ns1:upperValue ; - sbol:displayId "Parameter1" ; - sbol:name "source" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger1" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger2" . - - a ns1:OrderedPropertyValue, - sbol:Identified ; - ns1:indexValue 1 ; - ns1:propertyValue ; - sbol:displayId "OrderedPropertyValue2" . - - a ns1:Parameter, - sbol:Identified ; - ns1:direction ns1:in ; - ns1:isOrdered true ; - ns1:isUnique true ; - ns1:lowerValue ; - ns1:type ; - ns1:upperValue ; - sbol:displayId "Parameter1" ; - sbol:name "destination" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger1" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger2" . - - a ns1:OrderedPropertyValue, - sbol:Identified ; - ns1:indexValue 2 ; - ns1:propertyValue ; - sbol:displayId "OrderedPropertyValue3" . - - a ns1:Parameter, - sbol:Identified ; - ns1:direction ns1:in ; - ns1:isOrdered true ; - ns1:isUnique true ; - ns1:lowerValue ; - ns1:type ns1:ValueSpecification ; - ns1:upperValue ; - sbol:displayId "Parameter1" ; - sbol:name "coordinates" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger1" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 0 ; - sbol:displayId "LiteralInteger2" . - - a ns1:OrderedPropertyValue, - sbol:Identified ; - ns1:indexValue 3 ; - ns1:propertyValue ; - sbol:displayId "OrderedPropertyValue4" . - - a ns1:Parameter, - sbol:Identified ; - ns1:direction ns1:in ; - ns1:isOrdered true ; - ns1:isUnique true ; - ns1:lowerValue ; - ns1:type ns1:ValueSpecification ; - ns1:upperValue ; - sbol:displayId "Parameter1" ; - sbol:name "replicates" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger1" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 0 ; - sbol:displayId "LiteralInteger2" . - - a ns1:OrderedPropertyValue, - sbol:Identified ; - ns1:indexValue 4 ; - ns1:propertyValue ; - sbol:displayId "OrderedPropertyValue5" . - - a ns1:Parameter, - sbol:Identified ; - ns1:direction ns1:in ; - ns1:isOrdered true ; - ns1:isUnique true ; - ns1:lowerValue ; - ns1:type om:Measure ; - ns1:upperValue ; - sbol:displayId "Parameter1" ; - sbol:name "temperature" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger1" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 0 ; - sbol:displayId "LiteralInteger2" . - - a ns1:OrderedPropertyValue, - sbol:Identified ; - ns1:indexValue 5 ; - ns1:propertyValue ; - sbol:displayId "OrderedPropertyValue6" . - - a ns1:Parameter, - sbol:Identified ; - ns1:direction ns1:in ; - ns1:isOrdered true ; - ns1:isUnique true ; - ns1:lowerValue ; - ns1:type om:Measure ; - ns1:upperValue ; - sbol:displayId "Parameter1" ; - sbol:name "amount" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger1" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger2" . - - a ns1:OrderedPropertyValue, - sbol:Identified ; - ns1:indexValue 6 ; - ns1:propertyValue ; - sbol:displayId "OrderedPropertyValue7" . - - a ns1:Parameter, - sbol:Identified ; - ns1:direction ns1:in ; - ns1:isOrdered true ; - ns1:isUnique true ; - ns1:lowerValue ; - ns1:type om:Measure ; - ns1:upperValue ; - sbol:displayId "Parameter1" ; - sbol:name "dispenseVelocity" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger1" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 0 ; - sbol:displayId "LiteralInteger2" . - - a ns1:OrderedPropertyValue, - sbol:Identified ; - ns1:indexValue 0 ; - ns1:propertyValue ; - sbol:displayId "OrderedPropertyValue1" . - - a ns1:Parameter, - sbol:Identified ; - ns1:direction ns1:in ; - ns1:isOrdered true ; - ns1:isUnique true ; - ns1:lowerValue ; - ns1:type ; - ns1:upperValue ; - sbol:displayId "Parameter1" ; - sbol:name "source" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger1" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger2" . - - a ns1:OrderedPropertyValue, - sbol:Identified ; - ns1:indexValue 1 ; - ns1:propertyValue ; - sbol:displayId "OrderedPropertyValue2" . - - a ns1:Parameter, - sbol:Identified ; - ns1:direction ns1:in ; - ns1:isOrdered true ; - ns1:isUnique true ; - ns1:lowerValue ; - ns1:type ns1:ValueSpecification ; - ns1:upperValue ; - sbol:displayId "Parameter1" ; - sbol:name "coordinates" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger1" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger2" . - - a ns1:OrderedPropertyValue, - sbol:Identified ; - ns1:indexValue 2 ; - ns1:propertyValue ; - sbol:displayId "OrderedPropertyValue3" . - - a ns1:Parameter, - sbol:Identified ; - ns1:direction ns1:out ; - ns1:isOrdered true ; - ns1:isUnique true ; - ns1:lowerValue ; - ns1:type ; - ns1:upperValue ; - sbol:displayId "Parameter1" ; - sbol:name "samples" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger1" . - - a ns1:LiteralInteger, - sbol:Identified ; - ns1:integerValue 1 ; - sbol:displayId "LiteralInteger2" . diff --git a/src/lab/labop/resources/upstream.json b/src/lab/labop/resources/upstream.json deleted file mode 100644 index 4c7fe3a..0000000 --- a/src/lab/labop/resources/upstream.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "repository": "https://github.com/Bioprotocols/labop", - "commit": "2e2bd88150c71a440771fd369f40295dfd622324", - "files": { - "uml.ttl": { - "path": "uml/inner/uml.ttl", - "sha256": "698921e4482a4f43348f62e8e51e7c4bdc079010d910e9bc2f10e8a5c4586a2a" - }, - "labop.ttl": { - "path": "labop/inner/labop.ttl", - "sha256": "914ee4cce8abe1982aaa9cac2197f853a878bb3972c97a3528f850dbd32efb3a" - }, - "liquid_handling.ttl": { - "path": "labop/lib/liquid_handling.ttl", - "sha256": "f848017ab3366f6e453caa48b8325b77ffcdc0bdb729f30a476ade52864a3355" - }, - "LICENSE.txt": { - "path": "LICENSE.txt", - "sha256": "6b14a3ec0069fab52046c0f1a967e7ffd43bd756d60874ca1e0065789e0ec99d" - }, - "sample_arrays.ttl": { - "path": "labop/lib/sample_arrays.ttl", - "sha256": "826ceac438e9d54f16040ee1802db8a6f3b2a975674fc8ce3b9cfd26daaaf97e" - } - }, - "subset": { - "file": "primitives.ttl", - "sha256": "de30936d92248c4df7b75839b974a0379129d2f3a3f8011e95d9fbcefd4d1bf2", - "subjects": "Each selected primitive and subjects beneath its identity plus a slash.", - "identities": [ - "https://bioprotocols.org/labop/primitives/liquid_handling/Transfer", - "https://bioprotocols.org/labop/primitives/liquid_handling/PipetteMix", - "https://bioprotocols.org/labop/primitives/sample_arrays/PlateCoordinates" - ] - } -} diff --git a/tests/test_labop.py b/tests/test_labop.py deleted file mode 100644 index b2804bf..0000000 --- a/tests/test_labop.py +++ /dev/null @@ -1,187 +0,0 @@ -import json -from dataclasses import replace -from urllib.parse import unquote - -import pytest -import sbol3 -from rdflib import RDF, URIRef - -import lab -from examples.cloning import ASSEMBLIES -from examples.sbol_provenance import inputs -from lab import Protocol, celsius, seconds, uL -from lab.deck import Container, Deck, DeckSite -from lab.experiments.cloning import build_assembly -from lab.labop import export -from lab.labop.primitives import LAB, LABOP, LIQUID, OM, SBOL, UML -from lab.labware import PCR_PLATE_96 -from lab.model import Origin -from lab.targets import LiquidHandler, Manual - - -def artifact(*, operations="operations"): - if operations == "assembly": - protocol = build_assembly(ASSEMBLIES) - else: - protocol = Protocol("Operation vocabulary") - plate = protocol.plate("plate", capacity=100 * uL) - protocol.load(plate["A1"], "water", volume=50 * uL) - protocol.set_temperature(plate, celsius(25)) - protocol.distribute(plate["A1"], (plate["A2"], plate["A3"]), volume=2 * uL, air_gap=1 * uL) - protocol.transfer(plate["A1"], plate["A4"], volume=2 * uL) - protocol.mix(plate["A1"], volume=2 * uL, cycles=2) - protocol.wait(1 * seconds) - protocol.manual("Inspect the plate") - protocol.thermocycle( - plate, - ((celsius(25), 1 * seconds),), - lid_temperature=celsius(40), - block_volume=10 * uL, - ) - recorded = protocol.snapshot() - return recorded, export(recorded) - - -@pytest.mark.parametrize("operations", ["operations", "assembly"]) -def test_labop_has_protocols_exact_semantic_actions_and_no_execution_claims(operations): - experiment, result = artifact(operations=operations) - graph = result.graph() - assert len(tuple(graph.subjects(RDF.type, LABOP.Protocol))) == 1 - for kind in ( - LABOP.ProtocolExecution, - LABOP.BehaviorExecution, - LABOP.ActivityNodeExecution, - LABOP.CallBehaviorExecution, - LABOP.ActivityEdgeFlow, - ): - assert not tuple(graph.subjects(RDF.type, kind)) - for predicate in (LABOP.execution, LABOP.completedNormally): - assert not tuple(graph.triples((None, predicate, None))) - assert {str(node) for node in graph.subjects(LAB.semanticStep)} == { - f"{result.protocol}/step_{index + 1}" for index in range(len(experiment.steps)) - } - for node in graph.subjects(RDF.type, LABOP.SampleArray): - if str(graph.value(node, SBOL.name)) == "products": - assert all( - value is None - for value in json.loads( - unquote(str(graph.value(node, LABOP.initial_contents))) - ).values() - ) - assert not tuple(graph.subjects(UML.behavior, URIRef(LIQUID + "Provision"))) - amounts = tuple(graph.subjects(RDF.type, OM.Measure)) - assert amounts and all(graph.value(node, OM.hasUnit) for node in amounts) - - -@pytest.mark.parametrize("operations", ["operations", "assembly"]) -def test_calls_follow_upstream_required_parameter_names_and_directions(operations): - _, result = artifact(operations=operations) - graph = result.graph() - for action in graph.subjects(RDF.type, UML.CallBehaviorAction): - behavior = graph.value(action, UML.behavior, any=False) - parameters = [ - graph.value(item, UML.propertyValue) - for item in graph.objects(behavior, UML.ownedParameter) - ] - by_name = {str(graph.value(parameter, SBOL.name)): parameter for parameter in parameters} - supplied = {} - for direction, predicate in (("in", UML.input), ("out", UML.output)): - for pin in graph.objects(action, predicate): - name = str(graph.value(pin, SBOL.name)) - assert name in by_name, (behavior, name) - assert graph.value(by_name[name], UML.direction) == UML[direction] - assert name not in supplied - supplied[name] = pin - if direction == "in": - constant = graph.value(pin, UML.value) - incoming = tuple(graph.subjects(UML.target, pin)) - assert bool(constant) != bool(incoming) - for name, parameter in by_name.items(): - lower = graph.value(parameter, UML.lowerValue) - if graph.value(lower, UML.integerValue).toPython() == 1: - assert name in supplied, (behavior, name) - - -def test_export_snapshots_authoring_and_rejects_invalid_material_accounting(): - protocol = Protocol("Direct export") - source = protocol.container("source", contents="water", volume=10 * uL, capacity=20 * uL) - destination = protocol.container("destination", capacity=20 * uL) - protocol.transfer(source, destination, volume=2 * uL) - recorded = protocol.snapshot() - exported = export(protocol) - compilation = lab.compile(protocol, Manual(), to=None) - assert compilation.files["protocol.labop.ttl"] == exported.text == export(recorded).text - protocol.transfer(source, destination, volume=20 * uL) - assert export(recorded).text == exported.text - assert compilation.files["protocol.labop.ttl"] == exported.text - with pytest.raises(lab.CompileError): - export(protocol) - - -@pytest.mark.integration -@pytest.mark.parametrize("handler", list(LiquidHandler)) -def test_labop_is_identical_across_robot_and_manual_compilation(handler): - pytest.importorskip("pylabrobot" if handler is LiquidHandler.STAR else "opentrons") - protocol = Protocol("Shared target input") - plate = protocol.plate("plate", capacity=100 * uL) - protocol.load(plate["A1"], "water", volume=20 * uL) - protocol.transfer(plate["A1"], plate["A2"], volume=10 * uL) - manual = lab.compile(protocol, Manual(), to=None) - deck = Deck(containers=(Container(id="plate", labware=PCR_PLATE_96, site=DeckSite.PLATES),)) - robot = lab.compile(protocol, deck=deck, liquid_handler=handler, to=None) - assert ( - robot.files["protocol.labop.ttl"] - == manual.files["protocol.labop.ttl"] - == export(protocol).text - ) - - -def test_automatic_identities_are_accepted_without_rewriting_by_sbol3(): - protocol = Protocol("Automatic identity") - protocol.wait(1 * seconds) - graph = export(protocol).graph() - for kind in (SBOL.TopLevel, SBOL.Identified): - for identity in graph.subjects(RDF.type, kind): - parsed = sbol3.Identified(str(identity), type_uri=str(kind)) - assert parsed.identity == str(identity) - assert parsed.display_id == str(graph.value(identity, SBOL.displayId)) - - -def test_export_identity_depends_on_meaning_and_accepts_an_explicit_sbol_identity(): - protocol, original = artifact() - moved = replace( - protocol, - steps=tuple( - replace(step, origin=Origin("/other/computer.py", 999)) for step in protocol.steps - ), - ) - assert export(moved).text == original.text - identity = "https://example.org/protocols/aliquot" - custom = export(protocol, identity=identity) - assert custom.protocol == identity - assert (URIRef(identity), RDF.type, LABOP.Protocol) in custom.graph() - for invalid in ( - "", - "relative", - "https://example.org/bad-id", - "https://bad host/protocol", - "https://host_name", - "https://example.org/plan?x=/valid", - ): - with pytest.raises(ValueError): - export(protocol, identity=invalid) - - -def test_labop_links_native_sbol_designs_without_embedding_the_document(): - from_example, protocol = inputs() - recorded = protocol.snapshot() - stock = "https://example.org/aliquot/stock" - recorded = replace( - recorded, - samples=(replace(recorded.samples[0], implementation=stock), *recorded.samples[1:]), - ) - result = export(recorded) - assert tuple(result.graph().subjects(LAB.implementation, URIRef(stock))) - design = from_example.find("https://example.org/aliquot/design") - assert len(tuple(result.graph().subjects(LAB.design, URIRef(design.identity)))) == 2 - assert not tuple(result.graph().subjects(RDF.type, SBOL.Component)) diff --git a/tests/test_labop_resources.py b/tests/test_labop_resources.py deleted file mode 100644 index 398eec7..0000000 --- a/tests/test_labop_resources.py +++ /dev/null @@ -1,20 +0,0 @@ -import hashlib -import json -from importlib.resources import files - -from rdflib import RDF, Graph - -from lab.labop.primitives import LABOP - - -def test_selected_upstream_primitives_match_the_packaged_checksum(): - resources = files("lab.labop").joinpath("resources") - manifest = json.loads(resources.joinpath("upstream.json").read_text()) - subset = manifest["subset"] - data = resources.joinpath(subset["file"]).read_bytes() - assert hashlib.sha256(data).hexdigest() == subset["sha256"] - graph = Graph().parse(data=data, format="turtle") - assert {str(node) for node in graph.subjects(RDF.type, LABOP.Primitive)} == set( - subset["identities"] - ) - assert Graph().parse(data=resources.joinpath("lab.ttl").read_text(), format="turtle") diff --git a/uv.lock b/uv.lock index 1cdfdd6..50f150c 100644 --- a/uv.lock +++ b/uv.lock @@ -384,7 +384,6 @@ source = { editable = "." } dependencies = [ { name = "pint", version = "0.25.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "pint", version = "0.26.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "rdflib" }, { name = "sbol3" }, ] @@ -411,7 +410,6 @@ requires-dist = [ { name = "opentrons", marker = "extra == 'opentrons'", specifier = "==8.8.2" }, { name = "pint", specifier = ">=0.24,<0.27" }, { name = "pylabrobot", marker = "extra == 'star'", specifier = "==0.2.1" }, - { name = "rdflib", specifier = ">=6.1.1,<7" }, { name = "sbol3", specifier = "==1.2.0.post0" }, ] provides-extras = ["opentrons", "star"] From 45ab95f064bc5aa126085b0201a60d7e88d0ba8f Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 1 Oct 2026 13:10:14 -0600 Subject: [PATCH 3/5] Clean up README --- README.md | 51 --------------------------------------------------- 1 file changed, 51 deletions(-) diff --git a/README.md b/README.md index 72c7bb3..c40f1ee 100644 --- a/README.md +++ b/README.md @@ -57,28 +57,6 @@ compiled = compile( ) ``` -`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. - -`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. - -`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` 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. - -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. - -`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. - -## SBOL provenance - -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. - -## LabOP export - -The optional [LabOP integration](docs/labop.md) converts a compiled `plan.json` into a prospective LabOP protocol using upstream's Python library in a separate environment. Lab's installed package has no LabOP dependency or bundled schemas. - ## 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). @@ -127,10 +105,6 @@ deck = Deck( ) ``` -The container ids, `sources` and `assay`, match the names used by the protocol. `DeckLayout` assigns each container a physical model and position, and connects the pipette to its tip rack. A deck can include additional layouts for Flex or STAR using the same container ids. - -A deck with explicit layouts must include one for the selected liquid handler. Preset decks, such as `transformation_deck`, instead use `Container` and `DeckSite` from `lab.deck` to assign containers to supported placement groups. A bare `ContainerSpec` requires an explicit layout. - ## Describe physical layouts in Lab Use `DeckLayout` when equipment or placement needs to be explicit. Each layout names its `LiquidHandler` and places the same shared containers. All objects below belong to Lab: @@ -191,10 +165,6 @@ The cloning example writes separate `assembly`, `transformation`, and `plating` The manifest uses logical container and well names; physical bindings remain in `plan.json`. Rewriting an identical bundle succeeds. If any generated file would replace different contents, `write()` rejects the write before changing any bundle files. -## Status - -Lab Compiler is an early prototype. It checks a plan and emits a document or device program for the OT-2, Flex, and STAR. Software checks are not calibration, collision safety, or qualification of a physical run. Generated instructions need a person and a facility before anyone uses them at the bench. - ## Development From the repository root: @@ -206,25 +176,4 @@ uv run --no-sync mypy uv run --no-sync pytest ``` -Shared types and compiler code live directly under `src/lab`; experiment families and target implementations have their own packages: - -```text -src/lab/ - protocol.py # Protocol builder, Plate, Well - model.py # Recorded operations, protocol snapshots, target plans - part.py # SBOL part identity - samples.py # Sample, Location, SamplePlacement, OutputManifest - labware.py # Logical labware specifications - equipment.py # Liquid handlers and equipment identifiers - deck.py # Container requirements and physical layouts - units.py # Quantities and unit conversion - compiler.py # Compilation and output bundles - validation.py # Volume, binding, and sample validation - documents.py # Printable protocol rendering - experiments/ - cloning/ # Cloning types, stage builders, decks, and workflow composition - types.py # Assembly and transformation designs, stage inputs - targets/ # Manual, Opentrons, and STAR backends -``` - See the [release guide](https://github.com/the-lab-compiler/lab-py/blob/master/docs/releasing.md) for package validation and PyPI publishing. From ff50609342ba512b26d8224a795e1f94ae1b4f33 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 1 Oct 2026 13:11:54 -0600 Subject: [PATCH 4/5] Move doc to integration folder --- docs/labop.md => integrations/labop/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/labop.md => integrations/labop/README.md (92%) diff --git a/docs/labop.md b/integrations/labop/README.md similarity index 92% rename from docs/labop.md rename to integrations/labop/README.md index 3629290..eeaaa4f 100644 --- a/docs/labop.md +++ b/integrations/labop/README.md @@ -1,6 +1,6 @@ # Export a compilation as LabOP -The optional integration reads the compiler's `lab.plan.v1` JSON artifact and writes a prospective LabOP protocol. It uses upstream LabOP's classes, primitive libraries, and SBOL serialization. No LabOP implementation, schemas, or dependency is included in the `lab-compiler` wheel. +Because LabOP is not currently on PyPi, we have made the integration with tthe Lab Compiler optional. The integration reads the compiler's `lab.plan.v1` JSON artifact and writes a prospective LabOP protocol. It uses upstream LabOP's classes, primitive libraries, and SBOL serialization. Install the integration from a checkout, using a separate Python 3.11 or 3.12 environment: From a50d421aef42b7fb15fa838d441d6213386f59ec Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 1 Oct 2026 13:46:28 -0600 Subject: [PATCH 5/5] Generate LabOP file in example --- .github/workflows/ci.yml | 7 ++++--- examples/labop_export.py | 5 +++-- integrations/labop/README.md | 11 +++++------ integrations/labop/export.py | 21 +++++++++++++-------- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6e00b0..b803f45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,14 +52,15 @@ jobs: - run: uv sync --locked - name: Generate compiler artifacts run: | - uv run --no-sync python -m examples.labop_export --out build/labop_check/operations uv run --no-sync python -m examples.cloning --target manual --out build/labop_check/cloning - name: Install upstream in a separate environment run: | uv run --no-sync python -m venv integrations/labop/.venv - integrations/labop/.venv/bin/python -m pip install -r integrations/labop/requirements.txt + integrations/labop/.venv/bin/python -m pip install -e . -r integrations/labop/requirements.txt - name: Check exported plans with upstream - run: integrations/labop/.venv/bin/python integrations/labop/check.py build/labop_check + run: | + integrations/labop/.venv/bin/python -m examples.labop_export --out build/labop_check/operations + integrations/labop/.venv/bin/python integrations/labop/check.py build/labop_check build: name: Build and check distributions diff --git a/examples/labop_export.py b/examples/labop_export.py index 02299f3..ffa38bf 100644 --- a/examples/labop_export.py +++ b/examples/labop_export.py @@ -4,11 +4,12 @@ from pathlib import Path import lab +from integrations.labop.export import export_file def main() -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--out", type=Path, default=Path("build/labop_export")) + parser.add_argument("--out", type=Path, default=Path("build/labop")) args = parser.parse_args() protocol = lab.Protocol("LabOP operations") plate = protocol.plate("operations", capacity=100 * lab.uL) @@ -28,7 +29,7 @@ def main() -> None: block_volume=10 * lab.uL, ) output = lab.compile(protocol, to=None).write(args.out) - print(output / "plan.json") + print(export_file(output / "plan.json")) if __name__ == "__main__": diff --git a/integrations/labop/README.md b/integrations/labop/README.md index eeaaa4f..4fc46fe 100644 --- a/integrations/labop/README.md +++ b/integrations/labop/README.md @@ -6,19 +6,18 @@ Install the integration from a checkout, using a separate Python 3.11 or 3.12 en ```sh python3.12 -m venv integrations/labop/.venv -integrations/labop/.venv/bin/python -m pip install -r integrations/labop/requirements.txt +integrations/labop/.venv/bin/python -m pip install -e . -r integrations/labop/requirements.txt ``` The upstream revision and direct compatibility dependencies are pinned in `requirements.txt`. This environment uses `pip` because upstream's container-ontology URL is not accepted by `uv`. Upstream's transitive dependencies are not fully locked. Installation needs network access; export uses the installed libraries. This environment is separate because upstream import modifies pySBOL3's registrations, classes, and process logging. -Compile with Lab, then export the saved artifact: +Run the example to compile with Lab and write LabOP: ```sh -uv run python -m examples.labop_export --out build/labop_export -integrations/labop/.venv/bin/python integrations/labop/export.py build/labop_export/plan.json +integrations/labop/.venv/bin/python -m examples.labop_export ``` -The second command writes `protocol.labop.ttl` beside the input. `--out path.ttl` selects another location. It refuses to replace a different existing artifact. Normal compilation emits `plan.json`, HTML, and target artifacts; LabOP is an explicit export step. +The example writes `build/labop/protocol.labop.ttl` beside `plan.json` and `protocol.html`. `--out directory` selects another output directory. It refuses to replace a different existing artifact. Normal compilation emits `plan.json`, HTML, and target artifacts; LabOP is an explicit export step. The integration consumes only the protocol portion of `plan.json`. Target configuration and authoring file locations do not affect the exported identity. Samples retain SBOL design and implementation references. The RDF records the intended operations, initial resource metadata, and declared outputs; it records no execution or observed products. @@ -33,7 +32,7 @@ Checks validate the constructed upstream document, operation parameters, control To check the integration against fresh compiler output: ```sh -uv run python -m examples.labop_export --out build/labop_check/operations +integrations/labop/.venv/bin/python -m examples.labop_export --out build/labop_check/operations uv run python -m examples.cloning --target manual --out build/labop_check/cloning integrations/labop/.venv/bin/python integrations/labop/check.py build/labop_check ``` diff --git a/integrations/labop/export.py b/integrations/labop/export.py index 43e8f29..d253a32 100644 --- a/integrations/labop/export.py +++ b/integrations/labop/export.py @@ -217,19 +217,24 @@ def extension(name: str) -> labop.Primitive: return document +def export_file(plan: Path, output: Path | None = None) -> Path: + """Write LabOP beside a saved plan, refusing to replace a different artifact.""" + document = export(json.loads(plan.read_text(encoding="utf-8"))) + graph = to_canonical_graph(document.graph()) + content = "\n".join(sorted(graph.serialize(format="nt").splitlines())) + "\n" + path = output or plan.with_name("protocol.labop.ttl") + if path.exists() and path.read_text(encoding="utf-8") != content: + raise FileExistsError(f"{path} already contains a different artifact") + path.write_text(content, encoding="utf-8") + return path + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("plan", type=Path) parser.add_argument("--out", type=Path) args = parser.parse_args() - document = export(json.loads(args.plan.read_text())) - graph = to_canonical_graph(document.graph()) - output = "\n".join(sorted(graph.serialize(format="nt").splitlines())) + "\n" - path = args.out or args.plan.with_name("protocol.labop.ttl") - if path.exists() and path.read_text() != output: - raise FileExistsError(f"{path} already contains a different artifact") - path.write_text(output) - print(path) + print(export_file(args.plan, args.out)) if __name__ == "__main__":