From 35cf75f6744500f1d87912616d67c50e55d8b69c Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Sat, 31 Oct 2026 11:22:28 +0000 Subject: [PATCH 1/7] Implement an LMSF-inspired concentration series --- .gitignore | 1 + README.md | 16 + docs/configuration.md | 47 +++ docs/liquid-classes.md | 87 ++++ examples/concentration_series/__init__.py | 1 + examples/concentration_series/__main__.py | 62 +++ examples/concentration_series/config.toml | 155 +++++++ examples/concentration_series/inputs.py | 257 +++++++++++ examples/concentration_series/protocol.py | 131 ++++++ pyproject.toml | 4 +- scripts/check_install.py | 4 +- src/lab/__init__.py | 3 + src/lab/config.py | 109 +++++ src/lab/documents.py | 17 +- src/lab/liquid_classes.py | 64 +++ src/lab/model.py | 24 +- src/lab/protocol.py | 41 +- src/lab/targets/opentrons.py | 17 + src/lab/targets/star.py | 60 ++- src/lab/units.py | 57 +++ tests/test_concentration_series.py | 492 ++++++++++++++++++++++ tests/test_config.py | 104 +++++ tests/test_liquid_classes.py | 204 +++++++++ tests/test_lmsf_layouts.py | 144 +++++++ tests/test_units.py | 124 ++++++ uv.lock | 8 +- 26 files changed, 2212 insertions(+), 21 deletions(-) create mode 100644 docs/configuration.md create mode 100644 docs/liquid-classes.md create mode 100644 examples/concentration_series/__init__.py create mode 100644 examples/concentration_series/__main__.py create mode 100644 examples/concentration_series/config.toml create mode 100644 examples/concentration_series/inputs.py create mode 100644 examples/concentration_series/protocol.py create mode 100644 src/lab/config.py create mode 100644 src/lab/liquid_classes.py create mode 100644 tests/test_concentration_series.py create mode 100644 tests/test_config.py create mode 100644 tests/test_liquid_classes.py create mode 100644 tests/test_lmsf_layouts.py create mode 100644 tests/test_units.py diff --git a/.gitignore b/.gitignore index 98ea52f..0d31297 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store .venv/ __pycache__/ *.py[cod] diff --git a/README.md b/README.md index b332339..125a687 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,22 @@ The cloning example writes separate `assembly`, `transformation`, and `plating` The manifest uses logical container and well names; physical bindings remain in `plan.json`. Rewriting an identical bundle succeeds. If any generated file would replace different contents, `write()` rejects the write before changing any bundle files. +## Concentration series + +The standalone [concentration-series example](examples/concentration_series/) reads an LMSF fitness CSV and [protocol configuration](examples/concentration_series/config.toml), then records ordinary transfers and mixes. The supplied configuration selects nine July conditions. `lab.units.dilution_volume()` calculates stock volumes; the recipe balances vehicle and medium, inoculates, and mixes. Source identities and volumes, working-stock selection, and liquid classes are explicit. The TOML also declares development OT-2 and STAR deck layouts, parsed directly into Lab's existing `Deck` types. + +`main()` parses the TOML into `ProtocolConfig` and the CSV into `PlateLayout` using Pydantic. The reusable quantity fields and liquid-class schemas come from [`lab.config`](docs/configuration.md), available through `lab-compiler[config]` and included in development dependencies (`uv sync --locked`). `prepare_wells()` checks the inputs together and calculates doses; `protocol()` consumes those prepared wells and the validated configuration to record operations. The configuration mirrors the TOML: `medium.name` is `config.medium.name`, and liquid-class names remain names until protocol construction resolves them. Saved configuration comes from that same model. + +```sh +uv run --no-sync python -m examples.concentration_series \ + --csv docs/lmsf/assay-options/2026-07-23_Mar-Pro_OD-test-5-plates_growth-plate_5.csv \ + --config examples/concentration_series/config.toml --target manual +``` + +Use `--target ot2` or `--target star` with the corresponding optional SDK installed; `--out` selects the bundle directory. The [LMSF component analysis](docs/lmsf/components.md) explains the inputs and development hardware. The example ends with an inoculated plate; incubation and measurement need separately specified methods. + +`LiquidClass` supplies unit-checked aspiration and dispense rates on either robot family. `HamiltonLiquidClass` references a native STAR built-in, whose definition is captured during compilation. Both can be passed as `liquid_class=` to `transfer()`, `distribute()`, and `mix()`. See [liquid classes](docs/liquid-classes.md) for examples, settings coverage, calibration boundaries, and the data needed to compare with VENUS. + ## Development From the repository root: diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..2549aa9 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,47 @@ +# Typed protocol configuration + +`lab.config` supplies Pydantic fields and models for application input files. Install `lab-compiler[config]` to use it; repository development dependencies also include Pydantic. Importing `lab` and using `Protocol` do not require Pydantic. + +Define the application's schema using ordinary Pydantic fields and validators. Lab's quantity fields handle parsing, dimensional checks, Decimal normalization, JSON serialization, and JSON Schema: + +```python +from lab.config import ConfigModel, Concentration, NonnegativeVolume, Volume + + +class ProtocolConfig(ConfigModel): + final_volume: Volume + source_dead_volume: NonnegativeVolume + stock_concentration: Concentration + + +config = ProtocolConfig.model_validate( + { + "final_volume": "0.2 mL", + "source_dead_volume": "20 uL", + "stock_concentration": "1 mM", + } +) +print(config.final_volume) # 2E+2 microliter +print(config.model_dump_json()) +``` + +| Field type | Accepted values and Python representation | +| --- | --- | +| `Volume` | Positive finite quantity, normalized to microlitres | +| `NonnegativeVolume` | As above, allowing zero; useful for dead volumes | +| `FlowRate` | Positive finite quantity, normalized to microlitres/second | +| `Concentration` | Nonnegative finite quantity, normalized to mole/litre or gram/litre | +| `ConcentrationUnit` | A separate mass/volume or amount/volume unit; retains its original scale for CSV numeric columns | +| `VolumeFraction` | Finite Decimal from zero through one | +| `NonnegativeDecimal` | Finite Decimal greater than or equal to zero | +| `NonemptyString` | String containing at least one non-whitespace character | + +Quantity fields accept unit-bearing strings or Pint `Quantity` objects. They reject bare numbers and incompatible dimensions. Mass concentration cannot be converted to molar concentration without additional information. Quantities serialize as unit-bearing strings and appear as strings in JSON Schema; the validation runs when parsing, not in JSON Schema itself. + +`ConfigModel` rejects unknown fields, allows these Pint types, and freezes attribute assignment. Its contained dictionaries are still mutable, following Pydantic's usual behavior. Parse the files once before building a protocol. Apply application-specific checks, such as stock coverage and composition balance, before recording operations; the compiler subsequently checks inventory, capacity, and equipment constraints. + +`LiquidClassConfig` is a discriminated union of `PortableLiquidClassConfig` (`kind = "portable"`, aspiration/dispense rates) and `HamiltonLiquidClassConfig` (`kind = "hamilton"`, native symbol). `MixingConfig` supplies a volume, positive integer cycle count, and liquid-class name. Names remain references within the application's configuration; the application checks them against its registry and explicitly constructs Lab liquid classes. See [liquid classes](liquid-classes.md). + +Existing Lab dataclasses can also be fields. For example, `deck: lab.deck.Deck` parses nested containers and layouts into typed Lab objects, including enums, locations, and Decimal limits. Pydantic applies their existing dataclass validation. The [concentration-series configuration](../examples/concentration_series/config.toml) demonstrates this without a separate deck parser or schema. + +For applications that do not use Pydantic, `lab.units.parse_quantity(value, unit="microliter")` provides the same normalization. `unit` may also be a tuple of alternatives for different dimensions, such as `("mole/liter", "gram/liter")`. The first compatible unit is used. `positive=False` permits zero; negative or nonfinite values always fail. This input parser does not change the `Protocol` API: operations take quantity objects. diff --git a/docs/liquid-classes.md b/docs/liquid-classes.md new file mode 100644 index 0000000..c81812d --- /dev/null +++ b/docs/liquid-classes.md @@ -0,0 +1,87 @@ +# Liquid classes + +A liquid class supplies pipetting settings for a particular operation. Attach it to `Protocol.transfer()`, `distribute()`, or `mix()`. It is separate from the material identity and concentration: two transfers of the same material can need different settings because their tips, volumes, or destinations differ. + +## Portable custom settings + +```python +from lab import LiquidClass, Protocol, seconds, uL + +gentle = LiquidClass( + "gentle culture", + aspiration_flow_rate=20 * uL / seconds, + dispense_flow_rate=30 * uL / seconds, +) +p = Protocol("Culture aliquot") +source = p.container("source", contents="culture", volume=200 * uL, capacity=360 * uL) +destination = p.container("destination", capacity=360 * uL) +p.transfer(source, destination, volume=50 * uL, liquid_class=gentle) +p.mix(destination, volume=40 * uL, cycles=3, liquid_class=gentle) +``` + +`LiquidClass` currently expresses **aspiration and dispense flow rates**. Both are required, finite, positive quantities with volume/time dimensions. Construction converts them to immutable Decimal µL/s values. A name describes the settings; it does not identify a calibrated fluid in a robot's catalogue. + +| Target | Application | +| --- | --- | +| Manual | Print the name and both rates beside the operation | +| OT-2 and Flex | Set the selected pipette's absolute aspiration/dispense rates for the operation, then restore its previous rates | +| STAR | Pass absolute `flow_rates` to each PyLabRobot aspiration and dispense | + +Mixing uses these same rates for each cycle. Distribution applies them to the target's aspirations and dispenses; distribution batching remains target-specific. With `liquid_class=None`, the operation uses the existing target defaults. + +This portable class is deliberately a small settings profile. It does **not** specify a volume-correction curve, air gap, blowout, liquid-level detection, heights, surface following, settling times, or dispensing mode. Those retain the target/SDK defaults. In particular, changing rates or naming a class `dmso` does not select a Hamilton DMSO calibration: the generated STAR program does not populate PyLabRobot liquid-identity trackers, so automatic class selection uses the SDK's default liquid. Use an explicit Hamilton reference when the native calibration is required. + +The common meaning of µL/s supports software portability. Equivalent physical delivery still depends on the device, tips, liquid, volume, and method. Add further portable fields when their meaning and lowering can be demonstrated on the relevant targets; keep device-specific calibration separate. + +## Hamilton built-ins + +PyLabRobot provides a catalogue imported from VENUS, and its STAR backend accepts native classes on both aspiration and dispense. A native definition contains more than rates, including a volume-correction curve. See the [PyLabRobot liquid-class guide](https://docs.pylabrobot.org/stable/user_guide/00_liquid-handling/hamilton-star/hamilton-liquid-classes.html). + +Reference an exact symbol through Lab's SDK-independent type: + +```python +from lab import HamiltonLiquidClass + +water = HamiltonLiquidClass("HighVolumeFilter_Water_DispenseSurface_Part") +p.transfer(source, destination, volume=50 * uL, liquid_class=water) +``` + +The symbol above is an illustration of lookup, not a recommendation for the concentration example's 300 µL tips. Select a definition validated for the intended liquid, tip/filter, channel/head, volume range, and dispense mode. Lab does not infer this compatibility from the symbol's spelling. + +With `lab-compiler[star]` installed, discover the available symbols without connecting to hardware: + +```python +from lab.targets.star import hamilton_liquid_class_names + +names = hamilton_liquid_class_names() +print([name for name in names if "DMSO" in name]) +``` + +STAR compilation checks that the symbol resolves to a native liquid-class object. It snapshots the definition into `plan.json` and the generated program, alongside the required PyLabRobot version. The program reconstructs that captured definition and passes it to both operations. Unknown symbols fail compilation. OT-2 and Flex reject Hamilton references; manual compilation can document a reference without installing PyLabRobot but cannot resolve it. + +The native backend performs volume correction. Lab records requested liquid volumes, so it does not pre-correct them or apply the curve a second time. Its inventory calculation and channel/tip checks concern nominal volumes; they are not a physical calibration or an exhaustive check of corrected piston movements and transport air. A native class on `mix()` applies to repeated ordinary aspirations and dispenses, not a separate Hamilton firmware mixing command. + +Capturing the definition does not imply that every field is used by the pinned backend. PyLabRobot's method-level defaults and implementation determine effective behavior, including which class fields are consumed. Native built-ins are supported here; importing an arbitrary local VENUS class or creating a full native Hamilton definition through Lab is not yet implemented. For a custom class beyond portable rates, first obtain its actual parameter table and method usage, then add a typed native representation and trace tests for the fields required. + +## CSV preparation + +The standalone [concentration-series example](../examples/concentration_series/) reads its class registry from the [protocol configuration TOML](../examples/concentration_series/config.toml) supplied with `--config`. Each stock, the medium, the balancing vehicle, and each culture names a class. Source and destination mixing name their classes separately: the final mixture is not interchangeable with its constituent stocks. + +`ProtocolConfig` is a Pydantic schema with the same nesting and field names as the TOML, validated once in `main()` before protocol construction. Its liquid-class and mixing schemas come from the optional [`lab.config`](configuration.md) module. `[liquid_classes.aqueous]` becomes `config.liquid_classes["aqueous"]`, a typed configuration entry. `config.medium.liquid_class` remains the string `"aqueous"`; the schema checks that this name exists. A Hamilton entry's TOML key is its local alias, and its `name` is the native STAR catalogue symbol. + +At the start of `protocol()`, a dictionary comprehension converts the entries to Lab's `LiquidClass` or `HamiltonLiquidClass` objects. Operations look them up explicitly, for example `liquid_class=liquid_classes[config.medium.liquid_class]`. Configuration loading does not substitute objects for names. The plan records `config.model_dump_json()` from the validated model used by the recipe. + +```toml +[liquid_classes.culture] +kind = "portable" +aspiration_flow_rate = "20 uL/s" +dispense_flow_rate = "20 uL/s" +``` + +For STAR, a class table can instead contain `kind = "hamilton"` and `name = ""`. The example numbers are development inputs. They are not measured performance data or reconstructed LMSF settings. + +## Establishing LMSF equivalence + +Ask for one matched run containing the destination CSV, source setup, resolved LMSF parameters, complete VENUS method export and dependencies, native liquid-class definitions, and a simulation trace plus a successful instrument trace. For each pipetting step, identify the selected class and any method overrides: tip/filter and channel/head, aspiration/dispense modes, LLD and heights, mixing, air/blowout, settling, and correction settings. Record VENUS, firmware, and instrument configuration versions. + +Compare the generated sequence and effective settings with that run before measuring delivery accuracy and precision over the actual volume range. A matching class name, nominal volume ledger, or software trace alone does not demonstrate the same physical result. These artifacts also establish whether direct PyLabRobot execution is suitable or whether LMSF needs to retain VENUS as its execution layer. diff --git a/examples/concentration_series/__init__.py b/examples/concentration_series/__init__.py new file mode 100644 index 0000000..66b03c0 --- /dev/null +++ b/examples/concentration_series/__init__.py @@ -0,0 +1 @@ +"""Prepare an LMSF concentration series: python -m examples.concentration_series.""" diff --git a/examples/concentration_series/__main__.py b/examples/concentration_series/__main__.py new file mode 100644 index 0000000..b20a278 --- /dev/null +++ b/examples/concentration_series/__main__.py @@ -0,0 +1,62 @@ +"""Prepare selected LMSF fitness CSV wells using explicit configuration. + +Run with --csv and --config (see config.toml). Stocks are supplied already +prepared. The example ends with an inoculated culture plate. +""" + +import argparse +import csv +import hashlib +import tomllib +from io import StringIO +from pathlib import Path + +from examples.concentration_series.inputs import ( + Condition, + PlateLayout, + ProtocolConfig, + prepare_wells, +) +from examples.concentration_series.protocol import protocol +from lab import compile +from lab.equipment import LiquidHandler + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--csv", required=True, type=Path, help="LMSF fitness destination CSV") + parser.add_argument("--config", required=True, type=Path, help="Protocol configuration TOML") + parser.add_argument("--target", choices=("manual", "ot2", "star"), default="manual") + parser.add_argument("--out") + args = parser.parse_args() + try: + with args.config.open("rb") as stream: + config = ProtocolConfig.model_validate(tomllib.load(stream)) + csv_bytes = args.csv.read_bytes() + reader = csv.DictReader(StringIO(csv_bytes.decode("utf-8-sig"), newline="")) + headers = {field.alias or name for name, field in Condition.model_fields.items()} + if set(reader.fieldnames or ()) != headers or len(reader.fieldnames or ()) != len(headers): + raise ValueError( + "Expected the LMSF fitness CSV schema; cytometry needs a separate recipe" + ) + layout = PlateLayout.model_validate({"conditions": list(reader)}) + prepared_wells = prepare_wells(layout, config) + p = protocol(prepared_wells, config) + p.description = ( + f"Targets from {args.csv.name} (SHA256 {hashlib.sha256(csv_bytes).hexdigest()}). " + + p.description + ) + handler = None if args.target == "manual" else LiquidHandler(args.target) + bundle = compile( + p, + deck=config.deck if handler else None, + liquid_handler=handler, + to=None, + ) + print(bundle.write(args.out or f"build/concentration_series/csv/{args.target}")) + except (ValueError, TypeError, KeyError, OSError) as exc: + parser.error(str(exc)) + + +if __name__ == "__main__": + main() diff --git a/examples/concentration_series/config.toml b/examples/concentration_series/config.toml new file mode 100644 index 0000000..dec4f5c --- /dev/null +++ b/examples/concentration_series/config.toml @@ -0,0 +1,155 @@ +# Illustrative preparation inputs, NOT recovered or validated LMSF method settings. +# Use with the July fitness CSV. The deck tables below declare development +# equipment, capacities, tips, positions, and channel limits. +# ProtocolConfig mirrors these tables and fields. Quantities include units; +# liquid_class values name entries in the liquid_classes table below. +wells = ["A1", "A5", "A6", "A7", "A8", "A9", "A10", "A11", "A12"] +final_volume = "200 uL" # includes inoculum and every stock addition +inoculum_volume = "10 uL" +vehicle_fraction = "0.02" # DMSO v/v; balancing source is pure DMSO +source_dead_volume = "20 uL" +# This recipe handles independent single-solute stocks, not premixes or passage. +# Assert known absence of every CSV solute and DMSO from medium AND all cultures. +solute_and_vehicle_free_medium_and_cultures = true + +# Portable classes specify these two rates only. Other robot settings and +# calibration retain SDK defaults; the numbers below need instrument validation. +[liquid_classes.aqueous] +kind = "portable" +aspiration_flow_rate = "20 uL/s" +dispense_flow_rate = "30 uL/s" + +[liquid_classes.dmso] +kind = "portable" +aspiration_flow_rate = "5 uL/s" +dispense_flow_rate = "10 uL/s" + +[liquid_classes.culture] +kind = "portable" +aspiration_flow_rate = "20 uL/s" +dispense_flow_rate = "20 uL/s" + +# To use an installed PyLabRobot STAR built-in, replace a class table with: +# [liquid_classes.aqueous] +# kind = "hamilton" +# name = "" +# Match liquid, tip, volume range, and dispensing mode; OT-2 rejects native classes. + +[medium] +name = "Base medium (development input)" +well = "A1" # reservoir A1; all other sources are on reagents +volume = "3000 uL" +dead_volume = "500 uL" +liquid_class = "aqueous" + +[vehicle] +name = "DMSO" +well = "A5" +volume = "100 uL" +liquid_class = "dmso" + +[source_mix] +volume = "50 uL" +cycles = 3 +liquid_class = "culture" + +[destination_mix] +volume = "100 uL" +cycles = 3 +liquid_class = "culture" + +# Stocks are supplied already prepared. Select the smallest max_target covering +# the requested concentration. Bounds use final concentration, not stock dose. +# Each stock supplies only its named solute; vehicle_fraction is DMSO v/v. +[[stocks]] +solute = "Van" +well = "A1" +volume = "300 uL" +concentration = "100 umol/L" +max_target = "25 umol/L" +vehicle_fraction = "0" +liquid_class = "aqueous" + +[[stocks]] +solute = "Van" +well = "A2" +volume = "200 uL" +concentration = "1000 umol/L" +max_target = "200 umol/L" +vehicle_fraction = "0" +liquid_class = "aqueous" + +[[stocks]] +solute = "Sal" +well = "A3" +volume = "300 uL" +concentration = "100 umol/L" +max_target = "100 umol/L" +vehicle_fraction = "0" +liquid_class = "aqueous" + +[[stocks]] +solute = "TMP" +well = "A4" +volume = "100 uL" +concentration = "300 ug/mL" +max_target = "3 ug/mL" +vehicle_fraction = "1" +liquid_class = "dmso" + +[[cultures]] +strain = "Mar-Clo" +plasmid = "pDRAC18-2-A" +well = "B1" +volume = "300 uL" +liquid_class = "culture" + +# Pydantic parses these tables into the existing lab.deck.Deck dataclasses. +# Adapt models and locations to installed equipment before physical use. +[[deck.containers]] +id = "reagents" +labware = { kind = "plate", rows = 8, columns = 12, capacity_ul = "360" } + +[[deck.containers]] +id = "cultures" +labware = { kind = "plate", rows = 8, columns = 12, capacity_ul = "360" } + +[[deck.containers]] +id = "medium" +labware = { kind = "reservoir", rows = 1, columns = 1, capacity_ul = "15000" } + +[[deck.layouts]] +liquid_handler = "ot2" +placements = [ + { container = "reagents", model = "corning_96_360_ul", location = { name = "1" } }, + { container = "cultures", model = "corning_96_360_ul", location = { name = "2" } }, + { container = "medium", model = "nest_12_reservoir_15_ml", location = { name = "3" }, wells = ["A1"] }, +] +tip_racks = [ + { id = "p300_tips", model = "opentrons_300_ul", location = { name = "5" } }, + { id = "p20_tips", model = "opentrons_20_ul", location = { name = "6" } }, +] +pipettes = [ + { model = "p300_single_gen2", mount = "left", tip_racks = ["p300_tips"] }, + { model = "p20_single_gen2", mount = "right", tip_racks = ["p20_tips"] }, +] + +[[deck.layouts]] +liquid_handler = "star" +placements = [ + { container = "reagents", model = "corning_96_360_ul", location = { holder = "plates", index = 0 } }, + { container = "cultures", model = "corning_96_360_ul", location = { holder = "plates", index = 1 } }, + { container = "medium", model = "nest_12_reservoir_15_ml", location = { holder = "plates", index = 2 }, wells = ["A1"] }, +] +carriers = [ + { id = "plates", model = "hamilton_plate_5", location = { index = 15 } }, + { id = "tips", model = "hamilton_tip_5", location = { index = 3 } }, +] +tip_racks = [ + { id = "star_tips", model = "hamilton_300_ul", location = { holder = "tips", index = 0 } }, +] +# One channel and 300 uL tips. The 1 uL minimum is a software demonstration +# limit, not a validated physical operating range. +channels = [ + { index = 0, min_volume_ul = "1", max_volume_ul = "300", tip_racks = ["star_tips"] }, +] diff --git a/examples/concentration_series/inputs.py b/examples/concentration_series/inputs.py new file mode 100644 index 0000000..b7b2d6a --- /dev/null +++ b/examples/concentration_series/inputs.py @@ -0,0 +1,257 @@ +"""LMSF fitness CSV and protocol configuration models, followed by dose calculation. + +The models mirror the CSV columns and config.toml. prepare_wells() checks the +inputs together and resolves sources and doses before protocol construction. +""" + +from dataclasses import dataclass +from typing import Annotated, Literal, Self + +from pydantic import Field, field_validator, model_validator + +from lab import Quantity, uL +from lab.config import ( + Concentration, + ConcentrationUnit, + ConfigModel, + LiquidClassConfig, + MixingConfig, + NonemptyString, + NonnegativeDecimal, + NonnegativeVolume, + Volume, + VolumeFraction, +) +from lab.deck import Deck +from lab.units import dilution_volume, magnitude + +WellName = Annotated[str, Field(strict=True, pattern=r"^[A-H](?:[1-9]|1[0-2])$")] + + +class SourceConfig(ConfigModel): + well: WellName + volume: Volume + liquid_class: NonemptyString # A key in ProtocolConfig.liquid_classes. + + +class NamedSourceConfig(SourceConfig): + name: NonemptyString + + +class MediumConfig(NamedSourceConfig): + well: Literal["A1"] # The development deck binds reservoir A1. + dead_volume: NonnegativeVolume + + +class StockConfig(SourceConfig): + solute: NonemptyString + concentration: Concentration + max_target: Concentration + vehicle_fraction: VolumeFraction + + @model_validator(mode="after") + def feasible_target(self) -> Self: + dilution_volume( + stock_concentration=self.concentration, + target_concentration=self.max_target, + final_volume=1 * uL, + ) + return self + + +class CultureConfig(SourceConfig): + strain: NonemptyString + plasmid: NonemptyString + + +class ProtocolConfig(ConfigModel): + """The preparation TOML schema; target concentrations apply after inoculation.""" + + wells: Annotated[tuple[WellName, ...], Field(min_length=1)] + final_volume: Volume + inoculum_volume: Volume + vehicle_fraction: VolumeFraction + source_dead_volume: NonnegativeVolume + solute_and_vehicle_free_medium_and_cultures: Annotated[bool, Field(strict=True)] + liquid_classes: dict[NonemptyString, LiquidClassConfig] + medium: MediumConfig + vehicle: NamedSourceConfig + stocks: tuple[StockConfig, ...] + cultures: tuple[CultureConfig, ...] + source_mix: MixingConfig + destination_mix: MixingConfig + deck: Deck + + @field_validator("solute_and_vehicle_free_medium_and_cultures") + @classmethod + def known_absence(cls, value: bool) -> bool: + if not value: + raise ValueError( + "This recipe requires solute- and vehicle-free medium and cultures; " + "carryover needs an explicit composition balance" + ) + return value + + @field_validator("wells") + @classmethod + def distinct_wells(cls, wells: tuple[str, ...]) -> tuple[str, ...]: + if len(set(wells)) != len(wells): + raise ValueError("Select wells without repeats") + return wells + + @model_validator(mode="after") + def compatible_inputs(self) -> Self: + if self.inoculum_volume >= self.final_volume: + raise ValueError("Inoculum volume must be less than final volume") + if len({(c.strain, c.plasmid) for c in self.cultures}) != len(self.cultures): + raise ValueError("Each strain/plasmid pair must have one explicit source") + # Validate working-stock bounds once, independently of the selected CSV rows. + for solute in {stock.solute for stock in self.stocks}: + targets = [stock.max_target for stock in self.stocks if stock.solute == solute] + limits = [ + magnitude(target, str(targets[0].units), positive=False) for target in targets + ] + if len(set(limits)) != len(limits): + raise ValueError(f"{solute}: stock target limits must be distinct") + sources: tuple[SourceConfig, ...] = (self.vehicle, *self.stocks, *self.cultures) + if len({source.well for source in sources}) != len(sources): + raise ValueError("A source well is assigned more than once") + uses: list[tuple[str, SourceConfig | MixingConfig]] = [ + ("medium", self.medium), + ("vehicle", self.vehicle), + ("source_mix", self.source_mix), + ("destination_mix", self.destination_mix), + ] + uses.extend((f"stocks[{i}]", stock) for i, stock in enumerate(self.stocks)) + uses.extend((f"cultures[{i}]", culture) for i, culture in enumerate(self.cultures)) + for path, source in uses: + if source.liquid_class not in self.liquid_classes: + raise ValueError(f"{path}.liquid_class: unknown class {source.liquid_class!r}") + return self + + +class Condition(ConfigModel): + """One fitness CSV record; aliases preserve the source column names.""" + + position: Annotated[int, Field(ge=1, le=96)] + well: WellName + row: Annotated[str, Field(pattern=r"^[A-H]$")] + column: Annotated[int, Field(ge=1, le=12)] + strain: NonemptyString + plasmid: NonemptyString + inducer1_id: NonemptyString = Field(alias="inducer1Id") + inducer1_concentration: NonnegativeDecimal = Field(alias="inducer1Concentration") + inducer1_units: ConcentrationUnit = Field(alias="inducer1Units") + inducer2_id: NonemptyString = Field(alias="inducer2Id") + inducer2_concentration: NonnegativeDecimal = Field(alias="inducer2Concentration") + inducer2_units: ConcentrationUnit = Field(alias="inducer2Units") + selector_id: NonemptyString = Field(alias="selectorId") + selector_concentration: NonnegativeDecimal = Field(alias="selectorConc") + selector_units: ConcentrationUnit = Field(alias="selectorUnits") + + @field_validator("strain", "plasmid", "inducer1_id", "inducer2_id", "selector_id") + @classmethod + def assigned_name(cls, value: str) -> str: + if value == "none": + raise ValueError("Expected a known culture or solute assignment") + return value + + @model_validator(mode="after") + def consistent_record(self) -> Self: + if ( + self.well != f"{self.row}{self.column}" + or self.position != 8 * (self.column - 1) + ord(self.row) - 64 + ): + raise ValueError("Inconsistent position/row/column/well") + if len({self.inducer1_id, self.inducer2_id, self.selector_id}) != 3: + raise ValueError("Expected distinct solutes") + return self + + +class PlateLayout(ConfigModel): + """Validated fitness CSV records, retaining each physical well occurrence.""" + + conditions: Annotated[tuple[Condition, ...], Field(min_length=1)] + + @model_validator(mode="after") + def distinct_wells(self) -> Self: + if len({condition.well for condition in self.conditions}) != len(self.conditions): + raise ValueError("Repeated CSV destination") + return self + + +@dataclass(frozen=True) +class PreparedWell: + """A CSV condition with its source and doses resolved before recording operations.""" + + well: str + culture: CultureConfig + concentrations: dict[str, Quantity] + stock_volumes: tuple[tuple[StockConfig, Quantity], ...] + vehicle_volume: Quantity + medium_volume: Quantity + + +def prepare_wells(layout: PlateLayout, config: ProtocolConfig) -> list[PreparedWell]: + """Check the two inputs together and calculate each well's additions once.""" + conditions = {condition.well: condition for condition in layout.conditions} + if missing := set(config.wells) - conditions.keys(): + raise ValueError(f"Selected wells absent from CSV: {sorted(missing)}") + cultures = {(culture.strain, culture.plasmid): culture for culture in config.cultures} + prepared_wells = [] + for well in config.wells: + condition = conditions[well] + culture = cultures.get((condition.strain, condition.plasmid)) + if culture is None: + raise ValueError(f"{well}: no source for {condition.strain} / {condition.plasmid}") + # The CSV model has already parsed the numbers and checked the units. + targets = { + condition.inducer1_id: condition.inducer1_concentration * condition.inducer1_units, + condition.inducer2_id: condition.inducer2_concentration * condition.inducer2_units, + condition.selector_id: condition.selector_concentration * condition.selector_units, + } + doses: list[tuple[StockConfig, Quantity]] = [] + for solute, target in targets.items(): + if target.magnitude == 0: + continue + candidates = sorted( + (stock for stock in config.stocks if stock.solute == solute), + key=lambda stock: magnitude(stock.max_target, str(target.units), positive=False), + ) + selected = next((stock for stock in candidates if target <= stock.max_target), None) + if selected is None: + raise ValueError(f"{well}: no stock covers {solute} target {target}") + doses.append( + ( + selected, + dilution_volume( + stock_concentration=selected.concentration, + target_concentration=target, + final_volume=config.final_volume, + ), + ) + ) + vehicle_volume: Quantity = config.vehicle_fraction * config.final_volume - sum( + (stock.vehicle_fraction * dose for stock, dose in doses), 0 * uL + ) + medium_volume: Quantity = ( + config.final_volume + - config.inoculum_volume + - vehicle_volume + - sum((dose for _, dose in doses), 0 * uL) + ) + if vehicle_volume.magnitude < 0 or medium_volume.magnitude < 0: + raise ValueError( + f"{well}: stock/vehicle/inoculum additions exceed the final composition" + ) + prepared_wells.append( + PreparedWell( + well=well, + culture=culture, + concentrations=targets, + stock_volumes=tuple(doses), + vehicle_volume=vehicle_volume, + medium_volume=medium_volume, + ) + ) + return prepared_wells diff --git a/examples/concentration_series/protocol.py b/examples/concentration_series/protocol.py new file mode 100644 index 0000000..0617af2 --- /dev/null +++ b/examples/concentration_series/protocol.py @@ -0,0 +1,131 @@ +"""Record preparation operations from validated, prepared inputs.""" + +from collections.abc import Sequence + +from examples.concentration_series.inputs import PreparedWell, ProtocolConfig +from lab import HamiltonLiquidClass, LiquidClass, Protocol, uL +from lab.config import PortableLiquidClassConfig +from lab.liquid_classes import LiquidClassSpec +from lab.samples import Sample + + +def protocol(prepared_wells: Sequence[PreparedWell], config: ProtocolConfig) -> Protocol: + """Prepare the selected wells with ordinary transfers and mixing.""" + + # Resolve named configuration entries into the Lab settings used below. + liquid_classes: dict[str, LiquidClassSpec] = { + name: LiquidClass( + name, + aspiration_flow_rate=spec.aspiration_flow_rate, + dispense_flow_rate=spec.dispense_flow_rate, + ) + if isinstance(spec, PortableLiquidClassConfig) + else HamiltonLiquidClass(spec.name) + for name, spec in config.liquid_classes.items() + } + p = Protocol( + "CSV culture preparation", + description="Concentrations refer to final volume after inoculation. " + f"Protocol configuration: {config.model_dump_json(indent=2)}", + ) + + # Declare the source plate for prepared stocks, vehicle, and inocula. + reagents = p.plate("reagents", capacity=360 * uL, dead_volume=config.source_dead_volume) + + # Declare the culture plate; each selected well holds one experimental condition. + destinations = p.plate("cultures", capacity=360 * uL) + + # Record the available base medium used to bring cultures to their final volume. + medium = p.container( + "medium", + contents=config.medium.name, + volume=config.medium.volume, + capacity=15000 * uL, + dead_volume=config.medium.dead_volume, + ) + + # Record the supplied vehicle used to balance the solvent fraction across conditions. + p.load(reagents[config.vehicle.well], config.vehicle.name, volume=config.vehicle.volume) + for stock in config.stocks: + + # Record each prepared solute stock, including its solvent contribution. + p.load( + reagents[stock.well], + f"{stock.solute}: {stock.concentration}; " + f"{config.vehicle.name} {stock.vehicle_fraction} v/v", + volume=stock.volume, + ) + for culture in config.cultures: + + # Record the supplied inoculum for this strain/plasmid pair. + p.load( + reagents[culture.well], f"{culture.strain} / {culture.plasmid}", volume=culture.volume + ) + + # Identify the parent culture so prepared wells retain their biological lineage. + p.add_sample( + Sample( + id=f"inoculum-{culture.well}", + material_identity=culture.plasmid, + label=f"{culture.strain} / {culture.plasmid}", + ), + at=reagents[culture.well], + is_input=True, + ) + + for prepared in prepared_wells: + well, culture = prepared.well, prepared.culture + destination = destinations[well] + additions = [ + (medium, prepared.medium_volume, config.medium.liquid_class), + (reagents[config.vehicle.well], prepared.vehicle_volume, config.vehicle.liquid_class), + ] + additions.extend( + (reagents[stock.well], dose, stock.liquid_class) + for stock, dose in prepared.stock_volumes + ) + for source, volume, liquid_class in additions: + if volume.magnitude: + + # Add medium, vehicle, and solute doses to meet targets after inoculation. + p.transfer( + source, destination, volume=volume, liquid_class=liquid_classes[liquid_class] + ) + + # Resuspend the source culture before withdrawing inoculum. + p.mix( + reagents[culture.well], + volume=config.source_mix.volume, + cycles=config.source_mix.cycles, + liquid_class=liquid_classes[config.source_mix.liquid_class], + ) + + # Inoculate this condition with the specified culture volume. + p.transfer( + reagents[culture.well], + destination, + volume=config.inoculum_volume, + liquid_class=liquid_classes[culture.liquid_class], + ) + + # Mix cells and reagents to homogenize the final culture. + p.mix( + destination, + volume=config.destination_mix.volume, + cycles=config.destination_mix.cycles, + liquid_class=liquid_classes[config.destination_mix.liquid_class], + ) + + # Record this well's condition and link it to the source inoculum. + p.add_sample( + Sample( + id=f"culture-{well}", + material_identity=culture.plasmid, + label=f"{well}: {culture.strain} / {culture.plasmid}; " + + ", ".join(f"{name} {value}" for name, value in prepared.concentrations.items()), + parent_ids=(f"inoculum-{culture.well}",), + ), + at=destination, + is_output=True, + ) + return p diff --git a/pyproject.toml b/pyproject.toml index b09f6a9..b8169fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,11 +32,13 @@ Repository = "https://github.com/MyersResearchGroup/LabCompiler" Issues = "https://github.com/MyersResearchGroup/LabCompiler/issues" [project.optional-dependencies] +config = ["pydantic>=2.13,<3"] opentrons = ["opentrons==8.8.2"] star = ["pylabrobot==0.2.1"] [dependency-groups] dev = [ + "pydantic>=2.13,<3", "pytest>=8,<10", "pytest-asyncio>=1,<2", "ruff>=0.12,<1", @@ -87,7 +89,7 @@ asyncio_mode = "auto" markers = ["integration: requires the optional robot SDKs; never connects to hardware"] [tool.mypy] -files = ["src/lab", "integrations/labop"] +files = ["src/lab", "integrations/labop", "examples/concentration_series"] check_untyped_defs = true disallow_untyped_defs = true diff --git a/scripts/check_install.py b/scripts/check_install.py index 2ce53e1..acc8e48 100644 --- a/scripts/check_install.py +++ b/scripts/check_install.py @@ -22,7 +22,7 @@ def main() -> None: 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 set(package.metadata.get_all("Provides-Extra", [])) == {"config", "opentrons", "star"} assert files("lab").joinpath("py.typed").is_file() assert any(str(path).endswith("licenses/LICENSE") for path in package.files or ()) assert lab.__file__ is not None @@ -45,7 +45,7 @@ def main() -> None: assert plan["compiler_version"] == package.version assert (output / "protocol.html").stat().st_size > 0 - isolated = {"opentrons", "pylabrobot", "labop", "uml", "sbol_factory"} + isolated = {"pydantic", "opentrons", "pylabrobot", "labop", "uml", "sbol_factory"} assert not any(name.split(".")[0] in isolated for name in sys.modules) assert not files("lab").joinpath("labop").is_dir() print(f"lab-compiler {package.version}: installed package check passed") diff --git a/src/lab/__init__.py b/src/lab/__init__.py index 938f022..37f5353 100644 --- a/src/lab/__init__.py +++ b/src/lab/__init__.py @@ -2,6 +2,7 @@ from lab._version import __version__ from lab.compiler import Compilation, compile +from lab.liquid_classes import HamiltonLiquidClass, LiquidClass 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,8 @@ __all__ = [ "Compilation", "CompileError", + "HamiltonLiquidClass", + "LiquidClass", "Plate", "Protocol", "Quantity", diff --git a/src/lab/config.py b/src/lab/config.py new file mode 100644 index 0000000..4255a30 --- /dev/null +++ b/src/lab/config.py @@ -0,0 +1,109 @@ +"""Optional Pydantic models and fields for protocol input files. + +Install ``lab-compiler[config]`` to use these types. Quantities become Decimal- +backed Pint objects in Python and unit-bearing strings in JSON. ConfigModel +rejects unknown fields and freezes attribute assignment. Protocol operations +continue to consume Lab objects; configuration models describe their inputs. +""" + +from decimal import Decimal +from functools import partial +from typing import Annotated, Literal + +from pint import PintError, Unit +from pydantic import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, + PlainSerializer, + WithJsonSchema, + field_validator, +) + +from lab.liquid_classes import HamiltonLiquidClass +from lab.units import Quantity, parse_quantity, units + +# All quantities are finite. Concentrations can be molar or mass/volume; +# conversion between those dimensions requires information this module cannot infer. +_CONCENTRATION_UNITS = ("mole/liter", "gram/liter") +_QuantityText = Annotated[ + Quantity, PlainSerializer(str, return_type=str), WithJsonSchema({"type": "string"}) +] +Volume = Annotated[_QuantityText, BeforeValidator(partial(parse_quantity, unit="microliter"))] +NonnegativeVolume = Annotated[ + _QuantityText, BeforeValidator(partial(parse_quantity, unit="microliter", positive=False)) +] +FlowRate = Annotated[ + _QuantityText, BeforeValidator(partial(parse_quantity, unit="microliter/second")) +] +Concentration = Annotated[ + _QuantityText, + BeforeValidator(partial(parse_quantity, unit=_CONCENTRATION_UNITS, positive=False)), +] +VolumeFraction = Annotated[Decimal, Field(ge=0, le=1, allow_inf_nan=False)] +NonnegativeDecimal = Annotated[Decimal, Field(ge=0, allow_inf_nan=False)] +NonemptyString = Annotated[str, Field(strict=True, min_length=1, pattern=r"\S")] + + +def _concentration_unit(value: str | Unit) -> Unit: + """Validate a separate units column without changing the numeric column's scale.""" + try: + unit = units.Unit(value) + parse_quantity(1 * unit, unit=_CONCENTRATION_UNITS) + return unit + except (PintError, TypeError) as exc: + raise ValueError(str(exc)) from exc + + +ConcentrationUnit = Annotated[ + Unit, + BeforeValidator(_concentration_unit), + PlainSerializer(str, return_type=str), + WithJsonSchema({"type": "string"}), +] + + +class ConfigModel(BaseModel): + """Base for explicit input schemas using Lab's quantity fields. + + Parse at the application boundary with model_validate(); serialize with + model_dump_json(). Nested models retain their fields and named references. + Like Pydantic's frozen models, contained dictionaries are not deeply frozen. + """ + + model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True, frozen=True) + + +class PortableLiquidClassConfig(ConfigModel): + """Portable aspiration/dispense rates, keyed by a name in the caller's config.""" + + kind: Literal["portable"] + aspiration_flow_rate: FlowRate + dispense_flow_rate: FlowRate + + +class HamiltonLiquidClassConfig(ConfigModel): + """Native STAR symbol; the installed catalogue is checked during compilation.""" + + kind: Literal["hamilton"] + name: NonemptyString + + @field_validator("name") + @classmethod + def public_symbol(cls, name: str) -> str: + HamiltonLiquidClass(name) + return name + + +LiquidClassConfig = Annotated[ + PortableLiquidClassConfig | HamiltonLiquidClassConfig, Field(discriminator="kind") +] + + +class MixingConfig(ConfigModel): + """Mixing volume, positive integer cycles, and a caller-defined liquid-class name.""" + + volume: Volume + cycles: Annotated[int, Field(strict=True, gt=0)] + liquid_class: NonemptyString diff --git a/src/lab/documents.py b/src/lab/documents.py index b4aceb1..1c60d14 100644 --- a/src/lab/documents.py +++ b/src/lab/documents.py @@ -3,6 +3,7 @@ from html import escape from typing import Any +from lab.liquid_classes import HamiltonLiquidClass, LiquidClass from lab.model import ( Distribute, ManualInstruction, @@ -17,15 +18,25 @@ def describe(step: Step) -> str: + liquid = "" + if isinstance(step, (Transfer, Distribute, Mix)): + if isinstance(step.liquid_class, LiquidClass): + spec = step.liquid_class + liquid = ( + f" Liquid class {spec.name}: aspirate {number(spec.aspiration_flow_rate_ul_s)} " + f"µL/s; dispense {number(spec.dispense_flow_rate_ul_s)} µL/s." + ) + elif isinstance(step.liquid_class, HamiltonLiquidClass): + liquid = f" Hamilton STAR liquid class {step.liquid_class.name}." match step: case Transfer(source, destination, volume, _): - return f"Transfer {number(volume)} µL from {source} to {destination}." + return f"Transfer {number(volume)} µL from {source} to {destination}.{liquid}" 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 "" - return f"Distribute {number(volume)} µL from {source} to {wells}.{gap}" + return f"Distribute {number(volume)} µL from {source} to {wells}.{gap}{liquid}" case Mix(location, volume, cycles, _): - return f"Mix {location}: {cycles} cycles of {number(volume)} µL." + return f"Mix {location}: {cycles} cycles of {number(volume)} µL.{liquid}" case Wait(seconds, _): unit = "second" if seconds == 1 else "seconds" return f"Wait {number(seconds)} {unit}." diff --git a/src/lab/liquid_classes.py b/src/lab/liquid_classes.py new file mode 100644 index 0000000..3ea045f --- /dev/null +++ b/src/lab/liquid_classes.py @@ -0,0 +1,64 @@ +"""Liquid-handling settings recorded with operations, independent of robot SDKs.""" + +from dataclasses import dataclass +from decimal import Decimal +from typing import TypeAlias + +from lab.units import Quantity, magnitude + + +@dataclass(frozen=True, init=False) +class LiquidClass: + """A named, portable pair of aspiration and dispense flow rates. + + Supply volume/time quantities; the immutable record stores Decimal µL/s. + These rates apply to every aspiration/dispense, including mixing cycles. + Other settings and volume calibration retain the target's defaults. This + class does not assert equivalent physical performance across instruments. + """ + + name: str + aspiration_flow_rate_ul_s: Decimal + dispense_flow_rate_ul_s: Decimal + + def __init__( + self, name: str, *, aspiration_flow_rate: Quantity, dispense_flow_rate: Quantity + ) -> None: + _validate_name(name) + object.__setattr__(self, "name", name) + object.__setattr__( + self, "aspiration_flow_rate_ul_s", magnitude(aspiration_flow_rate, "microliter/second") + ) + object.__setattr__( + self, "dispense_flow_rate_ul_s", magnitude(dispense_flow_rate, "microliter/second") + ) + + +@dataclass(frozen=True) +class HamiltonLiquidClass: + """Exact built-in symbol in PyLabRobot's Hamilton STAR liquid-class catalogue. + + STAR compilation resolves and snapshots the installed SDK's definition. + Other robot targets reject this reference. Selection must match the liquid, + tips, volume range, and dispensing mode validated on the intended STAR. + """ + + name: str + + def __post_init__(self) -> None: + _validate_name(self.name) + if not self.name.isidentifier() or self.name.startswith("_"): + raise ValueError("Hamilton liquid class must name a public built-in symbol") + + +LiquidClassSpec: TypeAlias = LiquidClass | HamiltonLiquidClass + + +def validate_liquid_class(value: LiquidClassSpec | None) -> None: + if value is not None and not isinstance(value, (LiquidClass, HamiltonLiquidClass)): + raise TypeError("Pass a LiquidClass or HamiltonLiquidClass, not an untyped name or mapping") + + +def _validate_name(name: str) -> None: + if not isinstance(name, str) or not name.strip(): + raise ValueError("Liquid class name must be a nonempty string") diff --git a/src/lab/model.py b/src/lab/model.py index 86fcb0a..0bfdbe5 100644 --- a/src/lab/model.py +++ b/src/lab/model.py @@ -1,10 +1,11 @@ """The entire recorded protocol vocabulary. No callbacks or device objects.""" -from dataclasses import asdict, dataclass +from dataclasses import dataclass from decimal import Decimal from enum import Enum from typing import Any +from lab.liquid_classes import HamiltonLiquidClass, LiquidClass, LiquidClassSpec from lab.samples import Location, OutputManifest, Sample, SamplePlacement from lab.units import number @@ -46,6 +47,7 @@ class Transfer: destination: Location volume: Decimal origin: Origin + liquid_class: LiquidClassSpec | None = None @dataclass(frozen=True) @@ -57,6 +59,7 @@ class Distribute: volume: Decimal air_gap: Decimal | None origin: Origin + liquid_class: LiquidClassSpec | None = None @dataclass(frozen=True) @@ -65,6 +68,7 @@ class Mix: volume: Decimal cycles: int origin: Origin + liquid_class: LiquidClassSpec | None = None @dataclass(frozen=True) @@ -159,9 +163,23 @@ 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, + LiquidClass, + HamiltonLiquidClass, + ), ): - return {"kind": type(value).__name__, **encode(asdict(value))} + return { + "kind": type(value).__name__, + **{name: encode(getattr(value, name)) for name in value.__dataclass_fields__}, + } 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__} diff --git a/src/lab/protocol.py b/src/lab/protocol.py index b0a93b7..a42fd11 100644 --- a/src/lab/protocol.py +++ b/src/lab/protocol.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any +from lab.liquid_classes import LiquidClassSpec, validate_liquid_class from lab.model import ( Distribute, Fill, @@ -191,11 +192,22 @@ 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, + liquid_class: LiquidClassSpec | None = None, + ) -> None: + """Move liquid using optional explicit rates or a native Hamilton class.""" + validate_liquid_class(liquid_class) 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())) + self._steps.append( + Transfer(start, end, magnitude(volume, "microliter"), _origin(), liquid_class) + ) def distribute( self, @@ -204,8 +216,10 @@ def distribute( *, volume: Any, air_gap: Any = None, + liquid_class: LiquidClassSpec | None = None, ) -> None: """Move the same volume from one source to each destination, using one tip.""" + validate_liquid_class(liquid_class) start = self._location(source) ends = tuple(self._location(destination) for destination in destinations) if not ends: @@ -213,13 +227,30 @@ def distribute( if any(end == start for end in ends): raise ValueError("Distribute source and destination must differ") gap = magnitude(air_gap, "microliter") if air_gap is not None else None - self._steps.append(Distribute(start, ends, magnitude(volume, "microliter"), gap, _origin())) + self._steps.append( + Distribute(start, ends, magnitude(volume, "microliter"), gap, _origin(), liquid_class) + ) - def mix(self, well: Well, *, volume: Any, cycles: int) -> None: + def mix( + self, + well: Well, + *, + volume: Any, + cycles: int, + liquid_class: LiquidClassSpec | None = None, + ) -> None: + """Repeat aspiration and dispense at one well, with the supplied class on both.""" + validate_liquid_class(liquid_class) if type(cycles) is not int or cycles < 1: raise ValueError("Mix cycles must be a positive integer") self._steps.append( - Mix(self._location(well), magnitude(volume, "microliter"), cycles, _origin()) + Mix( + self._location(well), + magnitude(volume, "microliter"), + cycles, + _origin(), + liquid_class, + ) ) def wait(self, duration: Any) -> None: diff --git a/src/lab/targets/opentrons.py b/src/lab/targets/opentrons.py index dcfeb17..d87826f 100644 --- a/src/lab/targets/opentrons.py +++ b/src/lab/targets/opentrons.py @@ -10,6 +10,7 @@ from lab.documents import describe from lab.equipment import LabwareModel, ModuleModel, TipRackModel from lab.labware import LabwareKind +from lab.liquid_classes import HamiltonLiquidClass, LiquidClass from lab.model import ( Binding, Distribute, @@ -261,6 +262,8 @@ def select_pipette(volume: Decimal) -> _Pipette: for index, step in enumerate(protocol.steps): commands.append(f"context.comment({describe(step)!r})") if isinstance(step, (Transfer, Mix, Distribute)): + if isinstance(step.liquid_class, HamiltonLiquidClass): + raise step_error(index, step, "Hamilton liquid classes require the STAR target") try: pipette = select_pipette(step.volume) except CompileError as exc: @@ -273,6 +276,16 @@ 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 isinstance(step.liquid_class, LiquidClass): + spec = step.liquid_class + commands.extend( + ( + f"previous_rates = ({tool}.flow_rate.aspirate, " + f"{tool}.flow_rate.dispense)", + f"{tool}.flow_rate.aspirate = {number(spec.aspiration_flow_rate_ul_s)}", + f"{tool}.flow_rate.dispense = {number(spec.dispense_flow_rate_ul_s)}", + ) + ) commands.append(f"{tool}.pick_up_tip({tip})") amount = number(step.volume) if isinstance(step, Transfer): @@ -292,6 +305,10 @@ def select_pipette(volume: Decimal) -> _Pipette: else: commands.append(f"{tool}.mix({step.cycles}, {amount}, {refs[step.location]})") commands.append(f"{tool}.drop_tip()") + if isinstance(step.liquid_class, LiquidClass): + commands.append( + f"{tool}.flow_rate.aspirate, {tool}.flow_rate.dispense = previous_rates" + ) elif isinstance(step, SetTemperature): module = temperature_of.get(step.resource) if module is None: diff --git a/src/lab/targets/star.py b/src/lab/targets/star.py index bc965e8..a124988 100644 --- a/src/lab/targets/star.py +++ b/src/lab/targets/star.py @@ -25,6 +25,7 @@ from lab.documents import describe from lab.equipment import CarrierModel, LabwareModel, TipRackModel from lab.labware import LabwareKind +from lab.liquid_classes import HamiltonLiquidClass, LiquidClass from lab.model import ( Binding, Distribute, @@ -46,6 +47,10 @@ _sdk_import_error: ImportError | None = None try: import pylabrobot.resources as plr + from pylabrobot.liquid_handling.liquid_classes.hamilton import ( + HamiltonLiquidClass as PLRHamiltonLiquidClass, + ) + from pylabrobot.liquid_handling.liquid_classes.hamilton import star as star_liquid_classes from pylabrobot.resources import ( Container, ItemizedResource, @@ -57,6 +62,21 @@ _sdk_import_error = exc +def hamilton_liquid_class_names() -> tuple[str, ...]: + """List built-in STAR symbols in the installed, optional PyLabRobot SDK.""" + if _sdk_import_error is not None: + raise ImportError( + "Install lab-compiler[star] to list Hamilton classes" + ) from _sdk_import_error + return tuple( + sorted( + name + for name, value in vars(star_liquid_classes).items() + if not name.startswith("_") and isinstance(value, PLRHamiltonLiquidClass) + ) + ) + + @dataclass class STAR: """Bind protocol resources to a supplied physical STAR or STARlet configuration. @@ -165,6 +185,8 @@ def on_deck(resource: Any) -> None: "external_thermal_resources": self.external_thermal_resources, } commands = [] + native_definitions = {} + native_initializers = [] tip_index = 0 for index, step in enumerate(protocol.steps): if isinstance(step, (Thermocycle, SetTemperature)): @@ -197,6 +219,23 @@ def on_deck(resource: Any) -> None: ) amount = number(step.volume) channels = f"use_channels=[{self.channel}]" + aspiration = dispense = "" + if isinstance(step.liquid_class, LiquidClass): + spec = step.liquid_class + aspiration = f", flow_rates=[{number(spec.aspiration_flow_rate_ul_s)}]" + dispense = f", flow_rates=[{number(spec.dispense_flow_rate_ul_s)}]" + elif isinstance(step.liquid_class, HamiltonLiquidClass): + name = step.liquid_class.name + native = getattr(star_liquid_classes, name, None) + if not isinstance(native, PLRHamiltonLiquidClass): + raise step_error(index, step, f"Unknown Hamilton STAR liquid class: {name}") + if name not in native_definitions: + definition = vars(native).copy() + native_definitions[name] = definition + native_initializers.append( + f" {name!r}: HamiltonLiquidClass(**{definition!r})," + ) + aspiration = dispense = f", hamilton_liquid_classes=[LIQUID_CLASSES[{name!r}]]" commands.append( f"await lh.pick_up_tips([deck.get_resource({spot.name!r})], {channels})" ) @@ -204,9 +243,9 @@ def on_deck(resource: Any) -> None: commands.extend( ( f"await lh.aspirate([{refs[step.source]}], " - f"vols=[{amount}], {channels})", + f"vols=[{amount}], {channels}{aspiration})", f"await lh.dispense([{refs[step.destination]}], " - f"vols=[{amount}], {channels})", + f"vols=[{amount}], {channels}{dispense})", ) ) elif isinstance(step, Distribute): @@ -214,9 +253,9 @@ def on_deck(resource: Any) -> None: commands.extend( ( f"await lh.aspirate([{refs[step.source]}], " - f"vols=[{amount}], {channels})", + f"vols=[{amount}], {channels}{aspiration})", f"await lh.dispense([{refs[destination]}], " - f"vols=[{amount}], {channels})", + f"vols=[{amount}], {channels}{dispense})", ) ) else: @@ -224,9 +263,9 @@ def on_deck(resource: Any) -> None: ( f"for _ in range({step.cycles}):", f" await lh.aspirate([{refs[step.location]}], " - f"vols=[{amount}], {channels})", + f"vols=[{amount}], {channels}{aspiration})", f" await lh.dispense([{refs[step.location]}], " - f"vols=[{amount}], {channels})", + f"vols=[{amount}], {channels}{dispense})", ) ) commands.append(f"await lh.discard_tips({channels}, allow_nonzero_volume=False)") @@ -276,10 +315,19 @@ def on_deck(resource: Any) -> None: isinstance(step, (Thermocycle, SetTemperature)) for step in protocol.steps ) thermal_import = "from inspect import iscoroutinefunction\n" if has_thermal else "" + liquid_import = "" + if native_definitions: + configuration["hamilton_liquid_classes"] = native_definitions + liquid_import = ( + "from pylabrobot.liquid_handling.liquid_classes.hamilton " + "import HamiltonLiquidClass\n" + "LIQUID_CLASSES = {\n" + "\n".join(native_initializers) + "\n}\n" + ) source = ( '"""Generated PyLabRobot protocol. Direct execution uses a software backend."""\n\n' "import asyncio\nimport json\nfrom importlib.metadata import version\n" f"{thermal_import}\n" + f"{liquid_import}\n" "from pylabrobot.liquid_handling import LiquidHandler\n" "from pylabrobot.liquid_handling.backends import LiquidHandlerChatterboxBackend\n" "from pylabrobot.resources import Resource\n\n" diff --git a/src/lab/units.py b/src/lab/units.py index 569f9c1..216741e 100644 --- a/src/lab/units.py +++ b/src/lab/units.py @@ -13,6 +13,63 @@ Quantity = pint.Quantity +def parse_quantity( + value: str | Quantity, *, unit: str | tuple[str, ...], positive: bool = True +) -> Quantity: + """Parse text or normalize a quantity to finite Decimal magnitudes and checked units. + + ``unit`` is the output unit, or a tuple of alternatives for distinct dimensions + (for example, ``("mole/liter", "gram/liter")`` for concentrations). The first + compatible unit is used. Values must be positive; ``positive=False`` also + permits zero. Bare numbers, incompatible dimensions, and invalid values + raise ValueError. Existing Protocol operations still require Quantity objects. + """ + alternatives = (unit,) if isinstance(unit, str) else unit + try: + quantity = units.Quantity(value) if isinstance(value, str) else value + for candidate in alternatives: + if isinstance(quantity, Quantity) and quantity.is_compatible_with(candidate): + return magnitude(quantity, candidate, positive=positive) * units(candidate) + except (pint.PintError, TypeError, ValueError, ArithmeticError) as exc: + raise ValueError(str(exc)) from exc + raise ValueError(f"Expected a quantity compatible with {' or '.join(alternatives)}") + + +def dilution_volume( + *, + stock_concentration: Quantity, + target_concentration: Quantity, + final_volume: Quantity, +) -> Quantity: + """Return the stock volume needed for a target concentration, in microlitres. + + Calculate ``target_concentration / stock_concentration * final_volume``. + Concentrations must have compatible mass/volume or amount/volume units; + a unitless number or a mass-to-molar conversion is not accepted. The stock + and final volume must be positive; the target may range from zero to the + stock concentration. The returned quantity has a Decimal magnitude. + + The stock must be the only source of this solute. ``final_volume`` includes + all additions, including inoculum. This calculation does not account for + solute carryover, select a stock, or record any protocol steps. A zero target + returns zero volume; the caller should omit that transfer. + """ + if not isinstance(stock_concentration, Quantity): + raise TypeError("Stock concentration must be a unit-bearing Quantity") + if stock_concentration.check("[substance] / [length] ** 3"): + unit = "mole/liter" + elif stock_concentration.check("[mass] / [length] ** 3"): + unit = "gram/liter" + else: + raise ValueError("Stock concentration must have mass/volume or amount/volume units") + stock = magnitude(stock_concentration, unit) + target = magnitude(target_concentration, unit, positive=False) + total = magnitude(final_volume, "microliter") + if target > stock: + raise ValueError("Target concentration cannot exceed the stock concentration") + return (target / stock * total) * uL + + def celsius(value: int | float | str | Decimal) -> pint.Quantity: """An absolute Celsius temperature; avoids multiplying an offset unit.""" if isinstance(value, bool): diff --git a/tests/test_concentration_series.py b/tests/test_concentration_series.py new file mode 100644 index 0000000..d31399a --- /dev/null +++ b/tests/test_concentration_series.py @@ -0,0 +1,492 @@ +"""Independent preparation balances and the generated robot operations.""" + +import csv +import hashlib +import json +import re +import sys +import tomllib +from decimal import Decimal +from io import StringIO +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from examples.concentration_series import __main__ as cli +from examples.concentration_series import inputs +from examples.concentration_series import protocol as recipe +from lab import CompileError, HamiltonLiquidClass, LiquidClass, compile, uL +from lab.config import PortableLiquidClassConfig +from lab.deck import Deck, HolderSite, Rail, Slot +from lab.equipment import LiquidHandler +from lab.model import Mix, Transfer +from lab.samples import Location +from lab.units import units + +ROOT = Path(__file__).resolve().parents[1] +CSV = ROOT / "docs/lmsf/assay-options/2026-07-23_Mar-Pro_OD-test-5-plates_growth-plate_5.csv" +CONFIG = ROOT / "examples/concentration_series/config.toml" + + +def configuration(): + return inputs.ProtocolConfig.model_validate(tomllib.loads(CONFIG.read_text())) + + +def updated(config, **changes): + return type(config).model_validate(config.model_dump() | changes) + + +def layout(path=CSV): + with path.open(newline="", encoding="utf-8-sig") as stream: + return inputs.PlateLayout.model_validate({"conditions": list(csv.DictReader(stream))}) + + +def protocol(): + config = configuration() + return recipe.protocol(inputs.prepare_wells(layout(), config), config) + + +try: + from opentrons.simulate import simulate +except ImportError: + simulate = None + + +def test_preparation_matches_independent_doses_inventory_and_identity(): + bundle = compile(protocol(), to=None) + transfers = [s for s in bundle.protocol.steps if isinstance(s, Transfer)] + # Expected Van dose in uL; fixed additions are 20 Sal + 2 TMP + 2 DMSO + 10 culture. + expected = { + "A1": (None, "0"), + "A5": ("A1", "1.5625"), + "A6": ("A1", "3.125"), + "A7": ("A1", "6.25"), + "A8": ("A1", "12.5"), + "A9": ("A1", "25"), + "A10": ("A1", "50"), + "A11": ("A2", "10"), + "A12": ("A2", "20"), + } + targets = { + row.well: (row.inducer1_concentration * row.inducer1_units).to("umol/liter").magnitude + for row in layout().conditions + } + for well, (stock, dose) in expected.items(): + moves = [s for s in transfers if s.destination == Location("cultures", well)] + sources = [Location("medium", "A1"), Location("reagents", "A5")] + volumes = [Decimal(166) - Decimal(dose), Decimal(2)] + if stock is not None: + sources.append(Location("reagents", stock)) + volumes.append(Decimal(dose)) + stock_um = 100 if stock == "A1" else 1000 + assert Decimal(dose) * stock_um / 200 == targets[well] + sources.extend(Location("reagents", source) for source in ("A3", "A4", "B1")) + volumes.extend((Decimal(20), Decimal(2), Decimal(10))) + assert [s.source for s in moves] == sources + assert [s.volume for s in moves] == volumes + assert sum(s.volume for s in moves) == 200 + assert (volumes[1] + Decimal(2)) / 200 == Decimal("0.02") + + remaining = dict(bundle.final_volumes) + assert {well: remaining[Location("cultures", well)] for well in expected} == dict.fromkeys( + expected, Decimal(200) + ) + for well, volume in ( + ("A1", "201.5625"), + ("A2", "170"), + ("A3", "120"), + ("A4", "82"), + ("A5", "82"), + ("B1", "210"), + ): + assert remaining[Location("reagents", well)] == Decimal(volume) + assert remaining[Location("medium", "A1")] == Decimal("1634.4375") + assert len(bundle.manifest.samples) == 9 + assert {p.location.well for p in bundle.manifest.placements} == set(expected) + assert all( + s.material_identity == "pDRAC18-2-A" + and s.parent_ids == ("inoculum-B1",) + and s.replicate is None + for s in bundle.manifest.samples + ) + assert '"name": "DMSO"' in bundle.protocol.description + assert "DMSO" in bundle.files["protocol.html"] + assert all(s.liquid_class is not None for s in bundle.protocol.steps) + assert all(isinstance(s, (Transfer, Mix)) for s in bundle.protocol.steps) + mixes = [s for s in bundle.protocol.steps if isinstance(s, Mix)] + assert [(s.location, s.volume, s.cycles) for s in mixes] == [ + item + for well in expected + for item in ((Location("reagents", "B1"), 50, 3), (Location("cultures", well), 100, 3)) + ] + + +@pytest.mark.integration +@pytest.mark.skipif(simulate is None, reason="Opentrons SDK not installed") +def test_generated_ot2_actions_match_transfers_mixes_and_tips(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + bundle = compile( + protocol(), deck=configuration().deck, liquid_handler=LiquidHandler.OT2, to=None + ) + log, _ = simulate(StringIO(bundle.files["protocol.py"])) + texts = [event["payload"]["text"] for event in log] + aspirate = re.compile(r"Aspirating ([0-9.]+) uL from ([A-H]\d+) of .+ on slot (\d+) at ") + dispense = re.compile(r"Dispensing ([0-9.]+) uL into ([A-H]\d+) of .+ on slot (\d+) at ") + pending, moves = None, [] + for text in texts: + start, end = aspirate.search(text), dispense.search(text) + if start: + pending = (start[3], start[2], Decimal(start[1])) + elif end: + assert pending is not None + destination = (end[3], end[2]) + if destination != pending[:2]: + assert Decimal(end[1]) == pending[2] + moves.append((*pending[:2], *destination, pending[2])) + bindings = { + b.location: tuple(b.physical.removeprefix("slot ").split("/")) + for b in bundle.target.bindings + } + expected = [ + (*bindings[s.source], *bindings[s.destination], s.volume) + for s in bundle.protocol.steps + if isinstance(s, Transfer) + ] + assert moves == expected + mixes = [s for s in bundle.protocol.steps if isinstance(s, Mix)] + actual_mixes = [text for text in texts if text.startswith("Mixing ")] + assert len(actual_mixes) == len(mixes) + for actual, expected_mix in zip(actual_mixes, mixes, strict=True): + assert actual.startswith(f"Mixing {expected_mix.cycles} times with a volume of ") + volume = re.search(r"volume of ([0-9.]+)", actual) + assert volume and Decimal(volume[1]) == expected_mix.volume + tips = len(expected) + len(mixes) + assert sum(t.startswith("Picking up tip") for t in texts) == tips + assert sum(t.startswith("Dropping tip") for t in texts) == tips + + +@pytest.mark.parametrize( + "change, message", + [ + (lambda s: updated(s, cultures=()), "no source"), + (lambda s: updated(s, stocks=()), "no stock"), + (lambda s: updated(s, vehicle_fraction=Decimal("0.005")), "exceed"), + (lambda s: updated(s, final_volume=30 * uL), "available above its dead volume"), + ( + lambda s: updated( + s, + cultures=(updated(s.cultures[0], volume=25 * uL),), + ), + "available above its dead volume", + ), + (lambda s: updated(s, vehicle=updated(s.vehicle, well="A1")), "assigned more than once"), + ], +) +def test_preparation_rejects_infeasible_or_unknown_inputs(change, message): + with pytest.raises((ValueError, CompileError), match=message): + config = change(configuration()) + compile(recipe.protocol(inputs.prepare_wells(layout(), config), config), to=None) + + +@pytest.mark.parametrize( + "old, new, message", + [ + ( + "solute_and_vehicle_free_medium_and_cultures = true", + "solute_and_vehicle_free_medium_and_cultures = false", + "carryover", + ), + ('source_dead_volume = "20 uL"', 'source_dead_volume = "-1 uL"', "nonnegative"), + ('vehicle_fraction = "0.02"', 'vehicle_fraction = "NaN"', "finite"), + ('inoculum_volume = "10 uL"', 'inoculum_volume = "200 uL"', "less than"), + ('liquid_class = "aqueous"', 'liquid_class = "missing"', "missing"), + ('dispense_flow_rate = "30 uL/s"', 'dispense_flow_rate = "30 uL"', "compatible"), + ('dispense_flow_rate = "30 uL/s"', 'dispense_fow_rate = "30 uL/s"', "Extra inputs"), + ], +) +def test_preparation_file_validates_explicit_inputs(tmp_path, old, new, message): + path = tmp_path / "config.toml" + path.write_text(CONFIG.read_text().replace(old, new)) + with pytest.raises((ValueError, TypeError, KeyError), match=message): + inputs.ProtocolConfig.model_validate(tomllib.loads(path.read_text())) + + +@pytest.mark.parametrize( + "key,value", + [ + ("well", "Z1"), + ("column", "2"), + ("position", "2"), + ("inducer1Concentration", "nan"), + ("inducer1Concentration", "-1"), + ("inducer1Units", "uL"), + ("inducer1Id", "Sal"), + ("plasmid", "none"), + ], +) +def test_csv_rejects_invalid_records_with_row_location(tmp_path, key, value): + with CSV.open(newline="") as stream: + rows = list(csv.DictReader(stream)) + rows[0][key] = value + path = tmp_path / "invalid.csv" + with path.open("w", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=rows[0].keys()) + writer.writeheader() + writer.writerows(rows) + with pytest.raises(ValidationError) as caught: + layout(path) + assert all(error["loc"][:2] == ("conditions", 0) for error in caught.value.errors()) + + +def test_csv_targets_are_read_and_duplicate_conditions_remain_separate(tmp_path): + with CSV.open(newline="") as stream: + rows = list(csv.DictReader(stream)) + rows = [r for r in rows if r["well"] in ("A1", "A5")] + for row in rows: + row["inducer1Concentration"] = "5" + path = tmp_path / "changed.csv" + with path.open("w", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=rows[0].keys()) + writer.writeheader() + writer.writerows(rows) + config = updated(configuration(), wells=("A1", "A5")) + bundle = compile(recipe.protocol(inputs.prepare_wells(layout(path), config), config), to=None) + van = [ + s + for s in bundle.protocol.steps + if isinstance(s, Transfer) and s.source == Location("reagents", "A1") + ] + assert [(s.destination.well, s.volume) for s in van] == [("A1", 10), ("A5", 10)] + assert len(bundle.manifest.samples) == 2 + + +@pytest.mark.integration +async def test_generated_star_example_tracks_all_delivered_volumes(): + plr = pytest.importorskip("pylabrobot.resources") + backends = pytest.importorskip("pylabrobot.liquid_handling.backends") + bundle = compile( + protocol(), + deck=configuration().deck, + liquid_handler=LiquidHandler.STAR, + to=None, + ) + namespace = {"__name__": "_generated"} + exec(bundle.files["protocol.py"], namespace) + plr.set_volume_tracking(True) + try: + handler = await namespace["run"](backends.LiquidHandlerChatterboxBackend()) + finally: + plr.set_volume_tracking(False) + for location, volume in bundle.final_volumes: + resource = handler.deck.get_resource(location.resource).get_item(location.well) + assert Decimal(str(resource.tracker.get_used_volume())) == volume + + +def test_config_round_trip_preserves_toml_shape_and_resolves_classes_explicitly(): + raw = tomllib.loads(CONFIG.read_text()) + config = inputs.ProtocolConfig.model_validate(raw) + saved = config.model_dump(mode="json") + assert saved.keys() == raw.keys() + for field in ("medium", "vehicle", "source_mix", "destination_mix"): + assert saved[field].keys() == raw[field].keys() + for field in ("stocks", "cultures"): + assert all( + actual.keys() == source.keys() + for actual, source in zip(saved[field], raw[field], strict=True) + ) + assert config.medium.name == raw["medium"]["name"] + assert config.medium.dead_volume == 500 * uL + assert config.medium.liquid_class == "aqueous" + assert config.source_mix.liquid_class == "culture" + assert config.stocks[0].well == raw["stocks"][0]["well"] + assert isinstance(config.deck, Deck) + ot2 = config.deck.layout_for(LiquidHandler.OT2) + star = config.deck.layout_for(LiquidHandler.STAR) + assert ot2.placements[0].location == Slot("1") + assert star.placements[0].location == HolderSite("plates", 0) + assert star.carriers[0].location == Rail(15) + assert isinstance(config.liquid_classes["aqueous"], PortableLiquidClassConfig) + assert inputs.ProtocolConfig.model_validate_json(config.model_dump_json()) == config + # Optional native definitions retain their config alias until explicit resolution. + raw["liquid_classes"]["aqueous"] = { + "kind": "hamilton", + "name": "HighVolumeFilter_Water_DispenseSurface_Part", + } + native_config = inputs.ProtocolConfig.model_validate(raw) + assert native_config.medium.liquid_class == "aqueous" + bundle = compile( + recipe.protocol(inputs.prepare_wells(layout(), native_config), native_config), to=None + ) + medium_class = bundle.protocol.steps[0].liquid_class + assert isinstance(medium_class, HamiltonLiquidClass) + assert medium_class.name == "HighVolumeFilter_Water_DispenseSurface_Part" + mixing_class = next( + step for step in bundle.protocol.steps if isinstance(step, Mix) + ).liquid_class + assert isinstance(mixing_class, LiquidClass) + assert mixing_class.name == "culture" + assert mixing_class.aspiration_flow_rate_ul_s == mixing_class.dispense_flow_rate_ul_s == 20 + + +@pytest.mark.parametrize( + "path, value, location, error_type", + [ + (("unexpected",), 1, ("unexpected",), "extra_forbidden"), + (("medium", "dead_volum"), "500 uL", ("medium", "dead_volum"), "extra_forbidden"), + (("medium", "dead_volume"), "1 second", ("medium", "dead_volume"), "value_error"), + (("medium", "volume"), "10 unknown_unit", ("medium", "volume"), "value_error"), + (("medium", "volume"), 3000, ("medium", "volume"), "value_error"), + (("source_mix", "cycles"), True, ("source_mix", "cycles"), "int_type"), + (("source_mix", "cycles"), "3", ("source_mix", "cycles"), "int_type"), + ( + ("solute_and_vehicle_free_medium_and_cultures",), + "true", + ("solute_and_vehicle_free_medium_and_cultures",), + "bool_type", + ), + ( + ("liquid_classes", "aqueous", "kind"), + "water", + ("liquid_classes", "aqueous"), + "union_tag_invalid", + ), + ( + ("liquid_classes", "aqueous", "aspiration_flow_rate"), + "0 uL/s", + ("liquid_classes", "aqueous", "portable", "aspiration_flow_rate"), + "value_error", + ), + (("cultures", 0, "well"), "A13", ("cultures", 0, "well"), "string_pattern_mismatch"), + ], +) +def test_config_validation_reports_the_invalid_field(path, value, location, error_type): + raw = tomllib.loads(CONFIG.read_text()) + parent = raw + for key in path[:-1]: + parent = parent[key] + parent[path[-1]] = value + with pytest.raises(ValidationError) as caught: + inputs.ProtocolConfig.model_validate(raw) + assert (location, error_type) in {(e["loc"], e["type"]) for e in caught.value.errors()} + + +def test_saved_config_is_the_validated_input_used_by_the_recipe(): + config = updated( + configuration(), + final_volume="180 uL", + medium=updated(configuration().medium, name="Specified medium"), + ) + bundle = compile(recipe.protocol(inputs.prepare_wells(layout(), config), config), to=None) + recorded = json.loads(bundle.protocol.description.split("Protocol configuration: ", 1)[1]) + assert recorded == config.model_dump(mode="json") + assert recorded["final_volume"] == "180 microliter" + assert recorded["medium"]["name"] == "Specified medium" + assert all( + dict(bundle.final_volumes)[Location("cultures", well)] == 180 for well in config.wells + ) + with pytest.raises(ValidationError, match="less than final"): + updated(config, inoculum_volume=200 * uL) + + +def test_config_schema_describes_nested_inputs_and_unit_strings(): + schema = inputs.ProtocolConfig.model_json_schema() + assert schema["additionalProperties"] is False + assert schema["properties"]["final_volume"]["type"] == "string" + assert schema["$defs"]["MediumConfig"]["properties"]["dead_volume"]["type"] == "string" + assert schema["$defs"]["MediumConfig"]["properties"]["liquid_class"]["type"] == "string" + + +def test_csv_model_parses_typed_records_and_round_trips_source_columns(): + parsed = layout() + row = next(condition for condition in parsed.conditions if condition.well == "A5") + assert row.position == 33 + assert row.inducer1_concentration == Decimal("0.78125") + assert row.inducer1_concentration * row.inducer1_units == units("0.78125 umol/liter") + saved = parsed.model_dump_json(by_alias=True) + assert "inducer1Concentration" in json.loads(saved)["conditions"][0] + assert inputs.PlateLayout.model_validate_json(saved) == parsed + + +@pytest.mark.parametrize("count, message", [(0, "at least 1"), (2, "Repeated CSV destination")]) +def test_csv_model_rejects_empty_layouts_and_repeated_destinations(count, message): + row = layout().conditions[0] + with pytest.raises(ValidationError, match=message): + inputs.PlateLayout.model_validate({"conditions": [row] * count}) + + +def test_protocol_consumes_prepared_inputs_without_parsing_or_recalculating(monkeypatch): + config = configuration() + prepared = inputs.prepare_wells(layout(), config) + + def unexpected(*args, **kwargs): + pytest.fail("Protocol construction must consume the already prepared inputs") + + with monkeypatch.context() as patch: + patch.setattr(inputs.ProtocolConfig, "model_validate", unexpected) + patch.setattr(inputs.PlateLayout, "model_validate", unexpected) + patch.setattr(inputs, "prepare_wells", unexpected) + patch.setattr(inputs, "dilution_volume", unexpected) + patch.setattr(Path, "open", unexpected) + patch.setattr(Path, "read_bytes", unexpected) + p = recipe.protocol(prepared, config) + bundle = compile(p, to=None) + assert all( + dict(bundle.final_volumes)[Location("cultures", well)] == 200 for well in config.wells + ) + + +def test_cli_config_and_csv_provenance_match_the_prepared_inputs(tmp_path, monkeypatch): + output = tmp_path / "compiled" + monkeypatch.setattr( + sys, + "argv", + ["concentration_series", "--csv", str(CSV), "--config", str(CONFIG), "--out", str(output)], + ) + cli.main() + plan = json.loads((output / "plan.json").read_text()) + description = plan["protocol"]["description"] + assert CSV.name in description + assert hashlib.sha256(CSV.read_bytes()).hexdigest() in description + assert json.loads( + description.split("Protocol configuration: ", 1)[1] + ) == configuration().model_dump(mode="json") + assert len(plan["protocol"]["steps"]) == 71 + + +@pytest.mark.parametrize( + "config_change, csv_change, message", + [ + (None, ("0.78125", "NaN"), "finite"), + (None, ("inducer1Units", "unknownColumn"), "CSV schema"), + (None, ('"A5","A",5', '"A5","A",6'), "Inconsistent position"), + (None, ("pDRAC18-2-A", "unknown-plasmid"), "no source"), + (('"A1", "A5"', '"A1", "A13"'), None, "wells.1"), + (('solute = "Van"', 'solute = "unknown"'), None, "no stock"), + (('vehicle_fraction = "0.02"', 'vehicle_fraction = "0.005"'), None, "exceed"), + (('name = "2"', 'name = "1"'), None, "occupied more than once"), + (("min_volume_ul", "min_volume_ull"), None, "min_volume_ull"), + (('min_volume_ul = "1"', 'min_volume_ul = "301"'), None, "minimum volume exceeds"), + ], +) +def test_cli_rejects_invalid_inputs_before_protocol_construction( + tmp_path, monkeypatch, capsys, config_change, csv_change, message +): + config_text, csv_text = CONFIG.read_text(), CSV.read_text() + assert config_change is None or config_change[0] in config_text + assert csv_change is None or csv_change[0] in csv_text + config_path, csv_path = tmp_path / "config.toml", tmp_path / "targets.csv" + config_path.write_text(config_text.replace(*config_change) if config_change else config_text) + csv_path.write_text(csv_text.replace(*csv_change) if csv_change else csv_text) + monkeypatch.setattr( + sys, "argv", ["concentration_series", "--csv", str(csv_path), "--config", str(config_path)] + ) + + def unexpected(*args, **kwargs): + pytest.fail("Invalid inputs must be rejected before protocol construction") + + monkeypatch.setattr(cli, "protocol", unexpected) + with pytest.raises(SystemExit) as caught: + cli.main() + assert caught.value.code == 2 + assert message in capsys.readouterr().err diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..62e4ddc --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,104 @@ +"""Reusable configuration types validate and serialize independently of any example.""" + +from decimal import Decimal + +import pytest +from pydantic import ValidationError + +from lab import Quantity, uL +from lab.config import ( + Concentration, + ConcentrationUnit, + ConfigModel, + FlowRate, + HamiltonLiquidClassConfig, + LiquidClassConfig, + MixingConfig, + NonnegativeVolume, + Volume, + VolumeFraction, +) +from lab.units import units + + +class Settings(ConfigModel): + volume: Volume + dead_volume: NonnegativeVolume + flow_rate: FlowRate + concentration: Concentration + concentration_units: ConcentrationUnit + vehicle_fraction: VolumeFraction + mixing: MixingConfig + liquid_classes: dict[str, LiquidClassConfig] + + +def inputs(): + return { + "volume": "0.2 mL", + "dead_volume": "0 uL", + "flow_rate": "30 uL/minute", + "concentration": "3 ug/mL", + "concentration_units": "umol/L", + "vehicle_fraction": "0.02", + "mixing": {"volume": "50 uL", "cycles": 3, "liquid_class": "aqueous"}, + "liquid_classes": { + "aqueous": { + "kind": "portable", + "aspiration_flow_rate": "20 uL/s", + "dispense_flow_rate": "30 uL/s", + }, + "native": {"kind": "hamilton", "name": "HighVolumeFilter_Water_DispenseSurface_Part"}, + }, + } + + +def test_config_quantities_are_typed_and_round_trip_with_units(): + parsed = Settings.model_validate(inputs()) + assert parsed.volume == 200 * uL + assert parsed.dead_volume == 0 * uL + assert parsed.flow_rate.units == uL / units.second + assert parsed.flow_rate.magnitude == pytest.approx( + Decimal("0.5"), rel=Decimal("1e-26"), abs=Decimal(0) + ) + assert parsed.concentration == units("0.003 g/L") + # A separate units column retains its scale, unlike a complete concentration. + assert 10 * parsed.concentration_units == units("10 umol/L") + assert parsed.vehicle_fraction == Decimal("0.02") + assert parsed.mixing.liquid_class == "aqueous" + assert isinstance(parsed.liquid_classes["native"], HamiltonLiquidClassConfig) + assert all( + isinstance(getattr(parsed, field).magnitude, Decimal) + for field in ("volume", "dead_volume", "flow_rate", "concentration") + ) + assert Settings.model_validate_json(parsed.model_dump_json()) == parsed + assert parsed.model_dump(mode="json")["volume"] == "2E+2 microliter" + schema = Settings.model_json_schema() + assert schema["properties"]["volume"]["type"] == "string" + assert schema["properties"]["concentration_units"]["type"] == "string" + assert schema["additionalProperties"] is False + with pytest.raises(ValidationError, match="frozen"): + parsed.volume = 10 * uL + + +@pytest.mark.parametrize( + "field, value", + [ + ("volume", "0 uL"), + ("dead_volume", "-1 uL"), + ("flow_rate", "1 uL"), + ("flow_rate", "0 uL/s"), + ("concentration", "2 seconds"), + ("concentration", Quantity(Decimal("NaN"), "mole/liter")), + ("concentration_units", "microliter"), + ("concentration_units", "invented_unit"), + ("vehicle_fraction", "1.01"), + ("vehicle_fraction", "NaN"), + ("mixing", {"volume": "20 uL", "cycles": True, "liquid_class": "aqueous"}), + ("liquid_classes", {"native": {"kind": "hamilton", "name": "_private"}}), + ("unexpected", 1), + ], +) +def test_config_reports_invalid_fields(field, value): + with pytest.raises(ValidationError) as caught: + Settings.model_validate(inputs() | {field: value}) + assert all(error["loc"][0] == field for error in caught.value.errors()) diff --git a/tests/test_liquid_classes.py b/tests/test_liquid_classes.py new file mode 100644 index 0000000..5128f89 --- /dev/null +++ b/tests/test_liquid_classes.py @@ -0,0 +1,204 @@ +"""Liquid-class intent survives recording and actual SDK lowering.""" + +import builtins +import json +from dataclasses import FrozenInstanceError +from decimal import Decimal +from io import StringIO + +import pytest + +from lab import CompileError, HamiltonLiquidClass, LiquidClass, Protocol, compile, mL, seconds, uL +from lab.equipment import LiquidHandler +from lab.model import encode +from tests.target_fixture import target + +try: + from opentrons.simulate import simulate +except ImportError: + simulate = None +try: + from pylabrobot.liquid_handling.backends import SerializingBackend + from pylabrobot.liquid_handling.backends.hamilton.STAR_chatterbox import STARChatterboxBackend + from pylabrobot.liquid_handling.liquid_classes.hamilton import star as native_classes +except ImportError: + STARChatterboxBackend = None + +BUILTIN = "HighVolumeFilter_Water_DispenseSurface_Part" + + +def rates(): + return LiquidClass( + "gentle", aspiration_flow_rate=10 * uL / seconds, dispense_flow_rate=15 * uL / seconds + ) + + +def protocol(liquid_class, *, follow_default=False): + p = Protocol("Liquid classes") + source = p.container("water", contents="water", volume=300 * uL, capacity=360 * uL) + plate = p.plate("aliquots", capacity=200 * uL) + p.transfer(source, plate["A1"], volume=25 * uL, liquid_class=liquid_class) + p.mix(plate["A1"], volume=20 * uL, cycles=2, liquid_class=liquid_class) + p.distribute(source, (plate["A2"], plate["A3"]), volume=25 * uL, liquid_class=liquid_class) + if follow_default: + p.transfer(source, plate["A4"], volume=25 * uL) + return p + + +def test_classes_are_typed_unit_checked_immutable_and_documented(): + spec = LiquidClass( + "slow", + aspiration_flow_rate=Decimal("0.6") * mL / (60 * seconds), + dispense_flow_rate=15 * uL / seconds, + ) + assert spec.aspiration_flow_rate_ul_s == Decimal(10) + with pytest.raises(FrozenInstanceError): + spec.name = "changed" + assert encode(spec) == { + "kind": "LiquidClass", + "name": "slow", + "aspiration_flow_rate_ul_s": "10", + "dispense_flow_rate_ul_s": "15", + } + bundle = compile(protocol(spec), to=None) + assert "slow: aspirate 10 µL/s; dispense 15 µL/s" in bundle.files["protocol.html"] + assert json.loads(bundle.plan_json)["protocol"]["steps"][0]["liquid_class"] == encode(spec) + assert all(s.liquid_class == spec for s in bundle.protocol.steps) + assert dict(bundle.final_volumes) == dict(compile(protocol(None), to=None).final_volumes) + assert encode(HamiltonLiquidClass(BUILTIN)) == {"kind": "HamiltonLiquidClass", "name": BUILTIN} + assert ( + BUILTIN in compile(protocol(HamiltonLiquidClass(BUILTIN)), to=None).files["protocol.html"] + ) + + +@pytest.mark.parametrize( + "rate", + [ + 10, + True, + 0 * uL / seconds, + -1 * uL / seconds, + float("nan") * uL / seconds, + float("inf") * uL / seconds, + 10 * uL, + 10 * seconds, + ], +) +def test_invalid_rates_fail_at_authoring(rate): + with pytest.raises((TypeError, ValueError)): + LiquidClass("invalid", aspiration_flow_rate=rate, dispense_flow_rate=10 * uL / seconds) + + +@pytest.mark.parametrize("value", ["water", {}, 1]) +def test_operations_reject_untyped_classes(value): + with pytest.raises(TypeError, match="LiquidClass"): + protocol(value) + + +@pytest.mark.parametrize("name", ["", " ", "_private", "a.b", "x; print('hi')"]) +def test_native_reference_is_a_public_symbol(name): + with pytest.raises(ValueError): + HamiltonLiquidClass(name) + + +@pytest.mark.integration +@pytest.mark.skipif(simulate is None, reason="Opentrons SDK not installed") +@pytest.mark.parametrize("handler", ["ot2", "flex"]) +def test_rates_apply_to_transfers_mixes_distributes_and_do_not_leak(handler, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + bundle = compile( + protocol(rates(), follow_default=True), + target(handler), + liquid_handler=LiquidHandler(handler), + to=None, + ) + log, _ = simulate(StringIO(bundle.files["protocol.py"])) + aspirations = [ + e["payload"]["text"] for e in log if e["payload"]["text"].startswith("Aspirating") + ] + dispenses = [e["payload"]["text"] for e in log if e["payload"]["text"].startswith("Dispensing")] + assert len(aspirations) == 5 # transfer, two mixes, distribution, default transfer + assert len(dispenses) == 6 + assert all("at 10.0 uL/sec" in text for text in aspirations[:-1]) + assert all("at 15.0 uL/sec" in text for text in dispenses[:-1]) + assert "at 10.0 uL/sec" not in aspirations[-1] + assert "at 15.0 uL/sec" not in dispenses[-1] + with pytest.raises(CompileError, match="require the STAR"): + compile( + protocol(HamiltonLiquidClass(BUILTIN)), + target(handler), + liquid_handler=LiquidHandler(handler), + to=None, + ) + + +@pytest.mark.integration +@pytest.mark.skipif(STARChatterboxBackend is None, reason="PyLabRobot SDK not installed") +async def test_star_portable_rates_reach_the_real_frontend(): + class Recorder(SerializingBackend): + def __init__(self): + super().__init__(num_channels=8) + self.events = [] + + async def send_command(self, command, data=None): + self.events.append((command, data)) + + bundle = compile( + protocol(rates(), follow_default=True), + target("star"), + liquid_handler=LiquidHandler.STAR, + to=None, + ) + namespace = {"__name__": "_generated"} + exec(builtins.compile(bundle.files["protocol.py"], "star.py", "exec"), namespace) + backend = Recorder() + await namespace["run"](backend) + aspirations = [data["channels"][0] for name, data in backend.events if name == "aspirate"] + dispenses = [data["channels"][0] for name, data in backend.events if name == "dispense"] + assert [op["flow_rate"] for op in aspirations] == [10] * 5 + [None] + assert [op["flow_rate"] for op in dispenses] == [15] * 5 + [None] + assert [op["volume"] for op in aspirations] == [25, 20, 20, 25, 25, 25] + + +@pytest.mark.integration +@pytest.mark.skipif(STARChatterboxBackend is None, reason="PyLabRobot SDK not installed") +async def test_native_definition_is_frozen_and_used_for_both_operations(capsys, monkeypatch): + bundle = compile( + protocol(HamiltonLiquidClass(BUILTIN)), + target("star"), + liquid_handler=LiquidHandler.STAR, + to=None, + ) + definition = json.loads(bundle.plan_json)["target"]["configuration"]["hamilton_liquid_classes"][ + BUILTIN + ] + assert definition["curve"]["20.0"] == 23.9 + assert definition["aspiration_flow_rate"] == 250 + assert definition["dispense_flow_rate"] == 120 + # Generated execution uses the definition captured at compile time. + monkeypatch.setattr(getattr(native_classes, BUILTIN), "aspiration_flow_rate", 42) + namespace = {"__name__": "_generated"} + exec(builtins.compile(bundle.files["protocol.py"], "star.py", "exec"), namespace) + await namespace["run"](STARChatterboxBackend()) + lines = capsys.readouterr().out.splitlines() + aspirations = [line for line in lines if line.startswith("C0AS")] + dispenses = [line for line in lines if line.startswith("C0DS")] + assert len(aspirations) == len(dispenses) == 5 + assert all("as2500" in line for line in aspirations) + assert all("ds1200" in line for line in dispenses) + # 25 uL requested -> 29.3 uL corrected by the native curve; ledger stays nominal. + assert "av00293" in aspirations[0] + assert "dv00293" in dispenses[0] + assert bundle.protocol.steps[0].volume == 25 + + +@pytest.mark.integration +@pytest.mark.skipif(STARChatterboxBackend is None, reason="PyLabRobot SDK not installed") +def test_star_rejects_unknown_builtin_before_emission(): + with pytest.raises(CompileError, match="Unknown Hamilton STAR liquid class"): + compile( + protocol(HamiltonLiquidClass("not_a_class")), + target("star"), + liquid_handler=LiquidHandler.STAR, + to=None, + ) diff --git a/tests/test_lmsf_layouts.py b/tests/test_lmsf_layouts.py new file mode 100644 index 0000000..94c2884 --- /dev/null +++ b/tests/test_lmsf_layouts.py @@ -0,0 +1,144 @@ +"""Audit the source layouts independently of the single executable recipe.""" + +import csv +import tomllib +from collections import Counter +from decimal import Decimal +from pathlib import Path + +import pytest + +from examples.concentration_series import inputs + +ROOT = Path(__file__).resolve().parents[1] / "docs/lmsf/assay-options" +LAYOUTS = { + "pooled-may": "2026-05-11_Mar-Pro_1_DNA-5-plates_growth-plate_5.csv", + "fitness-may": "2026-05-14_Mar-Pro_OD-test-5-plates_growth-plate_5.csv", + "fitness-july": "2026-07-23_Mar-Pro_OD-test-5-plates_growth-plate_5.csv", + "expression": "2026-08-13_Cytom-12-plasmids_cytom-plate_1-3.csv", + "pooled-august": "2026-08-19_Mar-Pro_1_DNA-5-plates_growth-plate_5.csv", +} + + +def rows(name): + with (ROOT / LAYOUTS[name]).open(newline="", encoding="utf-8") as stream: + return list(csv.DictReader(stream)) + + +def condition(row): + return tuple( + Decimal(row[key]) + for key in ("inducer1Concentration", "inducer2Concentration", "selectorConc") + ) + + +@pytest.mark.parametrize("name", LAYOUTS) +def test_all_480_rows_preserve_coordinates_schema_and_units(name): + layout = rows(name) + assert len(layout) == 96 + assert {r["well"] for r in layout} == {f"{r}{c}" for r in "ABCDEFGH" for c in range(1, 13)} + factors = ( + (("inducerId", "inducerConcentration", "inducerUnits"),) + if name == "expression" + else ( + ("inducer1Id", "inducer1Concentration", "inducer1Units"), + ("inducer2Id", "inducer2Concentration", "inducer2Units"), + ) + ) + (("selectorId", "selectorConc", "selectorUnits"),) + schema = {"position", "well", "row", "column", "strain", "plasmid"} + schema.update(key for factor in factors for key in factor) + for position, row in enumerate(layout, start=1): + assert set(row) == schema and all(value is not None for value in row.values()) + assert int(row["position"]) == position + assert row["well"] == f"{row['row']}{int(row['column'])}" + assert position == 8 * (int(row["column"]) - 1) + ord(row["row"]) - 64 + assert (row["strain"] == "none") == (row["plasmid"] == "none") + for name_key, value_key, unit_key in factors: + value = Decimal(row[value_key]) + assert value.is_finite() and value >= 0 + if name == "expression": + assert row[name_key] == "none" and value == 0 + assert row[unit_key] in ("N/A", "none") + else: + expected = { + "inducer1Id": ("Van", "umol/L"), + "inducer2Id": ("Sal", "umol/L"), + "selectorId": ("TMP", "ug/mL"), + } + assert (row[name_key], row[unit_key]) == expected[name_key] + + +@pytest.mark.parametrize( + "name,materials,conditions,combinations", + [ + ("pooled-may", 1, 24, 24), + ("fitness-may", 4, 23, 92), + ("fitness-july", 4, 24, 96), + ("pooled-august", 1, 24, 24), + ], +) +def test_material_assignments_and_distinct_conditions(name, materials, conditions, combinations): + layout = rows(name) + assert {r["strain"] for r in layout} == {"Mar-Clo"} + assert len({r["plasmid"] for r in layout}) == materials + assert len({condition(r) for r in layout}) == conditions + assert len({(r["plasmid"], condition(r)) for r in layout}) == combinations + if name.startswith("pooled"): + assert set(Counter(condition(r) for r in layout).values()) == {4} + pool = "Profluent_lib_1_mix" if name == "pooled-may" else "Profluent_lib_2_mix" + assert {r["plasmid"] for r in layout} == {pool} + else: + materials_by_row = dict( + zip( + "ABCDEFGH", + ( + "pDRAC18-2-A", + "pDRAC18-2-A", + "pDRAC27-2-A", + "pDRAC27-2-A", + "pDRAC28-2-A", + "pDRAC28-2-A", + "pDRAC33-2-A", + "pDRAC33-2-A", + ), + strict=True, + ) + ) + assert all(r["plasmid"] == materials_by_row[r["row"]] for r in layout) + + +def test_v6_layouts_differ_only_in_material_and_retain_asymmetric_series(): + july, august = rows("fitness-july"), rows("pooled-august") + assert [{k: v for k, v in r.items() if k != "plasmid"} for r in july] == [ + {k: v for k, v in r.items() if k != "plasmid"} for r in august + ] + assert all(a["plasmid"] != b["plasmid"] for a, b in zip(july, august, strict=True)) + low = [r for r in july if r["row"] == "A" and condition(r)[1:] == (10, 3)] + high = [r for r in july if r["row"] == "B" and condition(r)[1:] == (25, 3)] + assert len(low) == 9 and len(high) == 6 + assert low[0]["well"] == "A1" # one baseline occurrence, shared by analysis uses + + +def test_may_duplicate_baselines_and_unresolved_cytometry_positions(): + baselines = [r for r in rows("fitness-may") if condition(r) == (0, 0, 3)] + assert {r["well"] for r in baselines} == {f"{r}2" for r in "ABCDEFGH"} + assigned = [r for r in rows("expression") if r["plasmid"] != "none"] + assert len(assigned) == 12 and len({r["plasmid"] for r in assigned}) == 5 + assert {r["well"] for r in assigned} == {f"{r}{c}" for r in "BCDEFG" for c in (2, 5)} + assert {r["strain"] for r in assigned} == {"NEB-10-beta"} + assert sum(r["plasmid"] == "none" for r in rows("expression")) == 84 + + +def test_example_uses_the_complete_low_sal_series_for_one_material(): + config = inputs.ProtocolConfig.model_validate( + tomllib.loads((ROOT.parents[2] / "examples/concentration_series/config.toml").read_text()) + ) + selected = [r for r in rows("fitness-july") if r["row"] == "A" and condition(r)[1:] == (10, 3)] + assert tuple(r["well"] for r in selected) == config.wells + layout = inputs.PlateLayout.model_validate({"conditions": rows("fitness-july")}) + parsed = {r.well: r for r in layout.conditions} + for row in selected: + assert parsed[row["well"]].plasmid == row["plasmid"] == config.cultures[0].plasmid + assert (parsed[row["well"]].inducer1_concentration * parsed[row["well"]].inducer1_units).to( + "umol/liter" + ).magnitude == condition(row)[0] diff --git a/tests/test_units.py b/tests/test_units.py new file mode 100644 index 0000000..81658c7 --- /dev/null +++ b/tests/test_units.py @@ -0,0 +1,124 @@ +from decimal import Decimal + +import pytest + +from lab import Quantity, mL, uL +from lab.units import dilution_volume, parse_quantity, units + + +@pytest.mark.parametrize( + "stock,target,final,expected", + [ + (1 * units.millimolar, 25 * units.micromolar, Decimal("0.2") * mL, "5"), + ( + Decimal("0.3") * units.milligram / mL, + 3 * units.microgram / mL, + 200 * uL, + "2", + ), + (100 * units.micromolar, Decimal("0.78125") * units.micromolar, 200 * uL, "1.5625"), + (1 * units.millimolar, 0 * units.micromolar, 200 * uL, "0"), + (1 * units.millimolar, 1000 * units.micromolar, 200 * uL, "200"), + ], +) +def test_dilution_volume_normalizes_units_and_returns_decimal_microlitres( + stock, target, final, expected +): + result = dilution_volume( + stock_concentration=stock, target_concentration=target, final_volume=final + ) + assert isinstance(result, Quantity) + assert isinstance(result.magnitude, Decimal) + assert result.units == uL + assert result.magnitude == Decimal(expected) + + +@pytest.mark.parametrize( + "changes", + [ + {"stock_concentration": 100}, + {"stock_concentration": True}, + {"stock_concentration": 0 * units.micromolar}, + {"stock_concentration": -1 * units.micromolar}, + {"stock_concentration": Decimal("NaN") * units.micromolar}, + {"stock_concentration": Decimal("Infinity") * units.micromolar}, + {"stock_concentration": 1 * units.second}, + {"stock_concentration": 1 * uL}, + {"stock_concentration": units.Quantity(1, "dimensionless")}, + {"target_concentration": 10}, + {"target_concentration": -1 * units.micromolar}, + {"target_concentration": Decimal("NaN") * units.micromolar}, + {"target_concentration": Decimal("Infinity") * units.micromolar}, + {"target_concentration": 101 * units.micromolar}, + {"target_concentration": 1 * units.microgram / mL}, + {"target_concentration": 1 * units.second}, + {"final_volume": 200}, + {"final_volume": 0 * uL}, + {"final_volume": -1 * uL}, + {"final_volume": Decimal("Infinity") * uL}, + {"final_volume": 1 * units.second}, + ], +) +def test_dilution_volume_rejects_invalid_values_or_dimensions(changes): + values = dict( + stock_concentration=100 * units.micromolar, + target_concentration=10 * units.micromolar, + final_volume=200 * uL, + ) + values.update(changes) + with pytest.raises((TypeError, ValueError)): + dilution_volume(**values) + + +@pytest.mark.parametrize( + "value, unit, positive, expected", + [ + ("0.2 mL", "microliter", True, 200 * uL), + (Quantity(0.1, "milliliter"), "microliter", True, 100 * uL), + ("30 uL/minute", "microliter/second", True, Decimal("0.5") * uL / units.second), + ("0 uL", "microliter", False, 0 * uL), + ( + "3 ug/mL", + ("mole/liter", "gram/liter"), + True, + Decimal("0.003") * units.gram / units.liter, + ), + ( + "10 uM", + ("mole/liter", "gram/liter"), + True, + Decimal("0.00001") * units.mole / units.liter, + ), + ("0 uM", ("mole/liter", "gram/liter"), False, 0 * units.mole / units.liter), + ], +) +def test_parse_quantity_checks_dimensions_and_normalizes_decimal_values( + value, unit, positive, expected +): + parsed = parse_quantity(value, unit=unit, positive=positive) + assert parsed.units == expected.units + # Pint rounds reciprocal conversion factors (for example, 1/60) in Decimal arithmetic. + assert parsed.magnitude == pytest.approx( + expected.magnitude, rel=Decimal("1e-26"), abs=Decimal(0) + ) + assert isinstance(parsed.magnitude, Decimal) + + +@pytest.mark.parametrize( + "value", + [ + 10, + True, + None, + "10", + "10 seconds", + "1 unknown_unit", + "-1 uL", + "0 uL", + Quantity(Decimal("NaN"), "microliter"), + Quantity(Decimal("Infinity"), "microliter"), + ], +) +def test_parse_quantity_rejects_invalid_or_unitless_inputs(value): + with pytest.raises(ValueError): + parse_quantity(value, unit="microliter") diff --git a/uv.lock b/uv.lock index 50f150c..4140c84 100644 --- a/uv.lock +++ b/uv.lock @@ -388,6 +388,9 @@ dependencies = [ ] [package.optional-dependencies] +config = [ + { name = "pydantic" }, +] opentrons = [ { name = "opentrons" }, ] @@ -398,6 +401,7 @@ star = [ [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "pydantic" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -409,14 +413,16 @@ dev = [ requires-dist = [ { name = "opentrons", marker = "extra == 'opentrons'", specifier = "==8.8.2" }, { name = "pint", specifier = ">=0.24,<0.27" }, + { name = "pydantic", marker = "extra == 'config'", specifier = ">=2.13,<3" }, { name = "pylabrobot", marker = "extra == 'star'", specifier = "==0.2.1" }, { name = "sbol3", specifier = "==1.2.0.post0" }, ] -provides-extras = ["opentrons", "star"] +provides-extras = ["config", "opentrons", "star"] [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.17,<2" }, + { name = "pydantic", specifier = ">=2.13,<3" }, { name = "pytest", specifier = ">=8,<10" }, { name = "pytest-asyncio", specifier = ">=1,<2" }, { name = "ruff", specifier = ">=0.12,<1" }, From d82e86741ed47acadabd073d10088103957453b5 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Mon, 2 Nov 2026 14:05:39 +0000 Subject: [PATCH 2/7] Add example plate layout --- .gitignore | 2 + README.md | 8 +- examples/concentration_series/config.toml | 7 +- examples/concentration_series/layout.csv | 97 +++++++++++++++ tests/test_concentration_series.py | 10 +- tests/test_lmsf_layouts.py | 144 ---------------------- 6 files changed, 114 insertions(+), 154 deletions(-) create mode 100644 examples/concentration_series/layout.csv delete mode 100644 tests/test_lmsf_layouts.py diff --git a/.gitignore b/.gitignore index 0d31297..78d159d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ __pycache__/ build/ dist/ *.egg-info/ + +docs/lmsf/assay-options/ diff --git a/README.md b/README.md index 125a687..2d3a60f 100644 --- a/README.md +++ b/README.md @@ -188,17 +188,17 @@ The manifest uses logical container and well names; physical bindings remain in ## Concentration series -The standalone [concentration-series example](examples/concentration_series/) reads an LMSF fitness CSV and [protocol configuration](examples/concentration_series/config.toml), then records ordinary transfers and mixes. The supplied configuration selects nine July conditions. `lab.units.dilution_volume()` calculates stock volumes; the recipe balances vehicle and medium, inoculates, and mixes. Source identities and volumes, working-stock selection, and liquid classes are explicit. The TOML also declares development OT-2 and STAR deck layouts, parsed directly into Lab's existing `Deck` types. +The standalone [concentration-series example](examples/concentration_series/) reads a [plate layout](examples/concentration_series/layout.csv) and [protocol configuration](examples/concentration_series/config.toml), then records ordinary transfers and mixes. The supplied layout preserves the well coordinates and target concentrations of an LMSF fitness export, with generic strain and plasmid names. The configuration selects nine conditions for `example-plasmid-1`. `lab.units.dilution_volume()` calculates stock volumes; the recipe balances vehicle and medium, inoculates, and mixes. Source identities and volumes, working-stock selection, and liquid classes are explicit. The TOML also declares development OT-2 and STAR deck layouts, parsed directly into Lab's existing `Deck` types. `main()` parses the TOML into `ProtocolConfig` and the CSV into `PlateLayout` using Pydantic. The reusable quantity fields and liquid-class schemas come from [`lab.config`](docs/configuration.md), available through `lab-compiler[config]` and included in development dependencies (`uv sync --locked`). `prepare_wells()` checks the inputs together and calculates doses; `protocol()` consumes those prepared wells and the validated configuration to record operations. The configuration mirrors the TOML: `medium.name` is `config.medium.name`, and liquid-class names remain names until protocol construction resolves them. Saved configuration comes from that same model. ```sh uv run --no-sync python -m examples.concentration_series \ - --csv docs/lmsf/assay-options/2026-07-23_Mar-Pro_OD-test-5-plates_growth-plate_5.csv \ + --csv examples/concentration_series/layout.csv \ --config examples/concentration_series/config.toml --target manual ``` -Use `--target ot2` or `--target star` with the corresponding optional SDK installed; `--out` selects the bundle directory. The [LMSF component analysis](docs/lmsf/components.md) explains the inputs and development hardware. The example ends with an inoculated plate; incubation and measurement need separately specified methods. +Use `--target ot2` or `--target star` with the corresponding optional SDK installed; `--out` selects the bundle directory. The example ends with an inoculated plate; incubation and measurement need separately specified methods. `LiquidClass` supplies unit-checked aspiration and dispense rates on either robot family. `HamiltonLiquidClass` references a native STAR built-in, whose definition is captured during compilation. Both can be passed as `liquid_class=` to `transfer()`, `distribute()`, and `mix()`. See [liquid classes](docs/liquid-classes.md) for examples, settings coverage, calibration boundaries, and the data needed to compare with VENUS. @@ -213,6 +213,8 @@ uv run --no-sync mypy uv run --no-sync pytest ``` +The concentration-series tests use the same `layout.csv` and `config.toml` as the example. They exercise parsing, dose calculations, compilation, robot simulation, and provenance without additional data files or configuration. + CI runs the OT-2 equivalence tests against a pinned PUDU revision. To compare against another checkout, set `PUDU_SOURCE` to its `src` directory. When this variable is set, missing PUDU source or the Opentrons SDK fails the run. ```sh diff --git a/examples/concentration_series/config.toml b/examples/concentration_series/config.toml index dec4f5c..64fa0a0 100644 --- a/examples/concentration_series/config.toml +++ b/examples/concentration_series/config.toml @@ -1,5 +1,5 @@ # Illustrative preparation inputs, NOT recovered or validated LMSF method settings. -# Use with the July fitness CSV. The deck tables below declare development +# Use with layout.csv. The deck tables below declare development # equipment, capacities, tips, positions, and channel limits. # ProtocolConfig mirrors these tables and fields. Quantities include units; # liquid_class values name entries in the liquid_classes table below. @@ -8,6 +8,7 @@ final_volume = "200 uL" # includes inoculum and every stock addition inoculum_volume = "10 uL" vehicle_fraction = "0.02" # DMSO v/v; balancing source is pure DMSO source_dead_volume = "20 uL" + # This recipe handles independent single-solute stocks, not premixes or passage. # Assert known absence of every CSV solute and DMSO from medium AND all cultures. solute_and_vehicle_free_medium_and_cultures = true @@ -98,8 +99,8 @@ vehicle_fraction = "1" liquid_class = "dmso" [[cultures]] -strain = "Mar-Clo" -plasmid = "pDRAC18-2-A" +strain = "example-strain" +plasmid = "example-plasmid-1" well = "B1" volume = "300 uL" liquid_class = "culture" diff --git a/examples/concentration_series/layout.csv b/examples/concentration_series/layout.csv new file mode 100644 index 0000000..a0b2d7b --- /dev/null +++ b/examples/concentration_series/layout.csv @@ -0,0 +1,97 @@ +position,well,row,column,inducer1Id,inducer1Concentration,inducer1Units,inducer2Id,inducer2Concentration,inducer2Units,selectorId,selectorConc,selectorUnits,strain,plasmid +1,A1,A,1,Van,0,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +2,B1,B,1,Van,0,umol/L,Sal,14.2857142857143,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +3,C1,C,1,Van,0,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +4,D1,D,1,Van,0,umol/L,Sal,14.2857142857143,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +5,E1,E,1,Van,0,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +6,F1,F,1,Van,0,umol/L,Sal,14.2857142857143,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +7,G1,G,1,Van,0,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +8,H1,H,1,Van,0,umol/L,Sal,14.2857142857143,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +9,A2,A,2,Van,0,umol/L,Sal,5,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +10,B2,B,2,Van,0,umol/L,Sal,7.14285714285714,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +11,C2,C,2,Van,0,umol/L,Sal,5,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +12,D2,D,2,Van,0,umol/L,Sal,7.14285714285714,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +13,E2,E,2,Van,0,umol/L,Sal,5,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +14,F2,F,2,Van,0,umol/L,Sal,7.14285714285714,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +15,G2,G,2,Van,0,umol/L,Sal,5,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +16,H2,H,2,Van,0,umol/L,Sal,7.14285714285714,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +17,A3,A,3,Van,0,umol/L,Sal,2.5,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +18,B3,B,3,Van,0,umol/L,Sal,3.57142857142857,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +19,C3,C,3,Van,0,umol/L,Sal,2.5,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +20,D3,D,3,Van,0,umol/L,Sal,3.57142857142857,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +21,E3,E,3,Van,0,umol/L,Sal,2.5,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +22,F3,F,3,Van,0,umol/L,Sal,3.57142857142857,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +23,G3,G,3,Van,0,umol/L,Sal,2.5,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +24,H3,H,3,Van,0,umol/L,Sal,3.57142857142857,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +25,A4,A,4,Van,0,umol/L,Sal,0,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +26,B4,B,4,Van,0,umol/L,Sal,0,umol/L,TMP,0,ug/mL,example-strain,example-plasmid-1 +27,C4,C,4,Van,0,umol/L,Sal,0,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +28,D4,D,4,Van,0,umol/L,Sal,0,umol/L,TMP,0,ug/mL,example-strain,example-plasmid-2 +29,E4,E,4,Van,0,umol/L,Sal,0,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +30,F4,F,4,Van,0,umol/L,Sal,0,umol/L,TMP,0,ug/mL,example-strain,example-plasmid-3 +31,G4,G,4,Van,0,umol/L,Sal,0,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +32,H4,H,4,Van,0,umol/L,Sal,0,umol/L,TMP,0,ug/mL,example-strain,example-plasmid-4 +33,A5,A,5,Van,0.78125,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +34,B5,B,5,Van,20,umol/L,Sal,0,umol/L,TMP,0,ug/mL,example-strain,example-plasmid-1 +35,C5,C,5,Van,0.78125,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +36,D5,D,5,Van,20,umol/L,Sal,0,umol/L,TMP,0,ug/mL,example-strain,example-plasmid-2 +37,E5,E,5,Van,0.78125,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +38,F5,F,5,Van,20,umol/L,Sal,0,umol/L,TMP,0,ug/mL,example-strain,example-plasmid-3 +39,G5,G,5,Van,0.78125,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +40,H5,H,5,Van,20,umol/L,Sal,0,umol/L,TMP,0,ug/mL,example-strain,example-plasmid-4 +41,A6,A,6,Van,1.5625,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +42,B6,B,6,Van,100,umol/L,Sal,0,umol/L,TMP,0,ug/mL,example-strain,example-plasmid-1 +43,C6,C,6,Van,1.5625,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +44,D6,D,6,Van,100,umol/L,Sal,0,umol/L,TMP,0,ug/mL,example-strain,example-plasmid-2 +45,E6,E,6,Van,1.5625,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +46,F6,F,6,Van,100,umol/L,Sal,0,umol/L,TMP,0,ug/mL,example-strain,example-plasmid-3 +47,G6,G,6,Van,1.5625,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +48,H6,H,6,Van,100,umol/L,Sal,0,umol/L,TMP,0,ug/mL,example-strain,example-plasmid-4 +49,A7,A,7,Van,3.125,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +50,B7,B,7,Van,0,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +51,C7,C,7,Van,3.125,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +52,D7,D,7,Van,0,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +53,E7,E,7,Van,3.125,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +54,F7,F,7,Van,0,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +55,G7,G,7,Van,3.125,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +56,H7,H,7,Van,0,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +57,A8,A,8,Van,6.25,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +58,B8,B,8,Van,6.25,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +59,C8,C,8,Van,6.25,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +60,D8,D,8,Van,6.25,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +61,E8,E,8,Van,6.25,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +62,F8,F,8,Van,6.25,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +63,G8,G,8,Van,6.25,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +64,H8,H,8,Van,6.25,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +65,A9,A,9,Van,12.5,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +66,B9,B,9,Van,12.5,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +67,C9,C,9,Van,12.5,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +68,D9,D,9,Van,12.5,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +69,E9,E,9,Van,12.5,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +70,F9,F,9,Van,12.5,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +71,G9,G,9,Van,12.5,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +72,H9,H,9,Van,12.5,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +73,A10,A,10,Van,25,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +74,B10,B,10,Van,25,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +75,C10,C,10,Van,25,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +76,D10,D,10,Van,25,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +77,E10,E,10,Van,25,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +78,F10,F,10,Van,25,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +79,G10,G,10,Van,25,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +80,H10,H,10,Van,25,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +81,A11,A,11,Van,50,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +82,B11,B,11,Van,50,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +83,C11,C,11,Van,50,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +84,D11,D,11,Van,50,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +85,E11,E,11,Van,50,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +86,F11,F,11,Van,50,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +87,G11,G,11,Van,50,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +88,H11,H,11,Van,50,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +89,A12,A,12,Van,100,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +90,B12,B,12,Van,100,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-1 +91,C12,C,12,Van,100,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +92,D12,D,12,Van,100,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-2 +93,E12,E,12,Van,100,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +94,F12,F,12,Van,100,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-3 +95,G12,G,12,Van,100,umol/L,Sal,10,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 +96,H12,H,12,Van,100,umol/L,Sal,25,umol/L,TMP,3,ug/mL,example-strain,example-plasmid-4 diff --git a/tests/test_concentration_series.py b/tests/test_concentration_series.py index d31399a..00f0e41 100644 --- a/tests/test_concentration_series.py +++ b/tests/test_concentration_series.py @@ -25,7 +25,7 @@ from lab.units import units ROOT = Path(__file__).resolve().parents[1] -CSV = ROOT / "docs/lmsf/assay-options/2026-07-23_Mar-Pro_OD-test-5-plates_growth-plate_5.csv" +CSV = ROOT / "examples/concentration_series/layout.csv" CONFIG = ROOT / "examples/concentration_series/config.toml" @@ -56,6 +56,7 @@ def protocol(): def test_preparation_matches_independent_doses_inventory_and_identity(): bundle = compile(protocol(), to=None) transfers = [s for s in bundle.protocol.steps if isinstance(s, Transfer)] + # Expected Van dose in uL; fixed additions are 20 Sal + 2 TMP + 2 DMSO + 10 culture. expected = { "A1": (None, "0"), @@ -105,7 +106,7 @@ def test_preparation_matches_independent_doses_inventory_and_identity(): assert len(bundle.manifest.samples) == 9 assert {p.location.well for p in bundle.manifest.placements} == set(expected) assert all( - s.material_identity == "pDRAC18-2-A" + s.material_identity == "example-plasmid-1" and s.parent_ids == ("inoculum-B1",) and s.replicate is None for s in bundle.manifest.samples @@ -308,6 +309,7 @@ def test_config_round_trip_preserves_toml_shape_and_resolves_classes_explicitly( assert star.carriers[0].location == Rail(15) assert isinstance(config.liquid_classes["aqueous"], PortableLiquidClassConfig) assert inputs.ProtocolConfig.model_validate_json(config.model_dump_json()) == config + # Optional native definitions retain their config alias until explicit resolution. raw["liquid_classes"]["aqueous"] = { "kind": "hamilton", @@ -459,8 +461,8 @@ def test_cli_config_and_csv_provenance_match_the_prepared_inputs(tmp_path, monke [ (None, ("0.78125", "NaN"), "finite"), (None, ("inducer1Units", "unknownColumn"), "CSV schema"), - (None, ('"A5","A",5', '"A5","A",6'), "Inconsistent position"), - (None, ("pDRAC18-2-A", "unknown-plasmid"), "no source"), + (None, ("A5,A,5", "A5,A,6"), "Inconsistent position"), + (None, ("example-plasmid-1", "unknown-plasmid"), "no source"), (('"A1", "A5"', '"A1", "A13"'), None, "wells.1"), (('solute = "Van"', 'solute = "unknown"'), None, "no stock"), (('vehicle_fraction = "0.02"', 'vehicle_fraction = "0.005"'), None, "exceed"), diff --git a/tests/test_lmsf_layouts.py b/tests/test_lmsf_layouts.py deleted file mode 100644 index 94c2884..0000000 --- a/tests/test_lmsf_layouts.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Audit the source layouts independently of the single executable recipe.""" - -import csv -import tomllib -from collections import Counter -from decimal import Decimal -from pathlib import Path - -import pytest - -from examples.concentration_series import inputs - -ROOT = Path(__file__).resolve().parents[1] / "docs/lmsf/assay-options" -LAYOUTS = { - "pooled-may": "2026-05-11_Mar-Pro_1_DNA-5-plates_growth-plate_5.csv", - "fitness-may": "2026-05-14_Mar-Pro_OD-test-5-plates_growth-plate_5.csv", - "fitness-july": "2026-07-23_Mar-Pro_OD-test-5-plates_growth-plate_5.csv", - "expression": "2026-08-13_Cytom-12-plasmids_cytom-plate_1-3.csv", - "pooled-august": "2026-08-19_Mar-Pro_1_DNA-5-plates_growth-plate_5.csv", -} - - -def rows(name): - with (ROOT / LAYOUTS[name]).open(newline="", encoding="utf-8") as stream: - return list(csv.DictReader(stream)) - - -def condition(row): - return tuple( - Decimal(row[key]) - for key in ("inducer1Concentration", "inducer2Concentration", "selectorConc") - ) - - -@pytest.mark.parametrize("name", LAYOUTS) -def test_all_480_rows_preserve_coordinates_schema_and_units(name): - layout = rows(name) - assert len(layout) == 96 - assert {r["well"] for r in layout} == {f"{r}{c}" for r in "ABCDEFGH" for c in range(1, 13)} - factors = ( - (("inducerId", "inducerConcentration", "inducerUnits"),) - if name == "expression" - else ( - ("inducer1Id", "inducer1Concentration", "inducer1Units"), - ("inducer2Id", "inducer2Concentration", "inducer2Units"), - ) - ) + (("selectorId", "selectorConc", "selectorUnits"),) - schema = {"position", "well", "row", "column", "strain", "plasmid"} - schema.update(key for factor in factors for key in factor) - for position, row in enumerate(layout, start=1): - assert set(row) == schema and all(value is not None for value in row.values()) - assert int(row["position"]) == position - assert row["well"] == f"{row['row']}{int(row['column'])}" - assert position == 8 * (int(row["column"]) - 1) + ord(row["row"]) - 64 - assert (row["strain"] == "none") == (row["plasmid"] == "none") - for name_key, value_key, unit_key in factors: - value = Decimal(row[value_key]) - assert value.is_finite() and value >= 0 - if name == "expression": - assert row[name_key] == "none" and value == 0 - assert row[unit_key] in ("N/A", "none") - else: - expected = { - "inducer1Id": ("Van", "umol/L"), - "inducer2Id": ("Sal", "umol/L"), - "selectorId": ("TMP", "ug/mL"), - } - assert (row[name_key], row[unit_key]) == expected[name_key] - - -@pytest.mark.parametrize( - "name,materials,conditions,combinations", - [ - ("pooled-may", 1, 24, 24), - ("fitness-may", 4, 23, 92), - ("fitness-july", 4, 24, 96), - ("pooled-august", 1, 24, 24), - ], -) -def test_material_assignments_and_distinct_conditions(name, materials, conditions, combinations): - layout = rows(name) - assert {r["strain"] for r in layout} == {"Mar-Clo"} - assert len({r["plasmid"] for r in layout}) == materials - assert len({condition(r) for r in layout}) == conditions - assert len({(r["plasmid"], condition(r)) for r in layout}) == combinations - if name.startswith("pooled"): - assert set(Counter(condition(r) for r in layout).values()) == {4} - pool = "Profluent_lib_1_mix" if name == "pooled-may" else "Profluent_lib_2_mix" - assert {r["plasmid"] for r in layout} == {pool} - else: - materials_by_row = dict( - zip( - "ABCDEFGH", - ( - "pDRAC18-2-A", - "pDRAC18-2-A", - "pDRAC27-2-A", - "pDRAC27-2-A", - "pDRAC28-2-A", - "pDRAC28-2-A", - "pDRAC33-2-A", - "pDRAC33-2-A", - ), - strict=True, - ) - ) - assert all(r["plasmid"] == materials_by_row[r["row"]] for r in layout) - - -def test_v6_layouts_differ_only_in_material_and_retain_asymmetric_series(): - july, august = rows("fitness-july"), rows("pooled-august") - assert [{k: v for k, v in r.items() if k != "plasmid"} for r in july] == [ - {k: v for k, v in r.items() if k != "plasmid"} for r in august - ] - assert all(a["plasmid"] != b["plasmid"] for a, b in zip(july, august, strict=True)) - low = [r for r in july if r["row"] == "A" and condition(r)[1:] == (10, 3)] - high = [r for r in july if r["row"] == "B" and condition(r)[1:] == (25, 3)] - assert len(low) == 9 and len(high) == 6 - assert low[0]["well"] == "A1" # one baseline occurrence, shared by analysis uses - - -def test_may_duplicate_baselines_and_unresolved_cytometry_positions(): - baselines = [r for r in rows("fitness-may") if condition(r) == (0, 0, 3)] - assert {r["well"] for r in baselines} == {f"{r}2" for r in "ABCDEFGH"} - assigned = [r for r in rows("expression") if r["plasmid"] != "none"] - assert len(assigned) == 12 and len({r["plasmid"] for r in assigned}) == 5 - assert {r["well"] for r in assigned} == {f"{r}{c}" for r in "BCDEFG" for c in (2, 5)} - assert {r["strain"] for r in assigned} == {"NEB-10-beta"} - assert sum(r["plasmid"] == "none" for r in rows("expression")) == 84 - - -def test_example_uses_the_complete_low_sal_series_for_one_material(): - config = inputs.ProtocolConfig.model_validate( - tomllib.loads((ROOT.parents[2] / "examples/concentration_series/config.toml").read_text()) - ) - selected = [r for r in rows("fitness-july") if r["row"] == "A" and condition(r)[1:] == (10, 3)] - assert tuple(r["well"] for r in selected) == config.wells - layout = inputs.PlateLayout.model_validate({"conditions": rows("fitness-july")}) - parsed = {r.well: r for r in layout.conditions} - for row in selected: - assert parsed[row["well"]].plasmid == row["plasmid"] == config.cultures[0].plasmid - assert (parsed[row["well"]].inducer1_concentration * parsed[row["well"]].inducer1_units).to( - "umol/liter" - ).magnitude == condition(row)[0] From b22da30ba00c220453f00264123dca259bbdef15 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Mon, 2 Nov 2026 14:15:51 +0000 Subject: [PATCH 3/7] Fix example CLI --- README.md | 2 +- examples/concentration_series/__main__.py | 4 ++-- tests/test_concentration_series.py | 23 ++++++++++++++++------- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2d3a60f..e21f062 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ uv run --no-sync python -m examples.concentration_series \ --config examples/concentration_series/config.toml --target manual ``` -Use `--target ot2` or `--target star` with the corresponding optional SDK installed; `--out` selects the bundle directory. The example ends with an inoculated plate; incubation and measurement need separately specified methods. +Use `--target ot2` or `--target star` with the corresponding optional SDK installed. Output goes to `build/concentration_series/csv//`; `--out` selects another directory. Rerunning replaces the generated bundle in that directory. The example ends with an inoculated plate; incubation and measurement need separately specified methods. `LiquidClass` supplies unit-checked aspiration and dispense rates on either robot family. `HamiltonLiquidClass` references a native STAR built-in, whose definition is captured during compilation. Both can be passed as `liquid_class=` to `transfer()`, `distribute()`, and `mix()`. See [liquid classes](docs/liquid-classes.md) for examples, settings coverage, calibration boundaries, and the data needed to compare with VENUS. diff --git a/examples/concentration_series/__main__.py b/examples/concentration_series/__main__.py index b20a278..788cec5 100644 --- a/examples/concentration_series/__main__.py +++ b/examples/concentration_series/__main__.py @@ -51,9 +51,9 @@ def main() -> None: p, deck=config.deck if handler else None, liquid_handler=handler, - to=None, + to=args.out or f"build/concentration_series/csv/{args.target}", ) - print(bundle.write(args.out or f"build/concentration_series/csv/{args.target}")) + print(bundle.directory) except (ValueError, TypeError, KeyError, OSError) as exc: parser.error(str(exc)) diff --git a/tests/test_concentration_series.py b/tests/test_concentration_series.py index 00f0e41..7514b43 100644 --- a/tests/test_concentration_series.py +++ b/tests/test_concentration_series.py @@ -438,13 +438,18 @@ def unexpected(*args, **kwargs): ) -def test_cli_config_and_csv_provenance_match_the_prepared_inputs(tmp_path, monkeypatch): - output = tmp_path / "compiled" - monkeypatch.setattr( - sys, - "argv", - ["concentration_series", "--csv", str(CSV), "--config", str(CONFIG), "--out", str(output)], - ) +@pytest.mark.parametrize("explicit_output", [False, True]) +def test_cli_replaces_existing_bundle_with_current_inputs(tmp_path, monkeypatch, explicit_output): + monkeypatch.chdir(tmp_path) + output = tmp_path / ("compiled" if explicit_output else "build/concentration_series/csv/manual") + output.mkdir(parents=True) + for name in ("plan.json", "protocol.html", "manifest.json", "protocol.py"): + (output / name).write_text("Previous compilation") + (output / "notes.txt").write_text("Keep these notes") + argv = ["concentration_series", "--csv", str(CSV), "--config", str(CONFIG)] + if explicit_output: + argv.extend(["--out", str(output)]) + monkeypatch.setattr(sys, "argv", argv) cli.main() plan = json.loads((output / "plan.json").read_text()) description = plan["protocol"]["description"] @@ -454,6 +459,10 @@ def test_cli_config_and_csv_provenance_match_the_prepared_inputs(tmp_path, monke description.split("Protocol configuration: ", 1)[1] ) == configuration().model_dump(mode="json") assert len(plan["protocol"]["steps"]) == 71 + assert "Previous compilation" not in (output / "protocol.html").read_text() + assert len(json.loads((output / "manifest.json").read_text())["outputs"]) == 9 + assert not (output / "protocol.py").exists() + assert (output / "notes.txt").read_text() == "Keep these notes" @pytest.mark.parametrize( From b9ba456c33f0b9af4e99c044f7a420ee3ff0dd35 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Mon, 2 Nov 2026 16:36:33 +0000 Subject: [PATCH 4/7] Refine the manual protocol worksheet UI --- README.md | 6 + docs/worksheet-ui.md | 47 ++++ examples/concentration_series/__main__.py | 6 +- examples/concentration_series/protocol.py | 4 +- pyproject.toml | 1 + src/lab/documents.py | 148 ----------- src/lab/documents/__init__.py | 6 + src/lab/documents/assets/flow-profile.css | 17 ++ src/lab/documents/assets/flow-profile.js | 33 +++ src/lab/documents/assets/inspector.css | 29 +++ src/lab/documents/assets/inspector.js | 204 +++++++++++++++ src/lab/documents/assets/print.css | 31 +++ src/lab/documents/assets/responsive.css | 17 ++ src/lab/documents/assets/worksheet.css | 75 ++++++ src/lab/documents/descriptions.py | 68 +++++ src/lab/documents/flow.py | 85 ++++++ src/lab/documents/models.py | 88 +++++++ src/lab/documents/prepare.py | 243 ++++++++++++++++++ src/lab/documents/render.py | 39 +++ .../templates/components/detail_cards.html | 44 ++++ .../templates/components/flow_profile.html | 46 ++++ .../templates/components/inspector.html | 7 + .../documents/templates/components/ui.html | 37 +++ .../documents/templates/sections/details.html | 19 ++ .../templates/sections/materials.html | 36 +++ .../templates/sections/procedure.html | 34 +++ .../documents/templates/sections/volumes.html | 19 ++ src/lab/documents/templates/worksheet.html | 41 +++ tests/test_documents.py | 170 ++++++++++++ 29 files changed, 1449 insertions(+), 151 deletions(-) create mode 100644 docs/worksheet-ui.md delete mode 100644 src/lab/documents.py create mode 100644 src/lab/documents/__init__.py create mode 100644 src/lab/documents/assets/flow-profile.css create mode 100644 src/lab/documents/assets/flow-profile.js create mode 100644 src/lab/documents/assets/inspector.css create mode 100644 src/lab/documents/assets/inspector.js create mode 100644 src/lab/documents/assets/print.css create mode 100644 src/lab/documents/assets/responsive.css create mode 100644 src/lab/documents/assets/worksheet.css create mode 100644 src/lab/documents/descriptions.py create mode 100644 src/lab/documents/flow.py create mode 100644 src/lab/documents/models.py create mode 100644 src/lab/documents/prepare.py create mode 100644 src/lab/documents/render.py create mode 100644 src/lab/documents/templates/components/detail_cards.html create mode 100644 src/lab/documents/templates/components/flow_profile.html create mode 100644 src/lab/documents/templates/components/inspector.html create mode 100644 src/lab/documents/templates/components/ui.html create mode 100644 src/lab/documents/templates/sections/details.html create mode 100644 src/lab/documents/templates/sections/materials.html create mode 100644 src/lab/documents/templates/sections/procedure.html create mode 100644 src/lab/documents/templates/sections/volumes.html create mode 100644 src/lab/documents/templates/worksheet.html create mode 100644 tests/test_documents.py diff --git a/README.md b/README.md index e21f062..29c9ed4 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,12 @@ The cloning example writes separate `assembly`, `transformation`, and `plating` The manifest uses logical container and well names; physical bindings remain in `plan.json`. Rewriting an identical bundle succeeds. If any generated file would replace different contents, `write()` rejects the write before changing any bundle files. +The HTML worksheet shows initial loads, numbered operations, liquid-class settings, and calculated final volumes. Hover or focus a material, location, resource, or liquid class to inspect its recorded details; click to keep the card open and press Escape to dismiss it. Location cards distinguish initial loads, planned final volumes, output samples, and incoming transfers. The first paragraph of `Protocol.description` is the overview; subsequent paragraphs appear in expandable protocol notes. Styles and interactions are embedded for offline use, with a plain reference section when JavaScript is disabled and a separate print layout. + +The [worksheet UI guide](docs/worksheet-ui.md) describes its typed view data, reusable templates, and interactive components. + +Liquid-class cards plot flow rate against time for the selected operation. Aspiration is positive and dispensing negative; mixing repeats those phases. Durations are ideal calculations from nominal volume divided by the recorded rate, excluding robot movement, pauses, ramps, air gaps, and volume correction. These diagrams are not measured device traces. Native Hamilton profiles require rates captured during STAR compilation. + ## Concentration series The standalone [concentration-series example](examples/concentration_series/) reads a [plate layout](examples/concentration_series/layout.csv) and [protocol configuration](examples/concentration_series/config.toml), then records ordinary transfers and mixes. The supplied layout preserves the well coordinates and target concentrations of an LMSF fitness export, with generic strain and plasmid names. The configuration selects nine conditions for `example-plasmid-1`. `lab.units.dilution_volume()` calculates stock volumes; the recipe balances vehicle and medium, inoculates, and mixes. Source identities and volumes, working-stock selection, and liquid classes are explicit. The TOML also declares development OT-2 and STAR deck layouts, parsed directly into Lab's existing `Deck` types. diff --git a/docs/worksheet-ui.md b/docs/worksheet-ui.md new file mode 100644 index 0000000..ff7f21d --- /dev/null +++ b/docs/worksheet-ui.md @@ -0,0 +1,47 @@ +# Protocol worksheet UI + +`Compilation.files["protocol.html"]` is a standalone document. Python renders the complete worksheet, then embeds the packaged CSS and JavaScript. It opens directly from disk, needs no network access, and has a print layout. JavaScript adds inspection and chart selection to content that already exists in the document. + +The implementation lives in `src/lab/documents/`: + +| File or directory | Responsibility | +| --- | --- | +| `models.py` | Typed presentation records for resources, wells, references, procedure rows, and liquid classes. | +| `prepare.py` | Derive those records from a compilation, including initial loads, sample identity, physical bindings, incoming transfers, and calculated final volumes. | +| `flow.py` | Calculate the numeric phases and coordinates of idealized flow profiles. | +| `templates/worksheet.html` | Compose the page and embed its assets. | +| `templates/sections/` | Materials, ordered procedure, final volumes, and reference details. | +| `templates/components/` | Reusable references, tables, headings, fields, detail cards, inspector, and flow charts. | +| `assets/` | Page and component styles, responsive and print rules, and interactive components. | +| `render.py` | Configure Jinja, load packaged assets in a fixed order, and render the page. | +| `descriptions.py` | Plain-text operation descriptions shared with robot backends. | + +## Data and rendering + +The recorded protocol and target plan supply the data. `build_worksheet()` prepares frozen dataclasses containing text, quantities, and references. It does not produce HTML. Flow profiles use nominal volume divided by rate; they do not represent measured device traces. + +Jinja templates own HTML and SVG. Shared macros in `components/ui.html` render links, tables, detail fields, and card frames; the sections and detail-card components compose them. A reference has one stable anchor and a label, so the same well can be linked from materials, procedure steps, final volumes, and other cards. + +Autoescaping applies to every template, including attributes and SVG. Missing template fields raise an error. Only the repository's static CSS and JavaScript use `safe`; protocol text must always pass through normal autoescaping. Keep HTML out of the Python view data and compiler models. + +## Interactive components + +`` owns the preview panel, pinning, positioning, dismissal, and focus handling. It handles references inside its enclosing `` and clones the corresponding reference card into its panel. The original cards remain available in the reference section. Without JavaScript, links navigate directly to those cards. + +`` owns its operation selector and phase readout. It works both in the reference section and in an inspector card. Its `selectOperation(stepId)` method lets the inspector choose the operation associated with a clicked procedure row. The inspector does not calculate chart data or handle chart controls. + +Both components use light DOM, so the templates, stylesheet, browser accessibility tree, and print rules share the same markup. Each instance owns its state and removes listeners and observers when disconnected. + +## Making changes + +For a content change, update the relevant view record and preparation code, then its template. For a visual change, edit the component template and stylesheet. For a new interaction, give the component its own behavior instead of adding chart or card logic to the page renderer. Add any new asset to the ordered manifests in `render.py`; there is no separate frontend build or runtime asset fetch. + +Run `uv run pytest tests/test_documents.py` for operation fidelity, escaping, references, and flow calculations. Regenerate the concentration-series example to check the complete worksheet: + +```sh +uv run python -m examples.concentration_series \ + --csv examples/concentration_series/layout.csv \ + --config examples/concentration_series/config.toml --target manual +``` + +Open `build/concentration_series/csv/manual/protocol.html`. Check hover and click inspection, nested references, Tab and Escape, operation selection and chart phase readouts, a narrow viewport, print preview, and reference navigation with JavaScript disabled. Templates and assets ship inside the Python package, so packaging changes also need a render check from the built wheel. diff --git a/examples/concentration_series/__main__.py b/examples/concentration_series/__main__.py index 788cec5..a8e9a9d 100644 --- a/examples/concentration_series/__main__.py +++ b/examples/concentration_series/__main__.py @@ -42,9 +42,11 @@ def main() -> None: layout = PlateLayout.model_validate({"conditions": list(reader)}) prepared_wells = prepare_wells(layout, config) p = protocol(prepared_wells, config) + summary, _, notes = p.description.partition("\n\n") p.description = ( - f"Targets from {args.csv.name} (SHA256 {hashlib.sha256(csv_bytes).hexdigest()}). " - + p.description + f"{summary}\n\n" + f"Targets from {args.csv.name} (SHA256 {hashlib.sha256(csv_bytes).hexdigest()}).\n\n" + f"{notes}" ) handler = None if args.target == "manual" else LiquidHandler(args.target) bundle = compile( diff --git a/examples/concentration_series/protocol.py b/examples/concentration_series/protocol.py index 0617af2..32f2d9d 100644 --- a/examples/concentration_series/protocol.py +++ b/examples/concentration_series/protocol.py @@ -25,7 +25,9 @@ def protocol(prepared_wells: Sequence[PreparedWell], config: ProtocolConfig) -> } p = Protocol( "CSV culture preparation", - description="Concentrations refer to final volume after inoculation. " + description=f"Prepare {len(prepared_wells)} culture wells " + f"at {config.final_volume:~P} each. " + "Concentrations refer to final volume after inoculation.\n\n" f"Protocol configuration: {config.model_dump_json(indent=2)}", ) diff --git a/pyproject.toml b/pyproject.toml index b8169fc..e9fd1b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ + "jinja2>=3.1.6,<4", "pint>=0.24,<0.27", "sbol3==1.2.0.post0", ] diff --git a/src/lab/documents.py b/src/lab/documents.py deleted file mode 100644 index 1c60d14..0000000 --- a/src/lab/documents.py +++ /dev/null @@ -1,148 +0,0 @@ -"""A printable document rendered from the same frozen data as robot code.""" - -from html import escape -from typing import Any - -from lab.liquid_classes import HamiltonLiquidClass, LiquidClass -from lab.model import ( - Distribute, - ManualInstruction, - Mix, - SetTemperature, - Step, - Thermocycle, - Transfer, - Wait, -) -from lab.units import number - - -def describe(step: Step) -> str: - liquid = "" - if isinstance(step, (Transfer, Distribute, Mix)): - if isinstance(step.liquid_class, LiquidClass): - spec = step.liquid_class - liquid = ( - f" Liquid class {spec.name}: aspirate {number(spec.aspiration_flow_rate_ul_s)} " - f"µL/s; dispense {number(spec.dispense_flow_rate_ul_s)} µL/s." - ) - elif isinstance(step.liquid_class, HamiltonLiquidClass): - liquid = f" Hamilton STAR liquid class {step.liquid_class.name}." - match step: - case Transfer(source, destination, volume, _): - return f"Transfer {number(volume)} µL from {source} to {destination}.{liquid}" - 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 "" - return f"Distribute {number(volume)} µL from {source} to {wells}.{gap}{liquid}" - case Mix(location, volume, cycles, _): - return f"Mix {location}: {cycles} cycles of {number(volume)} µL.{liquid}" - case Wait(seconds, _): - unit = "second" if seconds == 1 else "seconds" - return f"Wait {number(seconds)} {unit}." - case Thermocycle(resource, profile, cycles, lid, _, block_volume): - holds = "; ".join( - f"{number(hold.celsius)} °C for {number(hold.seconds)} s" for hold in profile - ) - lid_text = ( - "lid follows block; lid heating off below the device's heated lid range" - if lid is None - else f"lid at {number(lid)} °C" - ) - block = ( - f" Reported block volume {number(block_volume)} µL." - if block_volume is not None - else "" - ) - return ( - f"Thermocycle all of {resource}: {holds}; {cycles} cycle(s); {lid_text}. " - "Wait for completion, keep the final temperature settings, and release the plate." - f"{block}" - ) - case SetTemperature(resource, celsius, _): - return ( - f"Hold {resource} at {number(celsius)} °C. " - "For a device with a heated lid, match its target within the heated lid range; " - "turn lid heating off below that range." - ) - case ManualInstruction(text, _): - return f"Operator: {text}" - case _: - raise TypeError(f"Unsupported step: {type(step).__name__}") - - -def render(compilation: Any) -> str: - p, target = compilation.protocol, compilation.target - bindings = {binding.location: binding for binding in target.bindings} - resources = [] - for resource in p.resources: - fills = ( - "; ".join( - f"{fill.well}: {fill.material}, {number(fill.volume)} µL" for fill in resource.fills - ) - or "Initially empty" - ) - locations = [ - binding.physical - for location, binding in bindings.items() - if location.resource == resource.name - ] - physical = ( - ", ".join(locations) - if len(locations) <= 4 - else f"{locations[0]} … {locations[-1]} ({len(locations)} wells; see plan.json)" - ) - resources.append( - f"{escape(resource.name)}{escape(fills)}" - f"{escape(physical)}" - ) - steps = "".join(f"
  • {escape(describe(step))}

  • " for step in p.steps) - setup = ( - "

    Equipment setup

      " - + "".join(f"
    • {escape(line)}
    • " for line in target.setup) - + "
    " - if target.setup - else "" - ) - totals = "".join( - f"{escape(str(location))}{number(volume)} µL" - for location, volume in compilation.final_volumes - if volume - ) - return f""" - - -{escape(p.name)} - -
    Lab / Compiled protocol
    -

    {escape(p.name)}

    {escape(p.description)}

    -

    Target: {escape(target.name)}

    -

    Planned quantities. Use a fresh tip for each transfer or mix.

    -{setup} -

    Materials and placement

    - -{"".join(resources)}
    ResourceDeclared initial contentsPhysical locations
    -

    Procedure

      {steps}
    -

    Calculated final contents

    {totals}
    -
    Plan {compilation.digest}
    Quantities are in microlitres unless stated otherwise. -Inspect plan.json for complete bindings and target configuration.
    - -""" diff --git a/src/lab/documents/__init__.py b/src/lab/documents/__init__.py new file mode 100644 index 0000000..77a9e51 --- /dev/null +++ b/src/lab/documents/__init__.py @@ -0,0 +1,6 @@ +"""Human-readable protocol descriptions and standalone HTML worksheets.""" + +from lab.documents.descriptions import describe +from lab.documents.render import render + +__all__ = ["describe", "render"] diff --git a/src/lab/documents/assets/flow-profile.css b/src/lab/documents/assets/flow-profile.css new file mode 100644 index 0000000..a6b7d28 --- /dev/null +++ b/src/lab/documents/assets/flow-profile.css @@ -0,0 +1,17 @@ +lab-flow-profile { display: block; } +.flow-chart { margin: 16px 0 0; border-top: 1px solid var(--line); padding-top: 16px; } +.flow-chart figcaption { font-size: 12px; font-weight: 650; margin-bottom: 14px; } +.profile-select { display: block; font-size: 11px; color: var(--muted); margin-top: 16px; } +.profile-select select { display: block; width: 100%; margin-top: 6px; padding: 8px; background: #f7f9f7; border: 1px solid var(--line); border-radius: 4px; color: #263b39; font: inherit; } +.profile-select select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.flow-chart svg { width: 100%; display: block; overflow: visible; } +.flow-chart svg text { fill: var(--muted); font-size: 9px; font-family: inherit; } +.flow-grid { stroke: #e5eae7; stroke-width: 1; fill: none; } +.flow-zero { stroke: #889891; stroke-width: 1; } +.flow-segment { stroke: var(--accent); fill: #176a5e18; stroke-width: 2; cursor: crosshair; } +.flow-segment.dispense { stroke: #556ba1; fill: #556ba118; } +.flow-segment:focus-visible, .flow-segment:hover { stroke-width: 3; outline: none; fill-opacity: .6; } +.flow-legend { display: flex; flex-wrap: wrap; gap: 12px; font-size: 10px; color: var(--muted); } +.flow-legend span::before { content: ""; display: inline-block; width: 15px; border-top: 2px solid var(--accent); vertical-align: middle; margin-right: 5px; } +.flow-legend span + span::before { border-color: #556ba1; } +.flow-reading { display: block; margin-top: 12px; padding: 8px; background: #f2f6f3; font-size: 11px; font-variant-numeric: tabular-nums; } diff --git a/src/lab/documents/assets/flow-profile.js b/src/lab/documents/assets/flow-profile.js new file mode 100644 index 0000000..d5ebac0 --- /dev/null +++ b/src/lab/documents/assets/flow-profile.js @@ -0,0 +1,33 @@ +class FlowProfile extends HTMLElement { + connectedCallback() { + this.events = new AbortController(); + const options = { signal: this.events.signal }; + this.select = this.querySelector("[data-profile-select]"); + this.select.disabled = false; + this.select.addEventListener("change", () => this.selectOperation(), options); + for (const event of ["pointerover", "focusin"]) { + this.addEventListener(event, event => { + const phase = event.target.closest("[data-flow-description]"); + if (phase) { + phase.closest(".flow-chart").querySelector("output").textContent = phase.dataset.flowDescription; + } + }, options); + } + this.selectOperation(); + } + + disconnectedCallback() { + this.events.abort(); + } + + selectOperation(operation) { + if (operation && [...this.select.options].some(option => option.value === operation)) { + this.select.value = operation; + } + this.querySelectorAll("[data-flow-profile]").forEach(profile => { + profile.hidden = profile.dataset.flowProfile !== this.select.value; + }); + } +} + +customElements.define("lab-flow-profile", FlowProfile); diff --git a/src/lab/documents/assets/inspector.css b/src/lab/documents/assets/inspector.css new file mode 100644 index 0000000..e1ef7e4 --- /dev/null +++ b/src/lab/documents/assets/inspector.css @@ -0,0 +1,29 @@ +.inspect { color: inherit; text-decoration: underline dotted #9aaea7; text-underline-offset: 4px; cursor: pointer; } +.inspect.liquid-class { text-decoration: none; color: var(--accent); } +.inspect-active { background: #e3f2eb; border-radius: 3px; color: #125346; } +.interaction-hint { display: none; color: var(--muted); font-size: 11px; margin: -6px 0 16px; } +.interactive .interaction-hint { display: block; } +.reference-details { border-bottom: 1px solid var(--line); } +.reference-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px; padding-block: 16px; } +.reference-card { border: 1px solid var(--line); padding: 20px; border-radius: 6px; overflow-wrap: anywhere; color: #263b39; font-size: 13px; } +.reference-card p { margin: 8px 0; } +.reference-card .detail-kind { margin: 0 0 6px; color: var(--accent); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; font-weight: 650; } +.reference-card .detail-title { font-size: 20px; font-weight: 650; letter-spacing: -.02em; margin: 0 0 18px; line-height: 1.3; } +.reference-card h4 { font-size: 11px; margin: 20px 0 5px; font-weight: 650; color: var(--muted); } +.detail-fields { margin: 14px 0 0; } +.detail-fields > div { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding-block: 8px; border-bottom: 1px solid #edf0ed; } +.detail-fields dt { font-size: 11px; text-transform: none; letter-spacing: 0; } +.detail-fields dd { margin: 0; font-size: 12px; font-weight: 500; line-height: 1.5; font-variant-numeric: tabular-nums; } +.reference-card .detail-caption { font-size: 11px; color: var(--muted); } +.detail-list { list-style: none; padding: 0; margin: 8px 0; } +.detail-list li { padding-block: 5px; font-size: 12px; } +.inspector { position: fixed; z-index: 10; width: min(370px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); display: flex; flex-direction: column; background: white; color: #263b39; border: 1px solid #cbd9d2; border-radius: 10px; box-shadow: 0 12px 40px #183c3626, 0 2px 8px #183c3614; } +.inspector[hidden] { display: none; } +.inspector-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 10px 8px 20px; border-bottom: 1px solid var(--line); flex-shrink: 0; } +.inspector-hint { color: var(--muted); font-size: 10px; } +.inspector-close { border: 0; border-radius: 5px; width: 32px; height: 32px; background: transparent; color: var(--muted); font: 22px/1 system-ui, sans-serif; cursor: pointer; } +.inspector-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +.inspector-close:active { transform: scale(.97); } +.inspector-content { overflow-y: auto; overscroll-behavior: contain; padding: 20px; } +.inspector .reference-card { border: 0; padding: 0; border-radius: 0; } +.procedure tr:target { background: #e3f2eb; } diff --git a/src/lab/documents/assets/inspector.js b/src/lab/documents/assets/inspector.js new file mode 100644 index 0000000..0bb1992 --- /dev/null +++ b/src/lab/documents/assets/inspector.js @@ -0,0 +1,204 @@ +class Inspector extends HTMLElement { + anchor = null; + activeId = null; + pinned = false; + timer = null; + restoringFocus = false; + + connectedCallback() { + this.scope = this.closest("lab-worksheet"); + this.content = this.querySelector(".inspector-content"); + this.closeButton = this.querySelector(".inspector-close"); + this.hint = this.querySelector(".inspector-hint"); + this.canHover = matchMedia("(hover: hover) and (pointer: fine)"); + this.events = new AbortController(); + const options = { signal: this.events.signal }; + + this.enhanceReferences(this.scope); + this.scope.querySelector(".reference-details").open = false; + this.scope.classList.add("interactive"); + + this.scope.addEventListener("pointerover", event => { + if (!this.canHover.matches || event.pointerType !== "mouse" || this.pinned) return; + if (this.contains(event.target)) { + clearTimeout(this.timer); + return; + } + const link = event.target.closest("[data-inspect]"); + if (!link || link.contains(event.relatedTarget)) return; + clearTimeout(this.timer); + this.timer = setTimeout(() => this.show(link), this.hidden ? 160 : 0); + }, options); + + this.scope.addEventListener("pointerout", event => { + const link = event.target.closest("[data-inspect]"); + if (link && !link.contains(event.relatedTarget)) this.scheduleClose(); + }, options); + this.addEventListener("pointerleave", () => this.scheduleClose(), options); + this.addEventListener("pointerenter", () => clearTimeout(this.timer), options); + + document.addEventListener("focusin", event => { + if (this.restoringFocus || this.contains(event.target)) return; + const link = this.scope.contains(event.target) && event.target.closest("[data-inspect]"); + if (link) this.show(link); + else this.close(); + }, options); + + this.scope.addEventListener("click", event => { + const link = event.target.closest("[data-inspect]"); + if (link) { + event.preventDefault(); + if (this.pinned && this.activeId === link.dataset.inspect && link === this.anchor) { + this.close(); + } else { + this.show(link, true); + if (event.detail === 0) this.closeButton.focus({ preventScroll: true }); + } + } else if (this.contains(event.target) && event.target.closest("a")) { + this.close(); + } else if (this.contains(event.target)) { + this.pinned = true; + this.hint.textContent = "Details"; + } + }, options); + + document.addEventListener("pointerdown", event => { + const reference = this.scope.contains(event.target) && event.target.closest("[data-inspect]"); + if (!this.contains(event.target) && !reference) this.close(); + }, options); + this.closeButton.addEventListener("click", event => { + event.stopPropagation(); + this.close(true); + }, options); + this.scope.addEventListener("keydown", event => this.onKeyDown(event), options); + document.addEventListener("scroll", event => { + if (this.hidden || this.contains(event.target)) return; + if (this.pinned || document.activeElement === this.anchor || this.contains(document.activeElement)) { + this.position(); + } else { + this.close(); + } + }, { ...options, capture: true }); + window.addEventListener("resize", () => this.position(), options); + window.addEventListener("blur", () => this.close(), options); + window.addEventListener("beforeprint", () => this.close(), options); + this.resizeObserver = new ResizeObserver(() => this.position()); + this.resizeObserver.observe(this); + } + + disconnectedCallback() { + clearTimeout(this.timer); + this.events.abort(); + this.resizeObserver.disconnect(); + } + + enhanceReferences(root) { + root.querySelectorAll("[data-inspect]").forEach(link => { + link.setAttribute("role", "button"); + link.setAttribute("aria-haspopup", "dialog"); + link.setAttribute("aria-controls", this.id); + link.setAttribute("aria-expanded", "false"); + }); + } + + markReferences() { + this.scope.querySelectorAll("[data-inspect]").forEach(link => { + link.classList.toggle("inspect-active", !this.hidden && link.dataset.inspect === this.activeId); + link.setAttribute("aria-expanded", String(!this.hidden && link === this.anchor)); + }); + } + + show(link, keepOpen = false) { + clearTimeout(this.timer); + const card = this.scope.querySelector(`#${CSS.escape(link.dataset.inspect)}`); + if (!card) return; + if (!this.contains(link)) this.anchor = link; + else this.closeButton.focus({ preventScroll: true }); + if (this.activeId !== card.id) { + const copy = card.cloneNode(true); + copy.removeAttribute("id"); + this.content.replaceChildren(copy); + this.enhanceReferences(this.content); + this.setAttribute("aria-label", `${card.querySelector(".detail-kind").textContent}: ${card.querySelector(".detail-title").textContent}`); + this.content.scrollTop = 0; + } + this.activeId = card.id; + this.content.querySelectorAll("lab-flow-profile").forEach(profile => { + profile.selectOperation(link.closest("tr[id^='step-']")?.id); + }); + this.pinned = keepOpen; + this.hint.textContent = this.pinned ? "Details" : "Preview · click to keep open"; + this.hidden = false; + this.markReferences(); + this.position(); + } + + close(restoreFocus = false) { + clearTimeout(this.timer); + const returnTo = this.anchor; + this.hidden = true; + this.pinned = false; + this.activeId = null; + this.markReferences(); + if (restoreFocus && returnTo?.isConnected) { + this.restoringFocus = true; + returnTo.focus({ preventScroll: true }); + this.restoringFocus = false; + } + } + + scheduleClose() { + clearTimeout(this.timer); + if (!this.pinned && document.activeElement !== this.anchor && !this.contains(document.activeElement)) { + this.timer = setTimeout(() => this.close(), 180); + } + } + + position() { + if (this.hidden || !this.anchor?.isConnected) return; + const box = this.anchor.getBoundingClientRect(); + const margin = 12; + const gap = 10; + let left = box.right + gap; + if (left + this.offsetWidth > innerWidth - margin) left = box.left - this.offsetWidth - gap; + let top = box.top; + if (left < margin) { + left = Math.max(margin, Math.min(box.left, innerWidth - this.offsetWidth - margin)); + top = box.bottom + gap; + if (top + this.offsetHeight > innerHeight - margin) top = box.top - this.offsetHeight - gap; + } + this.style.left = `${Math.max(margin, left)}px`; + this.style.top = `${Math.max(margin, Math.min(top, innerHeight - this.offsetHeight - margin))}px`; + } + + onKeyDown(event) { + if (event.key === "Escape" && !this.hidden) { + event.preventDefault(); + this.close(true); + } else if (event.key === " " && event.target.matches("[data-inspect]")) { + event.preventDefault(); + event.target.click(); + } else if (event.key === "Tab" && !this.hidden && this.pinned) { + const inside = [...this.querySelectorAll("button, a, select, summary, [tabindex='0']")] + .filter(node => node.getClientRects().length); + if (event.target === this.anchor && !event.shiftKey) { + event.preventDefault(); + this.closeButton.focus(); + } else if (event.target === this.closeButton && event.shiftKey) { + event.preventDefault(); + this.close(true); + } else if (event.target === inside.at(-1) && !event.shiftKey) { + const outside = [...this.scope.querySelectorAll("a, button, summary, [tabindex='0']")] + .filter(node => !this.contains(node) && node.getClientRects().length); + const next = outside[outside.indexOf(this.anchor) + 1]; + if (next) { + event.preventDefault(); + this.close(); + next.focus(); + } + } + } + } +} + +customElements.define("lab-inspector", Inspector); diff --git a/src/lab/documents/assets/print.css b/src/lab/documents/assets/print.css new file mode 100644 index 0000000..e89efef --- /dev/null +++ b/src/lab/documents/assets/print.css @@ -0,0 +1,31 @@ +@page { size: A4; margin: 14mm; } +@media print { + :root { background: white; } + body { padding: 0; font-size: 9pt; } + main { max-width: none; padding: 0; border: 0; } + h1 { font-size: 24pt; margin-top: 16px; } + h2 { font-size: 14pt; } + .description { font-size: 10pt; } + .overview { margin-block: 18px; } + dd { font-size: 18pt; } + nav, .notes, .reference-details, .inspector, .interactive .interaction-hint { display: none; } + .table-scroll { overflow: visible; } + table, .materials, .procedure { min-width: 0; font-size: 8pt; } + th, td, .procedure td, .procedure th { padding: 6px; } + thead { display: table-header-group; } + tr { break-inside: avoid; } + .section-heading, h2, h3, .section-note { break-after: avoid; } + .location { font-size: 8pt; } + .physical, .settings, thead th { font-size: 7pt; } + small, .materials th[scope="rowgroup"] small { font-size: 7pt; } + .materials th[scope="rowgroup"] { min-width: 0; } + .procedure-note { padding: 8px; font-size: 9pt; } + .liquid-settings { font-size: 8pt; } + .volume-grid { display: block; columns: 3; column-gap: 16px; } + .volume-grid table { break-inside: avoid; margin-bottom: 16px; } + a { color: inherit; text-decoration: none; } + .liquid-class { border: 0; padding: 0; background: none; } + .inspect { text-decoration: none; color: inherit; } + .inspect-active { background: none; } + section, footer { margin-top: 24px; } +} diff --git a/src/lab/documents/assets/responsive.css b/src/lab/documents/assets/responsive.css new file mode 100644 index 0000000..13dfcb0 --- /dev/null +++ b/src/lab/documents/assets/responsive.css @@ -0,0 +1,17 @@ +@media (hover: hover) and (pointer: fine) { + nav a:hover, summary:hover { color: #103c32; text-decoration: underline; } + .procedure tbody tr:hover { background: #f6f9f7; } + .inspect:hover { color: var(--accent); text-decoration-style: solid; } + .inspector-close:hover { background: #edf5f1; color: var(--accent); } +} +@media (max-width: 760px) { + body { padding: 0; } + main { border-inline: 0; border-radius: 0; padding: 28px 20px; } + .masthead { font-size: 10px; } + .overview { gap: 16px 10px; grid-template-columns: repeat(2, 1fr); } + nav { column-gap: 20px; } + .count { display: none; } + .liquid-settings { display: block; } + .liquid-settings h3 { margin-bottom: 4px; } + .volume-grid { grid-template-columns: 1fr; } +} diff --git a/src/lab/documents/assets/worksheet.css b/src/lab/documents/assets/worksheet.css new file mode 100644 index 0000000..7c564d1 --- /dev/null +++ b/src/lab/documents/assets/worksheet.css @@ -0,0 +1,75 @@ +:root { + color-scheme: light; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #263b39; + background: #f3f4f1; + font-synthesis: none; + --accent: #176a5e; + --muted: #61706e; + --line: #dce3df; +} +* { box-sizing: border-box; } +lab-worksheet { display: block; } +body { margin: 0; padding: 36px 24px; line-height: 1.5; } +main { max-width: 1120px; margin: auto; padding: 42px 48px; background: white; border: 1px solid var(--line); border-top: 4px solid var(--accent); border-radius: 4px; } +a { color: var(--accent); text-underline-offset: 3px; } +a:focus-visible, summary:focus-visible, .table-scroll:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; } +.masthead { display: flex; justify-content: space-between; gap: 16px; color: var(--muted); font-size: 12px; letter-spacing: .06em; text-transform: uppercase; } +.brand { font-weight: 750; color: var(--accent); } +h1 { margin: 24px 0 12px; color: #183c36; font-size: clamp(26px, 3.5vw, 40px); font-weight: 650; line-height: 1.15; letter-spacing: -.035em; text-wrap: balance; overflow-wrap: anywhere; } +.description { max-width: 78ch; margin: 0; color: var(--muted); font-size: 15px; white-space: pre-wrap; overflow-wrap: anywhere; } +.overview { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin: 28px 0 24px; } +.overview > div { border-left: 2px solid var(--line); padding-left: 14px; } +dt { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); } +dd { margin: 4px 0 0; font-size: 24px; font-weight: 600; line-height: 1.2; overflow-wrap: anywhere; } +nav { display: flex; flex-wrap: wrap; column-gap: 28px; padding: 4px 0; border-block: 1px solid var(--line); } +nav a { padding: 10px 0; font-size: 13px; font-weight: 600; text-decoration: none; } +section, footer { margin-top: 34px; scroll-margin-top: 20px; } +.section-heading { display: flex; align-items: baseline; gap: 12px; } +.section-heading > span:first-child { font-size: 12px; font-weight: 600; color: var(--accent); } +h2 { margin: 0; font-size: 19px; font-weight: 650; letter-spacing: -.02em; } +h3 { margin: 0; font-size: 12px; font-weight: 650; } +.count { margin-left: auto; color: var(--muted); font-size: 12px; } +.section-note { margin: 7px 0 16px; color: var(--muted); font-size: 13px; } +.table-scroll { overflow-x: auto; } +table { border-collapse: collapse; width: 100%; font-size: 13px; } +thead { background: #f2f5f3; } +th, td { padding: 10px 12px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; } +thead th { font-size: 11px; font-weight: 600; color: var(--muted); white-space: nowrap; } +tbody th { font-weight: 600; } +.materials { min-width: 700px; } +.materials tbody + tbody { border-top: 2px solid var(--line); } +.materials th[scope="rowgroup"] { min-width: 170px; width: 22%; } +.materials th[scope="rowgroup"] small { font-family: inherit; font-weight: 400; font-size: 10px; } +.materials td:nth-last-child(3) { width: 37%; } +.quantity { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +.location { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; } +.physical { color: var(--muted); font-size: 11px; } +small { display: block; color: var(--muted); font-size: 10px; line-height: 1.5; margin-top: 3px; } +.muted { color: var(--muted); } +.procedure-note { margin: 12px 0; padding: 12px 16px; background: #edf5f1; border-left: 3px solid var(--accent); font-size: 13px; } +.liquid-settings { display: flex; gap: 22px; padding: 2px 0 16px; color: var(--muted); font-size: 11px; } +.liquid-settings h3 { flex-shrink: 0; padding-top: 2px; } +.liquid-settings ul { margin: 0; padding: 0; list-style: none; } +.liquid-settings li { padding: 2px 0; overflow-wrap: anywhere; scroll-margin-top: 20px; } +.procedure { min-width: 700px; } +.procedure td, .procedure th { padding-block: 9px; } +.step-number { color: #77837f; font-variant-numeric: tabular-nums; width: 5%; font-size: 11px; } +.action { font-weight: 550; width: 12%; } +.procedure .quantity { width: 14%; } +.procedure .location { width: 23%; } +.settings { width: 23%; font-size: 11px; } +.liquid-class { display: inline-block; border: 1px solid #d9e6e0; border-radius: 4px; padding: 1px 6px; background: #f4f8f5; text-decoration: none; overflow-wrap: anywhere; } +.instruction { line-height: 1.7; background: #f8f9f6; } +.volume-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 24px; align-items: start; } +caption { text-align: left; padding: 0 0 10px; font-weight: 650; font-size: 14px; overflow-wrap: anywhere; } +.volume-grid th, .volume-grid td { padding-block: 7px; } +.setup ul { font-size: 13px; padding-left: 20px; } +.setup li { margin-top: 8px; overflow-wrap: anywhere; } +footer { border-top: 1px solid var(--line); padding-top: 24px; color: var(--muted); font-size: 12px; } +footer h2 { font-size: 15px; margin-bottom: 12px; } +summary { cursor: pointer; padding: 12px 0; color: var(--accent); font-weight: 600; } +.notes { border-bottom: 1px solid var(--line); } +pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: 20px; background: #f5f6f3; font-size: 11px; line-height: 1.6; } +.digest { margin-top: 20px; font-size: 10px; overflow-wrap: anywhere; } +.digest code { display: block; margin-top: 4px; } diff --git a/src/lab/documents/descriptions.py b/src/lab/documents/descriptions.py new file mode 100644 index 0000000..4c58fe1 --- /dev/null +++ b/src/lab/documents/descriptions.py @@ -0,0 +1,68 @@ +"""Plain operation descriptions shared by documents and robot targets.""" + +from lab.liquid_classes import HamiltonLiquidClass, LiquidClass +from lab.model import ( + Distribute, + ManualInstruction, + Mix, + SetTemperature, + Step, + Thermocycle, + Transfer, + Wait, +) +from lab.units import number + + +def describe(step: Step) -> str: + liquid = "" + if isinstance(step, (Transfer, Distribute, Mix)): + if isinstance(step.liquid_class, LiquidClass): + spec = step.liquid_class + liquid = ( + f" Liquid class {spec.name}: aspirate {number(spec.aspiration_flow_rate_ul_s)} " + f"µL/s; dispense {number(spec.dispense_flow_rate_ul_s)} µL/s." + ) + elif isinstance(step.liquid_class, HamiltonLiquidClass): + liquid = f" Hamilton STAR liquid class {step.liquid_class.name}." + match step: + case Transfer(source, destination, volume, _): + return f"Transfer {number(volume)} µL from {source} to {destination}.{liquid}" + 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 "" + return f"Distribute {number(volume)} µL from {source} to {wells}.{gap}{liquid}" + case Mix(location, volume, cycles, _): + return f"Mix {location}: {cycles} cycles of {number(volume)} µL.{liquid}" + case Wait(seconds, _): + unit = "second" if seconds == 1 else "seconds" + return f"Wait {number(seconds)} {unit}." + case Thermocycle(resource, profile, cycles, lid, _, block_volume): + holds = "; ".join( + f"{number(hold.celsius)} °C for {number(hold.seconds)} s" for hold in profile + ) + lid_text = ( + "lid follows block; lid heating off below the device's heated lid range" + if lid is None + else f"lid at {number(lid)} °C" + ) + block = ( + f" Reported block volume {number(block_volume)} µL." + if block_volume is not None + else "" + ) + return ( + f"Thermocycle all of {resource}: {holds}; {cycles} cycle(s); {lid_text}. " + "Wait for completion, keep the final temperature settings, and release the plate." + f"{block}" + ) + case SetTemperature(resource, celsius, _): + return ( + f"Hold {resource} at {number(celsius)} °C. " + "For a device with a heated lid, match its target within the heated lid range; " + "turn lid heating off below that range." + ) + case ManualInstruction(text, _): + return f"Operator: {text}" + case _: + raise TypeError(f"Unsupported step: {type(step).__name__}") diff --git a/src/lab/documents/flow.py b/src/lab/documents/flow.py new file mode 100644 index 0000000..0768c98 --- /dev/null +++ b/src/lab/documents/flow.py @@ -0,0 +1,85 @@ +"""Numeric view data for idealized flow-versus-time diagrams.""" + +from dataclasses import dataclass +from decimal import Decimal + +from lab.model import Distribute, Mix, Transfer +from lab.units import number + + +@dataclass(frozen=True) +class FlowSegment: + start: Decimal + end: Decimal + rate: Decimal + volume: Decimal + x1: Decimal + x2: Decimal + y: Decimal + action: str + description: str + + +@dataclass(frozen=True) +class TimeTick: + x: Decimal + time: Decimal + + +@dataclass(frozen=True) +class FlowProfile: + step_id: str + label: str + duration: Decimal + maximum: Decimal + segments: tuple[FlowSegment, ...] + ticks: tuple[TimeTick, ...] + + +def build_flow_profile( + index: int, step: Transfer | Distribute | Mix, aspiration: Decimal, dispense: Decimal +) -> FlowProfile: + """Calculate nominal V/Q phases; no HTML, ramps, or device delays.""" + label = f"Step {index} · {type(step).__name__} · {number(step.volume)} µL" + if isinstance(step, Mix): + phases = [(step.volume, aspiration), (step.volume, -dispense)] * step.cycles + label += f" × {step.cycles} cycles" + elif isinstance(step, Distribute): + phases = [(step.volume * len(step.destinations), aspiration)] + phases.extend((step.volume, -dispense) for _ in step.destinations) + label += f" × {len(step.destinations)} destinations" + else: + phases = [(step.volume, aspiration), (step.volume, -dispense)] + duration = sum((volume / abs(rate) for volume, rate in phases), Decimal(0)) + maximum = max(aspiration, dispense) + elapsed = Decimal(0) + segments = [] + for volume, rate in phases: + end = elapsed + volume / abs(rate) + action = "Aspiration" if rate > 0 else "Dispense" + segments.append( + FlowSegment( + start=elapsed, + end=end, + rate=rate, + volume=volume, + x1=48 + elapsed / duration * 252, + x2=48 + end / duration * 252, + y=100 - rate / maximum * 70, + action=action, + description=f"{action}: {elapsed:.3g}–{end:.3g} s, " + f"{number(abs(rate))} µL/s, {number(volume)} µL", + ) + ) + elapsed = end + return FlowProfile( + step_id=f"step-{index}", + label=label, + duration=duration, + maximum=maximum, + segments=tuple(segments), + ticks=tuple( + TimeTick(48 + fraction * 252, duration * fraction) + for fraction in (Decimal(0), Decimal("0.5"), Decimal(1)) + ), + ) diff --git a/src/lab/documents/models.py b/src/lab/documents/models.py new file mode 100644 index 0000000..7a3eb40 --- /dev/null +++ b/src/lab/documents/models.py @@ -0,0 +1,88 @@ +"""Typed presentation data shared by the worksheet's template components. + +These records contain text, quantities, and references, never rendered markup. +The recorded protocol remains the authority for operations and material identity. +""" + +from dataclasses import dataclass +from decimal import Decimal +from typing import TypeAlias + +from lab.documents.flow import FlowProfile +from lab.model import Fill, RecordedProtocol, Resource, TargetPlan +from lab.samples import Location, Sample + +DetailFields: TypeAlias = tuple[tuple[str, str], ...] + + +@dataclass(frozen=True) +class Reference: + anchor: str + label: str + + +@dataclass(frozen=True) +class IncomingTransfer: + step_number: int + source: Reference + volume: Decimal + + +@dataclass(frozen=True) +class WellView: + reference: Reference + location: Location + fill: Fill | None + physical: str + final_volume: Decimal + fields: DetailFields + sample: Sample | None + sample_heading: str + sample_fields: DetailFields + incoming: tuple[IncomingTransfer, ...] + + +@dataclass(frozen=True) +class ResourceView: + reference: Reference + resource: Resource + geometry: str + physical_summary: str + fields: DetailFields + wells: tuple[WellView, ...] + loaded_wells: tuple[WellView, ...] + final_wells: tuple[WellView, ...] + + +@dataclass(frozen=True) +class ProcedureRow: + number: int + action: str + instruction: str = "" + volume: Decimal | None = None + volume_note: str = "" + source: Reference | None = None + destinations: tuple[Reference, ...] = () + liquid_class: Reference | None = None + air_gap: Decimal | None = None + + +@dataclass(frozen=True) +class LiquidClassView: + reference: Reference + description: str + fields: DetailFields + notes: tuple[str, ...] + profiles: tuple[FlowProfile, ...] + + +@dataclass(frozen=True) +class Worksheet: + protocol: RecordedProtocol + target: TargetPlan + digest: str + summary: str + notes: str + resources: tuple[ResourceView, ...] + procedure: tuple[ProcedureRow, ...] + liquid_classes: tuple[LiquidClassView, ...] diff --git a/src/lab/documents/prepare.py b/src/lab/documents/prepare.py new file mode 100644 index 0000000..c115cff --- /dev/null +++ b/src/lab/documents/prepare.py @@ -0,0 +1,243 @@ +"""Prepare typed worksheet data without constructing or escaping markup.""" + +from __future__ import annotations + +import json +from decimal import Decimal + +import lab.compiler as compiler +from lab.documents.descriptions import describe +from lab.documents.flow import build_flow_profile +from lab.documents.models import ( + DetailFields, + IncomingTransfer, + LiquidClassView, + ProcedureRow, + Reference, + ResourceView, + WellView, + Worksheet, +) +from lab.liquid_classes import LiquidClass, LiquidClassSpec +from lab.model import Distribute, Mix, Step, TargetPlan, Transfer +from lab.samples import Location +from lab.units import number + + +def _procedure_row( + index: int, + step: Step, + locations: dict[Location, Reference], + classes: dict[LiquidClassSpec, Reference], +) -> ProcedureRow: + destinations: tuple[Location, ...] + if not isinstance(step, (Transfer, Distribute, Mix)): + return ProcedureRow(index, type(step).__name__, instruction=describe(step)) + if isinstance(step, Mix): + source, destinations = step.location, () + note = f"{step.cycles} cycles" + elif isinstance(step, Distribute): + source, destinations = step.source, step.destinations + note = "per destination" + else: + source, destinations = step.source, (step.destination,) + note = "" + return ProcedureRow( + number=index, + action=type(step).__name__, + volume=step.volume, + volume_note=note, + source=locations[source], + destinations=tuple(locations[well] for well in destinations), + liquid_class=classes.get(step.liquid_class) if step.liquid_class else None, + air_gap=step.air_gap if isinstance(step, Distribute) else None, + ) + + +def _native_rates(target: TargetPlan, name: str) -> tuple[Decimal, Decimal] | None: + configuration = json.loads(target.configuration_json, parse_float=Decimal, parse_int=Decimal) + definition = configuration.get("hamilton_liquid_classes", {}).get(name, {}) + aspiration, dispense = ( + definition.get("aspiration_flow_rate"), + definition.get("dispense_flow_rate"), + ) + if ( + isinstance(aspiration, Decimal) + and aspiration.is_finite() + and aspiration > 0 + and isinstance(dispense, Decimal) + and dispense.is_finite() + and dispense > 0 + ): + return aspiration, dispense + return None + + +def _liquid_class( + reference: Reference, spec: LiquidClassSpec, steps: tuple[Step, ...], target: TargetPlan +) -> LiquidClassView: + rates: tuple[Decimal, Decimal] | None + fields: DetailFields + notes: tuple[str, ...] + if isinstance(spec, LiquidClass): + rates = spec.aspiration_flow_rate_ul_s, spec.dispense_flow_rate_ul_s + description = ( + f"{spec.name}: aspirate {number(rates[0])} µL/s; dispense {number(rates[1])} µL/s" + ) + fields = ( + ("Type", "Portable flow rates"), + ("Aspiration", f"{number(rates[0])} µL/s"), + ("Dispense", f"{number(rates[1])} µL/s"), + ) + notes = ( + "Applied to aspiration and dispense, including mixing cycles. " + "Other settings and volume calibration use target defaults.", + ) + else: + rates = _native_rates(target, spec.name) + description = f"Hamilton STAR liquid class {spec.name}" + fields = (("Type", "Hamilton STAR built-in"), ("Catalogue symbol", spec.name)) + notes = ( + "The STAR target resolves this symbol against its installed PyLabRobot " + "catalogue and captures the definition in plan.json.", + ) + if rates is None: + notes += ( + "This plan records the class name only. Compile for STAR to capture " + "the rates needed for a flow profile.", + ) + profiles = ( + tuple( + build_flow_profile(index, step, *rates) + for index, step in enumerate(steps, 1) + if isinstance(step, (Transfer, Distribute, Mix)) and step.liquid_class == spec + ) + if rates is not None + else () + ) + return LiquidClassView(reference, description, fields, notes, profiles) + + +def build_worksheet(compilation: compiler.Compilation) -> Worksheet: + p, target = compilation.protocol, compilation.target + fills = { + Location(resource.name, fill.well): fill + for resource in p.resources + for fill in resource.fills + } + samples = {sample.id: sample for sample in p.samples} + placed = {placement.location: samples[placement.sample_id] for placement in p.placements} + used = set(fills) | set(placed) + incoming: dict[Location, list[tuple[int, Transfer | Distribute]]] = {} + for index, step in enumerate(p.steps, 1): + if isinstance(step, (Transfer, Distribute)): + destinations = (step.destination,) if isinstance(step, Transfer) else step.destinations + used.add(step.source) + used.update(destinations) + for destination in destinations: + incoming.setdefault(destination, []).append((index, step)) + elif isinstance(step, Mix): + used.add(step.location) + locations = { + location: Reference(f"location-{index}", str(location)) + for index, location in enumerate( + (Location(resource.name, well) for resource in p.resources for well in resource.wells), + 1, + ) + if location in used + } + classes = { + spec: Reference(f"liquid-class-{index}", spec.name) + for index, spec in enumerate( + dict.fromkeys( + step.liquid_class + for step in p.steps + if isinstance(step, (Transfer, Distribute, Mix)) and step.liquid_class is not None + ), + 1, + ) + } + bindings = {binding.location: binding for binding in target.bindings} + final_volumes = dict(compilation.final_volumes) + resources = [] + for index, resource in enumerate(p.resources, 1): + wells = [] + for location, reference in locations.items(): + if location.resource != resource.name: + continue + fill, binding, sample = fills.get(location), bindings[location], placed.get(location) + sample_fields: DetailFields = ( + (("Sample ID", sample.id), ("Material identity", sample.material_identity)) + if sample + else () + ) + if sample and sample.parent_ids: + sample_fields += (("Parent samples", ", ".join(sample.parent_ids)),) + wells.append( + WellView( + reference=reference, + location=location, + fill=fill, + physical=binding.physical, + final_volume=final_volumes[location], + fields=( + ("Initial volume", f"{number(fill.volume) if fill else '0'} µL"), + ("Planned final volume", f"{number(final_volumes[location])} µL"), + ("Capacity", f"{number(min(resource.capacity, binding.capacity))} µL"), + ( + "Dead volume", + f"{number(max(resource.dead_volume, binding.dead_volume))} µL", + ), + ("Physical location", binding.physical), + ), + sample=sample, + sample_fields=sample_fields, + sample_heading="Planned output sample" + if sample and sample.id in p.output_sample_ids + else "Declared sample", + incoming=tuple( + IncomingTransfer(i, locations[step.source], step.volume) + for i, step in incoming.get(location, ()) + ), + ) + ) + physical = [ + binding.physical + for location, binding in bindings.items() + if location.resource == resource.name + ] + resources.append( + ResourceView( + reference=Reference(f"resource-{index}", resource.name), + resource=resource, + geometry=f"{resource.rows} × {resource.columns} wells" + if resource.rows != 1 or resource.columns != 1 + else "Single container", + physical_summary=", ".join(physical) + if len(physical) <= 4 + else f"{physical[0]} … {physical[-1]} ({len(physical)} wells)", + fields=( + ("Layout", f"{resource.rows} rows × {resource.columns} columns"), + ("Declared capacity / well", f"{number(resource.capacity)} µL"), + ("Declared dead volume / well", f"{number(resource.dead_volume)} µL"), + ), + wells=tuple(wells), + loaded_wells=tuple(well for well in wells if well.fill), + final_wells=tuple(well for well in wells if well.final_volume), + ) + ) + summary, _, notes = p.description.partition("\n\n") + return Worksheet( + protocol=p, + target=target, + digest=compilation.digest, + summary=summary, + notes=notes, + resources=tuple(resources), + procedure=tuple( + _procedure_row(i, step, locations, classes) for i, step in enumerate(p.steps, 1) + ), + liquid_classes=tuple( + _liquid_class(ref, spec, p.steps, target) for spec, ref in classes.items() + ), + ) diff --git a/src/lab/documents/render.py b/src/lab/documents/render.py new file mode 100644 index 0000000..db807d7 --- /dev/null +++ b/src/lab/documents/render.py @@ -0,0 +1,39 @@ +"""Assemble packaged templates and assets into one offline worksheet.""" + +from __future__ import annotations + +from importlib.resources import files + +from jinja2 import Environment, PackageLoader, StrictUndefined + +import lab.compiler as compiler +from lab.documents.prepare import build_worksheet +from lab.units import number + +STYLESHEETS = ("worksheet.css", "inspector.css", "flow-profile.css", "responsive.css", "print.css") +SCRIPTS = ("flow-profile.js", "inspector.js") + +environment = Environment( + loader=PackageLoader("lab.documents", "templates"), + autoescape=True, + undefined=StrictUndefined, + trim_blocks=True, + lstrip_blocks=True, + keep_trailing_newline=True, +) +environment.filters.update( + number=number, coordinate=lambda value: f"{value:.2f}", short_time=lambda value: f"{value:.3g}" +) + + +def render(compilation: compiler.Compilation) -> str: + assets = files("lab.documents").joinpath("assets") + stylesheet = "\n".join( + assets.joinpath(name).read_text(encoding="utf-8") for name in STYLESHEETS + ) + script = "\n".join(assets.joinpath(name).read_text(encoding="utf-8") for name in SCRIPTS) + return environment.get_template("worksheet.html").render( + document=build_worksheet(compilation), + stylesheet=stylesheet, + script=script, + ) diff --git a/src/lab/documents/templates/components/detail_cards.html b/src/lab/documents/templates/components/detail_cards.html new file mode 100644 index 0000000..9e31d33 --- /dev/null +++ b/src/lab/documents/templates/components/detail_cards.html @@ -0,0 +1,44 @@ +{% from "components/ui.html" import card, fields, reference %} +{% from "components/flow_profile.html" import profile_selector %} + +{% macro resource_card(resource) -%} +{% call card(resource.reference, "Resource") %} + {{ fields(resource.fields) }} + {% if resource.wells %} +

    Wells used in this protocol

    +
      + {% for well in resource.wells %} +
    • {{ reference(well.reference, well.location.well) }} — {{ well.fill.material if well.fill else "Initially empty" }}
    • + {% endfor %} +
    + {% endif %} +{% endcall %} +{%- endmacro %} + +{% macro location_card(well) -%} +{% call card(well.reference, "Location") %} +

    Initial contents

    {{ well.fill.material if well.fill else "Initially empty" }}

    + {{ fields(well.fields) }} + {% if well.sample %} +

    {{ well.sample_heading }}

    {{ well.sample.label }}

    + {{ fields(well.sample_fields) }} + {% endif %} + {% if well.incoming %} +

    Planned incoming transfers

    +

    Across the full procedure; not a measurement of current contents.

    +
      + {% for addition in well.incoming %} +
    • Step {{ addition.step_number }}: {{ addition.volume|number }} µL from {{ reference(addition.source) }}
    • + {% endfor %} +
    + {% endif %} +{% endcall %} +{%- endmacro %} + +{% macro liquid_class_card(liquid_class) -%} +{% call card(liquid_class.reference, "Liquid class") %} + {{ fields(liquid_class.fields) }} + {% for note in liquid_class.notes %}

    {{ note }}

    {% endfor %} + {% if liquid_class.profiles %}{{ profile_selector(liquid_class.profiles) }}{% endif %} +{% endcall %} +{%- endmacro %} diff --git a/src/lab/documents/templates/components/flow_profile.html b/src/lab/documents/templates/components/flow_profile.html new file mode 100644 index 0000000..2495b6c --- /dev/null +++ b/src/lab/documents/templates/components/flow_profile.html @@ -0,0 +1,46 @@ +{% macro chart(flow) -%} +
    +
    Flow rate over time
    + + + + + +{{ flow.maximum|number }} + 0 + −{{ flow.maximum|number }} + {% for tick in flow.ticks %} + {{ tick.time|short_time }} + {% endfor %} + + Ideal active time (s) + Flow rate (µL/s) + {% for phase in flow.segments %} + + {{ phase.description }} + + + {% endfor %} + +
    Aspiration (+)Dispense (−)
    + Hover or focus a phase for its values. +

    Idealized nominal-volume profile (time = volume / rate). + Excludes motion, pauses, ramps, air gaps, and volume correction. Not a measured trace.

    +
    +{%- endmacro %} + +{% macro profile_selector(profiles) -%} + + + {% for profile in profiles %} +
    + {{ chart(profile) }} +
    + {% endfor %} +
    +{%- endmacro %} diff --git a/src/lab/documents/templates/components/inspector.html b/src/lab/documents/templates/components/inspector.html new file mode 100644 index 0000000..e5a87ce --- /dev/null +++ b/src/lab/documents/templates/components/inspector.html @@ -0,0 +1,7 @@ + diff --git a/src/lab/documents/templates/components/ui.html b/src/lab/documents/templates/components/ui.html new file mode 100644 index 0000000..7bbb15b --- /dev/null +++ b/src/lab/documents/templates/components/ui.html @@ -0,0 +1,37 @@ +{% macro reference(ref, label=none, css_class="") -%} +{{ ref.label if label is none else label }} +{%- endmacro %} + +{% macro fields(items) -%} +
    + {% for label, value in items %} +
    {{ label }}
    {{ value }}
    + {% endfor %} +
    +{%- endmacro %} + +{% macro section_heading(number, anchor, title, count=none) -%} +
    + {{ number }}

    {{ title }}

    + {% if count is not none %}{{ count }} operations{% endif %} +
    +{%- endmacro %} + +{% macro table(label, css_class, columns) -%} +
    + + + {% for label, css in columns %}{% endfor %} + + {{ caller() }} +
    {{ label }}
    +
    +{%- endmacro %} + +{% macro card(ref, kind) -%} +
    +

    {{ kind }}

    +

    {{ ref.label }}

    + {{ caller() }} +
    +{%- endmacro %} diff --git a/src/lab/documents/templates/sections/details.html b/src/lab/documents/templates/sections/details.html new file mode 100644 index 0000000..b369422 --- /dev/null +++ b/src/lab/documents/templates/sections/details.html @@ -0,0 +1,19 @@ +{% from "components/detail_cards.html" import resource_card, location_card, liquid_class_card %} +
    +

    Plan details

    + {% if document.notes %} +
    Full protocol notes and configuration
    {{ document.notes }}
    + {% endif %} +
    + Material, location & liquid-class reference +
    + {% for resource in document.resources %} + {{ resource_card(resource) }} + {% for well in resource.wells %}{{ location_card(well) }}{% endfor %} + {% endfor %} + {% for liquid_class in document.liquid_classes %}{{ liquid_class_card(liquid_class) }}{% endfor %} +
    +
    +

    Complete plan & physical bindings

    +

    Plan SHA256 {{ document.digest }}

    +
    diff --git a/src/lab/documents/templates/sections/materials.html b/src/lab/documents/templates/sections/materials.html new file mode 100644 index 0000000..89e68b4 --- /dev/null +++ b/src/lab/documents/templates/sections/materials.html @@ -0,0 +1,36 @@ +{% from "components/ui.html" import reference, section_heading, table %} + +{% macro resource_cell(resource) -%} + + {{ reference(resource.reference) }}{{ resource.geometry }} + {{ resource.resource.capacity|number }} µL capacity per well + {{ resource.resource.dead_volume|number }} µL dead volume per well + +{%- endmacro %} + +
    + {{ section_heading("01", "materials-title", "Materials & placement") }} +

    Prepare these initial loads before starting the procedure.

    +

    Hover or focus an underlined item for details; click to keep them open.

    + {% call table("Materials and placement", "materials", [("Resource", ""), ("Well", ""), ("Initial material", ""), ("Load", "quantity"), ("Physical location", "")]) %} + {% for resource in document.resources %} + + {% for well in resource.loaded_wells %} + + {% if loop.first %}{{ resource_cell(resource) }}{% endif %} + {{ reference(well.reference, well.location.well) }} + {{ reference(well.reference, well.fill.material) }} + {{ well.fill.volume|number }} µL + {{ reference(well.reference, well.physical) }} + + {% else %} + + {{ resource_cell(resource) }} + —{{ reference(resource.reference, "Initially empty") }} + 0 µL{{ resource.physical_summary }} + + {% endfor %} + + {% endfor %} + {% endcall %} +
    diff --git a/src/lab/documents/templates/sections/procedure.html b/src/lab/documents/templates/sections/procedure.html new file mode 100644 index 0000000..2bab68e --- /dev/null +++ b/src/lab/documents/templates/sections/procedure.html @@ -0,0 +1,34 @@ +{% from "components/ui.html" import reference, section_heading, table %} + +
    + {{ section_heading("02", "procedure-title", "Procedure", document.procedure|length) }} +

    Follow the numbered order. Use a fresh tip for each transfer, + distribution, or mix. Quantities are planned, not measurements.

    + {% if document.liquid_classes %} + + {% endif %} + {% call table("Ordered procedure", "procedure", [("Step", ""), ("Action", ""), ("Volume", "quantity"), ("From / location", ""), ("To", ""), ("Liquid class", "")]) %} + + {% for step in document.procedure %} + {{ "%02d"|format(step.number) }} + {% if step.instruction %} + {{ step.instruction }} + {% else %} + {{ step.action }} + {{ step.volume|number }} µL{% if step.volume_note %}{{ step.volume_note }}{% endif %} + {{ reference(step.source) }} + {% for destination in step.destinations %}{{ reference(destination) }}{% if not loop.last %}
    {% endif %}{% else %}—{% endfor %} + + {% if step.liquid_class %}{{ reference(step.liquid_class, css_class="liquid-class") }}{% else %}Unspecified{% endif %} + {% if step.air_gap is not none %}Air gap {{ step.air_gap|number }} µL{% endif %} + + {% endif %} + + {% endfor %} + + {% endcall %} +
    diff --git a/src/lab/documents/templates/sections/volumes.html b/src/lab/documents/templates/sections/volumes.html new file mode 100644 index 0000000..ba9ef86 --- /dev/null +++ b/src/lab/documents/templates/sections/volumes.html @@ -0,0 +1,19 @@ +{% from "components/ui.html" import reference, section_heading %} +
    + {{ section_heading("03", "volumes-title", "Calculated final volumes") }} +

    Nonempty wells after all operations, including liquid remaining in sources.

    +
    + {% for resource in document.resources if resource.final_wells %} + + + + + {% for well in resource.final_wells %} + + + {% endfor %} + +
    {{ reference(resource.reference) }}
    WellVolume
    {{ reference(well.reference, well.location.well) }}{{ well.final_volume|number }} µL
    + {% else %}

    No liquid remains in the declared resources.

    {% endfor %} +
    +
    diff --git a/src/lab/documents/templates/worksheet.html b/src/lab/documents/templates/worksheet.html new file mode 100644 index 0000000..117d82b --- /dev/null +++ b/src/lab/documents/templates/worksheet.html @@ -0,0 +1,41 @@ + + + + + + {{ document.protocol.name }} + + + + +
    +
    +
    Lab CompilerProtocol worksheet
    +

    {{ document.protocol.name }}

    + {% if document.summary %}

    {{ document.summary }}

    {% endif %} +
    +
    Target
    {{ document.target.name }}
    +
    Operations
    {{ document.procedure|length }}
    +
    Resources
    {{ document.resources|length }}
    +
    Output samples
    {{ document.protocol.output_sample_ids|length }}
    +
    +
    + + {% if document.target.setup %} +

    Equipment setup

    +
      {% for line in document.target.setup %}
    • {{ line }}
    • {% endfor %}
    +
    + {% endif %} + {% include "sections/materials.html" %} + {% include "sections/procedure.html" %} + {% include "sections/volumes.html" %} + {% include "sections/details.html" %} +
    + {% include "components/inspector.html" %} +
    + + + diff --git a/tests/test_documents.py b/tests/test_documents.py new file mode 100644 index 0000000..54cf252 --- /dev/null +++ b/tests/test_documents.py @@ -0,0 +1,170 @@ +"""Human-readable worksheets retain the exact operation order and settings.""" + +import re +from dataclasses import replace +from decimal import Decimal +from html import unescape + +import pytest + +from lab import HamiltonLiquidClass, LiquidClass, Protocol, compile, seconds, uL +from lab.documents.flow import build_flow_profile +from lab.samples import Sample + + +def test_procedure_rows_preserve_doses_destinations_cycles_and_distinct_classes(): + p = Protocol("Worksheet") + source = p.container("stock", contents="Water", volume=100 * uL, capacity=200 * uL) + plate = p.plate("plate", capacity=200 * uL) + slow = LiquidClass( + "aqueous", aspiration_flow_rate=10 * uL / seconds, dispense_flow_rate=15 * uL / seconds + ) + fast = LiquidClass( + "aqueous", aspiration_flow_rate=20 * uL / seconds, dispense_flow_rate=30 * uL / seconds + ) + p.transfer(source, plate["A1"], volume=12.5 * uL, liquid_class=slow) + p.distribute( + source, + (plate["A2"], plate["A3"]), + volume=7.25 * uL, + air_gap=2.5 * uL, + liquid_class=fast, + ) + p.mix(plate["A2"], volume=3 * uL, cycles=4, liquid_class=slow) + p.wait(90 * seconds) + p.manual("Inspect the plate .") + html = compile(p, to=None).files["protocol.html"] + rows = re.findall(r'.*?', html, flags=re.DOTALL) + text = [" ".join(unescape(re.sub(r"<[^>]+>", " ", row)).split()) for row in rows] + assert text == [ + "01 Transfer 12.5 µL stock:A1 plate:A1 aqueous", + "02 Distribute 7.25 µL per destination stock:A1 plate:A2 plate:A3 aqueous Air gap 2.5 µL", + "03 Mix 3 µL 4 cycles plate:A2 — aqueous", + "04 Wait 90 seconds.", + "05 Operator: Inspect the plate .", + ] + assert 'href="#liquid-class-1"' in rows[0] and 'href="#liquid-class-1"' in rows[2] + assert 'href="#liquid-class-2"' in rows[1] + assert 'id="liquid-class-1"' in html and 'id="liquid-class-2"' in html + assert "aqueous: aspirate 10 µL/s; dispense 15 µL/s" in html + assert "aqueous: aspirate 20 µL/s; dispense 30 µL/s" in html + + +def test_summary_notes_and_materials_are_escaped_and_retained(): + p = Protocol( + "", + description="Prepare an aliquot.\n\nInput notes: \nLine two.", + ) + source = p.container("stock", contents="Buffer ", volume=40 * uL, capacity=200 * uL) + destination = p.container("output", capacity=200 * uL) + p.transfer(source, destination, volume=10 * uL) + html = compile(p, to=None).files["protocol.html"] + header = html.split("
    ", 1)[1].split("
    ", 1)[0] + assert "Prepare an aliquot." in header + assert "Input notes:" not in header + assert "<Worksheet>" in header + assert " + +
    +

    Initial loads

    +

    {{ document.preparation.frames[0].description }}

    + +
    +
    + Liquid volume / capacity + Source + Destination + Mix / temperature step +
    +
    + {% for plate in document.preparation.plates %}{{ plate_map(plate) }}{% endfor %} + {% include "components/transfer_motion.html" %} +
    +

    Volumes are planned after the selected operation. Filled area is proportional to volume / capacity. + Inspect wells for material identities and physical bindings. Pipette motion illustrates direction; + playback timing is chosen for readability.

    + + +{% endif %} diff --git a/src/lab/documents/templates/sections/procedure.html b/src/lab/documents/templates/sections/procedure.html index 2bab68e..aabf7d0 100644 --- a/src/lab/documents/templates/sections/procedure.html +++ b/src/lab/documents/templates/sections/procedure.html @@ -1,7 +1,7 @@ {% from "components/ui.html" import reference, section_heading, table %}
    - {{ section_heading("02", "procedure-title", "Procedure", document.procedure|length) }} + {{ section_heading("03" if document.preparation.plates else "02", "procedure-title", "Procedure", document.procedure|length) }}

    Follow the numbered order. Use a fresh tip for each transfer, distribution, or mix. Quantities are planned, not measurements.

    {% if document.liquid_classes %} @@ -14,7 +14,7 @@

    Liquid classes

      {% call table("Ordered procedure", "procedure", [("Step", ""), ("Action", ""), ("Volume", "quantity"), ("From / location", ""), ("To", ""), ("Liquid class", "")]) %} {% for step in document.procedure %} - {{ "%02d"|format(step.number) }} + {% if document.preparation.plates %}{{ "%02d"|format(step.number) }}{% else %}{{ "%02d"|format(step.number) }}{% endif %} {% if step.instruction %} {{ step.instruction }} {% else %} diff --git a/src/lab/documents/templates/sections/volumes.html b/src/lab/documents/templates/sections/volumes.html index ba9ef86..f9659ea 100644 --- a/src/lab/documents/templates/sections/volumes.html +++ b/src/lab/documents/templates/sections/volumes.html @@ -1,6 +1,6 @@ {% from "components/ui.html" import reference, section_heading %}
      - {{ section_heading("03", "volumes-title", "Calculated final volumes") }} + {{ section_heading("04" if document.preparation.plates else "03", "volumes-title", "Calculated final volumes") }}

      Nonempty wells after all operations, including liquid remaining in sources.

      {% for resource in document.resources if resource.final_wells %} diff --git a/src/lab/documents/templates/worksheet.html b/src/lab/documents/templates/worksheet.html index 117d82b..9f67b5e 100644 --- a/src/lab/documents/templates/worksheet.html +++ b/src/lab/documents/templates/worksheet.html @@ -16,13 +16,15 @@

      {{ document.protocol.name }}

      Target
      {{ document.target.name }}
      Operations
      {{ document.procedure|length }}
      +
      Fresh tips
      {{ document.tip_count }}
      Resources
      {{ document.resources|length }}
      Output samples
      {{ document.protocol.output_sample_ids|length }}
      {% if document.target.setup %}

      Equipment setup

      @@ -30,6 +32,7 @@

      {{ document.protocol.name }}

      {% endif %} {% include "sections/materials.html" %} + {% include "sections/preparation.html" %} {% include "sections/procedure.html" %} {% include "sections/volumes.html" %} {% include "sections/details.html" %} diff --git a/tests/test_concentration_series.py b/tests/test_concentration_series.py index 7514b43..a157d41 100644 --- a/tests/test_concentration_series.py +++ b/tests/test_concentration_series.py @@ -20,7 +20,7 @@ from lab.config import PortableLiquidClassConfig from lab.deck import Deck, HolderSite, Rail, Slot from lab.equipment import LiquidHandler -from lab.model import Mix, Transfer +from lab.model import Distribute, Mix, Transfer from lab.samples import Location from lab.units import units @@ -47,6 +47,18 @@ def protocol(): return recipe.protocol(inputs.prepare_wells(layout(), config), config) +def delivered_transfers(steps): + """Expand distributions to compare per-well doses independently of batching.""" + return [ + Transfer(step.source, destination, step.volume, step.origin, step.liquid_class) + for step in steps + if isinstance(step, (Transfer, Distribute)) + for destination in ( + (step.destination,) if isinstance(step, Transfer) else step.destinations + ) + ] + + try: from opentrons.simulate import simulate except ImportError: @@ -55,7 +67,7 @@ def protocol(): def test_preparation_matches_independent_doses_inventory_and_identity(): bundle = compile(protocol(), to=None) - transfers = [s for s in bundle.protocol.steps if isinstance(s, Transfer)] + transfers = delivered_transfers(bundle.protocol.steps) # Expected Van dose in uL; fixed additions are 20 Sal + 2 TMP + 2 DMSO + 10 culture. expected = { @@ -114,7 +126,7 @@ def test_preparation_matches_independent_doses_inventory_and_identity(): assert '"name": "DMSO"' in bundle.protocol.description assert "DMSO" in bundle.files["protocol.html"] assert all(s.liquid_class is not None for s in bundle.protocol.steps) - assert all(isinstance(s, (Transfer, Mix)) for s in bundle.protocol.steps) + assert all(isinstance(s, (Transfer, Distribute, Mix)) for s in bundle.protocol.steps) mixes = [s for s in bundle.protocol.steps if isinstance(s, Mix)] assert [(s.location, s.volume, s.cycles) for s in mixes] == [ item @@ -123,6 +135,30 @@ def test_preparation_matches_independent_doses_inventory_and_identity(): ] +def test_additions_are_grouped_by_source_and_dose_before_inoculation(): + steps = protocol().steps + assert len(steps) == 47 + assert all( + isinstance(step, Transfer) and step.source.resource == "medium" for step in steps[:9] + ) + distributions = [step for step in steps if isinstance(step, Distribute)] + assert [(step.source.well, step.volume) for step in distributions] == [ + ("A5", 2), + ("A3", 20), + ("A4", 2), + ] + assert all( + tuple(well.well for well in step.destinations) == configuration().wells + for step in distributions + ) + first_inoculum_mix = next(index for index, step in enumerate(steps) if isinstance(step, Mix)) + assert first_inoculum_mix == 20 + assert all( + step.source.resource == "medium" or step.source.well != "B1" + for step in steps[:first_inoculum_mix] + ) + + @pytest.mark.integration @pytest.mark.skipif(simulate is None, reason="Opentrons SDK not installed") def test_generated_ot2_actions_match_transfers_mixes_and_tips(tmp_path, monkeypatch): @@ -134,25 +170,28 @@ def test_generated_ot2_actions_match_transfers_mixes_and_tips(tmp_path, monkeypa texts = [event["payload"]["text"] for event in log] aspirate = re.compile(r"Aspirating ([0-9.]+) uL from ([A-H]\d+) of .+ on slot (\d+) at ") dispense = re.compile(r"Dispensing ([0-9.]+) uL into ([A-H]\d+) of .+ on slot (\d+) at ") - pending, moves = None, [] + pending, remaining, moves = None, Decimal(0), [] for text in texts: start, end = aspirate.search(text), dispense.search(text) if start: - pending = (start[3], start[2], Decimal(start[1])) + assert remaining == 0 + pending, remaining = (start[3], start[2]), Decimal(start[1]) elif end: assert pending is not None destination = (end[3], end[2]) - if destination != pending[:2]: - assert Decimal(end[1]) == pending[2] - moves.append((*pending[:2], *destination, pending[2])) + volume = Decimal(end[1]) + assert 0 < volume <= remaining + remaining -= volume + if destination != pending: + moves.append((*pending, *destination, volume)) + assert remaining == 0 bindings = { b.location: tuple(b.physical.removeprefix("slot ").split("/")) for b in bundle.target.bindings } expected = [ (*bindings[s.source], *bindings[s.destination], s.volume) - for s in bundle.protocol.steps - if isinstance(s, Transfer) + for s in delivered_transfers(bundle.protocol.steps) ] assert moves == expected mixes = [s for s in bundle.protocol.steps if isinstance(s, Mix)] @@ -162,7 +201,8 @@ def test_generated_ot2_actions_match_transfers_mixes_and_tips(tmp_path, monkeypa assert actual.startswith(f"Mixing {expected_mix.cycles} times with a volume of ") volume = re.search(r"volume of ([0-9.]+)", actual) assert volume and Decimal(volume[1]) == expected_mix.volume - tips = len(expected) + len(mixes) + tips = len(bundle.protocol.steps) + assert tips == 47 assert sum(t.startswith("Picking up tip") for t in texts) == tips assert sum(t.startswith("Dropping tip") for t in texts) == tips @@ -255,8 +295,8 @@ def test_csv_targets_are_read_and_duplicate_conditions_remain_separate(tmp_path) bundle = compile(recipe.protocol(inputs.prepare_wells(layout(path), config), config), to=None) van = [ s - for s in bundle.protocol.steps - if isinstance(s, Transfer) and s.source == Location("reagents", "A1") + for s in delivered_transfers(bundle.protocol.steps) + if s.source == Location("reagents", "A1") ] assert [(s.destination.well, s.volume) for s in van] == [("A1", 10), ("A5", 10)] assert len(bundle.manifest.samples) == 2 @@ -458,7 +498,7 @@ def test_cli_replaces_existing_bundle_with_current_inputs(tmp_path, monkeypatch, assert json.loads( description.split("Protocol configuration: ", 1)[1] ) == configuration().model_dump(mode="json") - assert len(plan["protocol"]["steps"]) == 71 + assert len(plan["protocol"]["steps"]) == 47 assert "Previous compilation" not in (output / "protocol.html").read_text() assert len(json.loads((output / "manifest.json").read_text())["outputs"]) == 9 assert not (output / "protocol.py").exists() diff --git a/tests/test_preparation_view.py b/tests/test_preparation_view.py new file mode 100644 index 0000000..9ed94ac --- /dev/null +++ b/tests/test_preparation_view.py @@ -0,0 +1,152 @@ +"""The plate preview uses the same accounting and geometry as the compiled plan.""" + +import json +import re +from dataclasses import replace +from decimal import Decimal + +from lab import LiquidClass, Protocol, celsius, compile, seconds, uL +from lab.documents.prepare import build_worksheet +from lab.samples import Location + + +def test_preview_tracks_serial_transfers_distribution_and_nonliquid_steps(): + p = Protocol("Preparation") + source = p.container("stock", contents="Buffer", volume=300 * uL, capacity=400 * uL) + plate = p.plate("assay", shape=(2, 3), capacity=200 * uL) + p.transfer(source, plate["A1"], volume=100 * uL) + p.mix(plate["A1"], volume=40 * uL, cycles=2) + p.transfer(plate["A1"], plate["B3"], volume=30 * uL) + p.distribute(source, (plate["A2"], plate["B1"]), volume=20 * uL, air_gap=2 * uL) + p.wait(2 * seconds) + p.set_temperature(plate, celsius(37)) + p.thermocycle(plate, [(celsius(37), 10 * seconds)], cycles=2) + p.manual("Read the plate.") + compilation = compile(p, to=None) + worksheet = build_worksheet(compilation) + assert worksheet.tip_count == 4 + wells = { + well.location: well.reference.anchor + for resource in worksheet.resources + for well in resource.wells + } + stock, a1, a2, b1, b3 = [ + wells[Location(*location)] + for location in ( + ("stock", "A1"), + ("assay", "A1"), + ("assay", "A2"), + ("assay", "B1"), + ("assay", "B3"), + ) + ] + frames = worksheet.preparation.frames + assert len(frames) == len(compilation.protocol.steps) + 1 + volumes = [{well.anchor: well.volume for well in frame.volumes} for frame in frames] + assert volumes[0] == {stock: 300, a1: 0, a2: 0, b1: 0, b3: 0} + assert volumes[1] == {stock: 200, a1: 100, a2: 0, b1: 0, b3: 0} + assert volumes[2] == volumes[1] + assert volumes[3] == {stock: 200, a1: 70, a2: 0, b1: 0, b3: 30} + assert volumes[4] == {stock: 160, a1: 70, a2: 20, b1: 20, b3: 30} + assert all(volume == volumes[4] for volume in volumes[5:]) + assert all(sum(volume.values()) == 300 for volume in volumes) + assert volumes[-1] == { + wells[location]: volume + for location, volume in compilation.final_volumes + if location in wells + } + assert frames[1].source == stock and frames[1].destinations == (a1,) + assert frames[2].active == (a1,) and frames[2].source is None + assert frames[2].mix_cycles == 2 + assert frames[3].source == a1 and frames[3].destinations == (b3,) + assert frames[4].destinations == (a2, b1) + assert frames[5].source is None and frames[5].active == () + assert set(frames[6].active) == {a1, a2, b1, b3} + assert frames[6].mix_cycles == 0 + assert frames[7].active == frames[6].active + assert frames[8].active == () + + layout = worksheet.preparation.plates[1] + assert layout.rows == ("A", "B") and layout.columns == (1, 2, 3) + assert [well.coordinate for well in layout.wells] == ["A1", "A2", "A3", "B1", "B2", "B3"] + assert layout.wells[2].reference is None + assert (layout.wells[-1].row, layout.wells[-1].column) == (1, 2) + assert (layout.wells[-1].x, layout.wells[-1].y) == (104, 72) + + +def test_well_fill_area_uses_effective_physical_capacity(): + p = Protocol("Bound capacity") + source = p.container("stock", contents="Buffer", volume=100 * uL, capacity=400 * uL) + output = p.container("output", capacity=200 * uL) + p.transfer(source, output, volume=25 * uL) + compilation = compile(p, to=None) + target = replace( + compilation.target, + bindings=tuple( + replace(binding, capacity=Decimal(100)) + if binding.location.resource == "stock" + else binding + for binding in compilation.target.bindings + ), + ) + worksheet = build_worksheet(replace(compilation, target=target)) + initial, final = worksheet.preparation.frames + assert initial.volumes[0].radius == 11 + assert abs(final.volumes[0].radius ** 2 / Decimal(121) - Decimal("0.75")) < Decimal("1e-24") + assert final.volumes[1].volume == 25 + + +def test_preview_payload_and_svg_labels_are_escaped_and_standalone(): + p = Protocol("Preview") + source = p.container("stock", contents="Buffer ", volume=100 * uL, capacity=200 * uL) + output = p.container("output", capacity=200 * uL) + p.transfer(source, output, volume=10 * uL) + instruction = '' + p.manual(instruction) + html = compile(p, to=None).files["protocol.html"] + payload = re.search( + r'', html, re.DOTALL + ).group(1) + frames = json.loads(payload) + assert frames[-1]["description"] == f"Operator: {instruction}" + assert instruction not in html and "" not in payload + assert "Buffer <A&B>" in html + assert "