diff --git a/README.md b/README.md index 494e096..0cb4504 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,14 @@ For custom protocols, declare typed sample metadata with `protocol.add_sample(sa `lab.samples` also defines `Location(resource, well)`, `SamplePlacement`, and `OutputManifest`. Recorded operations, sample placements, target bindings, and final volume accounting use the same logical `Location` type. For example, an output placement's `location` can be used directly as a key in `dict(compiled.final_volumes)`. `lab.part.Part` identifies a biological part by its SBOL IRI; cloning types and stage builders live under `lab.experiments.cloning`. +## Biological designs and provenance + +`lab.provenance` provides immutable SBOL3 designs, material implementations, activities, qualified usages and associations, agents, and plans. Author a `Document`, explicitly add its objects, and call `freeze()` to validate references and obtain a reproducible snapshot. Import and export local Turtle with `Document.read()` and `snapshot.write()`, or exchange a detached pySBOL3 document with `from_sbol3()` and `to_sbol3()`. + +The [provenance guide](docs/provenance.md) covers the Python API and evidence states for supplied material records and planned work. Run `uv run --no-sync python -m examples.provenance` for a complete authoring and round-trip example. The [experiment walkthrough](docs/cloning-provenance.md) covers inventory-aware assembly, transformation, plating, external preparation, Addgene records, and compilation to SBOL/LabOP/robot bundles. LabOP export describes the protocol plan, including its operations, parameters, and material flows. Run `uv run python -m examples.cloning_workflow --target manual` for the complete synthetic software example. See the [implementation status](docs/provenance-implementation.md) for remaining integrations. + +The [worked notebook](examples/cloning_provenance/cloning_provenance.ipynb) follows a received vector lot through a reporter-reference project. It reads the [example input files](examples/cloning_provenance/data/reporter_reference/README.md), explains actual Turtle excerpts from SBOL and LabOP, and traces a planned operation into robot code. Optional cells compile Flex and STAR as well as OT-2 and verify that each target shares the same LabOP protocol. Saved outputs let you read the story without running it. Launch it with `uv run --extra opentrons --extra star --with jupyterlab jupyter lab examples/cloning_provenance/cloning_provenance.ipynb`. + ## 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). @@ -168,7 +176,7 @@ uv run --no-sync python -m examples.deck_layouts --target ot2 uv run --no-sync python -m examples.deck_layouts --target flex ``` -Use `manual` or `ot2` for the cloning example. Its assembly recipe includes transfers below the current Flex preset's supported pipetting range. The deck layouts example supports `ot2`, `flex`, and `star`. +The provenance assembly and complete cloning workflow examples support `manual`, `ot2`, `flex`, and `star`. Flex selects the 50 µL pipette for small transfers. STAR thermal operations require explicit runtime callbacks. The deck layouts example supports `ot2`, `flex`, and `star`. The cloning example writes separate `assembly`, `transformation`, and `plating` bundles under `build/cloning/`, including for the manual target. The deck layouts example writes to `build/decks/`. Both accept `--out` to choose a different output directory. Compilation never connects to hardware. @@ -207,6 +215,7 @@ src/lab/ protocol.py # Protocol builder, Plate, Well model.py # Recorded operations, protocol snapshots, target plans part.py # SBOL part identity + provenance/ # SBOL3 designs, provenance, immutable document snapshots samples.py # Sample, Location, SamplePlacement, OutputManifest labware.py # Logical labware specifications equipment.py # Liquid handlers and equipment identifiers diff --git a/docs/cloning-provenance.md b/docs/cloning-provenance.md new file mode 100644 index 0000000..7509ec2 --- /dev/null +++ b/docs/cloning-provenance.md @@ -0,0 +1,138 @@ +# Planning and compiling an experiment + +The pipeline is `SBOL designs + inventory + recipes + resolved methods → BuildPlan → ExperimentPlan → ExperimentCompilation`. Each arrow creates a new immutable snapshot. Target selection happens after the experiment has been frozen. SBOL, LabOP, methods, and robot programs are projections of those snapshots. + +## Python API + +```python +from lab import compile +from lab.experiments import cloning +from lab.inventory import Inventory +from lab.provenance import Document +from lab.targets import LiquidHandler + +designs = Document.read("designs.ttl").freeze() +inventory = Inventory.read("inventory.json", document=designs) +system = cloning.CloningSystem.read("system.json") +methods = cloning.CloningMethods.read("methods.json") + +build = cloning.plan( + request, + document=designs, + inventory=inventory, + system=system, + methods=methods, +) +build.write("review/build") +build.require_ready() + +experiment = cloning.build(build) +compile(experiment, LiquidHandler.OT2).write("review/ot2") +compile(experiment, LiquidHandler.FLEX).write("review/flex") +compile(experiment, LiquidHandler.STAR).write("review/star") +``` + +`BuildRequest.targets` accepts `BuildTarget(design=component.ref, volume_ul=Decimal(...), form=...)` for liquids and `CountTarget(design=component.ref, count=..., form=...)` for whole material units. A `CountTarget` defaults to `MaterialForm.PLATED_SAMPLE`. Method and system files are typed JSON snapshots written by `CloningMethods.write()` and `CloningSystem.write()`. Recipe parameter values cannot be recovered from SBOL usage edges: they must be specified separately. + +The complete runnable [example](../examples/provenance_build.py) authors every input, using short synthetic DNA strings and arbitrary parameters as a software fixture. Run `uv run python -m examples.provenance_build --target manual`; choose `ot2`, `flex`, or `star` with the corresponding SDK extra installed. + +## Designs, quantities, and alternative routes + +`Stock` requires an explicit `Ref[Implementation]`, `Ref[Component]`, `MaterialForm`, and measured available volume. Pint inputs are normalized to immutable Decimal quantities. Inventory contains recorded implementations; catalog listings, planned products, and simulated material are not available physical inventory. An inventory design assertion does not establish that its sequence was verified. `CountedStock` records assessed whole-unit counts for bacterial stabs and plated samples. A supplier package count does not automatically establish an available material count. Both stock classes require recorded provenance; simulated material cannot satisfy physical inventory. + +An `AssemblyRecipe` specifies an enzyme, intended product, method identity, and ordered `FragmentSelection` objects. Each fragment identifies its source component and zero-based Watson cut positions; `None` denotes a linear end. A circular single-cut linearization has equal left and right cuts. Reversal is explicit. The sequence calculator tracks both strands and sticky ends, checks ligation compatibility, and compares a specified target sequence modulo circular rotation. Linear products with unpaired ends require explicit end repair. No fragment is selected by size. + +`AssemblyMethod` supplies all reagent and DNA volumes, diluent, thermal holds, cycle counts, mixing parameters, and planned usable output. The planner accounts for available quantities across the entire request, including repeated reaction batches and shared intermediates. It explores permitted recipe combinations and prefers existing stock, then fewer outstanding design/acquisition/preparation requirements, fewer dependent stages, and fewer reactions. `PlanningPolicy.max_states` bounds exploration explicitly; exceeding it fails rather than silently switching algorithms. + +Calculated designs have recorded **computational** activities and content-dependent identities. Planned physical implementations point to them without asserting `Implementation.built`. `propose_edits()` examines only positions the caller marks editable. Applying a selected proposal creates a new design; it does not change inventory, establish biological function, or automatically modify a recipe. + +The planner schedules assembly, transformation, plating, and explicitly defined external preparation. It matches both the component identity and material form; a design in culture is not available DNA. Missing material, an unspecified conversion procedure, or an invalid design remains an explicit requirement and blocks executable generation. Each reaction has its own output implementation. Allocations consume one shared quantity ledger across all requested targets; planning leaves the input inventory snapshot unchanged. + +## Transformation, plating, and preparation + +A `CloningSystem` contains an immutable tuple of typed recipes. Recipes choose biological inputs and intended products; `CloningMethods` supplies the full procedure parameters. For example, with authored components and methods: + +```python +from lab.inventory import MaterialForm + +system = cloning.CloningSystem( + identity="https://example.org/system", + recipes=( + assembly_recipe, + cloning.TransformationRecipe( + identity="https://example.org/transform", + product=strain.ref, + chassis=cells.ref, + plasmids=(plasmid.ref,), + method=transformation_method.identity, + ), + cloning.PlatingRecipe( + identity="https://example.org/plate", + product=strain.ref, + method=plating_method.identity, + ), + cloning.ExternalPreparationRecipe( + identity="https://example.org/prepare_dna", + source=vector.ref, + source_form=MaterialForm.BACTERIAL_STAB, + product=vector.ref, + output_form=MaterialForm.DNA, + method=preparation_method.identity, + ), + ), +) +request = cloning.BuildRequest( + identity="https://example.org/build", + targets=(cloning.CountTarget(design=strain.ref, count=2),), +) +``` + +`TransformationMethod` requires cell and DNA volumes, a recovery-medium `Reagent`, a treatment `profile`, a `recovery_profile`, mixing volumes/cycles, and a planned usable output volume. `initial_celsius` is optional. The generated protocol adds and mixes the cells, mixes and adds each allocated DNA source, applies the treatment profile, adds the recovery medium, and applies the recovery profile. Thermal block volumes follow the actual additions. The output has form `CULTURE` and represents the intended recovery mixture; it does not assert transformation success, clonality, or sequence verification. + +`PlatingMethod` requires a substrate component, diluent component, transfer volume, ordered dilution factors, mixing volume/cycles, spot volume, and `spot_height_mm` above the destination well bottom. Each recipe invocation produces one `PLATED_SAMPLE` at the final dilution. Repeated count targets generate independent dilution series and spots. No colony count, incubation, or selection is inferred. The substrate is a supplied plate precondition, recorded in the method and planned SBOL usage; it is a consumable like tips and labware, not an automatically reserved inventory aliquot. A plated sample cannot be pipetted as liquid. Spotting height is carried into LabOP, Opentrons positioning, and the STAR dispense command. + +`ExternalPreparationMethod` requires a procedure IRI and explicit instructions, plus exactly one source quantity (`source_volume_ul` or `source_count`) and one expected output quantity (`output_volume_ul` or `output_count`). Optional `reagents` name additional consumed liquids. The matching recipe specifies source and output forms; quantity kinds must agree with those forms. Instructions are preserved in SBOL, LabOP, methods, and the input snapshot. Supply complete instructions and a versioned procedure identifier; Lab does not fetch or invent the external procedure. + +External preparation becomes a `ProtocolStage(external=True)` containing an `ExternalPreparation` operation. Its input consumption and prospective output are checked at the operation boundary, so an output is not falsely declared present at the start of the stage. `compile(experiment, LiquidHandler.OT2)` emits an operator document for this stage and robot code for the automated stages. Explicit per-stage target mappings must assign external stages to `Manual()`. The preparation output remains a planned implementation with an expected quantity. + +The runnable [complete workflow](../examples/cloning_workflow.py) demonstrates counted input → external preparation → assembly → transformation → two plated samples using synthetic test data. Run `uv run python -m examples.cloning_workflow --target ot2` with the Opentrons extra, or choose `manual`, `flex`, or `star`. + +## Supplier evidence + +`AddgeneClient(token=...).plasmid(id)` explicitly retrieves one catalog item through Addgene's [read-only API](https://developers.addgene.org/access-options/). Parsing preserves depositor versus Addgene sequences, full versus partial sequence assertions, retrieval time, and original metadata. `parse_plasmid()` accepts saved responses without network access. + +`CatalogEntry(item=..., design=component.ref, form=MaterialForm.BACTERIAL_STAB)` is a caller-reviewed mapping. Pass a frozen `Catalog` to `cloning.plan(catalog=...)`. Acquisition requests contain matching candidates, with Addgene first. A candidate is not an order or usable stock. + +`QuoteRecord` and `OrderReference` record transactions performed outside Lab. `Receipt.record(document)` adds an observed arrival and material implementation. A liquid receipt enters `Inventory` only through an explicitly measured `receipt.stock(...)`. `receipt.counted_stock(identity=..., count=...)` records an explicitly assessed count for a counted material form. The declared preparation route can then consume that stock; the receipt alone does not establish a quantity or a usable liquid yield. Addgene authentication and response shape are contract-tested with synthetic fixtures; no authenticated live retrieval is claimed. + +## Frozen experiments and hardware + +`ExperimentPlan` contains a provenance snapshot and ordered `ProtocolStage` objects. Each stage holds a `RecordedProtocol`, logical deck requirements, dependencies, and material handoffs. `cloning.build()` retains full design and implementation IRIs, carries forward remaining liquid volumes and whole-unit counts after previous consumers, and separates usable-yield planning from the physical volume ledger. + +Default automated stages use PCR plates for reagents and products, with separate dilution and substrate plates for plating. A caller may supply a different `reagent_container`; `preparation_container` controls the logical output container of external procedures. Counted external inputs occupy abstract storage positions that do not prescribe robot labware. Loading is an initial condition; the compiler does not invent aliquoting, stock preparation, or an additional liquid transfer during a plate handoff. A loading amount that exceeds a container's capacity fails validation. + +Every operation has an identity. Supplying `Protocol(..., identity="https://.../protocol")` gives a stable parent for generated step identities. Without an explicit identity, the snapshot derives one from its contents. Semantic digests exclude developer source paths and hardware; target compilation digests include physical configuration and generated source. Source maps identify inclusive generated line spans for each semantic step. + +`compile(experiment, handler)` resolves each stage's deck. A mapping from stage identities to concrete targets or decks supports custom equipment. Flex supports the 50 µL pipette's documented [volume modes](https://docs.opentrons.com/python-api/pipettes/volume-modes/), configured before picking up a tip. STAR thermal stages require supplied asynchronous runtime callbacks: `thermocycle` returns the plate after the profile, while `set_temperature` must maintain the hold with the plate accessible for subsequent pipetting. Generated STAR scripts use a software preview backend when invoked directly. SDK simulations and previews do not qualify physical equipment. + +## LabOP and digital methods + +`lab.labop.export(experiment)` emits protocol RDF from the frozen experiment, including its stage protocols, operations, parameters, and material flows. This is the scope of the LabOP integration; execution recording is outside the compiler. The packaged UML ontology, LabOP ontology, and primitive libraries are pinned to upstream commit `2e2bd88150c71a440771fd369f40295dfd622324`; their hashes and license are included in `lab/labop/resources`. + +| Lab semantics | LabOP representation | +| --- | --- | +| Experiment and stage | Protocols with ordered subprotocol calls | +| Initial plate contents | Input SampleArray defaults and explicit loading conditions | +| Well selection | PlateCoordinates and object flows | +| Transfer | liquid_handling/Transfer; TransferAtHeight when a destination height is specified | +| External preparation | Named extension primitive with source/destination collections, procedure, instructions, and expected amounts | +| Counted material | Sample assertions and amount measures in OM one, separate from microlitres | +| Mixing | liquid_handling/PipetteMix, including volume and cycle count | +| Wait, persistent temperature, thermal profile, distribution, operator pause | Named Lab extension primitives with explicit typed parameters | +| Material handoff | Subprotocol output-to-input object flow | + +The extension primitives are defined inside each artifact. Thermal profiles contain ordered holds with unit-bearing temperatures and durations. Distribution uses an ordered collection of sample aliases and preserves its air-gap and tip-reuse semantics. Lab extensions also retain material identities, initial volumes, planned sample assertions, and the semantic step payload. Initial loads never become fictitious Provision actions. The exporter supports whole-plate continuity across stage handoffs; arbitrary inter-stage well remapping requires an explicit material operation. + +The bundle includes the Lab, LabOP, and UML schemas. The independent `scripts/check_labop.py` check loads them through SBOLFactory 1.1.2 and pySBOL3, validates the document, and verifies that thermal-profile parameters deserialize into typed objects. Run it in an isolated interpreter because SBOLFactory registers process-wide builders. This check does not execute the protocol. + +Bundles contain `experiment.json`, `build.json` where applicable, `provenance.ttl`, `protocol.labop.ttl`, `methods.md`, input snapshots, schemas, per-stage protocol/target plans, manifests, source maps, and generated programs. Inputs include the build-plan provenance before its protocol links were resolved, inventory, catalog mappings, recipes, and methods. `bundle.json` records SHA-256 checksums of the other artifacts. Methods describe the planned procedure and its expected outputs. diff --git a/docs/provenance-implementation.md b/docs/provenance-implementation.md new file mode 100644 index 0000000..b88de04 --- /dev/null +++ b/docs/provenance-implementation.md @@ -0,0 +1,20 @@ +# Provenance implementation status + +Lab owns immutable design, inventory, planning, and protocol models. SBOL3 describes designs, supplied materials, and planned work; LabOP describes the protocol's operations and flows. Target programs are compiled from the same frozen semantics. + +## Implemented path + +1. **SBOL3 foundation.** `provenance/types.py`, `_schema.py`, `document.py`, `validation.py`, `sbol3.py`, and `resources/lab.ttl` implement the taxonomy, explicit references, authoring, validation, deterministic snapshots, and pySBOL3 interchange. Ordinary imports keep annotations resolvable at runtime. +2. **Cloning planning.** `inventory.py` and cloning `systems.py`, `methods.py`, `_dna.py`, `sequences.py`, `domestication.py`, `routes.py`, and `planning.py` handle measured stock snapshots, explicit fragments, sequence calculations, caller-selected edit proposals, alternative routes, shared quantities, repeated batches, and multi-level dependencies. Typed transformation, counted plating, and explicit external preparation share the allocation and dependency model. Unspecified preparation and acquisition remain requirements. Sequence calculations use Biopython 1.84 enzyme definitions; pydna is not a runtime dependency. +3. **Supplier records.** `suppliers/types.py` and `addgene.py` provide read-only catalog retrieval, preserved sequence evidence, reviewed design mappings, acquisition requests, external quote/order references, and counted receipts. No purchasing action or available stock is inferred from a listing. +4. **Shared experiment compilation.** `experiment.py`, `operations.py`, `target.py`, `model.py`, and cloning `build.py` freeze stage protocols and material handoffs. Samples retain design and implementation references. The compiler accepts an experiment, supports per-stage target selection, and writes source maps with stable semantic step identities. Shared target values live in `target.py` so importing validation does not initialize the backend package. +5. **LabOP and methods.** `labop/protocol.py`, `primitives.py`, pinned resources, `artifacts.py`, and `methods.py` produce static protocol RDF, typed parameter and sample flows, methods, and artifact checksums. Exact upstream primitives are used where their semantics match. Named extensions specify the remaining operations. Compilation never invokes an execution engine. +6. **Robot targets.** Flex supports 50 µL pipettes and explicit volume-mode changes. STAR separates persistent-temperature and thermal-profile callbacks. The assembly, transformation, and plating paths have software simulation/preview coverage for OT-2, Flex, and STAR; physical qualification is outside those checks. + +The developer walkthrough is [Planning and compiling an experiment](cloning-provenance.md). [The complete runnable example](../examples/cloning_workflow.py) supplies all inputs using a synthetic software fixture. + +## Boundaries and remaining integrations + +External procedures remain operator stages; their expected yields are prospective until observed. Plating plans one deposited sample per reaction and does not infer colony formation or selection. The substrate, tips, and labware are supplied consumables, not stock reservations. The existing standalone transformation and plating builders also remain available for direct protocol authoring. + +The LabOP integration ends at protocol export; post-execution recording is outside its scope. Durable inventory reservation/depletion, automatic procurement, arbitrary inter-stage well remapping, and physical instrument qualification are not implemented. The Addgene adapter is contract-tested without authenticated live credentials. LabOP validation checks pinned ontology cardinalities and primitive contracts; it does not claim that every third-party interpreter implements the Lab extension primitives. diff --git a/docs/provenance.md b/docs/provenance.md new file mode 100644 index 0000000..6db6c42 --- /dev/null +++ b/docs/provenance.md @@ -0,0 +1,123 @@ +# Biological designs and provenance + +`lab.provenance` represents SBOL3 designs and the activities, agents, plans, and material realizations associated with them. Its objects are immutable Python dataclasses. A `Document` collects explicitly added objects, and `freeze()` produces a validated `DocumentSnapshot` with stable identities for owned children. The module imports and exports SBOL3 Turtle and interoperates with pySBOL3 without changing its global namespace or builder registry. + +## Author a document + +```python +from lab.provenance import Component, Document, Sequence +from lab.provenance.vocabulary import DNA, IUPAC_DNA + +document = Document(namespace="https://example.org/my_project") +sequence = Sequence( + identity=document.iri("sequence"), + elements="ACGTACGT", + encoding=IUPAC_DNA, +) +design = Component( + identity=document.iri("design"), + types=(DNA,), + sequences=(sequence.ref,), +) +document.add(sequence, design) +snapshot = document.freeze() +snapshot.write("build/design.ttl") +``` + +`Ref[T]` carries an absolute IRI and a Python target type. `design.ref` is a `Ref[Component]`; `snapshot.resolve(design.ref)` returns the corresponding component. `snapshot.get(identity, Component)` also checks the requested type at runtime. References never fetch remote resources. Adding an object does not automatically add its references. + +Collections use tuples. SBOL multi-valued properties are unordered RDF sets, so freezing sorts them deterministically. Represent biological order with locations and constraints. Tuple position does not specify an assembly recipe. + +`document.iri("design/feature")` constructs an IRI from slash-separated SBOL display IDs. It does not use a process-wide namespace. Top-level objects require explicit identities. Owned children such as `Usage`, `Association`, and `SubComponent` may omit theirs; freezing assigns names such as `planned_build/Usage1` in a new snapshot. Explicit child identities are preserved, and generated identities avoid existing ones. Give a child an explicit identity when another object needs to reference it before freezing. + +`Document.add()` accepts top-level objects and rejects conflicting definitions of an existing IRI. Adding an identical definition is idempotent. Edits use `dataclasses.replace()` and a new identity where they describe a new design or record. `Document.from_snapshot(snapshot)` creates an independent authoring document containing an existing snapshot's objects and annotations. + +## Model designs, materials, and activities + +| Objects | Purpose | +| --- | --- | +| `Sequence`, `Component` | Sequence data and structural or functional designs | +| `SubComponent`, `SequenceFeature`, `LocalSubComponent`, `ExternallyDefined`, `ComponentReference` | Features owned by a component | +| `Range`, `Cut`, `EntireSequence` | Locations on a referenced sequence | +| `Constraint`, `Interaction`, `Participation`, `Interface` | Structural relationships and functional interactions | +| `CombinatorialDerivation`, `VariableFeature`, `Collection` | Design families and grouped objects | +| `Implementation` | A planned, recorded, or simulated material realization | +| `Activity`, `Usage`, `Association` | Work, qualified input roles, and qualified agent roles | +| `Agent`, `Plan` | Who or what is involved, and the method identity | +| `Attachment`, `ExperimentalData`, `Experiment`, `Model`, `Measure` | Linked evidence, datasets, computational models, and quantities | + +Objects share `name`, `description`, `derived_from`, `generated_by`, and owned `measures`. Top-level objects also have `namespace` and attachment references. `Usage` and `Association` belong to an `Activity`; agents, plans, activities, and implementations are top-level objects. + +```python +from lab.provenance import ( + Activity, + Agent, + AgentKind, + Association, + EvidenceState, + Implementation, + Plan, + Usage, +) + +planner = Agent( + identity=document.iri("planner"), + kind=AgentKind.SOFTWARE, + software_version="0.1.0", +) +method = Plan(identity=document.iri("method")) +activity = Activity( + identity=document.iri("planned_build"), + usage=(Usage(entity=design.ref),), + association=(Association(agent=planner.ref, plan=method.ref),), + evidence_state=EvidenceState.PLANNED, +) +output = Implementation( + identity=document.iri("planned_output"), + derived_from=(design.ref,), + generated_by=(activity.ref,), + evidence_state=EvidenceState.PLANNED, +) +document.add(planner, method, activity, output) +``` + +`Implementation.derived_from` can identify the intended design. `built` describes the realized structure when that assertion is available. Planned implementations must leave `built` unset. An implementation's material inputs belong in the generating activity's usages; the model does not infer genetic lineage from every physical reagent contribution. + +Evidence states are `UNKNOWN`, `PLANNED`, `RECORDED`, and `SIMULATED`. A recorded inventory assertion does not imply sequence verification or a successful experiment. Imported SBOL without an evidence state remains unknown. Planned activities cannot have execution timestamps. Recorded and simulated activities can include timezone-aware `datetime` values, and end times must not precede start times. + +`Activity.types` contains ontology classifications encoded as `sbol:type`. `Activity.informed_by` references predecessor activities through `prov:wasInformedBy`; multiple activities can share a predecessor. `Plan.protocol` optionally links to a separately specified protocol IRI, including a LabOP protocol. The plan itself does not contain or execute a method body. + +## Import, validate, and export + +```python +document = Document.read("build/design.ttl") +document.validate().raise_for_errors() +snapshot = document.freeze() + +native = snapshot.to_sbol3() # A new, mutable pySBOL3 Document +native_report = native.validate() # pySBOL3's additional SBOL/SHACL checks +restored = Document.from_sbol3(native).freeze() +assert restored.digest == snapshot.digest +``` + +Input is local SBOL3 Turtle. SBOL2 input and unsupported SBOL classes or properties fail explicitly. Foreign RDF annotations, including language-tagged values and nested blank nodes, survive import and export. Unknown annotations remain RDF rather than becoming inferred Python fields. A mixed-namespace or empty import needs an explicit `namespace=` for authoring new objects. + +Lab checks field types, required values, unique identities and ownership, reference closure and target types, sequence bounds, feature reference scope, component containment and activity dependency cycles, and evidence assertions. `validate()` returns diagnostics with identity, field path, code, and message. `freeze()` raises `ProvenanceError` containing that report when checks fail. These checks are not a complete implementation of every SBOL specification rule; use the detached pySBOL3 document's validator for additional checks. + +Use `freeze(allow_external=True)` for intentionally incomplete graphs. References to objects that are present still undergo type validation; unresolved references remain unresolved. The module never creates placeholder objects or downloads referenced attachments. + +Turtle output is deterministic, with full IRIs and canonical blank-node identifiers. `snapshot.digest` is SHA-256 over that serialization, including retained annotations. This identifies the provenance document, independently of the compiler artifact digest. `write()` accepts an identical existing file and refuses to replace different contents. + +The adapter pins pySBOL3 `1.2.0.post0`. It handles `Activity.informed_by` at the RDF boundary because the upstream property uses ownership. It also corrects that release's mappings of `Cut.at` and `SubComponent.role_integration` on detached returned instances, while preserving the standard `sbol:at` and `sbol:roleIntegration` predicates. Importing an existing pySBOL3 document normalizes those known mappings without mutating the original. No global classes, namespaces, or builders are patched. + +The small Lab extension vocabulary is packaged at `lab/provenance/resources/lab.ttl`. It defines evidence state, agent kind, software version, and the link from a plan to a protocol. The namespace is an identifier; using it does not require a network lookup. + +## Run the example + +```sh +uv run --no-sync python -m examples.provenance --out build/provenance.ttl +``` + +[The example](../examples/provenance.py) builds a design, an inventory assertion, a prospective activity, and a planned output; checks the SBOL3 export; writes it; and verifies its round trip. It specifies no executable cloning method. The [experiment walkthrough](cloning-provenance.md) covers inventory planning, compiler integration, and LabOP protocol export; the [implementation status](provenance-implementation.md) lists the remaining integrations. + +The mappings follow the [SBOL 3.1 specification](https://sbolstandard.org/docs/SBOL3.1.0.pdf) and [pySBOL3's provenance model](https://raw.githubusercontent.com/SynBioDex/pySBOL3/main/sbol3/provenance.py). Protocol interchange uses [LabOP](https://bioprotocols.github.io/labop/). diff --git a/examples/cloning_provenance/README.md b/examples/cloning_provenance/README.md new file mode 100644 index 0000000..89d313f --- /dev/null +++ b/examples/cloning_provenance/README.md @@ -0,0 +1,11 @@ +# Cloning provenance walkthrough + +Open [cloning_provenance.ipynb](cloning_provenance.ipynb) for the worked reporter-reference example, from supplied material records through planning, readable SBOL and LabOP excerpts, and robot compilation. Saved outputs are included. + +From the repository root, launch it with: + +```sh +uv run --extra opentrons --extra star --with jupyterlab jupyter lab examples/cloning_provenance/cloning_provenance.ipynb +``` + +The notebook's [presentation helper](notebook_views.py) and [input files](data/reporter_reference/README.md) belong to this example. Generated bundles go under the repository's ignored `build/cloning-notebook/` directory. diff --git a/examples/cloning_provenance/cloning_provenance.ipynb b/examples/cloning_provenance/cloning_provenance.ipynb new file mode 100644 index 0000000..6dda04b --- /dev/null +++ b/examples/cloning_provenance/cloning_provenance.ipynb @@ -0,0 +1,1006 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "reporter-00", + "metadata": {}, + "source": [ + "# A reporter reference, from receipt to a protocol plan\n", + "\n", + "A lab is preparing a **reporter reference for a later fluorescence study**. The intended construct is *pReference-Green*, and the intended recipient is *host H1*. The immediate request is two deposited samples from the resulting culture; fluorescence measurement and clone verification will happen later.\n", + "\n", + "The project starts with an awkward detail: the vector arrived as **receipt R-042, lot V-17, in a bacterial stab**. The insert is already available as DNA. Someone must prepare vector DNA before a robot can use it.\n", + "\n", + "We will follow that one received material through the files: **what arrived → what is planned → what the protocol specifies → what the robot would do**. At each point, we will open the relevant Turtle rather than treat it as an opaque export.\n", + "\n", + "The study and records are illustrative. The dataset uses clearly marked dummy sequences and software-test method parameters, not a validated laboratory protocol. No robot is run in this notebook.\n" + ] + }, + { + "cell_type": "markdown", + "id": "reporter-01", + "metadata": {}, + "source": [ + "
\n", + "Run this notebook\n", + "

From the repository root, with Python 3.11 or 3.12:

\n", + "
uv run --extra opentrons --extra star --with jupyterlab jupyter lab examples/cloning_provenance/cloning_provenance.ipynb
\n", + "

Use the environment's Python kernel and run the cells in order. New artifacts go under the ignored build/cloning-notebook/ directory. Saved outputs can be read without running anything.

\n", + "
\n", + "\n", + "The [four input files](data/reporter_reference/README.md) are part of this example. Table formatting and Turtle selection live in [notebook_views.py](notebook_views.py), keeping the compiler calls visible here.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "reporter-02", + "metadata": { + "jupyter": { + "source_hidden": true + } + }, + "outputs": [], + "source": [ + "import notebook_views as view\n", + "from notebook_views import EX, LAB, LABOP, PROV, SBOL\n", + "from rdflib import RDF, Graph\n", + "\n", + "import lab\n", + "from lab.experiments import cloning\n", + "from lab.inventory import Inventory\n", + "from lab.operations import Transfer\n", + "from lab.provenance import Component, Document, EvidenceState\n", + "from lab.targets import LiquidHandler\n", + "\n", + "output = view.fresh_output()" + ] + }, + { + "cell_type": "markdown", + "id": "reporter-03", + "metadata": {}, + "source": [ + "## 1. What did the lab receive?\n", + "\n", + "Start with the designs and the material records. The vector **design** and the received **lot** are different objects. The inventory then says where that lot is and in what form it is available. Storage positions are distinct from the robot deck addresses assigned later.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "reporter-04", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
Material recordIntended designFormAvailableLocation
Host aliquot H1-03Host H1competent_cells20 µLCell box H1 / A1
Insert aliquot I-07Green reporter cassettedna10 µLDNA rack D1 / C2
Receipt R-042 / lot V-17pReference backbonebacterial_stab1 unitReceipt box / B3
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "input_ttl = view.CASE / \"designs-and-materials.ttl\"\n", + "document = Document.read(input_ttl, namespace=str(EX).rstrip(\"/\")).freeze()\n", + "inventory = Inventory.read(view.CASE / \"inventory.json\", document=document)\n", + "\n", + "view.inventory(inventory, document)" + ] + }, + { + "cell_type": "markdown", + "id": "reporter-05", + "metadata": {}, + "source": [ + "**Read the corresponding Turtle.** A `.ttl` file is a text representation of an RDF graph: statements with a subject, a relationship, and an object. Prefixes abbreviate full identifiers. `ex:received_stab` is one persistent identity, not a Python variable or a file path.\n", + "\n", + "The next cell selects real statements about the vector and its received material. It omits housekeeping fields and uses prefixes for readability; it does not change their meaning.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "reporter-06", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
@prefix prov: <http://www.w3.org/ns/prov#> .\n",
+       "@prefix ex: <https://example.org/reporter/> .\n",
+       "@prefix sbol: <http://sbols.org/v3#> .\n",
+       "@prefix lab: <https://the-lab-compiler.github.io/lab-py/ns#> .\n",
+       "\n",
+       "ex:received_stab a sbol:Implementation ;\n",
+       "    sbol:name \"Receipt R-042 / lot V-17\" ;\n",
+       "    prov:wasDerivedFrom ex:vector ;\n",
+       "    lab:evidenceState lab:recorded .\n",
+       "\n",
+       "ex:vector a sbol:Component ;\n",
+       "    sbol:name \"pReference backbone\" .\n",
+       "
\n" + ], + "text/latex": [ + "\\begin{Verbatim}[commandchars=\\\\\\{\\}]\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{prov:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}http://www.w3.org/ns/prov\\PYZsh{}\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{ex:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}https://example.org/reporter/\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{sbol:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}http://sbols.org/v3\\PYZsh{}\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{lab:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}https://the\\PYZhy{}lab\\PYZhy{}compiler.github.io/lab\\PYZhy{}py/ns\\PYZsh{}\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\n", + "\\PY{n+nn}{ex}\\PY{p}{:}\\PY{n+nt}{received\\PYZus{}stab} \\PY{k+kt}{a} \\PY{n+nn}{sbol}\\PY{p}{:}\\PY{n+nt}{Implementation} \\PY{p}{;}\n", + " \\PY{n+nn}{sbol}\\PY{p}{:}\\PY{n+nt}{name} \\PY{l+s}{\\PYZdq{}}\\PY{l+s}{Receipt R\\PYZhy{}042 / lot V\\PYZhy{}17}\\PY{l+s}{\\PYZdq{}} \\PY{p}{;}\n", + " \\PY{n+nn}{prov}\\PY{p}{:}\\PY{n+nt}{wasDerivedFrom} \\PY{n+nn}{ex}\\PY{p}{:}\\PY{n+nt}{vector} \\PY{p}{;}\n", + " \\PY{n+nn}{lab}\\PY{p}{:}\\PY{n+nt}{evidenceState} \\PY{n+nn}{lab}\\PY{p}{:}\\PY{n+nt}{recorded} \\PY{p}{.}\n", + "\n", + "\\PY{n+nn}{ex}\\PY{p}{:}\\PY{n+nt}{vector} \\PY{k+kt}{a} \\PY{n+nn}{sbol}\\PY{p}{:}\\PY{n+nt}{Component} \\PY{p}{;}\n", + " \\PY{n+nn}{sbol}\\PY{p}{:}\\PY{n+nt}{name} \\PY{l+s}{\\PYZdq{}}\\PY{l+s}{pReference backbone}\\PY{l+s}{\\PYZdq{}} \\PY{p}{.}\n", + "\\end{Verbatim}\n" + ], + "text/plain": [ + "@prefix prov: .\n", + "@prefix ex: .\n", + "@prefix sbol: .\n", + "@prefix lab: .\n", + "\n", + "ex:received_stab a sbol:Implementation ;\n", + " sbol:name \"Receipt R-042 / lot V-17\" ;\n", + " prov:wasDerivedFrom ex:vector ;\n", + " lab:evidenceState lab:recorded .\n", + "\n", + "ex:vector a sbol:Component ;\n", + " sbol:name \"pReference backbone\" ." + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "view.turtle(\n", + " input_ttl,\n", + " [str(EX.vector), str(EX.received_stab)],\n", + " predicates=(RDF.type, SBOL.name, PROV.wasDerivedFrom, LAB.evidenceState),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "reporter-07", + "metadata": {}, + "source": [ + "Read this as: **“Receipt R-042 is a material implementation associated with the pReference vector design.”**\n", + "\n", + "- `a sbol:Component` declares a design; `a sbol:Implementation` declares a material realization or intended realization.\n", + "- `prov:wasDerivedFrom` links the material record to its intended design. It does not establish that the sequence was verified.\n", + "- `lab:evidenceState lab:recorded` says this is a supplied inventory assertion in our example.\n", + "\n", + "A semicolon continues statements about the same subject; a period finishes that group. The storage position and the counted `bacterial_stab` form live in `inventory.json`. The TTL and inventory share the same implementation IRI.\n" + ] + }, + { + "cell_type": "markdown", + "id": "reporter-08", + "metadata": {}, + "source": [ + "## 2. What needs to happen before there are two samples?\n", + "\n", + "We request the intended H1/reference material. Two samples here means **two deposits from one upstream culture**, not two independent clones.\n", + "\n", + "The project supplies [routes](data/reporter_reference/system.json) and [method parameters](data/reporter_reference/methods.json) as separate inputs. One route explicitly converts a vector stab into vector DNA using external procedure **P01**. The planner may use that route; it may not silently treat the stab as pipettable DNA.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "reporter-09", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
OrderTaskExpected materialResponsible system
1Prepare vector DNAdna: 10 µLExternal procedure P01
2Assemble reporter designdna: 5 µLLiquid handler
3Introduce it into host H1culture: 5 µLLiquid handler
4Deposit a sampleplated_sample: 1 sampleLiquid handler
5Deposit a sampleplated_sample: 1 sampleLiquid handler
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/svg+xml": [ + "1. Preparationdna2. Assemblydna3. Transformationculture4. Platingplated_sample5. Platingplated_sample" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "strain = document.get(str(EX.strain), Component)\n", + "request = cloning.BuildRequest(\n", + " identity=str(EX.build),\n", + " targets=(cloning.CountTarget(design=strain.ref, count=2),),\n", + ")\n", + "plan = cloning.plan(\n", + " request,\n", + " document=document,\n", + " inventory=inventory,\n", + " system=cloning.CloningSystem.read(view.CASE / \"system.json\"),\n", + " methods=cloning.CloningMethods.read(view.CASE / \"methods.json\"),\n", + ")\n", + "plan.require_ready()\n", + "view.plan(plan)" + ] + }, + { + "cell_type": "markdown", + "id": "reporter-10", + "metadata": {}, + "source": [ + "The first stage is manual preparation; the remaining stages can be compiled for a liquid handler. Both deposits share the culture produced by the transformation stage. These are material dependencies; the protocol specifies an ordered stage list.\n", + "\n", + "**What did planning add to the SBOL graph?** Follow the new vector-DNA output back to its preparation activity, then through the activity's `Usage` to receipt R-042.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "reporter-11", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
@prefix prov: <http://www.w3.org/ns/prov#> .\n",
+       "@prefix ex: <https://example.org/reporter/> .\n",
+       "@prefix sbol: <http://sbols.org/v3#> .\n",
+       "@prefix lab: <https://the-lab-compiler.github.io/lab-py/ns#> .\n",
+       "@prefix build: <https://example.org/reporter/build/> .\n",
+       "@prefix prep: <https://example.org/reporter/build/preparation_1_6a0c3b428b5f/> .\n",
+       "\n",
+       "build:preparation_1_6a0c3b428b5f a prov:Activity ;\n",
+       "    prov:qualifiedUsage prep:Usage1 ;\n",
+       "    lab:evidenceState lab:planned .\n",
+       "\n",
+       "prep:Usage1 a prov:Usage ;\n",
+       "    prov:entity ex:received_stab .\n",
+       "\n",
+       "prep:output a sbol:Implementation ;\n",
+       "    prov:wasDerivedFrom ex:vector ;\n",
+       "    prov:wasGeneratedBy build:preparation_1_6a0c3b428b5f ;\n",
+       "    lab:evidenceState lab:planned .\n",
+       "
\n" + ], + "text/latex": [ + "\\begin{Verbatim}[commandchars=\\\\\\{\\}]\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{prov:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}http://www.w3.org/ns/prov\\PYZsh{}\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{ex:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}https://example.org/reporter/\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{sbol:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}http://sbols.org/v3\\PYZsh{}\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{lab:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}https://the\\PYZhy{}lab\\PYZhy{}compiler.github.io/lab\\PYZhy{}py/ns\\PYZsh{}\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{build:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}https://example.org/reporter/build/\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{prep:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}https://example.org/reporter/build/preparation\\PYZus{}1\\PYZus{}6a0c3b428b5f/\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\n", + "\\PY{n+nn}{build}\\PY{p}{:}\\PY{n+nt}{preparation\\PYZus{}1\\PYZus{}6a0c3b428b5f} \\PY{k+kt}{a} \\PY{n+nn}{prov}\\PY{p}{:}\\PY{n+nt}{Activity} \\PY{p}{;}\n", + " \\PY{n+nn}{prov}\\PY{p}{:}\\PY{n+nt}{qualifiedUsage} \\PY{n+nn}{prep}\\PY{p}{:}\\PY{n+nt}{Usage1} \\PY{p}{;}\n", + " \\PY{n+nn}{lab}\\PY{p}{:}\\PY{n+nt}{evidenceState} \\PY{n+nn}{lab}\\PY{p}{:}\\PY{n+nt}{planned} \\PY{p}{.}\n", + "\n", + "\\PY{n+nn}{prep}\\PY{p}{:}\\PY{n+nt}{Usage1} \\PY{k+kt}{a} \\PY{n+nn}{prov}\\PY{p}{:}\\PY{n+nt}{Usage} \\PY{p}{;}\n", + " \\PY{n+nn}{prov}\\PY{p}{:}\\PY{n+nt}{entity} \\PY{n+nn}{ex}\\PY{p}{:}\\PY{n+nt}{received\\PYZus{}stab} \\PY{p}{.}\n", + "\n", + "\\PY{n+nn}{prep}\\PY{p}{:}\\PY{n+nt}{output} \\PY{k+kt}{a} \\PY{n+nn}{sbol}\\PY{p}{:}\\PY{n+nt}{Implementation} \\PY{p}{;}\n", + " \\PY{n+nn}{prov}\\PY{p}{:}\\PY{n+nt}{wasDerivedFrom} \\PY{n+nn}{ex}\\PY{p}{:}\\PY{n+nt}{vector} \\PY{p}{;}\n", + " \\PY{n+nn}{prov}\\PY{p}{:}\\PY{n+nt}{wasGeneratedBy} \\PY{n+nn}{build}\\PY{p}{:}\\PY{n+nt}{preparation\\PYZus{}1\\PYZus{}6a0c3b428b5f} \\PY{p}{;}\n", + " \\PY{n+nn}{lab}\\PY{p}{:}\\PY{n+nt}{evidenceState} \\PY{n+nn}{lab}\\PY{p}{:}\\PY{n+nt}{planned} \\PY{p}{.}\n", + "\\end{Verbatim}\n" + ], + "text/plain": [ + "@prefix prov: .\n", + "@prefix ex: .\n", + "@prefix sbol: .\n", + "@prefix lab: .\n", + "@prefix build: .\n", + "@prefix prep: .\n", + "\n", + "build:preparation_1_6a0c3b428b5f a prov:Activity ;\n", + " prov:qualifiedUsage prep:Usage1 ;\n", + " lab:evidenceState lab:planned .\n", + "\n", + "prep:Usage1 a prov:Usage ;\n", + " prov:entity ex:received_stab .\n", + "\n", + "prep:output a sbol:Implementation ;\n", + " prov:wasDerivedFrom ex:vector ;\n", + " prov:wasGeneratedBy build:preparation_1_6a0c3b428b5f ;\n", + " lab:evidenceState lab:planned ." + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "preparation = plan.tasks[0]\n", + "planning_ttl = plan.document.write(output / \"build-provenance.ttl\")\n", + "view.turtle(\n", + " planning_ttl,\n", + " [preparation.output.identity, preparation.identity],\n", + " predicates=(\n", + " RDF.type,\n", + " PROV.wasDerivedFrom,\n", + " PROV.wasGeneratedBy,\n", + " PROV.qualifiedUsage,\n", + " PROV.entity,\n", + " LAB.evidenceState,\n", + " ),\n", + " follow=(PROV.qualifiedUsage,),\n", + " prefixes={\"build\": request.identity + \"/\", \"prep\": preparation.identity + \"/\"},\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "reporter-12", + "metadata": {}, + "source": [ + "`prov:wasGeneratedBy` identifies the preparation activity that would produce the new implementation. `prov:qualifiedUsage` points to a usage record whose `prov:entity` is the received stab. The output still refers to the same vector design, but it has **a new material identity**.\n", + "\n", + "Here `lab:planned` matters: these edges describe intended work. They do not say P01 has happened. There is no `sbol:built` assertion because no resulting structure has been verified. `require_ready()` established that the route and required inputs were resolved, not that the bench work was complete.\n" + ] + }, + { + "cell_type": "markdown", + "id": "reporter-13", + "metadata": {}, + "source": [ + "## 3. What would the robot do with the prepared DNA?\n", + "\n", + "Build the experiment and compile it for an OT-2. Preparation stays a manual stage. We will inspect one operation in the next stage: the transfer from the prepared vector material into the assembly mixture.\n", + "\n", + "The arbitrary quantities below belong to the software fixture. Our focus is the link between a material, a logical operation, and its emitted code.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "reporter-14", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": [ + "`protocol.py`, lines **24–30**" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
    # lab:step https://example.org/reporter/build/assembly_1_90ae1a318f82/protocol/step_2\n",
+       "    context.comment('Transfer 1 µL from upstream_1:A1 to products:A1.')\n",
+       "    pipette.pick_up_tip(labware_4.wells_by_name()['B1'])\n",
+       "    pipette.aspirate(1, labware_3.wells_by_name()['A1'])\n",
+       "    pipette.dispense(1, labware_2.wells_by_name()['A1'])\n",
+       "    pipette.drop_tip()\n",
+       "    # lab:end https://example.org/reporter/build/assembly_1_90ae1a318f82/protocol/step_2\n",
+       "
\n" + ], + "text/latex": [ + "\\begin{Verbatim}[commandchars=\\\\\\{\\}]\n", + " \\PY{c+c1}{\\PYZsh{} lab:step https://example.org/reporter/build/assembly\\PYZus{}1\\PYZus{}90ae1a318f82/protocol/step\\PYZus{}2}\n", + " \\PY{n}{context}\\PY{o}{.}\\PY{n}{comment}\\PY{p}{(}\\PY{l+s+s1}{\\PYZsq{}}\\PY{l+s+s1}{Transfer 1 µL from upstream\\PYZus{}1:A1 to products:A1.}\\PY{l+s+s1}{\\PYZsq{}}\\PY{p}{)}\n", + " \\PY{n}{pipette}\\PY{o}{.}\\PY{n}{pick\\PYZus{}up\\PYZus{}tip}\\PY{p}{(}\\PY{n}{labware\\PYZus{}4}\\PY{o}{.}\\PY{n}{wells\\PYZus{}by\\PYZus{}name}\\PY{p}{(}\\PY{p}{)}\\PY{p}{[}\\PY{l+s+s1}{\\PYZsq{}}\\PY{l+s+s1}{B1}\\PY{l+s+s1}{\\PYZsq{}}\\PY{p}{]}\\PY{p}{)}\n", + " \\PY{n}{pipette}\\PY{o}{.}\\PY{n}{aspirate}\\PY{p}{(}\\PY{l+m+mi}{1}\\PY{p}{,} \\PY{n}{labware\\PYZus{}3}\\PY{o}{.}\\PY{n}{wells\\PYZus{}by\\PYZus{}name}\\PY{p}{(}\\PY{p}{)}\\PY{p}{[}\\PY{l+s+s1}{\\PYZsq{}}\\PY{l+s+s1}{A1}\\PY{l+s+s1}{\\PYZsq{}}\\PY{p}{]}\\PY{p}{)}\n", + " \\PY{n}{pipette}\\PY{o}{.}\\PY{n}{dispense}\\PY{p}{(}\\PY{l+m+mi}{1}\\PY{p}{,} \\PY{n}{labware\\PYZus{}2}\\PY{o}{.}\\PY{n}{wells\\PYZus{}by\\PYZus{}name}\\PY{p}{(}\\PY{p}{)}\\PY{p}{[}\\PY{l+s+s1}{\\PYZsq{}}\\PY{l+s+s1}{A1}\\PY{l+s+s1}{\\PYZsq{}}\\PY{p}{]}\\PY{p}{)}\n", + " \\PY{n}{pipette}\\PY{o}{.}\\PY{n}{drop\\PYZus{}tip}\\PY{p}{(}\\PY{p}{)}\n", + " \\PY{c+c1}{\\PYZsh{} lab:end https://example.org/reporter/build/assembly\\PYZus{}1\\PYZus{}90ae1a318f82/protocol/step\\PYZus{}2}\n", + "\\end{Verbatim}\n" + ], + "text/plain": [ + " # lab:step https://example.org/reporter/build/assembly_1_90ae1a318f82/protocol/step_2\n", + " context.comment('Transfer 1 µL from upstream_1:A1 to products:A1.')\n", + " pipette.pick_up_tip(labware_4.wells_by_name()['B1'])\n", + " pipette.aspirate(1, labware_3.wells_by_name()['A1'])\n", + " pipette.dispense(1, labware_2.wells_by_name()['A1'])\n", + " pipette.drop_tip()\n", + " # lab:end https://example.org/reporter/build/assembly_1_90ae1a318f82/protocol/step_2" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "experiment = cloning.build(plan)\n", + "compiled = lab.compile(experiment, LiquidHandler.OT2)\n", + "compiled.write(output / \"ot2\")\n", + "\n", + "assembly = compiled.stages[1]\n", + "transfer = next(\n", + " step\n", + " for step in assembly.protocol.steps\n", + " if isinstance(step, Transfer) and step.source.resource == \"upstream_1\"\n", + ")\n", + "view.source(assembly, transfer.identity)" + ] + }, + { + "cell_type": "markdown", + "id": "reporter-15", + "metadata": {}, + "source": [ + "The `lab:step` comment is a stable operation identity. The source map connects that identity to these exact lines. Well bindings and robot API calls belong to the target compilation.\n", + "\n", + "**The same operation in `protocol.labop.ttl`** is a behavior call. This excerpt follows just its amount parameter; source and destination sample selections and their object-flow edges remain in the full file.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "reporter-16", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n",
+       "@prefix sbol: <http://sbols.org/v3#> .\n",
+       "@prefix uml: <http://bioprotocols.org/uml#> .\n",
+       "@prefix om: <http://www.ontology-of-units-of-measure.org/resource/om-2/> .\n",
+       "@prefix stage: <https://example.org/reporter/build/assembly_1_90ae1a318f82/protocol/> .\n",
+       "@prefix step: <https://example.org/reporter/build/assembly_1_90ae1a318f82/protocol/step_2/> .\n",
+       "@prefix pin: <https://example.org/reporter/build/assembly_1_90ae1a318f82/protocol/step_2/input_amount/> .\n",
+       "@prefix amount: <https://example.org/reporter/build/assembly_1_90ae1a318f82/protocol/step_2/input_amount/value/> .\n",
+       "@prefix liquid: <https://bioprotocols.org/labop/primitives/liquid_handling/> .\n",
+       "\n",
+       "stage:step_2 a uml:CallBehaviorAction ;\n",
+       "    uml:behavior liquid:Transfer ;\n",
+       "    uml:input step:input_amount .\n",
+       "\n",
+       "step:input_amount a uml:ValuePin ;\n",
+       "    uml:value pin:value ;\n",
+       "    sbol:name \"amount\" .\n",
+       "\n",
+       "pin:value a uml:LiteralIdentified ;\n",
+       "    uml:identifiedValue amount:measure .\n",
+       "\n",
+       "amount:measure a om:Measure ;\n",
+       "    om:hasNumericalValue \"1\"^^xsd:decimal ;\n",
+       "    om:hasUnit om:microlitre .\n",
+       "
\n" + ], + "text/latex": [ + "\\begin{Verbatim}[commandchars=\\\\\\{\\}]\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{xsd:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}http://www.w3.org/2001/XMLSchema\\PYZsh{}\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{sbol:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}http://sbols.org/v3\\PYZsh{}\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{uml:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}http://bioprotocols.org/uml\\PYZsh{}\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{om:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}http://www.ontology\\PYZhy{}of\\PYZhy{}units\\PYZhy{}of\\PYZhy{}measure.org/resource/om\\PYZhy{}2/\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{stage:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}https://example.org/reporter/build/assembly\\PYZus{}1\\PYZus{}90ae1a318f82/protocol/\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{step:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}https://example.org/reporter/build/assembly\\PYZus{}1\\PYZus{}90ae1a318f82/protocol/step\\PYZus{}2/\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{pin:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}https://example.org/reporter/build/assembly\\PYZus{}1\\PYZus{}90ae1a318f82/protocol/step\\PYZus{}2/input\\PYZus{}amount/\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{amount:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}https://example.org/reporter/build/assembly\\PYZus{}1\\PYZus{}90ae1a318f82/protocol/step\\PYZus{}2/input\\PYZus{}amount/value/\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\\PY{k}{@prefix}\\PY{+w}{ }\\PY{n+nn}{liquid:}\\PY{+w}{ }\\PY{n+nv}{\\PYZlt{}https://bioprotocols.org/labop/primitives/liquid\\PYZus{}handling/\\PYZgt{}}\\PY{+w}{ }\\PY{p}{.}\n", + "\n", + "\\PY{n+nn}{stage}\\PY{p}{:}\\PY{n+nt}{step\\PYZus{}2} \\PY{k+kt}{a} \\PY{n+nn}{uml}\\PY{p}{:}\\PY{n+nt}{CallBehaviorAction} \\PY{p}{;}\n", + " \\PY{n+nn}{uml}\\PY{p}{:}\\PY{n+nt}{behavior} \\PY{n+nn}{liquid}\\PY{p}{:}\\PY{n+nt}{Transfer} \\PY{p}{;}\n", + " \\PY{n+nn}{uml}\\PY{p}{:}\\PY{n+nt}{input} \\PY{n+nn}{step}\\PY{p}{:}\\PY{n+nt}{input\\PYZus{}amount} \\PY{p}{.}\n", + "\n", + "\\PY{n+nn}{step}\\PY{p}{:}\\PY{n+nt}{input\\PYZus{}amount} \\PY{k+kt}{a} \\PY{n+nn}{uml}\\PY{p}{:}\\PY{n+nt}{ValuePin} \\PY{p}{;}\n", + " \\PY{n+nn}{uml}\\PY{p}{:}\\PY{n+nt}{value} \\PY{n+nn}{pin}\\PY{p}{:}\\PY{n+nt}{value} \\PY{p}{;}\n", + " \\PY{n+nn}{sbol}\\PY{p}{:}\\PY{n+nt}{name} \\PY{l+s}{\\PYZdq{}}\\PY{l+s}{amount}\\PY{l+s}{\\PYZdq{}} \\PY{p}{.}\n", + "\n", + "\\PY{n+nn}{pin}\\PY{p}{:}\\PY{n+nt}{value} \\PY{k+kt}{a} \\PY{n+nn}{uml}\\PY{p}{:}\\PY{n+nt}{LiteralIdentified} \\PY{p}{;}\n", + " \\PY{n+nn}{uml}\\PY{p}{:}\\PY{n+nt}{identifiedValue} \\PY{n+nn}{amount}\\PY{p}{:}\\PY{n+nt}{measure} \\PY{p}{.}\n", + "\n", + "\\PY{n+nn}{amount}\\PY{p}{:}\\PY{n+nt}{measure} \\PY{k+kt}{a} \\PY{n+nn}{om}\\PY{p}{:}\\PY{n+nt}{Measure} \\PY{p}{;}\n", + " \\PY{n+nn}{om}\\PY{p}{:}\\PY{n+nt}{hasNumericalValue} \\PY{l+s}{\\PYZdq{}}\\PY{l+s}{1}\\PY{l+s}{\\PYZdq{}}\\PY{p}{\\PYZca{}}\\PY{p}{\\PYZca{}}\\PY{n+nn}{xsd}\\PY{p}{:}\\PY{n+nt}{decimal} \\PY{p}{;}\n", + " \\PY{n+nn}{om}\\PY{p}{:}\\PY{n+nt}{hasUnit} \\PY{n+nn}{om}\\PY{p}{:}\\PY{n+nt}{microlitre} \\PY{p}{.}\n", + "\\end{Verbatim}\n" + ], + "text/plain": [ + "@prefix xsd: .\n", + "@prefix sbol: .\n", + "@prefix uml: .\n", + "@prefix om: .\n", + "@prefix stage: .\n", + "@prefix step: .\n", + "@prefix pin: .\n", + "@prefix amount: .\n", + "@prefix liquid: .\n", + "\n", + "stage:step_2 a uml:CallBehaviorAction ;\n", + " uml:behavior liquid:Transfer ;\n", + " uml:input step:input_amount .\n", + "\n", + "step:input_amount a uml:ValuePin ;\n", + " uml:value pin:value ;\n", + " sbol:name \"amount\" .\n", + "\n", + "pin:value a uml:LiteralIdentified ;\n", + " uml:identifiedValue amount:measure .\n", + "\n", + "amount:measure a om:Measure ;\n", + " om:hasNumericalValue \"1\"^^xsd:decimal ;\n", + " om:hasUnit om:microlitre ." + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "view.labop_amount(output / \"ot2/protocol.labop.ttl\", transfer.identity)" + ] + }, + { + "cell_type": "markdown", + "id": "reporter-17", + "metadata": {}, + "source": [ + "Follow the chain in the excerpt:\n", + "\n", + "1. `uml:behavior liquid:Transfer` names the operation's meaning.\n", + "2. `uml:input` links the action to a parameter pin named `amount`.\n", + "3. `uml:value` and `uml:identifiedValue` lead to a measure.\n", + "4. `om:hasNumericalValue` and `om:hasUnit` carry its quantity and unit explicitly. `\"1\"^^xsd:decimal` is a typed numeric value.\n", + "\n", + "This is why the LabOP file is more than a prose description. Another implementation can interpret the operation and its parameters independently of OT-2 deck slots. The full document also includes stage calls, sample collections, and control flow. Operations without an exact upstream primitive use explicit Lab extensions, whose schemas travel with the bundle.\n", + "\n", + "**SBOL answers “which materials, designs, and provenance?” LabOP answers “which operations and flows?”** Both refer to this same experiment. Compilation does not invoke a LabOP execution engine.\n" + ] + }, + { + "cell_type": "markdown", + "id": "reporter-26", + "metadata": {}, + "source": [ + "## 4. What is in the compilation bundle?\n", + "\n", + "The bundle preserves the planned procedure and connects its representations:\n", + "\n", + "| File | Question it answers |\n", + "|---|---|\n", + "| `ot2/provenance.ttl` | Which designs and materials does the planned experiment connect? |\n", + "| `ot2/protocol.labop.ttl` | What procedure is specified, including operations, parameters, and material and control flow? |\n", + "| `ot2/methods.md` | How can a reader inspect the planned procedure in prose? |\n", + "| `ot2/stages//protocol.py` | What program implements an automated stage for the selected robot? |\n", + "\n", + "These artifacts specify the intended preparation, assembly, transformation, and plating. Expected yields and products remain part of the plan; compilation does not establish that the reporter was built, colonies were verified, or fluorescence was measured.\n", + "\n", + "The generated artifacts below preserve the complete graphs. The notebook's excerpts are selected triples from those files, reserialized with prefixes; the compiler's canonical files use full IRIs for deterministic output.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "reporter-27", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": [ + "- [Planned SBOL](../../build/cloning-notebook/reporter-675dh7jn/ot2/provenance.ttl)\n", + "- [LabOP procedure](../../build/cloning-notebook/reporter-675dh7jn/ot2/protocol.labop.ttl)\n", + "- [Planned methods](../../build/cloning-notebook/reporter-675dh7jn/ot2/methods.md)" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "view.links(\n", + " output,\n", + " {\n", + " \"Planned SBOL\": \"ot2/provenance.ttl\",\n", + " \"LabOP procedure\": \"ot2/protocol.labop.ttl\",\n", + " \"Planned methods\": \"ot2/methods.md\",\n", + " },\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "reporter-28", + "metadata": {}, + "source": [ + "## Optional: other robots and integrity checks\n", + "\n", + "The same `experiment` can be compiled for Flex or STAR. The target-specific source and deck configuration change; the semantic protocols and LabOP remain shared. The cell below also verifies checksums and SBOL validation. These checks establish software-artifact consistency, not experimental success or physical instrument qualification.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "reporter-29", + "metadata": { + "jupyter": { + "source_hidden": true + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
BundleVerified file checksums
ot244
flex44
star44
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "SBOL validation passed; the planned LabOP protocol is identical across all three targets.\n" + ] + } + ], + "source": [ + "for hardware in (LiquidHandler.FLEX, LiquidHandler.STAR):\n", + " alternative = lab.compile(experiment, hardware)\n", + " alternative.write(output / hardware.value)\n", + " assert alternative.files[\"protocol.labop.ttl\"] == compiled.files[\"protocol.labop.ttl\"]\n", + "\n", + "view.table(\n", + " (\"Bundle\", \"Verified file checksums\"),\n", + " [(name, view.check_bundle(output / name)) for name in (\"ot2\", \"flex\", \"star\")],\n", + ")\n", + "assert not experiment.provenance.to_sbol3().validate().errors\n", + "prepared = experiment.provenance.resolve(preparation.output)\n", + "assert prepared.built is None and prepared.evidence_state is EvidenceState.PLANNED\n", + "protocol_graph = Graph().parse(output / \"ot2/protocol.labop.ttl\", format=\"turtle\")\n", + "assert not tuple(protocol_graph.subjects(RDF.type, LABOP.ProtocolExecution))\n", + "print(\"SBOL validation passed; the planned LabOP protocol is identical across all three targets.\")" + ] + }, + { + "cell_type": "markdown", + "id": "reporter-30", + "metadata": {}, + "source": [ + "The [case README](data/reporter_reference/README.md) explains every input file and the use of dummy sequences. The [provenance guide](../../docs/provenance.md) and [cloning guide](../../docs/cloning-provenance.md) provide the full Python APIs.\n", + "\n", + "When opening this notebook in another checkout, run it to regenerate the artifact links locally. Input files and saved cell outputs are included in the repository.\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/cloning_provenance/data/reporter_reference/README.md b/examples/cloning_provenance/data/reporter_reference/README.md new file mode 100644 index 0000000..be85105 --- /dev/null +++ b/examples/cloning_provenance/data/reporter_reference/README.md @@ -0,0 +1,18 @@ +# Reporter-reference case + +An illustrative lab wants to prepare two deposited samples of a reporter reference for a later fluorescence study. Its starting records include vector receipt R-042 (lot V-17), insert aliquot I-07, and host aliquot H1-03. Procedure P01 is an external preparation boundary: the vector arrives as a counted stab, not an available DNA aliquot. + +The names, receipt, lots, locations, and study are fictional. This case supplies realistic record-keeping relationships, not a reproduced experiment. The short DNA strings are compiler surrogates, not the sequences of an actual reporter or plasmid; quantities, cut selections, and method parameters are the arbitrary software fixture from `examples/cloning_workflow.py`. No sequence or fluorescence verification is asserted. + +## Input files + +| File | What it supplies | +|---|---| +| `designs-and-materials.ttl` | SBOL3 designs, named material implementations, and their asserted relationships. Readable Turtle with namespace prefixes. | +| `inventory.json` | Available material forms, quantities, and storage locations, referencing the SBOL identities. | +| `system.json` | Explicit preparation, assembly, transformation, and plating routes. | +| `methods.json` | The method identity and parameters selected by each route. | + +The notebook reads these files directly using `Document.read`, `Inventory.read`, `CloningSystem.read`, and `CloningMethods.read`. It does not substitute objects held inside a display helper. The presentation helpers in [notebook_views.py](../../notebook_views.py) inspect the resulting objects and RDF; their Turtle excerpts are checked to contain only triples from the named source file. + +The walkthrough follows receipt R-042 through the planned preparation and downstream protocol. Its generated SBOL, LabOP, methods, and robot programs describe the intended work and expected outputs. diff --git a/examples/cloning_provenance/data/reporter_reference/designs-and-materials.ttl b/examples/cloning_provenance/data/reporter_reference/designs-and-materials.ttl new file mode 100644 index 0000000..7534e43 --- /dev/null +++ b/examples/cloning_provenance/data/reporter_reference/designs-and-materials.ttl @@ -0,0 +1,136 @@ +@prefix edam: . +@prefix ex: . +@prefix lab: . +@prefix prov: . +@prefix sbo: . +@prefix sbol: . +@prefix so: . + + a sbol:Implementation ; + sbol:description "Illustrative reagent stock." ; + sbol:displayId "material" ; + sbol:hasNamespace ; + sbol:name "broth/material" ; + prov:wasDerivedFrom ex:broth ; + lab:evidenceState lab:recorded . + + a sbol:Implementation ; + sbol:description "Illustrative reagent stock." ; + sbol:displayId "implementation" ; + sbol:hasNamespace ; + sbol:name "buffer/implementation" ; + prov:wasDerivedFrom ex:buffer ; + lab:evidenceState lab:recorded . + + a sbol:Implementation ; + sbol:description "Illustrative aliquot in cell box H1, position A1." ; + sbol:displayId "material" ; + sbol:hasNamespace ; + sbol:name "Host aliquot H1-03" ; + prov:wasDerivedFrom ex:cells ; + lab:evidenceState lab:recorded . + + a sbol:Implementation ; + sbol:description "Illustrative aliquot in DNA rack D1, position C2." ; + sbol:displayId "implementation" ; + sbol:hasNamespace ; + sbol:name "Insert aliquot I-07" ; + prov:wasDerivedFrom ex:insert ; + lab:evidenceState lab:recorded . + +ex:received_stab a sbol:Implementation ; + sbol:description "Illustrative received vector stab; logged in receipt box B3." ; + sbol:displayId "received_stab" ; + sbol:hasNamespace ; + sbol:name "Receipt R-042 / lot V-17" ; + prov:wasDerivedFrom ex:vector ; + lab:evidenceState lab:recorded . + +ex:strain a sbol:Component ; + sbol:description "Intended reporter reference material; no fluorescence or sequence verification is asserted." ; + sbol:displayId "strain" ; + sbol:hasNamespace ; + sbol:name "H1 / pReference-Green" ; + sbol:type . + +ex:substrate a sbol:Component ; + sbol:description "Supplied consumable; automatic consumable reservation is outside this example." ; + sbol:displayId "substrate" ; + sbol:hasNamespace ; + sbol:name "Deposition substrate" ; + sbol:type sbo:0000247 . + +ex:target a sbol:Component ; + sbol:description "Intended reporter reference construct for the illustrative study." ; + sbol:displayId "target" ; + sbol:hasNamespace ; + sbol:name "pReference-Green" ; + sbol:type sbo:0000251, + so:0000988 . + + a sbol:Implementation ; + sbol:description "Illustrative reagent stock." ; + sbol:displayId "implementation" ; + sbol:hasNamespace ; + sbol:name "water/implementation" ; + prov:wasDerivedFrom ex:water ; + lab:evidenceState lab:recorded . + +ex:broth a sbol:Component ; + sbol:description "Supplied reagent in the illustrative inventory." ; + sbol:displayId "broth" ; + sbol:hasNamespace ; + sbol:name "Recovery and dilution medium" ; + sbol:type sbo:0000247 . + +ex:buffer a sbol:Component ; + sbol:description "Supplied reagent in the illustrative inventory." ; + sbol:displayId "buffer" ; + sbol:hasNamespace ; + sbol:name "Assembly buffer" ; + sbol:type sbo:0000247 . + +ex:cells a sbol:Component ; + sbol:description "A named recipient stock in the illustrative laboratory record." ; + sbol:displayId "cells" ; + sbol:hasNamespace ; + sbol:name "Host H1" ; + sbol:type . + +ex:insert a sbol:Component ; + sbol:description "Illustrative cassette design; the sequence is a compiler surrogate, not a reporter coding sequence." ; + sbol:displayId "insert" ; + sbol:hasNamespace ; + sbol:hasSequence ex:insert_sequence ; + sbol:name "Green reporter cassette" ; + sbol:type sbo:0000251, + so:0000987 . + +ex:insert_sequence a sbol:Sequence ; + sbol:displayId "insert_sequence" ; + sbol:elements "GGGAATTCCCCGAATTCGG" ; + sbol:encoding edam:format_1207 ; + sbol:hasNamespace . + +ex:vector a sbol:Component ; + sbol:description "Illustrative reporter-study design; its short sequence is a compiler surrogate, not a usable plasmid." ; + sbol:displayId "vector" ; + sbol:hasNamespace ; + sbol:hasSequence ex:vector_sequence ; + sbol:name "pReference backbone" ; + sbol:type sbo:0000251, + so:0000988 . + +ex:vector_sequence a sbol:Sequence ; + sbol:displayId "vector_sequence" ; + sbol:elements "AAAAGAATTCTTTT" ; + sbol:encoding edam:format_1207 ; + sbol:hasNamespace . + +ex:water a sbol:Component ; + sbol:description "Supplied reagent in the illustrative inventory." ; + sbol:displayId "water" ; + sbol:hasNamespace ; + sbol:name "Diluent" ; + sbol:type sbo:0000247 . + diff --git a/examples/cloning_provenance/data/reporter_reference/inventory.json b/examples/cloning_provenance/data/reporter_reference/inventory.json new file mode 100644 index 0000000..49153fa --- /dev/null +++ b/examples/cloning_provenance/data/reporter_reference/inventory.json @@ -0,0 +1,109 @@ +{ + "format": "lab.inventory.v1", + "inventory": { + "identity": "https://example.org/reporter/integrated_inventory", + "stocks": [ + { + "concentration_ng_ul": null, + "design": { + "identity": "https://example.org/reporter/broth" + }, + "form": "reagent", + "identity": "https://example.org/reporter/broth/stock", + "implementation": { + "identity": "https://example.org/reporter/broth/material" + }, + "location": { + "container": "Reagent rack", + "position": "broth" + }, + "quantity_ul": "20", + "supplier_item": null + }, + { + "concentration_ng_ul": null, + "design": { + "identity": "https://example.org/reporter/buffer" + }, + "form": "reagent", + "identity": "https://example.org/reporter/buffer/stock", + "implementation": { + "identity": "https://example.org/reporter/buffer/implementation" + }, + "location": { + "container": "Reagent rack", + "position": "buffer" + }, + "quantity_ul": "10", + "supplier_item": null + }, + { + "concentration_ng_ul": null, + "design": { + "identity": "https://example.org/reporter/cells" + }, + "form": "competent_cells", + "identity": "https://example.org/reporter/cells/stock", + "implementation": { + "identity": "https://example.org/reporter/cells/material" + }, + "location": { + "container": "Cell box H1", + "position": "A1" + }, + "quantity_ul": "20", + "supplier_item": null + }, + { + "concentration_ng_ul": null, + "design": { + "identity": "https://example.org/reporter/insert" + }, + "form": "dna", + "identity": "https://example.org/reporter/insert/stock", + "implementation": { + "identity": "https://example.org/reporter/insert/implementation" + }, + "location": { + "container": "DNA rack D1", + "position": "C2" + }, + "quantity_ul": "10", + "supplier_item": null + }, + { + "count": 1, + "design": { + "identity": "https://example.org/reporter/vector" + }, + "form": "bacterial_stab", + "identity": "https://example.org/reporter/stab_stock", + "implementation": { + "identity": "https://example.org/reporter/received_stab" + }, + "location": { + "container": "Receipt box", + "position": "B3" + }, + "supplier_item": null + }, + { + "concentration_ng_ul": null, + "design": { + "identity": "https://example.org/reporter/water" + }, + "form": "reagent", + "identity": "https://example.org/reporter/water/stock", + "implementation": { + "identity": "https://example.org/reporter/water/implementation" + }, + "location": { + "container": "Reagent rack", + "position": "water" + }, + "quantity_ul": "10", + "supplier_item": null + } + ] + } +} diff --git a/examples/cloning_provenance/data/reporter_reference/methods.json b/examples/cloning_provenance/data/reporter_reference/methods.json new file mode 100644 index 0000000..663cfcf --- /dev/null +++ b/examples/cloning_provenance/data/reporter_reference/methods.json @@ -0,0 +1,97 @@ +{ + "format": "lab.cloning-methods.v1", + "methods": { + "assemblies": [ + { + "cycles": 1, + "diluent": { + "identity": "https://example.org/reporter/water" + }, + "dna_volume_ul": "1", + "enzyme": "EcoRI", + "identity": "https://example.org/reporter/method", + "lid_celsius": null, + "mix_cycles": 1, + "mix_volume_ul": "2", + "output_volume_ul": "5", + "profile": [ + { + "celsius": "25", + "seconds": "1" + } + ], + "reaction_volume_ul": "5", + "reagents": [ + { + "component": { + "identity": "https://example.org/reporter/buffer" + }, + "volume_ul": "1" + } + ] + } + ], + "platings": [ + { + "diluent": { + "identity": "https://example.org/reporter/broth" + }, + "dilution_factors": [ + "2", + "3" + ], + "identity": "https://example.org/reporter/plating_method", + "mix_cycles": 1, + "mix_volume_ul": "1", + "spot_height_mm": "2", + "spot_volume_ul": "1", + "substrate": { + "identity": "https://example.org/reporter/substrate" + }, + "transfer_volume_ul": "1" + } + ], + "preparations": [ + { + "identity": "https://example.org/reporter/preparation_method", + "instructions": "Follow the supplied preparation procedure P01; record the recovered material identity and measured quantity.", + "output_count": 0, + "output_volume_ul": "10", + "procedure": "https://example.org/reporter/SOP_P01", + "reagents": [], + "source_count": 1, + "source_volume_ul": "0" + } + ], + "transformations": [ + { + "cell_mix_cycles": 1, + "cell_mix_volume_ul": "1", + "cell_volume_ul": "2", + "dna_mix_cycles": 1, + "dna_volume_ul": "1", + "identity": "https://example.org/reporter/transformation_method", + "initial_celsius": "25", + "output_volume_ul": "5", + "profile": [ + { + "celsius": "25", + "seconds": "1" + } + ], + "recovery": { + "component": { + "identity": "https://example.org/reporter/broth" + }, + "volume_ul": "2" + }, + "recovery_profile": [ + { + "celsius": "25", + "seconds": "1" + } + ] + } + ] + } +} diff --git a/examples/cloning_provenance/data/reporter_reference/system.json b/examples/cloning_provenance/data/reporter_reference/system.json new file mode 100644 index 0000000..fd50d47 --- /dev/null +++ b/examples/cloning_provenance/data/reporter_reference/system.json @@ -0,0 +1,73 @@ +{ + "format": "lab.cloning-system.v1", + "system": { + "identity": "https://example.org/reporter/complete_system", + "recipes": [ + { + "identity": "https://example.org/reporter/plating_recipe", + "kind": "plating", + "method": "https://example.org/reporter/plating_method", + "product": { + "identity": "https://example.org/reporter/strain" + } + }, + { + "identity": "https://example.org/reporter/preparation_recipe", + "kind": "preparation", + "method": "https://example.org/reporter/preparation_method", + "output_form": "dna", + "product": { + "identity": "https://example.org/reporter/vector" + }, + "source": { + "identity": "https://example.org/reporter/vector" + }, + "source_form": "bacterial_stab" + }, + { + "circular": true, + "enzyme": "EcoRI", + "fragments": [ + { + "component": { + "identity": "https://example.org/reporter/vector" + }, + "left_cut": 5, + "reverse_complement": false, + "right_cut": 5 + }, + { + "component": { + "identity": "https://example.org/reporter/insert" + }, + "left_cut": 3, + "reverse_complement": false, + "right_cut": 12 + } + ], + "identity": "https://example.org/reporter/recipe", + "kind": "assembly", + "method": "https://example.org/reporter/method", + "product": { + "identity": "https://example.org/reporter/target" + } + }, + { + "chassis": { + "identity": "https://example.org/reporter/cells" + }, + "identity": "https://example.org/reporter/transformation_recipe", + "kind": "transformation", + "method": "https://example.org/reporter/transformation_method", + "plasmids": [ + { + "identity": "https://example.org/reporter/target" + } + ], + "product": { + "identity": "https://example.org/reporter/strain" + } + } + ] + } +} diff --git a/examples/cloning_provenance/notebook_views.py b/examples/cloning_provenance/notebook_views.py new file mode 100644 index 0000000..70babde --- /dev/null +++ b/examples/cloning_provenance/notebook_views.py @@ -0,0 +1,279 @@ +"""Presentation helpers for the reporter-reference notebook. + +These functions present ordinary Lab objects and emitted files as tables, +diagrams, source excerpts, and selected RDF statements. +""" + +import hashlib +import json +from collections.abc import Iterable, Mapping +from html import escape +from os.path import relpath +from pathlib import Path +from tempfile import mkdtemp + +from IPython.display import HTML, SVG, Code, Markdown, display +from rdflib import RDF, Graph, Namespace, URIRef +from rdflib.term import Node + +from lab.compiler import Compilation +from lab.experiments.cloning.planning import BuildPlan +from lab.inventory import Inventory +from lab.provenance import DocumentSnapshot + +EXAMPLE = Path(__file__).resolve().parent +ROOT = EXAMPLE.parents[1] +CASE = EXAMPLE / "data/reporter_reference" +EX = Namespace("https://example.org/reporter/") +SBOL = Namespace("http://sbols.org/v3#") +PROV = Namespace("http://www.w3.org/ns/prov#") +LAB = Namespace("https://the-lab-compiler.github.io/lab-py/ns#") +UML = Namespace("http://bioprotocols.org/uml#") +LABOP = Namespace("http://bioprotocols.org/labop#") +OM = Namespace("http://www.ontology-of-units-of-measure.org/resource/om-2/") + + +def fresh_output() -> Path: + parent = ROOT / "build/cloning-notebook" + parent.mkdir(parents=True, exist_ok=True) + return Path(mkdtemp(prefix="reporter-", dir=parent)) + + +def table(headers: Iterable[object], rows: Iterable[Iterable[object]]) -> None: + head = "".join(f"{escape(str(value))}" for value in headers) + body = "".join( + "" + + "".join( + f'{escape(str(value))}' + for value in row + ) + + "" + for row in rows + ) + display(HTML(f"{head}{body}
")) + + +def inventory(inventory: Inventory, document: DocumentSnapshot) -> None: + table( + ("Material record", "Intended design", "Form", "Available", "Location"), + ( + ( + document.resolve(stock.implementation).name, + document.resolve(stock.design).name, + stock.form.value, + f"{stock.amount} {'unit' if stock.form.counted else 'µL'}", + f"{stock.location.container} / {stock.location.position}" + if stock.location + else "not assigned", + ) + for stock in inventory.stocks + if stock.form.value in {"bacterial_stab", "dna", "competent_cells"} + ), + ) + + +def plan(plan: BuildPlan) -> None: + labels = { + "preparation": "Prepare vector DNA", + "assembly": "Assemble reporter design", + "transformation": "Introduce it into host H1", + "plating": "Deposit a sample", + } + table( + ("Order", "Task", "Expected material", "Responsible system"), + ( + ( + index, + labels[task.kind.value], + f"{task.output_form.value}: " + + ( + f"{task.output_count} sample" + if task.output_form.counted + else f"{task.output_volume_ul} µL" + ), + "External procedure P01" if task.kind.value == "preparation" else "Liquid handler", + ) + for index, task in enumerate(plan.tasks, 1) + ), + ) + positions = [(16, 66), (254, 66), (492, 66), (730, 8), (730, 126)] + if len(plan.tasks) != len(positions): + return + numbers = {task.identity: index for index, task in enumerate(plan.tasks)} + parts = [ + '', + '', + '', + ] + for index, task in enumerate(plan.tasks): + x, y = positions[index] + for parent in task.depends_on: + px, py = positions[numbers[parent]] + parts.append( + f'' + ) + parts.extend( + [ + f'', + f'{index + 1}. ' + f"{escape(task.kind.value.title())}", + f'{escape(task.output_form.value)}', + ] + ) + parts.append("") + display(SVG("".join(parts))) + + +def _excerpt(source: Graph, selected: Graph, prefixes: Mapping[str, str]) -> str: + for name, namespace in { + "ex": str(EX), + "sbol": str(SBOL), + "prov": str(PROV), + "lab": str(LAB), + "uml": str(UML), + "labop": str(LABOP), + "om": str(OM), + "build": str(EX.build) + "/", + **prefixes, + }.items(): + selected.bind(name, Namespace(namespace), replace=True) + # Keep typed numeric literals explicit. RDFLib's compact Turtle writer can + # change a decimal's lexical spelling (for example "1" to 1.0). + used = set() + + def term(node: Node) -> str: + rendered = node.n3(namespace_manager=selected.namespace_manager) + for prefix, _ in selected.namespaces(): + if rendered.startswith(prefix + ":") or "^^" + prefix + ":" in rendered: + used.add(prefix) + return rendered + + blocks = [] + for subject in sorted(set(selected.subjects()), key=str): + statements = [] + for predicate in sorted( + set(selected.predicates(subject)), key=lambda value: (value != RDF.type, str(value)) + ): + objects = ", ".join( + term(obj) for obj in sorted(selected.objects(subject, predicate), key=str) + ) + statements.append(f"{'a' if predicate == RDF.type else term(predicate)} {objects}") + blocks.append(term(subject) + " " + " ;\n ".join(statements) + " .") + declarations = [ + f"@prefix {prefix}: <{namespace}> ." + for prefix, namespace in selected.namespaces() + if prefix in used + ] + text = "\n".join(declarations) + "\n\n" + "\n\n".join(blocks) + "\n" + # Excerpts must be real triples from the source, with only notation changed. + assert len(selected) and set(selected) <= set(source) + assert set(Graph().parse(data=text, format="turtle")) == set(selected) + return text + + +def turtle( + path: Path, + subjects: Iterable[str], + *, + predicates: Iterable[str] | None = None, + follow: Iterable[str] = (), + prefixes: Mapping[str, str] | None = None, +) -> None: + """Show an actual RDF subset using compact Turtle notation. + + Housekeeping identifiers, redundant SBOL base classes, and embedded semantic + JSON are omitted. Explicit subject and predicate selection keeps each view + small; the original artifact is never modified. + """ + source = Graph().parse(path, format="turtle") + selected = Graph() + allowed = None if predicates is None else {URIRef(value) for value in predicates} + follow_edges = {URIRef(value) for value in follow} + pending = [URIRef(value) for value in subjects] + visited = set() + excluded = {SBOL.displayId, SBOL.hasNamespace, LAB.semanticStep, LAB.planDigest} + while pending: + subject = pending.pop() + if subject in visited: + continue + visited.add(subject) + for triple in source.triples((subject, None, None)): + _, predicate, obj = triple + if predicate in excluded or (allowed is not None and predicate not in allowed): + continue + if predicate == RDF.type and obj in {SBOL.TopLevel, SBOL.Identified}: + continue + selected.add(triple) + if predicate in follow_edges: + pending.append(obj) + display(Code(_excerpt(source, selected, prefixes or {}), language="turtle")) + + +def labop_amount(path: Path, step: str) -> None: + """Follow an action's amount pin into its measured value in the real RDF.""" + source = Graph().parse(path, format="turtle") + action = URIRef(step) + pin = URIRef(step + "/input_amount") + value = source.value(pin, UML.value, any=False) + if value is None: + raise ValueError("Select an action with an explicit amount parameter") + measure = source.value(value, UML.identifiedValue, any=False) + if measure is None: + raise ValueError("Select an action with an explicit amount parameter") + selected = Graph() + for subject, predicates in ( + (action, (RDF.type, UML.behavior)), + (pin, (RDF.type, SBOL.name, UML.value)), + (value, (RDF.type, UML.identifiedValue)), + (measure, (RDF.type, OM.hasNumericalValue, OM.hasUnit)), + ): + for predicate in predicates: + for triple in source.triples((subject, predicate, None)): + if predicate != RDF.type or triple[2] != SBOL.Identified: + selected.add(triple) + selected.add((action, UML.input, pin)) + protocol = step.rsplit("/", 1)[0] + display( + Code( + _excerpt( + source, + selected, + { + "stage": protocol + "/", + "step": step + "/", + "pin": str(pin) + "/", + "amount": str(value) + "/", + "liquid": "https://bioprotocols.org/labop/primitives/liquid_handling/", + }, + ), + language="turtle", + ) + ) + + +def source(stage: Compilation, step: str) -> None: + span = next(row for row in stage.source_map if row["step"] == step) + if stage.target.source is None: + raise ValueError("Select an automated stage") + lines = stage.target.source.splitlines() + display(Markdown(f"`protocol.py`, lines **{span['start_line']}–{span['end_line']}**")) + display(Code("\n".join(lines[span["start_line"] - 1 : span["end_line"]]), language="python")) + + +def links(directory: Path, paths: Mapping[str, str]) -> None: + relative = Path(relpath(directory, start=EXAMPLE)).as_posix() + display(Markdown("\n".join(f"- [{label}]({relative}/{path})" for label, path in paths.items()))) + + +def check_bundle(directory: Path) -> int: + checksums = json.loads((directory / "bundle.json").read_text())["sha256"] + for name, expected in checksums.items(): + assert hashlib.sha256((directory / name).read_bytes()).hexdigest() == expected + return len(checksums) diff --git a/examples/cloning_workflow.py b/examples/cloning_workflow.py new file mode 100644 index 0000000..ea5991f --- /dev/null +++ b/examples/cloning_workflow.py @@ -0,0 +1,168 @@ +"""Compile all cloning stages from explicit synthetic inputs. + +The arbitrary sequences, quantities, and parameters exercise the compiler; +this example does not supply validated laboratory methods. +""" + +import argparse +from dataclasses import replace +from decimal import Decimal + +import lab +from examples.provenance_build import NS +from examples.provenance_build import inputs as assembly_inputs +from lab import uL +from lab.experiments import cloning +from lab.experiments.cloning.methods import ( + ExternalPreparationMethod, + PlatingMethod, + Reagent, + TransformationMethod, +) +from lab.experiments.cloning.planning import BuildRequest, CountTarget +from lab.experiments.cloning.systems import ( + CloningSystem, + ExternalPreparationRecipe, + PlatingRecipe, + TransformationRecipe, +) +from lab.inventory import CountedStock, Inventory, MaterialForm, Stock +from lab.operations import Hold +from lab.provenance import Component, Document, EvidenceState, Implementation +from lab.provenance.vocabulary import SMALL_MOLECULE +from lab.targets import LiquidHandler, Manual + + +def inputs(count=2): + _, inputs = assembly_inputs() + doc = Document.from_snapshot(inputs["document"]) + vector = doc.get(NS + "/vector", Component) + target = doc.get(NS + "/target", Component) + cells = Component(identity=NS + "/cells", types=("https://example.org/cell",)) + strain = Component(identity=NS + "/strain", types=("https://example.org/cell",)) + broth = Component(identity=NS + "/broth", types=(SMALL_MOLECULE,)) + substrate = Component(identity=NS + "/substrate", types=(SMALL_MOLECULE,)) + doc.add(cells, strain, broth, substrate) + stocks = [s for s in inputs["inventory"].stocks if s.design != vector.ref] + for design, form in ((cells, MaterialForm.COMPETENT_CELLS), (broth, MaterialForm.REAGENT)): + material = Implementation( + identity=design.identity + "/material", + derived_from=(design.ref,), + evidence_state=EvidenceState.RECORDED, + ) + doc.add(material) + stocks.append( + Stock( + identity=design.identity + "/stock", + design=design.ref, + implementation=material.ref, + form=form, + quantity=20 * uL, + ) + ) + material = Implementation( + identity=NS + "/received_stab", + derived_from=(vector.ref,), + evidence_state=EvidenceState.RECORDED, + ) + doc.add(material) + stocks.append( + CountedStock( + identity=NS + "/stab_stock", + design=vector.ref, + implementation=material.ref, + form=MaterialForm.BACTERIAL_STAB, + count=1, + ) + ) + preparation = ExternalPreparationMethod( + identity=NS + "/preparation_method", + procedure=NS + "/sop", + instructions=( + "Apply the supplied test procedure and record the resulting material and quantity." + ), + source_count=1, + output_volume_ul=Decimal(10), + ) + transformation = TransformationMethod( + identity=NS + "/transformation_method", + cell_volume_ul=Decimal(2), + dna_volume_ul=Decimal(1), + recovery=Reagent(component=broth.ref, volume_ul=Decimal(2)), + profile=(Hold(Decimal(25), Decimal(1)),), + recovery_profile=(Hold(Decimal(25), Decimal(1)),), + output_volume_ul=Decimal(5), + cell_mix_volume_ul=Decimal(1), + cell_mix_cycles=1, + dna_mix_cycles=1, + initial_celsius=Decimal(25), + ) + plating = PlatingMethod( + identity=NS + "/plating_method", + substrate=substrate.ref, + diluent=broth.ref, + transfer_volume_ul=Decimal(1), + dilution_factors=(Decimal(2), Decimal(3)), + spot_volume_ul=Decimal(1), + spot_height_mm=Decimal(2), + mix_volume_ul=Decimal(1), + mix_cycles=1, + ) + system = CloningSystem( + identity=NS + "/complete_system", + recipes=( + *inputs["system"].recipes, + ExternalPreparationRecipe( + identity=NS + "/preparation_recipe", + product=vector.ref, + source=vector.ref, + source_form=MaterialForm.BACTERIAL_STAB, + output_form=MaterialForm.DNA, + method=preparation.identity, + ), + TransformationRecipe( + identity=NS + "/transformation_recipe", + product=strain.ref, + chassis=cells.ref, + plasmids=(target.ref,), + method=transformation.identity, + ), + PlatingRecipe( + identity=NS + "/plating_recipe", product=strain.ref, method=plating.identity + ), + ), + ) + return BuildRequest( + identity=NS + "/integrated_request", targets=(CountTarget(design=strain.ref, count=count),) + ), { + **inputs, + "document": doc.freeze(), + "inventory": Inventory(identity=NS + "/integrated_inventory", stocks=tuple(stocks)), + "system": system, + "methods": replace( + inputs["methods"], + transformations=(transformation,), + platings=(plating,), + preparations=(preparation,), + ), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target", choices=("manual", "ot2", "flex", "star"), default="manual") + parser.add_argument("--out") + args = parser.parse_args() + request, supplied = inputs() + plan = cloning.plan(request, **supplied) + plan.require_ready() + experiment = cloning.build(plan) + target = Manual() if args.target == "manual" else LiquidHandler(args.target) + compilation = lab.compile(experiment, target) + print(compilation.write(args.out or f"build/cloning/{args.target}")) + for stage in compilation.stages: + print(f"{stage.protocol.name}: {stage.target.name}") + + +if __name__ == "__main__": + main() diff --git a/examples/provenance.py b/examples/provenance.py new file mode 100644 index 0000000..bf8ae2f --- /dev/null +++ b/examples/provenance.py @@ -0,0 +1,94 @@ +"""Author and round-trip an SBOL3 design and prospective build provenance. + +Run ``python -m examples.provenance --out build/provenance.ttl``. The short +sequence is illustrative; this example specifies no executable cloning method. +""" + +import argparse +from pathlib import Path + +from lab import __version__ +from lab.provenance import ( + Activity, + Agent, + AgentKind, + Association, + Component, + Document, + EvidenceState, + Implementation, + Plan, + Sequence, + SubComponent, + Usage, +) +from lab.provenance.vocabulary import DNA, IUPAC_DNA, LAB + + +def build_document() -> Document: + document = Document(namespace="https://example.org/lab/provenance_example") + sequence = Sequence( + identity=document.iri("input_sequence"), + elements="ACGTACGT", + encoding=IUPAC_DNA, + ) + part = Component( + identity=document.iri("input_design"), + name="Illustrative input design", + types=(DNA,), + sequences=(sequence.ref,), + ) + product = Component( + identity=document.iri("product_design"), + name="Intended product design", + types=(DNA,), + features=(SubComponent(instance_of=part.ref),), + ) + stock = Implementation( + identity=document.iri("input_stock"), + derived_from=(part.ref,), + evidence_state=EvidenceState.RECORDED, + description="An author-supplied inventory assertion; sequence verification unspecified.", + ) + planner = Agent( + identity=document.iri("lab_compiler"), + name="Lab Compiler", + kind=AgentKind.SOFTWARE, + software_version=__version__, + ) + method = Plan( + identity=document.iri("method"), + description="Prospective method identity; detailed protocol specification is separate.", + ) + activity = Activity( + identity=document.iri("planned_build"), + evidence_state=EvidenceState.PLANNED, + usage=(Usage(entity=stock.ref, roles=(LAB + "inputMaterial",)),), + association=(Association(agent=planner.ref, plan=method.ref, roles=(LAB + "planner",)),), + ) + output = Implementation( + identity=document.iri("planned_output"), + derived_from=(product.ref,), + generated_by=(activity.ref,), + evidence_state=EvidenceState.PLANNED, + ) + document.add(sequence, part, product, stock, planner, method, activity, output) + return document + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=Path("build/provenance.ttl")) + args = parser.parse_args() + frozen = build_document().freeze() + report = frozen.to_sbol3().validate() + if report.errors: + raise ValueError(str(report)) + output = frozen.write(args.out) + restored = Document.read(output).freeze() + assert restored.digest == frozen.digest + print(f"Wrote {output}; {len(frozen.objects)} top-level objects; SHA-256 {frozen.digest}") + + +if __name__ == "__main__": + main() diff --git a/examples/provenance_build.py b/examples/provenance_build.py new file mode 100644 index 0000000..294b3ac --- /dev/null +++ b/examples/provenance_build.py @@ -0,0 +1,120 @@ +"""Compile a synthetic software fixture from SBOL designs through robot artifacts. + +The short DNA strings and arbitrary method numbers demonstrate the compiler. +They are not a laboratory cloning recipe. +""" + +import argparse +from decimal import Decimal + +import lab +from lab import uL +from lab.experiments import cloning +from lab.experiments.cloning.methods import AssemblyMethod, CloningMethods, Reagent +from lab.experiments.cloning.planning import BuildRequest, BuildTarget +from lab.experiments.cloning.sequences import CIRCULAR, LINEAR +from lab.experiments.cloning.systems import AssemblyRecipe, CloningSystem, FragmentSelection +from lab.inventory import Inventory, MaterialForm, Stock +from lab.operations import Hold +from lab.provenance import Component, Document, EvidenceState, Implementation, Sequence +from lab.provenance.vocabulary import DNA, IUPAC_DNA, SMALL_MOLECULE +from lab.targets import LiquidHandler, Manual + +NS = "https://example.org/lab_demo" + + +def inputs(): + volume, stock_volume = "3", "10" + doc = Document(namespace=NS) + vector_sequence = Sequence( + identity=doc.iri("vector_sequence"), elements="AAAAGAATTCTTTT", encoding=IUPAC_DNA + ) + + insert_sequence = Sequence( + identity=doc.iri("insert_sequence"), elements="GGGAATTCCCCGAATTCGG", encoding=IUPAC_DNA + ) + vector = Component( + identity=doc.iri("vector"), types=(DNA, CIRCULAR), sequences=(vector_sequence.ref,) + ) + insert = Component( + identity=doc.iri("insert"), types=(DNA, LINEAR), sequences=(insert_sequence.ref,) + ) + target = Component(identity=doc.iri("target"), types=(DNA, CIRCULAR)) + buffer = Component(identity=doc.iri("buffer"), types=(SMALL_MOLECULE,)) + water = Component(identity=doc.iri("water"), types=(SMALL_MOLECULE,)) + doc.add(vector_sequence, insert_sequence, vector, insert, target, buffer, water) + stocks = [] + for design, form in ( + (vector, MaterialForm.DNA), + (insert, MaterialForm.DNA), + (buffer, MaterialForm.REAGENT), + (water, MaterialForm.REAGENT), + ): + implementation = Implementation( + identity=design.identity + "/implementation", + derived_from=(design.ref,), + evidence_state=EvidenceState.RECORDED, + ) + doc.add(implementation) + stocks.append( + Stock( + identity=design.identity + "/stock", + design=design.ref, + implementation=implementation.ref, + form=form, + quantity=Decimal(stock_volume) * uL, + ) + ) + recipe = AssemblyRecipe( + identity=doc.iri("recipe"), + product=target.ref, + enzyme="EcoRI", + fragments=( + FragmentSelection(component=vector.ref, left_cut=5, right_cut=5), + FragmentSelection(component=insert.ref, left_cut=3, right_cut=12), + ), + method=doc.iri("method"), + ) + method = AssemblyMethod( + identity=recipe.method, + enzyme="EcoRI", + dna_volume_ul=Decimal(1), + reaction_volume_ul=Decimal(5), + output_volume_ul=Decimal(5), + reagents=(Reagent(component=buffer.ref, volume_ul=Decimal(1)),), + diluent=water.ref, + profile=(Hold(Decimal(25), Decimal(1)),), + cycles=1, + mix_volume_ul=Decimal(2), + mix_cycles=1, + ) + return ( + BuildRequest( + identity=doc.iri("request"), + targets=(BuildTarget(design=target.ref, volume_ul=Decimal(volume)),), + ), + dict( + document=doc.freeze(), + inventory=Inventory(identity=doc.iri("inventory"), stocks=tuple(stocks)), + system=CloningSystem(identity=doc.iri("system"), recipes=(recipe,)), + methods=CloningMethods(assemblies=(method,)), + ), + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target", choices=("manual", "ot2", "flex", "star"), default="manual") + parser.add_argument("--out", default=None) + args = parser.parse_args() + request, supplied = inputs() + planned = cloning.plan(request, **supplied) + planned.require_ready() + experiment = cloning.build(planned) + target = Manual() if args.target == "manual" else LiquidHandler(args.target) + compilation = lab.compile(experiment, target) + print(compilation.write(args.out or f"build/provenance/{args.target}")) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 2b0a7ee..db827fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,12 @@ classifiers = [ "Topic :: Scientific/Engineering", "Typing :: Typed", ] -dependencies = ["pint>=0.24,<0.27"] +dependencies = [ + "pint>=0.24,<0.27", + "sbol3==1.2.0.post0", + "rdflib>=6.1.1,<7", + "biopython==1.84", +] [project.urls] Homepage = "https://github.com/the-lab-compiler/lab-py" @@ -87,5 +92,5 @@ check_untyped_defs = true disallow_untyped_defs = true [[tool.mypy.overrides]] -module = ["opentrons.*", "pylabrobot.*"] +module = ["opentrons.*", "pylabrobot.*", "sbol3.*", "Bio.*"] ignore_missing_imports = true diff --git a/scripts/check_install.py b/scripts/check_install.py index d3bfb05..b42d76b 100644 --- a/scripts/check_install.py +++ b/scripts/check_install.py @@ -2,6 +2,7 @@ import json import sys +from decimal import Decimal from importlib import import_module from importlib.metadata import distribution from importlib.resources import files @@ -9,20 +10,32 @@ from tempfile import TemporaryDirectory import lab +from lab.deck import ContainerSpec, Deck +from lab.experiment import ExperimentPlan, ProtocolStage +from lab.labware import LabwareKind, LabwareSpec +from lab.provenance import Activity, Document from lab.targets import Manual def main() -> None: # Verify public modules and subpackages are present in the installed distribution. import_module("lab.experiments.cloning") + import_module("lab.experiments.cloning.routes") import_module("lab.part") import_module("lab.samples") + import_module("lab.provenance") + import_module("lab.inventory") + import_module("lab.suppliers") + import_module("lab.labop") package = distribution("lab-compiler") assert package.version == lab.__version__ assert package.metadata["Name"] == "lab-compiler" assert set(package.metadata.get_all("Provides-Extra", [])) == {"opentrons", "star"} assert files("lab").joinpath("py.typed").is_file() + assert files("lab.provenance").joinpath("resources/lab.ttl").is_file() + assert files("lab.labop").joinpath("resources/upstream.json").is_file() + assert files("lab.labop").joinpath("resources/LICENSE.txt").is_file() assert any(str(path).endswith("licenses/LICENSE") for path in package.files or ()) assert lab.__file__ is not None assert not Path(lab.__file__).resolve().is_relative_to(Path(__file__).resolve().parents[1]) @@ -39,6 +52,39 @@ 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 + provenance = Document(namespace="https://example.org/install_check") + provenance.add(Activity(identity=provenance.iri("activity"))) + snapshot = provenance.freeze() + snapshot.write(output / "provenance.ttl") + assert Document.read(output / "provenance.ttl").freeze().digest == snapshot.digest + assert not snapshot.to_sbol3().validate().errors + experiment = ExperimentPlan( + identity="https://example.org/install_check/experiment", + provenance=snapshot, + stages=( + ProtocolStage( + identity="https://example.org/install_check/stage", + protocol=protocol.snapshot(), + deck=Deck( + containers=tuple( + ContainerSpec( + id=name, + labware=LabwareSpec( + kind=LabwareKind.PLATE, + rows=1, + columns=1, + capacity_ul=Decimal(200), + ), + ) + for name in ("source", "destination") + ) + ), + ), + ), + ) + bundle = lab.compile(experiment, Manual()) + bundle.write(output / "experiment") + assert "protocol.labop.ttl" in bundle.files 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..d62830a --- /dev/null +++ b/scripts/check_labop.py @@ -0,0 +1,44 @@ +"""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 +from pathlib import Path + +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) + args = parser.parse_args() + lab_namespace = "https://the-lab-compiler.github.io/lab-py/ns#" + SBOLFactory("lab", str(args.schemas / "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/__init__.py b/src/lab/__init__.py index 938f022..9cbe0d1 100644 --- a/src/lab/__init__.py +++ b/src/lab/__init__.py @@ -1,7 +1,8 @@ """Build and compile laboratory protocols using ordinary Python.""" from lab._version import __version__ -from lab.compiler import Compilation, compile +from lab.compiler import Compilation, ExperimentCompilation, compile +from lab.experiment import ExperimentPlan, MaterialHandoff, ProtocolStage from lab.protocol import Plate, Protocol, Well from lab.units import Quantity, celsius, minutes, mL, seconds, uL from lab.validation import CompileError @@ -9,6 +10,10 @@ __all__ = [ "Compilation", "CompileError", + "ExperimentCompilation", + "ExperimentPlan", + "MaterialHandoff", + "ProtocolStage", "Plate", "Protocol", "Quantity", diff --git a/src/lab/artifacts.py b/src/lab/artifacts.py new file mode 100644 index 0000000..287a986 --- /dev/null +++ b/src/lab/artifacts.py @@ -0,0 +1,76 @@ +"""Deterministic artifact encoding and non-destructive bundle writes.""" + +import hashlib +import json +from dataclasses import dataclass, fields, is_dataclass +from datetime import datetime +from decimal import Decimal +from enum import Enum +from pathlib import Path, PurePosixPath +from typing import Any + +from lab.units import number + + +@dataclass(frozen=True, kw_only=True) +class SourceArtifact: + name: str + text: str + + def __post_init__(self) -> None: + relative = PurePosixPath(self.name) + if relative.is_absolute() or ".." in relative.parts or not relative.parts: + raise ValueError("Source artifacts require a relative bundle path") + if not isinstance(self.text, str): + raise TypeError("Source artifact content must be text") + + @property + def digest(self) -> str: + return hashlib.sha256(self.text.encode()).hexdigest() + + +def encode(value: Any) -> Any: + if isinstance(value, Decimal): + return number(value) + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, Enum): + return value.value + if is_dataclass(value) and not isinstance(value, type): + return { + field.name: encode(getattr(value, field.name)) + for field in fields(value) + if not field.name.startswith("_") + } + if isinstance(value, (tuple, list)): + return [encode(item) for item in value] + if isinstance(value, dict): + return {key: encode(item) for key, item in value.items()} + return value + + +def canonical_json(value: Any) -> str: + return ( + json.dumps(encode(value), ensure_ascii=False, sort_keys=True, indent=2, allow_nan=False) + + "\n" + ) + + +def digest(value: Any) -> str: + return hashlib.sha256(canonical_json(value).encode()).hexdigest() + + +def write_bundle(directory: str | Path, files: dict[str, str]) -> Path: + directory = Path(directory) + for name, content in files.items(): + relative = PurePosixPath(name) + if relative.is_absolute() or ".." in relative.parts or not relative.parts: + raise ValueError(f"Invalid bundle path {name!r}") + path = directory / name + if path.exists() and path.read_text(encoding="utf-8") != content: + raise FileExistsError(f"{path} already contains a different artifact") + for name, content in files.items(): + path = directory / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return directory diff --git a/src/lab/compiler.py b/src/lab/compiler.py index 2c206b3..3f9760a 100644 --- a/src/lab/compiler.py +++ b/src/lab/compiler.py @@ -3,15 +3,20 @@ import hashlib import json import os +from collections.abc import Mapping from dataclasses import dataclass, replace from decimal import Decimal +from importlib.resources import files as resource_files from pathlib import Path from typing import Any, overload from typing import Protocol as Interface import lab.documents as documents +import lab.methods as methods from lab._version import __version__ +from lab.artifacts import write_bundle from lab.deck import Deck +from lab.experiment import ExperimentPlan from lab.experiments.cloning.stages.assembly import build_assembly from lab.experiments.cloning.stages.plating import build_plating from lab.experiments.cloning.stages.transformation import build_transformation @@ -22,13 +27,16 @@ Transformation, TransformationRequest, ) -from lab.model import Distribute, Mix, RecordedProtocol, TargetPlan, Transfer, encode +from lab.labop import export as export_labop +from lab.model import RecordedProtocol, encode +from lab.operations import Distribute, Mix, Transfer from lab.protocol import Protocol from lab.samples import Location, OutputManifest +from lab.target import TargetPlan from lab.targets.liquid_handler import LiquidHandler from lab.targets.lower import lower_deck from lab.targets.manual import Manual -from lab.validation import CompileError, logical_bindings, validate +from lab.validation import CompileError, count_trace, logical_bindings, validate class Target(Interface): @@ -69,6 +77,10 @@ def plan_json(self) -> str: {"location": encode(location), "volume": encode(volume)} for location, volume in self.final_volumes ], + "final_counts": [ + {"location": encode(location), "count": count} + for location, count in count_trace(self.protocol)[-1].items() + ], "source_sha256": ( hashlib.sha256(self.target.source.encode()).hexdigest() if self.target.source is not None @@ -81,9 +93,44 @@ def plan_json(self) -> str: def digest(self) -> str: return hashlib.sha256(self.plan_json.encode()).hexdigest() + @property + def source_map(self) -> tuple[dict[str, object], ...]: + """One-based inclusive generated line spans for semantic operations.""" + source = self.target.source + if source is None: + return () + lines = source.splitlines() + markers = [ + (index + 1, line.strip().removeprefix("# lab:step ")) + for index, line in enumerate(lines) + if line.strip().startswith("# lab:step ") + ] + ends = { + line.strip().removeprefix("# lab:end "): index + 1 + for index, line in enumerate(lines) + if line.strip().startswith("# lab:end ") + } + expected = tuple(step.identity for step in self.protocol.steps) + if tuple(identity for _, identity in markers) != expected or set(ends) != set(expected): + raise CompileError("Generated source does not map every protocol step exactly once") + return tuple( + { + "step": identity, + "file": "protocol.py", + "start_line": start, + "end_line": ends[identity], + } + for start, identity in markers + ) + @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.json": self.protocol.semantic_json, + "source-map.json": canonical_json(self.source_map), + } if self.protocol.output_sample_ids: result["manifest.json"] = canonical_json(self.manifest.to_dict()) if self.target.source is not None: @@ -92,16 +139,127 @@ def files(self) -> dict[str, str]: def write(self, directory: str | Path) -> Path: """Write a bundle. Refuse to replace any different existing artifact.""" - directory = Path(directory) - files = self.files - for name, text in files.items(): - path = directory / name - if path.exists() and path.read_text(encoding="utf-8") != text: - raise FileExistsError(f"{path} already contains a different artifact") - directory.mkdir(parents=True, exist_ok=True) - for name, text in files.items(): - (directory / name).write_text(text, encoding="utf-8") - return directory + return write_bundle(directory, self.files) + + +def _compile_protocol( + protocol: Protocol | RecordedProtocol, + hardware: Target | Deck, + *, + liquid_handler: LiquidHandler | None = None, +) -> Compilation: + """Compile offline for one piece of hardware. + + A ``Deck`` contains shared requirements and optional Lab-owned layouts. The + selected backend validates and translates its layout or supported preset. + Concrete backend targets are also accepted for low-level integrations. + A document target such as ``Manual()`` has no robot, so ``liquid_handler`` is omitted. + """ + recorded = protocol.snapshot() if isinstance(protocol, Protocol) else protocol + authored_deck = hardware if isinstance(hardware, Deck) else None + if isinstance(hardware, Deck): + if not isinstance(liquid_handler, LiquidHandler): + raise TypeError( + "Pass liquid_handler=LiquidHandler.OT2, LiquidHandler.FLEX, or LiquidHandler.STAR." + ) + liquid = tuple( + step.volume for step in recorded.steps if isinstance(step, (Transfer, Mix, Distribute)) + ) + requirements = {container.id: container.labware for container in hardware.containers} + for resource in recorded.resources: + spec = requirements.get(resource.name) + if spec is None or (spec.rows, spec.columns) != (resource.rows, resource.columns): + raise CompileError( + f"Deck requirements must match the protocol geometry for {resource.name}." + ) + if resource.capacity > spec.capacity_ul: + raise CompileError(f"Protocol capacity exceeds the deck limit for {resource.name}.") + hardware = lower_deck(hardware, liquid_handler, liquid) + declared = getattr(hardware, "liquid_handler", None) + if isinstance(declared, LiquidHandler) and liquid_handler != declared: + raise TypeError( + f"This hardware is LiquidHandler.{declared.name}. " + f"Pass liquid_handler=LiquidHandler.{declared.name}." + ) + if liquid_handler is not None and not isinstance(declared, LiquidHandler): + raise TypeError("This hardware does not name a LiquidHandler.") + if liquid_handler is not None and not isinstance(liquid_handler, LiquidHandler): + raise TypeError("Pass LiquidHandler.OT2, LiquidHandler.FLEX, or LiquidHandler.STAR.") + validate(recorded, logical_bindings(recorded)) + prepared = hardware.prepare(recorded) + if authored_deck is not None: + configuration = json.loads(prepared.configuration_json) + configuration["lab_deck"] = encode(authored_deck) + prepared = replace(prepared, configuration_json=json.dumps(configuration, sort_keys=True)) + volumes = validate(recorded, prepared.bindings) + return Compilation(recorded, prepared, tuple(volumes.items())) + + +@dataclass(frozen=True, kw_only=True) +class ExperimentCompilation: + experiment: ExperimentPlan + stages: tuple[Compilation, ...] + + def __post_init__(self) -> None: + if not isinstance(self.stages, tuple) or len(self.stages) != len(self.experiment.stages): + raise ValueError("Compile every experiment stage into an immutable tuple") + if any( + compilation.protocol != stage.protocol + for compilation, stage in zip(self.stages, self.experiment.stages, strict=True) + ): + raise ValueError("Stage compilations must use the frozen experiment's protocols") + + @property + def files(self) -> dict[str, str]: + files = { + "experiment.json": self.experiment.plan_json, + "provenance.ttl": self.experiment.provenance.to_turtle(), + "protocol.labop.ttl": export_labop(self.experiment).text, + "methods.md": methods.render(self.experiment), + } + files.update({f"inputs/{item.name}": item.text for item in self.experiment.inputs}) + for name in ("lab.ttl", "labop.ttl", "uml.ttl", "upstream.json", "LICENSE.txt"): + files[f"schemas/{name}"] = ( + resource_files("lab.labop") + .joinpath( + "resources", + name, + ) + .read_text(encoding="utf-8") + ) + for index, compilation in enumerate(self.stages, 1): + files.update( + {f"stages/{index}/{name}": text for name, text in compilation.files.items()} + ) + if self.experiment.build_json is not None: + files["build.json"] = self.experiment.build_json + checksums = { + name: hashlib.sha256(text.encode()).hexdigest() for name, text in sorted(files.items()) + } + files["bundle.json"] = canonical_json( + { + "format": "lab.bundle.v1", + "experiment": self.experiment.identity, + "semantic_sha256": self.experiment.digest, + "sha256": checksums, + "stages": [ + { + "protocol": compilation.protocol.identity, + "target": compilation.target.name, + "plan_sha256": compilation.digest, + } + for compilation in self.stages + ], + } + ) + return files + + @property + def digest(self) -> str: + return hashlib.sha256(self.files["bundle.json"].encode()).hexdigest() + + def write(self, directory: str | Path) -> Path: + return write_bundle(directory, self.files) class _DefaultOutput: @@ -109,7 +267,14 @@ 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.json", + "source-map.json", +) def _segment(value: str) -> str: @@ -145,7 +310,7 @@ def _write_output(directory: Path, files: dict[str, str]) -> None: @overload def compile( - protocol: Protocol | Assembly | AssemblyRequest, + protocol: Protocol | RecordedProtocol | Assembly | AssemblyRequest, target: Target | None = None, *, deck: Deck | None = None, @@ -179,23 +344,35 @@ def compile( ) -> Compilation: ... +@overload +def compile( + protocol: ExperimentPlan, + target: Target | LiquidHandler | Mapping[str, Target | Deck], + *, + liquid_handler: LiquidHandler | None = None, + to: str | Path | None = ..., +) -> ExperimentCompilation: ... + + def compile( protocol: ( Protocol + | RecordedProtocol + | ExperimentPlan | Assembly | AssemblyRequest | Transformation | TransformationRequest | PlatingRequest ), - target: Target | None = None, + target: Target | LiquidHandler | Mapping[str, Target | Deck] | None = None, *, deck: Deck | None = None, liquid_handler: LiquidHandler | None = None, inputs: OutputManifest | None = None, to: str | Path | None | _DefaultOutput = _DEFAULT_OUTPUT, -) -> Compilation: - """Compile a protocol or a cloning request. +) -> Compilation | ExperimentCompilation: + """Compile a protocol, an experiment, or a cloning request. One assembly uses its id as the protocol name. An assembly request names a protocol that holds every assembly. One transformation uses its id as the @@ -206,22 +383,56 @@ def compile( ``LAB_HOME`` replaces ``~/.lab``. ``to=None`` skips the write. A deck contains shared requirements and optional Lab-owned layouts. The selected backend validates and translates its layout or supported preset. Concrete - backend targets are also accepted for low-level integrations. + backend targets are also accepted for low-level integrations. An experiment + accepts a handler, one document target, or an exact stage-to-target/deck + mapping; use its compilation's write() method to save the complete bundle. """ + if inputs is not None and not isinstance( + protocol, (Transformation, TransformationRequest, PlatingRequest) + ): + raise TypeError("Pass inputs with a transformation or plating request.") + if isinstance(protocol, ExperimentPlan): + if deck is not None or isinstance(target, Deck): + raise TypeError("Map stage identities to decks when compiling an experiment") + experiment_target = target if target is not None else Manual() + if isinstance(experiment_target, Mapping) and set(experiment_target) != { + stage.identity for stage in protocol.stages + }: + raise ValueError("Hardware mapping must cover exactly the experiment's stages") + stages = [] + for stage in protocol.stages: + stage_target = ( + stage.deck + if isinstance(experiment_target, LiquidHandler) + else experiment_target[stage.identity] + if isinstance(experiment_target, Mapping) + else experiment_target + ) + handler = ( + experiment_target + if isinstance(experiment_target, LiquidHandler) + else liquid_handler + ) + if stage.external: + if isinstance(experiment_target, Mapping) and not isinstance(stage_target, Manual): + raise ValueError("Map external preparation stages to Manual targets") + stage_target = Manual() + handler = None + stages.append(_compile_protocol(stage.protocol, stage_target, liquid_handler=handler)) + experiment_compilation = ExperimentCompilation(experiment=protocol, stages=tuple(stages)) + if to is not None and not isinstance(to, _DefaultOutput): + experiment_compilation.write(to) + return experiment_compilation + if isinstance(target, (LiquidHandler, Mapping)): + raise TypeError("A single protocol requires a target or a Deck") if isinstance(target, Deck): raise TypeError("Pass a deck with deck=.") if deck is not None and target is not None: raise TypeError("Pass a target or a deck.") if deck is None and target is None and liquid_handler is not None: raise TypeError("Pass deck= with liquid_handler.") - hardware: Target | Deck = ( - deck if deck is not None else target if target is not None else Manual() - ) - if inputs is not None and not isinstance( - protocol, (Transformation, TransformationRequest, PlatingRequest) - ): - raise TypeError("Pass inputs with a transformation or plating request.") - work: Protocol + hardware = deck if deck is not None else target if target is not None else Manual() + work: Protocol | RecordedProtocol if isinstance(protocol, Assembly): work = build_assembly(AssemblyRequest(id=protocol.id, assemblies=(protocol,))) elif isinstance(protocol, AssemblyRequest): @@ -237,44 +448,7 @@ def compile( work = build_plating(protocol, inputs=inputs) else: work = protocol - recorded = work.snapshot() - authored_deck = hardware if isinstance(hardware, Deck) else None - if isinstance(hardware, Deck): - if not isinstance(liquid_handler, LiquidHandler): - raise TypeError( - "Pass liquid_handler=LiquidHandler.OT2, LiquidHandler.FLEX, or LiquidHandler.STAR." - ) - liquid = tuple( - step.volume for step in recorded.steps if isinstance(step, (Transfer, Mix, Distribute)) - ) - requirements = {container.id: container.labware for container in hardware.containers} - for resource in recorded.resources: - spec = requirements.get(resource.name) - if spec is None or (spec.rows, spec.columns) != (resource.rows, resource.columns): - raise CompileError( - f"Deck requirements must match the protocol geometry for {resource.name}." - ) - if resource.capacity > spec.capacity_ul: - raise CompileError(f"Protocol capacity exceeds the deck limit for {resource.name}.") - hardware = lower_deck(hardware, liquid_handler, liquid) - declared = getattr(hardware, "liquid_handler", None) - if isinstance(declared, LiquidHandler) and liquid_handler != declared: - raise TypeError( - f"This hardware is LiquidHandler.{declared.name}. " - f"Pass liquid_handler=LiquidHandler.{declared.name}." - ) - if liquid_handler is not None and not isinstance(declared, LiquidHandler): - raise TypeError("This hardware does not name a LiquidHandler.") - if liquid_handler is not None and not isinstance(liquid_handler, LiquidHandler): - raise TypeError("Pass LiquidHandler.OT2, LiquidHandler.FLEX, or LiquidHandler.STAR.") - validate(recorded, logical_bindings(recorded)) - prepared = hardware.prepare(recorded) - if authored_deck is not None: - configuration = json.loads(prepared.configuration_json) - configuration["lab_deck"] = encode(authored_deck) - prepared = replace(prepared, configuration_json=json.dumps(configuration, sort_keys=True)) - volumes = validate(recorded, prepared.bindings) - compilation = Compilation(recorded, prepared, tuple(volumes.items())) + compilation = _compile_protocol(work, hardware, liquid_handler=liquid_handler) directory = _output_directory(compilation, to) if directory is not None: _write_output(directory, compilation.files) diff --git a/src/lab/documents.py b/src/lab/documents.py index 8ef51ea..885927d 100644 --- a/src/lab/documents.py +++ b/src/lab/documents.py @@ -3,8 +3,9 @@ from html import escape from typing import Any -from lab.model import ( +from lab.operations import ( Distribute, + ExternalPreparation, ManualInstruction, Mix, SetTemperature, @@ -18,8 +19,9 @@ def describe(step: Step) -> str: match step: - case Transfer(source, destination, volume, _): - return f"Transfer {number(volume)} µL from {source} to {destination}." + case Transfer(source, destination, volume, _, height): + placement = "" if height is None else f" at {number(height)} mm above the well bottom" + return f"Transfer {number(volume)} µL from {source} to {destination}{placement}." case Distribute(source, destinations, volume, air_gap, _): wells = ", ".join(str(destination) for destination in destinations) gap = f" Air gap {number(air_gap)} µL." if air_gap is not None else "" @@ -48,6 +50,22 @@ def describe(step: Step) -> str: return f"Hold {resource} at {number(celsius)} °C." case ManualInstruction(text, _): return f"Operator: {text}" + case ExternalPreparation(procedure, instructions, inputs, outputs, _): + + def describe_ports(ports: tuple) -> str: + return "; ".join( + f"{port.count} unit(s) at {port.location}" + if port.count + else f"{number(port.volume_ul)} µL at {port.location}" + for port in ports + ) + + return ( + f"External procedure <{procedure}>: {instructions} " + f"Consume {describe_ports(inputs)}. Expected output: {describe_ports(outputs)}. " + "Record completion and measured output before treating material " + "as available inventory." + ) case _: raise TypeError(f"Unsupported step: {type(step).__name__}") @@ -55,11 +73,23 @@ def describe(step: Step) -> str: def render(compilation: Any) -> str: p, target = compilation.protocol, compilation.target bindings = {binding.location: binding for binding in target.bindings} + samples = {sample.id: sample for sample in p.samples} resources = [] for resource in p.resources: fills = ( "; ".join( - f"{fill.well}: {fill.material}, {number(fill.volume)} µL" for fill in resource.fills + [ + f"{fill.well}: {fill.material}, {number(fill.volume)} µL" + for fill in resource.fills + ] + + [ + f"{place.location.well}: {samples[place.sample_id].label}, " + f"{samples[place.sample_id].count} unit(s)" + for place in p.placements + if place.location.resource == resource.name + and place.sample_id in p.input_sample_ids + and samples[place.sample_id].count is not None + ] ) or "Initially empty" ) diff --git a/src/lab/equipment.py b/src/lab/equipment.py index 5cc3a66..d74ce2a 100644 --- a/src/lab/equipment.py +++ b/src/lab/equipment.py @@ -37,6 +37,7 @@ class TipRackModel(Enum): OPENTRONS_20_UL = "opentrons_20_ul" OPENTRONS_300_UL = "opentrons_300_ul" FLEX_200_UL = "flex_200_ul" + FLEX_50_UL = "flex_50_ul" HAMILTON_50_UL = "hamilton_50_ul" HAMILTON_300_UL = "hamilton_300_ul" @@ -45,6 +46,7 @@ class PipetteModel(Enum): P20_SINGLE_GEN2 = "p20_single_gen2" P300_SINGLE_GEN2 = "p300_single_gen2" FLEX_1CHANNEL_1000 = "flex_1channel_1000" + FLEX_1CHANNEL_50 = "flex_1channel_50" class Mount(Enum): diff --git a/src/lab/experiment.py b/src/lab/experiment.py new file mode 100644 index 0000000..238f1b2 --- /dev/null +++ b/src/lab/experiment.py @@ -0,0 +1,227 @@ +"""Frozen logical experiments, stages, and material handoffs.""" + +import hashlib +from dataclasses import dataclass +from decimal import Decimal + +from lab.artifacts import SourceArtifact, canonical_json +from lab.deck import Deck +from lab.model import RecordedProtocol, semantic +from lab.provenance import DocumentSnapshot, EvidenceState, Implementation, Ref +from lab.provenance.types import require_iri +from lab.samples import Location +from lab.validation import count_trace, logical_bindings, validate + + +@dataclass(frozen=True, kw_only=True) +class MaterialHandoff: + producer: str + implementation: Ref[Implementation] + source: Location + destination: Location + volume_ul: Decimal + count: int | None = None + + def __post_init__(self) -> None: + require_iri(self.producer) + if ( + not isinstance(self.volume_ul, Decimal) + or not self.volume_ul.is_finite() + or self.volume_ul < 0 + or (self.count is None and self.volume_ul <= 0) + or ( + self.count is not None + and (type(self.count) is not int or self.count <= 0 or self.volume_ul != 0) + ) + ): + raise ValueError("A handoff needs either positive volume or a positive count") + + +@dataclass(frozen=True, kw_only=True) +class ProtocolStage: + identity: str + protocol: RecordedProtocol + deck: Deck + depends_on: tuple[str, ...] = () + handoffs: tuple[MaterialHandoff, ...] = () + external: bool = False + + def __post_init__(self) -> None: + require_iri(self.identity) + if type(self.external) is not bool: + raise TypeError("Stage external must be a bool") + if not isinstance(self.protocol, RecordedProtocol) or not isinstance(self.deck, Deck): + raise TypeError("Stages require a frozen protocol and logical deck") + if not isinstance(self.depends_on, tuple) or not isinstance(self.handoffs, tuple): + raise TypeError("Dependencies and handoffs must be immutable tuples") + if len(set(self.depends_on)) != len(self.depends_on): + raise ValueError("Stage dependencies must be unique") + requirements = {item.id: item.labware for item in self.deck.containers} + if set(requirements) != {resource.name for resource in self.protocol.resources}: + raise ValueError("Stage deck requirements must cover exactly the protocol resources") + for resource in self.protocol.resources: + spec = requirements[resource.name] + if (spec.rows, spec.columns) != (resource.rows, resource.columns) or ( + resource.capacity > spec.capacity_ul + ): + raise ValueError("Stage deck geometry and capacity must match the protocol") + + +@dataclass(frozen=True, kw_only=True) +class ExperimentPlan: + identity: str + provenance: DocumentSnapshot + stages: tuple[ProtocolStage, ...] + build_json: str | None = None + inputs: tuple[SourceArtifact, ...] = () + + def __post_init__(self) -> None: + require_iri(self.identity) + if not isinstance(self.inputs, tuple) or not all( + isinstance(item, SourceArtifact) for item in self.inputs + ): + raise TypeError("Experiment inputs must be a tuple of SourceArtifact objects") + if len({item.name for item in self.inputs}) != len(self.inputs): + raise ValueError("Input artifact paths must be unique") + if not isinstance(self.stages, tuple) or not all( + isinstance(stage, ProtocolStage) for stage in self.stages + ): + raise TypeError("Experiment stages must be an immutable tuple") + self.provenance.validate().raise_for_errors() + previous: dict[str, ProtocolStage] = {} + step_ids: set[str | None] = set() + protocol_ids: set[str | None] = set() + # Track carry-over material after every consumer, not the original yield. + remaining: dict[Ref[Implementation], Decimal] = {} + forms: dict[Ref[Implementation], object] = {} + for stage in self.stages: + if stage.identity in previous or not set(stage.depends_on) <= previous.keys(): + raise ValueError("Stages need unique identities and earlier dependencies") + if stage.protocol.identity in protocol_ids: + raise ValueError("Stage protocols need unique identities") + protocol_ids.add(stage.protocol.identity) + for step in stage.protocol.steps: + if step.identity in step_ids: + raise ValueError("Experiment step identities must be unique") + step_ids.add(step.identity) + final = validate(stage.protocol, logical_bindings(stage.protocol)) + final_counts = count_trace(stage.protocol)[-1] + samples = {sample.id: sample for sample in stage.protocol.samples} + places = {place.sample_id: place.location for place in stage.protocol.placements} + at = {location: samples[key] for key, location in places.items()} + initial = { + Location(resource.name, fill.well): fill.volume + for resource in stage.protocol.resources + for fill in resource.fills + } + for key in stage.protocol.input_sample_ids: + if samples[key].count is not None: + initial[places[key]] = Decimal(samples[key].count or 0) + for location, count in final_counts.items(): + final[location] = Decimal(count) + input_refs = [ + samples[key].implementation + for key in stage.protocol.input_sample_ids + if samples[key].implementation is not None + ] + if len(set(input_refs)) != len(input_refs): + raise ValueError("Split input aliquots require distinct implementation identities") + for sample in samples.values(): + if sample.design is not None: + self.provenance.resolve(sample.design) + if sample.implementation is not None: + material = self.provenance.resolve(sample.implementation) + if ( + sample.implementation in forms + and forms[sample.implementation] != sample.form + ): + raise ValueError( + "An implementation cannot change material form across stages" + ) + forms[sample.implementation] = sample.form + if sample.design is None or ( + material.built != sample.design + and sample.design not in material.derived_from + ): + raise ValueError("Sample design conflicts with its implementation") + handoff_locations = {handoff.destination for handoff in stage.handoffs} + if len(handoff_locations) != len(stage.handoffs): + raise ValueError("An input location needs exactly one handoff") + for sample_id in stage.protocol.input_sample_ids: + sample = samples[sample_id] + material_ref = sample.implementation + if material_ref is None or places[sample_id] in handoff_locations: + continue + material = self.provenance.resolve(material_ref) + if material.evidence_state is EvidenceState.PLANNED: + raise ValueError("A planned input requires an upstream material handoff") + location = places[sample_id] + if material_ref in remaining and remaining[material_ref] != initial.get(location): + raise ValueError("Initial volume conflicts with the remaining stock quantity") + remaining[material_ref] = final[location] + for handoff in stage.handoffs: + if handoff.producer not in stage.depends_on: + raise ValueError("Handoff producer must be a stage dependency") + producer = previous[handoff.producer] + outputs = producer.protocol.output_manifest() + output_at = {place.location: place.sample_id for place in outputs.placements} + source = next( + ( + sample + for sample in outputs.samples + if sample.id == output_at.get(handoff.source) + ), + None, + ) + destination = at.get(handoff.destination) + if ( + source is None + or destination is None + or source.implementation != handoff.implementation + or destination.implementation != handoff.implementation + or destination.design != source.design + or destination.form != source.form + or (handoff.count is not None) != (destination.count is not None) + ): + raise ValueError("Handoff must preserve the exact material and design") + amount = Decimal(handoff.count) if handoff.count is not None else handoff.volume_ul + if initial.get(handoff.destination) != amount: + raise ValueError("Handoff volume must match the destination precondition") + if remaining.get(handoff.implementation) != amount: + raise ValueError("Handoff volume conflicts with remaining upstream material") + remaining[handoff.implementation] = final[handoff.destination] + for sample_id in stage.protocol.output_sample_ids: + output_ref = samples[sample_id].implementation + if output_ref is not None: + if output_ref in remaining: + raise ValueError("An output implementation must have one producing stage") + remaining[output_ref] = final[places[sample_id]] + previous[stage.identity] = stage + + @property + def plan_json(self) -> str: + return canonical_json( + { + "format": "lab.experiment.v1", + "identity": self.identity, + "provenance_sha256": self.provenance.digest, + "stages": [ + { + "identity": stage.identity, + "protocol": semantic(stage.protocol), + "depends_on": stage.depends_on, + "handoffs": stage.handoffs, + "external": stage.external, + # Physical layouts do not define the logical experiment. + "containers": stage.deck.containers, + } + for stage in self.stages + ], + "build": self.build_json, + "inputs": {item.name: item.digest for item in self.inputs}, + } + ) + + @property + def digest(self) -> str: + return hashlib.sha256(self.plan_json.encode()).hexdigest() diff --git a/src/lab/experiments/cloning/__init__.py b/src/lab/experiments/cloning/__init__.py index 8a350c5..c926be7 100644 --- a/src/lab/experiments/cloning/__init__.py +++ b/src/lab/experiments/cloning/__init__.py @@ -4,7 +4,25 @@ containers and sites; compilation lowers it for one handler. """ +from lab.experiments.cloning.build import build from lab.experiments.cloning.decks import assembly_deck, plating_deck, transformation_deck +from lab.experiments.cloning.methods import ( + AssemblyMethod, + CloningMethods, + ExternalPreparationMethod, + PlatingMethod, + Reagent, + TransformationMethod, +) +from lab.experiments.cloning.planning import ( + BuildPlan, + BuildRequest, + BuildTarget, + CountTarget, + EditablePositions, + PlanningPolicy, + plan, +) from lab.experiments.cloning.stages import ( AssemblyLayout, AssemblyReaction, @@ -21,6 +39,14 @@ record_plating, record_transformation, ) +from lab.experiments.cloning.systems import ( + AssemblyRecipe, + CloningSystem, + ExternalPreparationRecipe, + FragmentSelection, + PlatingRecipe, + TransformationRecipe, +) from lab.experiments.cloning.types import ( BSAI, Assembly, @@ -33,6 +59,24 @@ __all__ = [ "Assembly", + "AssemblyMethod", + "AssemblyRecipe", + "CloningSystem", + "BuildPlan", + "BuildRequest", + "BuildTarget", + "CloningMethods", + "CountTarget", + "ExternalPreparationMethod", + "ExternalPreparationRecipe", + "EditablePositions", + "FragmentSelection", + "PlanningPolicy", + "PlatingMethod", + "PlatingRecipe", + "Reagent", + "build", + "plan", "AssemblyLayout", "AssemblyReaction", "AssemblyRequest", @@ -41,6 +85,8 @@ "PlatingRequest", "PlatingVolumes", "Transformation", + "TransformationMethod", + "TransformationRecipe", "TransformationLayout", "TransformationRequest", "assembly_deck", diff --git a/src/lab/experiments/cloning/_dna.py b/src/lab/experiments/cloning/_dna.py new file mode 100644 index 0000000..7885cf0 --- /dev/null +++ b/src/lab/experiments/cloning/_dna.py @@ -0,0 +1,118 @@ +"""Small, explicit double-stranded restriction/ligation calculations. + +Both strands are written 5' to 3'. ``overhang`` is Watson-start minus +Crick-start when both strands are aligned left to right. Enzyme recognition and +Watson cleavage positions come from Biopython. No end repair is implicit. +""" + +from dataclasses import dataclass + +from Bio.Restriction.Restriction import RestrictionBatch +from Bio.Seq import Seq + + +def reverse_complement(sequence: str) -> str: + return str(Seq(sequence).reverse_complement()) + + +@dataclass(frozen=True) +class Duplex: + watson: str + crick: str + overhang: int = 0 + + @property + def left_end(self) -> tuple[str, str]: + if self.overhang < 0: + return "5", self.watson[: -self.overhang] + if self.overhang > 0: + return "3", self.crick[-self.overhang :] + return "blunt", "" + + @property + def right_end(self) -> tuple[str, str]: + difference = len(self.watson) - len(self.crick) + self.overhang + if difference > 0: + return "3", self.watson[-difference:] + if difference < 0: + return "5", self.crick[:-difference] + return "blunt", "" + + def reverse_complement(self) -> "Duplex": + return Duplex(self.crick, self.watson, self.overhang + len(self.watson) - len(self.crick)) + + def ligate(self, other: "Duplex") -> "Duplex": + if not compatible(self.right_end, other.left_end): + raise ValueError(f"Incompatible ends: {self.right_end} and {other.left_end}") + return Duplex(self.watson + other.watson, other.crick + self.crick, self.overhang) + + def close(self) -> str: + if not compatible(self.right_end, self.left_end) or len(self.watson) != len(self.crick): + raise ValueError("Fragment ends cannot close into a circular duplex") + return self.watson + + def linear_sequence(self) -> str: + if self.left_end[0] != "blunt" or self.right_end[0] != "blunt": + raise ValueError("Linear products with unpaired ends need explicit end repair") + return self.watson + + +def compatible(right: tuple[str, str], left: tuple[str, str]) -> bool: + return right[0] == left[0] and reverse_complement(right[1]) == left[1] + + +@dataclass(frozen=True) +class DnaSequence: + elements: str + circular: bool + + def cuts(self, enzyme: str) -> tuple[int, ...]: + restriction = next(iter(RestrictionBatch([enzyme]))) + positions = { + position - 1 + for position in restriction.search(Seq(self.elements), linear=not self.circular) + } + if self.circular: + return tuple(sorted(position % len(self.elements) for position in positions)) + return tuple( + sorted( + position + for position in positions + if 0 <= position <= len(self.elements) + and 0 <= position - restriction.ovhg <= len(self.elements) + ) + ) + + def digest(self, enzyme: str) -> tuple[tuple[int | None, int | None, Duplex], ...]: + restriction = next(iter(RestrictionBatch([enzyme]))) + # Enzymes with two cleavage events or unknown cuts need a different model. + if restriction.ovhg is None or restriction.scd5 is not None or restriction.scd3 is not None: + raise ValueError(f"{enzyme} does not have one supported, known cleavage pair") + cuts = self.cuts(enzyme) + if not cuts: + return () + bounds: tuple[int | None, ...] = (*cuts, cuts[0]) if self.circular else (None, *cuts, None) + result: list[tuple[int | None, int | None, Duplex]] = [] + length = len(self.elements) + + def region(start: int, end: int) -> str: + if self.circular: + return "".join(self.elements[index % length] for index in range(start, end)) + return self.elements[start:end] + + for left, right in zip(bounds, bounds[1:], strict=False): + watson_start = 0 if left is None else left + watson_end = length if right is None else right + if self.circular and watson_end <= watson_start: + watson_end += length + crick_start = 0 if left is None else watson_start - restriction.ovhg + crick_end = length if right is None else watson_end - restriction.ovhg + if min(watson_end, crick_end) <= max(watson_start, crick_start): + raise ValueError("Overlapping cuts do not leave a double-stranded fragment") + fragment = Duplex( + region(watson_start, watson_end), + reverse_complement(region(crick_start, crick_end)), + watson_start - crick_start, + ) + result.append((left, right, fragment)) + return tuple(result) diff --git a/src/lab/experiments/cloning/build.py b/src/lab/experiments/cloning/build.py new file mode 100644 index 0000000..6da2c9c --- /dev/null +++ b/src/lab/experiments/cloning/build.py @@ -0,0 +1,398 @@ +"""Freeze planned cloning routes as ordinary protocols and explicit operator stages.""" + +from collections import defaultdict +from dataclasses import replace +from decimal import Decimal + +from lab.artifacts import SourceArtifact, canonical_json +from lab.deck import Container, Deck, DeckSite +from lab.experiment import ExperimentPlan, MaterialHandoff, ProtocolStage +from lab.experiments.cloning.methods import ( + AssemblyMethod, + ExternalPreparationMethod, + PlatingMethod, + TransformationMethod, +) +from lab.experiments.cloning.planning import Allocation, BuildPlan +from lab.experiments.cloning.systems import TransformationRecipe +from lab.labware import PCR_PLATE_96, ContainerSpec, LabwareKind, LabwareSpec +from lab.operations import MaterialPort +from lab.protocol import Protocol, Well +from lab.provenance import Activity, Document, EvidenceState, Implementation, Plan +from lab.samples import Location, Sample +from lab.units import celsius, seconds, uL, units + + +def _transfers( + protocol: Protocol, + wells: dict[str, Well], + allocations: tuple[Allocation, ...], + destination: Well, +) -> None: + for allocation in allocations: + protocol.transfer( + wells[allocation.implementation.identity], destination, volume=allocation.volume_ul * uL + ) + + +def _role(inputs: tuple[Allocation, ...], name: str) -> tuple[Allocation, ...]: + return tuple(allocation for allocation in inputs if allocation.role == name) + + +def build( + plan: BuildPlan, + *, + reagent_container: Container | None = None, + preparation_container: ContainerSpec | None = None, +) -> ExperimentPlan: + """Generate one stage per selected reaction, preserving material quantities. + + External procedures compile as operator stages. Counted stocks occupy logical + positions there; those positions do not prescribe robot labware. Every input + load and substrate is an explicit precondition, not an inferred preparation. + """ + plan.require_ready() + reagent_container = reagent_container or Container( + id="reagents", + labware=PCR_PLATE_96, + site=DeckSite.PLATES, + ) + preparation_container = preparation_container or ContainerSpec( + id="products", labware=PCR_PLATE_96 + ) + if reagent_container.id != "reagents" or preparation_container.id != "products": + raise ValueError("Container ids must be 'reagents' and 'products', respectively") + document = Document.from_snapshot(plan.document) + stages: list[ProtocolStage] = [] + remaining = {stock.implementation.identity: stock.amount for stock in plan.inventory.stocks} + produced_at: dict[str, tuple[str, Location, ContainerSpec]] = {} + for task in plan.tasks: + method = task.method + if method is None or task.recipe is None: + raise ValueError("The builder requires resolved typed recipes and methods") + external = isinstance(method, ExternalPreparationMethod) + plating = isinstance(method, PlatingMethod) + product_container = ( + preparation_container + if external + else Container( + id="agar" if plating else "products", + labware=PCR_PLATE_96, + site=DeckSite.MORE_PLATES if plating else DeckSite.THERMOCYCLER, + ) + ) + protocol_id = task.identity + "/protocol" + description = f"Planned {task.kind.value} using {method.identity}." + if isinstance(method, PlatingMethod): + description += ( + f" Supplied substrate: {method.substrate.identity}. " + "Output is a deposited sample, not a confirmed colony." + ) + protocol = Protocol( + name=f"{task.kind.value.capitalize()} {len(stages) + 1}", + identity=protocol_id, + description=description, + ) + containers: list[ContainerSpec] = [reagent_container, product_container] + plates = { + container.id: protocol.plate( + container.id, + shape=(container.labware.rows, container.labware.columns), + capacity=container.labware.capacity_ul * uL, + ) + for container in containers + } + container: ContainerSpec + if plating: + container = Container(id="dilutions", labware=PCR_PLATE_96, site=DeckSite.PLATES) + containers.append(container) + plates[container.id] = protocol.plate( + container.id, capacity=container.labware.capacity_ul * uL + ) + counted_stocks = { + a.implementation for a in task.inputs if a.form.counted and a.producer is None + } + if counted_stocks: + container = ContainerSpec( + id="external_inputs", + labware=LabwareSpec( + kind=LabwareKind.PLATE, + rows=1, + columns=len(counted_stocks), + capacity_ul=Decimal(1), + ), + ) + containers.append(container) + plates[container.id] = protocol.plate( + container.id, shape=(1, len(counted_stocks)), capacity=1 * uL + ) + wells: dict[str, Well] = {} + handoffs: list[MaterialHandoff] = [] + stock_index = count_index = 0 + samples: dict[str, Sample] = {} + for allocation in task.inputs: + identity = allocation.implementation.identity + if identity in wells: + continue + if allocation.producer is None: + if allocation.form.counted: + well = plates["external_inputs"][f"A{count_index + 1}"] + count_index += 1 + else: + names = plates["reagents"].wells + if stock_index >= len(names): + raise ValueError("Inputs exceed the reagent container's number of wells") + well = plates["reagents"][names[stock_index]] + stock_index += 1 + else: + producer, source, previous_container = produced_at[identity] + name = f"upstream_{len(handoffs) + 1}" + container = Container( + id=name, + labware=previous_container.labware, + site=DeckSite.MORE_PLATES + if sum( + isinstance(item, Container) and item.site is DeckSite.PLATES + for item in containers + ) + >= 2 + else DeckSite.PLATES, + ) + containers.append(container) + plate = protocol.plate( + name, + shape=(container.labware.rows, container.labware.columns), + capacity=container.labware.capacity_ul * uL, + ) + well = plate[source.well] + handoffs.append( + MaterialHandoff( + producer=producer, + implementation=allocation.implementation, + source=source, + destination=Location(well.resource, well.name), + volume_ul=Decimal(0) if allocation.form.counted else remaining[identity], + count=int(remaining[identity]) if allocation.form.counted else None, + ) + ) + wells[identity] = well + if not allocation.form.counted: + protocol.load(well, identity, volume=remaining[identity] * uL) + sample = Sample( + id=protocol_id + f"/input_{len(samples) + 1}", + material_identity=allocation.design.identity, + label=allocation.design.identity, + design=allocation.design, + implementation=allocation.implementation, + form=allocation.form, + count=int(remaining[identity]) if allocation.form.counted else None, + ) + samples[identity] = sample + protocol.add_sample(sample, at=well, is_input=True) + output = plates[product_container.id]["A1"] + + if isinstance(method, AssemblyMethod): + _transfers(protocol, wells, task.inputs, output) + protocol.mix(output, volume=method.mix_volume_ul * uL, cycles=method.mix_cycles) + protocol.thermocycle( + plates[product_container.id], + tuple((celsius(h.celsius), h.seconds * seconds) for h in method.profile), + cycles=method.cycles, + lid_temperature=None if method.lid_celsius is None else celsius(method.lid_celsius), + ) + physical_output = method.reaction_volume_ul + elif isinstance(method, TransformationMethod): + assert isinstance(task.recipe, TransformationRecipe) + if method.initial_celsius is not None: + protocol.set_temperature( + plates[product_container.id], celsius(method.initial_celsius) + ) + _transfers(protocol, wells, _role(task.inputs, "cells"), output) + protocol.mix( + output, volume=method.cell_mix_volume_ul * uL, cycles=method.cell_mix_cycles + ) + for allocation in task.inputs: + if allocation.role.startswith("dna_"): + protocol.mix( + wells[allocation.implementation.identity], + volume=allocation.volume_ul * uL, + cycles=method.dna_mix_cycles, + ) + _transfers(protocol, wells, (allocation,), output) + physical_output = method.reaction_volume(len(task.recipe.plasmids)) + protocol.thermocycle( + plates[product_container.id], + tuple((celsius(h.celsius), h.seconds * seconds) for h in method.profile), + block_volume=(physical_output - method.recovery.volume_ul) * uL, + ) + _transfers(protocol, wells, _role(task.inputs, "recovery"), output) + protocol.thermocycle( + plates[product_container.id], + tuple((celsius(h.celsius), h.seconds * seconds) for h in method.recovery_profile), + block_volume=physical_output * uL, + ) + elif isinstance(method, PlatingMethod): + if len(method.dilution_factors) > len(plates["dilutions"].wells): + raise ValueError("Dilution series exceeds the plate's number of wells") + previous = None + previous_sample = None + for index, _factor in enumerate(method.dilution_factors): + destination = plates["dilutions"][plates["dilutions"].wells[index]] + additions = _role(task.inputs, f"diluent_{index + 1}") + _transfers(protocol, wells, additions, destination) + if previous is None: + _transfers(protocol, wells, _role(task.inputs, "culture"), destination) + parents = tuple( + samples[a.implementation.identity].id + for a in (*additions, *_role(task.inputs, "culture")) + ) + else: + protocol.transfer(previous, destination, volume=method.transfer_volume_ul * uL) + parents = ( + *(samples[a.implementation.identity].id for a in additions), + str(previous_sample), + ) + protocol.mix( + destination, volume=method.mix_volume_ul * uL, cycles=method.mix_cycles + ) + material = Implementation( + identity=task.identity + f"/dilution_{index + 1}", + derived_from=(task.design,), + generated_by=(document.get(task.identity, Activity).ref,), + evidence_state=EvidenceState.PLANNED, + ) + document.add(material) + sample = Sample( + id=material.identity, + material_identity=task.design.identity, + label=task.design.identity, + design=task.design, + implementation=material.ref, + role="dilution", + dilution=index + 1, + parent_ids=tuple(dict.fromkeys(parents)), + ) + protocol.add_sample(sample, at=destination) + previous, previous_sample = destination, sample.id + assert previous is not None + protocol.transfer( + previous, + output, + volume=method.spot_volume_ul * uL, + destination_height=method.spot_height_mm * units.millimeter, + ) + physical_output = Decimal(1) + else: + consumed: dict[str, Decimal] = defaultdict(Decimal) + for allocation in task.inputs: + consumed[allocation.implementation.identity] += allocation.amount + ports = tuple( + MaterialPort( + location=Location(wells[identity].resource, wells[identity].name), + volume_ul=Decimal(0) if samples[identity].count is not None else amount, + count=int(amount) if samples[identity].count is not None else 0, + ) + for identity, amount in consumed.items() + ) + protocol.external_preparation( + procedure=method.procedure, + instructions=method.instructions, + inputs=ports, + outputs=( + MaterialPort( + location=Location(output.resource, output.name), + volume_ul=method.output_volume_ul, + count=method.output_count, + ), + ), + ) + physical_output = ( + Decimal(method.output_count) + if task.output_form.counted + else method.output_volume_ul + ) + for allocation in task.inputs: + remaining[allocation.implementation.identity] -= allocation.amount + protocol.add_sample( + Sample( + id=task.output.identity, + material_identity=task.design.identity, + label=task.design.identity, + design=task.design, + implementation=task.output, + form=task.output_form, + count=task.output_count if task.output_form.counted else None, + parent_ids=(str(previous_sample),) + if plating + else tuple(sample.id for sample in samples.values()), + role=task.kind.value, + ), + at=output, + is_output=True, + ) + remaining[task.output.identity] = physical_output + produced_at[task.output.identity] = ( + task.identity, + Location(output.resource, output.name), + product_container, + ) + recorded = protocol.snapshot() + specification = Plan( + identity=task.identity + "/specification", + protocol=recorded.identity, + derived_from=(document.get(method.identity, Plan).ref,), + ) + document.add(specification) + activity = document.get(task.identity, Activity) + document.replace( + replace( + activity, + association=tuple( + replace(association, plan=specification.ref) + for association in activity.association + ), + ) + ) + stages.append( + ProtocolStage( + identity=task.identity, + protocol=recorded, + deck=Deck(containers=tuple(containers)), + depends_on=task.depends_on, + handoffs=tuple(handoffs), + external=external, + ) + ) + return ExperimentPlan( + identity=plan.request.identity, + provenance=document.freeze(), + stages=tuple(stages), + build_json=plan.plan_json, + inputs=( + SourceArtifact(name="build-provenance.ttl", text=plan.document.to_turtle()), + SourceArtifact( + name="inventory.json", + text=canonical_json( + {"format": "lab.inventory.v1", "inventory": plan.inventory}, + ), + ), + SourceArtifact( + name="catalog.json", + text=canonical_json( + {"format": "lab.catalog.v1", "catalog": plan.catalog}, + ), + ), + SourceArtifact( + name="system.json", + text=canonical_json( + {"format": "lab.cloning-system.v1", "system": plan.system}, + ), + ), + SourceArtifact( + name="methods.json", + text=canonical_json( + {"format": "lab.cloning-methods.v1", "methods": plan.methods}, + ), + ), + ), + ) diff --git a/src/lab/experiments/cloning/domestication.py b/src/lab/experiments/cloning/domestication.py new file mode 100644 index 0000000..e051ebc --- /dev/null +++ b/src/lab/experiments/cloning/domestication.py @@ -0,0 +1,144 @@ +"""Sequence edit proposals that require an explicit caller selection.""" + +from dataclasses import dataclass, replace + +from lab.experiments.cloning._dna import DnaSequence +from lab.experiments.cloning.sequences import sequence_record +from lab.provenance import ( + Activity, + Component, + Document, + DocumentSnapshot, + EvidenceState, + Ref, + Sequence, + Usage, +) +from lab.provenance.types import require_iri +from lab.provenance.vocabulary import IUPAC_DNA, LAB + + +@dataclass(frozen=True, kw_only=True) +class SequenceEdit: + position: int + before: str + after: str + + def __post_init__(self) -> None: + if type(self.position) is not int or self.position < 0: + raise ValueError("Edit positions must be nonnegative integers") + if ( + self.before not in tuple("ACGT") + or self.after not in tuple("ACGT") + or self.before == self.after + ): + raise ValueError("An edit replaces one unambiguous base with a different base") + + +@dataclass(frozen=True, kw_only=True) +class EditProposal: + """A single-base candidate; no claim is made about biological function. + + Positions are zero-based. Further sites can remain after this edit. Applying + a proposal creates a new design; it does not alter inventory or a recipe. + """ + + component: Ref[Component] + sequence: Ref[Sequence] + enzyme: str + edit: SequenceEdit + remaining_sites: int + + def apply(self, document: DocumentSnapshot, *, identity: str) -> DocumentSnapshot: + require_iri(identity) + original = document.get(self.component.identity, Component) + sequence = document.get(self.sequence.identity, Sequence) + if sequence.ref not in original.sequences: + raise ValueError("The proposal sequence does not belong to its component") + if ( + sequence.elements[self.edit.position : self.edit.position + 1].upper() + != self.edit.before + ): + raise ValueError("Edit proposal no longer matches the input sequence") + if identity == original.identity: + raise ValueError("An edited design needs a new identity") + elements = ( + sequence.elements[: self.edit.position] + + self.edit.after + + sequence.elements[self.edit.position + 1 :] + ) + activity = Activity( + identity=identity + "/edit", + types=(LAB + "sequenceEdit",), + usage=(Usage(entity=original.ref), Usage(entity=sequence.ref)), + evidence_state=EvidenceState.RECORDED, + ) + edited_sequence = replace( + sequence, + identity=identity + "/sequence", + namespace=None, + elements=elements, + derived_from=(sequence.ref,), + generated_by=(activity.ref,), + ) + # Coordinate-bearing annotations require review after an edit. Keep the + # original linked rather than copying annotations onto changed bases. + edited = Component( + identity=identity, + types=original.types, + roles=original.roles, + name=original.name, + sequences=(edited_sequence.ref,), + derived_from=(original.ref,), + generated_by=(activity.ref,), + ) + result = Document.from_snapshot(document) + result.add(activity, edited_sequence, edited) + return result.freeze() + + +def propose_edits( + component: Ref[Component], + *, + document: DocumentSnapshot, + enzyme: str, + editable_positions: tuple[int, ...], +) -> tuple[EditProposal, ...]: + """Enumerate substitutions only at positions explicitly declared editable. + + Retain candidates that reduce this enzyme's cut count. The caller must review + coding/regulatory effects and other assembly-system constraints before use. + """ + record = sequence_record(component, document) + design = document.get(component.identity, Component) + sequence = next( + sequence + for ref in design.sequences + if (sequence := document.get(ref.identity, Sequence)).encoding == IUPAC_DNA + ) + baseline = len(record.cuts(enzyme)) + if not isinstance(editable_positions, tuple) or len(set(editable_positions)) != len( + editable_positions + ): + raise ValueError("Editable positions must be a tuple of unique positions") + result: list[EditProposal] = [] + for position in sorted(editable_positions): + if type(position) is not int or not 0 <= position < len(sequence.elements): + raise ValueError("Editable position lies outside the sequence") + before = sequence.elements[position].upper() + for after in "ACGT": + if after == before: + continue + candidate = record.elements[:position] + after + record.elements[position + 1 :] + remaining = len(DnaSequence(candidate, record.circular).cuts(enzyme)) + if remaining < baseline: + result.append( + EditProposal( + component=component, + sequence=sequence.ref, + enzyme=enzyme, + edit=SequenceEdit(position=position, before=before, after=after), + remaining_sites=remaining, + ) + ) + return tuple(result) diff --git a/src/lab/experiments/cloning/methods.py b/src/lab/experiments/cloning/methods.py new file mode 100644 index 0000000..407b3be --- /dev/null +++ b/src/lab/experiments/cloning/methods.py @@ -0,0 +1,405 @@ +"""Resolved method parameters, supplied independently of provenance graphs.""" + +import json +from dataclasses import dataclass +from decimal import Decimal +from pathlib import Path +from typing import TypeVar + +from lab.artifacts import canonical_json, write_bundle +from lab.operations import Hold +from lab.provenance import Component, Ref +from lab.provenance.types import require_iri + + +def positive(value: Decimal, label: str) -> None: + if not isinstance(value, Decimal) or not value.is_finite() or value <= 0: + raise ValueError(f"{label} must be a positive finite Decimal") + + +@dataclass(frozen=True, kw_only=True) +class Reagent: + component: Ref[Component] + volume_ul: Decimal + + def __post_init__(self) -> None: + if not isinstance(self.component, Ref): + raise TypeError("Reagents need a component reference") + positive(self.volume_ul, "Reagent volume") + + +@dataclass(frozen=True, kw_only=True) +class AssemblyMethod: + """All volumes are microlitres; all thermal holds use seconds and Celsius. + + ``output_volume_ul`` is an explicit planned usable yield, not a measurement. + The enzyme is an ordinary explicit reagent in this method. + """ + + identity: str + enzyme: str + dna_volume_ul: Decimal + reaction_volume_ul: Decimal + output_volume_ul: Decimal + reagents: tuple[Reagent, ...] + diluent: Ref[Component] + profile: tuple[Hold, ...] + cycles: int + mix_volume_ul: Decimal + mix_cycles: int + lid_celsius: Decimal | None = None + + def __post_init__(self) -> None: + require_iri(self.identity) + for name in ("dna_volume_ul", "reaction_volume_ul", "output_volume_ul", "mix_volume_ul"): + positive(getattr(self, name), name) + if ( + self.output_volume_ul > self.reaction_volume_ul + or self.mix_volume_ul > self.reaction_volume_ul + ): + raise ValueError("Usable output and mixing volumes cannot exceed reaction volume") + if not isinstance(self.reagents, tuple) or not all( + isinstance(item, Reagent) for item in self.reagents + ): + raise TypeError("Reagents must be a tuple of Reagent objects") + if not isinstance(self.diluent, Ref): + raise TypeError("Diluent must be a component reference") + if ( + not isinstance(self.profile, tuple) + or not self.profile + or not all(isinstance(hold, Hold) for hold in self.profile) + ): + raise ValueError("Supply an explicit thermal profile") + for hold in self.profile: + if ( + not isinstance(hold.celsius, Decimal) + or not hold.celsius.is_finite() + or hold.celsius < Decimal("-273.15") + ): + raise ValueError("Thermal temperatures must be finite Decimal Celsius values") + positive(hold.seconds, "Hold duration") + for value in (self.cycles, self.mix_cycles): + if type(value) is not int or value < 1: + raise ValueError("Cycle counts must be positive integers") + if self.lid_celsius is not None and ( + not isinstance(self.lid_celsius, Decimal) + or not self.lid_celsius.is_finite() + or self.lid_celsius < 0 + ): + raise ValueError("Lid temperature must be nonnegative finite Decimal Celsius") + + def additions(self, components: tuple[Ref[Component], ...]) -> tuple[Reagent, ...]: + dna = tuple( + Reagent(component=component, volume_ul=self.dna_volume_ul) for component in components + ) + water = self.reaction_volume_ul - sum( + (item.volume_ul for item in (*dna, *self.reagents)), Decimal(0) + ) + if water < 0: + raise ValueError("Method additions exceed the reaction volume") + return ( + *self.reagents, + *dna, + *((Reagent(component=self.diluent, volume_ul=water),) if water else ()), + ) + + +def thermal_profile(profile: tuple[Hold, ...]) -> None: + if not isinstance(profile, tuple) or not profile: + raise ValueError("Supply an explicit nonempty thermal profile") + for hold in profile: + if not isinstance(hold, Hold): + raise TypeError("Thermal profiles contain Hold values") + if ( + not isinstance(hold.celsius, Decimal) + or not hold.celsius.is_finite() + or hold.celsius < Decimal("-273.15") + ): + raise ValueError("Invalid Celsius temperature") + positive(hold.seconds, "Hold duration") + + +@dataclass(frozen=True, kw_only=True) +class TransformationMethod: + """Explicit cell/DNA additions, treatment, medium addition, and recovery. + + The output is an unverified recovery mixture, never a confirmed clone. + No temperatures, durations, or biological yields are inferred. + """ + + identity: str + cell_volume_ul: Decimal + dna_volume_ul: Decimal + recovery: Reagent + profile: tuple[Hold, ...] + recovery_profile: tuple[Hold, ...] + output_volume_ul: Decimal + cell_mix_volume_ul: Decimal + cell_mix_cycles: int + dna_mix_cycles: int + initial_celsius: Decimal | None = None + + def __post_init__(self) -> None: + require_iri(self.identity) + for name in ("cell_volume_ul", "dna_volume_ul", "output_volume_ul", "cell_mix_volume_ul"): + positive(getattr(self, name), name) + if self.cell_mix_volume_ul > self.cell_volume_ul: + raise ValueError("Cell mixing volume cannot exceed the allocated cell volume") + if not isinstance(self.recovery, Reagent): + raise TypeError("Recovery medium must be a Reagent") + thermal_profile(self.profile) + thermal_profile(self.recovery_profile) + for value in (self.cell_mix_cycles, self.dna_mix_cycles): + if type(value) is not int or value < 1: + raise ValueError("Mixing cycles must be positive integers") + if self.initial_celsius is not None: + thermal_profile((Hold(self.initial_celsius, Decimal(1)),)) + + def reaction_volume(self, plasmids: int) -> Decimal: + volume = self.cell_volume_ul + self.dna_volume_ul * plasmids + self.recovery.volume_ul + if self.output_volume_ul > volume: + raise ValueError("Usable transformation output cannot exceed the added liquid") + return volume + + +@dataclass(frozen=True, kw_only=True) +class PlatingMethod: + """One deposited sample after an explicit dilution series. + + Each planned reaction produces one counted spot at the final dilution. + Repeated targets produce independent series; no colony yield is predicted. + The substrate is a supplied plate precondition, like other deck consumables. + """ + + identity: str + substrate: Ref[Component] + diluent: Ref[Component] + transfer_volume_ul: Decimal + dilution_factors: tuple[Decimal, ...] + spot_volume_ul: Decimal + mix_volume_ul: Decimal + mix_cycles: int + spot_height_mm: Decimal + + def __post_init__(self) -> None: + require_iri(self.identity) + if not isinstance(self.substrate, Ref) or not isinstance(self.diluent, Ref): + raise TypeError("Plating substrate and diluent must be component references") + for name in ("transfer_volume_ul", "spot_volume_ul", "mix_volume_ul", "spot_height_mm"): + positive(getattr(self, name), name) + if not isinstance(self.dilution_factors, tuple) or not self.dilution_factors: + raise ValueError("Supply an explicit dilution series") + for factor in self.dilution_factors: + positive(factor, "Dilution factor") + if factor <= 1 or self.mix_volume_ul > self.transfer_volume_ul * factor: + raise ValueError("Dilutions must increase volume and cover the mixing volume") + if self.spot_volume_ul > self.transfer_volume_ul * self.dilution_factors[-1]: + raise ValueError("The last dilution cannot supply the specified spot volume") + if type(self.mix_cycles) is not int or self.mix_cycles < 1: + raise ValueError("Mixing cycles must be positive integers") + if ( + not isinstance(self.spot_height_mm, Decimal) + or not self.spot_height_mm.is_finite() + or self.spot_height_mm < 0 + ): + raise ValueError("Specify a nonnegative spotting height above the well bottom") + + +@dataclass(frozen=True, kw_only=True) +class ExternalPreparationMethod: + """A caller-specified external procedure and prospective material balance. + + Exactly one input and output quantity kind is required. Other consumed + liquids can be named as reagents. The procedure must specify handling, + waste, and losses; a declared yield is not a measurement or a robot action. + """ + + identity: str + procedure: str + instructions: str + source_volume_ul: Decimal = Decimal(0) + source_count: int = 0 + output_volume_ul: Decimal = Decimal(0) + output_count: int = 0 + reagents: tuple[Reagent, ...] = () + + def __post_init__(self) -> None: + require_iri(self.identity) + require_iri(self.procedure) + if not isinstance(self.instructions, str) or not self.instructions.strip(): + raise ValueError("External preparation needs explicit instructions") + for volume, count in ( + (self.source_volume_ul, self.source_count), + (self.output_volume_ul, self.output_count), + ): + if ( + not isinstance(volume, Decimal) + or not volume.is_finite() + or volume < 0 + or type(count) is not int + or count < 0 + or (volume > 0) == (count > 0) + ): + raise ValueError("Specify either a positive volume or a positive count") + if not isinstance(self.reagents, tuple) or not all( + isinstance(r, Reagent) for r in self.reagents + ): + raise TypeError("Preparation reagents must be an immutable tuple") + + +Method = AssemblyMethod | TransformationMethod | PlatingMethod | ExternalPreparationMethod +M = TypeVar("M", bound=Method) + + +@dataclass(frozen=True, kw_only=True) +class CloningMethods: + assemblies: tuple[AssemblyMethod, ...] = () + transformations: tuple[TransformationMethod, ...] = () + platings: tuple[PlatingMethod, ...] = () + preparations: tuple[ExternalPreparationMethod, ...] = () + + def __post_init__(self) -> None: + for name, cls in ( + ("assemblies", AssemblyMethod), + ("transformations", TransformationMethod), + ("platings", PlatingMethod), + ("preparations", ExternalPreparationMethod), + ): + items = getattr(self, name) + if not isinstance(items, tuple) or not all(isinstance(item, cls) for item in items): + raise TypeError(f"{name} must contain an immutable tuple of {cls.__name__}") + object.__setattr__(self, name, tuple(sorted(items, key=lambda item: item.identity))) + if len({method.identity for method in self.all}) != len(self.all): + raise ValueError("Method identities must be unique") + + @property + def all(self) -> tuple[Method, ...]: + return (*self.assemblies, *self.transformations, *self.platings, *self.preparations) + + def get(self, identity: str, kind: type[M]) -> M: + for method in self.all: + if method.identity == identity: + if not isinstance(method, kind): + raise TypeError(f"Method {identity} must be a {kind.__name__}") + return method + raise KeyError(identity) + + def write(self, path: str | Path) -> Path: + path = Path(path) + write_bundle( + path.parent, + { + path.name: canonical_json( + { + "format": "lab.cloning-methods.v1", + "methods": self, + } + ) + }, + ) + return path + + @classmethod + def read(cls, path: str | Path) -> "CloningMethods": + data = json.loads(Path(path).read_text(encoding="utf-8"), parse_float=Decimal) + if data.get("format") != "lab.cloning-methods.v1": + raise ValueError("Expected lab.cloning-methods.v1") + methods = [] + for row in data["methods"]["assemblies"]: + decimal_fields = ( + "dna_volume_ul", + "reaction_volume_ul", + "output_volume_ul", + "mix_volume_ul", + ) + methods.append( + AssemblyMethod( + **{ + **row, + **{name: Decimal(row[name]) for name in decimal_fields}, + "lid_celsius": None + if row["lid_celsius"] is None + else Decimal(row["lid_celsius"]), + "reagents": tuple( + Reagent( + component=Ref(reagent["component"]["identity"]), + volume_ul=Decimal(reagent["volume_ul"]), + ) + for reagent in row["reagents"] + ), + "diluent": Ref(row["diluent"]["identity"]), + "profile": tuple( + Hold(Decimal(hold["celsius"]), Decimal(hold["seconds"])) + for hold in row["profile"] + ), + } + ) + ) + + def reagent(row: dict) -> Reagent: + return Reagent( + component=Ref(row["component"]["identity"]), volume_ul=Decimal(row["volume_ul"]) + ) + + def profile(rows: list) -> tuple[Hold, ...]: + return tuple(Hold(Decimal(row["celsius"]), Decimal(row["seconds"])) for row in rows) + + return cls( + assemblies=tuple(methods), + transformations=tuple( + TransformationMethod( + **{ + **row, + **{ + name: Decimal(row[name]) + for name in ( + "cell_volume_ul", + "dna_volume_ul", + "output_volume_ul", + "cell_mix_volume_ul", + ) + }, + "initial_celsius": None + if row["initial_celsius"] is None + else Decimal(row["initial_celsius"]), + "recovery": reagent(row["recovery"]), + "profile": profile(row["profile"]), + "recovery_profile": profile(row["recovery_profile"]), + } + ) + for row in data["methods"]["transformations"] + ), + platings=tuple( + PlatingMethod( + **{ + **row, + "substrate": Ref(row["substrate"]["identity"]), + "diluent": Ref(row["diluent"]["identity"]), + **{ + name: Decimal(row[name]) + for name in ( + "transfer_volume_ul", + "spot_volume_ul", + "mix_volume_ul", + "spot_height_mm", + ) + }, + "dilution_factors": tuple( + Decimal(factor) for factor in row["dilution_factors"] + ), + } + ) + for row in data["methods"]["platings"] + ), + preparations=tuple( + ExternalPreparationMethod( + **{ + **row, + "source_volume_ul": Decimal(row["source_volume_ul"]), + "output_volume_ul": Decimal(row["output_volume_ul"]), + "reagents": tuple(reagent(r) for r in row["reagents"]), + } + ) + for row in data["methods"]["preparations"] + ), + ) diff --git a/src/lab/experiments/cloning/planning.py b/src/lab/experiments/cloning/planning.py new file mode 100644 index 0000000..57be116 --- /dev/null +++ b/src/lab/experiments/cloning/planning.py @@ -0,0 +1,701 @@ +"""Pure inventory allocation and dependency-aware assembly route planning.""" + +import hashlib +from dataclasses import dataclass, field, replace +from decimal import Decimal +from enum import StrEnum +from pathlib import Path + +from lab._version import __version__ +from lab.artifacts import canonical_json, digest, write_bundle +from lab.experiments.cloning.domestication import EditProposal, propose_edits +from lab.experiments.cloning.methods import ( + CloningMethods, + ExternalPreparationMethod, + Method, + PlatingMethod, + positive, +) +from lab.experiments.cloning.routes import resolve_recipe +from lab.experiments.cloning.sequences import calculate_assembly +from lab.experiments.cloning.systems import ( + AssemblyRecipe, + CloningSystem, + Recipe, +) +from lab.inventory import Inventory, MaterialForm +from lab.provenance import ( + Activity, + Agent, + AgentKind, + Association, + Component, + Document, + DocumentSnapshot, + EvidenceState, + Implementation, + Plan, + Ref, + Usage, +) +from lab.provenance.types import require_iri +from lab.provenance.vocabulary import LAB +from lab.suppliers.types import AcquisitionRequest, Catalog, Receipt + + +class TaskKind(StrEnum): + ASSEMBLY = "assembly" + TRANSFORMATION = "transformation" + PLATING = "plating" + ACQUISITION = "acquisition" + PREPARATION = "preparation" + + +class RequirementKind(StrEnum): + ACQUISITION = "acquisition" + PREPARATION = "preparation" + DESIGN = "design" + + +@dataclass(frozen=True, kw_only=True) +class BuildTarget: + design: Ref[Component] + volume_ul: Decimal + form: MaterialForm = MaterialForm.DNA + + def __post_init__(self) -> None: + if not isinstance(self.design, Ref) or not isinstance(self.form, MaterialForm): + raise TypeError("Targets need a design reference and a MaterialForm") + positive(self.volume_ul, "Target volume") + if self.form.counted: + raise ValueError("Use CountTarget for counted material forms") + + @property + def amount(self) -> Decimal: + return self.volume_ul + + +@dataclass(frozen=True, kw_only=True) +class CountTarget: + design: Ref[Component] + count: int + form: MaterialForm = MaterialForm.PLATED_SAMPLE + + def __post_init__(self) -> None: + if ( + not isinstance(self.design, Ref) + or not isinstance(self.form, MaterialForm) + or not self.form.counted + ): + raise TypeError("Count targets need a design reference and a counted material form") + if type(self.count) is not int or self.count < 1: + raise ValueError("Target count must be a positive integer") + + @property + def amount(self) -> Decimal: + return Decimal(self.count) + + +@dataclass(frozen=True, kw_only=True) +class BuildRequest: + identity: str + targets: tuple[BuildTarget | CountTarget, ...] + + def __post_init__(self) -> None: + require_iri(self.identity) + if ( + not isinstance(self.targets, tuple) + or not self.targets + or not all(isinstance(target, (BuildTarget, CountTarget)) for target in self.targets) + ): + raise ValueError("A build request needs a nonempty tuple of targets") + if len({(target.design, target.form) for target in self.targets}) != len(self.targets): + raise ValueError("Combine quantities for duplicate targets") + + +@dataclass(frozen=True, kw_only=True) +class EditablePositions: + component: Ref[Component] + positions: tuple[int, ...] + + def __post_init__(self) -> None: + if not isinstance(self.component, Ref): + raise TypeError("Editable component must be a reference") + if ( + not isinstance(self.positions, tuple) + or any(type(position) is not int or position < 0 for position in self.positions) + or len(set(self.positions)) != len(self.positions) + ): + raise ValueError("Editable positions must be a tuple of unique nonnegative integers") + + +@dataclass(frozen=True, kw_only=True) +class PlanningPolicy: + """Reuse available stock, then minimize missing work, depth, and reactions. + + Route combinations are explored across the entire request. Exceeding the + explicit search limit is an error, never an undisclosed greedy fallback. + """ + + max_states: int = 4096 + editable_positions: tuple[EditablePositions, ...] = () + + def __post_init__(self) -> None: + if type(self.max_states) is not int or self.max_states < 1: + raise ValueError("max_states must be positive") + if not isinstance(self.editable_positions, tuple) or not all( + isinstance(item, EditablePositions) for item in self.editable_positions + ): + raise TypeError("Editable positions must be an immutable tuple") + if len({item.component for item in self.editable_positions}) != len( + self.editable_positions + ): + raise ValueError("Specify editable positions once per component") + + +@dataclass(frozen=True, kw_only=True) +class Allocation: + consumer: str + design: Ref[Component] + implementation: Ref[Implementation] + volume_ul: Decimal + stock: str | None + producer: str | None + form: MaterialForm = MaterialForm.DNA + count: int = 0 + role: str = "material" + + @property + def amount(self) -> Decimal: + return Decimal(self.count) if self.form.counted else self.volume_ul + + +@dataclass(frozen=True, kw_only=True) +class Requirement: + kind: RequirementKind + design: Ref[Component] + form: MaterialForm + volume_ul: Decimal + message: str + task: str | None = None + count: int = 0 + + +@dataclass(frozen=True, kw_only=True) +class BuildTask: + identity: str + kind: TaskKind + requested_design: Ref[Component] + design: Ref[Component] + output: Ref[Implementation] + output_volume_ul: Decimal + depends_on: tuple[str, ...] = () + inputs: tuple[Allocation, ...] = () + recipe: Recipe | None = None + method: Method | None = None + output_form: MaterialForm = MaterialForm.DNA + output_count: int = 0 + + +@dataclass(frozen=True, kw_only=True) +class BuildPlan: + request: BuildRequest + document: DocumentSnapshot + inventory: Inventory + system: CloningSystem + methods: CloningMethods + policy: PlanningPolicy + tasks: tuple[BuildTask, ...] + allocations: tuple[Allocation, ...] + products: tuple[Allocation, ...] + requirements: tuple[Requirement, ...] + catalog: Catalog = Catalog() + receipts: tuple[Receipt, ...] = () + acquisitions: tuple[AcquisitionRequest, ...] = () + edit_proposals: tuple[EditProposal, ...] = () + + @property + def ready(self) -> bool: + return not self.requirements + + def require_ready(self) -> None: + if not self.ready: + raise ValueError( + "Build is not ready:\n" + "\n".join(item.message for item in self.requirements) + ) + + @property + def plan_json(self) -> str: + return canonical_json( + { + "format": "lab.build.v1", + "request": self.request, + "provenance_sha256": self.document.digest, + "inventory_sha256": self.inventory.digest, + "system": self.system, + "methods": self.methods, + "policy": self.policy, + "tasks": self.tasks, + "allocations": self.allocations, + "products": self.products, + "requirements": self.requirements, + "catalog_sha256": self.catalog.digest, + "receipts": self.receipts, + "acquisitions": self.acquisitions, + "edit_proposals": self.edit_proposals, + } + ) + + @property + def digest(self) -> str: + return hashlib.sha256(self.plan_json.encode()).hexdigest() + + def write(self, directory: str | Path) -> Path: + return write_bundle( + directory, + { + "build.json": self.plan_json, + "provenance.ttl": self.document.to_turtle(), + "inventory.json": canonical_json( + {"format": "lab.inventory.v1", "inventory": self.inventory} + ), + "catalog.json": canonical_json( + {"format": "lab.catalog.v1", "catalog": self.catalog} + ), + }, + ) + + +@dataclass(frozen=True) +class _Supply: + requested: Ref[Component] + design: Ref[Component] + implementation: Ref[Implementation] + form: MaterialForm + remaining: Decimal + stock: str | None = None + producer: str | None = None + + +@dataclass +class _State: + document: DocumentSnapshot + supplies: dict[str, _Supply] + tasks: tuple[BuildTask, ...] = () + allocations: tuple[Allocation, ...] = () + requirements: tuple[Requirement, ...] = () + proposals: tuple[EditProposal, ...] = () + resolved: dict[str, Ref[Component]] = field(default_factory=dict) + + def copy(self) -> "_State": + return replace(self, supplies=dict(self.supplies), resolved=dict(self.resolved)) + + +def _take( + state: _State, + consumer: str, + design: Ref[Component], + form: MaterialForm, + amount: Decimal, + role: str = "material", +) -> Decimal: + for key, supply in sorted( + state.supplies.items(), key=lambda item: (item[1].stock is None, item[0]) + ): + if supply.requested != design or supply.form != form or not supply.remaining: + continue + taken = min(amount, supply.remaining) + allocation = Allocation( + consumer=consumer, + design=supply.design, + implementation=supply.implementation, + volume_ul=Decimal(0) if form.counted else taken, + count=int(taken) if form.counted else 0, + form=form, + role=role, + stock=supply.stock, + producer=supply.producer, + ) + state.allocations += (allocation,) + state.supplies[key] = replace(supply, remaining=supply.remaining - taken) + amount -= taken + if not amount: + break + return amount + + +def _score(state: _State) -> tuple[object, ...]: + depths: dict[str, int] = {} + for task in state.tasks: + depths[task.identity] = max((depths[parent] for parent in task.depends_on), default=0) + ( + task.recipe is not None + ) + return ( + sum(item.kind is RequirementKind.DESIGN for item in state.requirements), + sum(item.kind is RequirementKind.ACQUISITION for item in state.requirements), + sum(item.kind is RequirementKind.PREPARATION for item in state.requirements), + max(depths.values(), default=0), + sum(task.recipe is not None for task in state.tasks), + tuple( + task.recipe.identity if task.recipe else task.design.identity for task in state.tasks + ), + ) + + +def plan( + request: BuildRequest, + *, + document: DocumentSnapshot | Document, + inventory: Inventory, + system: CloningSystem, + methods: CloningMethods, + policy: PlanningPolicy | None = None, + catalog: Catalog | None = None, + receipts: tuple[Receipt, ...] = (), +) -> BuildPlan: + """Allocate a whole request and choose a deterministic feasible route. + + Planning never edits input sequences, depletes inventory, orders materials, + or operates a device. A plan with outstanding requirements is inspectable + but cannot be built into executable protocols. + """ + policy = policy or PlanningPolicy() + catalog = catalog or Catalog() + snapshot = document.freeze() if isinstance(document, Document) else document + snapshot.validate().raise_for_errors() + inventory.validate(snapshot) + for entry in catalog.entries: + snapshot.resolve(entry.design) + if not isinstance(receipts, tuple) or not all(isinstance(item, Receipt) for item in receipts): + raise TypeError("Receipts must be an immutable tuple") + for receipt in receipts: + material = snapshot.resolve(receipt.implementation) + if ( + material.evidence_state is not EvidenceState.RECORDED + or receipt.design not in material.derived_from + ): + raise ValueError("Record received material in provenance before planning with it") + for target in request.targets: + snapshot.get(target.design.identity, Component) + for recipe in system.recipes: + snapshot.get(recipe.product.identity, Component) + resolved_recipe = resolve_recipe(recipe, methods) + for demand in resolved_recipe.inputs: + snapshot.get(demand.design.identity, Component) + if isinstance(resolved_recipe.method, PlatingMethod): + snapshot.resolve(resolved_recipe.method.substrate) + initial = _State( + snapshot, + { + stock.identity: _Supply( + requested=stock.design, + design=stock.design, + implementation=stock.implementation, + form=stock.form, + remaining=stock.amount, + stock=stock.identity, + ) + for stock in inventory.stocks + }, + ) + explored = 0 + + def satisfy( + state: _State, + consumer: str, + design: Ref[Component], + form: MaterialForm, + amount: Decimal, + stack: tuple[tuple[str, MaterialForm], ...], + role: str = "material", + ) -> list[_State]: + nonlocal explored + explored += 1 + if explored > policy.max_states: + raise ValueError( + f"Planning exceeded max_states={policy.max_states}; " + "narrow the route set or raise the limit" + ) + state = state.copy() + missing = _take(state, consumer, design, form, amount, role) + if not missing: + return [state] + if (design.identity, form) in stack: + state.requirements += ( + Requirement( + kind=RequirementKind.DESIGN, + design=design, + form=form, + volume_ul=Decimal(0) if form.counted else missing, + count=int(missing) if form.counted else 0, + message=f"Cyclic build dependency for {design.identity}", + ), + ) + return [state] + routes = system.routes(design, form) + if not routes: + candidates = [ + stock + for stock in inventory.stocks + if stock.design == design and stock.form != form and stock.amount > 0 + ] + received = tuple(receipt for receipt in receipts if receipt.design == design) + kind = TaskKind.PREPARATION if candidates or received else TaskKind.ACQUISITION + task_id = request.identity + f"/task_{len(state.tasks) + 1}" + implementation = Implementation( + identity=task_id + "/output", + derived_from=(design,), + evidence_state=EvidenceState.PLANNED, + ) + doc = Document.from_snapshot(state.document) + doc.add(implementation) + state.document = doc.freeze() + state.tasks += ( + BuildTask( + identity=task_id, + kind=kind, + requested_design=design, + design=design, + output=implementation.ref, + output_volume_ul=Decimal(0) if form.counted else missing, + output_count=int(missing) if form.counted else 0, + output_form=form, + ), + ) + reason = ( + ( + f"Prepare {design.identity} as {form.value}; available forms: " + + ", ".join( + sorted( + {stock.form.value for stock in candidates} + | {receipt.form.value for receipt in received} + ) + ) + ) + if candidates or received + else ( + f"Acquire {missing} {'unit(s)' if form.counted else 'µL'} " + f"of {design.identity} as {form.value}" + ) + ) + state.requirements += ( + Requirement( + kind=RequirementKind(kind.value), + design=design, + form=form, + volume_ul=Decimal(0) if form.counted else missing, + count=int(missing) if form.counted else 0, + message=reason, + task=task_id, + ), + ) + state.supplies[implementation.identity] = _Supply( + design, design, implementation.ref, form, missing, producer=task_id + ) + _take(state, consumer, design, form, missing, role) + return [state] + outcomes: list[_State] = [] + for recipe in routes: + branch = state.copy() + resolved_recipe = resolve_recipe(recipe, methods) + method = resolved_recipe.method + task_id = ( + request.identity + + "/" + + recipe.kind + + "_" + + str(len(branch.tasks) + 1) + + "_" + + digest((recipe.identity, consumer))[:12] + ) + states = [branch] + for demand in resolved_recipe.inputs: + states = [ + next_state + for current in states + for next_state in satisfy( + current, + task_id, + demand.design, + demand.form, + demand.amount, + (*stack, (design.identity, form)), + demand.role, + ) + ] + for current in states: + try: + calculated = ( + calculate_assembly( + recipe, document=current.document, resolved=current.resolved + ) + if isinstance(recipe, AssemblyRecipe) + else None + ) + except (ValueError, KeyError) as error: + current.requirements += ( + Requirement( + kind=RequirementKind.DESIGN, + design=design, + form=form, + volume_ul=Decimal(0) if form.counted else missing, + count=int(missing) if form.counted else 0, + message=str(error), + ), + ) + for editable in policy.editable_positions: + if isinstance(recipe, AssemblyRecipe) and editable.component in tuple( + fragment.component for fragment in recipe.fragments + ): + current.proposals += propose_edits( + editable.component, + document=current.document, + enzyme=recipe.enzyme, + editable_positions=editable.positions, + ) + outcomes.append(current) + continue + inputs = tuple(item for item in current.allocations if item.consumer == task_id) + parents = tuple( + sorted({item.producer for item in inputs if item.producer is not None}) + ) + product = calculated.product.ref if calculated else design + planner = Agent( + identity=request.identity + "/planner", + kind=AgentKind.SOFTWARE, + name="Lab planner", + software_version=__version__, + ) + method_plan = Plan( + identity=method.identity, + protocol=method.procedure + if isinstance(method, ExternalPreparationMethod) + else None, + description=method.instructions + if isinstance(method, ExternalPreparationMethod) + else None, + ) + activity = Activity( + identity=task_id, + types=(LAB + recipe.kind,), + evidence_state=EvidenceState.PLANNED, + usage=tuple( + Usage(entity=ref) + for ref in dict.fromkeys(item.implementation for item in inputs) + ) + + ( + (Usage(entity=method.substrate, roles=(LAB + "substrate",)),) + if isinstance(method, PlatingMethod) + else () + ), + informed_by=tuple( + Ref(parent) + for parent in parents + if any( + task.identity == parent and task.recipe is not None + for task in current.tasks + ) + ), + association=( + Association( + agent=planner.ref, + plan=method_plan.ref, + roles=(LAB + "planner",), + ), + ), + ) + implementation = Implementation( + identity=task_id + "/output", + derived_from=(product,), + generated_by=(activity.ref,), + evidence_state=EvidenceState.PLANNED, + ) + doc = Document.from_snapshot(current.document) + doc.add( + *(calculated.objects if calculated else ()), + planner, + method_plan, + activity, + implementation, + ) + current.document = doc.freeze() + current.tasks += ( + BuildTask( + identity=task_id, + kind=TaskKind(recipe.kind), + requested_design=design, + design=product, + output=implementation.ref, + output_volume_ul=Decimal(0) + if form.counted + else resolved_recipe.output_amount, + output_count=int(resolved_recipe.output_amount) if form.counted else 0, + output_form=form, + depends_on=parents, + inputs=inputs, + recipe=recipe, + method=method, + ), + ) + if calculated is not None: + current.resolved[design.identity] = product + current.supplies[implementation.identity] = _Supply( + design, + product, + implementation.ref, + form, + resolved_recipe.output_amount, + producer=task_id, + ) + remainder = _take(current, consumer, design, form, missing, role) + if remainder: + outcomes.extend( + satisfy(current, consumer, design, form, remainder, stack, role) + ) + else: + outcomes.append(current) + return outcomes + + states = [initial] + for index, target in enumerate(request.targets): + consumer = request.identity + f"/target_{index + 1}" + states = [ + next_state + for state in states + for next_state in satisfy( + state, consumer, target.design, target.form, target.amount, () + ) + ] + winner = min(states, key=_score) + products = tuple( + item + for item in winner.allocations + if item.consumer.startswith(request.identity + "/target_") + ) + return BuildPlan( + request=request, + document=winner.document, + inventory=inventory, + system=system, + methods=methods, + policy=policy, + tasks=winner.tasks, + allocations=winner.allocations, + products=products, + requirements=winner.requirements, + catalog=catalog, + receipts=receipts, + acquisitions=tuple( + AcquisitionRequest( + identity=request.identity + f"/acquisition_{index + 1}", + design=item.design, + required_form=item.form, + volume_ul=item.volume_ul, + count=item.count, + candidates=catalog.candidates(item.design), + ) + for index, item in enumerate(winner.requirements) + if item.kind is RequirementKind.ACQUISITION + ), + edit_proposals=tuple(dict.fromkeys(winner.proposals)), + ) diff --git a/src/lab/experiments/cloning/routes.py b/src/lab/experiments/cloning/routes.py new file mode 100644 index 0000000..faef6b2 --- /dev/null +++ b/src/lab/experiments/cloning/routes.py @@ -0,0 +1,135 @@ +"""Resolve typed recipes into explicit material demands, without choosing a route.""" + +from dataclasses import dataclass +from decimal import Decimal + +from lab.experiments.cloning.methods import ( + AssemblyMethod, + CloningMethods, + ExternalPreparationMethod, + Method, + PlatingMethod, + TransformationMethod, +) +from lab.experiments.cloning.systems import ( + AssemblyRecipe, + ExternalPreparationRecipe, + PlatingRecipe, + Recipe, + TransformationRecipe, +) +from lab.inventory import MaterialForm +from lab.provenance import Component, Ref + + +@dataclass(frozen=True) +class Demand: + design: Ref[Component] + form: MaterialForm + amount: Decimal + role: str + + +@dataclass(frozen=True) +class ResolvedRecipe: + method: Method + inputs: tuple[Demand, ...] + output_amount: Decimal + + +def resolve_recipe(recipe: Recipe, methods: CloningMethods) -> ResolvedRecipe: + if isinstance(recipe, AssemblyRecipe): + assembly = methods.get(recipe.method, AssemblyMethod) + if recipe.enzyme != assembly.enzyme: + raise ValueError(f"Recipe {recipe.identity} and its method use different enzymes") + inputs = tuple( + Demand( + addition.component, + MaterialForm.DNA + if len(assembly.reagents) <= index < len(assembly.reagents) + len(recipe.fragments) + else MaterialForm.REAGENT, + addition.volume_ul, + f"addition_{index + 1}", + ) + for index, addition in enumerate( + assembly.additions(tuple(f.component for f in recipe.fragments)) + ) + ) + return ResolvedRecipe(assembly, inputs, assembly.output_volume_ul) + if isinstance(recipe, TransformationRecipe): + transformation = methods.get(recipe.method, TransformationMethod) + transformation.reaction_volume(len(recipe.plasmids)) + return ResolvedRecipe( + transformation, + ( + Demand( + recipe.chassis, + MaterialForm.COMPETENT_CELLS, + transformation.cell_volume_ul, + "cells", + ), + *( + Demand( + plasmid, MaterialForm.DNA, transformation.dna_volume_ul, f"dna_{index + 1}" + ) + for index, plasmid in enumerate(recipe.plasmids) + ), + Demand( + transformation.recovery.component, + MaterialForm.REAGENT, + transformation.recovery.volume_ul, + "recovery", + ), + ), + transformation.output_volume_ul, + ) + if isinstance(recipe, PlatingRecipe): + plating = methods.get(recipe.method, PlatingMethod) + return ResolvedRecipe( + plating, + ( + Demand(recipe.product, MaterialForm.CULTURE, plating.transfer_volume_ul, "culture"), + *( + Demand( + plating.diluent, + MaterialForm.REAGENT, + plating.transfer_volume_ul * (factor - 1), + f"diluent_{index + 1}", + ) + for index, factor in enumerate(plating.dilution_factors) + ), + ), + Decimal(1), + ) + if isinstance(recipe, ExternalPreparationRecipe): + preparation = methods.get(recipe.method, ExternalPreparationMethod) + if recipe.source_form.counted != bool( + preparation.source_count + ) or recipe.output_form.counted != bool(preparation.output_count): + raise ValueError("Preparation quantity kinds must match the declared material forms") + return ResolvedRecipe( + preparation, + ( + Demand( + recipe.source, + recipe.source_form, + Decimal(preparation.source_count) + if recipe.source_form.counted + else preparation.source_volume_ul, + "source", + ), + *( + Demand( + reagent.component, + MaterialForm.REAGENT, + reagent.volume_ul, + f"reagent_{index + 1}", + ) + for index, reagent in enumerate(preparation.reagents) + ), + ), + Decimal(preparation.output_count) + if recipe.output_form.counted + else preparation.output_volume_ul, + ) + raise TypeError(f"Unsupported recipe {type(recipe).__name__}") diff --git a/src/lab/experiments/cloning/sequences.py b/src/lab/experiments/cloning/sequences.py new file mode 100644 index 0000000..4d733e9 --- /dev/null +++ b/src/lab/experiments/cloning/sequences.py @@ -0,0 +1,188 @@ +"""Calculate products from explicitly selected restriction digest fragments.""" + +from collections.abc import Mapping +from dataclasses import dataclass + +from lab._version import __version__ +from lab.artifacts import digest +from lab.experiments.cloning._dna import DnaSequence, Duplex +from lab.experiments.cloning.systems import AssemblyRecipe, FragmentSelection +from lab.provenance import ( + Activity, + Agent, + AgentKind, + Association, + Component, + DocumentSnapshot, + EvidenceState, + Plan, + Ref, + Sequence, + TopLevel, + Usage, +) +from lab.provenance.vocabulary import DNA, IUPAC_DNA, LAB + +CIRCULAR = "https://identifiers.org/SO:0000988" +LINEAR = "https://identifiers.org/SO:0000987" + + +@dataclass(frozen=True, kw_only=True) +class DigestFragment: + selection: FragmentSelection + watson: str + crick: str + overhang: int + + def record(self) -> Duplex: + return Duplex(self.watson, self.crick, self.overhang) + + +def sequence_record( + component: Ref[Component], + document: DocumentSnapshot, + resolved: Mapping[str, Ref[Component]] | None = None, +) -> DnaSequence: + actual = (resolved or {}).get(component.identity, component) + design = document.get(actual.identity, Component) + sequences = [document.get(ref.identity, Sequence) for ref in design.sequences] + dna = [sequence for sequence in sequences if sequence.encoding == IUPAC_DNA] + if DNA not in design.types or len(dna) != 1: + raise ValueError(f"{design.identity} needs exactly one explicit DNA sequence") + if not dna[0].elements or set(dna[0].elements.upper()) - set("ACGT"): + raise ValueError(f"{design.identity} needs a complete, unambiguous DNA sequence") + if (CIRCULAR in design.types) == (LINEAR in design.types): + raise ValueError( + f"{design.identity} needs exactly one explicit circular or linear topology" + ) + return DnaSequence(dna[0].elements.upper(), circular=CIRCULAR in design.types) + + +def digest_fragments( + component: Ref[Component], + *, + document: DocumentSnapshot, + enzyme: str, + resolved: Mapping[str, Ref[Component]] | None = None, +) -> tuple[DigestFragment, ...]: + record = sequence_record(component, document, resolved) + result: list[DigestFragment] = [] + for left, right, fragment in record.digest(enzyme): + result.append( + DigestFragment( + selection=FragmentSelection(component=component, left_cut=left, right_cut=right), + watson=fragment.watson, + crick=fragment.crick, + overhang=fragment.overhang, + ) + ) + return tuple(result) + + +@dataclass(frozen=True, kw_only=True) +class AssemblyDesign: + product: Component + sequence: Sequence + activity: Activity + agent: Agent + plan: Plan + + @property + def objects(self) -> tuple[TopLevel, ...]: + return self.product, self.sequence, self.activity, self.agent, self.plan + + +def calculate_assembly( + recipe: AssemblyRecipe, + *, + document: DocumentSnapshot, + resolved: Mapping[str, Ref[Component]] | None = None, +) -> AssemblyDesign: + selected: list[Duplex] = [] + inputs: dict[str, Ref[Component]] = {} + for selection in recipe.fragments: + fragments = digest_fragments( + selection.component, document=document, enzyme=recipe.enzyme, resolved=resolved + ) + matches = [ + fragment + for fragment in fragments + if fragment.selection.left_cut == selection.left_cut + and fragment.selection.right_cut == selection.right_cut + ] + if len(matches) != 1: + raise ValueError( + f"{selection.component.identity}: cut pair " + f"({selection.left_cut}, {selection.right_cut}) " + "does not select exactly one digest fragment" + ) + fragment = matches[0].record() + selected.append(fragment.reverse_complement() if selection.reverse_complement else fragment) + actual = (resolved or {}).get(selection.component.identity, selection.component) + inputs[actual.identity] = actual + product = selected[0] + try: + for fragment in selected[1:]: + product = product.ligate(fragment) + elements = product.close() if recipe.circular else product.linear_sequence() + except (ValueError, TypeError) as error: + raise ValueError( + f"{recipe.identity}: selected fragment ends are incompatible: {error}" + ) from error + try: + intended = document.get(recipe.product.identity, Component) + except KeyError: + intended = None + if intended is not None: + inputs[intended.identity] = intended.ref + if intended.sequences: + expected = sequence_record(intended.ref, document) + equal = len(expected.elements) == len(elements) and ( + elements in expected.elements * 2 + if recipe.circular + else elements == expected.elements + ) + if expected.circular != recipe.circular or not equal: + raise ValueError( + f"{recipe.identity}: calculated sequence does not match the requested design" + ) + calculation_id = ( + recipe.identity + "/calculation_" + digest((recipe, tuple(inputs), tuple(selected)))[:16] + ) + agent = Agent( + identity=calculation_id + "/calculator", + kind=AgentKind.SOFTWARE, + name="Lab sequence calculation", + software_version=f"lab-compiler {__version__}; Biopython 1.84", + ) + plan = Plan( + identity=calculation_id + "/method", + description=( + f"Ordered restriction-fragment ligation using {recipe.enzyme}; " + f"circular={recipe.circular}." + ), + ) + activity = Activity( + identity=calculation_id, + types=(LAB + "sequenceCalculation",), + evidence_state=EvidenceState.RECORDED, + usage=tuple(Usage(entity=ref) for ref in inputs.values()), + association=(Association(agent=agent.ref, plan=plan.ref),), + ) + sequence = Sequence( + identity=calculation_id + "/sequence", + elements=elements, + encoding=IUPAC_DNA, + generated_by=(activity.ref,), + ) + design = Component( + identity=calculation_id + "/product", + name=None if intended is None else intended.name, + types=(DNA, CIRCULAR if recipe.circular else LINEAR), + sequences=(sequence.ref,), + derived_from=tuple(inputs.values()), + generated_by=(activity.ref,), + ) + return AssemblyDesign( + product=design, sequence=sequence, activity=activity, agent=agent, plan=plan + ) diff --git a/src/lab/experiments/cloning/systems.py b/src/lab/experiments/cloning/systems.py new file mode 100644 index 0000000..3125092 --- /dev/null +++ b/src/lab/experiments/cloning/systems.py @@ -0,0 +1,214 @@ +"""Explicit fragment selections and allowed assembly routes.""" + +import json +from dataclasses import dataclass, field +from pathlib import Path + +from Bio.Restriction.Restriction import RestrictionBatch + +from lab.artifacts import canonical_json, write_bundle +from lab.inventory import MaterialForm +from lab.provenance import Component, Ref +from lab.provenance.types import require_iri + + +@dataclass(frozen=True, kw_only=True) +class FragmentSelection: + """Select a digest fragment by zero-based Watson cut positions. + + ``None`` denotes an end of a linear molecule. For a circular molecule, equal + cut positions select a single-cut linearization. Reversal is explicit. + """ + + component: Ref[Component] + left_cut: int | None + right_cut: int | None + reverse_complement: bool = False + + def __post_init__(self) -> None: + if not isinstance(self.component, Ref): + raise TypeError("Fragment component must be a Ref[Component]") + for position in (self.left_cut, self.right_cut): + if position is not None and (type(position) is not int or position < 0): + raise ValueError("Cut positions must be nonnegative integers or None") + if type(self.reverse_complement) is not bool: + raise TypeError("reverse_complement must be a bool") + + +@dataclass(frozen=True, kw_only=True) +class AssemblyRecipe: + kind: str = field(default="assembly", init=False) + identity: str + product: Ref[Component] + enzyme: str + fragments: tuple[FragmentSelection, ...] + method: str + circular: bool = True + + @property + def output_form(self) -> MaterialForm: + return MaterialForm.DNA + + def __post_init__(self) -> None: + require_iri(self.identity) + require_iri(self.method) + if not isinstance(self.product, Ref): + raise TypeError("Recipe product must be a Ref[Component]") + if ( + not isinstance(self.fragments, tuple) + or not self.fragments + or not all(isinstance(item, FragmentSelection) for item in self.fragments) + ): + raise ValueError("Recipes need an ordered tuple of selected fragments") + if len(RestrictionBatch([self.enzyme])) != 1: + raise ValueError("Select one restriction enzyme") + if type(self.circular) is not bool: + raise TypeError("circular must be a bool") + + +@dataclass(frozen=True, kw_only=True) +class TransformationRecipe: + kind: str = field(default="transformation", init=False) + identity: str + product: Ref[Component] + chassis: Ref[Component] + plasmids: tuple[Ref[Component], ...] + method: str + + @property + def output_form(self) -> MaterialForm: + return MaterialForm.CULTURE + + def __post_init__(self) -> None: + require_iri(self.identity) + require_iri(self.method) + if not isinstance(self.product, Ref) or not isinstance(self.chassis, Ref): + raise TypeError("Transformation product and chassis must be component references") + if ( + not isinstance(self.plasmids, tuple) + or not self.plasmids + or not all(isinstance(p, Ref) for p in self.plasmids) + ): + raise ValueError("Supply an ordered, nonempty tuple of plasmid references") + if len(set(self.plasmids)) != len(self.plasmids): + raise ValueError("Transformation plasmids must be distinct") + + +@dataclass(frozen=True, kw_only=True) +class PlatingRecipe: + kind: str = field(default="plating", init=False) + identity: str + product: Ref[Component] + method: str + + @property + def output_form(self) -> MaterialForm: + return MaterialForm.PLATED_SAMPLE + + def __post_init__(self) -> None: + require_iri(self.identity) + require_iri(self.method) + if not isinstance(self.product, Ref): + raise TypeError("Plating design must be a component reference") + + +@dataclass(frozen=True, kw_only=True) +class ExternalPreparationRecipe: + kind: str = field(default="preparation", init=False) + identity: str + product: Ref[Component] + source: Ref[Component] + source_form: MaterialForm + output_form: MaterialForm + method: str + + def __post_init__(self) -> None: + require_iri(self.identity) + require_iri(self.method) + if not isinstance(self.product, Ref) or not isinstance(self.source, Ref): + raise TypeError("Preparation source and product must be component references") + if not isinstance(self.source_form, MaterialForm) or not isinstance( + self.output_form, MaterialForm + ): + raise TypeError("Preparation needs explicit source and output material forms") + + +Recipe = AssemblyRecipe | TransformationRecipe | PlatingRecipe | ExternalPreparationRecipe + + +@dataclass(frozen=True, kw_only=True) +class CloningSystem: + identity: str + recipes: tuple[Recipe, ...] + + def __post_init__(self) -> None: + require_iri(self.identity) + if not isinstance(self.recipes, tuple) or not all( + isinstance( + recipe, + (AssemblyRecipe, TransformationRecipe, PlatingRecipe, ExternalPreparationRecipe), + ) + for recipe in self.recipes + ): + raise TypeError("Recipes must be an immutable tuple of typed cloning recipes") + if len({recipe.identity for recipe in self.recipes}) != len(self.recipes): + raise ValueError("Recipe identities must be unique") + object.__setattr__( + self, "recipes", tuple(sorted(self.recipes, key=lambda item: item.identity)) + ) + + def routes( + self, product: Ref[Component], form: MaterialForm = MaterialForm.DNA + ) -> tuple[Recipe, ...]: + return tuple( + recipe + for recipe in self.recipes + if recipe.product == product and recipe.output_form is form + ) + + def write(self, path: str | Path) -> Path: + path = Path(path) + write_bundle( + path.parent, + { + path.name: canonical_json( + { + "format": "lab.cloning-system.v1", + "system": self, + } + ) + }, + ) + return path + + @classmethod + def read(cls, path: str | Path) -> "CloningSystem": + data = json.loads(Path(path).read_text(encoding="utf-8")) + if data.get("format") != "lab.cloning-system.v1": + raise ValueError("Expected lab.cloning-system.v1") + recipes: list[Recipe] = [] + for row in data["system"]["recipes"]: + kind = row.pop("kind") + row["product"] = Ref(row["product"]["identity"]) + if kind == "assembly": + row["fragments"] = tuple( + FragmentSelection( + **{**fragment, "component": Ref(fragment["component"]["identity"])} + ) + for fragment in row["fragments"] + ) + recipes.append(AssemblyRecipe(**row)) + elif kind == "transformation": + row["chassis"] = Ref(row["chassis"]["identity"]) + row["plasmids"] = tuple(Ref(p["identity"]) for p in row["plasmids"]) + recipes.append(TransformationRecipe(**row)) + elif kind == "plating": + recipes.append(PlatingRecipe(**row)) + elif kind == "preparation": + row["source"] = Ref(row["source"]["identity"]) + row["source_form"] = MaterialForm(row["source_form"]) + row["output_form"] = MaterialForm(row["output_form"]) + recipes.append(ExternalPreparationRecipe(**row)) + else: + raise ValueError(f"Unknown recipe kind: {kind}") + return cls(identity=data["system"]["identity"], recipes=tuple(recipes)) diff --git a/src/lab/inventory.py b/src/lab/inventory.py new file mode 100644 index 0000000..143963e --- /dev/null +++ b/src/lab/inventory.py @@ -0,0 +1,213 @@ +"""Explicit material assertions and immutable inventory snapshots.""" + +import json +from dataclasses import dataclass +from decimal import Decimal +from enum import StrEnum +from pathlib import Path + +from lab.artifacts import canonical_json, digest, write_bundle +from lab.provenance import Component, DocumentSnapshot, EvidenceState, Implementation, Ref +from lab.provenance.types import require_iri +from lab.units import magnitude, uL, units + + +class MaterialForm(StrEnum): + DNA = "dna" + COMPETENT_CELLS = "competent_cells" + CULTURE = "culture" + BACTERIAL_STAB = "bacterial_stab" + PLATED_SAMPLE = "plated_sample" + REAGENT = "reagent" + + @property + def counted(self) -> bool: + return self in (MaterialForm.BACTERIAL_STAB, MaterialForm.PLATED_SAMPLE) + + +@dataclass(frozen=True, slots=True) +class StockLocation: + container: str + position: str + + def __post_init__(self) -> None: + if not self.container.strip() or not self.position.strip(): + raise ValueError("Stock locations need a container and position") + + +@dataclass(frozen=True, kw_only=True, init=False) +class Stock: + identity: str + implementation: Ref[Implementation] + design: Ref[Component] + form: MaterialForm + quantity_ul: Decimal + concentration_ng_ul: Decimal | None + location: StockLocation | None + supplier_item: str | None + + def __init__( + self, + *, + identity: str, + implementation: Ref[Implementation], + design: Ref[Component], + form: MaterialForm, + quantity: object, + concentration: object = None, + location: StockLocation | None = None, + supplier_item: str | None = None, + ) -> None: + require_iri(identity) + if not isinstance(implementation, Ref) or not isinstance(design, Ref): + raise TypeError("Stock implementation and design must be references") + if not isinstance(form, MaterialForm): + raise TypeError("Pass a MaterialForm") + if form.counted: + raise ValueError("Use CountedStock or counted Receipts, not liquid-volume stocks") + if location is not None and not isinstance(location, StockLocation): + raise TypeError("Pass a StockLocation") + if supplier_item is not None: + require_iri(supplier_item) + values = { + "identity": identity, + "implementation": implementation, + "design": design, + "form": form, + "quantity_ul": magnitude(quantity, "microliter", positive=False), + "concentration_ng_ul": None + if concentration is None + else magnitude(concentration, "nanogram/microliter"), + "location": location, + "supplier_item": supplier_item, + } + for name, value in values.items(): + object.__setattr__(self, name, value) + + @property + def amount(self) -> Decimal: + return self.quantity_ul + + +@dataclass(frozen=True, kw_only=True) +class CountedStock: + """Available whole material units, without an inferred liquid volume.""" + + identity: str + implementation: Ref[Implementation] + design: Ref[Component] + form: MaterialForm + count: int + location: StockLocation | None = None + supplier_item: str | None = None + + def __post_init__(self) -> None: + require_iri(self.identity) + if not isinstance(self.implementation, Ref) or not isinstance(self.design, Ref): + raise TypeError("Stock implementation and design must be references") + if not isinstance(self.form, MaterialForm) or not self.form.counted: + raise ValueError("Counted stocks require a counted material form") + if type(self.count) is not int or self.count < 0: + raise ValueError("Available count must be a nonnegative integer") + if self.location is not None and not isinstance(self.location, StockLocation): + raise TypeError("Pass a StockLocation") + if self.supplier_item is not None: + require_iri(self.supplier_item) + + @property + def amount(self) -> Decimal: + return Decimal(self.count) + + +@dataclass(frozen=True, kw_only=True) +class Inventory: + """Available amounts, never a live database or an automatically depleted ledger. + + ``design`` is an explicit inventory assertion. It does not mean the stock's + sequence was verified. Liquid volumes and whole-unit counts are distinct. + """ + + identity: str + stocks: tuple[Stock | CountedStock, ...] = () + + def __post_init__(self) -> None: + require_iri(self.identity) + if not isinstance(self.stocks, tuple) or not all( + isinstance(stock, (Stock, CountedStock)) for stock in self.stocks + ): + raise TypeError("Inventory stocks must be a tuple of Stock objects") + if len({stock.identity for stock in self.stocks}) != len(self.stocks): + raise ValueError("Stock identities must be unique") + if len({stock.implementation.identity for stock in self.stocks}) != len(self.stocks): + raise ValueError( + "Each stock needs its own implementation; " + "duplicate lots would double-count material" + ) + occupied = [stock.location for stock in self.stocks if stock.location is not None] + if len(set(occupied)) != len(occupied): + raise ValueError("Inventory locations must be unique") + object.__setattr__( + self, "stocks", tuple(sorted(self.stocks, key=lambda stock: stock.identity)) + ) + + def validate(self, document: DocumentSnapshot) -> None: + for stock in self.stocks: + implementation = document.get(stock.implementation.identity, Implementation) + document.get(stock.design.identity, Component) + if implementation.evidence_state is not EvidenceState.RECORDED: + raise ValueError( + f"Inventory stock {stock.identity} must reference a recorded implementation" + ) + if implementation.built is not None and implementation.built != stock.design: + raise ValueError(f"Stock {stock.identity} conflicts with its realized design") + if implementation.built is None and stock.design not in implementation.derived_from: + raise ValueError( + f"Stock {stock.identity} must identify its intended design in provenance" + ) + + @property + def digest(self) -> str: + return digest(self) + + def write(self, path: str | Path) -> Path: + path = Path(path) + write_bundle( + path.parent, + {path.name: canonical_json({"format": "lab.inventory.v1", "inventory": self})}, + ) + return path + + @classmethod + def read(cls, path: str | Path, *, document: DocumentSnapshot) -> "Inventory": + data = json.loads(Path(path).read_text(encoding="utf-8")) + if data.get("format") != "lab.inventory.v1": + raise ValueError("Expected lab.inventory.v1") + item = data["inventory"] + stocks = tuple( + CountedStock( + identity=row["identity"], + implementation=Ref(row["implementation"]["identity"]), + design=Ref(row["design"]["identity"]), + form=MaterialForm(row["form"]), + count=row["count"], + location=None if row["location"] is None else StockLocation(**row["location"]), + supplier_item=row["supplier_item"], + ) + if MaterialForm(row["form"]).counted + else Stock( + identity=row["identity"], + implementation=Ref(row["implementation"]["identity"]), + design=Ref(row["design"]["identity"]), + form=MaterialForm(row["form"]), + quantity=Decimal(row["quantity_ul"]) * uL, + concentration=None + if row["concentration_ng_ul"] is None + else Decimal(row["concentration_ng_ul"]) * units.nanogram / uL, + location=None if row["location"] is None else StockLocation(**row["location"]), + supplier_item=row["supplier_item"], + ) + for row in item["stocks"] + ) + result = cls(identity=item["identity"], stocks=stocks) + result.validate(document) + return result diff --git a/src/lab/labop/__init__.py b/src/lab/labop/__init__.py new file mode 100644 index 0000000..05f803d --- /dev/null +++ b/src/lab/labop/__init__.py @@ -0,0 +1,5 @@ +"""Static LabOP interchange from frozen Lab experiments.""" + +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..ce248f3 --- /dev/null +++ b/src/lab/labop/primitives.py @@ -0,0 +1,76 @@ +"""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 + +from lab.provenance.vocabulary import LAB as LAB_NAMESPACE + +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(LAB_NAMESPACE) +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 = { + "TransferAtHeight": ( + Parameter("source", LABOP.SampleCollection), + Parameter("destination", LABOP.SampleCollection), + Parameter("amount", OM.Measure), + Parameter("destinationHeight", OM.Measure), + ), + "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 = { + "TransferAtHeight": "Transfer liquid, dispensing at the specified height above the " + "destination well bottom. For a plated sample this is a deposition, not colony formation.", + "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() + for name in ("liquid_handling", "sample_arrays"): + graph.parse( + data=files("lab.labop").joinpath(f"resources/{name}.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..9982eaf --- /dev/null +++ b/src/lab/labop/protocol.py @@ -0,0 +1,540 @@ +"""Static LabOP/UML projection. No execution engine or robot SDK is imported.""" + +import hashlib +import json +from dataclasses import dataclass +from decimal import Decimal +from urllib.parse import quote + +from rdflib import RDF, XSD, Graph, Literal, URIRef +from rdflib.compare import to_canonical_graph + +from lab.experiment import ExperimentPlan, ProtocolStage +from lab.labop.primitives import ( + DESCRIPTIONS, + EXT, + EXTENSIONS, + LAB, + LABOP, + LIQUID, + OM, + SBOL, + UML, + upstream_liquid_primitives, +) +from lab.model import semantic +from lab.operations import ( + Distribute, + ExternalPreparation, + ManualInstruction, + Mix, + SetTemperature, + Thermocycle, + Transfer, + Wait, +) +from lab.samples import Location + + +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 export(experiment: ExperimentPlan) -> LabOPDocument: + """Export ordered stage calls and explicit material/control flows.""" + writer = _Writer() + root = writer.node(experiment.identity + "/labop", LABOP.Protocol, top=True) + writer.add(root, LAB.planDigest, Literal(experiment.digest)) + writer.add(root, LAB.evidenceState, LAB.planned) + calls: list[URIRef] = [] + outputs: dict[tuple[str, str], URIRef] = {} + for stage in experiment.stages: + protocol, collections, ports = _stage(writer, stage) + call = writer.node(f"{root}/stage_{len(calls) + 1}", UML.CallBehaviorAction) + writer.add(call, UML.behavior, protocol) + writer.add(call, LAB.plannedActivity, URIRef(stage.identity)) + for resource, (name, collection) in collections.items(): + handoffs = tuple( + item for item in stage.handoffs if item.destination.resource == resource + ) + if not handoffs: + writer.pin(call, name, collection) + else: + sources = {(item.producer, item.source.resource) for item in handoffs} + if len(sources) != 1 or any( + item.source.well != item.destination.well for item in handoffs + ): + raise ValueError("LabOP stage handoffs require whole-plate continuity") + pin = writer.pin(call, name) + writer.flow(root, outputs[next(iter(sources))], pin, control=False) + writer.add( + pin, LAB.handoffs, Literal(json.dumps(semantic(handoffs), sort_keys=True)) + ) + for resource, name in ports.items(): + pin = writer.pin(call, name, output=True) + outputs[(stage.identity, resource)] = writer.fork(root, pin) + calls.append(call) + writer.chain(root, calls) + return LabOPDocument(str(root), turtle(writer.graph)) + + +def _stage( + writer: _Writer, stage: ProtocolStage +) -> tuple[URIRef, dict[str, tuple[str, URIRef]], dict[str, str]]: + recorded = stage.protocol + protocol = writer.node(str(recorded.identity), LABOP.Protocol, name=recorded.name, top=True) + writer.add(protocol, SBOL.description, Literal(recorded.description)) + writer.add(protocol, LAB.planDigest, Literal(recorded.digest)) + writer.add(protocol, LAB.evidenceState, LAB.planned) + writer.add(protocol, LAB.external, Literal(stage.external)) + collections: dict[str, tuple[str, URIRef]] = {} + 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} + counted = { + place.location.well: samples[place.sample_id] + for place in recorded.placements + if place.location.resource == resource.name + and place.sample_id in recorded.input_sample_ids + and samples[place.sample_id].count is not None + } + counted_materials = { + well: sample.implementation.identity + if sample.implementation + else sample.material_identity + for well, sample in counted.items() + } + writer.add( + array, + LABOP.initial_contents, + Literal( + quote( + json.dumps( + { + well: ( + fills[well].material + if well in fills + else counted_materials.get(well) + ) + 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 well, sample in counted.items(): + node = writer.node(f"{array}/initial_{well}", LAB.InitialMaterial) + writer.add(array, LAB.initialMaterial, node) + writer.add(node, LAB.coordinates, Literal(well)) + writer.add( + node, + LAB.materialIdentity, + Literal( + sample.implementation.identity + if sample.implementation + else sample.material_identity + ), + ) + writer.add(node, LAB.itemCount, Literal(sample.count)) + 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)) + if sample.form is not None: + writer.add(node, LAB.materialForm, URIRef(LAB + sample.form.value)) + if sample.count is not None: + writer.add(node, LAB.sampleCount, Literal(sample.count)) + 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.identity)) + 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) + collections[resource.name] = (name, array) + + 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 step in recorded.steps: + action = writer.node(str(step.identity), 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) + if step.destination_height_mm is None + else writer.primitive("TransferAtHeight") + ) + 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) + if step.destination_height_mm is not None: + writer.amount_pin( + action, "destinationHeight", step.destination_height_mm, OM.millimetre + ) + 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, ExternalPreparation): + primitive = URIRef(EXT + f"ExternalPreparation_{len(step.inputs)}_{len(step.outputs)}") + if (primitive, RDF.type, LABOP.Primitive) not in writer.graph: + writer.node(str(primitive), LABOP.Primitive, top=True) + writer.add( + primitive, + SBOL.description, + Literal( + "Perform the named external procedure using the declared material inputs. " + "Outputs specify expected quantities at distinct destination locations. " + "Counted amounts use OM one; liquid amounts use microlitres. " + "This action specifies an operator-performed preparation." + ), + ) + writer.parameter(primitive, "procedure", UML.ValueSpecification, 0) + writer.parameter(primitive, "instruction", UML.ValueSpecification, 1) + parameter_index = 2 + for prefix, material_ports in ( + ("source", step.inputs), + ("destination", step.outputs), + ): + for index in range(len(material_ports)): + writer.parameter( + primitive, f"{prefix}_{index}", LABOP.SampleCollection, parameter_index + ) + writer.parameter( + primitive, f"{prefix}Amount_{index}", OM.Measure, parameter_index + 1 + ) + parameter_index += 2 + writer.scalar_pin(action, "procedure", step.procedure) + writer.scalar_pin(action, "instruction", step.instructions) + for prefix, material_ports in (("source", step.inputs), ("destination", step.outputs)): + for index, port in enumerate(material_ports): + collection_pin(action, f"{prefix}_{index}", well_source(port.location)) + writer.amount_pin( + action, + f"{prefix}Amount_{index}", + port.count or port.volume_ul, + OM.one if port.count else OM.microlitre, + ) + 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(collections) + 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, collections, ports 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..7b5378b --- /dev/null +++ b/src/lab/labop/resources/lab.ttl @@ -0,0 +1,61 @@ +@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:itemCount a owl:DatatypeProperty ; rdfs:label "item_count" ; + rdfs:domain lab:InitialMaterial ; rdfs:range xsd:integer . +lab:sampleCount a owl:DatatypeProperty ; rdfs:label "sample_count" ; + rdfs:domain lab:SampleAssertion ; rdfs:range xsd:integer . +lab:materialForm a owl:ObjectProperty ; rdfs:label "material_form" ; + rdfs:domain lab:SampleAssertion . +lab:sampleRole a owl:DatatypeProperty ; rdfs:label "sample_role" ; + rdfs:domain lab:SampleAssertion ; rdfs:range xsd:string . + +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/labop.ttl b/src/lab/labop/resources/labop.ttl new file mode 100644 index 0000000..d29417d --- /dev/null +++ b/src/lab/labop/resources/labop.ttl @@ -0,0 +1,809 @@ +@prefix om: . +@prefix owl: . +@prefix labop: . +@prefix prov: . +@prefix rdfs: . +@prefix sbol: . +@prefix uml: . +@prefix xsd: . + + a owl:Ontology ; + rdfs:comment "Laboratory Open Procotol Language (LabOP) ontology." ; + owl:imports , + , + , + ; + owl:versionInfo "0.3" . + +labop:Primitive a owl:Class ; + rdfs:comment """A Primitive describes a library function that acts as a basic ``building block'' for a Protocol. + For example, a Primitive could describe pipetting, measuring absorbance in a plate reader, or centrifuging. + At present this class adds no additional information over uml:Behavior, but may in the future.""" ; + owl:disjointWith uml:Activity ; + rdfs:subClassOf uml:Behavior . + +xsd:anySimpleType a rdfs:Datatype . + +owl:maxCardinality a owl:AnnotationProperty . + +owl:minCardinality a owl:AnnotationProperty . + +labop:CallBehaviorExecution a owl:Class ; + rdfs:comment """A CallBehaviorExecution extends ActivityNodeExecution by adding a pointer to a BehaviorExecution + record for the uml:Behavior that is being executed. + + For a primitive action (e.g., measuring absorbance on a plate reader), this is a plain BehaviorExecution, + while for calling a Protocol as a sub-routine (e.g., to run a stage of an Type IIS assembly), this would be a + ProtocolExecution.""" ; + rdfs:subClassOf [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:call ], + [ a owl:Restriction ; + owl:allValuesFrom labop:BehaviorExecution ; + owl:onProperty labop:call ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:call ], + labop:ActivityNodeExecution . + +labop:SampleMask a owl:Class ; + rdfs:comment """A SampleMask is a subset of a SampleCollection. The subset of samples to be included is defined + by an array of Boolean values, where true values indicate that a sample is included and false values indicate + that it is excluded. + + The dimensions of the mask MUST be identical to the dimensions of the source SampleCollection. For this purpose, + the dimensions of a masked subset are not reduced, but remain the same as the original SampleArray. This allows + masks to be composed, such that SampleMask(source=SampleMask(source=X,mask=mask1),mask=mask2) is equivalent to + SampleMask(source=X,mask=mask1 AND mask2). Note that this implies masks are commutative and idempotent.""" ; + rdfs:label "SampleMask" ; + rdfs:subClassOf [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:mask ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:source ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:mask ], + [ a owl:Restriction ; + owl:allValuesFrom xsd:string ; + owl:onProperty labop:mask ], + [ a owl:Restriction ; + owl:allValuesFrom labop:SampleCollection ; + owl:onProperty labop:source ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:source ], + labop:SampleCollection . + +labop:consumedMaterial a owl:ObjectProperty ; + rdfs:label "consumed_material" ; + rdfs:comment """This property is used to record the noteworthy consumables used during the execution of the + Behavior. For example, a cell culture protocols will consume various reagents and samples of cells. Materials + with the same specification SHOULD be consolidated, such that the list of materials SHOULD NOT contain two + materials with the same specification. + + For example, consuming 5.0 mL of PBS and 2.0 mL of PBS should be recorded as consuming 7.0 mL of PBS. + Complex materials, however, MAY contain the same material more than once in their substructure. + For example, M9 media contains glucose, but it would not be necessary to consolidate the glucose in M9 media + with additional glucose that was added as a supplement, since that would change the definition of the media.""" ; + rdfs:domain labop:BehaviorExecution ; + rdfs:range labop:Material ; + rdfs:subPropertyOf sbol:directlyComprises . + +labop:execution a owl:ObjectProperty ; + rdfs:label "executions" ; + rdfs:comment """Each instance of this property links to an ActivityNodeExecution that records one + firing of a uml:ActivityNode during the execution of its containing Protocol""" ; + rdfs:domain labop:ProtocolExecution ; + rdfs:range labop:ActivityNodeExecution ; + rdfs:subPropertyOf sbol:directlyComprises . + +labop:flow a owl:ObjectProperty ; + rdfs:label "flows" ; + rdfs:comment """Each instance of this property links to an ActivityEdgeFlow that records one movement of a UML + token along a uml:ActivityEdge during the execution of its containing Protocol""" ; + rdfs:domain labop:ProtocolExecution ; + rdfs:range labop:ActivityEdgeFlow ; + rdfs:subPropertyOf sbol:directlyComprises . + +labop:incomingFlow a owl:ObjectProperty ; + rdfs:label "incoming_flows" ; + rdfs:comment """This property is used to indicate an ActivityEdgeFlow that delivered a token consumed during + the execution of the uml:ActivityNode.""" ; + rdfs:domain labop:ActivityNodeExecution ; + rdfs:range labop:ActivityEdgeFlow . + +labop:parameterValuePair a owl:ObjectProperty ; + rdfs:label "parameter_values" ; + rdfs:comment """The parameterValuePair property is used to record the value that was associated with each + uml:Parameter for the uml:Behavior when it was executed, by means of a ParameterValue object. + Any uml:Parameter that is not listed is assumed to have had no value assigned. Conversely, every non-optional + uml:Parameter for the uml:Behavior MUST have an associated parameter value. + Finally, note that this applies both to input uml:Parameter objects, whose value is set before execution begins, + and to output uml:Parameter objects, whose value is set by the time execution ends. + + TODO: are multiple values allowed, or do those need to be passed as list/set types? + """ ; + rdfs:domain labop:BehaviorExecution ; + rdfs:range labop:ParameterValue ; + rdfs:subPropertyOf sbol:directlyComprises . + +labop:ContainerSpec a owl:Class ; + rdfs:comment """A ContainerSpec is used to indicate the type of container to be used for a SampleArray, e.g., + a standard 96-well flat-bottom transparent plate. + + TODO: determine if we want to use this format or modify it in some way.""" ; + rdfs:subClassOf [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:queryString ], + [ a owl:Restriction ; + owl:minCardinality "0"^^xsd:nonNegativeInteger ; + owl:onProperty labop:queryString ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:prefixMap ], + [ a owl:Restriction ; + owl:minCardinality "0"^^xsd:nonNegativeInteger ; + owl:onProperty labop:prefixMap ], + sbol:TopLevel . + +labop:Protocol a owl:Class ; + rdfs:comment """A Protocol describes how to carry out some form of laboratory or research process. + For example, a Protocol could describe DNA miniprep, Golden-Gate assembly, a cell culture experiment. + At present this class adds no additional information over uml:Activity, but may in the future.""" ; + rdfs:subClassOf uml:Activity . + +labop:SampleArray a owl:Class ; + rdfs:label "SampleArray" ; + rdfs:comment """A SampleArray specifies an n-dimensional rectangular array of samples, all stored in the same + type of container. For example, a SampleCollection might describe a set of 10 cell cultures growing in + 96-well plate cells, or a set of 6 streaked agar plates, or a single 500 mL flask filled with media. + + Wells may start with material in them, in which case the initial_contents property should contain a URI to a + description of the sample, or empty, in which case the initial_contents should be null. + + Note that this is a logical array, and does not necessarily indicate the actual layout of the samples in space. + For example, a 2x4 array of samples in 96-well plate wells might end up being laid out as a 2x4 array in wells + A1 to B4 or as a 2x4 array in wells G5 to H8 or as an 8x1 column in wells A1 to H1, or even as eight wells + scattered arbitrarily around the plate according to an anti-bias quality control schema. + + This also allows for higher-dimensional arrays where each dimension represents an experimental factor. + For example, an experiment testing four factors with 3, 3, 4, and 5 values per factor, for a total of 180 + combinations, could be represented as a 4-dimensional sample array of 96-well plate wells, and then end up + laid out over two plates. + + TODO: the format of the data values needs to be compatible with the array format chosen for the + SampleArray initial_contents property. In this case, however, we also need to consider how we want to support + multiple values for each sample (e.g., measurement of both fluorescence and absorbance in a plate reader), + as well as links to more complex data (e.g., results of flow cytometry or omics for each sample)""" ; + rdfs:subClassOf [ a owl:Restriction ; + rdfs:comment "N-dimensional array of URI for specification or nulls" ; + owl:allValuesFrom xsd:string ; + owl:onProperty labop:initial_contents ], + [ a owl:Restriction ; + rdfs:comment "Type of container used for storing the samples. The size and dimension may not match that of the array: it is up to execution to lay out the array in one or more containers" ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:containerType ], + [ a owl:Restriction ; + rdfs:comment "Type of container used for storing the samples. The size and dimension may not match that of the array: it is up to execution to lay out the array in one or more containers" ; + owl:allValuesFrom labop:ContainerSpec ; + owl:onProperty labop:containerType ], + [ a owl:Restriction ; + rdfs:comment "Type of container used for storing the samples. The size and dimension may not match that of the array: it is up to execution to lay out the array in one or more containers" ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:containerType ], + [ a owl:Restriction ; + rdfs:comment "N-dimensional array of URI for specification or nulls" ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:initial_contents ], + [ a owl:Restriction ; + rdfs:comment "N-dimensional array of URI for specification or nulls" ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:initial_contents ], + labop:SampleCollection . + +labop:SampleData a owl:Class ; + rdfs:comment """The SampleData class is used to associate a set of data with a collection of samples. + This is typically used to capture measurements, e.g., an array of absorbance measurements collected by + a plate reader. Using this data structure allows the values in a dataframe to be automatically linked to + the descriptions of the samples that the data describes, which is critical for data analysis. + + The dimensions of the sampleDataValues MUST equal the dimensions of the SampleCollection linked with fromSamples. + + TODO: the format of the data values needs to be compatible with the array format chosen for the + SampleArray initial_contents property. In this case, however, we also need to consider how we want to support + multiple values for each sample (e.g., measurement of both fluorescence and absorbance in a plate reader), + as well as links to more complex data (e.g., results of flow cytometry or omics for each sample)""" ; + rdfs:subClassOf [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:fromSamples ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:sampleDataValues ], + [ a owl:Restriction ; + owl:allValuesFrom xsd:string ; + owl:onProperty labop:sampleDataValues ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:sampleDataValues ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:fromSamples ], + [ a owl:Restriction ; + owl:allValuesFrom labop:SampleCollection ; + owl:onProperty labop:fromSamples ], + sbol:Identified . + +labop:SampleMetadata a owl:Class ; + rdfs:comment """The SampleMetadata class is used to associate a set of descriptions with a collection of samples. + This is typically used to capture a model of the important properties of each sample, e.g., the media, cells, + and inducers that are transferred into each well of a plate at the beginning of the experiment. These + descriptions are then associated with the data values in a SampleData to make a Dataset ready for analysis. + + The dimensions of the sampleDescriptions MUST equal the dimensions of the SampleCollection linked with forSamples. + + TODO: the format of the data values needs to be compatible with the array format chosen for the + SampleArray initial_contents property.""" ; + rdfs:subClassOf [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:forSamples ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:sampleDescriptions ], + [ a owl:Restriction ; + owl:allValuesFrom xsd:string ; + owl:onProperty labop:sampleDescriptions ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:sampleDescriptions ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:forSamples ], + [ a owl:Restriction ; + owl:allValuesFrom labop:SampleCollection ; + owl:onProperty labop:forSamples ], + sbol:Identified . + +labop:Dataset a owl:Class ; + rdfs:comment """The Dataset class is used to associate a set of data and metadata for a collection of samples, + thereby producing a collection of data and attributes ready for analysis. + + The fromSamples property of the data and the forSamples property of the metadata must be the same SampleCollection + """ ; + rdfs:subClassOf [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:data ], + [ a owl:Restriction ; + owl:allValuesFrom labop:SampleData ; + owl:onProperty labop:data ], + [ a owl:Restriction ; + owl:allValuesFrom labop:SampleMetadata ; + owl:onProperty labop:metadata ], + [ a owl:Restriction ; + owl:allValuesFrom labop:SampleMetadata ; + owl:onProperty labop:linkedMetadata ], + [ a owl:Restriction ; + owl:allValuesFrom labop:Dataset ; + owl:onProperty labop:dataset ], + sbol:Identified . + +labop:SampleMap a owl:Class ; + rdfs:comment """The SampleMap class is used to associate SampleCollections to SampleCollections. It references + the ActivityNode objects that are sources or destinations.""" ; + rdfs:subClassOf [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:sampleMapValues ], + [ a owl:Restriction ; + owl:allValuesFrom xsd:string ; + owl:onProperty labop:sampleMapValues ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:sampleMapValues ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:sampleMapSources ], + [ a owl:Restriction ; + owl:allValuesFrom labop:SampleCollection ; + owl:onProperty labop:sampleMapSources ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:sampleMapTargets ], + [ a owl:Restriction ; + owl:allValuesFrom labop:SampleCollection ; + owl:onProperty labop:sampleMapTargets ], + sbol:Identified . + +labop:sampleMapSources a owl:ObjectProperty ; + rdfs:label "sources" ; + rdfs:comment """The sources property indicates the SampleCollection that is a source in a SampleMap.""" ; + rdfs:domain labop:SampleMap ; + rdfs:range labop:SampleCollection . + +labop:sampleMapTargets a owl:ObjectProperty ; + rdfs:label "targets" ; + rdfs:comment """The targets property indicates the SampleCollection that is a target in a SampleMap.""" ; + rdfs:domain labop:SampleMap ; + rdfs:range labop:SampleCollection . + +labop:sampleMapValues a owl:DatatypeProperty ; + rdfs:comment """The sampleMapValues are an encoded JSON representation of an XArray Dataset. + The XArray Dataset represents fan-in or fan-out maps so that the elements of the Dataset + are source-to-destination XArrays (fan-in) or destination-to-source XArrays (fan-out). + """; + rdfs:domain labop:SampleMap ; + rdfs:range xsd:string ; + rdfs:label "values" . + +labop:ProtocolExecution a owl:Class ; + rdfs:comment """A ProtocolExecution expands on the information in a BehaviorExecution by including records for + the nodes and edges defining the Protocol's behavior as a uml:Activity. Specifically, the execution property + is used to record each firing of a uml:ActivityNode and the flow property is used to record each time a token + moves along a uml:ActivityEdge. + Otherwise, a ProtocolExecution is used exactly the same way as its parent class BehaviorExecution. + + TODO: consider dropping the protocol field as redundant with use prov:type field in its parent""" ; + rdfs:subClassOf [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:protocol ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:protocol ], + [ a owl:Restriction ; + owl:allValuesFrom labop:Protocol ; + owl:onProperty labop:protocol ], + [ a owl:Restriction ; + owl:allValuesFrom labop:ActivityNodeExecution ; + owl:onProperty labop:execution ], + [ a owl:Restriction ; + owl:allValuesFrom labop:ActivityEdgeFlow ; + owl:onProperty labop:flow ], + [ a owl:Restriction ; + owl:allValuesFrom labop:edge ; + owl:onProperty labop:activityCallEdge ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ActivityNode ; + owl:onProperty labop:activityCallNode ], + labop:BehaviorExecution . + +labop:activityCallEdge a owl:ObjectProperty ; + rdfs:label "activity_call_edge" ; + rdfs:comment """ActivityEdge list for CallBehaviorAction to Activity invocations""" ; + rdfs:domain labop:ProtocolExecution ; + rdfs:range labop:edge ; + rdfs:subPropertyOf sbol:directlyComprises . + +labop:activityCallNode a owl:ObjectProperty ; + rdfs:label "activity_call_node" ; + rdfs:comment """ActivityNode list for CallBehaviorAction to Activity invocations""" ; + rdfs:domain labop:ProtocolExecution ; + rdfs:range uml:ActivityNode ; + rdfs:subPropertyOf sbol:directlyComprises . + +labop:amount a owl:ObjectProperty ; + rdfs:label "amount" ; + rdfs:comment """The amount property of a Material is used to indicate the quantity of material used. + For example, 2.5 mL (referring to a fluid) or 3 (with unit "number", referring to a group of microplates)""" ; + rdfs:domain labop:Material ; + rdfs:range om:Measure ; + rdfs:subPropertyOf sbol:directlyComprises . + +labop:call a owl:ObjectProperty ; + rdfs:label "call" ; + rdfs:comment "This property indicates the BehaviorExecution record for the uml:Behavior that was called." ; + rdfs:domain labop:CallBehaviorExecution ; + rdfs:range labop:BehaviorExecution . + +labop:completedNormally a owl:DatatypeProperty ; + rdfs:label "completed_normally" ; + rdfs:comment """This boolean should be set to true if the Behavior completed normally and false if there + was some exception condition. At present, no further information is being encoded about exceptions, but this + is an extension that is anticipated for the future.""" ; + rdfs:domain labop:BehaviorExecution ; + rdfs:range xsd:boolean . + +labop:containerType a owl:ObjectProperty ; + rdfs:label "container_type" ; + rdfs:comment "" ; + rdfs:domain labop:SampleArray ; + rdfs:range labop:ContainerSpec . + +labop:initial_contents a owl:DatatypeProperty ; + rdfs:label "initial_contents" ; + rdfs:comment """Description of the initial contents of a SampleArray. + TODO: need to decide whether this is a multi-valued property with associated array coordinates or a + single-valued property with an array value. + Currently set to string as a "dummy" value that can serialize anything.""" ; + rdfs:domain labop:SampleArray ; + rdfs:range xsd:string . + +labop:edge a owl:ObjectProperty ; + rdfs:label "edge" ; + rdfs:comment "This property is used to indicate the uml:ActivityEdge down which the token moved." ; + rdfs:domain labop:ActivityEdgeFlow ; + rdfs:range uml:ActivityEdge . + +labop:edgeValue a owl:ObjectProperty ; + rdfs:label "value" ; + rdfs:comment "This property is used to indicate the value of a token that moved on a uml:ObjectFlow edge." ; + rdfs:domain labop:ActivityEdgeFlow ; + rdfs:range uml:LiteralSpecification ; + rdfs:subPropertyOf sbol:directlyComprises . + +labop:sampleDataValues a owl:DatatypeProperty ; + rdfs:comment """The sampleDataValues are an array of data values, one for each sample, format to be determined."""; + rdfs:domain labop:SampleData ; + rdfs:range xsd:string ; # TODO: consider https://github.com/SynBioDex/sbol_factory/issues/7 + rdfs:label "values" . + +labop:fromSamples a owl:ObjectProperty ; + rdfs:label "from_samples" ; + rdfs:comment """The fromSamples property indicates the SampleCollection from which the data were collected.""" ; + rdfs:domain labop:SampleData ; + rdfs:range labop:SampleCollection . + +labop:sampleDescriptions a owl:DatatypeProperty ; + rdfs:comment """The sampleDescriptions are an array of SBOL Implementations, one for each sample, array format to be determined."""; + rdfs:domain labop:SampleMetadata ; + rdfs:range xsd:string ; # TODO: consider https://github.com/SynBioDex/sbol_factory/issues/7 + rdfs:label "descriptions" . + +labop:forSamples a owl:ObjectProperty ; + rdfs:label "for_samples" ; + rdfs:comment """The forSamples property indicates the SampleCollection that are described by a SampleMetadata.""" ; + rdfs:domain labop:SampleMetadata ; + rdfs:range labop:SampleCollection . + +labop:data a owl:ObjectProperty ; + rdfs:label "data" ; + rdfs:comment """The data property indicates the SampleData used in a Dataset.""" ; + rdfs:domain labop:Dataset ; + rdfs:range labop:SampleData ; + rdfs:subPropertyOf sbol:directlyComprises . + + +labop:metadata a owl:ObjectProperty ; + rdfs:label "metadata" ; + rdfs:comment """The metadata property indicates the SampleMetadata used in a Dataset.""" ; + rdfs:domain labop:Dataset ; + rdfs:range labop:SampleMetadata ; + rdfs:subPropertyOf sbol:directlyComprises . + + +labop:dataset a owl:ObjectProperty ; + rdfs:label "dataset" ; + rdfs:comment """The data property indicates the sub Dataset used in a Dataset.""" ; + rdfs:domain labop:Dataset ; + rdfs:range labop:Dataset . + + +labop:linkedMetadata a owl:ObjectProperty ; + rdfs:label "linked_metadata" ; + rdfs:comment """The metadata property indicates the (referenced) SampleMetadata used in a Dataset.""" ; + rdfs:domain labop:Dataset ; + rdfs:range labop:SampleMetadata . + + +labop:node a owl:ObjectProperty ; + rdfs:label "node" ; + rdfs:comment "This property is used to indicate the uml:ActivityNode that has been executed." ; + rdfs:domain labop:ActivityNodeExecution ; + rdfs:range uml:ActivityNode . + +labop:parameter a owl:ObjectProperty ; + rdfs:label "parameter" ; + rdfs:comment """This property points to the uml:Parameter associated with the value (e.g., wavelength for a + plate reader absorbance measurement behavior).""" ; + rdfs:domain labop:ParameterValue ; + rdfs:range uml:OrderedPropertyValue . + +labop:parameterValue a owl:ObjectProperty ; + rdfs:label "value" ; + rdfs:comment """This property points to the literal value used for the parameter during execution (e.g., a + uml:LiteralIdentified for an om:Measure representing a 600 nm wavelength).""" ; + rdfs:domain labop:ParameterValue ; + rdfs:range uml:LiteralSpecification ; + rdfs:subPropertyOf sbol:directlyComprises . + +labop:protocol a owl:ObjectProperty ; + rdfs:label "protocol" ; + rdfs:comment "This property appears to be redundant with the use of prov:type specified by BehaviorExecution, and is likely to be deleted" ; + rdfs:domain labop:ProtocolExecution ; + rdfs:range labop:Protocol . + +labop:sampleDataValues a owl:ObjectProperty ; + rdfs:label "values" ; + rdfs:comment "The sampleDataValues are an array of data values, one for each sample, format to be determined." ; + rdfs:domain labop:SampleData ; + rdfs:range xsd:string . + +labop:source a owl:ObjectProperty ; + rdfs:label "source" ; + rdfs:comment "The source indicates the SampleCollection that is being subsetted via the mask" ; + rdfs:domain labop:SampleMask ; + rdfs:range labop:SampleCollection . + +labop:specification a owl:ObjectProperty ; + rdfs:label "specification" ; + rdfs:comment """The specification property is used to indicate the type of material used. + For example a DNA sample would be described by an sbol:Component. + + TODO: add example for glucose and for 96-well plate""" ; + rdfs:domain labop:Material ; + rdfs:range sbol:TopLevel . + +labop:tokenSource a owl:ObjectProperty ; + rdfs:label "token_source" ; + rdfs:comment "This property is used to indicate the ActivityNodeExecution that produced the token." ; + rdfs:domain labop:ActivityEdgeFlow ; + rdfs:range labop:ActivityNodeExecution . + +labop:Material a owl:Class ; + rdfs:comment """An amount of material allocated for use during the execution of a behavior. + For example a Material might be used to specify 1 96-well flat-bottom microplate or 2.5 mL of 10 millimolar glucose. + + TODO: consider changing type of specification to allow non-TopLevel descriptions, such as a ContainerSpec or sbol:ExternallyDefined + TODO: consider adding a field to distinguish between expended vs. reusable materials.""" ; + rdfs:subClassOf [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:specification ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:amount ], + [ a owl:Restriction ; + owl:allValuesFrom sbol:TopLevel ; + owl:onProperty labop:specification ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:amount ], + [ a owl:Restriction ; + owl:allValuesFrom om:Measure ; + owl:onProperty labop:amount ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:specification ], + sbol:Identified . + +labop:ParameterValue a owl:Class ; + rdfs:comment """This class is used to represent the assignment of a value to a parameter in a BehaviorExecution + that records the execution of a uml:Behavior. This class is similar to prov:Usage, but instead of always + pointing to an object it uses an arbitrary literal (which might or might not be an object). An example would + be recording that a plate reader absorbance measurement was taken with its absorbance wavelength parameter set + to 600 nm""" ; + rdfs:subClassOf [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:parameter ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:parameter ], + [ a owl:Restriction ; + owl:allValuesFrom uml:LiteralSpecification ; + owl:onProperty labop:parameterValue ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:parameterValue ], + [ a owl:Restriction ; + owl:allValuesFrom uml:OrderedPropertyValue ; + owl:onProperty labop:parameter ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:parameterValue ], + sbol:Identified . + +labop:mask a owl:DatatypeProperty ; + rdfs:label "mask" ; + rdfs:comment """The mask is an N-dimensional array of Booleans values, where each Boolean indicates whether the + sample at the corresponding location in the source is included in the subset. + + TODO: format of mask array needs to match the array format chosen for the SampleArray initial_contents property""" ; + rdfs:domain labop:mask ; + rdfs:range xsd:string . + +labop:BehaviorExecution a owl:Class ; + rdfs:comment """A BehaviorExecution is a record of how a Protocol, Primitive, or other uml:Behavior was carried out. + The execution of the behavior could be either real or simulated. + + In specifying a BehaviorExecution, the prov:type field inherited from prov:Activity is used to indicate the + uml:Behavior whose execution is being recorded. Precisely one value of prov:type MUST be a URI for a uml:Behavior. + The prov:startedAtTime and prov:endedAtTime fields SHOULD be used to record timing information as this becomes + available. + Finally, the entity carrying out the execution SHOULD be recorded as a prov:Agent indicated using a + prov:Association. + + Note that a BehaviorExecution can be used to record both the state of an in-progress execution as well as an + execution that has completed. As a BehaviorExecution proceeds, all values of its properties are monotonic, + i.e., they are only added to and never changed. + + TODO: need to changing completedNormally to allow indication of an in-progress BehaviorExecution + TODO: Is there a good ontology for agent roles in association?""" ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom xsd:boolean ; + owl:onProperty labop:completedNormally ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:completedNormally ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:completedNormally ], + [ a owl:Restriction ; + owl:allValuesFrom labop:ParameterValue ; + owl:onProperty labop:parameterValuePair ], + [ a owl:Restriction ; + owl:allValuesFrom labop:Material ; + owl:onProperty labop:consumedMaterial ], + prov:Activity . + +labop:SampleCollection a owl:Class ; + rdfs:comment """SampleCollection is the base class for describing the collections of physical materials that are + acted upon by a Protocol. For example, a SampleCollection might describe a set of 10 cell cultures growing in + 96-well plate cells, or a set of 6 streaked agar plates, or a single 500 mL flask filled with media. + + There are two types of SampleCollection. A SampleArray specifies an n-dimensional rectangular array of samples, + all stored in the same type of container. A SampleMask specifies a subset of a SampleCollection by means of an + array of Boolean values indicating whether each element is included or excluded from the subset. + + Note, however, that a SampleCollection is a logical object and not a physical object. Thus, while a + SampleCollection might describe a set of samples in 96-well plate wells, it does not necessarily identify + a particular 96-well plate or the location of those wells. In practice, these will be determined as a + result of the specific library calls made to generate SampleCollection objects, and may not be determined + until the protocol is actually run in a particular execution environment. + + This is important for increasing the flexibility with which a Protocol can be specified and applied. + Consider, for example, a cell culturing protocol that includes a step to measure sample absorbance on a plate + reader. Describing this step does not require knowing how the samples are laid out on the plate, and in many + cases is even acceptable to run on samples across multiple plates. This flexibility will allow the cell + culturing protocol to be applied for experiments with different numbers and arrangements of samples.""" ; + rdfs:subClassOf sbol:Identified . + +labop:PrimitiveArray a owl:Class ; + rdfs:comment """A PrimitiveArray is a one-dimensional array of primtitive types represented as strings""" ; + rdfs:subClassOf sbol:Identified , + [ a owl:Restriction ; + owl:onProperty labop:elements ; + owl:allValuesFrom uml:OrderedPropertyValue ], + [ a owl:Restriction ; + owl:onProperty labop:elements ; + owl:minCardinality "0"^^xsd:nonNegativeInteger ] + . + +labop:elements a owl:ObjectProperty ; + rdfs:comment """The elements of a PrimitiveArray""" ; + rdfs:subPropertyOf sbol:directlyComprises ; + rdfs:domain labop:PrimitiveArray ; + rdfs:range uml:OrderedPropertyValue ; + rdfs:label "elements" . + + +labop:SampleMask a owl:Class ; + rdfs:comment """A SampleMask is a subset of a SampleCollection. The subset of samples to be included is defined + by an array of Boolean values, where true values indicate that a sample is included and false values indicate + that it is excluded. + + The dimensions of the mask MUST be identical to the dimensions of the source SampleCollection. For this purpose, + the dimensions of a masked subset are not reduced, but remain the same as the original SampleArray. This allows + masks to be composed, such that SampleMask(source=SampleMask(source=X,mask=mask1),mask=mask2) is equivalent to + SampleMask(source=X,mask=mask1 AND mask2). Note that this implies masks are commutative and idempotent.""" ; + rdfs:subClassOf labop:SampleCollection , + [ a owl:Restriction ; + owl:onProperty labop:source ; + owl:allValuesFrom labop:SampleCollection ], + [ a owl:Restriction ; + owl:onProperty labop:source ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ], + [ a owl:Restriction ; + owl:onProperty labop:source ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ], + [ a owl:Restriction ; + owl:onProperty labop:mask ; + # need to replace this with an serializable array model + owl:allValuesFrom xsd:string ], + [ a owl:Restriction ; + owl:onProperty labop:mask ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ], + [ a owl:Restriction ; + owl:onProperty labop:mask ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ] + . + +labop:source a owl:ObjectProperty ; + rdfs:comment """The source indicates the SampleCollection that is being subsetted via the mask""" ; + rdfs:domain labop:SampleMask ; + rdfs:range labop:SampleCollection ; + rdfs:label "source" . + +labop:mask a owl:DatatypeProperty ; + rdfs:comment """The mask is an N-dimensional array of Booleans values, where each Boolean indicates whether the + sample at the corresponding location in the source is included in the subset. + + TODO: format of mask array needs to match the array format chosen for the SampleArray initial_contents property""" ; + rdfs:domain labop:mask ; + rdfs:range xsd:string ; + rdfs:label "mask" . + + + +labop:ActivityEdgeFlow a owl:Class ; + rdfs:comment """An ActivityEdgeFlow records one movement of a UML token along a uml:ActivityEdge during the + execution of its containing Protocol. If the edge is a uml:ObjectFlow, then the value MUST be set. + If the edge is a uml:ControlFlow, then the value MUST NOT be set. + + For instance, the ActivityEdgeFlow for a uml:ObjectFlow might record a measurement being sent to an output + uml:Parameter, while the ActivityEdgeFlow for a uml:ControlFlow might record a decision to proceed down a + particular branch from a uml:DecisionNode. + + Note that a uml:ActivityEdge might appear in multiple ActivityEdgeFlow records associated with a single + ProtocolExecution, e.g., due to a loop in the Protocol. It also might not appear in any, if the + uml:ActivityEdge is on a path not taken due to branching control flow. + + TODO: correct the cardinality: edgeValue is supposed to be optional, not edge + """ ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom uml:LiteralSpecification ; + owl:onProperty labop:edgeValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:edge ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:tokenSource ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:edgeValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:tokenSource ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ActivityEdge ; + owl:onProperty labop:edge ], + [ a owl:Restriction ; + owl:minCardinality "0"^^xsd:nonNegativeInteger ; + owl:onProperty labop:edge ], + [ a owl:Restriction ; + owl:allValuesFrom labop:ActivityNodeExecution ; + owl:onProperty labop:tokenSource ], + sbol:Identified . + +labop:ActivityNodeExecution a owl:Class ; + rdfs:comment """An ActivityNodeExecution records one instance in which a uml:ActivityNode is executed during the + execution of its containing Protocol. + + For instance, the ActivityNodeExecution for a uml:CallBehaviorAction to measure absorbance on a plate reader + would set its node property to point to the uml:CallBehaviorAction and might have incomingFlow properties + indicating arrival of information about the samples to measure via a uml:ObjectFlow and the arrival a + of permission to begin via a uml:ControlFlow. + + Note that a uml:ActivityNode might appear in multiple ActivityNodeExecution records associated with a single + ProtocolExecution, e.g., due to a loop in the Protocol. It also might not appear in any, if the + uml:ActivityNode is on a path not taken due to branching control flow.""" ; + rdfs:subClassOf [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:node ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty labop:node ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ActivityNode ; + owl:onProperty labop:node ], + [ a owl:Restriction ; + owl:allValuesFrom labop:ActivityEdgeFlow ; + owl:onProperty labop:incomingFlow ], + sbol:Identified . + + + +################################################################# +# Container specification +################################################################# +labop:queryString a owl:DatatypeProperty , + owl:FunctionalProperty ; + rdfs:domain labop:ContainerSpec ; + rdfs:range xsd:string ; + rdfs:label "queryString" ; + rdfs:comment "A query string, in OWL Manchester syntax, to be used to find matching containers in the ContainerSpec." . + +labop:prefixMap a owl:DatatypeProperty , + owl:FunctionalProperty ; + rdfs:domain labop:ContainerSpec ; + rdfs:range xsd:string ; + rdfs:label "prefixMap" ; + rdfs:comment "A prefix map in JSON-LD format, to be applied to a queryString." . diff --git a/src/lab/labop/resources/liquid_handling.ttl b/src/lab/labop/resources/liquid_handling.ttl new file mode 100644 index 0000000..70ab21a --- /dev/null +++ b/src/lab/labop/resources/liquid_handling.ttl @@ -0,0 +1,1526 @@ +@prefix ns1: . +@prefix om: . +@prefix sbol: . +@prefix xsd: . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + , + , + , + , + ; + sbol:description "Dilute" ; + sbol:displayId "Dilute" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + , + , + , + ; + sbol:description "Dilute" ; + sbol:displayId "DiluteToTargetOD" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + ; + sbol:description "Discard part or all of a sample" ; + sbol:displayId "Discard" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + , + ; + sbol:description "Move a measured volume of liquid from one source sample to create samples at multiple destination locations" ; + sbol:displayId "Dispense" ; + sbol:hasNamespace . + + 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 "Place a measured amount (mass or volume) of a specified component into a location, where it may then be used in executing the protocol." ; + sbol:displayId "Provision" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + , + , + ; + sbol:description "Serial Dilution" ; + sbol:displayId "SerialDilution" ; + 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 "Move volumes from a collection of source samples to a collection of destination samples following a plan of value given for each location" ; + sbol:displayId "TransferByMap" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + , + , + ; + sbol:description "Mix a measured volume taken from an collection of source samples into a collection of destination samples whose shape can contain them" ; + sbol:displayId "TransferInto" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + ; + sbol:description "Vortex a sample in order to homogeneously mix or suspend its contents" ; + sbol:displayId "Vortex" ; + 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 "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 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 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 sbol:Component ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "diluent" . + + 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 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 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 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 "dilution_factor" . + + 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 "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 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 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 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 sbol:Component ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "diluent" . + + 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 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 "target_od" . + + 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 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 "temperature" . + + 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 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 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 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 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 "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 "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 sbol:Component ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "resource" . + + 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 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 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 "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 "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 ns1:ValueSpecification ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "direction" . + + 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 sbol:Component ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "diluent" . + + 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 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 "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 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 "dilution_factor" . + + 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: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:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "plan" . + + 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 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 "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 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 1 ; + 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 "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: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 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 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 "mixCycles" . + + 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 "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 "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 "duration" . + + 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/sample_arrays.ttl b/src/lab/labop/resources/sample_arrays.ttl new file mode 100644 index 0000000..7391045 --- /dev/null +++ b/src/lab/labop/resources/sample_arrays.ttl @@ -0,0 +1,1742 @@ +@prefix ns1: . +@prefix om: . +@prefix prov: . +@prefix sbol: . +@prefix xsd: . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + ; + sbol:description "Select only the samples with specified columns from a sample collection" ; + sbol:displayId "Columns" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + ; + sbol:description "Compute Metadata describing samples at the time that this primitive executes." ; + sbol:displayId "ComputeMetadata" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + ; + sbol:description "Specify an instrument configuration consisting of optional instrument modules, such as pipettes, heat blocks, thermocyclers, etc, which are represented by Agents" ; + sbol:displayId "ConfigureRobot" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + , + ; + sbol:description "Create a new sample collection containing a set of replicate slots for every sample in the input" ; + sbol:displayId "ContainerSet" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + ; + sbol:description "Create a new sample collection with identical parameters to the input collection" ; + sbol:displayId "DuplicateCollection" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + ; + sbol:displayId "EmbeddedImage" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + ; + sbol:description "Allocate a sample array with size and type based on an empty container" ; + sbol:displayId "EmptyContainer" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + ; + sbol:description "Allocate a sample array with size and type based on the instrument configuration" ; + sbol:displayId "EmptyInstrument" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + ; + sbol:description "Allocate a sample array with dimensions based on a rack as specified by an instance of cont:Rack" ; + sbol:displayId "EmptyRack" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + ; + sbol:description "Parse sample descriptions and metadata from an Excel file" ; + sbol:displayId "ExcelMetadata" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + ; + sbol:description "Group several labop.Dataset to create a labop.Dataset" ; + sbol:displayId "JoinDatasets" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + ; + sbol:description "Associate a labop:SampleMetadata with a labop.Dataset to create a labop.Dataset that also includes the new metadata" ; + sbol:displayId "JoinMetadata" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + , + ; + sbol:description "Insert cont:Containers into a rack at the indicated rack coordinates. A call to this Primitive should be preceded by EmptyRack" ; + sbol:displayId "LoadContainerInRack" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + , + ; + sbol:description "Insert cont:Containers directly into an instrument, such as a PCR machine, heat block, etc. at the specified slot cooordinates. A call to this Primitive should be preceded by EmptyInstrument" ; + sbol:displayId "LoadContainerOnInstrument" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + ; + sbol:description "Insert a tube rack, pipette tip rack, or microwell plate into an addressed location on a robotic platform" ; + sbol:displayId "LoadRackOnInstrument" ; + 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 , + sbol:TopLevel ; + ns1:ownedParameter , + , + , + ; + sbol:description "Create a new sample collection containing a set of replicate slots for every sample in the input" ; + sbol:displayId "PoolSamples" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + ; + sbol:description "Create a new sample collection containing a set of replicate slots for every sample in the input" ; + sbol:displayId "ReplicateCollection" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + , + ; + sbol:description "Select only the samples with specified rows from a sample collection" ; + sbol:displayId "Rows" ; + sbol:hasNamespace . + + a , + sbol:TopLevel ; + ns1:ownedParameter , + ; + sbol:description "Allocate a SampleArray object representing a stock reagent" ; + sbol:displayId "StockReagent" ; + 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 "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 "col" . + + 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" . + + 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 "for_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:out ; + ns1:isOrdered true ; + ns1:isUnique true ; + ns1:lowerValue ; + ns1:type ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "metadata" . + + 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 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 prov:Agent ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "instrument" . + + 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 "mount" . + + 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:ValueSpecification ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "quantity" . + + 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 "specification" . + + 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 "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 3 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue4" . + + 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" . + + 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: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" . + + 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:ValueSpecification ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "image" . + + 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 "caption" . + + 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 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 sbol:Identified ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "specification" . + + 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 "sample_array" . + + 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 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" . + + 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 prov:Agent ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "instrument" . + + 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 "sample_array" . + + 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 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 "slots" . + + 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 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 sbol:Identified ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "specification" . + + 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 "sample_array" . + + 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 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 "slots" . + + 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 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:ValueSpecification ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "filename" . + + 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 "for_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 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 "metadata" . + + 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 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 "metadata" . + + 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 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 ; + sbol:displayId "Parameter1" ; + sbol:name "dataset" . + + a ns1:LiteralInteger, + sbol:Identified ; + ns1:integerValue 1 ; + sbol:displayId "LiteralInteger1" . + + 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 "joint_dataset" . + + 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 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 "metadata" . + + 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 "dataset" . + + 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 "enhanced_dataset" . + + 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 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 "slots" . + + 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 "container" . + + 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 1 ; + sbol:displayId "LiteralInteger2" . + + a ns1:OrderedPropertyValue, + sbol:Identified ; + ns1:indexValue 3 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue4" . + + 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" . + + 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 "specification" . + + 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 "slots" . + + 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 prov:Agent ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "instrument" . + + 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 3 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue4" . + + 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" . + + 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:ValueSpecification ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "rack" . + + 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 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" . + + 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 om:Measure ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "volume" . + + 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 3 ; + ns1:propertyValue ; + sbol:displayId "OrderedPropertyValue4" . + + 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" . + + 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 "replicates" . + + 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" . + + 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 "row" . + + 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" . + + 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 sbol:Identified ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "initial_contents" . + + 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:out ; + ns1:isOrdered true ; + ns1:isUnique true ; + ns1:lowerValue ; + ns1:type ; + ns1:upperValue ; + sbol:displayId "Parameter1" ; + sbol:name "reagent" . + + 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/uml.ttl b/src/lab/labop/resources/uml.ttl new file mode 100644 index 0000000..f927fcf --- /dev/null +++ b/src/lab/labop/resources/uml.ttl @@ -0,0 +1,901 @@ +@prefix owl: . +@prefix rdfs: . +@prefix sbol: . +@prefix uml: . +@prefix xsd: . + + a owl:Ontology ; + rdfs:comment "Unified Modeling Languge (UML) subset, translated to an SBOL factory ontology." ; + owl:imports , + ; + owl:versionInfo "1.0-alpha1" . + +uml:ControlFlow a owl:Class ; + rdfs:comment "A ControlFlow is an ActivityEdge traversed by control tokens or object tokens of control type, which are use to control the execution of ExecutableNodes. See UML 2.5.1 specification section 15.2." ; + rdfs:subClassOf uml:ActivityEdge . + +uml:FlowFinalNode a owl:Class ; + rdfs:comment "A FlowFinalNode is a FinalNode that terminates a flow. All tokens accepted by a FlowFinalNode are destroyed. This has no effect on other flows in the Activity. See UML 2.5.1 specification section 15.3." ; + rdfs:subClassOf uml:FinalNode . + +uml:ForkNode a owl:Class ; + rdfs:comment "A ForkNode is a ControlNode that splits a flow into multiple concurrent flows. A ForkNode shall have exactly one incoming ActivityEdge, though it may have multiple outgoing ActivityEdges. See UML 2.5.1 specification section 15.3." ; + rdfs:subClassOf uml:ControlNode . + +uml:InitialNode a owl:Class ; + rdfs:comment "An InitialNode acts as a starting point for executing an Activity. An Activity may have more than one InitialNodes that start multiple concurrent control flows. See UML 2.5.1 specification section 15.3." ; + rdfs:subClassOf uml:ControlNode . + +uml:JoinNode a owl:Class ; + rdfs:comment "A JoinNode is a ControlNode that synchronizes multiple flows. A JoinNode shall have exactly one outgoing ActivityEdge but may have multiple incoming ActivityEdges. See UML 2.5.1 specification section 15.3." ; + rdfs:subClassOf uml:ControlNode . + +uml:LiteralNull a owl:Class ; + rdfs:comment "A LiteralNull specifies the lack of a value. See UML 2.5.1 specification section 8.2." ; + rdfs:subClassOf uml:LiteralSpecification . + +uml:MergeNode a owl:Class ; + rdfs:comment "A MergeNode is a control node that brings together multiple flows without synchronization. A MergeNode shall have exactly one outgoing ActivityEdge but may have multiple incoming ActivityEdges. See UML 2.5.1 specification section 15.3." ; + rdfs:subClassOf uml:ControlNode . + +xsd:anySimpleType a rdfs:Datatype . + +uml:ActivityParameterNode a owl:Class ; + rdfs:comment "An ActivityParameterNode is an ObjectNode for accepting values from the input Parameters or providing values to the output Parameters of an Activity. UML 2.5.1 specification section 15.4." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom uml:OrderedPropertyValue ; + owl:onProperty uml:parameter ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:parameter ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:parameter ], + uml:ObjectNode . + +uml:CallAction a owl:Class ; + rdfs:comment "A CallAction is an abstract class for Actions that invoke a Behavior with given argument values and (if the invocation is synchronous) receive reply values. See UML 2.5.1 specification section 16.3." ; + rdfs:subClassOf uml:InvocationAction . + +uml:CallBehaviorAction a owl:Class ; + rdfs:comment "A CallBehaviorAction is a CallAction that invokes a Behavior directly. The argument values of the CallBehaviorAction are passed on the input Parameters of the invoked Behavior. UML 2.5.1 specification section 16.3" ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom uml:Behavior ; + owl:onProperty uml:behavior ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:behavior ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:behavior ], + uml:CallAction . + +uml:ExecutableNode a owl:Class ; + rdfs:comment "An ExecutableNode is an abstract class for ActivityNodes whose execution may be controlled using ControlFlows and to which ExceptionHandlers may be attached. See UML 2.5.1 specification section 15.5." ; + rdfs:subClassOf uml:ActivityNode . + +uml:FinalNode a owl:Class ; + rdfs:comment "A FinalNode is a ControlNode at which a flow in an Activity stops. A FinalNode shall not have outgoing ActivityEdges. See UML 2.5.1 specification section 15.3." ; + rdfs:subClassOf uml:ControlNode . + +uml:InvocationAction a owl:Class ; + rdfs:comment "UML 2.5.1 specification section 16.3" ; + rdfs:subClassOf uml:Action . + +uml:LiteralBoolean a owl:Class ; + rdfs:comment "A LiteralBoolean is a specification of a Boolean value. See UML 2.5.1 specification section 8.2." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom xsd:boolean ; + owl:onProperty uml:booleanValue ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:booleanValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:booleanValue ], + uml:LiteralSpecification . + +uml:LiteralIdentified a owl:Class ; + rdfs:comment "A LiteralIdentified is used for linking SBOL objects as a child object to UML objects." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom sbol:Identified ; + owl:onProperty uml:identifiedValue ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:identifiedValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:identifiedValue ], + uml:LiteralSpecification . + +uml:LiteralInteger a owl:Class ; + rdfs:comment "A LiteralInteger is a specification of an Integer value. See UML 2.5.1 specification section 8.2." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom xsd:integer ; + owl:onProperty uml:integerValue ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:integerValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:integerValue ], + uml:LiteralSpecification . + +uml:LiteralReal a owl:Class ; + rdfs:comment "A LiteralReal is a specification of a Real value. See UML 2.5.1 specification section 8.2." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom xsd:float ; + owl:onProperty uml:realValue ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:realValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:realValue ], + uml:LiteralSpecification . + +uml:LiteralReference a owl:Class ; + rdfs:comment "A LiteralReference is used for embedding SBOL objects as a reference." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:referenceValue ], + [ a owl:Restriction ; + owl:allValuesFrom sbol:Identified ; + owl:onProperty uml:referenceValue ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:referenceValue ], + uml:LiteralSpecification . + +uml:LiteralString a owl:Class ; + rdfs:comment "A LiteralSpecification identifies a literal constant being modeled. See UML 2.5.1 specification section 8.2." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom xsd:string ; + owl:onProperty uml:stringValue ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:stringValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:stringValue ], + uml:LiteralSpecification . + +uml:ValuePin a owl:Class ; + rdfs:comment "A ValuePin is an InputPin that provides a value by evaluating a ValueSpecification. See UML 2.5.1 specification section 16.2." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom uml:ValueSpecification ; + owl:onProperty uml:value ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:value ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:value ], + uml:InputPin . + +uml:constrainedElement a owl:ObjectProperty ; + rdfs:label "constrained_elements" ; + rdfs:comment "The OrderedPropertyValue referenced by this Constraint." ; + rdfs:domain uml:Constraint ; + rdfs:range uml:OrderedPropertyValue ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:edge a owl:ObjectProperty ; + rdfs:label "edges" ; + rdfs:comment "ActivityEdges expressing flow between the nodes of the Activity." ; + rdfs:domain uml:Activity ; + rdfs:range uml:ActivityEdge ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:input a owl:ObjectProperty ; + rdfs:label "inputs" ; + rdfs:comment "The ordered set of InputPins representing the inputs to the Action." ; + rdfs:domain uml:Action ; + rdfs:range uml:InputPin ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:node a owl:ObjectProperty ; + rdfs:label "nodes" ; + rdfs:comment "ActivityNodes coordinated by the Activity." ; + rdfs:domain uml:Activity ; + rdfs:range uml:ActivityNode ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:operandValue a owl:ObjectProperty ; + rdfs:label "operand" ; + rdfs:comment "Specifies a sequence of operand ValueSpecifications." ; + rdfs:domain uml:Expression ; + rdfs:range uml:OrderedPropertyValue ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:output a owl:ObjectProperty ; + rdfs:label "outputs" ; + rdfs:comment "The ordered set of OutputPins representing outputs from the Action." ; + rdfs:domain uml:Action ; + rdfs:range uml:OutputPin ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:ownedParameter a owl:ObjectProperty ; + rdfs:label "parameters" ; + rdfs:comment " a list of Parameters to the Behavior which describes the order and type of arguments that can be given when the Behavior is invoked and of the values which will be returned when the Behavior completes its execution." ; + rdfs:domain uml:Behavior ; + rdfs:range uml:OrderedPropertyValue ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:postcondition a owl:ObjectProperty ; + rdfs:label "postconditions" ; + rdfs:comment "An optional set of Constraints specifying what is fulfilled after the execution of the Behavior is completed, if its precondition was fulfilled before its invocation." ; + rdfs:domain uml:Behavior ; + rdfs:range uml:Constraint ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:precondition a owl:ObjectProperty ; + rdfs:label "preconditions" ; + rdfs:comment "An optional set of Constraints specifying what must be fulfilled before the Behavior is invoked." ; + rdfs:domain uml:Behavior ; + rdfs:range uml:Constraint ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:Activity a owl:Class ; + rdfs:comment "An Activity coordinates and groups steps in a protocol or workflow. See UML 2.5.1 specification section 15." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom uml:ActivityNode ; + owl:onProperty uml:node ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ActivityEdge ; + owl:onProperty uml:edge ], + uml:Behavior . + +uml:DecisionNode a owl:Class ; + rdfs:comment "A DecisionNode is a ControlNode that chooses between outgoing flows. A DecisionNode shall have at least one and at most two incoming ActivityEdges, and at least one outgoing ActivityEdge. See UML 2.5.1 specification section 15.3." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom uml:Behavior ; + owl:onProperty uml:decisionInput ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:decisionInput ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ObjectFlow ; + owl:onProperty uml:decisionInputFlow ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:decisionInputFlow ], + uml:ControlNode . + +uml:DurationConstraint a owl:Class ; + rdfs:comment "A DurationConstraint is a Constraint that refers to a DurationInterval." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:maxCardinality "2"^^xsd:nonNegativeInteger ; + owl:onProperty uml:firstEventValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:durationSpecification ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:durationSpecification ], + [ a owl:Restriction ; + owl:minCardinality "0"^^xsd:nonNegativeInteger ; + owl:onProperty uml:firstEventValue ], + [ a owl:Restriction ; + owl:allValuesFrom uml:OrderedPropertyValue ; + owl:onProperty uml:firstEventValue ], + uml:IntervalConstraint . + +uml:DurationObservation a owl:Class ; + rdfs:comment "A DurationObservation is a reference to a duration during an execution. It points out the entities in the model to observe and whether the observations are when these entities are entered or exited." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:durationObservationValue ], + [ a owl:Restriction ; + owl:allValuesFrom sbol:Identified ; + owl:onProperty uml:durationObservationValue ], + [ a owl:Restriction ; + owl:allValuesFrom uml:OrderedPropertyValue ; + owl:onProperty uml:firstEventValue ], + [ a owl:Restriction ; + owl:minCardinality "0"^^xsd:nonNegativeInteger ; + owl:onProperty uml:firstEventValue ], + [ a owl:Restriction ; + owl:maxCardinality "2"^^xsd:nonNegativeInteger ; + owl:onProperty uml:firstEventValue ], + [ a owl:Restriction ; + owl:maxCardinality "2"^^xsd:nonNegativeInteger ; + owl:onProperty uml:durationObservationValue ], + uml:Observation . + +uml:Expression a owl:Class ; + rdfs:comment "An Expression represents a node in an expression tree, which may be non-terminal or terminal. It defines a symbol, and has a possibly empty sequence of operands that are ValueSpecifications. See UML 2.5.1 specification section 8.6.5.1." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:isOrdered ], + [ a owl:Restriction ; + owl:allValuesFrom xsd:boolean ; + owl:onProperty uml:isOrdered ], + [ a owl:Restriction ; + owl:minCardinality "0"^^xsd:nonNegativeInteger ; + owl:onProperty uml:symbolValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:symbolValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:isOrdered ], + [ a owl:Restriction ; + owl:allValuesFrom xsd:string ; + owl:onProperty uml:symbolValue ], + [ a owl:Restriction ; + owl:allValuesFrom uml:OrderedPropertyValue ; + owl:onProperty uml:operandValue ], + uml:ValueSpecification . + +uml:Interval a owl:Class ; + rdfs:comment "An Interval defines the range between two ValueSpecifications." ; + rdfs:subClassOf uml:ValueSpecification . + +uml:IntervalConstraint a owl:Class ; + rdfs:comment "An IntervalConstraint is a Constraint that is specified by an Interval." ; + rdfs:subClassOf uml:Constraint . + +uml:ObjectFlow a owl:Class ; + rdfs:comment "An ObjectFlow is an ActivityEdge that is traversed by object tokens that may hold values. Object flows also support multicast/receive, token selection from object nodes, and transformation of tokens. See UML 2.5.1 specification section 15.2." ; + rdfs:subClassOf uml:ActivityEdge . + +uml:ObjectNode a owl:Class ; + rdfs:comment "An ObjectNode is a kind of ActivityNode used to hold value-containing object tokens during the course of the execution of an Activity. See UML 2.5.1 specification section 15.4." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom xsd:anyURI ; + owl:onProperty uml:type ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:type ], + uml:ActivityNode . + +uml:OutputPin a owl:Class ; + rdfs:comment "An OutputPin is a Pin that holds output values produced by an Action. See UML 2.5.1 specification section 16.2." ; + rdfs:subClassOf uml:Pin . + +uml:Parameter a owl:Class ; + rdfs:comment "A Parameter is a specification of an argument used to pass information into or out of an invocation of a Behavior. See UML 2.5.1 specification section 9.4." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom xsd:anyURI ; + owl:onProperty uml:direction ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:direction ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:direction ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ValueSpecification ; + owl:onProperty uml:defaultValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:defaultValue ], + [ a owl:Restriction ; + owl:allValuesFrom xsd:anyURI ; + owl:onProperty uml:type ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:type ], + [ a owl:Restriction ; + owl:allValuesFrom xsd:boolean ; + owl:onProperty uml:isOrdered ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:isOrdered ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:isOrdered ], + [ a owl:Restriction ; + owl:allValuesFrom xsd:boolean ; + owl:onProperty uml:isUnique ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:isUnique ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:isUnique ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ValueSpecification ; + owl:onProperty uml:lowerValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:lowerValue ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ValueSpecification ; + owl:onProperty uml:upperValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:upperValue ], + sbol:Identified . + +uml:Pin a owl:Class ; + rdfs:comment "A Pin is an ObjectNode and MultiplicityElement that provides input values to an Action or accepts output values from an Action. See UML 2.5.1 specification section 16.2." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom xsd:boolean ; + owl:onProperty uml:isOrdered ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:isOrdered ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:isOrdered ], + [ a owl:Restriction ; + owl:allValuesFrom xsd:boolean ; + owl:onProperty uml:isUnique ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:isUnique ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:isUnique ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ValueSpecification ; + owl:onProperty uml:lowerValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:lowerValue ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ValueSpecification ; + owl:onProperty uml:upperValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:upperValue ], + uml:ObjectNode . + +uml:TimeConstraint a owl:Class ; + rdfs:comment "A TimeConstraint is a Constraint that refers to a TimeInterval." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:timeSpecification ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:firstEventValue ], + [ a owl:Restriction ; + owl:minCardinality "0"^^xsd:nonNegativeInteger ; + owl:onProperty uml:firstEventValue ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:timeSpecification ], + [ a owl:Restriction ; + owl:allValuesFrom uml:OrderedPropertyValue ; + owl:onProperty uml:firstEventValue ], + uml:IntervalConstraint . + +uml:TimeObservation a owl:Class ; + rdfs:comment "A TimeObservation is a reference to a time instant during an execution. It points out which entity in the model to observe and whether the observation is when this entity is entered or when it is exited." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:timeObservationValue ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:timeObservationValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:firstEventValue ], + [ a owl:Restriction ; + owl:allValuesFrom sbol:Identified ; + owl:onProperty uml:timeObservationValue ], + [ a owl:Restriction ; + owl:allValuesFrom uml:OrderedPropertyValue ; + owl:onProperty uml:firstEventValue ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:firstEventValue ], + uml:Observation . + +uml:decisionInput a owl:ObjectProperty ; + rdfs:label "decision_input" ; + rdfs:comment "A Behavior that is executed to provide an input to guard ValueSpecifications on ActivityEdges outgoing from the DecisionNode." ; + rdfs:domain uml:DecisionNode ; + rdfs:range uml:Behavior . + +uml:decisionInputFlow a owl:ObjectProperty ; + rdfs:label "decision_input_flow" ; + rdfs:comment "An additional ActivityEdge incoming to the DecisionNode that provides a decision input value for the guards ValueSpecifications on ActivityEdges outgoing from the DecisionNode." ; + rdfs:domain uml:DecisionNode ; + rdfs:range uml:ObjectFlow . + +uml:defaultValue a owl:ObjectProperty ; + rdfs:label "default_value" ; + rdfs:comment "A ValueSpecification that represents a value to be used when no argument is supplied for the Parameter." ; + rdfs:domain uml:Parameter ; + rdfs:range uml:ValueSpecification ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:observationValue a owl:ObjectProperty ; + rdfs:label "observation" ; + rdfs:comment "Refers to the Observations that are involved in the computation of the Duration value." ; + rdfs:domain uml:Duration, + uml:TimeExpression ; + rdfs:range uml:Observation . + +uml:Action a owl:Class ; + rdfs:comment "An Action is the fundamental unit of executable functionality. The execution of an Action represents some transformation or processing in the modeled system. Actions provide the ExecutableNodes within Activities and may also be used within Interactions. See UML 2.5.1 specification section 16." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom uml:InputPin ; + owl:onProperty uml:input ], + [ a owl:Restriction ; + owl:allValuesFrom uml:OutputPin ; + owl:onProperty uml:output ], + uml:ExecutableNode . + +uml:InputPin a owl:Class ; + rdfs:comment "An InputPin is a Pin that holds input values to be consumed by an Action. See UML 2.5.1 specification section 16.2." ; + rdfs:subClassOf uml:Pin . + +uml:behavior a owl:ObjectProperty ; + rdfs:label "behavior" ; + rdfs:comment "The Behavior being invoked." ; + rdfs:domain uml:CallBehaviorAction ; + rdfs:range uml:Behavior . + +uml:booleanValue a owl:DatatypeProperty ; + rdfs:label "value" ; + rdfs:comment "The specified Boolean value." ; + rdfs:domain uml:LiteralBoolean ; + rdfs:range xsd:boolean . + +uml:direction a owl:DatatypeProperty ; + rdfs:label "direction" ; + rdfs:comment "Indicates whether a parameter is being sent into or out of a behavioral element." ; + rdfs:domain uml:Parameter ; + rdfs:range xsd:anyURI . + +uml:durationObservationValue a owl:ObjectProperty ; + rdfs:label "event" ; + rdfs:comment "The DurationObservation is determined as the duration between the entering or exiting of a single event during execution, or the entering/exiting of one event and the entering/exiting of a second." ; + rdfs:domain uml:DurationObservation ; + rdfs:range sbol:Identified . + +uml:durationSpecification a owl:ObjectProperty ; + rdfs:label "specification" ; + rdfs:comment "The DurationInterval constraining the duration." ; + rdfs:domain uml:DurationConstraint ; + rdfs:range uml:DurationInterval ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:identifiedValue a owl:ObjectProperty ; + rdfs:label "value" ; + rdfs:comment "The embedded SBOL object" ; + rdfs:domain uml:LiteralIdentified ; + rdfs:range sbol:Identified ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:indexValue a owl:DatatypeProperty ; + rdfs:label "index" ; + rdfs:domain uml:OrderedPropertyValue ; + rdfs:range xsd:integer . + +uml:integerValue a owl:DatatypeProperty ; + rdfs:label "value" ; + rdfs:comment "The specified Integer value." ; + rdfs:domain uml:LiteralInteger ; + rdfs:range xsd:integer . + +uml:maxDurationValue a owl:ObjectProperty ; + rdfs:label "max" ; + rdfs:comment "Refers to the Duration denoting the maximum value of the range." ; + rdfs:domain uml:DurationInterval ; + rdfs:range uml:Duration ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:maxTimeValue a owl:ObjectProperty ; + rdfs:label "max" ; + rdfs:comment "Refers to the TimeExpression denoting the maximum value of the range." ; + rdfs:domain uml:TimeInterval ; + rdfs:range uml:TimeExpression ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:minDurationValue a owl:ObjectProperty ; + rdfs:label "min" ; + rdfs:comment "Refers to the Duration denoting the minimum value of the range." ; + rdfs:domain uml:DurationInterval ; + rdfs:range uml:Duration ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:minTimeValue a owl:ObjectProperty ; + rdfs:label "min" ; + rdfs:comment "Refers to the TimeExpression denoting the minimum value of the range." ; + rdfs:domain uml:TimeInterval ; + rdfs:range uml:TimeExpression ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:parameter a owl:ObjectProperty ; + rdfs:label "parameter" ; + rdfs:comment "The Parameter for which the ActivityParameterNode will be accepting or providing values." ; + rdfs:domain uml:ActivityParameterNode ; + rdfs:range uml:OrderedPropertyValue . + +uml:propertyValue a owl:ObjectProperty ; + rdfs:label "property_value" ; + rdfs:domain uml:OrderedPropertyValue ; + rdfs:range sbol:Identified ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:realValue a owl:DatatypeProperty ; + rdfs:label "value" ; + rdfs:comment "The specified Real value." ; + rdfs:domain uml:LiteralReal ; + rdfs:range xsd:float . + +uml:referenceValue a owl:ObjectProperty ; + rdfs:label "value" ; + rdfs:comment "The referenced SBOL object." ; + rdfs:domain uml:LiteralReference ; + rdfs:range sbol:Identified . + +uml:source a owl:ObjectProperty ; + rdfs:label "source" ; + rdfs:comment "The ActivityNode from which tokens are taken when they traverse the ActivityEdge." ; + rdfs:domain uml:ActivityEdge ; + rdfs:range uml:ActivityNode . + +uml:stringValue a owl:DatatypeProperty ; + rdfs:label "value" ; + rdfs:comment "The specified String value." ; + rdfs:domain uml:LiteralString ; + rdfs:range xsd:string . + +uml:symbolValue a owl:DatatypeProperty ; + rdfs:label "symbol" ; + rdfs:comment "The symbol associated with this node in the expression tree." ; + rdfs:domain uml:Expression ; + rdfs:range xsd:anyURI . + +uml:target a owl:ObjectProperty ; + rdfs:label "target" ; + rdfs:comment "The ActivityNode to which tokens are put when they traverse the ActivityEdge." ; + rdfs:domain uml:ActivityEdge ; + rdfs:range uml:ActivityNode . + +uml:timeObservationValue a owl:ObjectProperty ; + rdfs:label "event" ; + rdfs:comment "The TimeObservation is determined by the entering or exiting of the event during execution." ; + rdfs:domain uml:TimeObservation ; + rdfs:range sbol:Identified . + +uml:timeSpecification a owl:ObjectProperty ; + rdfs:label "specification" ; + rdfs:comment "TheTimeInterval constraining the duration." ; + rdfs:domain uml:TimeConstraint ; + rdfs:range uml:TimeInterval ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:value a owl:ObjectProperty ; + rdfs:label "value" ; + rdfs:comment "The ValueSpecification that is evaluated to obtain the value that the ValuePin will provide." ; + rdfs:domain uml:ValuePin ; + rdfs:range uml:ValueSpecification ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:Duration a owl:Class ; + rdfs:comment "A Duration is a ValueSpecification that specifies the temporal distance between two time instants." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:exprValue ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ValueSpecification ; + owl:onProperty uml:exprValue ], + [ a owl:Restriction ; + owl:minCardinality "0"^^xsd:nonNegativeInteger ; + owl:onProperty uml:exprValue ], + [ a owl:Restriction ; + owl:allValuesFrom uml:Observation ; + owl:onProperty uml:observationValue ], + uml:ValueSpecification . + +uml:DurationInterval a owl:Class ; + rdfs:comment "A DurationInterval defines the range between two Durations." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:maxDurationValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:minDurationValue ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:minDurationValue ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:maxDurationValue ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ValueSpecification ; + owl:onProperty uml:minDurationValue ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ValueSpecification ; + owl:onProperty uml:maxDurationValue ], + uml:Interval . + +uml:TimeExpression a owl:Class ; + rdfs:comment "A TimeExpression is a ValueSpecification that represents a time value." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom uml:ValueSpecification ; + owl:onProperty uml:exprValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:exprValue ], + [ a owl:Restriction ; + owl:minCardinality "0"^^xsd:nonNegativeInteger ; + owl:onProperty uml:exprValue ], + [ a owl:Restriction ; + owl:allValuesFrom uml:Observation ; + owl:onProperty uml:observationValue ], + uml:ValueSpecification . + +uml:TimeInterval a owl:Class ; + rdfs:comment "A TimeInterval defines the range between two TimeExpressions." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:maxTimeValue ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:minTimeValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:minTimeValue ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ValueSpecification ; + owl:onProperty uml:maxTimeValue ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:maxTimeValue ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ValueSpecification ; + owl:onProperty uml:minTimeValue ], + uml:Interval . + +uml:lowerValue a owl:ObjectProperty ; + rdfs:label "lower_value" ; + rdfs:comment "For MultiplicityElement abstract class; UML 2.5.1 specification section 7.5" ; + rdfs:domain sbol:Identified ; + rdfs:range uml:ValueSpecification ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:upperValue a owl:ObjectProperty ; + rdfs:label "upper_value" ; + rdfs:comment "For MultiplicityElement abstract class; UML 2.5.1 specification section 7.5" ; + rdfs:domain sbol:Identified ; + rdfs:range uml:ValueSpecification ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:Observation a owl:Class ; + rdfs:comment "Observation specifies a value determined by observing an event or events that occur relative to other model entities." ; + rdfs:subClassOf sbol:Identified . + +uml:ActivityEdge a owl:Class ; + rdfs:comment "An ActivityEdge is an abstract class for directed connections between two ActivityNodes. See UML 2.5.1 specification section 15.2." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom uml:ActivityNode ; + owl:onProperty uml:source ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:source ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:source ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ActivityNode ; + owl:onProperty uml:target ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:target ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:target ], + [ a owl:Restriction ; + owl:allValuesFrom uml:ActivityNode ; + owl:onProperty uml:guard ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:guard ], + sbol:Identified . + +uml:guard a owl:ObjectProperty ; + rdfs:label "guard" ; + rdfs:comment "A ValueSpecification that guards an ActivityEdge." ; + rdfs:domain uml:ActivityEdge ; + rdfs:range uml:ValueSpecification ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:Constraint a owl:Class ; + rdfs:comment "A Constraint is a condition or restriction expressed in natural language text or in a machine readable language. See UML 2.5.1 specification section 7.6." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom uml:OrderedPropertyValue ; + owl:onProperty uml:constrainedElement ], + sbol:Identified . + +uml:ControlNode a owl:Class ; + rdfs:comment "A ControlNode is a kind of ActivityNode used to manage the flow of tokens between other nodes in an Activity. It can manage branching and merging of workflows and the implementation of logic for flow control. See UML 2.5.1 specification section 15.3." ; + rdfs:subClassOf uml:ActivityNode . + +uml:exprValue a owl:ObjectProperty ; + rdfs:label "expr" ; + rdfs:comment "A ValueSpecification that evaluates to the value of the Duration." ; + rdfs:domain uml:Duration, + uml:TimeExpression ; + rdfs:range uml:ValueSpecification ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:isUnique a owl:DatatypeProperty ; + rdfs:label "is_unique" ; + rdfs:comment "For MultiplicityElement abstract class; UML 2.5.1 specification section 7.5" ; + rdfs:domain sbol:Identified ; + rdfs:range xsd:boolean . + +uml:type a owl:DatatypeProperty ; + rdfs:label "type" ; + rdfs:comment "Specifies a set of Type instances constraining the allowed values. See UML 2.5.1 specification section 7.5." ; + rdfs:domain sbol:Identified ; + rdfs:range xsd:anyURI . + +uml:LiteralSpecification a owl:Class ; + rdfs:comment "A LiteralSpecification identifies a literal constant being modeled. See UML 2.5.1 specification section 8.2." ; + rdfs:subClassOf uml:ValueSpecification . + +uml:Behavior a owl:Class ; + rdfs:comment "Behavior is an abstract specification of how a state changes over time. This specification may be a prospective definition of a protocol or a capture of an execution trace. See UML 2.5.1 specification section 13.2." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom uml:Constraint ; + owl:onProperty uml:precondition ], + [ a owl:Restriction ; + owl:allValuesFrom uml:Constraint ; + owl:onProperty uml:postcondition ], + [ a owl:Restriction ; + owl:allValuesFrom uml:OrderedPropertyValue ; + owl:onProperty uml:ownedParameter ], + sbol:TopLevel . + +uml:ActivityNode a owl:Class ; + rdfs:comment "ActivityNode is an abstract class for points in the flow of an Activity connected by ActivityEdges. See UML 2.5.1 specification section 15.2." ; + rdfs:subClassOf sbol:Identified . + +uml:isOrdered a owl:DatatypeProperty ; + rdfs:label "is_ordered" ; + rdfs:comment "For MultiplicityElement abstract class; UML 2.5.1 specification section 7.5" ; + rdfs:domain sbol:Identified ; + rdfs:range xsd:boolean . + +uml:firstEventValue a owl:ObjectProperty ; + rdfs:label "firstEvent" ; + rdfs:comment "The value of firstEvent[i] is related to event[i] (where i is 1 or 2). If firstEvent[i] is true, then the correspondingobservation event is the first time instant the execution enters event[i]. If firstEvent[i] is false, then the corresponding observation event is the time instant the execution exits event[i]." ; + rdfs:domain uml:DurationConstraint, + uml:DurationObservation, + uml:TimeConstraint, + uml:TimeObservation ; + rdfs:range uml:OrderedPropertyValue ; + rdfs:subPropertyOf sbol:directlyComprises . + +uml:OrderedPropertyValue a owl:Class ; + rdfs:subClassOf [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:indexValue ], + [ a owl:Restriction ; + owl:allValuesFrom sbol:Identified ; + owl:onProperty uml:propertyValue ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:indexValue ], + [ a owl:Restriction ; + owl:allValuesFrom xsd:integer ; + owl:onProperty uml:indexValue ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:propertyValue ], + [ a owl:Restriction ; + owl:minCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:propertyValue ], + sbol:Identified . + +uml:ValueSpecification a owl:Class ; + rdfs:label "ValueSpecification" ; + rdfs:comment "A ValueSpecification is the specification of a (possibly empty) set of values. See UML 2.5.1 specification section 8." ; + rdfs:subClassOf [ a owl:Restriction ; + owl:allValuesFrom xsd:anyURI ; + owl:onProperty uml:type ], + [ a owl:Restriction ; + owl:maxCardinality "1"^^xsd:nonNegativeInteger ; + owl:onProperty uml:type ], + sbol:Identified . + +sbol:directlyComprises a owl:ObjectProperty . diff --git a/src/lab/labop/resources/upstream.json b/src/lab/labop/resources/upstream.json new file mode 100644 index 0000000..cec0962 --- /dev/null +++ b/src/lab/labop/resources/upstream.json @@ -0,0 +1,26 @@ +{ + "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" + } + } +} diff --git a/src/lab/methods.py b/src/lab/methods.py new file mode 100644 index 0000000..0347e49 --- /dev/null +++ b/src/lab/methods.py @@ -0,0 +1,82 @@ +"""Digital methods rendered from the complete frozen experiment.""" + +from lab.documents import describe +from lab.experiment import ExperimentPlan +from lab.units import number + + +def render(experiment: ExperimentPlan) -> str: + """Render a planned methods specification without claiming completed work.""" + lines = [ + "# Planned methods", + "", + f"Experiment: <{experiment.identity}>", + "", + f"Semantic plan SHA-256: {experiment.digest}", + "", + "This document specifies planned work. It does not establish that any physical " + "step was performed. The accompanying SBOL document contains design sequences " + "and provenance; the LabOP document specifies control and material flow.", + "", + ] + if not experiment.stages: + lines += [ + "The requested materials are allocated from inventory; no procedure is required.", + "", + ] + for index, stage in enumerate(experiment.stages, 1): + protocol = stage.protocol + lines += [ + f"## {index}. {protocol.name}", + "", + f"Stage: <{stage.identity}>. Protocol: <{protocol.identity}>.", + "", + protocol.description, + "", + "### Initial conditions", + "", + "These loads are preconditions, not inferred dispensing operations.", + "", + ] + for resource in protocol.resources: + lines.append( + f"- {resource.name}: {resource.rows} × {resource.columns} wells, " + f"{number(resource.capacity)} µL capacity and " + f"{number(resource.dead_volume)} µL residual volume per well." + ) + for fill in resource.fills: + lines.append( + f"- {resource.name}:{fill.well}: {number(fill.volume)} µL of {fill.material}." + ) + lines += ["", "### Material identity", ""] + for sample in protocol.samples: + quantity = f"; {sample.count} unit(s)" if sample.count is not None else "" + form = f"; form {sample.form.value}" if sample.form is not None else "" + lines.append( + f"- {sample.id}: design {sample.material_identity}; implementation " + f"{sample.implementation.identity if sample.implementation else 'unspecified'}" + f"{form}{quantity}." + ) + lines += ["", "### Handoffs", ""] + if not stage.handoffs: + lines += ["No upstream stage handoff.", ""] + for handoff in stage.handoffs: + amount = ( + f"{handoff.count} unit(s)" + if handoff.count is not None + else f"{number(handoff.volume_ul)} µL" + ) + lines += [ + f"- Carry {handoff.implementation.identity} from stage <{handoff.producer}> " + f"at {handoff.source} to the logical binding {handoff.destination}, with " + f"{amount} remaining. This identifies the same material " + "and plate; it does not specify an additional liquid transfer.", + ] + lines += ["", "### Procedure", ""] + for step_index, step in enumerate(protocol.steps, 1): + lines += [f"{step_index}. {describe(step)} Step: <{step.identity}>."] + lines += ["", "### Planned outputs", ""] + for sample in protocol.output_manifest().samples: + lines.append(f"- {sample.id}; intended design {sample.material_identity}.") + lines.append("") + return "\n".join(lines) diff --git a/src/lab/model.py b/src/lab/model.py index 86fcb0a..222f064 100644 --- a/src/lab/model.py +++ b/src/lab/model.py @@ -1,112 +1,28 @@ """The entire recorded protocol vocabulary. No callbacks or device objects.""" -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, replace from decimal import Decimal from enum import Enum from typing import Any -from lab.samples import Location, OutputManifest, Sample, SamplePlacement +from lab.artifacts import canonical_json, digest +from lab.operations import ( + Distribute, + ExternalPreparation, + ManualInstruction, + Mix, + Resource, + SetTemperature, + Step, + Thermocycle, + Transfer, + Wait, +) +from lab.provenance.types import require_iri +from lab.samples import OutputManifest, Sample, SamplePlacement from lab.units import number -@dataclass(frozen=True) -class Origin: - file: str - line: int - - -@dataclass(frozen=True) -class Fill: - well: str - material: str - volume: Decimal - - -@dataclass(frozen=True) -class Resource: - name: str - rows: int - columns: int - capacity: Decimal - dead_volume: Decimal - fills: tuple[Fill, ...] = () - - @property - def wells(self) -> tuple[str, ...]: - return tuple( - f"{chr(65 + row)}{column + 1}" - for row in range(self.rows) - for column in range(self.columns) - ) - - -@dataclass(frozen=True) -class Transfer: - source: Location - destination: Location - volume: Decimal - origin: Origin - - -@dataclass(frozen=True) -class Distribute: - """One aspiration shared across destinations. ``air_gap`` is air, not liquid volume.""" - - source: Location - destinations: tuple[Location, ...] - volume: Decimal - air_gap: Decimal | None - origin: Origin - - -@dataclass(frozen=True) -class Mix: - location: Location - volume: Decimal - cycles: int - origin: Origin - - -@dataclass(frozen=True) -class Wait: - seconds: Decimal - origin: Origin - - -@dataclass(frozen=True) -class Hold: - celsius: Decimal - seconds: Decimal - - -@dataclass(frozen=True) -class Thermocycle: - resource: str - profile: tuple[Hold, ...] - cycles: int - lid_celsius: Decimal | None - origin: Origin - block_volume: Decimal | None = None - - -@dataclass(frozen=True) -class SetTemperature: - """Hold a plate's controlling module at one temperature. Liquid handling may continue.""" - - resource: str - celsius: Decimal - origin: Origin - - -@dataclass(frozen=True) -class ManualInstruction: - text: str - origin: Origin - - -Step = Transfer | Distribute | Mix | Wait | Thermocycle | SetTemperature | ManualInstruction - - @dataclass(frozen=True) class RecordedProtocol: name: str @@ -117,37 +33,63 @@ class RecordedProtocol: placements: tuple[SamplePlacement, ...] = () input_sample_ids: tuple[str, ...] = () output_sample_ids: tuple[str, ...] = () + identity: str | None = None + + def __post_init__(self) -> None: + for field in ( + self.resources, + self.steps, + self.samples, + self.placements, + self.input_sample_ids, + self.output_sample_ids, + ): + if not isinstance(field, tuple): + raise TypeError("Recorded protocol collections must be immutable tuples") + if any(not isinstance(resource.fills, tuple) for resource in self.resources): + raise TypeError("Recorded initial fills must be immutable tuples") + if any( + isinstance(step, Thermocycle) and not isinstance(step.profile, tuple) + for step in self.steps + ): + raise TypeError("Thermal profiles must be immutable tuples") + if any( + isinstance(step, Distribute) and not isinstance(step.destinations, tuple) + for step in self.steps + ): + raise TypeError("Distribution destinations must be immutable tuples") + identity = self.identity or "urn:lab:protocol:" + digest(semantic(self)) + require_iri(identity) + object.__setattr__(self, "identity", identity) + steps = tuple( + replace(step, identity=step.identity or identity + f"/step_{index + 1}") + for index, step in enumerate(self.steps) + ) + if len({step.identity for step in steps}) != len(steps): + raise ValueError("Step identities must be unique") + for step in steps: + require_iri(str(step.identity)) + object.__setattr__(self, "steps", steps) + + @property + def semantic_json(self) -> str: + return canonical_json(semantic(self)) + + @property + def digest(self) -> str: + return digest(semantic(self)) def output_manifest(self) -> OutputManifest: """Project declared outputs from this snapshot using logical locations.""" samples = {sample.id: sample for sample in self.samples} placements = {placement.sample_id: placement for placement in self.placements} return OutputManifest( - protocol_id=self.name, + protocol_id=str(self.identity), samples=tuple(samples[sample_id] for sample_id in self.output_sample_ids), placements=tuple(placements[sample_id] for sample_id in self.output_sample_ids), ) -@dataclass(frozen=True) -class Binding: - """One exact physical location and its usable constraints, in microlitres.""" - - location: Location - physical: str - capacity: Decimal - dead_volume: Decimal - - -@dataclass(frozen=True) -class TargetPlan: - name: str - bindings: tuple[Binding, ...] - configuration_json: str - source: str | None = None - setup: tuple[str, ...] = () - - def encode(value: Any) -> Any: """JSON-compatible data for the small, closed vocabulary.""" if isinstance(value, Decimal): @@ -159,10 +101,33 @@ def encode(value: Any) -> Any: if isinstance(value, dict): return {key: encode(item) for key, item in value.items()} if isinstance( - value, (Transfer, Distribute, Mix, Wait, Thermocycle, SetTemperature, ManualInstruction) + value, + ( + Transfer, + Distribute, + Mix, + Wait, + Thermocycle, + SetTemperature, + ManualInstruction, + ExternalPreparation, + ), ): return {"kind": type(value).__name__, **encode(asdict(value))} if hasattr(value, "__dataclass_fields__"): # Do not recursively use asdict here: it would erase step discriminants. return {name: encode(getattr(value, name)) for name in value.__dataclass_fields__} return value + + +def semantic(value: Any) -> Any: + """Encode meaning without developer source paths or line numbers.""" + + def clean(item: Any) -> Any: + 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)) diff --git a/src/lab/operations.py b/src/lab/operations.py new file mode 100644 index 0000000..617df37 --- /dev/null +++ b/src/lab/operations.py @@ -0,0 +1,178 @@ +"""Immutable semantic operations with persistent step identities.""" + +from dataclasses import dataclass +from decimal import Decimal + +from lab.provenance.types import require_iri +from lab.samples import Location + + +@dataclass(frozen=True) +class Origin: + file: str + line: int + + +@dataclass(frozen=True) +class Fill: + well: str + material: str + volume: Decimal + + +@dataclass(frozen=True) +class Resource: + name: str + rows: int + columns: int + capacity: Decimal + dead_volume: Decimal + fills: tuple[Fill, ...] = () + + @property + def wells(self) -> tuple[str, ...]: + return tuple( + f"{chr(65 + row)}{column + 1}" + for row in range(self.rows) + for column in range(self.columns) + ) + + +@dataclass(frozen=True, kw_only=True) +class Operation: + identity: str | None = None + + +@dataclass(frozen=True) +class Transfer(Operation): + source: Location + destination: Location + volume: Decimal + origin: Origin + destination_height_mm: Decimal | None = None + + def __post_init__(self) -> None: + if self.destination_height_mm is not None and ( + not isinstance(self.destination_height_mm, Decimal) + or not self.destination_height_mm.is_finite() + or self.destination_height_mm < 0 + ): + raise ValueError("Destination height must be a nonnegative finite Decimal") + + +@dataclass(frozen=True) +class Distribute(Operation): + """Reuse one tip across destinations, splitting aspirations to fit the target. + + ``air_gap`` is air, not liquid volume. + """ + + source: Location + destinations: tuple[Location, ...] + volume: Decimal + air_gap: Decimal | None + origin: Origin + + +@dataclass(frozen=True) +class Mix(Operation): + location: Location + volume: Decimal + cycles: int + origin: Origin + + +@dataclass(frozen=True) +class Wait(Operation): + seconds: Decimal + origin: Origin + + +@dataclass(frozen=True) +class Hold: + celsius: Decimal + seconds: Decimal + + +@dataclass(frozen=True) +class Thermocycle(Operation): + resource: str + profile: tuple[Hold, ...] + cycles: int + lid_celsius: Decimal | None + origin: Origin + block_volume: Decimal | None = None + + +@dataclass(frozen=True) +class SetTemperature(Operation): + """Hold a plate's controlling module at one temperature. Liquid handling may continue.""" + + resource: str + celsius: Decimal + origin: Origin + + +@dataclass(frozen=True) +class ManualInstruction(Operation): + text: str + origin: Origin + + +@dataclass(frozen=True, kw_only=True) +class MaterialPort: + location: Location + volume_ul: Decimal = Decimal(0) + count: int = 0 + + def __post_init__(self) -> None: + if not isinstance(self.location, Location): + raise TypeError("A material port needs a logical Location") + if ( + not isinstance(self.volume_ul, Decimal) + or not self.volume_ul.is_finite() + or self.volume_ul < 0 + or type(self.count) is not int + or self.count < 0 + or (self.volume_ul > 0) == (self.count > 0) + ): + raise ValueError("A material port specifies either positive volume or count") + + +@dataclass(frozen=True) +class ExternalPreparation(Operation): + procedure: str + instructions: str + inputs: tuple[MaterialPort, ...] + outputs: tuple[MaterialPort, ...] + origin: Origin + + def __post_init__(self) -> None: + require_iri(self.procedure) + if not isinstance(self.instructions, str) or not self.instructions.strip(): + raise ValueError("Supply external procedure instructions") + for ports in (self.inputs, self.outputs): + if ( + not isinstance(ports, tuple) + or not ports + or not all(isinstance(p, MaterialPort) for p in ports) + ): + raise TypeError( + "External inputs and outputs must be nonempty tuples of material ports" + ) + if len({port.location for port in ports}) != len(ports): + raise ValueError("Combine quantities for repeated material ports") + if {p.location for p in self.inputs} & {p.location for p in self.outputs}: + raise ValueError("External preparation outputs require distinct locations") + + +Step = ( + Transfer + | Distribute + | Mix + | Wait + | Thermocycle + | SetTemperature + | ManualInstruction + | ExternalPreparation +) diff --git a/src/lab/protocol.py b/src/lab/protocol.py index 6ba2fe2..2dae84e 100644 --- a/src/lab/protocol.py +++ b/src/lab/protocol.py @@ -7,14 +7,16 @@ from pathlib import Path from typing import Any -from lab.model import ( +from lab.model import RecordedProtocol +from lab.operations import ( Distribute, + ExternalPreparation, Fill, Hold, ManualInstruction, + MaterialPort, Mix, Origin, - RecordedProtocol, Resource, SetTemperature, Step, @@ -77,9 +79,10 @@ def _text(value: str, label: str) -> str: class Protocol: """Record sequential work. Construction and compilation never operate hardware.""" - def __init__(self, name: str, *, description: str = "") -> None: + def __init__(self, name: str, *, description: str = "", identity: str | None = None) -> None: self.name = _text(name, "Protocol name") self.description = description + self.identity = identity self._owner = object() self._resources: dict[str, Resource] = {} self._steps: list[Step] = [] @@ -150,6 +153,21 @@ def _location(self, well: Well) -> Location: raise ValueError("Unknown well") return Location(well.resource, well.name) + def external_preparation( + self, + *, + procedure: str, + instructions: str, + inputs: tuple[MaterialPort, ...], + outputs: tuple[MaterialPort, ...], + ) -> None: + """Specify operator work and its expected material balance, without executing it.""" + for port in (*inputs, *outputs): + resource = self._resources.get(port.location.resource) + if resource is None or port.location.well not in resource.wells: + raise ValueError("External material ports must reference this protocol's wells") + self._steps.append(ExternalPreparation(procedure, instructions, inputs, outputs, _origin())) + def load(self, well: Well, material: str, *, volume: Any) -> None: """Declare initial contents before recording any steps; this is not a transfer.""" if self._steps: @@ -191,11 +209,18 @@ def add_sample( if is_output: self._output_sample_ids.append(sample.id) - def transfer(self, source: Well, destination: Well, *, volume: Any) -> None: + def transfer( + self, source: Well, destination: Well, *, volume: Any, destination_height: Any = None + ) -> None: start, end = self._location(source), self._location(destination) if start == end: raise ValueError("Transfer source and destination must differ") - self._steps.append(Transfer(start, end, magnitude(volume, "microliter"), _origin())) + height = ( + None + if destination_height is None + else magnitude(destination_height, "millimeter", positive=False) + ) + self._steps.append(Transfer(start, end, magnitude(volume, "microliter"), _origin(), height)) def distribute( self, @@ -280,4 +305,5 @@ def snapshot(self) -> RecordedProtocol: tuple(self._placements), tuple(self._input_sample_ids), tuple(self._output_sample_ids), + self.identity, ) diff --git a/src/lab/provenance/__init__.py b/src/lab/provenance/__init__.py new file mode 100644 index 0000000..0755933 --- /dev/null +++ b/src/lab/provenance/__init__.py @@ -0,0 +1,83 @@ +"""SBOL3-native biological designs and planned or recorded provenance.""" + +from lab.provenance.document import Document, DocumentSnapshot +from lab.provenance.types import ( + Activity, + Agent, + Association, + Attachment, + Collection, + CombinatorialDerivation, + Component, + ComponentReference, + Constraint, + Cut, + EntireSequence, + Experiment, + ExperimentalData, + ExternallyDefined, + Feature, + Identified, + Implementation, + Interaction, + Interface, + LocalSubComponent, + Measure, + Model, + Participation, + Plan, + Range, + Ref, + Sequence, + SequenceFeature, + SequenceLocation, + SubComponent, + TopLevel, + Usage, + VariableFeature, +) +from lab.provenance.validation import Diagnostic, ProvenanceError, ValidationReport +from lab.provenance.vocabulary import AgentKind, EvidenceState + +__all__ = [ + "Activity", + "Agent", + "AgentKind", + "Association", + "Attachment", + "Collection", + "CombinatorialDerivation", + "Component", + "ComponentReference", + "Constraint", + "Cut", + "Diagnostic", + "Document", + "DocumentSnapshot", + "EntireSequence", + "EvidenceState", + "Experiment", + "ExperimentalData", + "ExternallyDefined", + "Feature", + "Identified", + "Implementation", + "Interaction", + "Interface", + "LocalSubComponent", + "Measure", + "Model", + "Participation", + "Plan", + "ProvenanceError", + "Range", + "Ref", + "Sequence", + "SequenceFeature", + "SequenceLocation", + "SubComponent", + "TopLevel", + "Usage", + "ValidationReport", + "VariableFeature", +] diff --git a/src/lab/provenance/_schema.py b/src/lab/provenance/_schema.py new file mode 100644 index 0000000..9aa8182 --- /dev/null +++ b/src/lab/provenance/_schema.py @@ -0,0 +1,229 @@ +"""Explicit SBOL3 property mapping shared by validation and serialization.""" + +from dataclasses import dataclass +from typing import Literal + +from lab.provenance.types import ( + Activity, + Agent, + Association, + Attachment, + Collection, + CombinatorialDerivation, + Component, + ComponentReference, + Constraint, + Cut, + EntireSequence, + Experiment, + ExperimentalData, + ExternallyDefined, + Feature, + Identified, + Implementation, + Interaction, + Interface, + LocalSubComponent, + Measure, + Model, + Participation, + Plan, + Range, + Sequence, + SequenceFeature, + SequenceLocation, + SubComponent, + TopLevel, + Usage, + VariableFeature, +) +from lab.provenance.vocabulary import LAB, OM, PROV, SBOL, AgentKind, EvidenceState + +Kind = Literal["text", "iri", "reference", "owned", "integer", "float", "datetime", "enum"] + + +@dataclass(frozen=True) +class Property: + predicate: str + kind: Kind + multiple: bool = False + required: bool = False + target: type | None = None + + +# These mappings are intentionally explicit: Python attribute names are not RDF +# predicate names, and Activity.types is sbol:type, not rdf:type. +PROPERTIES: dict[type[Identified], dict[str, Property]] = { + Identified: { + "name": Property(SBOL + "name", "text"), + "description": Property(SBOL + "description", "text"), + "derived_from": Property(PROV + "wasDerivedFrom", "reference", True, target=Identified), + "generated_by": Property(PROV + "wasGeneratedBy", "reference", True, target=Activity), + "measures": Property(SBOL + "hasMeasure", "owned", True, target=Measure), + }, + TopLevel: { + "namespace": Property(SBOL + "hasNamespace", "iri"), + "attachments": Property(SBOL + "hasAttachment", "reference", True, target=Attachment), + }, + Sequence: { + "elements": Property(SBOL + "elements", "text", required=True), + "encoding": Property(SBOL + "encoding", "iri", required=True), + }, + Component: { + "types": Property(SBOL + "type", "iri", True, True), + "roles": Property(SBOL + "role", "iri", True), + "sequences": Property(SBOL + "hasSequence", "reference", True, target=Sequence), + "features": Property(SBOL + "hasFeature", "owned", True, target=Feature), + "constraints": Property(SBOL + "hasConstraint", "owned", True, target=Constraint), + "interactions": Property(SBOL + "hasInteraction", "owned", True, target=Interaction), + "interface": Property(SBOL + "hasInterface", "owned", target=Interface), + "models": Property(SBOL + "hasModel", "reference", True, target=Model), + }, + Feature: { + "roles": Property(SBOL + "role", "iri", True), + "orientation": Property(SBOL + "orientation", "iri"), + }, + SubComponent: { + "instance_of": Property(SBOL + "instanceOf", "reference", required=True, target=Component), + "role_integration": Property(SBOL + "roleIntegration", "iri"), + "locations": Property(SBOL + "hasLocation", "owned", True, target=SequenceLocation), + "source_locations": Property( + SBOL + "sourceLocation", "owned", True, target=SequenceLocation + ), + }, + SequenceFeature: { + "locations": Property(SBOL + "hasLocation", "owned", True, True, SequenceLocation), + }, + LocalSubComponent: { + "types": Property(SBOL + "type", "iri", True, True), + "locations": Property(SBOL + "hasLocation", "owned", True, target=SequenceLocation), + }, + ExternallyDefined: { + "types": Property(SBOL + "type", "iri", True, True), + "definition": Property(SBOL + "definition", "iri", required=True), + }, + ComponentReference: { + "in_child_of": Property( + SBOL + "inChildOf", "reference", required=True, target=SubComponent + ), + "refers_to": Property(SBOL + "refersTo", "reference", required=True, target=Feature), + }, + SequenceLocation: { + "sequence": Property(SBOL + "hasSequence", "reference", required=True, target=Sequence), + "orientation": Property(SBOL + "orientation", "iri"), + "order": Property(SBOL + "order", "integer"), + }, + Range: { + "start": Property(SBOL + "start", "integer", required=True), + "end": Property(SBOL + "end", "integer", required=True), + }, + Cut: {"at": Property(SBOL + "at", "integer", required=True)}, + EntireSequence: {}, + Constraint: { + "restriction": Property(SBOL + "restriction", "iri", required=True), + "subject": Property(SBOL + "subject", "reference", required=True, target=Feature), + "object": Property(SBOL + "object", "reference", required=True, target=Feature), + }, + Interaction: { + "types": Property(SBOL + "type", "iri", True, True), + "participations": Property(SBOL + "hasParticipation", "owned", True, target=Participation), + }, + Participation: { + "roles": Property(SBOL + "role", "iri", True, True), + "participant": Property(SBOL + "participant", "reference", required=True, target=Feature), + }, + Interface: { + "inputs": Property(SBOL + "input", "reference", True, target=Feature), + "outputs": Property(SBOL + "output", "reference", True, target=Feature), + "nondirectionals": Property(SBOL + "nondirectional", "reference", True, target=Feature), + }, + Implementation: { + "built": Property(SBOL + "built", "reference", target=Component), + "evidence_state": Property(LAB + "evidenceState", "enum", target=EvidenceState), + }, + Attachment: { + "source": Property(SBOL + "source", "iri", required=True), + "format": Property(SBOL + "format", "iri"), + "size": Property(SBOL + "size", "integer"), + "hash": Property(SBOL + "hash", "text"), + "hash_algorithm": Property(SBOL + "hashAlgorithm", "text"), + }, + Model: { + "source": Property(SBOL + "source", "iri", required=True), + "language": Property(SBOL + "language", "iri", required=True), + "framework": Property(SBOL + "framework", "iri", required=True), + }, + Collection: {"members": Property(SBOL + "member", "reference", True, target=TopLevel)}, + Experiment: {"members": Property(SBOL + "member", "reference", True, target=ExperimentalData)}, + ExperimentalData: {}, + CombinatorialDerivation: { + "template": Property(SBOL + "template", "reference", required=True, target=Component), + "strategy": Property(SBOL + "strategy", "iri"), + "variable_features": Property( + SBOL + "hasVariableFeature", "owned", True, target=VariableFeature + ), + }, + VariableFeature: { + "cardinality": Property(SBOL + "cardinality", "iri", required=True), + "variable": Property(SBOL + "variable", "reference", required=True, target=Feature), + "variants": Property(SBOL + "variant", "reference", True, target=Component), + "variant_collections": Property( + SBOL + "variantCollection", "reference", True, target=Collection + ), + "variant_derivations": Property( + SBOL + "variantDerivation", "reference", True, target=CombinatorialDerivation + ), + "variant_measures": Property(SBOL + "variantMeasure", "owned", True, target=Measure), + }, + Measure: { + "value": Property(OM + "hasNumericalValue", "float", required=True), + "unit": Property(OM + "hasUnit", "iri", required=True), + "types": Property(SBOL + "type", "iri", True), + }, + Usage: { + "entity": Property(PROV + "entity", "reference", required=True, target=Identified), + "roles": Property(PROV + "hadRole", "iri", True), + }, + Association: { + "agent": Property(PROV + "agent", "reference", required=True, target=Agent), + "plan": Property(PROV + "hadPlan", "reference", target=Plan), + "roles": Property(PROV + "hadRole", "iri", True), + }, + Agent: { + "kind": Property(LAB + "agentKind", "enum", target=AgentKind), + "software_version": Property(LAB + "softwareVersion", "text"), + }, + Plan: {"protocol": Property(LAB + "protocol", "iri")}, + Activity: { + "types": Property(SBOL + "type", "iri", True), + "usage": Property(PROV + "qualifiedUsage", "owned", True, target=Usage), + "association": Property(PROV + "qualifiedAssociation", "owned", True, target=Association), + "informed_by": Property(PROV + "wasInformedBy", "reference", True, target=Activity), + "start_time": Property(PROV + "startedAtTime", "datetime"), + "end_time": Property(PROV + "endedAtTime", "datetime"), + "evidence_state": Property(LAB + "evidenceState", "enum", target=EvidenceState), + }, +} + +ABSTRACT = {Identified, TopLevel, Feature, SequenceLocation} +RDF_TYPES = { + cls: ( + PROV + if cls in {Activity, Agent, Plan, Usage, Association} + else OM + if cls is Measure + else SBOL + ) + + cls.__name__ + for cls in PROPERTIES + if cls not in ABSTRACT +} + + +def properties(cls: type[Identified]) -> dict[str, Property]: + if cls not in RDF_TYPES: + raise TypeError(f"Unsupported provenance class: {cls.__name__}") + result: dict[str, Property] = {} + for base in reversed(cls.__mro__): + result.update(PROPERTIES.get(base, {})) + return result diff --git a/src/lab/provenance/document.py b/src/lab/provenance/document.py new file mode 100644 index 0000000..43611f3 --- /dev/null +++ b/src/lab/provenance/document.py @@ -0,0 +1,262 @@ +"""Explicit document ownership, immutable snapshots, and local interchange.""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any, TypeVar, cast + +import sbol3 as native + +from lab.provenance import sbol3 as adapter +from lab.provenance._schema import properties +from lab.provenance.types import Identified, Ref, TopLevel, require_iri +from lab.provenance.validation import ( + ProvenanceError, + ValidationReport, + validate_objects, + validate_structure, + values, + walk, +) + +T = TypeVar("T", bound=Identified) + + +def _find(objects: tuple[TopLevel, ...], identity: str) -> Identified: + for top in objects: + for obj in walk(top): + if obj.identity == identity: + return obj + raise KeyError(identity) + + +def _normalized(objects: tuple[TopLevel, ...], namespace: str) -> tuple[TopLevel, ...]: + validate_structure(objects).raise_for_errors() + reserved = {obj.identity for top in objects for obj in walk(top) if obj.identity is not None} + + def normalize(obj: Identified, identity: str) -> Identified: + changes: dict[str, Any] = {"identity": identity} + if isinstance(obj, TopLevel) and obj.namespace is None: + if identity.startswith(namespace): + changes["namespace"] = namespace + else: + # Imported/mixed namespaces remain explicit, independent of the + # document's namespace for new authoring. + changes["namespace"] = identity.rsplit("/", 1)[0] if "/" in identity else namespace + counts: dict[str, int] = {} + for name, prop in properties(type(obj)).items(): + items = values(obj, name, prop) + if not items: + continue + if prop.kind == "owned": + children: list[Identified] = [] + for child in items: + assert isinstance(child, Identified) + child_id = child.identity + if child_id is None: + label = type(child).__name__ + number = counts.get(label, 0) + 1 + child_id = f"{identity}/{label}{number}" + while child_id in reserved: + number += 1 + child_id = f"{identity}/{label}{number}" + counts[label] = number + reserved.add(child_id) + children.append(normalize(child, child_id)) + changes[name] = ( + tuple(sorted(children, key=lambda child: child.identity or "")) + if prop.multiple + else children[0] + ) + elif prop.multiple: + # SBOL multi-valued properties are RDF sets. Biological order is + # encoded by locations and constraints, never tuple position. + changes[name] = tuple( + sorted( + items, + key=lambda item: item.identity if isinstance(item, Ref) else str(item), + ) + ) + return replace(obj, **changes) + + return tuple( + cast(TopLevel, normalize(obj, obj.identity)) + for obj in sorted(objects, key=lambda obj: obj.identity) + ) + + +def _write(path: str | Path, text: str) -> Path: + path = Path(path) + if path.exists() and path.read_text(encoding="utf-8") != text: + raise FileExistsError(f"{path} already contains a different artifact") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +@dataclass(frozen=True, kw_only=True) +class DocumentSnapshot: + namespace: str + objects: tuple[TopLevel, ...] + allow_external: bool = False + _extra_rdf: str = "" + + def __post_init__(self) -> None: + require_iri(self.namespace) + if not isinstance(self.objects, tuple): + raise TypeError("Snapshot objects must be a tuple") + self.validate().raise_for_errors() + + def get(self, identity: str, expected: type[T]) -> T: + obj = _find(self.objects, identity) + if not isinstance(obj, expected): + raise TypeError(f"{identity} is {type(obj).__name__}, not {expected.__name__}") + return obj + + def resolve(self, ref: Ref[T]) -> T: + return cast(T, _find(self.objects, ref.identity)) + + def validate(self) -> ValidationReport: + return validate_objects(self.objects, allow_external=self.allow_external) + + def to_sbol3(self) -> native.Document: + """Return a new mutable pySBOL3 document, detached from this snapshot.""" + return adapter.to_sbol3(self.objects, self._extra_rdf) + + def to_turtle(self) -> str: + return adapter.serialize(self.objects, self._extra_rdf) + + @property + def digest(self) -> str: + return hashlib.sha256(self.to_turtle().encode("utf-8")).hexdigest() + + def write(self, path: str | Path) -> Path: + return _write(path, self.to_turtle()) + + +class Document: + """An explicit collection of SBOL top-level objects. + + Adding objects does not add their referenced objects, infer missing designs, + fetch URLs, or change a global namespace. ``freeze`` assigns identities to + owned children and checks reference closure before returning a snapshot. + """ + + def __init__(self, *, namespace: str) -> None: + self.namespace = require_iri(namespace) + self._objects: dict[str, TopLevel] = {} + self._extra_rdf = "" + + def iri(self, local_id: str) -> str: + if not isinstance(local_id, str) or not re.fullmatch( + r"[A-Za-z_][A-Za-z0-9_]*(/[A-Za-z_][A-Za-z0-9_]*)*", local_id + ): + raise ValueError( + "Use slash-separated SBOL display IDs containing letters, digits, or underscores" + ) + return self.namespace.rstrip("/") + "/" + local_id + + @property + def objects(self) -> tuple[TopLevel, ...]: + return tuple(self._objects.values()) + + def add(self, *objects: TopLevel) -> None: + """Add top-level objects atomically; an identical addition is idempotent.""" + validate_structure(objects).raise_for_errors() + updated = dict(self._objects) + for obj in objects: + existing = updated.get(obj.identity) + if ( + existing is not None + and existing != obj + and _normalized((existing,), self.namespace) != _normalized((obj,), self.namespace) + ): + raise ValueError(f"Conflicting definition for {obj.identity}") + updated[obj.identity] = existing or obj + self._objects = updated + + def replace(self, *objects: TopLevel) -> None: + """Explicitly revise existing definitions in this authoring document. + + Frozen snapshots remain unchanged. Identity and concrete type must already + exist; freeze validates references and evidence after the revisions. + """ + validate_structure(objects).raise_for_errors() + updated = dict(self._objects) + for obj in objects: + if obj.identity not in updated: + raise KeyError(obj.identity) + if type(updated[obj.identity]) is not type(obj): + raise TypeError("Replacing a definition cannot change its concrete type") + updated[obj.identity] = obj + self._objects = updated + + def get(self, identity: str, expected: type[T]) -> T: + obj = _find(self.objects, identity) + if not isinstance(obj, expected): + raise TypeError(f"{identity} is {type(obj).__name__}, not {expected.__name__}") + return obj + + def resolve(self, ref: Ref[T]) -> T: + return cast(T, _find(self.objects, ref.identity)) + + def validate(self, *, allow_external: bool = False) -> ValidationReport: + try: + objects = _normalized(self.objects, self.namespace) + except ProvenanceError as error: + return error.report + return validate_objects(objects, allow_external=allow_external) + + def freeze(self, *, allow_external: bool = False) -> DocumentSnapshot: + return DocumentSnapshot( + namespace=self.namespace, + objects=_normalized(self.objects, self.namespace), + allow_external=allow_external, + _extra_rdf=self._extra_rdf, + ) + + def to_sbol3(self, *, allow_external: bool = False) -> native.Document: + return self.freeze(allow_external=allow_external).to_sbol3() + + def write(self, path: str | Path, *, allow_external: bool = False) -> Path: + return self.freeze(allow_external=allow_external).write(path) + + @classmethod + def from_snapshot(cls, snapshot: DocumentSnapshot) -> Document: + result = cls(namespace=snapshot.namespace) + result.add(*snapshot.objects) + result._extra_rdf = snapshot._extra_rdf + return result + + @classmethod + def _from_objects( + cls, objects: tuple[TopLevel, ...], extra_rdf: str, namespace: str | None + ) -> Document: + if namespace is None: + namespaces = {obj.namespace for obj in objects if obj.namespace is not None} + if len(namespaces) != 1: + raise ValueError("Supply namespace= for an empty document or multiple namespaces") + namespace = namespaces.pop() + assert namespace is not None + result = cls(namespace=namespace) + result.add(*objects) + result._extra_rdf = extra_rdf + return result + + @classmethod + def from_sbol3(cls, document: native.Document, *, namespace: str | None = None) -> Document: + objects, extra = adapter.from_sbol3(document) + return cls._from_objects(objects, extra, namespace) + + @classmethod + def from_turtle(cls, text: str, *, namespace: str | None = None) -> Document: + objects, extra = adapter.parse(text) + return cls._from_objects(objects, extra, namespace) + + @classmethod + def read(cls, path: str | Path, *, namespace: str | None = None) -> Document: + """Read local Turtle; referenced resources are never fetched.""" + return cls.from_turtle(Path(path).read_text(encoding="utf-8"), namespace=namespace) diff --git a/src/lab/provenance/resources/lab.ttl b/src/lab/provenance/resources/lab.ttl new file mode 100644 index 0000000..a9efafe --- /dev/null +++ b/src/lab/provenance/resources/lab.ttl @@ -0,0 +1,18 @@ +@prefix lab: . +@prefix rdf: . +@prefix rdfs: . + +lab:evidenceState a rdf:Property ; + rdfs:comment "Nature of the assertion; recorded does not imply sequence verification or experimental success." . +lab:unknown rdfs:label "Evidence state unspecified" . +lab:planned rdfs:label "Prospective work or output" . +lab:recorded rdfs:label "Recorded activity or material assertion" . +lab:simulated rdfs:label "Simulated activity or output" . +lab:agentKind a rdf:Property ; + rdfs:comment "PROV Person, Organization, or SoftwareAgent classification." . +lab:softwareVersion a rdf:Property ; + rdfs:comment "Version of the software represented by this agent." . +lab:protocol a rdf:Property ; + rdfs:comment "IRI of the protocol specified by a PROV Plan, for example a LabOP Protocol." . +lab:inputMaterial rdfs:label "Material used as an input to an activity" . +lab:planner rdfs:label "Agent responsible for planning an activity" . diff --git a/src/lab/provenance/sbol3.py b/src/lab/provenance/sbol3.py new file mode 100644 index 0000000..645e46a --- /dev/null +++ b/src/lab/provenance/sbol3.py @@ -0,0 +1,242 @@ +"""SBOL3 RDF interchange and a detached pySBOL3 adapter. + +RDF is the interoperability boundary. Constructing the graph explicitly preserves +owned identities and avoids pySBOL3's Activity.informed_by reparenting behavior. +No builder registrations or process-wide namespaces are changed. +""" + +from datetime import datetime +from enum import Enum +from typing import Any + +import sbol3 as native +from rdflib import RDF, XSD, BNode, Graph, Literal, URIRef +from rdflib.compare import to_canonical_graph +from rdflib.term import Identifier, Node +from sbol3.identified import extract_display_id, is_valid_display_id + +from lab.provenance._schema import RDF_TYPES, Property, properties +from lab.provenance.types import Identified, Ref, TopLevel, require_iri +from lab.provenance.validation import values, walk +from lab.provenance.vocabulary import SBOL, EvidenceState + + +def _n3(term: Node) -> str: + assert isinstance(term, (URIRef, BNode, Literal)) + return term.n3() + + +def canonical_rdf(graph: Graph) -> str: + """Deterministic Turtle using full IRIs and canonical blank-node identifiers.""" + canonical = to_canonical_graph(graph) + return "".join(sorted(f"{_n3(s)} {_n3(p)} {_n3(o)} .\n" for s, p, o in canonical)) + + +def graph_for(objects: tuple[TopLevel, ...], extra_rdf: str = "") -> Graph: + graph = Graph() + if extra_rdf: + graph.parse(data=extra_rdf, format="turtle") + for top in objects: + for obj in walk(top): + assert obj.identity is not None + subject = URIRef(obj.identity) + graph.add((subject, RDF.type, URIRef(RDF_TYPES[type(obj)]))) + if not RDF_TYPES[type(obj)].startswith(SBOL): + base = "TopLevel" if isinstance(obj, TopLevel) else "Identified" + graph.add((subject, RDF.type, URIRef(SBOL + base))) + # Preserve an imported displayId; otherwise derive it as pySBOL3 does. + if not list(graph.objects(subject, URIRef(SBOL + "displayId"))): + display_id = extract_display_id(obj.identity) + if display_id is not None: + graph.add((subject, URIRef(SBOL + "displayId"), Literal(display_id))) + for name, prop in properties(type(obj)).items(): + for value in values(obj, name, prop): + # Absence means unknown; do not fabricate assertions on import. + if value is EvidenceState.UNKNOWN: + continue + if isinstance(value, (Ref, Identified)): + assert value.identity is not None + term: Identifier = URIRef(value.identity) + elif isinstance(value, Enum): + term = URIRef(value.value) + elif prop.kind == "iri": + term = URIRef(str(value)) + elif prop.kind == "float": + term = Literal(float(value), datatype=XSD.float) # type: ignore[arg-type] + else: + term = Literal(value) + graph.add((subject, URIRef(prop.predicate), term)) + return graph + + +def serialize(objects: tuple[TopLevel, ...], extra_rdf: str = "") -> str: + return canonical_rdf(graph_for(objects, extra_rdf)) + + +def parse(data: str, *, format: str = "turtle") -> tuple[tuple[TopLevel, ...], str]: + """Parse local RDF data; formats that can fetch remote contexts are excluded.""" + if format not in {"turtle", "nt"}: + raise ValueError("Provenance input must be Turtle or N-Triples") + # A relative IRI without an explicit @base must not silently become a path + # inside the caller's current working directory. + graph = Graph().parse(data=data, format=format, publicID="urn:lab:provenance:input") + by_type: dict[Node, type[Identified]] = {URIRef(iri): cls for cls, iri in RDF_TYPES.items()} + classes: dict[Node, type[Identified]] = {} + for subject, _, rdf_type in graph.triples((None, RDF.type, None)): + if str(rdf_type).startswith("http://sbols.org/v2#"): + raise ValueError("SBOL2 input is unsupported; supply an SBOL3 document") + if rdf_type not in by_type: + if str(rdf_type) in {SBOL + "TopLevel", SBOL + "Identified"}: + continue + if str(rdf_type).startswith(SBOL): + raise ValueError(f"Unsupported SBOL class {rdf_type}") + continue + if not isinstance(subject, URIRef): + raise ValueError("SBOL objects need absolute IRI identities, not blank nodes") + if subject in classes and classes[subject] is not by_type[rdf_type]: + raise ValueError(f"Conflicting SBOL classes for {subject}") + classes[subject] = by_type[rdf_type] + + consumed = Graph() + cache: dict[Node, Identified] = {} + visiting: set[Node] = set() + owners: dict[Node, Node] = {} + + def decode(term: Node, prop: Property) -> object: + if prop.kind in {"iri", "reference", "owned", "enum"}: + if not isinstance(term, URIRef): + raise ValueError(f"{prop.predicate} requires an IRI, got {term!r}") + require_iri(str(term)) + if prop.kind == "reference": + return Ref(str(term)) + if prop.kind == "owned": + return build(term) + if prop.kind == "enum": + assert prop.target is not None + return prop.target(str(term)) + return str(term) + if not isinstance(term, Literal): + raise ValueError(f"{prop.predicate} requires a literal, got {term!r}") + python = term.toPython() + if prop.kind == "text": + if term.language or (term.datatype is not None and term.datatype != XSD.string): + raise ValueError(f"{prop.predicate} requires an untagged string literal") + return str(term) + if prop.kind == "integer" and type(python) is int: + return python + if prop.kind == "float" and type(python) in {float, int}: + return float(python) + if prop.kind == "datetime" and isinstance(python, datetime): + return python + raise ValueError(f"Invalid {prop.kind} literal for {prop.predicate}: {term!r}") + + def build(subject: Node) -> Identified: + if subject in visiting: + raise ValueError(f"Cyclic SBOL ownership at {subject}") + if subject in cache: + return cache[subject] + cls = classes.get(subject) + if cls is None: + raise ValueError(f"Missing or unsupported owned object {subject}") + visiting.add(subject) + args: dict[str, Any] = {"identity": str(subject)} + consumed.add((subject, RDF.type, URIRef(RDF_TYPES[cls]))) + for name, prop in properties(cls).items(): + predicate = URIRef(prop.predicate) + terms = sorted(graph.objects(subject, predicate), key=_n3) + if not prop.multiple and len(terms) > 1: + raise ValueError(f"{subject}.{name} must have at most one value") + if prop.required and not terms: + raise ValueError(f"{subject}.{name} is required") + if prop.kind == "owned": + for term in terms: + if term in owners: + raise ValueError(f"Owned object {term} has more than one owner/property") + owners[term] = subject + if terms: + decoded = tuple(decode(term, prop) for term in terms) + args[name] = decoded if prop.multiple else decoded[0] + for term in terms: + consumed.add((subject, predicate, term)) + result = cls(**args) + cache[subject] = result + visiting.remove(subject) + return result + + objects = tuple( + build(subject) + for subject in sorted(classes, key=str) + if issubclass(classes[subject], TopLevel) + ) + for subject, cls in classes.items(): + if not issubclass(cls, TopLevel) and subject not in owners: + raise ValueError(f"Orphaned owned object {subject}") + for subject, _, _ in graph.triples((None, URIRef(SBOL + "hasNamespace"), None)): + if subject not in classes: + raise ValueError(f"Unsupported top-level object {subject}") + extra = graph - consumed + for subject in classes: + display_id = extract_display_id(str(subject)) + declared = tuple(graph.objects(subject, URIRef(SBOL + "displayId"))) + if len(declared) > 1: + raise ValueError(f"{subject}.displayId must have at most one value") + if declared: + value = declared[0] + if ( + not isinstance(value, Literal) + or value.language + or value.datatype not in {None, XSD.string} + or not is_valid_display_id(str(value)) + or (display_id is not None and str(value) != display_id) + ): + raise ValueError(f"Invalid displayId for {subject}: {value!r}") + if display_id is not None: + extra.remove((subject, URIRef(SBOL + "displayId"), None)) + if not RDF_TYPES[classes[subject]].startswith(SBOL): + base = "TopLevel" if issubclass(classes[subject], TopLevel) else "Identified" + extra.remove((subject, RDF.type, URIRef(SBOL + base))) + # Foreign annotation graphs, including nested blank nodes, are retained. An + # unrecognized SBOL property is an unsupported schema, not an annotation. + for subject, predicate, _ in extra: + if str(predicate).startswith(SBOL) and predicate != URIRef(SBOL + "displayId"): + raise ValueError(f"Unsupported SBOL property {predicate} on {subject}") + assert all(isinstance(obj, TopLevel) for obj in objects) + return tuple(obj for obj in objects if isinstance(obj, TopLevel)), canonical_rdf(extra) + + +def to_sbol3(objects: tuple[TopLevel, ...], extra_rdf: str = "") -> native.Document: + document = native.Document() + document.read_string(serialize(objects, extra_rdf), native.TURTLE) + # Two further mapping defects in the pinned pySBOL3 release are repaired on + # these detached instances only. Standard predicates remain intact in RDF. + for top in objects: + for obj in walk(top): + converted = document.find(obj.identity) + if isinstance(converted, native.Cut): + object.__setattr__( + converted, "at", native.IntProperty(converted, SBOL + "at", 1, 1) + ) + if isinstance(converted, native.SubComponent): + object.__setattr__( + converted, + "role_integration", + native.URIProperty(converted, SBOL + "roleIntegration", 0, 1), + ) + return document + + +def from_sbol3(document: native.Document) -> tuple[tuple[TopLevel, ...], str]: + if not isinstance(document, native.Document): + raise TypeError("Pass a pySBOL3 Document") + graph = document.graph() + # Normalize only the known legacy predicates emitted by this pySBOL3 version. + for subject in graph.subjects(RDF.type, URIRef(SBOL + "Cut")): + for value in tuple(graph.objects(subject, URIRef(SBOL + "start"))): + graph.remove((subject, URIRef(SBOL + "start"), value)) + graph.add((subject, URIRef(SBOL + "at"), value)) + for subject in graph.subjects(RDF.type, URIRef(SBOL + "SubComponent")): + for value in tuple(graph.objects(subject, URIRef(SBOL + "role"))): + if str(value) in {SBOL + "mergeRoles", SBOL + "overrideRoles"}: + graph.remove((subject, URIRef(SBOL + "role"), value)) + graph.add((subject, URIRef(SBOL + "roleIntegration"), value)) + return parse(canonical_rdf(graph)) diff --git a/src/lab/provenance/types.py b/src/lab/provenance/types.py new file mode 100644 index 0000000..832739a --- /dev/null +++ b/src/lab/provenance/types.py @@ -0,0 +1,290 @@ +"""Immutable SBOL3 design and provenance types, with no global document state.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, fields +from datetime import datetime +from typing import Generic, Self, TypeVar +from urllib.parse import urlsplit + +from lab.provenance.vocabulary import AgentKind, EvidenceState + +T_co = TypeVar("T_co", bound="Identified", covariant=True) + + +def require_iri(value: str) -> str: + """Require an absolute IRI; never resolve, fetch, or rewrite it.""" + if ( + not isinstance(value, str) + or not value + or re.search(r'[\s<>"{}|\\^`\x00-\x1f\x7f]', value) + or not re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", value) + ): + raise ValueError(f"Expected an absolute IRI, got {value!r}") + parsed = urlsplit(value) + if parsed.scheme in {"http", "https"} and not parsed.netloc: + raise ValueError(f"Expected an absolute IRI, got {value!r}") + return value + + +@dataclass(frozen=True, slots=True) +class Ref(Generic[T_co]): + identity: str + + def __post_init__(self) -> None: + require_iri(self.identity) + + +@dataclass(frozen=True, kw_only=True) +class Identified: + """Common SBOL metadata. Only owned objects may omit an identity.""" + + identity: str | None = None + name: str | None = None + description: str | None = None + derived_from: tuple[Ref[Identified], ...] = () + generated_by: tuple[Ref[Activity], ...] = () + measures: tuple[Measure, ...] = () + + def __post_init__(self) -> None: + if self.identity is not None: + require_iri(self.identity) + # Reject shallowly frozen objects containing mutable collections. + for field in fields(self): + value = getattr(self, field.name) + if isinstance(value, (list, dict, set)): + raise TypeError( + f"{type(self).__name__}.{field.name} must be immutable; use a tuple" + ) + + @property + def ref(self) -> Ref[Self]: + if self.identity is None: + raise ValueError("Assign an identity, or retrieve the owned object after freezing") + return Ref(self.identity) + + +@dataclass(frozen=True, kw_only=True) +class TopLevel(Identified): + identity: str + namespace: str | None = None + attachments: tuple[Ref[Attachment], ...] = () + + def __post_init__(self) -> None: + super().__post_init__() + require_iri(self.identity) + if self.namespace is not None: + require_iri(self.namespace) + + +@dataclass(frozen=True, kw_only=True) +class Measure(Identified): + value: float + unit: str + types: tuple[str, ...] = () + + +@dataclass(frozen=True, kw_only=True) +class Sequence(TopLevel): + elements: str + encoding: str + + +@dataclass(frozen=True, kw_only=True) +class SequenceLocation(Identified): + sequence: Ref[Sequence] + orientation: str | None = None + order: int | None = None + + +@dataclass(frozen=True, kw_only=True) +class Range(SequenceLocation): + """One-based, inclusive sequence coordinates.""" + + start: int + end: int + + +@dataclass(frozen=True, kw_only=True) +class Cut(SequenceLocation): + """Position between bases; zero denotes the beginning of a sequence.""" + + at: int + + +@dataclass(frozen=True, kw_only=True) +class EntireSequence(SequenceLocation): + pass + + +@dataclass(frozen=True, kw_only=True) +class Feature(Identified): + roles: tuple[str, ...] = () + orientation: str | None = None + + +@dataclass(frozen=True, kw_only=True) +class SubComponent(Feature): + instance_of: Ref[Component] + role_integration: str | None = None + locations: tuple[SequenceLocation, ...] = () + source_locations: tuple[SequenceLocation, ...] = () + + +@dataclass(frozen=True, kw_only=True) +class SequenceFeature(Feature): + locations: tuple[SequenceLocation, ...] + + +@dataclass(frozen=True, kw_only=True) +class LocalSubComponent(Feature): + types: tuple[str, ...] + locations: tuple[SequenceLocation, ...] = () + + +@dataclass(frozen=True, kw_only=True) +class ExternallyDefined(Feature): + types: tuple[str, ...] + definition: str + + +@dataclass(frozen=True, kw_only=True) +class ComponentReference(Feature): + in_child_of: Ref[SubComponent] + refers_to: Ref[Feature] + + +@dataclass(frozen=True, kw_only=True) +class Constraint(Identified): + restriction: str + subject: Ref[Feature] + object: Ref[Feature] + + +@dataclass(frozen=True, kw_only=True) +class Participation(Identified): + roles: tuple[str, ...] + participant: Ref[Feature] + + +@dataclass(frozen=True, kw_only=True) +class Interaction(Identified): + types: tuple[str, ...] + participations: tuple[Participation, ...] = () + + +@dataclass(frozen=True, kw_only=True) +class Interface(Identified): + inputs: tuple[Ref[Feature], ...] = () + outputs: tuple[Ref[Feature], ...] = () + nondirectionals: tuple[Ref[Feature], ...] = () + + +@dataclass(frozen=True, kw_only=True) +class Component(TopLevel): + types: tuple[str, ...] + roles: tuple[str, ...] = () + sequences: tuple[Ref[Sequence], ...] = () + features: tuple[Feature, ...] = () + constraints: tuple[Constraint, ...] = () + interactions: tuple[Interaction, ...] = () + interface: Interface | None = None + models: tuple[Ref[Model], ...] = () + + +@dataclass(frozen=True, kw_only=True) +class Implementation(TopLevel): + """A planned, recorded, or simulated realization of a design. + + ``derived_from`` names the intended design. ``built`` describes the realized + structure, when known. A planned output does not assert that it was built. + """ + + built: Ref[Component] | None = None + evidence_state: EvidenceState = EvidenceState.UNKNOWN + + +@dataclass(frozen=True, kw_only=True) +class Attachment(TopLevel): + source: str + format: str | None = None + size: int | None = None + hash: str | None = None + hash_algorithm: str | None = None + + +@dataclass(frozen=True, kw_only=True) +class Model(TopLevel): + source: str + language: str + framework: str + + +@dataclass(frozen=True, kw_only=True) +class Collection(TopLevel): + members: tuple[Ref[TopLevel], ...] = () + + +@dataclass(frozen=True, kw_only=True) +class ExperimentalData(TopLevel): + pass + + +@dataclass(frozen=True, kw_only=True) +class Experiment(TopLevel): + members: tuple[Ref[ExperimentalData], ...] = () + + +@dataclass(frozen=True, kw_only=True) +class VariableFeature(Identified): + cardinality: str + variable: Ref[Feature] + variants: tuple[Ref[Component], ...] = () + variant_collections: tuple[Ref[Collection], ...] = () + variant_derivations: tuple[Ref[CombinatorialDerivation], ...] = () + variant_measures: tuple[Measure, ...] = () + + +@dataclass(frozen=True, kw_only=True) +class CombinatorialDerivation(TopLevel): + template: Ref[Component] + strategy: str | None = None + variable_features: tuple[VariableFeature, ...] = () + + +@dataclass(frozen=True, kw_only=True) +class Usage(Identified): + entity: Ref[Identified] + roles: tuple[str, ...] = () + + +@dataclass(frozen=True, kw_only=True) +class Association(Identified): + agent: Ref[Agent] + plan: Ref[Plan] | None = None + roles: tuple[str, ...] = () + + +@dataclass(frozen=True, kw_only=True) +class Agent(TopLevel): + kind: AgentKind | None = None + software_version: str | None = None + + +@dataclass(frozen=True, kw_only=True) +class Plan(TopLevel): + """Method identity; ``protocol`` links to its separately specified protocol.""" + + protocol: str | None = None + + +@dataclass(frozen=True, kw_only=True) +class Activity(TopLevel): + types: tuple[str, ...] = () + usage: tuple[Usage, ...] = () + association: tuple[Association, ...] = () + informed_by: tuple[Ref[Activity], ...] = () + start_time: datetime | None = None + end_time: datetime | None = None + evidence_state: EvidenceState = EvidenceState.UNKNOWN diff --git a/src/lab/provenance/validation.py b/src/lab/provenance/validation.py new file mode 100644 index 0000000..3bee4be --- /dev/null +++ b/src/lab/provenance/validation.py @@ -0,0 +1,373 @@ +"""Structural, reference, and evidence checks for Lab provenance documents.""" + +import math +from dataclasses import dataclass +from datetime import datetime +from urllib.parse import urlsplit + +from sbol3.identified import extract_display_id + +from lab.provenance._schema import Property, properties +from lab.provenance.types import ( + Activity, + Attachment, + CombinatorialDerivation, + Component, + ComponentReference, + Cut, + Identified, + Implementation, + Range, + Ref, + Sequence, + SequenceLocation, + SubComponent, + TopLevel, + VariableFeature, + require_iri, +) +from lab.provenance.vocabulary import INLINE, REVERSE_COMPLEMENT, SBOL, EvidenceState + + +@dataclass(frozen=True) +class Diagnostic: + identity: str | None + path: str + code: str + message: str + + +@dataclass(frozen=True) +class ValidationReport: + errors: tuple[Diagnostic, ...] = () + + @property + def is_valid(self) -> bool: + return not self.errors + + def raise_for_errors(self) -> None: + if self.errors: + raise ProvenanceError(self) + + +class ProvenanceError(ValueError): + def __init__(self, report: ValidationReport) -> None: + self.report = report + super().__init__( + "\n".join( + f"{item.identity or ''}.{item.path}: {item.message} [{item.code}]" + for item in report.errors + ) + ) + + +def values(obj: Identified, name: str, prop: Property) -> tuple[object, ...]: + value = getattr(obj, name) + if prop.multiple: + return value if isinstance(value, tuple) else (value,) + return () if value is None else (value,) + + +def owned(obj: Identified) -> tuple[Identified, ...]: + return tuple( + value + for name, prop in properties(type(obj)).items() + if prop.kind == "owned" + for value in values(obj, name, prop) + if isinstance(value, Identified) + ) + + +def walk(obj: Identified) -> tuple[Identified, ...]: + return (obj, *(descendant for child in owned(obj) for descendant in walk(child))) + + +def _valid_value(value: object, prop: Property) -> bool: + if prop.kind == "reference": + return isinstance(value, Ref) + if prop.kind in {"owned", "enum"}: + return prop.target is not None and isinstance(value, prop.target) + if prop.kind == "integer": + return type(value) is int + if prop.kind == "float": + return type(value) in {float, int} and math.isfinite(value) # type: ignore[arg-type] + if prop.kind == "datetime": + return isinstance(value, datetime) + if not isinstance(value, str): + return False + if prop.kind == "iri": + try: + require_iri(value) + except ValueError: + return False + return True + + +def validate_structure(objects: tuple[TopLevel, ...]) -> ValidationReport: + """Check field shapes before ownership normalization or RDF serialization.""" + errors: list[Diagnostic] = [] + + def visit(obj: Identified) -> None: + try: + schema = properties(type(obj)) + except TypeError as error: + errors.append(Diagnostic(obj.identity, "type", "unsupported-type", str(error))) + return + for name, prop in schema.items(): + raw = getattr(obj, name) + items = values(obj, name, prop) + if prop.multiple and not isinstance(raw, tuple): + errors.append(Diagnostic(obj.identity, name, "field-type", "Expected a tuple")) + if prop.required and not items: + errors.append(Diagnostic(obj.identity, name, "required", "A value is required")) + for value in items: + if not _valid_value(value, prop): + errors.append( + Diagnostic( + obj.identity, name, "field-type", f"Invalid {prop.kind} value {value!r}" + ) + ) + elif prop.kind == "owned" and isinstance(value, Identified): + visit(value) + if ( + prop.multiple + and prop.kind != "owned" + and all(_valid_value(value, prop) for value in items) + and len(set(items)) != len(items) + ): + errors.append( + Diagnostic( + obj.identity, + name, + "duplicate-value", + "SBOL properties contain unique values", + ) + ) + + for obj in objects: + if not isinstance(obj, TopLevel): + errors.append(Diagnostic(None, "objects", "field-type", "Expected a TopLevel object")) + else: + visit(obj) + return ValidationReport(tuple(errors)) + + +def validate_objects( + objects: tuple[TopLevel, ...], *, allow_external: bool = False +) -> ValidationReport: + structural = validate_structure(objects) + if not structural.is_valid: + return structural + errors: list[Diagnostic] = [] + index: dict[str, Identified] = {} + + def error(obj: Identified, path: str, code: str, message: str) -> None: + errors.append(Diagnostic(obj.identity, path, code, message)) + + for top in objects: + for obj in walk(top): + if obj.identity is None: + error( + obj, "identity", "missing-identity", "Freeze the document to assign identities" + ) + elif obj.identity in index: + error(obj, "identity", "duplicate-identity", "An identity has more than one owner") + else: + index[obj.identity] = obj + for obj in index.values(): + try: + extract_display_id(obj.identity) + except ValueError as invalid_identity: + error(obj, "identity", "display-id", str(invalid_identity)) + if ( + isinstance(obj, TopLevel) + and obj.namespace is not None + and urlsplit(obj.identity).netloc + and not obj.identity.startswith(obj.namespace) + ): + error(obj, "namespace", "namespace", "Namespace must be a prefix of the identity") + for name, prop in properties(type(obj)).items(): + if prop.kind != "reference": + continue + for value in values(obj, name, prop): + assert isinstance(value, Ref) + target = index.get(value.identity) + if target is None and not allow_external: + error(obj, name, "unresolved-reference", f"Missing {value.identity}") + elif ( + target is not None + and prop.target is not None + and not isinstance(target, prop.target) + ): + error( + obj, + name, + "reference-type", + f"{value.identity} must reference {prop.target.__name__}", + ) + orientation = getattr(obj, "orientation", None) + if orientation is not None and orientation not in {INLINE, REVERSE_COMPLEMENT}: + error(obj, "orientation", "orientation", "Expected inline or reverseComplement") + if ( + isinstance(obj, SubComponent) + and obj.role_integration is not None + and obj.role_integration not in {SBOL + "mergeRoles", SBOL + "overrideRoles"} + ): + error( + obj, "role_integration", "role-integration", "Expected mergeRoles or overrideRoles" + ) + if isinstance(obj, SequenceLocation): + if obj.order is not None and obj.order < 1: + error(obj, "order", "location-order", "Order must be positive") + sequence = index.get(obj.sequence.identity) + length = len(sequence.elements) if isinstance(sequence, Sequence) else None + if isinstance(obj, Range) and ( + obj.start < 1 or obj.end < obj.start or (length is not None and obj.end > length) + ): + error( + obj, + "start/end", + "sequence-range", + "Range must be within its sequence (1-based, inclusive)", + ) + if isinstance(obj, Cut) and (obj.at < 0 or (length is not None and obj.at > length)): + error(obj, "at", "sequence-cut", "Cut must be between zero and sequence length") + if isinstance(obj, Attachment): + if obj.size is not None and obj.size < 0: + error(obj, "size", "attachment-size", "Size must not be negative") + if (obj.hash is None) != (obj.hash_algorithm is None): + error(obj, "hash", "attachment-hash", "Supply both hash and hash_algorithm") + if ( + isinstance(obj, Implementation) + and obj.evidence_state is EvidenceState.PLANNED + and obj.built is not None + ): + error( + obj, + "built", + "planned-realization", + "Use derived_from for the intended design of a planned output", + ) + if isinstance(obj, Activity): + times = tuple(t for t in (obj.start_time, obj.end_time) if t is not None) + aware = all(t.tzinfo is not None and t.utcoffset() is not None for t in times) + if not aware: + error(obj, "start_time/end_time", "timezone", "Timestamps must include a timezone") + if ( + aware + and obj.start_time is not None + and obj.end_time is not None + and obj.end_time < obj.start_time + ): + error(obj, "end_time", "time-order", "End precedes start") + if times and obj.evidence_state is EvidenceState.PLANNED: + error( + obj, + "start_time/end_time", + "planned-execution", + "Planned activities cannot assert execution times", + ) + if isinstance(obj, VariableFeature) and obj.cardinality not in { + SBOL + word for word in ("one", "zeroOrOne", "oneOrMore", "zeroOrMore") + }: + error(obj, "cardinality", "cardinality", "Unknown SBOL cardinality") + if isinstance(obj, CombinatorialDerivation): + if obj.strategy is not None and obj.strategy not in { + SBOL + "sample", + SBOL + "enumerate", + }: + error(obj, "strategy", "strategy", "Unknown SBOL derivation strategy") + template = index.get(obj.template.identity) + if isinstance(template, Component): + members = {feature.identity for feature in template.features} + for variable in obj.variable_features: + if variable.variable.identity not in members: + error( + variable, + "variable", + "feature-scope", + "Variable must belong to the template", + ) + if isinstance(obj, Component): + members = {feature.identity for feature in obj.features} + scoped: list[tuple[Identified, str, Ref[Identified]]] = [ + (constraint, name, ref) + for constraint in obj.constraints + for name, ref in (("subject", constraint.subject), ("object", constraint.object)) + ] + scoped.extend( + (p, "participant", p.participant) + for interaction in obj.interactions + for p in interaction.participations + ) + if obj.interface is not None: + scoped.extend( + (obj.interface, name, ref) + for name in ("inputs", "outputs", "nondirectionals") + for ref in getattr(obj.interface, name) + ) + for scoped_child, name, ref in scoped: + if ref.identity not in members: + error( + scoped_child, name, "feature-scope", "Feature must belong to this component" + ) + for feature in obj.features: + if isinstance(feature, ComponentReference): + child = index.get(feature.in_child_of.identity) + child_component = ( + index.get(child.instance_of.identity) + if isinstance(child, SubComponent) + else None + ) + if feature.in_child_of.identity not in members: + error( + feature, + "in_child_of", + "feature-scope", + "SubComponent must belong to this component", + ) + if isinstance( + child_component, Component + ) and feature.refers_to.identity not in { + item.identity for item in child_component.features + }: + error( + feature, + "refers_to", + "feature-scope", + "Feature must belong to the referenced component", + ) + + # A temporal dependency graph and a component containment graph must be acyclic. + def check_cycles(cls: type[Activity] | type[Component], field: str) -> None: + visited: set[str] = set() + visiting: set[str] = set() + + def visit(identity: str) -> None: + obj = index.get(identity) + if not isinstance(obj, cls) or identity in visited: + return + if identity in visiting: + error(obj, field, "dependency-cycle", "Cyclic dependency") + return + visiting.add(identity) + refs = ( + obj.informed_by + if isinstance(obj, Activity) + else tuple( + feature.instance_of + for feature in obj.features + if isinstance(feature, SubComponent) + ) + ) + for ref in refs: + visit(ref.identity) + visiting.remove(identity) + visited.add(identity) + + for identity in index: + visit(identity) + + check_cycles(Activity, "informed_by") + check_cycles(Component, "features") + return ValidationReport(tuple(errors)) diff --git a/src/lab/provenance/vocabulary.py b/src/lab/provenance/vocabulary.py new file mode 100644 index 0000000..0aa2dc8 --- /dev/null +++ b/src/lab/provenance/vocabulary.py @@ -0,0 +1,38 @@ +"""SBOL, PROV, and Lab terms used by the public provenance model. + +Ontology terms remain ordinary absolute IRIs, so callers can use other ontologies +without registering Python classes or changing a process-wide namespace. +""" + +from enum import StrEnum + +SBOL = "http://sbols.org/v3#" +PROV = "http://www.w3.org/ns/prov#" +OM = "http://www.ontology-of-units-of-measure.org/resource/om-2/" +LAB = "https://the-lab-compiler.github.io/lab-py/ns#" + +DNA = "https://identifiers.org/SBO:0000251" +RNA = "https://identifiers.org/SBO:0000250" +PROTEIN = "https://identifiers.org/SBO:0000252" +SMALL_MOLECULE = "https://identifiers.org/SBO:0000247" +FUNCTIONAL_ENTITY = "https://identifiers.org/SBO:0000241" +IUPAC_DNA = "https://identifiers.org/edam:format_1207" +IUPAC_PROTEIN = "https://identifiers.org/edam:format_1208" +INLINE = SBOL + "inline" +REVERSE_COMPLEMENT = SBOL + "reverseComplement" +PRECEDES = SBOL + "precedes" + + +class EvidenceState(StrEnum): + """Nature of an assertion, independent of experimental verification.""" + + UNKNOWN = LAB + "unknown" + PLANNED = LAB + "planned" + RECORDED = LAB + "recorded" + SIMULATED = LAB + "simulated" + + +class AgentKind(StrEnum): + PERSON = PROV + "Person" + ORGANIZATION = PROV + "Organization" + SOFTWARE = PROV + "SoftwareAgent" diff --git a/src/lab/samples.py b/src/lab/samples.py index 04c8a76..3e54aea 100644 --- a/src/lab/samples.py +++ b/src/lab/samples.py @@ -2,6 +2,9 @@ from dataclasses import dataclass +from lab.inventory import MaterialForm +from lab.provenance import Component, Implementation, Ref + @dataclass(frozen=True) class Location: @@ -16,6 +19,8 @@ def __str__(self) -> str: @dataclass(frozen=True, slots=True, kw_only=True) class Sample: + """Material at a location; parent_ids are contributions, not genetic ancestry.""" + id: str material_identity: str label: str @@ -26,8 +31,25 @@ class Sample: source_protocol_id: str | None = None contents: tuple[str, ...] = () dilution: int | None = None + design: Ref[Component] | None = None + implementation: Ref[Implementation] | None = None + form: MaterialForm | None = None + count: int | None = None def __post_init__(self) -> None: + if self.form is not None and not isinstance(self.form, MaterialForm): + raise TypeError("Sample form must be a MaterialForm") + if self.form is not None and self.form.counted: + if type(self.count) is not int or self.count < 1: + raise ValueError("Counted samples need a positive integer count") + elif self.count is not None: + raise ValueError("Only counted material forms have a count") + if self.design is not None and ( + not isinstance(self.design, Ref) or self.material_identity != self.design.identity + ): + raise ValueError("Sample material identity must match its design reference") + if self.implementation is not None and not isinstance(self.implementation, Ref): + raise TypeError("Sample implementation must be a reference") if not all( isinstance(value, str) and value.strip() for value in (self.id, self.material_identity, self.label, self.role) @@ -91,9 +113,17 @@ def to_dict(self) -> dict[str, object]: { "sample_id": sample.id, "material_identity": sample.material_identity, + "design": None if sample.design is None else sample.design.identity, + "implementation": ( + None if sample.implementation is None else sample.implementation.identity + ), "label": sample.label, "parent_sample_ids": list(sample.parent_ids), "replicate": sample.replicate, + "role": sample.role, + "form": None if sample.form is None else sample.form.value, + "count": sample.count, + "dilution": sample.dilution, "source_sample_id": sample.source_sample_id, "source_protocol_id": sample.source_protocol_id, "contents": list(sample.contents), diff --git a/src/lab/suppliers/__init__.py b/src/lab/suppliers/__init__.py new file mode 100644 index 0000000..6d75c31 --- /dev/null +++ b/src/lab/suppliers/__init__.py @@ -0,0 +1,28 @@ +"""Read-only supplier catalogs and explicit purchase/receipt records.""" + +from lab.suppliers.addgene import AddgeneClient, parse_plasmid +from lab.suppliers.types import ( + AcquisitionRequest, + Catalog, + CatalogEntry, + CatalogSequence, + OrderReference, + QuoteRecord, + Receipt, + SequenceSource, + SupplierItem, +) + +__all__ = [ + "AcquisitionRequest", + "AddgeneClient", + "Catalog", + "CatalogEntry", + "CatalogSequence", + "OrderReference", + "QuoteRecord", + "Receipt", + "SequenceSource", + "SupplierItem", + "parse_plasmid", +] diff --git a/src/lab/suppliers/addgene.py b/src/lab/suppliers/addgene.py new file mode 100644 index 0000000..caa21b1 --- /dev/null +++ b/src/lab/suppliers/addgene.py @@ -0,0 +1,103 @@ +"""Explicit, read-only Addgene catalog retrieval. + +Contract: https://docs.developers.addgene.org/docs/schema/ (e285fff01c). +Credentials stay on the client and are never included in snapshots. Catalog +access requires an approved token with the appropriate retrieve scope. +""" + +import json +from dataclasses import dataclass, field +from datetime import UTC, datetime +from email.message import Message +from typing import IO +from urllib.error import HTTPError +from urllib.request import HTTPRedirectHandler, Request, build_opener + +from lab.artifacts import canonical_json +from lab.suppliers.types import CatalogSequence, SequenceSource, SupplierItem + +BASE_URL = "https://api.developers.addgene.org" + + +class _NoRedirect(HTTPRedirectHandler): + # Never forward a credential to a catalog redirect destination. + def redirect_request( + self, req: Request, fp: IO[bytes], code: int, msg: str, headers: Message, newurl: str + ) -> None: + return None + + +def parse_plasmid(payload: str, *, retrieved_at: datetime) -> SupplierItem: + """Parse a saved retrieve response; no network or automatic sequence choice.""" + data = json.loads(payload) + if not isinstance(data, dict) or type(data.get("id")) is not int or data["id"] < 1: + raise ValueError("Expected an Addgene plasmid retrieve response") + plasmid_id = data["id"] + identity = f"https://www.addgene.org/{plasmid_id}/" + sequences = [] + groups = data.get("sequences", {}) + if not isinstance(groups, dict): + raise ValueError("Catalog sequences must be grouped by source and completeness") + for key, source, complete in ( + ("public_user_full_sequences", SequenceSource.DEPOSITOR, True), + ("public_addgene_full_sequences", SequenceSource.ADDGENE, True), + ("public_user_partial_sequences", SequenceSource.DEPOSITOR, False), + ("public_addgene_partial_sequences", SequenceSource.ADDGENE, False), + ): + for row in groups.get(key, []): + if type(row["sequence_id"]) is not int or not isinstance(row["sequence"], str): + raise ValueError("Invalid catalog sequence") + sequences.append( + CatalogSequence( + identity=identity + f"sequence/{row['sequence_id']}", + description=row["sequence_description"], + elements=row["sequence"], + source=source, + complete=complete, + reported_length=row["length"], + genbank_url=row.get("genbank_api_url") or row.get("genbank_url") or None, + ) + ) + return SupplierItem( + identity=identity, + supplier="Addgene", + catalog_id=str(plasmid_id), + name=data["name"], + url=data["url"], + retrieved_at=retrieved_at, + sequences=tuple(sequences), + metadata_json=canonical_json(data), + ) + + +@dataclass(frozen=True, kw_only=True) +class AddgeneClient: + token: str = field(repr=False) + timeout: float = 30 + + def __post_init__(self) -> None: + if not self.token or any(character.isspace() for character in self.token): + raise ValueError("Pass a nonempty Addgene token without whitespace") + if not 0 < self.timeout <= 120: + raise ValueError("Timeout must be between zero and 120 seconds") + + def plasmid(self, plasmid_id: int, *, include_sequences: bool = True) -> SupplierItem: + if type(plasmid_id) is not int or plasmid_id < 1: + raise ValueError("Plasmid ID must be a positive integer") + endpoint = "plasmid-with-sequences" if include_sequences else "plasmid" + request = Request( + f"{BASE_URL}/catalog/{endpoint}/{plasmid_id}/", + headers={"Authorization": f"Token {self.token}", "Accept": "application/json"}, + method="GET", + ) + try: + with build_opener(_NoRedirect()).open(request, timeout=self.timeout) as response: + payload = response.read(16_000_001) + except HTTPError as error: + raise ValueError(f"Addgene catalog returned HTTP {error.code}") from None + if len(payload) > 16_000_000: + raise ValueError("Catalog response exceeds 16 MB") + result = parse_plasmid(payload.decode("utf-8"), retrieved_at=datetime.now(UTC)) + if result.catalog_id != str(plasmid_id): + raise ValueError("Catalog returned a different plasmid ID") + return result diff --git a/src/lab/suppliers/types.py b/src/lab/suppliers/types.py new file mode 100644 index 0000000..0f1ee7e --- /dev/null +++ b/src/lab/suppliers/types.py @@ -0,0 +1,336 @@ +"""Immutable catalog evidence and records of externally placed purchases.""" + +import json +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from enum import StrEnum +from pathlib import Path + +from lab.artifacts import canonical_json, digest, write_bundle +from lab.inventory import CountedStock, MaterialForm, Stock, StockLocation +from lab.provenance import ( + Activity, + Agent, + Association, + Component, + Document, + DocumentSnapshot, + EvidenceState, + Implementation, + Ref, +) +from lab.provenance.types import require_iri +from lab.provenance.vocabulary import LAB + + +def aware(value: datetime) -> None: + if not isinstance(value, datetime) or value.utcoffset() is None: + raise ValueError("Record timestamps must include a timezone") + + +class SequenceSource(StrEnum): + DEPOSITOR = "depositor" + ADDGENE = "addgene" + + +@dataclass(frozen=True, kw_only=True) +class CatalogSequence: + identity: str + description: str + elements: str + source: SequenceSource + complete: bool + reported_length: int | None + genbank_url: str | None = None + + def __post_init__(self) -> None: + require_iri(self.identity) + if not isinstance(self.source, SequenceSource) or type(self.complete) is not bool: + raise TypeError("Sequence source and completeness must be explicit") + if self.reported_length is not None and ( + type(self.reported_length) is not int or self.reported_length < 0 + ): + raise ValueError("Reported length must be nonnegative") + if self.genbank_url is not None: + require_iri(self.genbank_url) + + +@dataclass(frozen=True, kw_only=True) +class SupplierItem: + identity: str + supplier: str + catalog_id: str + name: str + url: str + retrieved_at: datetime + sequences: tuple[CatalogSequence, ...] = () + metadata_json: str = "{}" + + def __post_init__(self) -> None: + require_iri(self.identity) + require_iri(self.url) + aware(self.retrieved_at) + if not self.supplier.strip() or not self.catalog_id.strip(): + raise ValueError("Supplier and catalog ID are required") + if not isinstance(self.sequences, tuple) or not all( + isinstance(item, CatalogSequence) for item in self.sequences + ): + raise TypeError("Catalog sequences must be an immutable tuple") + if len({item.identity for item in self.sequences}) != len(self.sequences): + raise ValueError("Catalog sequence identities must be unique") + metadata = json.loads(self.metadata_json) + if not isinstance(metadata, dict): + raise ValueError("Catalog metadata must be a JSON object") + object.__setattr__(self, "metadata_json", canonical_json(metadata)) + + +@dataclass(frozen=True, kw_only=True) +class CatalogEntry: + """Caller-reviewed mapping; a catalog name is never a design identity. + + The caller explicitly selects a design and the supplied material form. + Sequence candidates remain evidence, not an automatic assertion of identity. + """ + + item: SupplierItem + design: Ref[Component] + form: MaterialForm + + def __post_init__(self) -> None: + if not isinstance(self.item, SupplierItem) or not isinstance(self.design, Ref): + raise TypeError("Catalog entries need an item and a design reference") + if not isinstance(self.form, MaterialForm): + raise TypeError("Catalog material form must be explicit") + + +@dataclass(frozen=True, kw_only=True) +class Catalog: + entries: tuple[CatalogEntry, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.entries, tuple) or not all( + isinstance(item, CatalogEntry) for item in self.entries + ): + raise TypeError("Catalog entries must be an immutable tuple") + keys = [(entry.item.identity, entry.design, entry.form) for entry in self.entries] + if len(set(keys)) != len(keys): + raise ValueError("Duplicate catalog mapping") + object.__setattr__( + self, + "entries", + tuple( + sorted( + self.entries, + key=lambda entry: ( + entry.item.supplier.casefold() != "addgene", + entry.item.identity, + entry.design.identity, + entry.form.value, + ), + ) + ), + ) + + def candidates(self, design: Ref[Component]) -> tuple[CatalogEntry, ...]: + return tuple(entry for entry in self.entries if entry.design == design) + + @property + def digest(self) -> str: + return digest(self) + + def write(self, path: str | Path) -> Path: + path = Path(path) + write_bundle( + path.parent, {path.name: canonical_json({"format": "lab.catalog.v1", "catalog": self})} + ) + return path + + @classmethod + def read(cls, path: str | Path) -> "Catalog": + data = json.loads(Path(path).read_text(encoding="utf-8")) + if data.get("format") != "lab.catalog.v1": + raise ValueError("Expected lab.catalog.v1") + entries = [] + for row in data["catalog"]["entries"]: + raw = row["item"] + sequences = tuple( + CatalogSequence(**{**sequence, "source": SequenceSource(sequence["source"])}) + for sequence in raw["sequences"] + ) + item = SupplierItem( + **{ + **raw, + "retrieved_at": datetime.fromisoformat(raw["retrieved_at"]), + "sequences": sequences, + } + ) + entries.append( + CatalogEntry( + item=item, design=Ref(row["design"]["identity"]), form=MaterialForm(row["form"]) + ) + ) + return cls(entries=tuple(entries)) + + +@dataclass(frozen=True, kw_only=True) +class AcquisitionRequest: + identity: str + design: Ref[Component] + required_form: MaterialForm + volume_ul: Decimal + candidates: tuple[CatalogEntry, ...] = () + count: int = 0 + + def __post_init__(self) -> None: + require_iri(self.identity) + if ( + not isinstance(self.volume_ul, Decimal) + or not self.volume_ul.is_finite() + or self.volume_ul < 0 + or type(self.count) is not int + or self.count < 0 + or (self.required_form.counted and (self.volume_ul != 0 or self.count <= 0)) + or (not self.required_form.counted and (self.volume_ul <= 0 or self.count != 0)) + ): + raise ValueError("Acquisition requires a positive quantity of the declared form") + if not isinstance(self.candidates, tuple) or any( + not isinstance(item, CatalogEntry) or item.design != self.design + for item in self.candidates + ): + raise ValueError("Acquisition candidates must refer to the requested design") + + +@dataclass(frozen=True, kw_only=True) +class QuoteRecord: + identity: str + acquisition: str + item: str + reference: str + issued_at: datetime + attachment: str | None = None + + def __post_init__(self) -> None: + for value in (self.identity, self.acquisition, self.item): + require_iri(value) + aware(self.issued_at) + if not self.reference.strip(): + raise ValueError("Quote reference is required") + if self.attachment is not None: + require_iri(self.attachment) + + +@dataclass(frozen=True, kw_only=True) +class OrderReference: + identity: str + acquisition: str + item: str + reference: str + placed_at: datetime + quote: str | None = None + tracking_url: str | None = None + + def __post_init__(self) -> None: + for value in (self.identity, self.acquisition, self.item): + require_iri(value) + aware(self.placed_at) + if not self.reference.strip(): + raise ValueError("External order reference is required") + for optional in (self.quote, self.tracking_url): + if optional is not None: + require_iri(optional) + + +@dataclass(frozen=True, kw_only=True) +class Receipt: + """Observation of receipt, not sequence verification or DNA preparation. + + Counts describe supplied packages. A liquid aliquot enters inventory only + through an explicit measured quantity and its own implementation identity. + """ + + identity: str + order: OrderReference + design: Ref[Component] + form: MaterialForm + received_at: datetime + received_by: Ref[Agent] + packages: int + lot: str | None = None + + def __post_init__(self) -> None: + require_iri(self.identity) + aware(self.received_at) + if not isinstance(self.order, OrderReference) or not isinstance(self.form, MaterialForm): + raise TypeError("A receipt needs an order and an explicit material form") + if not isinstance(self.design, Ref) or not isinstance(self.received_by, Ref): + raise TypeError("Receipt design and receiving agent must be references") + if type(self.packages) is not int or self.packages < 1: + raise ValueError("Receipt package count must be positive") + if self.received_at < self.order.placed_at: + raise ValueError("Receipt precedes its order") + + @property + def implementation(self) -> Ref[Implementation]: + return Ref(self.identity + "/material") + + def record(self, document: DocumentSnapshot) -> DocumentSnapshot: + document.resolve(self.design) + document.resolve(self.received_by) + activity = Activity( + identity=self.identity, + types=(LAB + "receipt",), + evidence_state=EvidenceState.RECORDED, + end_time=self.received_at, + association=(Association(agent=self.received_by),), + description=f"Received {self.packages} package(s); order {self.order.reference}; " + f"item {self.order.item}; form {self.form.value}; lot {self.lot or 'unrecorded'}.", + ) + material = Implementation( + identity=self.implementation.identity, + derived_from=(self.design,), + generated_by=(activity.ref,), + evidence_state=EvidenceState.RECORDED, + ) + result = Document.from_snapshot(document) + result.add(activity, material) + return result.freeze() + + def counted_stock( + self, *, identity: str, count: int, location: StockLocation | None = None + ) -> CountedStock: + """Record an explicitly assessed count; package count is not a material count.""" + return CountedStock( + identity=identity, + implementation=self.implementation, + design=self.design, + form=self.form, + count=count, + location=location, + supplier_item=self.order.item, + ) + + def stock( + self, + *, + identity: str, + quantity: object, + location: StockLocation | None = None, + concentration: object = None, + ) -> Stock: + if self.form.counted: + raise ValueError( + "A bacterial stab requires explicit preparation before liquid inventory" + ) + if self.packages != 1: + raise ValueError("Record each package separately before measuring an inventory aliquot") + return Stock( + identity=identity, + implementation=self.implementation, + design=self.design, + form=self.form, + quantity=quantity, + concentration=concentration, + location=location, + supplier_item=self.order.item, + ) diff --git a/src/lab/target.py b/src/lab/target.py new file mode 100644 index 0000000..9d62b92 --- /dev/null +++ b/src/lab/target.py @@ -0,0 +1,25 @@ +"""Frozen physical target artifacts, independent of device SDKs.""" + +from dataclasses import dataclass +from decimal import Decimal + +from lab.samples import Location + + +@dataclass(frozen=True) +class Binding: + """One exact physical location and its usable constraints, in microlitres.""" + + location: Location + physical: str + capacity: Decimal + dead_volume: Decimal + + +@dataclass(frozen=True) +class TargetPlan: + name: str + bindings: tuple[Binding, ...] + configuration_json: str + source: str | None = None + setup: tuple[str, ...] = () diff --git a/src/lab/targets/manual.py b/src/lab/targets/manual.py index d31b161..8f6676c 100644 --- a/src/lab/targets/manual.py +++ b/src/lab/targets/manual.py @@ -1,6 +1,7 @@ from dataclasses import dataclass -from lab.model import RecordedProtocol, TargetPlan +from lab.model import RecordedProtocol +from lab.target import TargetPlan from lab.validation import logical_bindings diff --git a/src/lab/targets/opentrons.py b/src/lab/targets/opentrons.py index 2f9291e..ea360d5 100644 --- a/src/lab/targets/opentrons.py +++ b/src/lab/targets/opentrons.py @@ -10,19 +10,18 @@ from lab.documents import describe from lab.equipment import LabwareModel, ModuleModel, TipRackModel from lab.labware import LabwareKind -from lab.model import ( - Binding, +from lab.model import RecordedProtocol +from lab.operations import ( Distribute, ManualInstruction, Mix, - RecordedProtocol, SetTemperature, - TargetPlan, Thermocycle, Transfer, Wait, ) from lab.samples import Location +from lab.target import Binding, TargetPlan from lab.targets.liquid_handler import LiquidHandler from lab.units import number from lab.validation import CompileError, step_error, volume_trace @@ -86,11 +85,10 @@ def prepare(self, protocol: RecordedProtocol) -> TargetPlan: "p300_single_gen2": ("OT-2", 20, 300, "opentrons_96_tiprack_300ul"), "p20_single_gen2": ("OT-2", 1, 20, "opentrons_96_tiprack_20ul"), "flex_1channel_1000": ("Flex", 5, 1000, "opentrons_flex_96_tiprack_200ul"), + "flex_1channel_50": ("Flex", 1, 50, "opentrons_flex_96_tiprack_50ul"), } pipette_specs = [(self.pipette, self.mount, self.tip_racks, "pipette")] if self.small_pipette is not None: - if self.robot != "OT-2": - raise CompileError("A second pipette is supported on the OT-2") if not self.small_tip_racks: raise CompileError("The second pipette needs its own tip racks") if self.small_mount not in ("left", "right") or self.small_mount == self.mount: @@ -103,7 +101,7 @@ def prepare(self, protocol: RecordedProtocol) -> TargetPlan: if model_name not in models or models[model_name][0] != self.robot: raise CompileError( "Supported pipettes: OT-2 P20 single GEN2, OT-2 P300 single GEN2, " - "and Flex 1-channel 1000" + "and Flex 1-channel 50 or 1000" ) if mount not in ("left", "right"): raise CompileError("Pipette mount must be left or right") @@ -259,6 +257,7 @@ def select_pipette(volume: Decimal) -> _Pipette: return min(fits, key=lambda pipette: pipette.maximum) for index, step in enumerate(protocol.steps): + commands.append(f"# lab:step {step.identity}") commands.append(f"context.comment({describe(step)!r})") if isinstance(step, (Transfer, Mix, Distribute)): try: @@ -273,13 +272,26 @@ def select_pipette(volume: Decimal) -> _Pipette: if step.volume > tip_capacity: raise step_error(index, step, "Volume is outside the pipette/tip range") tool = pipette.variable + if pipette.model == "flex_1channel_50": + commands.append(f"{tool}.configure_for_volume({number(step.volume)})") + if isinstance(step, Distribute): + mode_max = ( + Decimal(30) + if pipette.model == "flex_1channel_50" and step.volume < 5 + else pipette.maximum + ) + if step.volume + (step.air_gap or Decimal(0)) > min(mode_max, tip_capacity): + raise step_error(index, step, "Liquid and air gap exceed the volume mode") commands.append(f"{tool}.pick_up_tip({tip})") amount = number(step.volume) if isinstance(step, Transfer): + destination = refs[step.destination] + if step.destination_height_mm is not None: + destination += f".bottom(z={number(step.destination_height_mm)})" commands.extend( ( f"{tool}.aspirate({amount}, {refs[step.source]})", - f"{tool}.dispense({amount}, {refs[step.destination]})", + f"{tool}.dispense({amount}, {destination})", ) ) elif isinstance(step, Distribute): @@ -351,6 +363,7 @@ def select_pipette(volume: Decimal) -> _Pipette: commands.append(f"context.pause({step.text!r})") else: raise step_error(index, step, "Unsupported step") + commands.append(f"# lab:end {step.identity}") source = ( '"""Generated by Lab. Each liquid operation uses a fresh tip."""\n\n' "from opentrons import protocol_api\n\n" @@ -432,11 +445,27 @@ def lower_deck( if robot == "Flex": if "D1" in taken: raise CompileError("Flex tip rack slot D1 is already in use") + small = not volumes or any(volume <= 50 for volume in volumes) + large = any(volume > 50 for volume in volumes) + second_tips = ( + (Labware("opentrons_flex_96_tiprack_50ul", _open_slot(("D2", "C2", "B2"), taken)),) + if small and large + else () + ) return Opentrons( robot="Flex", - pipette="flex_1channel_1000", + pipette="flex_1channel_1000" if large else "flex_1channel_50", labware=labware, - tip_racks=(Labware("opentrons_flex_96_tiprack_200ul", "D1"),), + tip_racks=( + Labware( + "opentrons_flex_96_tiprack_200ul" + if large + else "opentrons_flex_96_tiprack_50ul", + "D1", + ), + ), + small_pipette="flex_1channel_50" if small and large else None, + small_tip_racks=second_tips, thermocycler=thermocycler, ) small = any(volume <= 20 for volume in volumes) or not any(volume > 20 for volume in volumes) @@ -525,6 +554,7 @@ def _open_slot(slots: tuple[str, ...], taken: set[str]) -> str: TipRackModel.OPENTRONS_20_UL: "opentrons_96_tiprack_20ul", TipRackModel.OPENTRONS_300_UL: "opentrons_96_tiprack_300ul", TipRackModel.FLEX_200_UL: "opentrons_flex_96_tiprack_200ul", + TipRackModel.FLEX_50_UL: "opentrons_flex_96_tiprack_50ul", } @@ -538,10 +568,8 @@ def lower_layout(layout: DeckLayout) -> Opentrons: f"{robot} does not support carriers, independent channels, " "or external thermal handoffs." ) - if not 1 <= len(layout.pipettes) <= (2 if robot == "OT-2" else 1): - raise CompileError( - f"Configure one {'or two pipettes' if robot == 'OT-2' else 'pipette'} for {robot}." - ) + if not 1 <= len(layout.pipettes) <= 2: + raise CompileError(f"Configure one or two pipettes for {robot}.") assigned_tips = [rack for pipette in layout.pipettes for rack in pipette.tip_racks] if len(set(assigned_tips)) != len(assigned_tips): raise CompileError("The Opentrons backend requires separate tip racks for each pipette.") diff --git a/src/lab/targets/star.py b/src/lab/targets/star.py index 44c7b46..eaeec4e 100644 --- a/src/lab/targets/star.py +++ b/src/lab/targets/star.py @@ -25,20 +25,18 @@ from lab.documents import describe from lab.equipment import CarrierModel, LabwareModel, TipRackModel from lab.labware import LabwareKind -from lab.model import ( - Binding, +from lab.model import RecordedProtocol, encode +from lab.operations import ( Distribute, ManualInstruction, Mix, - RecordedProtocol, SetTemperature, - TargetPlan, Thermocycle, Transfer, Wait, - encode, ) from lab.samples import Location +from lab.target import Binding, TargetPlan from lab.targets.liquid_handler import LiquidHandler from lab.units import magnitude, number, uL from lab.validation import CompileError, step_error @@ -167,6 +165,7 @@ def on_deck(resource: Any) -> None: commands = [] tip_index = 0 for index, step in enumerate(protocol.steps): + commands.append(f"# lab:step {step.identity}") if isinstance(step, (Thermocycle, SetTemperature)): if ( self.external_thermal_resources is not None @@ -201,12 +200,17 @@ def on_deck(resource: Any) -> None: f"await lh.pick_up_tips([deck.get_resource({spot.name!r})], {channels})" ) if isinstance(step, Transfer): + height = ( + "" + if step.destination_height_mm is None + else f", liquid_height=[{number(step.destination_height_mm)}]" + ) commands.extend( ( f"await lh.aspirate([{refs[step.source]}], " f"vols=[{amount}], {channels})", f"await lh.dispense([{refs[step.destination]}], " - f"vols=[{amount}], {channels})", + f"vols=[{amount}], {channels}{height})", ) ) elif isinstance(step, Distribute): @@ -259,11 +263,12 @@ def on_deck(resource: Any) -> None: if not isinstance(plate, ItemizedResource): raise step_error(index, step, "A held temperature must bind a whole plate") commands.append( - f"await thermocycle(deck.get_resource({plate.name!r}), " - f"profile=[({number(step.celsius)}, 0)], cycles=1, lid_temperature=None)" + f"await set_temperature(deck.get_resource({plate.name!r}), " + f"temperature={number(step.celsius)})" ) else: raise step_error(index, step, "Unsupported step") + commands.append(f"# lab:end {step.identity}") initialize = [] for resource in protocol.resources: fills = {fill.well: fill.volume for fill in resource.fills} @@ -275,6 +280,8 @@ def on_deck(resource: Any) -> None: has_thermal = any( isinstance(step, (Thermocycle, SetTemperature)) for step in protocol.steps ) + has_cycle = any(isinstance(step, Thermocycle) for step in protocol.steps) + has_hold = any(isinstance(step, SetTemperature) for step in protocol.steps) thermal_import = "from inspect import iscoroutinefunction\n" if has_thermal else "" source = ( '"""Generated PyLabRobot protocol. Direct execution uses a software backend."""\n\n' @@ -285,7 +292,8 @@ def on_deck(resource: Any) -> None: "from pylabrobot.resources import Resource\n\n" f"DECK_JSON = {serialized_deck!r}\n" f"SDK_VERSION = {version('pylabrobot')!r}\n\n" - "async def run(backend, *, confirm=None, thermocycle=None, sleep=asyncio.sleep):\n" + "async def run(backend, *, confirm=None, thermocycle=None, " + "set_temperature=None, sleep=asyncio.sleep):\n" " if version('pylabrobot') != SDK_VERSION:\n" " raise RuntimeError(f'This artifact requires pylabrobot=={SDK_VERSION}')\n" ) @@ -294,12 +302,18 @@ def on_deck(resource: Any) -> None: " if confirm is None:\n" " raise ValueError('Supply confirm for explicit operator steps')\n" ) - if has_thermal: + if has_cycle: source += ( " if not iscoroutinefunction(thermocycle):\n" " raise ValueError('Supply an async thermocycle callback for the external " "thermal device; it must return the plate to its original position')\n" ) + if has_hold: + source += ( + " if not iscoroutinefunction(set_temperature):\n" + " raise ValueError('Supply an async set_temperature callback; " + "the hold must persist while the plate remains accessible for pipetting')\n" + ) source += ( " deck = Resource.deserialize(json.loads(DECK_JSON))\n" + "\n".join(f" {line}" for line in initialize) @@ -310,13 +324,20 @@ def on_deck(resource: Any) -> None: + "\n finally:\n await lh.stop()\n" " return lh\n\n" ) - if has_thermal: + if has_cycle: source += ( "async def preview_thermocycle(plate, *, profile, cycles, lid_temperature):\n" " print(f'Simulation only: thermal profile for {plate.name}: {profile}; " "{cycles} cycles; lid {lid_temperature} C')\n\n" ) - thermal_callback = ", thermocycle=preview_thermocycle" if has_thermal else "" + if has_hold: + source += ( + "async def preview_set_temperature(plate, *, temperature):\n" + " print(f'Simulation only: persistent hold for {plate.name}: " + "{temperature} C')\n\n" + ) + thermal_callback = ", thermocycle=preview_thermocycle" if has_cycle else "" + thermal_callback += ", set_temperature=preview_set_temperature" if has_hold else "" source += ( "if __name__ == '__main__':\n" " asyncio.run(run(LiquidHandlerChatterboxBackend(), confirm=input" @@ -437,7 +458,7 @@ def place(resource: Any, location: Any, expected_carrier: CarrierModel) -> None: def lower_deck(deck: Deck, *, volumes: tuple[Decimal, ...]) -> STAR: - """Resolve an ambient plate preset; other equipment needs a Lab DeckLayout.""" + """Resolve plate carriers, with explicit runtime contracts for thermal plates.""" if len(deck.containers) > 10: raise CompileError("The STAR plate preset holds at most ten plates; provide a DeckLayout.") placements = [] @@ -453,6 +474,7 @@ def lower_deck(deck: Deck, *, volumes: tuple[Decimal, ...]) -> STAR: if not isinstance(container, DeckContainer) or container.site not in ( DeckSite.PLATES, DeckSite.MORE_PLATES, + DeckSite.THERMOCYCLER, ): raise CompileError( f"No STAR preset for {container.id}; provide a Lab DeckLayout " @@ -493,5 +515,10 @@ def lower_deck(deck: Deck, *, volumes: tuple[Decimal, ...]) -> STAR: tip_racks=("tips",), ), ), + external_thermal_resources=tuple( + container.id + for container in deck.containers + if isinstance(container, DeckContainer) and container.site == DeckSite.THERMOCYCLER + ), ) return lower_layout(deck, layout) diff --git a/src/lab/validation.py b/src/lab/validation.py index 0f1a6e1..0bd85a7 100644 --- a/src/lab/validation.py +++ b/src/lab/validation.py @@ -2,17 +2,19 @@ from decimal import Decimal -from lab.model import ( - Binding, +from lab.inventory import MaterialForm +from lab.model import RecordedProtocol +from lab.operations import ( Distribute, + ExternalPreparation, Mix, - RecordedProtocol, SetTemperature, Step, Thermocycle, Transfer, ) from lab.samples import Location +from lab.target import Binding from lab.units import number @@ -26,6 +28,7 @@ def step_error(index: int, step: Step, message: str) -> CompileError: def validate(protocol: RecordedProtocol, bindings: tuple[Binding, ...]) -> dict[Location, Decimal]: validate_samples(protocol) + count_trace(protocol) return volume_trace(protocol, bindings)[-1] @@ -102,10 +105,20 @@ def volume_trace( if fill.volume > capacities[location]: raise CompileError(f"Initial volume exceeds the bound capacity of {location}") volumes[location] = fill.volume + samples = {sample.id: sample for sample in protocol.samples} + counted = {p.location for p in protocol.placements if samples[p.sample_id].count is not None} + if any(volumes[location] for location in counted): + raise CompileError("Counted materials cannot be loaded as liquid volumes") states = [volumes.copy()] for index, step in enumerate(protocol.steps): if isinstance(step, (Transfer, Mix)): source = step.source if isinstance(step, Transfer) else step.location + if source in counted: + raise step_error( + index, + step, + "Counted material requires explicit external preparation before pipetting", + ) if source not in volumes: raise step_error(index, step, f"Unknown source {source}") available = max(Decimal(0), volumes[source] - dead[source]) @@ -124,6 +137,8 @@ def volume_trace( volumes[source] -= step.volume volumes[step.destination] += step.volume elif isinstance(step, Distribute): + if step.source in counted: + raise step_error(index, step, "Counted material cannot be pipetted") if step.source not in volumes: raise step_error(index, step, f"Unknown source {step.source}") needed = step.volume * len(step.destinations) @@ -143,6 +158,23 @@ def volume_trace( volumes[step.source] -= needed for destination in step.destinations: volumes[destination] += step.volume + elif isinstance(step, ExternalPreparation): + for port in step.inputs: + if port.location not in volumes: + raise step_error(index, step, "Unknown external input location") + if port.volume_ul > max(Decimal(0), volumes[port.location] - dead[port.location]): + raise step_error( + index, step, "External preparation exceeds available input volume" + ) + volumes[port.location] -= port.volume_ul + for port in step.outputs: + if port.location not in volumes or volumes[port.location]: + raise step_error( + index, step, "External output must occupy a known empty location" + ) + if port.volume_ul > capacities[port.location]: + raise step_error(index, step, "External output exceeds container capacity") + volumes[port.location] = port.volume_ul elif isinstance(step, Thermocycle): contents = [v for loc, v in volumes.items() if loc.resource == step.resource] if not contents: @@ -156,6 +188,58 @@ def volume_trace( return tuple(states) +def count_trace(protocol: RecordedProtocol) -> tuple[dict[Location, int], ...]: + """Count whole materials separately; pipetting never consumes those counts.""" + samples = {sample.id: sample for sample in protocol.samples} + at = {place.location: samples[place.sample_id] for place in protocol.placements} + counted = {location: sample for location, sample in at.items() if sample.count is not None} + counts = { + location: int(sample.count or 0) if sample.id in protocol.input_sample_ids else 0 + for location, sample in counted.items() + } + states = [counts.copy()] + for index, step in enumerate(protocol.steps): + if isinstance(step, ExternalPreparation): + for port in (*step.inputs, *step.outputs): + if port.location not in at or (port.count > 0) != (port.location in counted): + raise step_error( + index, step, "External ports must match declared material forms" + ) + for port in step.inputs: + if port.count: + if port.count > counts[port.location]: + raise step_error( + index, step, "External preparation exceeds available material count" + ) + counts[port.location] -= port.count + for port in step.outputs: + if port.count: + if counts[port.location]: + raise step_error( + index, step, "External counted output requires an empty location" + ) + counts[port.location] = port.count + elif isinstance(step, (Transfer, Distribute)): + destinations = (step.destination,) if isinstance(step, Transfer) else step.destinations + for destination in destinations: + if destination in counted: + if ( + counted[destination].form is not MaterialForm.PLATED_SAMPLE + or counts[destination] + ): + raise step_error( + index, step, "A deposition requires an empty plated-sample location" + ) + counts[destination] = 1 + states.append(counts.copy()) + for location, sample in counted.items(): + if sample.id in protocol.output_sample_ids and counts[location] != sample.count: + raise CompileError( + "Declared output count does not match the protocol's material balance" + ) + return tuple(states) + + def logical_bindings(protocol: RecordedProtocol) -> tuple[Binding, ...]: return tuple( Binding( diff --git a/tests/cloning_integration_fixture.py b/tests/cloning_integration_fixture.py new file mode 100644 index 0000000..efa3ac8 --- /dev/null +++ b/tests/cloning_integration_fixture.py @@ -0,0 +1,139 @@ +"""Synthetic compiler fixtures; the arbitrary parameters are not wet-lab methods.""" + +from dataclasses import replace +from decimal import Decimal + +from lab import uL +from lab.experiments.cloning.methods import ( + ExternalPreparationMethod, + PlatingMethod, + Reagent, + TransformationMethod, +) +from lab.experiments.cloning.planning import BuildRequest, CountTarget +from lab.experiments.cloning.systems import ( + CloningSystem, + ExternalPreparationRecipe, + PlatingRecipe, + TransformationRecipe, +) +from lab.inventory import CountedStock, Inventory, MaterialForm, Stock +from lab.operations import Hold +from lab.provenance import Component, Document, EvidenceState, Implementation +from lab.provenance.vocabulary import SMALL_MOLECULE +from tests.planning_fixture import NS, planning_case + + +def integrated_case(count=2): + _, inputs = planning_case() + doc = Document.from_snapshot(inputs["document"]) + vector = doc.get(NS + "/vector", Component) + target = doc.get(NS + "/target", Component) + cells = Component(identity=NS + "/cells", types=("https://example.org/cell",)) + strain = Component(identity=NS + "/strain", types=("https://example.org/cell",)) + broth = Component(identity=NS + "/broth", types=(SMALL_MOLECULE,)) + substrate = Component(identity=NS + "/substrate", types=(SMALL_MOLECULE,)) + doc.add(cells, strain, broth, substrate) + stocks = [s for s in inputs["inventory"].stocks if s.design != vector.ref] + for design, form in ((cells, MaterialForm.COMPETENT_CELLS), (broth, MaterialForm.REAGENT)): + material = Implementation( + identity=design.identity + "/material", + derived_from=(design.ref,), + evidence_state=EvidenceState.RECORDED, + ) + doc.add(material) + stocks.append( + Stock( + identity=design.identity + "/stock", + design=design.ref, + implementation=material.ref, + form=form, + quantity=20 * uL, + ) + ) + material = Implementation( + identity=NS + "/received_stab", + derived_from=(vector.ref,), + evidence_state=EvidenceState.RECORDED, + ) + doc.add(material) + stocks.append( + CountedStock( + identity=NS + "/stab_stock", + design=vector.ref, + implementation=material.ref, + form=MaterialForm.BACTERIAL_STAB, + count=1, + ) + ) + preparation = ExternalPreparationMethod( + identity=NS + "/preparation_method", + procedure=NS + "/sop", + instructions=( + "Apply the supplied test procedure and record the resulting material and quantity." + ), + source_count=1, + output_volume_ul=Decimal(10), + ) + transformation = TransformationMethod( + identity=NS + "/transformation_method", + cell_volume_ul=Decimal(2), + dna_volume_ul=Decimal(1), + recovery=Reagent(component=broth.ref, volume_ul=Decimal(2)), + profile=(Hold(Decimal(25), Decimal(1)),), + recovery_profile=(Hold(Decimal(25), Decimal(1)),), + output_volume_ul=Decimal(5), + cell_mix_volume_ul=Decimal(1), + cell_mix_cycles=1, + dna_mix_cycles=1, + initial_celsius=Decimal(25), + ) + plating = PlatingMethod( + identity=NS + "/plating_method", + substrate=substrate.ref, + diluent=broth.ref, + transfer_volume_ul=Decimal(1), + dilution_factors=(Decimal(2), Decimal(3)), + spot_volume_ul=Decimal(1), + spot_height_mm=Decimal(2), + mix_volume_ul=Decimal(1), + mix_cycles=1, + ) + system = CloningSystem( + identity=NS + "/complete_system", + recipes=( + *inputs["system"].recipes, + ExternalPreparationRecipe( + identity=NS + "/preparation_recipe", + product=vector.ref, + source=vector.ref, + source_form=MaterialForm.BACTERIAL_STAB, + output_form=MaterialForm.DNA, + method=preparation.identity, + ), + TransformationRecipe( + identity=NS + "/transformation_recipe", + product=strain.ref, + chassis=cells.ref, + plasmids=(target.ref,), + method=transformation.identity, + ), + PlatingRecipe( + identity=NS + "/plating_recipe", product=strain.ref, method=plating.identity + ), + ), + ) + return BuildRequest( + identity=NS + "/integrated_request", targets=(CountTarget(design=strain.ref, count=count),) + ), { + **inputs, + "document": doc.freeze(), + "inventory": Inventory(identity=NS + "/integrated_inventory", stocks=tuple(stocks)), + "system": system, + "methods": replace( + inputs["methods"], + transformations=(transformation,), + platings=(plating,), + preparations=(preparation,), + ), + } diff --git a/tests/fixtures/provenance/design.ttl b/tests/fixtures/provenance/design.ttl new file mode 100644 index 0000000..78b8f90 --- /dev/null +++ b/tests/fixtures/provenance/design.ttl @@ -0,0 +1,27 @@ +@prefix sbol: . +@prefix ex: . +@prefix vendor: . + +ex:sequence a sbol:Sequence ; + sbol:hasNamespace ; + sbol:displayId "sequence" ; + sbol:elements "ACGTACGT" ; + sbol:encoding . + +ex:design a sbol:Component ; + sbol:hasNamespace ; + sbol:displayId "design" ; + sbol:type ; + sbol:hasSequence ex:sequence ; + sbol:hasFeature ; + vendor:catalog [ vendor:accession "external-catalog-record" ; + vendor:label "Descriptive label"@en ] . + + a sbol:SequenceFeature ; + sbol:displayId "site" ; + sbol:hasLocation . + + a sbol:Cut ; + sbol:displayId "cut" ; + sbol:hasSequence ex:sequence ; + sbol:at 4 . diff --git a/tests/planning_fixture.py b/tests/planning_fixture.py new file mode 100644 index 0000000..b129261 --- /dev/null +++ b/tests/planning_fixture.py @@ -0,0 +1,124 @@ +from dataclasses import replace +from decimal import Decimal + +from lab import uL +from lab.experiments.cloning.methods import AssemblyMethod, CloningMethods, Reagent +from lab.experiments.cloning.planning import BuildRequest, BuildTarget +from lab.experiments.cloning.sequences import CIRCULAR, LINEAR +from lab.experiments.cloning.systems import AssemblyRecipe, CloningSystem, FragmentSelection +from lab.inventory import Inventory, MaterialForm, Stock +from lab.operations import Hold +from lab.provenance import Component, Document, EvidenceState, Implementation, Sequence +from lab.provenance.vocabulary import DNA, IUPAC_DNA, SMALL_MOLECULE + +NS = "https://example.org/planning" + + +def planning_case(*, volume="3", stock_volume="10"): + doc = Document(namespace=NS) + vector_sequence = Sequence( + identity=doc.iri("vector_sequence"), elements="AAAAGAATTCTTTT", encoding=IUPAC_DNA + ) + + insert_sequence = Sequence( + identity=doc.iri("insert_sequence"), elements="GGGAATTCCCCGAATTCGG", encoding=IUPAC_DNA + ) + vector = Component( + identity=doc.iri("vector"), types=(DNA, CIRCULAR), sequences=(vector_sequence.ref,) + ) + insert = Component( + identity=doc.iri("insert"), types=(DNA, LINEAR), sequences=(insert_sequence.ref,) + ) + target = Component(identity=doc.iri("target"), types=(DNA, CIRCULAR)) + buffer = Component(identity=doc.iri("buffer"), types=(SMALL_MOLECULE,)) + water = Component(identity=doc.iri("water"), types=(SMALL_MOLECULE,)) + doc.add(vector_sequence, insert_sequence, vector, insert, target, buffer, water) + stocks = [] + for design, form in ( + (vector, MaterialForm.DNA), + (insert, MaterialForm.DNA), + (buffer, MaterialForm.REAGENT), + (water, MaterialForm.REAGENT), + ): + implementation = Implementation( + identity=design.identity + "/implementation", + derived_from=(design.ref,), + evidence_state=EvidenceState.RECORDED, + ) + doc.add(implementation) + stocks.append( + Stock( + identity=design.identity + "/stock", + design=design.ref, + implementation=implementation.ref, + form=form, + quantity=Decimal(stock_volume) * uL, + ) + ) + recipe = AssemblyRecipe( + identity=doc.iri("recipe"), + product=target.ref, + enzyme="EcoRI", + fragments=( + FragmentSelection(component=vector.ref, left_cut=5, right_cut=5), + FragmentSelection(component=insert.ref, left_cut=3, right_cut=12), + ), + method=doc.iri("method"), + ) + method = AssemblyMethod( + identity=recipe.method, + enzyme="EcoRI", + dna_volume_ul=Decimal(1), + reaction_volume_ul=Decimal(5), + output_volume_ul=Decimal(5), + reagents=(Reagent(component=buffer.ref, volume_ul=Decimal(1)),), + diluent=water.ref, + profile=(Hold(Decimal(25), Decimal(1)),), + cycles=1, + mix_volume_ul=Decimal(2), + mix_cycles=1, + ) + return ( + BuildRequest( + identity=doc.iri("request"), + targets=(BuildTarget(design=target.ref, volume_ul=Decimal(volume)),), + ), + dict( + document=doc.freeze(), + inventory=Inventory(identity=doc.iri("inventory"), stocks=tuple(stocks)), + system=CloningSystem(identity=doc.iri("system"), recipes=(recipe,)), + methods=CloningMethods(assemblies=(method,)), + ), + ) + + +def multilevel_case(): + request, inputs = planning_case() + doc = Document.from_snapshot(inputs["document"]) + intermediate = doc.get(NS + "/target", Component) + finals = tuple(replace(intermediate, identity=NS + f"/final_{i}") for i in range(2)) + doc.add(*finals) + first = inputs["system"].recipes[0] + recipes = tuple( + replace( + first, + identity=NS + f"/second_recipe_{index}", + product=final.ref, + fragments=( + first.fragments[0], + FragmentSelection(component=intermediate.ref, left_cut=14, right_cut=0), + ), + ) + for index, final in enumerate(finals) + ) + return ( + replace( + request, + targets=tuple(BuildTarget(design=final.ref, volume_ul=Decimal(3)) for final in finals), + ), + { + **inputs, + "document": doc.freeze(), + "system": replace(inputs["system"], recipes=(first, *recipes)), + }, + ) diff --git a/tests/provenance/test_activity.py b/tests/provenance/test_activity.py new file mode 100644 index 0000000..db45058 --- /dev/null +++ b/tests/provenance/test_activity.py @@ -0,0 +1,149 @@ +from datetime import UTC, datetime, timedelta + +import pytest +from rdflib import RDF, Graph, URIRef + +from lab.provenance import ( + Activity, + Agent, + AgentKind, + Association, + Component, + Document, + EvidenceState, + Implementation, + Plan, + Ref, + Usage, +) +from lab.provenance.vocabulary import DNA, LAB, PROV, SBOL + +NS = "https://example.org/provenance" + + +def test_qualified_provenance_has_correct_directions_and_owned_cardinalities(): + document = Document(namespace=NS) + design = Component(identity=document.iri("design"), types=(DNA,)) + software = Agent( + identity=document.iri("compiler"), kind=AgentKind.SOFTWARE, software_version="1.2" + ) + plan = Plan(identity=document.iri("method"), protocol=document.iri("protocol")) + planned = Activity( + identity=document.iri("planned"), + evidence_state=EvidenceState.PLANNED, + types=(LAB + "assembly",), + usage=(Usage(entity=design.ref, roles=(LAB + "design",)),), + association=(Association(agent=software.ref, plan=plan.ref, roles=(LAB + "planner",)),), + ) + output = Implementation( + identity=document.iri("output"), + derived_from=(design.ref,), + generated_by=(planned.ref,), + evidence_state=EvidenceState.PLANNED, + ) + document.add(design, software, plan, planned, output) + frozen = document.freeze() + graph = Graph().parse(data=frozen.to_turtle(), format="turtle") + assert ( + URIRef(output.identity), + URIRef(PROV + "wasGeneratedBy"), + URIRef(planned.identity), + ) in graph + assert (URIRef(planned.identity), URIRef(SBOL + "type"), URIRef(LAB + "assembly")) in graph + assert (URIRef(planned.identity), RDF.type, URIRef(LAB + "assembly")) not in graph + assert not list(graph.objects(URIRef(output.identity), URIRef(SBOL + "built"))) + native = frozen.to_sbol3() + assert len(native.objects) == 5 + activity = native.find(planned.identity) + assert len(activity.usage) == len(activity.association) == 1 + assert activity.association[0].agent == software.identity + assert activity.association[0].plan == plan.identity + assert activity.start_time is None and activity.end_time is None + assert not native.validate().errors + assert Document.from_sbol3(native).freeze() == frozen + + +def test_shared_predecessors_round_trip_without_reparenting(): + document = Document(namespace=NS) + parent = Activity(identity=document.iri("parent")) + left = Activity(identity=document.iri("left"), informed_by=(parent.ref,)) + right = Activity(identity=document.iri("right"), informed_by=(parent.ref,)) + document.add(parent, left, right) + native = document.to_sbol3() + assert len(native.objects) == 3 + assert native.find(left.identity).informed_by[0] is native.find(parent.identity) + assert native.find(right.identity).informed_by[0] is native.find(parent.identity) + assert Document.from_sbol3(native).freeze() == document.freeze() + + +def test_execution_timestamps_retain_timezone_and_evidence_kind(): + start = datetime(2026, 9, 27, 10, tzinfo=UTC) + document = Document(namespace=NS) + activity = Activity( + identity=document.iri("execution"), + start_time=start, + end_time=start + timedelta(seconds=5), + evidence_state=EvidenceState.SIMULATED, + ) + document.add(activity) + frozen = document.freeze() + restored = Document.from_sbol3(frozen.to_sbol3()).freeze() + assert restored == frozen + assert restored.resolve(activity.ref).evidence_state is EvidenceState.SIMULATED + + +@pytest.mark.parametrize( + "activity,code", + [ + (Activity(identity=NS + "/a", start_time=datetime(2026, 1, 1)), "timezone"), + ( + Activity( + identity=NS + "/a", + start_time=datetime(2026, 1, 2, tzinfo=UTC), + end_time=datetime(2026, 1, 1, tzinfo=UTC), + ), + "time-order", + ), + ( + Activity( + identity=NS + "/a", + start_time=datetime(2026, 1, 1, tzinfo=UTC), + evidence_state=EvidenceState.PLANNED, + ), + "planned-execution", + ), + ], +) +def test_invalid_execution_assertions_are_rejected(activity, code): + document = Document(namespace=NS) + document.add(activity) + with pytest.raises(ValueError, match=code): + document.freeze() + + +def test_planned_implementation_does_not_assert_realized_structure(): + document = Document(namespace=NS) + component = Component(identity=document.iri("design"), types=(DNA,)) + document.add( + component, + Implementation( + identity=document.iri("output"), + built=component.ref, + evidence_state=EvidenceState.PLANNED, + ), + ) + with pytest.raises(ValueError, match="planned-realization"): + document.freeze() + + +def test_activity_cycles_and_wrong_reference_types_are_rejected(): + document = Document(namespace=NS) + a = Activity(identity=document.iri("a"), informed_by=(Ref(document.iri("b")),)) + b = Activity(identity=document.iri("b"), informed_by=(a.ref,)) + document.add(a, b) + with pytest.raises(ValueError, match="dependency-cycle"): + document.freeze() + document = Document(namespace=NS) + document.add(Agent(identity=document.iri("b")), a) + with pytest.raises(ValueError, match="reference-type"): + document.freeze() diff --git a/tests/provenance/test_design.py b/tests/provenance/test_design.py new file mode 100644 index 0000000..a9e88df --- /dev/null +++ b/tests/provenance/test_design.py @@ -0,0 +1,216 @@ +from dataclasses import replace + +import pytest + +from lab.provenance import ( + Attachment, + Collection, + CombinatorialDerivation, + Component, + ComponentReference, + Constraint, + Cut, + Document, + EntireSequence, + Experiment, + ExperimentalData, + ExternallyDefined, + Implementation, + Interaction, + Interface, + LocalSubComponent, + Measure, + Model, + Participation, + Range, + Ref, + Sequence, + SequenceFeature, + SubComponent, + VariableFeature, +) +from lab.provenance.vocabulary import DNA, INLINE, IUPAC_DNA, OM, PRECEDES, SBOL + +NS = "https://example.org/design" + + +def test_structured_design_locations_interactions_and_derivations_round_trip(): + document = Document(namespace=NS) + sequence = Sequence(identity=document.iri("sequence"), elements="ACGTACGT", encoding=IUPAC_DNA) + inner_feature = LocalSubComponent(identity=document.iri("part/feature"), types=(DNA,)) + part = Component(identity=document.iri("part"), types=(DNA,), features=(inner_feature,)) + sub = SubComponent( + identity=document.iri("design/sub"), + instance_of=part.ref, + role_integration=SBOL + "mergeRoles", + roles=(NS + "/role",), + locations=(Range(sequence=sequence.ref, start=1, end=4, orientation=INLINE, order=1),), + source_locations=(EntireSequence(sequence=sequence.ref),), + ) + feature = SequenceFeature( + identity=document.iri("design/feature"), locations=(Cut(sequence=sequence.ref, at=4),) + ) + reference = ComponentReference(in_child_of=sub.ref, refers_to=inner_feature.ref) + external = ExternallyDefined(types=(DNA,), definition="https://example.org/catalog/item") + model = Model( + identity=document.iri("model"), + source="https://example.org/model.xml", + language="https://identifiers.org/edam:format_2585", + framework=NS + "/framework", + ) + design = Component( + identity=document.iri("design"), + types=(DNA,), + sequences=(sequence.ref,), + features=(sub, feature, reference, external), + models=(model.ref,), + constraints=(Constraint(restriction=PRECEDES, subject=sub.ref, object=feature.ref),), + interactions=( + Interaction( + types=(NS + "/interaction",), + participations=(Participation(roles=(NS + "/participant",), participant=sub.ref),), + ), + ), + interface=Interface(inputs=(sub.ref,), outputs=(feature.ref,)), + measures=(Measure(value=4.0, unit=OM + "nanogram"),), + ) + collection = Collection(identity=document.iri("collection"), members=(part.ref,)) + variants = CombinatorialDerivation( + identity=document.iri("variants"), + template=design.ref, + strategy=SBOL + "enumerate", + variable_features=( + VariableFeature( + cardinality=SBOL + "one", + variable=sub.ref, + variants=(part.ref,), + variant_collections=(collection.ref,), + variant_measures=(Measure(value=2.0, unit=OM + "one"),), + ), + ), + ) + outer_variants = CombinatorialDerivation( + identity=document.iri("outer_variants"), + template=design.ref, + variable_features=( + VariableFeature( + cardinality=SBOL + "one", variable=sub.ref, variant_derivations=(variants.ref,) + ), + ), + ) + attachment = Attachment( + identity=document.iri("attachment"), + source="https://example.org/data.csv", + format="https://identifiers.org/edam:format_3752", + size=4, + hash="abcd", + hash_algorithm="sha256", + ) + data = ExperimentalData(identity=document.iri("data"), attachments=(attachment.ref,)) + experiment = Experiment(identity=document.iri("experiment"), members=(data.ref,)) + implementation = Implementation(identity=document.iri("implementation"), built=design.ref) + document.add( + sequence, + part, + design, + model, + collection, + variants, + outer_variants, + attachment, + data, + experiment, + implementation, + ) + frozen = document.freeze() + native = frozen.to_sbol3() + assert not native.validate().errors + assert Document.from_sbol3(native).freeze() == frozen + assert Document.from_turtle(frozen.to_turtle()).freeze() == frozen + native_sub = native.find(sub.identity) + assert native_sub.role_integration == SBOL + "mergeRoles" + assert list(native_sub.roles) == [NS + "/role"] + assert native.find(feature.identity).locations[0].at == 4 + + +@pytest.mark.parametrize( + "location", + [ + Range(sequence=Ref(NS + "/sequence"), start=0, end=2), + Range(sequence=Ref(NS + "/sequence"), start=3, end=2), + Range(sequence=Ref(NS + "/sequence"), start=1, end=5), + Cut(sequence=Ref(NS + "/sequence"), at=-1), + Cut(sequence=Ref(NS + "/sequence"), at=5), + ], +) +def test_locations_are_checked_against_the_referenced_sequence(location): + document = Document(namespace=NS) + sequence = Sequence(identity=document.iri("sequence"), elements="ACGT", encoding=IUPAC_DNA) + document.add( + sequence, + Component( + identity=document.iri("design"), + types=(DNA,), + features=(SequenceFeature(locations=(location,)),), + ), + ) + with pytest.raises(ValueError, match="sequence-(range|cut)"): + document.freeze() + + +def test_constraint_references_must_belong_to_the_containing_component(): + document = Document(namespace=NS) + first = LocalSubComponent(identity=document.iri("first/feature"), types=(DNA,)) + other = LocalSubComponent(identity=document.iri("other/feature"), types=(DNA,)) + document.add( + Component(identity=document.iri("other"), types=(DNA,), features=(other,)), + Component( + identity=document.iri("first"), + types=(DNA,), + features=(first,), + constraints=(Constraint(restriction=PRECEDES, subject=first.ref, object=other.ref),), + ), + ) + with pytest.raises(ValueError, match="feature-scope"): + document.freeze() + + +def test_one_owned_object_cannot_belong_to_two_components(): + document = Document(namespace=NS) + feature = LocalSubComponent(identity=document.iri("shared"), types=(DNA,)) + first = Component(identity=document.iri("first"), types=(DNA,), features=(feature,)) + document.add(first, replace(first, identity=document.iri("second"))) + with pytest.raises(ValueError, match="duplicate-identity"): + document.freeze() + + +def test_component_containment_cannot_be_recursive(): + document = Document(namespace=NS) + document.add( + Component( + identity=document.iri("design"), + types=(DNA,), + features=(SubComponent(instance_of=Ref(document.iri("design"))),), + ) + ) + with pytest.raises(ValueError, match="dependency-cycle"): + document.freeze() + + +@pytest.mark.parametrize( + "obj", + [ + Component(identity=NS + "/a", types=()), + Component(identity=NS + "/a", types=(DNA, DNA)), + Sequence(identity=NS + "/a", elements=123, encoding=IUPAC_DNA), + Component(identity=NS + "/a", types=(DNA,), features=("invalid",)), + Component( + identity=NS + "/a", + types=(DNA,), + measures=(Measure(value=float("nan"), unit=OM + "one"),), + ), + ], +) +def test_invalid_model_field_values_fail_before_serialization(obj): + with pytest.raises(ValueError): + Document(namespace=NS).add(obj) diff --git a/tests/provenance/test_document.py b/tests/provenance/test_document.py new file mode 100644 index 0000000..7ca7940 --- /dev/null +++ b/tests/provenance/test_document.py @@ -0,0 +1,148 @@ +from dataclasses import FrozenInstanceError, replace +from typing import get_type_hints + +import pytest +import sbol3 + +import lab.provenance as provenance +from lab.provenance import Activity, Component, Document, Ref, Sequence, Usage +from lab.provenance.vocabulary import DNA, IUPAC_DNA + +NS = "https://example.org/provenance" + + +def test_freezing_assigns_owned_ids_without_mutating_authored_objects(): + document = Document(namespace=NS) + component = Component(identity=document.iri("design"), types=(DNA,)) + activity = Activity(identity=document.iri("build"), usage=(Usage(entity=component.ref),)) + document.add(activity, component) + frozen = document.freeze() + owned = frozen.resolve(activity.ref).usage[0] + assert owned.identity == NS + "/build/Usage1" + assert activity.usage[0].identity is None + with pytest.raises(ValueError, match="Assign an identity"): + _ = activity.usage[0].ref + assert frozen.resolve(owned.ref) == owned + with pytest.raises(FrozenInstanceError): + owned.name = "changed" + assert document.freeze() == frozen + document.add(Activity(identity=document.iri("later"))) + assert len(frozen.objects) == 2 + + +def test_owned_identity_allocation_preserves_explicit_ids_and_skips_collisions(): + document = Document(namespace=NS) + entity = Component(identity=document.iri("design"), types=(DNA,)) + activity = Activity( + identity=document.iri("build"), + usage=( + Usage(entity=entity.ref), + Usage(identity=document.iri("build/Usage1"), entity=entity.ref), + ), + ) + document.add(entity, activity) + assert [usage.identity for usage in document.freeze().resolve(activity.ref).usage] == [ + NS + "/build/Usage1", + NS + "/build/Usage2", + ] + + +def test_add_is_atomic_and_conflicting_definitions_are_rejected(): + document = Document(namespace=NS) + design = Component(identity=document.iri("design"), types=(DNA,)) + document.add(design, design) + with pytest.raises(ValueError, match="Conflicting definition"): + document.add(Activity(identity=document.iri("unused")), replace(design, name="different")) + assert document.objects == (design,) + with pytest.raises(ValueError, match="TopLevel"): + document.add(Usage(entity=design.ref)) + + +def test_reference_lookup_and_closure_are_explicit(): + document = Document(namespace=NS) + sequence = Sequence(identity=document.iri("sequence"), elements="ACGT", encoding=IUPAC_DNA) + design = Component(identity=document.iri("design"), types=(DNA,), sequences=(sequence.ref,)) + document.add(design) + assert not document.validate().is_valid + with pytest.raises(ValueError, match="unresolved-reference"): + document.freeze() + external = document.freeze(allow_external=True) + with pytest.raises(KeyError): + external.resolve(sequence.ref) + document.add(sequence) + frozen = document.freeze() + assert frozen.get(sequence.identity, Sequence) == frozen.resolve(sequence.ref) + with pytest.raises(TypeError, match="not Sequence"): + frozen.get(design.identity, Sequence) + assert Document.from_snapshot(frozen).freeze() == frozen + + +def test_no_process_global_namespace_and_native_documents_are_detached(): + previous = sbol3.get_namespace() + document = Document(namespace=NS) + design = Component(identity=document.iri("design"), types=(DNA,)) + document.add(design) + frozen = document.freeze() + native = frozen.to_sbol3() + native.find(design.identity).name = "Modified elsewhere" + assert frozen.resolve(design.ref).name is None + assert document.to_sbol3().find(design.identity).name is None + assert sbol3.get_namespace() == previous + + +def test_digest_is_independent_of_object_insertion_and_rdf_statement_order(): + first, second = Document(namespace=NS), Document(namespace=NS) + a = Activity(identity=first.iri("a")) + b = Activity(identity=first.iri("b")) + first.add(a, b) + second.add(b, a) + assert first.freeze().digest == second.freeze().digest + reversed_rdf = "\n".join(reversed(first.freeze().to_turtle().splitlines())) + assert Document.from_turtle(reversed_rdf).freeze().digest == first.freeze().digest + + +def test_local_io_is_reproducible_and_refuses_accidental_replacement(tmp_path): + document = Document(namespace=NS) + document.add(Activity(identity=document.iri("a"))) + path = tmp_path / "nested/provenance.ttl" + document.write(path) + document.write(path) + restored = Document.read(path) + assert restored.freeze() == document.freeze() + document.add(Activity(identity=document.iri("b"))) + with pytest.raises(FileExistsError): + document.write(path) + assert Document.read(path).freeze() == restored.freeze() + + +@pytest.mark.parametrize("value", ["relative", "", "https:///path", "https://example.org/bad name"]) +def test_refs_require_absolute_iris(value): + with pytest.raises(ValueError, match="absolute IRI"): + Ref(value) + + +@pytest.mark.parametrize("value", ["bad-name", "../escape", "123", "a//b", "", "a#b"]) +def test_document_iris_are_explicit_sbol_display_ids(value): + with pytest.raises(ValueError, match="display IDs"): + Document(namespace=NS).iri(value) + + +def test_every_public_model_has_runtime_resolvable_annotations(): + for name in provenance.__all__: + cls = getattr(provenance, name) + if isinstance(cls, type) and hasattr(cls, "__dataclass_fields__"): + get_type_hints(cls) + + +def test_mutable_collections_are_rejected_at_construction(): + with pytest.raises(TypeError, match="immutable"): + Component(identity=NS + "/design", types=[DNA]) + + +def test_mixed_namespaces_need_an_explicit_authoring_namespace_on_import(): + document = Document(namespace=NS) + document.add(Activity(identity=NS + "/a"), Activity(identity="https://elsewhere.org/b")) + rdf = document.freeze().to_turtle() + with pytest.raises(ValueError, match="multiple namespaces"): + Document.from_turtle(rdf) + assert Document.from_turtle(rdf, namespace=NS).freeze() == document.freeze() diff --git a/tests/provenance/test_sbol3.py b/tests/provenance/test_sbol3.py new file mode 100644 index 0000000..71599f4 --- /dev/null +++ b/tests/provenance/test_sbol3.py @@ -0,0 +1,127 @@ +from pathlib import Path + +import pytest +import sbol3 +from rdflib import RDF, Graph, Literal, URIRef +from rdflib.compare import isomorphic + +from lab.provenance import Activity, Component, Document, EvidenceState +from lab.provenance.vocabulary import SBOL + +NS = "https://example.org/native" +FIXTURE = Path(__file__).parents[1] / "fixtures/provenance/design.ttl" + + +def test_independently_authored_rdf_and_foreign_annotations_are_preserved(): + original = Graph().parse(FIXTURE, format="turtle") + document = Document.read(FIXTURE) + exported = Graph().parse(data=document.freeze().to_turtle(), format="turtle") + assert isomorphic(original, exported) + restored = Document.from_turtle(document.freeze().to_turtle()).freeze() + assert restored.digest == document.freeze().digest + native = restored.to_sbol3() + assert native.find("https://example.org/fixture/design/site/cut").at == 4 + assert isomorphic(original, native.graph()) + + +def test_native_provenance_document_import_has_no_inferred_execution_claims(): + native = sbol3.Document() + agent = sbol3.Agent(NS + "/agent") + plan = sbol3.Plan(NS + "/plan") + design = sbol3.Component(NS + "/design", [sbol3.SBO_DNA]) + activity = sbol3.Activity( + NS + "/activity", + usage=[sbol3.Usage(design.identity)], + association=[sbol3.Association(agent=agent, plan=plan)], + ) + native.add([agent, plan, design, activity]) + imported = Document.from_sbol3(native).freeze() + result = imported.get(activity.identity, Activity) + assert result.evidence_state is EvidenceState.UNKNOWN + assert result.start_time is None and result.end_time is None + assert result.usage[0].identity == activity.usage[0].identity + assert isomorphic(native.graph(), imported.to_sbol3().graph()) + + +def test_native_cut_and_role_integration_defects_are_normalized_only_at_adapter_boundary(): + native = sbol3.Document() + sequence = sbol3.Sequence(NS + "/sequence", elements="ACGT", encoding=sbol3.IUPAC_DNA_ENCODING) + part = sbol3.Component(NS + "/part", [sbol3.SBO_DNA]) + feature = sbol3.SubComponent( + part, role_integration=SBOL + "mergeRoles", locations=[sbol3.Cut(sequence, 2)] + ) + design = sbol3.Component(NS + "/design", [sbol3.SBO_DNA], features=[feature]) + native.add([sequence, part, design]) + imported = Document.from_sbol3(native).freeze() + sub = imported.get(design.identity, Component).features[0] + assert sub.role_integration == SBOL + "mergeRoles" + assert sub.roles == () + assert sub.locations[0].at == 2 + graph = Graph().parse(data=imported.to_turtle(), format="turtle") + assert ( + URIRef(feature.identity), + URIRef(SBOL + "roleIntegration"), + URIRef(SBOL + "mergeRoles"), + ) in graph + assert not list(graph.triples((None, URIRef(SBOL + "start"), None))) + # The source document remains untouched. + assert feature.role_integration == SBOL + "mergeRoles" + assert list(native.graph().triples((None, URIRef(SBOL + "start"), None))) + + +def test_input_with_multiple_scalar_values_is_not_silently_truncated(): + graph = Graph().parse(FIXTURE, format="turtle") + graph.add( + (URIRef("https://example.org/fixture/sequence"), URIRef(SBOL + "elements"), Literal("AAAA")) + ) + with pytest.raises(ValueError, match="at most one"): + Document.from_turtle(graph.serialize(format="turtle")) + + +@pytest.mark.parametrize("rdf_type", ["http://sbols.org/v2#ComponentDefinition", SBOL + "Unknown"]) +def test_unsupported_sbol_versions_and_classes_fail_explicitly(rdf_type): + text = f"<{NS}/object> <{RDF.type}> <{rdf_type}> ." + with pytest.raises(ValueError, match="SBOL2|Unsupported SBOL"): + Document.from_turtle(text, namespace=NS) + + +def test_unknown_sbol_properties_are_not_disguised_as_annotations(): + graph = Graph().parse(FIXTURE, format="turtle") + graph.add( + ( + URIRef("https://example.org/fixture/design"), + URIRef(SBOL + "unimplemented"), + Literal("value"), + ) + ) + with pytest.raises(ValueError, match="Unsupported SBOL property"): + Document.from_turtle(graph.serialize(format="turtle")) + + +def test_relative_iris_do_not_depend_on_the_working_directory(): + with pytest.raises(ValueError, match="relative"): + Document.from_turtle(f" <{RDF.type}> <{SBOL}Component> .", namespace=NS) + + +def test_display_ids_cannot_conflict_with_object_identity(): + graph = Graph().parse(FIXTURE, format="turtle") + graph.set( + (URIRef("https://example.org/fixture/design"), URIRef(SBOL + "displayId"), Literal("other")) + ) + with pytest.raises(ValueError, match="Invalid displayId"): + Document.from_turtle(graph.serialize(format="turtle")) + + +def test_orphaned_and_multiply_owned_children_are_rejected_on_import(): + graph = Graph().parse(FIXTURE, format="turtle") + graph.remove((URIRef("https://example.org/fixture/design"), URIRef(SBOL + "hasFeature"), None)) + with pytest.raises(ValueError, match="Orphaned"): + Document.from_turtle(graph.serialize(format="turtle")) + graph = Graph().parse(FIXTURE, format="turtle") + first = URIRef("https://example.org/fixture/design") + second = URIRef("https://example.org/fixture/second") + for _, predicate, obj in tuple(graph.triples((first, None, None))): + if predicate != URIRef(SBOL + "displayId"): + graph.add((second, predicate, obj)) + with pytest.raises(ValueError, match="more than one owner"): + Document.from_turtle(graph.serialize(format="turtle")) diff --git a/tests/test_cloning_integration.py b/tests/test_cloning_integration.py new file mode 100644 index 0000000..ffa2f6e --- /dev/null +++ b/tests/test_cloning_integration.py @@ -0,0 +1,298 @@ +from dataclasses import replace +from decimal import Decimal +from io import StringIO + +import pytest + +import lab +from lab.experiments import cloning +from lab.inventory import CountedStock, MaterialForm +from lab.operations import ExternalPreparation, Thermocycle, Transfer +from lab.provenance import Component, Document, EvidenceState, Implementation +from lab.targets import LiquidHandler, Manual +from tests.cloning_integration_fixture import integrated_case +from tests.planning_fixture import NS + + +def test_all_cloning_routes_share_material_and_evidence_accounting(): + request, inputs = integrated_case() + planned = cloning.plan(request, **inputs) + planned.require_ready() + assert [task.kind.value for task in planned.tasks] == [ + "preparation", + "assembly", + "transformation", + "plating", + "plating", + ] + assert sum(product.count for product in planned.products) == 2 + assert all( + product.volume_ul == 0 and product.form is MaterialForm.PLATED_SAMPLE + for product in planned.products + ) + experiment = cloning.build(planned) + assert len(experiment.stages) == 5 + assert experiment.stages[0].external + assert isinstance(experiment.stages[0].protocol.steps[0], ExternalPreparation) + transformation = experiment.stages[2] + assert ( + len([step for step in transformation.protocol.steps if isinstance(step, Thermocycle)]) == 2 + ) + first, second = experiment.stages[-2:] + assert first.handoffs[0].implementation == second.handoffs[0].implementation + assert (first.handoffs[0].volume_ul, second.handoffs[0].volume_ul) == (Decimal(5), Decimal(4)) + for task in planned.tasks: + material = experiment.provenance.resolve(task.output) + assert material.built is None and material.evidence_state is EvidenceState.PLANNED + compilation = lab.compile(experiment, Manual()) + assert "Expected output" in compilation.files["methods.md"] + + +def test_all_method_recipe_and_stock_inputs_round_trip(tmp_path): + request, inputs = integrated_case() + assert ( + cloning.CloningMethods.read(inputs["methods"].write(tmp_path / "methods.json")) + == inputs["methods"] + ) + assert ( + cloning.CloningSystem.read(inputs["system"].write(tmp_path / "system.json")) + == inputs["system"] + ) + inventory = inputs["inventory"] + assert ( + type(inventory).read( + inventory.write(tmp_path / "inventory.json"), document=inputs["document"] + ) + == inventory + ) + + +def test_missing_preparation_never_becomes_an_implicit_yield(): + request, inputs = integrated_case() + system = replace( + inputs["system"], + recipes=tuple( + recipe for recipe in inputs["system"].recipes if recipe.kind != "preparation" + ), + ) + planned = cloning.plan(request, **{**inputs, "system": system}) + assert not planned.ready + assert any(requirement.kind.value == "preparation" for requirement in planned.requirements) + with pytest.raises(ValueError, match="not ready"): + cloning.build(planned) + + +def test_counted_stock_is_not_reused_when_repeating_external_preparation(): + _, inputs = integrated_case() + vector = inputs["document"].get(NS + "/vector", Component) + request = cloning.BuildRequest( + identity=NS + "/repeat_preparation", + targets=(cloning.BuildTarget(design=vector.ref, volume_ul=Decimal(11)),), + ) + planned = cloning.plan(request, **inputs) + assert not planned.ready + assert ( + sum( + allocation.count + for allocation in planned.allocations + if allocation.stock == NS + "/stab_stock" + ) + == 1 + ) + assert sum(requirement.count for requirement in planned.requirements) == 1 + assert all(requirement.volume_ul == 0 for requirement in planned.requirements) + assert planned.acquisitions[0].count == 1 + + +def test_counted_output_handoff_can_feed_an_explicit_external_procedure(): + _, inputs = integrated_case() + doc = Document.from_snapshot(inputs["document"]) + strain = doc.get(NS + "/strain", Component) + product = Component(identity=NS + "/extracted", types=("https://example.org/dna",)) + doc.add(product) + method = cloning.ExternalPreparationMethod( + identity=NS + "/extract_method", + procedure=NS + "/extract_sop", + instructions="Apply the supplied external test procedure; record the output.", + source_count=1, + output_volume_ul=Decimal(5), + ) + recipe = cloning.ExternalPreparationRecipe( + identity=NS + "/extract_recipe", + product=product.ref, + source=strain.ref, + source_form=MaterialForm.PLATED_SAMPLE, + output_form=MaterialForm.DNA, + method=method.identity, + ) + request = cloning.BuildRequest( + identity=NS + "/extract_request", + targets=(cloning.BuildTarget(design=product.ref, volume_ul=Decimal(2)),), + ) + planned = cloning.plan( + request, + **{ + **inputs, + "document": doc.freeze(), + "system": replace(inputs["system"], recipes=(*inputs["system"].recipes, recipe)), + "methods": replace( + inputs["methods"], preparations=(*inputs["methods"].preparations, method) + ), + }, + ) + planned.require_ready() + experiment = cloning.build(planned) + stage = experiment.stages[-1] + assert stage.external and stage.handoffs[0].count == 1 + assert stage.handoffs[0].volume_ul == 0 + compilation = lab.compile(experiment, Manual()) + assert "1 unit(s)" in compilation.files["methods.md"] + with pytest.raises(ValueError, match="volume|count"): + replace( + experiment, + stages=( + *experiment.stages[:-1], + replace(stage, handoffs=(replace(stage.handoffs[0], count=2),)), + ), + ) + + +@pytest.mark.integration +@pytest.mark.parametrize("handler", [LiquidHandler.OT2, LiquidHandler.FLEX]) +def test_integrated_workflow_runs_each_robot_stage_in_official_simulator(handler): + simulator = pytest.importorskip("opentrons.simulate") + request, inputs = integrated_case(count=1) + experiment = cloning.build(cloning.plan(request, **inputs)) + compilation = lab.compile(experiment, handler) + assert compilation.stages[0].target.name == "Manual" + for stage in compilation.stages[1:]: + simulator.simulate(StringIO(stage.files["protocol.py"])) + assert len(stage.source_map) == len(stage.protocol.steps) + assert ".bottom(z=2)" in compilation.stages[-1].files["protocol.py"] + assert ( + compilation.files["protocol.labop.ttl"] + == lab.compile(experiment, Manual()).files["protocol.labop.ttl"] + ) + + +@pytest.mark.integration +async def test_integrated_star_preview_carries_temperature_and_spot_height(): + backends = pytest.importorskip("pylabrobot.liquid_handling.backends") + resources = pytest.importorskip("pylabrobot.resources") + events = [] + + class Recorder(backends.SerializingBackend): + async def send_command(self, command, data=None): + events.append((command, data)) + + async def thermal(plate, *, profile, cycles, lid_temperature): + events.append(("thermal", profile)) + + async def hold(plate, *, temperature): + events.append(("hold", temperature)) + + request, inputs = integrated_case(count=1) + compilation = lab.compile(cloning.build(cloning.plan(request, **inputs)), LiquidHandler.STAR) + resources.set_volume_tracking(True) + try: + for stage in compilation.stages[1:]: + namespace = {"__name__": "_generated"} + exec(compile(stage.files["protocol.py"], "preview.py", "exec"), namespace) + await namespace["run"]( + Recorder(num_channels=8), + **( + {"thermocycle": thermal, "set_temperature": hold} + if any(isinstance(step, Thermocycle) for step in stage.protocol.steps) + else {} + ), + ) + finally: + resources.set_volume_tracking(False) + assert ("hold", 25) in events + spots = [ + data + for command, data in events + if command == "dispense" + and any(channel.get("liquid_height") == 2 for channel in data["channels"]) + ] + assert spots + + +def test_external_material_accounting_rejects_overdrawn_counts_and_yields(): + request, inputs = integrated_case(count=1) + experiment = cloning.build(cloning.plan(request, **inputs)) + stage = experiment.stages[0] + step = stage.protocol.steps[0] + overdraw = replace(step, inputs=(replace(step.inputs[0], count=2),)) + with pytest.raises(ValueError, match="available material count"): + replace( + experiment, + stages=( + replace(stage, protocol=replace(stage.protocol, steps=(overdraw,))), + *experiment.stages[1:], + ), + ) + overflow = replace(step, outputs=(replace(step.outputs[0], volume_ul=Decimal(101)),)) + with pytest.raises(ValueError, match="capacity"): + replace( + experiment, + stages=( + replace(stage, protocol=replace(stage.protocol, steps=(overflow,))), + *experiment.stages[1:], + ), + ) + with pytest.raises(ValueError, match="positive volume or count"): + replace(step.inputs[0], volume_ul=Decimal(1)) + pipetting = Transfer(step.inputs[0].location, step.outputs[0].location, Decimal(1), step.origin) + with pytest.raises(ValueError, match="Counted material"): + replace( + experiment, + stages=( + replace(stage, protocol=replace(stage.protocol, steps=(pipetting,))), + *experiment.stages[1:], + ), + ) + + +def test_count_targets_reuse_recorded_inventory_without_claiming_colonies(): + request, inputs = integrated_case(count=2) + doc = Document.from_snapshot(inputs["document"]) + design = request.targets[0].design + material = Implementation( + identity=NS + "/existing_spots", + derived_from=(design,), + evidence_state=EvidenceState.RECORDED, + ) + doc.add(material) + stock = CountedStock( + identity=NS + "/spot_stock", + design=design, + implementation=material.ref, + form=MaterialForm.PLATED_SAMPLE, + count=2, + ) + planned = cloning.plan( + request, + **{ + **inputs, + "document": doc.freeze(), + "inventory": replace(inputs["inventory"], stocks=(*inputs["inventory"].stocks, stock)), + }, + ) + assert planned.ready and not planned.tasks + assert planned.products[0].implementation == material.ref and planned.products[0].count == 2 + assert not doc.freeze().resolve(material.ref).built + + +def test_external_preparation_needs_matching_quantity_kinds_and_explicit_target(): + request, inputs = integrated_case(count=1) + method = inputs["methods"].preparations[0] + wrong = replace(method, source_count=0, source_volume_ul=Decimal(1)) + with pytest.raises(ValueError, match="quantity kinds"): + cloning.plan( + request, **{**inputs, "methods": replace(inputs["methods"], preparations=(wrong,))} + ) + experiment = cloning.build(cloning.plan(request, **inputs)) + targets = {stage.identity: stage.deck for stage in experiment.stages} + with pytest.raises(ValueError, match="Manual targets"): + lab.compile(experiment, targets, liquid_handler=LiquidHandler.OT2) diff --git a/tests/test_dna.py b/tests/test_dna.py new file mode 100644 index 0000000..c794568 --- /dev/null +++ b/tests/test_dna.py @@ -0,0 +1,98 @@ +"""Independent expected strands captured from pydna 5.5.0 / Biopython 1.84.""" + +import pytest + +from lab.experiments.cloning._dna import DnaSequence + + +@pytest.mark.parametrize( + "enzyme,elements,circular,expected", + [ + ( + "PstI", + "GGCTGCAGAAAAGCTGCAGTT", + False, + ( + (None, 7, "GGCTGCA", "GCC", 0), + (7, 18, "GAAAAGCTGCA", "GCTTTTCTGCA", 4), + (18, None, "GTT", "AACTGCA", 4), + ), + ), + ( + "SmaI", + "GGCCCGGGTTTCCCGGGAA", + False, + ( + (None, 5, "GGCCC", "GGGCC", 0), + (5, 14, "GGGTTTCCC", "GGGAAACCC", 0), + (14, None, "GGGAA", "TTCCC", 0), + ), + ), + ( + "EcoRI", + "AATTCCCCGGAATTCG", + True, + ( + (0, 10, "AATTCCCCGG", "AATTCCGGGG", -4), + (10, 0, "AATTCG", "AATTCG", -4), + ), + ), + ( + "PstI", + "CTGCAGAAAAGCTGCAGTT", + True, + ( + (5, 16, "GAAAAGCTGCA", "GCTTTTCTGCA", 4), + (16, 5, "GTTCTGCA", "GAACTGCA", 4), + ), + ), + ( + "BsaI", + "TTTGGTCTCAACGTTACGTTTGAGACCAAA", + False, + ( + (None, 10, "TTTGGTCTCA", "ACGTTGAGACCAAA", 0), + (10, 16, "ACGTTA", "AACGTA", -4), + (16, None, "CGTTTGAGACCAAA", "TTTGGTCTCA", -4), + ), + ), + ], +) +def test_digest_and_ligation_match_independent_strand_reference( + enzyme, elements, circular, expected +): + fragments = DnaSequence(elements, circular).digest(enzyme) + assert ( + tuple( + (left, right, fragment.watson, fragment.crick, fragment.overhang) + for left, right, fragment in fragments + ) + == expected + ) + joined = fragments[0][2] + for _, _, fragment in fragments[1:]: + joined = joined.ligate(fragment) + if circular: + assert len(joined.close()) == len(elements) and joined.close() in elements * 2 + else: + assert joined.linear_sequence() == elements + + +def test_reverse_complement_preserves_three_prime_ends(): + fragment = DnaSequence("GGCTGCAGAAAAGCTGCAGTT", False).digest("PstI")[0][2] + reversed_fragment = fragment.reverse_complement() + assert (reversed_fragment.watson, reversed_fragment.crick, reversed_fragment.overhang) == ( + "GCC", + "GGCTGCA", + 4, + ) + assert reversed_fragment.reverse_complement() == fragment + + +def test_incompatible_ends_and_unpaired_linear_products_fail_explicitly(): + eco = DnaSequence("GGGAATTCCCCGAATTCGG", False).digest("EcoRI")[1][2] + pst = DnaSequence("GGCTGCAGAAAAGCTGCAGTT", False).digest("PstI")[1][2] + with pytest.raises(ValueError, match="Incompatible ends"): + eco.ligate(pst) + with pytest.raises(ValueError, match="end repair"): + eco.linear_sequence() diff --git a/tests/test_experiments.py b/tests/test_experiments.py new file mode 100644 index 0000000..85faf8f --- /dev/null +++ b/tests/test_experiments.py @@ -0,0 +1,162 @@ +import builtins +import hashlib +import json +from dataclasses import replace +from decimal import Decimal +from io import StringIO + +import pytest + +import lab +from lab import Protocol, seconds, uL +from lab.experiments import cloning +from lab.experiments.cloning.decks import assembly_deck +from lab.labop import export +from lab.operations import Origin +from lab.provenance import Document, EvidenceState, Implementation +from lab.targets import LiquidHandler, Manual +from tests.planning_fixture import multilevel_case, planning_case + + +def test_build_freezes_protocol_and_material_identity_before_hardware_selection(tmp_path): + request, inputs = planning_case() + experiment = cloning.build(cloning.plan(request, **inputs)) + artifact = lab.compile(experiment, Manual()) + assert all( + sample.design and sample.implementation for sample in artifact.stages[0].protocol.samples + ) + assert all(step.identity for step in artifact.stages[0].protocol.steps) + files = artifact.files + build_data = json.loads(files["build.json"]) + assert ( + hashlib.sha256(files["inputs/build-provenance.ttl"].encode()).hexdigest() + == (build_data["provenance_sha256"]) + ) + manifest = json.loads(files["bundle.json"]) + for name, checksum in manifest["sha256"].items(): + assert hashlib.sha256(files[name].encode()).hexdigest() == checksum + path = artifact.write(tmp_path) + assert Document.read(path / "provenance.ttl").freeze().digest == experiment.provenance.digest + assert artifact.write(tmp_path) == path + assert "Planned methods" in files["methods.md"] + outputs = tuple( + obj + for obj in experiment.provenance.objects + if isinstance(obj, Implementation) and obj.evidence_state is EvidenceState.PLANNED + ) + assert outputs and all(obj.built is None for obj in outputs) + + +def test_multilevel_handoffs_use_remaining_physical_volume(): + request, inputs = multilevel_case() + experiment = cloning.build(cloning.plan(request, **inputs)) + assert len(experiment.stages) == 3 + first, second, third = experiment.stages + assert second.depends_on == third.depends_on == (first.identity,) + assert second.handoffs[0].implementation == third.handoffs[0].implementation + assert second.handoffs[0].volume_ul == Decimal(5) + assert third.handoffs[0].volume_ul == Decimal(4) + with pytest.raises(ValueError, match="requires an upstream"): + replace(experiment, stages=(first, replace(second, handoffs=()), third)) + with pytest.raises(ValueError, match="volume"): + replace( + experiment, + stages=( + first, + second, + replace(third, handoffs=(replace(third.handoffs[0], volume_ul=Decimal(5)),)), + ), + ) + assert export(experiment).text == export(experiment).text + + +def test_missing_inputs_block_executable_generation(): + request, inputs = planning_case(stock_volume="0") + with pytest.raises(ValueError, match="not ready"): + cloning.build(cloning.plan(request, **inputs)) + + +def test_semantic_identity_is_independent_of_authoring_paths(): + p = Protocol("Same meaning") + p.wait(1 * seconds) + original = p.snapshot() + moved = replace( + original, + steps=tuple( + replace( + step, + origin=Origin("/another/machine/experiment.py", 800), + ) + for step in original.steps + ), + ) + assert moved.digest == original.digest + assert moved.steps[0].identity == original.steps[0].identity + p.wait(2 * seconds) + assert original.digest != p.snapshot().digest + with pytest.raises(ValueError, match="unique"): + replace(original, steps=(*original.steps, *original.steps)) + + +@pytest.mark.integration +@pytest.mark.parametrize("handler", [LiquidHandler.OT2, LiquidHandler.FLEX]) +def test_assembly_robot_code_runs_in_official_simulator(handler): + simulator = pytest.importorskip("opentrons.simulate") + request, inputs = planning_case() + experiment = cloning.build(cloning.plan(request, **inputs)) + artifact = lab.compile(experiment, handler) + compilation = artifact.stages[0] + log, _ = simulator.simulate(StringIO(compilation.files["protocol.py"])) + assert any("Aspirating 1.0" in event["payload"]["text"] for event in log) + assert [item["step"] for item in compilation.source_map] == [ + step.identity for step in compilation.protocol.steps + ] + manual = lab.compile(experiment, Manual()) + for name in ("experiment.json", "protocol.labop.ttl", "provenance.ttl", "methods.md"): + assert artifact.files[name] == manual.files[name] + + +@pytest.mark.integration +async def test_assembly_star_preview_matches_planned_final_volumes(): + backend_module = pytest.importorskip("pylabrobot.liquid_handling.backends") + resources = pytest.importorskip("pylabrobot.resources") + events = [] + + class Recorder(backend_module.SerializingBackend): + async def send_command(self, command, data=None): + events.append(command) + + async def thermal(plate, *, profile, cycles, lid_temperature): + assert profile == [(25, 1)] + assert plate.get_item("A1").tracker.get_used_volume() == 5 + events.append("thermal") + + request, inputs = planning_case() + experiment = cloning.build(cloning.plan(request, **inputs)) + artifact = lab.compile(experiment, LiquidHandler.STAR) + compilation = artifact.stages[0] + namespace = {"__name__": "_generated"} + exec(builtins.compile(compilation.files["protocol.py"], "preview.py", "exec"), namespace) + resources.set_volume_tracking(True) + try: + await namespace["run"](Recorder(num_channels=8), thermocycle=thermal) + finally: + resources.set_volume_tracking(False) + assert events[-2:] == ["thermal", "stop"] + assert len(compilation.source_map) == len(compilation.protocol.steps) + + +@pytest.mark.integration +def test_flex_switches_volume_modes_before_picking_up_fresh_tips(): + simulator = pytest.importorskip("opentrons.simulate") + + p = Protocol("Mixed volumes") + source = p.plate("reagents", shape=(4, 6), capacity=1500 * uL) + target = p.plate("products", capacity=100 * uL) + p.load(source["A1"], "water", volume=200 * uL) + for index, volume in enumerate((1, 4, 5, 30, 50), 1): + p.transfer(source["A1"], target[f"A{index}"], volume=volume * uL) + artifact = lab.compile(p, deck=assembly_deck(), liquid_handler=LiquidHandler.FLEX) + source_text = artifact.files["protocol.py"] + assert source_text.count("configure_for_volume") == 5 + simulator.simulate(StringIO(source_text)) diff --git a/tests/test_labop.py b/tests/test_labop.py new file mode 100644 index 0000000..173d649 --- /dev/null +++ b/tests/test_labop.py @@ -0,0 +1,168 @@ +import hashlib +import json +from importlib.resources import files +from urllib.parse import unquote + +import pytest +from rdflib import OWL, RDF, RDFS, Graph, URIRef + +from lab import ExperimentPlan, Protocol, ProtocolStage, celsius, seconds, uL +from lab.deck import Container, Deck, DeckSite +from lab.experiments import cloning +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.provenance import Document +from tests.cloning_integration_fixture import integrated_case +from tests.planning_fixture import multilevel_case, planning_case + + +def artifact(*, operations=False): + if operations == "cloning": + request, inputs = integrated_case(count=1) + experiment = cloning.build(cloning.plan(request, **inputs)) + return experiment, export(experiment) + if operations: + protocol = Protocol("Operation vocabulary", identity="https://example.org/operations") + 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.wait(1 * seconds) + protocol.manual("Inspect the plate") + protocol.thermocycle( + plate, ((celsius(25), 1 * seconds),), lid_temperature=celsius(40), block_volume=10 * uL + ) + experiment = ExperimentPlan( + identity="https://example.org/operation_experiment", + provenance=Document(namespace="https://example.org/").freeze(), + stages=( + ProtocolStage( + identity="https://example.org/operation_stage", + protocol=protocol.snapshot(), + deck=Deck( + containers=( + Container(id="plate", labware=PCR_PLATE_96, site=DeckSite.THERMOCYCLER), + ) + ), + ), + ), + ) + return experiment, export(experiment) + request, inputs = planning_case() + experiment = cloning.build(cloning.plan(request, **inputs)) + return experiment, export(experiment) + + +@pytest.mark.parametrize("operations", [False, True, "cloning"]) +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 + len(experiment.stages) + 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)} == { + step.identity for stage in experiment.stages for step in stage.protocol.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", [False, True, "cloning"]) +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) + + +@pytest.mark.parametrize("operations", [False, True, "cloning"]) +def test_generated_nodes_satisfy_pinned_ontology_cardinalities(operations): + _, result = artifact(operations=operations) + graph = result.graph() + ontology = Graph() + for name in ("uml.ttl", "labop.ttl", "lab.ttl"): + ontology.parse( + data=files("lab.labop").joinpath("resources", name).read_text(), format="turtle" + ) + for subject in set(graph.subjects(RDF.type)): + pending = list(graph.objects(subject, RDF.type)) + seen = set() + while pending: + kind = pending.pop() + if kind in seen: + continue + seen.add(kind) + for parent in ontology.objects(kind, RDFS.subClassOf): + if isinstance(parent, URIRef): + pending.append(parent) + continue + predicate = ontology.value(parent, OWL.onProperty) + count = len(tuple(graph.objects(subject, predicate))) + minimum = ontology.value(parent, OWL.minCardinality) + maximum = ontology.value(parent, OWL.maxCardinality) + exact = ontology.value(parent, OWL.cardinality) + if minimum is not None: + assert count >= int(minimum), (subject, predicate, minimum) + if maximum is not None: + assert count <= int(maximum), (subject, predicate, maximum) + if exact is not None: + assert count == int(exact), (subject, predicate, exact) + # Unknown properties in upstream namespaces generally indicate a spelling or version error. + declared = set(ontology.subjects()) + for predicate in set(graph.predicates()): + if str(predicate).startswith((str(LABOP), str(UML))): + assert predicate in declared, predicate + + +def test_multilevel_calls_have_explicit_material_flows_and_pinned_resources(): + request, inputs = multilevel_case() + experiment = cloning.build(cloning.plan(request, **inputs)) + graph = export(experiment).graph() + handoff_pins = tuple(graph.subjects(LAB.handoffs)) + assert len(handoff_pins) == 2 + for pin in handoff_pins: + edge = graph.value(predicate=UML.target, object=pin) + assert (edge, RDF.type, UML.ObjectFlow) in graph + manifest = json.loads(files("lab.labop").joinpath("resources/upstream.json").read_text()) + for name, item in manifest["files"].items(): + data = files("lab.labop").joinpath("resources", name).read_bytes() + assert hashlib.sha256(data).hexdigest() == item["sha256"] diff --git a/tests/test_layouts.py b/tests/test_layouts.py index 36e4561..7ef760d 100644 --- a/tests/test_layouts.py +++ b/tests/test_layouts.py @@ -232,4 +232,4 @@ def test_star_thermal_steps_require_a_declared_handoff(): deck=replace(example_deck(), layouts=(layout,)), liquid_handler=LiquidHandler.STAR, ) - assert "Supply an async thermocycle callback" in bundle.files["protocol.py"] + assert "Supply an async set_temperature callback" in bundle.files["protocol.py"] diff --git a/tests/test_planning.py b/tests/test_planning.py new file mode 100644 index 0000000..4033aee --- /dev/null +++ b/tests/test_planning.py @@ -0,0 +1,310 @@ +import json +from dataclasses import replace +from decimal import Decimal + +import pytest + +from lab import uL +from lab.experiments.cloning.domestication import propose_edits +from lab.experiments.cloning.methods import CloningMethods +from lab.experiments.cloning.planning import ( + BuildTarget, + PlanningPolicy, + RequirementKind, + TaskKind, + plan, +) +from lab.experiments.cloning.sequences import calculate_assembly, digest_fragments +from lab.experiments.cloning.systems import CloningSystem, FragmentSelection +from lab.inventory import Inventory, MaterialForm, Stock +from lab.provenance import Component, Document, EvidenceState, Implementation, Sequence +from tests.planning_fixture import NS, planning_case + + +def test_explicit_fragments_calculate_product_and_computational_provenance(): + _, inputs = planning_case() + recipe = inputs["system"].recipes[0] + fragments = digest_fragments( + recipe.fragments[1].component, document=inputs["document"], enzyme="EcoRI" + ) + assert [ + (f.selection.left_cut, f.selection.right_cut, f.watson, f.crick, f.overhang) + for f in fragments + ] == [ + (None, 3, "GGG", "AATTCCC", 0), + (3, 12, "AATTCCCCG", "AATTCGGGG", -4), + (12, None, "AATTCGG", "CCG", -4), + ] + calculated = calculate_assembly(recipe, document=inputs["document"]) + assert calculated.sequence.elements == "AATTCTTTTAAAAGAATTCCCCG" + assert calculated.activity.evidence_state is EvidenceState.RECORDED + doc = Document.from_snapshot(inputs["document"]) + doc.add(*calculated.objects) + assert not doc.to_sbol3().validate().errors + + +def test_planner_allocates_specific_implementations_and_emits_planned_outputs(tmp_path): + request, inputs = planning_case() + build = plan(request, **inputs) + build.require_ready() + assert len(build.tasks) == 1 + task = build.tasks[0] + assert task.kind is TaskKind.ASSEMBLY + assert len(task.inputs) == 4 + assert sum(item.volume_ul for item in task.inputs) == 5 + assert all(item.stock is not None for item in task.inputs) + output = build.document.resolve(task.output) + assert output.built is None and output.evidence_state is EvidenceState.PLANNED + assert sum(item.volume_ul for item in build.products) == 3 + assert inputs["inventory"].stocks[0].quantity_ul == 10 + assert plan(request, **inputs).digest == build.digest + path = build.write(tmp_path) + assert ( + json.loads((path / "build.json").read_text())["provenance_sha256"] == build.document.digest + ) + assert Document.read(path / "provenance.ttl").freeze().digest == build.document.digest + + +def test_inventory_is_reused_before_assembling(): + request, inputs = planning_case() + doc = Document.from_snapshot(inputs["document"]) + implementation = Implementation( + identity=NS + "/existing", + derived_from=(request.targets[0].design,), + evidence_state=EvidenceState.RECORDED, + ) + doc.add(implementation) + inventory = Inventory( + identity=NS + "/available", + stocks=( + *inputs["inventory"].stocks, + Stock( + identity=NS + "/available_stock", + implementation=implementation.ref, + design=request.targets[0].design, + form=MaterialForm.DNA, + quantity=20 * uL, + ), + ), + ) + build = plan(request, **{**inputs, "document": doc.freeze(), "inventory": inventory}) + assert build.ready and build.tasks == () + assert build.products[0].implementation == implementation.ref + + +def test_multiple_batches_do_not_double_count_inventory_or_reuse_output_identity(): + request, inputs = planning_case(volume="11", stock_volume="3") + build = plan(request, **inputs) + assert sum(task.kind is TaskKind.ASSEMBLY for task in build.tasks) == 3 + assert len({task.output for task in build.tasks}) == len(build.tasks) + # Three batches require six microlitres of water; inventory contains three. + water = [item for item in build.requirements if item.design.identity == NS + "/water"] + assert sum(item.volume_ul for item in water) == 3 + for stock in inputs["inventory"].stocks: + assert ( + sum(item.volume_ul for item in build.allocations if item.stock == stock.identity) + <= stock.quantity_ul + ) + with pytest.raises(ValueError, match="not ready"): + build.require_ready() + + +def test_wrong_material_form_requires_preparation(): + request, inputs = planning_case() + original = inputs["inventory"].stocks + stocks = tuple( + Stock( + identity=stock.identity, + implementation=stock.implementation, + design=stock.design, + form=MaterialForm.CULTURE if stock.design.identity == NS + "/vector" else stock.form, + quantity=stock.quantity_ul * uL, + ) + for stock in original + ) + build = plan( + request, **{**inputs, "inventory": Inventory(identity=NS + "/other", stocks=stocks)} + ) + assert any(item.kind is RequirementKind.PREPARATION for item in build.requirements) + + +def test_invalid_fragment_selection_does_not_silently_choose_another_fragment(): + request, inputs = planning_case() + recipe = inputs["system"].recipes[0] + invalid = replace( + recipe, fragments=(replace(recipe.fragments[0], left_cut=4), *recipe.fragments[1:]) + ) + build = plan(request, **{**inputs, "system": replace(inputs["system"], recipes=(invalid,))}) + assert not build.ready + assert any("does not select exactly one" in item.message for item in build.requirements) + + +def test_route_tie_breaking_is_stable_and_search_limits_are_explicit(): + request, inputs = planning_case() + recipe = inputs["system"].recipes[0] + alternate = replace(recipe, identity=NS + "/z_recipe") + system = replace(inputs["system"], recipes=(alternate, recipe)) + build = plan(request, **{**inputs, "system": system}) + assert build.tasks[0].recipe.identity == recipe.identity + with pytest.raises(ValueError, match="max_states"): + plan(request, **inputs, policy=PlanningPolicy(max_states=1)) + + +def test_edits_are_proposals_until_explicitly_applied_to_a_new_design(): + _, inputs = planning_case() + document = inputs["document"] + component = document.get(NS + "/insert", Component) + proposals = propose_edits( + component.ref, document=document, enzyme="EcoRI", editable_positions=(3,) + ) + assert proposals + original = document.resolve(component.sequences[0]) + edited = proposals[0].apply(document, identity=NS + "/selected_edit") + assert document.resolve(component.sequences[0]) == original + assert edited.get(NS + "/selected_edit/sequence", Sequence).elements != original.elements + assert edited.get(NS + "/selected_edit", Component).derived_from == (component.ref,) + assert len(edited.objects) == len(document.objects) + 3 + + +def test_inventory_round_trip_and_recorded_evidence_requirement(tmp_path): + _, inputs = planning_case() + inventory = inputs["inventory"] + path = inventory.write(tmp_path / "inventory.json") + assert Inventory.read(path, document=inputs["document"]) == inventory + implementation = Implementation(identity=NS + "/planned", evidence_state=EvidenceState.PLANNED) + doc = Document.from_snapshot(inputs["document"]) + doc.add(implementation) + stock = Stock( + identity=NS + "/stock", + implementation=implementation.ref, + design=inventory.stocks[0].design, + form=MaterialForm.DNA, + quantity=1 * uL, + ) + with pytest.raises(ValueError, match="recorded"): + Inventory(identity=NS + "/inventory", stocks=(stock,)).validate(doc.freeze()) + + +def test_stock_quantities_are_normalized_and_detached_from_mutable_pint_values(): + _, inputs = planning_case() + source = inputs["inventory"].stocks[0] + quantity = 10 * uL + stock = Stock( + identity=source.identity, + implementation=source.implementation, + design=source.design, + form=source.form, + quantity=quantity, + ) + quantity *= 2 + assert stock.quantity_ul == Decimal(10) + + +def test_multilevel_assembly_consumes_a_planned_intermediate(): + request, inputs = planning_case() + doc = Document.from_snapshot(inputs["document"]) + intermediate = doc.get(NS + "/target", Component) + final = replace(intermediate, identity=NS + "/final") + doc.add(final) + first = inputs["system"].recipes[0] + second = replace( + first, + identity=NS + "/second_recipe", + product=final.ref, + fragments=( + first.fragments[0], + FragmentSelection(component=intermediate.ref, left_cut=14, right_cut=0), + ), + ) + request = replace(request, targets=(BuildTarget(design=final.ref, volume_ul=Decimal(3)),)) + build = plan( + request, + **{ + **inputs, + "document": doc.freeze(), + "system": replace(inputs["system"], recipes=(first, second)), + }, + ) + build.require_ready() + assert len(build.tasks) == 2 + assert build.tasks[1].depends_on == (build.tasks[0].identity,) + assert any(item.implementation == build.tasks[0].output for item in build.tasks[1].inputs) + assert sum(item.volume_ul for item in build.products) == 3 + + +def test_route_search_preserves_scarce_inventory_for_another_target(): + request, inputs = planning_case() + doc = Document.from_snapshot(inputs["document"]) + vector = doc.get(NS + "/vector", Component) + other_vector = replace(vector, identity=NS + "/other_vector") + other_target = replace(doc.get(NS + "/target", Component), identity=NS + "/other_target") + stock_impl = Implementation( + identity=NS + "/other_vector_impl", + derived_from=(other_vector.ref,), + evidence_state=EvidenceState.RECORDED, + ) + doc.add(other_vector, other_target, stock_impl) + stocks = tuple( + Stock( + identity=stock.identity, + implementation=stock.implementation, + design=stock.design, + form=stock.form, + quantity=(1 if stock.design == vector.ref else 10) * uL, + ) + for stock in inputs["inventory"].stocks + ) + alternate_stock = Stock( + identity=NS + "/other_vector_stock", + implementation=stock_impl.ref, + design=other_vector.ref, + form=MaterialForm.DNA, + quantity=1 * uL, + ) + first = inputs["system"].recipes[0] + alternative = replace( + first, + identity=NS + "/z_alternative", + fragments=(replace(first.fragments[0], component=other_vector.ref), first.fragments[1]), + ) + second = replace(first, identity=NS + "/second", product=other_target.ref) + build = plan( + replace( + request, + targets=(*request.targets, BuildTarget(design=other_target.ref, volume_ul=Decimal(3))), + ), + **{ + **inputs, + "document": doc.freeze(), + "inventory": Inventory(identity=NS + "/scarce", stocks=(*stocks, alternate_stock)), + "system": replace(inputs["system"], recipes=(first, alternative, second)), + }, + ) + build.require_ready() + assert build.tasks[0].recipe.identity == alternative.identity + assert build.tasks[1].recipe.identity == second.identity + + +def test_typed_recipe_and_method_files_round_trip(tmp_path): + _, inputs = planning_case() + system = inputs["system"] + methods = inputs["methods"] + assert CloningSystem.read(system.write(tmp_path / "system.json")) == system + assert CloningMethods.read(methods.write(tmp_path / "methods.json")) == methods + + +def test_authoring_can_add_frozen_objects_idempotently_and_explicitly_revise(): + _, inputs = planning_case() + doc = Document.from_snapshot(inputs["document"]) + authored = Component(identity=NS + "/new", types=("https://example.org/type",)) + doc.add(authored) + snapshot = doc.freeze() + doc = Document.from_snapshot(snapshot) + doc.add(authored) + assert doc.freeze().digest == snapshot.digest + revised = replace(authored, name="A deliberate revision") + with pytest.raises(ValueError, match="Conflicting"): + doc.add(revised) + doc.replace(revised) + assert doc.freeze().get(authored.identity, Component).name == revised.name + assert snapshot.get(authored.identity, Component).name is None diff --git a/tests/test_stages.py b/tests/test_stages.py index 38bba47..9abed65 100644 --- a/tests/test_stages.py +++ b/tests/test_stages.py @@ -24,7 +24,7 @@ transformation_deck, ) from lab.experiments.cloning.addresses import well_name -from lab.model import Mix, Transfer +from lab.operations import Mix, Transfer from lab.part import Part from lab.samples import Location from lab.targets import Labware, LiquidHandler, Manual @@ -137,7 +137,7 @@ def test_transformation_uses_caller_defined_materials(): ) compiled = lab.compile(transformation, Manual()) assert compiled.protocol.name == transformation.id - assert compiled.manifest.protocol_id == transformation.id + assert compiled.manifest.protocol_id == compiled.protocol.identity assert {sample.material_identity for sample in compiled.manifest.samples} == {"custom-strain"} assert { sample.material_identity for sample in compiled.protocol.samples if sample.role == "dna" diff --git a/tests/test_suppliers.py b/tests/test_suppliers.py new file mode 100644 index 0000000..7f61706 --- /dev/null +++ b/tests/test_suppliers.py @@ -0,0 +1,171 @@ +import io +import json +from dataclasses import replace +from datetime import UTC, datetime +from unittest.mock import patch + +import pytest + +from lab import uL +from lab.experiments.cloning.planning import RequirementKind, plan +from lab.inventory import Inventory, MaterialForm +from lab.provenance import Agent, Document, EvidenceState +from lab.suppliers import ( + AddgeneClient, + Catalog, + CatalogEntry, + OrderReference, + Receipt, + SequenceSource, + parse_plasmid, +) +from tests.planning_fixture import NS, planning_case + +TIME = datetime(2026, 9, 27, tzinfo=UTC) + + +def response(): + # Synthetic neutral bases, with the field names of Addgene's retrieve schema. + return json.dumps( + { + "id": 42, + "name": "Contract fixture", + "url": "https://www.addgene.org/42/", + "sequences": { + "public_user_full_sequences": [ + { + "sequence_id": 17, + "sequence_description": "Depositor assertion", + "sequence": "ACGT", + "length": 4, + "genbank_url": "", + "genbank_api_url": "https://example.org/sequence.gb", + } + ], + "public_addgene_partial_sequences": [ + { + "sequence_id": 18, + "sequence_description": "Partial trace", + "sequence": "AC", + "length": 2, + "genbank_api_url": "", + } + ], + }, + } + ) + + +def catalog(design): + item = parse_plasmid(response(), retrieved_at=TIME) + return Catalog( + entries=(CatalogEntry(item=item, design=design, form=MaterialForm.BACTERIAL_STAB),) + ) + + +def test_catalog_preserves_source_completeness_and_original_metadata(tmp_path): + request, _ = planning_case() + snapshot = catalog(request.targets[0].design) + item = snapshot.entries[0].item + assert item.sequences[0].source is SequenceSource.DEPOSITOR + assert item.sequences[0].complete + assert item.sequences[1].source is SequenceSource.ADDGENE + assert not item.sequences[1].complete + assert json.loads(item.metadata_json) == json.loads(response()) + path = snapshot.write(tmp_path / "catalog.json") + assert Catalog.read(path) == snapshot + + +def test_catalog_http_request_matches_documented_auth_and_endpoint(): + client = AddgeneClient(token="secret-example") + assert "secret-example" not in repr(client) + with patch("lab.suppliers.addgene.build_opener") as opener: + opener.return_value.open.return_value = io.BytesIO(response().encode()) + result = client.plasmid(42) + request = opener.return_value.open.call_args.args[0] + assert request.full_url == ( + "https://api.developers.addgene.org/catalog/plasmid-with-sequences/42/" + ) + assert request.method == "GET" + assert request.headers["Authorization"] == "Token secret-example" + assert result.catalog_id == "42" + with pytest.raises(ValueError, match="positive integer"): + client.plasmid(True) + with patch("lab.suppliers.addgene.build_opener") as opener: + opener.return_value.open.return_value = io.BytesIO(response().encode()) + with pytest.raises(ValueError, match="different plasmid"): + client.plasmid(43) + + +def test_catalog_candidate_does_not_count_as_inventory(): + request, inputs = planning_case() + missing = next( + stock for stock in inputs["inventory"].stocks if stock.design.identity == NS + "/vector" + ) + inventory = replace( + inputs["inventory"], + stocks=tuple(stock for stock in inputs["inventory"].stocks if stock != missing), + ) + build = plan(request, **{**inputs, "inventory": inventory}, catalog=catalog(missing.design)) + assert not build.ready + assert build.acquisitions[0].candidates[0].form is MaterialForm.BACTERIAL_STAB + assert build.acquisitions[0].required_form is MaterialForm.DNA + assert build.acquisitions[0].volume_ul == 1 + + +def test_receipt_records_physical_arrival_without_claiming_sequence_verification(): + request, inputs = planning_case() + design = inputs["system"].recipes[0].fragments[0].component + person = Agent(identity=NS + "/person") + doc = Document.from_snapshot(inputs["document"]) + doc.add(person) + item = catalog(design).entries[0].item + order = OrderReference( + identity=NS + "/order", + acquisition=NS + "/acquisition", + item=item.identity, + reference="external-order-42", + placed_at=TIME, + ) + receipt = Receipt( + identity=NS + "/receipt", + order=order, + design=design, + form=MaterialForm.BACTERIAL_STAB, + received_at=TIME, + received_by=person.ref, + packages=1, + ) + recorded = receipt.record(doc.freeze()) + assert recorded.resolve(receipt.implementation).built is None + assert recorded.resolve(receipt.implementation).evidence_state is EvidenceState.RECORDED + counted = receipt.counted_stock(identity=NS + "/counted_stock", count=1) + Inventory(identity=NS + "/counted_inventory", stocks=(counted,)).validate(recorded) + assert counted.count == 1 and counted.implementation == receipt.implementation + with pytest.raises(ValueError, match="preparation"): + receipt.stock(identity=NS + "/stock", quantity=1 * uL) + inventory = replace( + inputs["inventory"], + stocks=tuple(stock for stock in inputs["inventory"].stocks if stock.design != design), + ) + build = plan( + request, **{**inputs, "document": recorded, "inventory": inventory}, receipts=(receipt,) + ) + assert any(item.kind is RequirementKind.PREPARATION for item in build.requirements) + assert not build.acquisitions + + liquid = replace(receipt, identity=NS + "/liquid_receipt", form=MaterialForm.DNA) + stock = liquid.stock(identity=NS + "/received_stock", quantity=10 * uL) + Inventory(identity=NS + "/received_inventory", stocks=(stock,)).validate( + liquid.record(doc.freeze()) + ) + + +def test_addgene_is_preferred_without_automatic_sequence_selection(): + request, _ = planning_case() + entry = catalog(request.targets[0].design).entries[0] + alternative = replace( + entry, item=replace(entry.item, supplier="Example", identity=NS + "/supplier") + ) + snapshot = Catalog(entries=(alternative, entry)) + assert snapshot.candidates(entry.design) == (entry, alternative) diff --git a/uv.lock b/uv.lock index 7772733..b7d7c41 100644 --- a/uv.lock +++ b/uv.lock @@ -55,6 +55,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181 }, ] +[[package]] +name = "biopython" +version = "1.84" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/7f/eaca4de03f0ee06c9d578d2730fd55858a57cee3620c62d3bc17b5da5447/biopython-1.84.tar.gz", hash = "sha256:60fbe6f996e8a6866a42698c17e552127d99a9aab3259d6249fbaabd0e0cc7b4", size = 25793001 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/7b/c1e9f66e23b01958ae0284a437a0e586ce20387fc6ea0382c21230ac59bc/biopython-1.84-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2d4ed30aebd96b4aadeb1f04adce92795c696f5bd56d1fd45517b89059918dd4", size = 2754460 }, + { url = "https://files.pythonhosted.org/packages/5f/49/c9ffacca2e26259e28215e0ac599db21adf4070359d2aa9d006f3ecf1051/biopython-1.84-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c792508988fc3ccf18eaae2a826c9cd97f1c27fb55bb87bdce6a101fee9f5a0c", size = 2737878 }, + { url = "https://files.pythonhosted.org/packages/b4/d6/5aae16c1dd91284a40b769926cd69214ddbb986e710f6d44dbe1f6f20c34/biopython-1.84-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:507ac3956f3107e77fee362ecb048dafb5f97cbcf110012d091418430c3227c7", size = 3174779 }, + { url = "https://files.pythonhosted.org/packages/4c/3c/cecf231afa65e7194ac06ba981631a9870515bb7a37a15cad1ab414325c4/biopython-1.84-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:894ee7533cca7f5f9769e2595fbe7b0dba5018f39a2170753d101a13e7585ff4", size = 3192041 }, + { url = "https://files.pythonhosted.org/packages/0f/6c/3e8f01ddea31eeba4cddaa6dbb37b7978ea0164bd35b783fda9f5be59cc4/biopython-1.84-cp311-cp311-win32.whl", hash = "sha256:7f4c746825721ec367c2f2d6a8cda3bc6495a1e084e5b2fbab26e9467706603f", size = 2755506 }, + { url = "https://files.pythonhosted.org/packages/89/38/e45df36e10de29141ba0e5d94bfb942925e62cb6ad182e58b74fa5edcfcc/biopython-1.84-cp311-cp311-win_amd64.whl", hash = "sha256:2cb8e839ab472244b6082635ad1df67c94c05df0bd02a023103ed00ea66c4d20", size = 2792261 }, + { url = "https://files.pythonhosted.org/packages/f7/f6/a61af0d2c8c04e446bce4727e8124797132858f518b6d6543d0e7213abed/biopython-1.84-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ba58a6d76288333c5f178a426116953fa68204bd0cfc401694087dd4f96d0059", size = 2755863 }, + { url = "https://files.pythonhosted.org/packages/e9/1a/25c7df41987383070987f7b9842f48d3a33b0a78a85c2ca9d93ed810fa2a/biopython-1.84-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee3566f6dc3acf20e238540daf896f0af20cff531521bf41fdf5143f73e209ae", size = 2738072 }, + { url = "https://files.pythonhosted.org/packages/a2/b2/c7f2a0a151208c634ac1eaa5d6345899659b1d5a700a84ef2e4f2b0e80a9/biopython-1.84-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89ef3967f5a88b5bb6344bef75ae83386de53fed3966d5c8c334ad885f8db08a", size = 3186633 }, + { url = "https://files.pythonhosted.org/packages/46/37/7db2bcbb396edba3f767dd89ac23ef5adc35c7a92ef3912c06d1e71469e1/biopython-1.84-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61765b71f84814a1eeb55ab222f43330aa7ad3e55ab91e8b444706149c67a281", size = 3206061 }, + { url = "https://files.pythonhosted.org/packages/b2/12/6c9d73cbb8c9d19ab4187aaf187f967de6e83738947b7180fdd8bc9211a2/biopython-1.84-cp312-cp312-win32.whl", hash = "sha256:52b6098f47d6b90fc8a5e8579b81ee50047e9108f0976e69c891ae0c4817e42d", size = 2756622 }, + { url = "https://files.pythonhosted.org/packages/d1/53/91d12cc254a804c797afaefec91ede04bc1f7cbd788a04ebbea9e31ee0cf/biopython-1.84-cp312-cp312-win_amd64.whl", hash = "sha256:ecff2fcf5da29b600474c0bfcdbbac0f98b25e22fe60a853d0ee798c00f7396c", size = 2792652 }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -235,6 +258,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl", hash = "sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846", size = 27625 }, ] +[[package]] +name = "html5lib" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173 }, +] + [[package]] name = "id" version = "1.6.1" @@ -277,6 +313,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, ] +[[package]] +name = "isodate" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/7a/c0a56c7d56c7fa723988f122fa1f1ccf8c5c4ccc48efad0d214b49e5b1af/isodate-0.6.1.tar.gz", hash = "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9", size = 28443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/85/7882d311924cbcfc70b1890780763e36ff0b140c7e51c110fc59a532f087/isodate-0.6.1-py2.py3-none-any.whl", hash = "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96", size = 41722 }, +] + [[package]] name = "jaraco-classes" version = "3.4.0" @@ -357,8 +405,11 @@ wheels = [ name = "lab-compiler" source = { editable = "." } dependencies = [ + { name = "biopython" }, { 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" }, ] [package.optional-dependencies] @@ -381,9 +432,12 @@ dev = [ [package.metadata] requires-dist = [ + { name = "biopython", specifier = "==1.84" }, { 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"] @@ -588,6 +642,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/40/a6620ced846254d020e350cccdf19cd505628e8dca1b82b93cfcddb242f8/opentrons_shared_data-8.8.2-py3-none-any.whl", hash = "sha256:12d77eab3a41bfb4e5a947f7ca5062d40eb1fcffcf2a6d12a317af7ac249b6f3", size = 1169425 }, ] +[[package]] +name = "owlrl" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rdflib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/c7/208aece36279e4f1236e437119358786e39530ecc1719d4e1afeddba5288/owlrl-6.0.2.tar.gz", hash = "sha256:904e3310ff4df15101475776693d2427d1f8244ee9a6a9f9e13c3c57fae90b74", size = 57179 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/56/11fe63c2c317347f69be17e9ece1991e0ec6c2cdb8225c0baa5b96e283ed/owlrl-6.0.2-py3-none-any.whl", hash = "sha256:57eca06b221edbbc682376c8d42e2ddffc99f61e82c0da02e26735592f08bacc", size = 54516 }, +] + [[package]] name = "packaging" version = "26.3" @@ -660,6 +726,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] +[[package]] +name = "prettytable" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/74/ba08d81e668ccfe8658d7520a307e63c19862c08eb4ccb26f356c5239a7a/prettytable-3.18.0.tar.gz", hash = "sha256:439217116152244369caf3d9f1caf2f9fe29b03bd79e88d2928c8e718c95d680", size = 76373 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/be/2e6798ace5cc036f5d05d36b7b2fd85346f1a708c87060890b070d0ec607/prettytable-3.18.0-py3-none-any.whl", hash = "sha256:b3346e0e6f79180833aebaac088ae926340586cf6d7d991b9eb125b65f72313a", size = 37357 }, +] + [[package]] name = "pycparser" version = "3.0" @@ -777,6 +855,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/c0/4a1d8d6d6ec6d4fc1c1f25257c3a71be07b438698fb24eedf7c14bc14313/pylabrobot-0.2.1-py3-none-any.whl", hash = "sha256:8b7b58d1c31a7b19b9d6a11f10ba1303c6222b6a582a8a95f18c6abcf41408cd", size = 1174640 }, ] +[[package]] +name = "pyparsing" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420 }, +] + [[package]] name = "pyrsistent" version = "0.20.0" @@ -807,6 +894,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585 }, ] +[[package]] +name = "pyshacl" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "owlrl" }, + { name = "packaging" }, + { name = "prettytable" }, + { name = "rdflib", extra = ["html"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/55/370d2bae6eb2d6302caac1eba2c1ad365fbb43c4d4805d00367839b3089c/pyshacl-0.28.1.tar.gz", hash = "sha256:c98e1927541f0cbeb0ba27f3fbe585d638281d6e63f314462dcc32282b3c8353", size = 1399231 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/17/57f5e48a856b70715b86b228ed4211fa0447e38c32eec6fd7c30239d5ea9/pyshacl-0.28.1-py3-none-any.whl", hash = "sha256:345fe96fb2f38218c53a0f977329ed0aa1a54256526bf6de62243ac7ae823d6b", size = 1321044 }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -836,6 +939,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930 }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + [[package]] name = "python-dotenv" version = "1.2.3" @@ -863,6 +978,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, ] +[[package]] +name = "rdflib" +version = "6.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "isodate" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/28/4d1f27c5d73f58e567ca1a14a4eab7d7978a09c4e117687f9f6c216d3366/rdflib-6.3.2.tar.gz", hash = "sha256:72af591ff704f4caacea7ecc0c5a9056b8553e0489dd4f35a9bc52dbd41522e0", size = 4749592 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/92/d7fb1d7fb70c9f7003fa50b7a3880ebcb311cc3f8552b3595e7c8f75aeeb/rdflib-6.3.2-py3-none-any.whl", hash = "sha256:36b4e74a32aa1e4fa7b8719876fb192f19ecd45ff932ea5ebbd2e417a0247e63", size = 528122 }, +] + +[package.optional-dependencies] +html = [ + { name = "html5lib" }, +] + [[package]] name = "readme-renderer" version = "46.0" @@ -951,6 +1084,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316 }, ] +[[package]] +name = "sbol3" +version = "1.2.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyshacl" }, + { name = "python-dateutil" }, + { name = "rdflib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/b3/24fc213e588cb2bfc0ec2f99b1ec8366bdbe42ecb096528cb2cacad5eb5e/sbol3-1.2.0.post0.tar.gz", hash = "sha256:e0ddeaf44a29495da043287667275a663629862820acf5456bc71899c940dd8b", size = 86027 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/43/6b787610144ba23da2577549d3be0e74351767c84d514eb34232f473124a/sbol3-1.2.0.post0-py3-none-any.whl", hash = "sha256:742188cacff5829816c61a2f0e82c15357c01821362f66e863530b0fac1b442f", size = 73510 }, +] + [[package]] name = "secretstorage" version = "3.5.0" @@ -964,6 +1111,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554 }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + [[package]] name = "twine" version = "6.2.0" @@ -1014,6 +1170,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717 }, ] +[[package]] +name = "wcwidth" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/ac/3a943d2792c9bb368aaa8b50121c0f778460ba2d7fbdc0a0366201d9e761/wcwidth-0.9.1.tar.gz", hash = "sha256:5823209b0d43af322ce698c689380d7c15ca31fa8e6e3be8459f27031bef0af5", size = 952410 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/7c/130de33c0a7f6efecee28fef5711fc1f8d55ac33885a6b85f4f6689004ba/wcwidth-0.9.1-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:10b00ba23482e352f874d2e8135e7ace9da838646c7dd800566246bbd46125ff", size = 604219 }, + { url = "https://files.pythonhosted.org/packages/33/7c/c7bb03da54de7ac5a08753e186b963f91847b04ed8ae5be136a4d5f52b75/wcwidth-0.9.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:40d936d72c9bdc10df43f93a8be502bc5024b487259139f66a328722c07f34a9", size = 609038 }, + { url = "https://files.pythonhosted.org/packages/0b/71/f57effdf895c2d6d49333f1d6833a9f5438725c55a04ed3124a1c95bb3aa/wcwidth-0.9.1-cp310-abi3-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:69bb970cf5652b88cfdb9d3fdd1764fc15e5f8ad531643e7bb3e894cd969740e", size = 761464 }, + { url = "https://files.pythonhosted.org/packages/4f/16/15b03d8fb7a747d0d2cf4a33da7b1b3e092493c58780f6589d5b1e9e3b49/wcwidth-0.9.1-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b5da43d6967668982e44a52fb551967d293f86d26cd86036ac95bbdd34394ed9", size = 770483 }, + { url = "https://files.pythonhosted.org/packages/e2/a0/834886b30e3b885a5a96e8358f1b3917bc0bac43ae4178d77f0c210d079b/wcwidth-0.9.1-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:747fb724223f417a17541a95a17c1dac3a8ef9a0cf41684950f0eab191a35f65", size = 768443 }, + { url = "https://files.pythonhosted.org/packages/a2/ae/1b7597e0132ebc2c920632f39d8c1c61881345c9b480c0a95eb91e7460fa/wcwidth-0.9.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6e1272b7986cefe79783737e38bdb9eaae0682b333c7bd132024441193dd5ce7", size = 781171 }, + { url = "https://files.pythonhosted.org/packages/fb/bb/32149e3b481953f4ef35f38f166e2532886d33ae54ccdd71393f821dfad9/wcwidth-0.9.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:fe021c4d8de9d36c31a0cb41d0d2546dadd3b0708a301f1a0c66ce200851831f", size = 781576 }, + { url = "https://files.pythonhosted.org/packages/d6/72/1ee3dff67697bda93bc56ec9f7b3b89609cd64b9abb328512a2f22eb9e47/wcwidth-0.9.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:708158c082364af442f9983de7b6ec9ac0d2e1b825ada25f0911b1f138d55405", size = 783526 }, + { url = "https://files.pythonhosted.org/packages/ce/0a/e94f19a60f6127bcf3dfcf223ca05a37650d57b37b5ed623597c842ce7ca/wcwidth-0.9.1-cp310-abi3-win32.whl", hash = "sha256:356376852357b8fca71fe5415808ec421679e04b4a98eb7c9cb6a7984b911a05", size = 592136 }, + { url = "https://files.pythonhosted.org/packages/3c/ff/d884d2ec7dcdc86cda742a86c7160bc726dbccae76d7ae1f9ac833c26680/wcwidth-0.9.1-cp310-abi3-win_amd64.whl", hash = "sha256:991d1c8834f548e9c1f16432075ee84638e122312556bbf1ed595ea8fffc4673", size = 596738 }, + { url = "https://files.pythonhosted.org/packages/fa/93/16e30d617b937272a2310a6e3c1f9d8f94984e19ad4a96eb935628e3e6b6/wcwidth-0.9.1-cp310-abi3-win_arm64.whl", hash = "sha256:61bd7aef9cafb6cb77a37a169998d7928ce82a51522146d11f60db9e7d1cb43a", size = 594700 }, + { url = "https://files.pythonhosted.org/packages/96/a2/f06f2e0be4895e5943d358755b51968ace85774cf0a4cc42b4dafb793832/wcwidth-0.9.1-py3-none-any.whl", hash = "sha256:e0c3a1c45c5b9550c6919a4449e95f5b177f6e165786376981db8f1addae9b21", size = 300656 }, +] + +[[package]] +name = "webencodings" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/a0/8fd707bcb776a7be556bad06a2ea5fb9bd519df78ef8e26f70ccf0f38bff/webencodings-0.6.1.tar.gz", hash = "sha256:565f9ad031c702dae404e27a099e3e09186a3ab1b9520f06d215502b651fd910", size = 15001 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl", hash = "sha256:7fab6269c8bf237c657876b52058ccb182e861518d1c695c1a9aaa8c1c105d5b", size = 8745 }, +] + [[package]] name = "websockets" version = "17.1"