diff --git a/.gitignore b/.gitignore index 98ea52f..49fa2b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store .venv/ __pycache__/ *.py[cod] @@ -7,3 +8,5 @@ __pycache__/ build/ dist/ *.egg-info/ + +docs/lmsf/ diff --git a/README.md b/README.md index b332339..4cd8cad 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,32 @@ 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. + +The preparation preview shows logical plate layouts and reagent inputs. Step through or play the procedure to see source volumes decrease and destination wells fill; a schematic pipette and directional paths show each transfer. Distributions visit each destination, and mixes animate in place. Click a procedure step number to preview that state. Well details and reagent amounts follow the selected step. The preview uses the compiler's volume accounting, with animation timing chosen for readability. It shows planned liquid movement; physical well bindings are available in the detail cards. + +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 + +Preparation proceeds across the plate: medium first, then vehicle and each stock, followed by inoculation and mixing. Equal doses from the same source share a `distribute()` operation and one tip. Unique doses remain individual transfers. Preparing the full supplied layout requires 327 tips, including source resuspension and destination mixing. The worksheet reports the tip count alongside the operation count. + +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. Every CSV row is prepared by default: 96 wells covering 24 conditions for each of four plasmid samples. An optional `wells` list in the configuration selects a subset explicitly. `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, with reservoir sources and enough tips for the full plate. + +`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 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. 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. + ## Development From the repository root: @@ -197,6 +223,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/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/docs/worksheet-ui.md b/docs/worksheet-ui.md new file mode 100644 index 0000000..9bf796c --- /dev/null +++ b/docs/worksheet-ui.md @@ -0,0 +1,62 @@ +# 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. | +| `preparation.py` | Turn the compiler's volume trace into plate geometry and a snapshot after each operation. | +| `templates/worksheet.html` | Compose the page and embed its assets. | +| `templates/sections/` | Materials, preparation preview, ordered procedure, final volumes, and reference details. | +| `templates/components/` | Reusable references, tables, headings, fields, detail cards, plate maps, 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. + +`` displays the logical resource grids and lets the reader play, step, or scrub through the procedure. It uses `volume_trace()` from the compiler's validator; JavaScript selects precomputed snapshots and does no volume accounting. A snapshot is the state after one complete protocol operation, including all destinations of a distribution. Mixes, waits, temperature operations, and operator instructions retain the recorded liquid volumes. Source and destination outlines identify transfers; a dashed outline identifies mixing and temperature operations. Filled well area is proportional to volume divided by the smaller of declared and bound capacities. + +The maps represent logical row and column coordinates. They do not infer robot trajectories, physical deck geometry, tip use, mixing completeness, or chemical composition. Initial reagent labels and sample identities come directly from the protocol. Well details include physical bindings. Playback uses one continuous clock for the pipette, dispensing drops, and position slider, allowing more time for distributions and repeated mixes. Between operations, the empty pipette travels to the next source in a short transition. Pausing preserves the current position; scrubbing selects a fractional position within an operation, and keyboard arrows step through whole operations. Playback starts only on request and pauses when the preview leaves view, the tab is hidden, or the reader inspects a well. Reduced-motion mode disables fill and pipette animations. + +`` draws directional paths between the rendered wells and animates a schematic pipette, aspiration, and dispensing drops. A distribution connects its source and ordered destinations; mixing repeats its recorded cycles in place. Temperature operations have no pipette motion. The component measures the actual displayed well positions, redraws on resize or plate scrolling, and leaves a static route available while paused or in reduced-motion mode. The animation illustrates the selected operation; the numeric snapshot remains explicitly labeled as its after-state. It is independent of hardware choreography and duration: a target may refill the tip between destinations. + +For the Manual target, a distribution represents a separate measured aspiration and complete dispense for each destination. The animation returns to the source empty between wells and the flow profile uses the same alternating aspiration/dispense phases. The grouped procedure step and its total delivered volumes remain unchanged. This avoids depicting repeated partial strokes from a single large load with an ordinary manual micropipette. + +The overview counts one fresh tip for each `Transfer`, `Distribute`, or `Mix`, matching the current target policy. A distribution reuses its tip across its destinations. The concentration-series recipe explicitly groups identical source/dose additions into distributions; the document renderer does not reorder operations. + +Each preview change publishes `lab:previewchange` inside its worksheet. `` uses that event to display the selected state's amount in reagent lists and well-detail cards, including cloned inspector cards. The preview also exposes `currentFrame` so a newly connected volume display can read the selected snapshot. This keeps preview-specific behavior out of the inspector. Procedure step numbers link back to the matching snapshot. + +The initial plate maps and loads remain visible without JavaScript. Printing selects the initial state and hides playback controls; closing print preview restores the selected state. Each plate is a keyboard stop: arrow keys move between used wells in a row or column, Home/End move to the first/last used well, and Enter opens its details. + +## 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 tests/test_preparation_view.py` for operation fidelity, escaping, references, flow calculations, and plate snapshots. 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/__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..21a0383 --- /dev/null +++ b/examples/concentration_series/__main__.py @@ -0,0 +1,65 @@ +"""Prepare an LMSF fitness CSV layout using explicit configuration. + +Run with --csv and --config (see config.toml). Stocks are supplied already +prepared. Every CSV row is prepared unless config.wells selects a subset. +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) + summary, _, notes = p.description.partition("\n\n") + 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( + p, + deck=config.deck if handler else None, + liquid_handler=handler, + to=args.out or f"build/concentration_series/csv/{args.target}", + ) + print(bundle.directory) + 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..1bf54d5 --- /dev/null +++ b/examples/concentration_series/config.toml @@ -0,0 +1,183 @@ +# Illustrative preparation inputs, NOT recovered or validated LMSF method settings. +# 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. +# Omitting wells prepares every CSV row. Set wells = ["A1", "A5"] for a subset. +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 = "500 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" # medium reservoir; other sources use reagents +volume = "15000 uL" +dead_volume = "500 uL" +liquid_class = "aqueous" + +[vehicle] +name = "DMSO" +well = "A5" +volume = "1000 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 = "1500 uL" +concentration = "100 umol/L" +max_target = "25 umol/L" +vehicle_fraction = "0" +liquid_class = "aqueous" + +[[stocks]] +solute = "Van" +well = "A2" +volume = "1000 uL" +concentration = "1000 umol/L" +max_target = "200 umol/L" +vehicle_fraction = "0" +liquid_class = "aqueous" + +[[stocks]] +solute = "Sal" +well = "A3" +volume = "3000 uL" +concentration = "100 umol/L" +max_target = "100 umol/L" +vehicle_fraction = "0" +liquid_class = "aqueous" + +[[stocks]] +solute = "TMP" +well = "A4" +volume = "1000 uL" +concentration = "300 ug/mL" +max_target = "3 ug/mL" +vehicle_fraction = "1" +liquid_class = "dmso" + +[[cultures]] +strain = "example-strain" +plasmid = "example-plasmid-1" +well = "A6" +volume = "1000 uL" +liquid_class = "culture" + +[[cultures]] +strain = "example-strain" +plasmid = "example-plasmid-2" +well = "A7" +volume = "1000 uL" +liquid_class = "culture" + +[[cultures]] +strain = "example-strain" +plasmid = "example-plasmid-3" +well = "A8" +volume = "1000 uL" +liquid_class = "culture" + +[[cultures]] +strain = "example-strain" +plasmid = "example-plasmid-4" +well = "A9" +volume = "1000 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 = "reservoir", rows = 1, columns = 12, capacity_ul = "15000" } + +[[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 = "nest_12_reservoir_15_ml", 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" } }, + { id = "p300_tips_2", model = "opentrons_300_ul", location = { name = "7" } }, + { id = "p300_tips_3", model = "opentrons_300_ul", location = { name = "8" } }, + { id = "p20_tips_2", model = "opentrons_20_ul", location = { name = "9" } }, +] +pipettes = [ + { model = "p300_single_gen2", mount = "left", tip_racks = ["p300_tips", "p300_tips_2", "p300_tips_3"] }, + { model = "p20_single_gen2", mount = "right", tip_racks = ["p20_tips", "p20_tips_2"] }, +] + +[[deck.layouts]] +liquid_handler = "star" +placements = [ + { container = "reagents", model = "nest_12_reservoir_15_ml", 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 } }, + { id = "star_tips_2", model = "hamilton_300_ul", location = { holder = "tips", index = 1 } }, + { id = "star_tips_3", model = "hamilton_300_ul", location = { holder = "tips", index = 2 } }, + { id = "star_tips_4", model = "hamilton_300_ul", location = { holder = "tips", index = 3 } }, +] +# 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", "star_tips_2", "star_tips_3", "star_tips_4"] }, +] diff --git a/examples/concentration_series/inputs.py b/examples/concentration_series/inputs.py new file mode 100644 index 0000000..7d39095 --- /dev/null +++ b/examples/concentration_series/inputs.py @@ -0,0 +1,258 @@ +"""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)] | None = None + 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, ...] | None) -> tuple[str, ...] | None: + if wells is not None and 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} + selected_wells = config.wells if config.wells is not None else tuple(conditions) + if missing := set(selected_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 selected_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/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/examples/concentration_series/protocol.py b/examples/concentration_series/protocol.py new file mode 100644 index 0000000..e5a0bb4 --- /dev/null +++ b/examples/concentration_series/protocol.py @@ -0,0 +1,165 @@ +"""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, Quantity, Well, 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: + """Add each reagent across the plate before inoculation and final 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=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)}", + ) + + # Hold prepared stocks, vehicle, and inocula in separate reservoir channels. + reagents = p.plate( + "reagents", shape=(1, 12), capacity=15000 * 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, + ) + + def add_across_plate( + source: Well, doses: Sequence[tuple[Well, Quantity]], liquid_class: LiquidClassSpec + ) -> None: + """Share a tip across equal doses; retain individual transfers for unique doses.""" + groups: dict[Quantity, list[Well]] = {} + for destination, dose in doses: + if dose.magnitude: + groups.setdefault(dose, []).append(destination) + for dose, wells in groups.items(): + if len(wells) == 1: + + # Supply a condition-specific dose from this source. + p.transfer(source, wells[0], volume=dose, liquid_class=liquid_class) + else: + + # Supply the same reagent dose to these conditions with one tip. + p.distribute(source, wells, volume=dose, liquid_class=liquid_class) + + # Establish the medium volume in every selected well before adding other reagents. + add_across_plate( + medium, + [(destinations[prepared.well], prepared.medium_volume) for prepared in prepared_wells], + liquid_classes[config.medium.liquid_class], + ) + + # Balance the solvent fraction across the selected conditions. + add_across_plate( + reagents[config.vehicle.well], + [(destinations[prepared.well], prepared.vehicle_volume) for prepared in prepared_wells], + liquid_classes[config.vehicle.liquid_class], + ) + for stock in config.stocks: + + # Add this working stock to every condition that uses it before changing sources. + add_across_plate( + reagents[stock.well], + [ + (destinations[prepared.well], dose) + for prepared in prepared_wells + for selected, dose in prepared.stock_volumes + if selected.well == stock.well + ], + liquid_classes[stock.liquid_class], + ) + + for prepared in prepared_wells: + well, culture = prepared.well, prepared.culture + destination = destinations[well] + + # 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..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", ] @@ -32,11 +33,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 +90,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 deleted file mode 100644 index b4aceb1..0000000 --- a/src/lab/documents.py +++ /dev/null @@ -1,137 +0,0 @@ -"""A printable document rendered from the same frozen data as robot code.""" - -from html import escape -from typing import Any - -from lab.model import ( - Distribute, - ManualInstruction, - Mix, - SetTemperature, - Step, - Thermocycle, - Transfer, - Wait, -) -from lab.units import number - - -def describe(step: Step) -> str: - match step: - case Transfer(source, destination, volume, _): - return f"Transfer {number(volume)} µL from {source} to {destination}." - 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}" - case Mix(location, volume, cycles, _): - return f"Mix {location}: {cycles} cycles of {number(volume)} µL." - 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/preparation.css b/src/lab/documents/assets/preparation.css new file mode 100644 index 0000000..5e23640 --- /dev/null +++ b/src/lab/documents/assets/preparation.css @@ -0,0 +1,95 @@ +lab-preparation { display: block; } +.preparation-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 16px; border: 1px solid var(--line); border-radius: 8px 8px 0 0; background: #f8faf8; } +.preparation-controls[hidden] { display: none; } +.playback-buttons, .playback-endpoints { display: flex; gap: 6px; } +.preparation-controls button { min-width: 36px; min-height: 36px; padding: 6px 12px; border: 1px solid var(--line); border-radius: 5px; background: white; color: #263b39; font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer; } +.preparation-controls button:active { transform: scale(.97); } +.preparation-controls button:disabled { opacity: .4; cursor: default; } +.preparation-controls [data-play] { min-width: 72px; color: white; background: var(--accent); border-color: var(--accent); } +.preparation-controls button:focus-visible, .preparation-scrubber input:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } +.preparation-scrubber { display: flex; flex: 1; align-items: center; gap: 12px; min-width: 180px; font-size: 11px; color: var(--muted); } +.preparation-scrubber input { width: 100%; min-width: 60px; accent-color: var(--accent); } +.preparation-status { padding: 16px 20px; border: 1px solid var(--line); border-top: 0; background: #edf5f1; min-height: 112px; } +.preparation-status p { margin: 5px 0; font-size: 12px; overflow-wrap: anywhere; } +.preparation-status .preparation-position { font-size: 14px; font-weight: 650; } +.preparation-status a { display: inline-block; margin-top: 3px; font-size: 11px; } +.preparation-status a[hidden] { display: none; } +.plate-legend { display: flex; flex-wrap: wrap; gap: 12px 20px; padding: 16px 0; font-size: 10px; color: var(--muted); } +.plate-legend span { display: inline-flex; gap: 7px; align-items: center; } +.plate-legend i { display: inline-block; width: 12px; height: 12px; border-radius: 50%; } +.legend-liquid { background: #6dafa1; border: 1px solid #3c8a79; } +.legend-source { border: 2px solid #b76a25; } +.legend-destination { border: 2px solid #176a5e; } +.legend-active { border: 2px dashed #7861a3; } +.preparation-plates { position: relative; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 20px; align-items: start; } +.preparation-resource { min-width: 0; border: 1px solid var(--line); border-radius: 8px; padding: 18px; } +.plate-heading { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 16px; } +.plate-heading h3 { font-size: 14px; } +.plate-heading > span { color: var(--muted); font-size: 10px; } +.plate-map-viewport { overflow-x: auto; } +.plate-map { width: 100%; display: block; min-width: var(--plate-min-width); } +.plate-surface { fill: #f5f7f4; stroke: #dce3df; } +.plate-axis { text-anchor: middle; fill: var(--muted); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; } +.well-unused { fill: #f0f2ee; stroke: #dce3df; } +.well-hit { fill: transparent; } +.well-outline { fill: white; stroke: #a7bcb3; stroke-width: 1; } +.well-liquid { fill: #6dafa1; pointer-events: none; } +[data-map-well] { cursor: pointer; } +[data-map-well].is-source .well-outline { stroke: #b76a25; stroke-width: 2.5; } +[data-map-well].is-destination .well-outline { stroke: #176a5e; stroke-width: 2.5; } +[data-map-well].is-active .well-outline { stroke: #7861a3; stroke-width: 2.5; stroke-dasharray: 3 2; } +[data-map-well]:focus-visible { outline: none; } +[data-map-well]:focus-visible .well-hit, [data-map-well].inspect-active .well-hit { stroke: #263b39; stroke-width: 1.5; } +.plate-inputs { margin-top: 16px; } +.plate-inputs summary { padding: 0; font-size: 11px; } +.plate-inputs summary span { margin-left: 6px; color: var(--muted); font-weight: 400; } +.plate-inputs ul { padding: 0; margin: 10px 0 0; list-style: none; } +.input-heading { display: flex; justify-content: space-between; gap: 12px; padding-left: 34px; margin-top: 12px; font-size: 9px; color: var(--muted); } +.plate-inputs li { display: grid; grid-template-columns: 26px 1fr auto; align-items: baseline; gap: 8px; padding: 7px 0; border-top: 1px solid #edf0ed; font-size: 11px; } +.input-material { min-width: 0; overflow-wrap: anywhere; } +.input-coordinate { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 10px; } +lab-well-volume { font-variant-numeric: tabular-nums; white-space: nowrap; } +.well-preview-volume { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; margin-top: 16px; background: #edf5f1; border-radius: 5px; font-size: 12px; } +.well-preview-volume > span { color: var(--muted); white-space: normal; } +.single-container { display: grid; grid-template-columns: 100px 1fr; column-gap: 16px; } +.single-container .plate-heading { grid-column: 1 / -1; } +.single-container .plate-map { min-width: 0; } +.single-container .plate-inputs { margin-top: 0; } +.plate-caption, .preparation-caption { color: var(--muted); font-size: 11px; margin: 14px 0 0; } +.procedure tr.preview-current { box-shadow: inset 3px 0 var(--accent); background: #edf5f1; } +lab-transfer-motion { position: absolute; inset: 0; z-index: 1; pointer-events: none; overflow: hidden; } +lab-transfer-motion[hidden] { display: none; } +.transfer-motion { width: 100%; height: 100%; display: block; overflow: hidden; } +.transfer-route { fill: none; stroke: #176a5e; stroke-width: 1.7; stroke-dasharray: 5 4; marker-end: url(#transfer-arrow); } +.transfer-route-halo { fill: none; stroke: white; stroke-width: 5; stroke-opacity: .85; } +.transfer-return { opacity: 0; } +.pipette-body { fill: white; stroke: #294e47; stroke-width: 1.4; filter: drop-shadow(0 2px 2px #183c3626); } +.pipette-grip { fill: #176a5e; stroke: #294e47; stroke-width: 1.4; } +.pipette-tip { fill: #e6f2eb; stroke: #294e47; stroke-width: 1.2; } +.pipette-liquid, .motion-drop { fill: #176a5e; } +.pipette-liquid { opacity: 0; transform-origin: center bottom; transform-box: fill-box; } +.motion-drop { opacity: 0; } +@media (hover: hover) and (pointer: fine) { + [data-map-well]:hover .well-hit { stroke: #263b39; stroke-width: 1.5; } + .preparation-controls button:not(:disabled):hover { border-color: var(--accent); } +} +@media (prefers-reduced-motion: no-preference) { + lab-preparation.playing .well-liquid { transition: r 180ms cubic-bezier(.23, 1, .32, 1); } +} +@media (max-width: 760px) { + .preparation-controls { gap: 12px; } + .preparation-scrubber { order: 3; flex-basis: 100%; } + .playback-endpoints { margin-left: auto; } + .preparation-resource { padding: 12px; } +} +@media print { + lab-transfer-motion { display: none; } + .preparation-controls, .preparation-status a { display: none; } + .preparation-status { border-top: 1px solid var(--line); min-height: 0; } + .preparation-resource { break-inside: avoid; } + .plate-map { min-width: 0; } + .preparation-plates { grid-template-columns: 1fr 1fr; gap: 12px; } + .preparation-resource { padding: 10px; } + .plate-inputs li { font-size: 7pt; } + .plate-legend, .preparation-caption { font-size: 7pt; } +} diff --git a/src/lab/documents/assets/preparation.js b/src/lab/documents/assets/preparation.js new file mode 100644 index 0000000..6bc6c4d --- /dev/null +++ b/src/lab/documents/assets/preparation.js @@ -0,0 +1,235 @@ +class PreparationPreview extends HTMLElement { + index = 0; + progress = 1; + playing = false; + animationFrame = null; + lastTime = null; + + connectedCallback() { + this.frames = JSON.parse(this.querySelector("[data-preparation-frames]").textContent); + this.scope = this.closest("lab-worksheet"); + this.slider = this.querySelector("[data-preview-position]"); + this.playButton = this.querySelector("[data-play]"); + this.motion = this.querySelector("lab-transfer-motion"); + this.wells = [...this.querySelectorAll("[data-map-well]")]; + this.events = new AbortController(); + const options = { signal: this.events.signal }; + + this.querySelector(".preparation-controls").hidden = false; + this.slider.addEventListener("input", () => this.scrub(Number(this.slider.value)), options); + this.slider.addEventListener("keydown", event => { + const current = Number(this.slider.value); + const positions = { + ArrowLeft: Math.ceil(current) - 1, ArrowDown: Math.ceil(current) - 1, + ArrowRight: Math.floor(current) + 1, ArrowUp: Math.floor(current) + 1, + PageDown: Math.ceil(current) - 10, PageUp: Math.floor(current) + 10, + Home: 0, End: this.frames.length - 1, + }; + if (event.key in positions) { + event.preventDefault(); + this.seek(positions[event.key]); + } + }, options); + this.querySelector("[data-previous]").addEventListener("click", event => this.seek(this.index - 1, event.detail > 0), options); + this.querySelector("[data-next]").addEventListener("click", event => this.seek(this.index + 1, event.detail > 0), options); + this.querySelector("[data-first]").addEventListener("click", () => this.seek(0), options); + this.querySelector("[data-last]").addEventListener("click", event => this.seek(this.frames.length - 1, event.detail > 0), options); + this.playButton.addEventListener("click", () => this.playing ? this.pause() : this.play(), options); + + this.scope.addEventListener("click", event => { + const link = event.target.closest("[data-preview-step]"); + if (!link) return; + event.preventDefault(); + this.seek(Number(link.dataset.previewStep), event.detail > 0); + this.scrollIntoView({ block: "start" }); + this.slider.focus({ preventScroll: true }); + }, options); + this.scope.addEventListener("pointerdown", event => { + if (event.target.closest("[data-inspect]")) this.pause(); + }, options); + this.scope.addEventListener("focusin", event => { + if (event.target.closest("[data-inspect]")) this.pause(); + }, options); + this.addEventListener("keydown", event => this.navigatePlate(event), options); + this.querySelectorAll(".plate-map").forEach(plate => { + plate.querySelectorAll("[data-map-well]").forEach((well, index) => { + well.setAttribute("tabindex", index === 0 ? "0" : "-1"); + }); + }); + + document.addEventListener("visibilitychange", () => { + if (document.hidden) this.pause(); + }, options); + window.addEventListener("beforeprint", () => { + this.printIndex = this.index; + this.seek(0); + }, options); + window.addEventListener("afterprint", () => { + this.seek(this.printIndex ?? this.index); + }, options); + this.visibilityObserver = new IntersectionObserver(entries => { + if (!entries[0].isIntersecting) this.pause(); + }); + this.visibilityObserver.observe(this); + this.showFrame(this.index); + } + + disconnectedCallback() { + this.pause(); + this.events.abort(); + this.visibilityObserver.disconnect(); + } + + seek(index, animate = false) { + this.pause(); + this.showFrame(index, animate); + if (animate) this.play(false); + } + + scrub(position) { + this.pause(); + position = Math.max(0, Math.min(position, this.frames.length - 1)); + this.showFrame(Math.ceil(position), position > 0, false); + this.progress = position === 0 ? 1 : position - (this.index - 1); + this.motion.setTime(this.progress * this.motion.duration); + this.updatePosition(); + } + + showFrame(index, animate = false, connect = true) { + const origin = animate && connect ? this.motion.capturePosition() : null; + this.index = Math.max(0, Math.min(Math.round(index), this.frames.length - 1)); + this.currentFrame = this.frames[this.index]; + this.volumes = new Map(this.currentFrame.volumes.map(well => [well.anchor, well])); + const frame = this.currentFrame; + const label = this.index === 0 ? "Initial loads" : `After step ${this.index} of ${this.frames.length - 1}`; + this.querySelector("[data-preview-label]").textContent = label; + this.querySelector("[data-preview-description]").textContent = frame.description; + const procedure = this.querySelector("[data-preview-procedure]"); + procedure.hidden = this.index === 0; + procedure.href = `#step-${this.index}`; + this.querySelector("[data-previous]").disabled = this.index === 0; + this.querySelector("[data-next]").disabled = this.index === this.frames.length - 1; + this.wells.forEach(well => { + const anchor = well.dataset.mapWell; + const state = this.volumes.get(anchor); + well.querySelector(".well-liquid").setAttribute("r", state.radius); + well.classList.toggle("is-source", anchor === frame.source); + well.classList.toggle("is-destination", frame.destinations.includes(anchor)); + well.classList.toggle("is-active", frame.active.includes(anchor)); + const description = `${well.dataset.wellLabel} · ${state.volume} µL · ${label}`; + well.setAttribute("aria-label", description); + well.querySelector("title").textContent = description; + }); + this.scope.querySelectorAll(".procedure tr[id]").forEach(row => { + const current = row.id === `step-${this.index}`; + row.classList.toggle("preview-current", current); + const link = row.querySelector("[data-preview-step]"); + if (current) link?.setAttribute("aria-current", "step"); + else link?.removeAttribute("aria-current"); + }); + this.scope.dispatchEvent(new CustomEvent("lab:previewchange", { detail: frame })); + this.motion.showFrame(frame, animate, origin); + this.motion.pause(); + this.progress = animate ? 0 : 1; + this.motion.setTime(this.progress * this.motion.duration); + this.updatePosition(); + } + + updatePosition() { + this.slider.value = String(this.index === 0 ? 0 : this.index - 1 + this.progress); + const label = this.index === 0 ? "Initial loads" + : this.progress === 1 ? `After step ${this.index} of ${this.frames.length - 1}` + : `Step ${this.index} of ${this.frames.length - 1}, ${Math.round(this.progress * 100)}% of preview`; + this.slider.setAttribute("aria-valuetext", label); + } + + play(continuous = true) { + if (this.playing) return; + if (this.index === this.frames.length - 1 && this.progress === 1) this.showFrame(0); + this.continuous = continuous; + this.playing = true; + this.classList.add("playing"); + this.playButton.textContent = "Pause"; + this.playButton.setAttribute("aria-label", "Pause preparation preview"); + if (this.progress === 1) this.showFrame(this.index + 1, true); + this.lastTime = null; + this.animationFrame = requestAnimationFrame(time => this.tick(time)); + } + + tick(time) { + if (!this.playing) return; + const elapsed = this.lastTime === null ? 0 : time - this.lastTime; + this.lastTime = time; + this.progress = Math.min(1, this.progress + elapsed / this.motion.duration); + this.motion.setTime(this.progress * this.motion.duration); + this.updatePosition(); + if (this.progress === 1) { + if (!this.continuous || this.index === this.frames.length - 1) { + this.pause(); + return; + } + this.showFrame(this.index + 1, true); + } + this.animationFrame = requestAnimationFrame(next => this.tick(next)); + } + + pause() { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = null; + this.lastTime = null; + this.playing = false; + this.motion.pause(); + this.classList.remove("playing"); + this.playButton.textContent = "Play"; + this.playButton.setAttribute("aria-label", "Play preparation preview"); + } + + navigatePlate(event) { + const well = event.target.closest("[data-map-well]"); + if (!well) return; + const siblings = [...well.closest(".plate-map").querySelectorAll("[data-map-well]")]; + const row = Number(well.dataset.row); + const column = Number(well.dataset.column); + let candidates = []; + switch (event.key) { + case "ArrowRight": candidates = siblings.filter(node => Number(node.dataset.row) === row && Number(node.dataset.column) > column); break; + case "ArrowLeft": candidates = siblings.filter(node => Number(node.dataset.row) === row && Number(node.dataset.column) < column).reverse(); break; + case "ArrowDown": candidates = siblings.filter(node => Number(node.dataset.column) === column && Number(node.dataset.row) > row); break; + case "ArrowUp": candidates = siblings.filter(node => Number(node.dataset.column) === column && Number(node.dataset.row) < row).reverse(); break; + case "Home": candidates = siblings; break; + case "End": candidates = siblings.slice().reverse(); break; + default: return; + } + event.preventDefault(); + const next = candidates[0]; + if (next) { + siblings.forEach(node => node.setAttribute("tabindex", node === next ? "0" : "-1")); + next.focus({ preventScroll: true }); + } + } +} + +class WellVolume extends HTMLElement { + connectedCallback() { + const scope = this.closest("lab-worksheet"); + const preview = scope.querySelector("lab-preparation"); + this.events = new AbortController(); + scope.addEventListener("lab:previewchange", event => this.showFrame(event.detail), { signal: this.events.signal }); + if (preview?.currentFrame) this.showFrame(preview.currentFrame); + } + + disconnectedCallback() { + this.events.abort(); + } + + showFrame(frame) { + const state = frame.volumes.find(well => well.anchor === this.dataset.location); + if (!state) return; + this.querySelector("[data-volume-value]").textContent = state.volume; + const context = this.querySelector("[data-volume-context]"); + if (context) context.textContent = frame.number === 0 ? "Initial loads" : `After step ${frame.number}`; + } +} + +customElements.define("lab-preparation", PreparationPreview); +customElements.define("lab-well-volume", WellVolume); 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/transfer-motion.js b/src/lab/documents/assets/transfer-motion.js new file mode 100644 index 0000000..a4fc25b --- /dev/null +++ b/src/lab/documents/assets/transfer-motion.js @@ -0,0 +1,197 @@ +class TransferMotion extends HTMLElement { + animations = []; + + connectedCallback() { + this.board = this.closest(".preparation-plates"); + this.svg = this.querySelector("svg"); + this.routes = this.querySelector("[data-transfer-routes]"); + this.pipette = this.querySelector("[data-motion-pipette]"); + this.liquid = this.querySelector(".pipette-liquid"); + this.drop = this.querySelector("[data-motion-drop]"); + this.reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); + this.events = new AbortController(); + const options = { signal: this.events.signal }; + this.board.addEventListener("scroll", () => this.redraw(), { ...options, capture: true }); + this.reducedMotion.addEventListener("change", () => this.redraw(true), options); + this.resizeObserver = new ResizeObserver(() => this.redraw()); + this.resizeObserver.observe(this.board); + } + + disconnectedCallback() { + this.stop(); + this.events.abort(); + this.resizeObserver.disconnect(); + } + + stop() { + this.animations.forEach(animation => animation.cancel()); + this.animations = []; + } + + pause() { + this.animations.forEach(animation => { + if (animation.playState === "running") animation.pause(); + }); + } + + setTime(time) { + this.animations.forEach(animation => { animation.currentTime = time; }); + } + + capturePosition() { + if (this.hidden) return null; + const matrix = this.pipette.getScreenCTM(); + return matrix ? new DOMPoint(0, 0).matrixTransform(matrix) : null; + } + + redraw(force = false) { + if (!this.frame || this.hidden) return; + if (force || this.geometry(this.frame).key !== this.geometryKey) { + const animated = this.animations.length > 0; + const progress = animated ? Number(this.animations.at(-1).currentTime ?? 0) / this.duration : 1; + this.showFrame(this.frame, animated); + this.pause(); + this.setTime(progress * this.duration); + } + } + + point(anchor) { + const well = this.board.querySelector(`[data-map-well="${CSS.escape(anchor)}"] .well-outline`); + const bounds = well.getBoundingClientRect(); + const board = this.board.getBoundingClientRect(); + return { x: bounds.x + bounds.width / 2 - board.x, y: bounds.y + bounds.height / 2 - board.y }; + } + + geometry(frame) { + const board = this.board.getBoundingClientRect(); + const anchors = frame.refill_between_destinations + ? frame.destinations.flatMap(destination => [frame.source, destination]) + : frame.source ? [frame.source, ...frame.destinations] : frame.active; + const points = anchors.map(anchor => this.point(anchor)); + return { width: board.width, height: board.height, points, + key: [board.width, board.height, ...points.flatMap(point => [point.x, point.y])].join(",") }; + } + + route(start, end, returning = false) { + const middle = (start.x + end.x) / 2; + const sameRow = Math.abs(start.y - end.y) < 20; + const lift = sameRow ? Math.min(40, Math.abs(end.x - start.x) / 2) : 0; + const d = `M${start.x} ${start.y} C${sameRow ? start.x : middle} ${start.y - lift} ${sameRow ? end.x : middle} ${end.y - lift} ${end.x} ${end.y}`; + const halo = document.createElementNS("http://www.w3.org/2000/svg", "path"); + halo.setAttribute("d", d); + halo.setAttribute("class", "transfer-route-halo"); + const path = halo.cloneNode(); + path.setAttribute("class", "transfer-route"); + if (returning) { + halo.classList.add("transfer-return"); + path.classList.add("transfer-return"); + } + this.routes.append(halo, path); + return path; + } + + showFrame(frame, animate = false, origin = null) { + this.stop(); + this.frame = frame; + this.duration = Math.max(900, frame.refill_between_destinations + ? frame.destinations.length * 900 + : frame.source ? 250 + frame.destinations.length * 450 : frame.mix_cycles * 400); + this.routes.replaceChildren(); + this.hidden = !frame.source && !frame.mix_cycles; + if (this.hidden) return; + const geometry = this.geometry(frame); + this.geometryKey = geometry.key; + this.svg.setAttribute("viewBox", `0 0 ${geometry.width} ${geometry.height}`); + const points = geometry.points; + const board = this.board.getBoundingClientRect(); + const previous = origin ? { x: origin.x - board.x, y: origin.y - board.y + 12 } : null; + const distance = previous ? Math.hypot(previous.x - points[0].x, previous.y - points[0].y) : 0; + const transitionDuration = animate && !this.reducedMotion.matches && distance > .5 + ? Math.min(240, Math.max(100, distance / 3)) : 0; + const paths = points.slice(1).map((point, index) => + this.route(points[index], point, frame.refill_between_destinations && index % 2 === 1)); + const last = points.at(-1); + this.pipette.style.transform = `translate(${last.x}px, ${last.y - 12}px)`; + if (!animate || this.reducedMotion.matches) return; + + const duration = this.duration; + this.duration += transitionDuration; + const position = (point, offset, dip = false) => ({ + transform: `translate(${point.x}px, ${point.y - (dip ? 2 : 12)}px)`, offset: Math.min(1, offset), + }); + let keyframes; + const liquidFrames = [{ transform: "scaleY(0)", opacity: 0, offset: 0 }]; + const travel = (path, start, end) => { + const length = path.getTotalLength(); + for (let sample = 1; sample <= 20; sample++) { + keyframes.push(position(path.getPointAtLength(length * sample / 20), start + (end - start) * sample / 20)); + } + }; + const dispense = (destination, start, end) => { + this.animations.push(this.drop.animate([ + { transform: `translate(${destination.x}px, ${destination.y - 8}px)`, opacity: 0 }, + { transform: `translate(${destination.x}px, ${destination.y - 4}px)`, opacity: 1, offset: .4 }, + { transform: `translate(${destination.x}px, ${destination.y}px)`, opacity: 0 }, + ], { duration: duration * (end - start), delay: transitionDuration + duration * start })); + }; + if (frame.mix_cycles) { + keyframes = [position(points[0], 0)]; + for (let cycle = 0; cycle < frame.mix_cycles; cycle++) { + keyframes.push(position(points[0], (cycle + .5) / frame.mix_cycles, true)); + keyframes.push(position(points[0], (cycle + 1) / frame.mix_cycles)); + liquidFrames.push({ transform: "scaleY(1)", opacity: 1, offset: (cycle + .5) / frame.mix_cycles }); + liquidFrames.push({ transform: "scaleY(0)", opacity: 0, offset: (cycle + 1) / frame.mix_cycles }); + } + } else if (frame.refill_between_destinations) { + keyframes = [position(points[0], 0)]; + const interval = 1 / frame.destinations.length; + frame.destinations.forEach((_, index) => { + const start = index * interval; + const source = points[index * 2]; + const destination = points[index * 2 + 1]; + if (index) travel(paths[index * 2 - 1], start, start + interval * .25); + keyframes.push(position(source, start + interval * .25)); + keyframes.push(position(source, start + interval * .33, true)); + keyframes.push(position(source, start + interval * .4)); + liquidFrames.push({ transform: "scaleY(0)", opacity: 0, offset: start + interval * .25 }); + liquidFrames.push({ transform: "scaleY(1)", opacity: 1, offset: start + interval * .4 }); + travel(paths[index * 2], start + interval * .4, start + interval * .8); + keyframes.push(position(destination, start + interval * .9, true)); + keyframes.push(position(destination, start + interval)); + liquidFrames.push({ transform: "scaleY(1)", opacity: 1, offset: start + interval * .8 }); + liquidFrames.push({ transform: "scaleY(0)", opacity: 0, offset: Math.min(1, start + interval) }); + dispense(destination, start + interval * .8, start + interval); + }); + } else { + keyframes = [position(points[0], 0), position(points[0], .08, true), position(points[0], .16)]; + liquidFrames.push({ transform: "scaleY(0)", opacity: 0, offset: .08 }); + liquidFrames.push({ transform: "scaleY(1)", opacity: 1, offset: .16 }); + paths.forEach((path, index) => { + const start = .16 + index / paths.length * .84; + const interval = .84 / paths.length; + travel(path, start, start + interval * .7); + keyframes.push(position(points[index + 1], start + interval * .85, true)); + keyframes.push(position(points[index + 1], start + interval)); + liquidFrames.push({ transform: `scaleY(${1 - index / paths.length})`, opacity: 1, offset: start + interval * .7 }); + liquidFrames.push({ transform: `scaleY(${1 - (index + 1) / paths.length})`, opacity: index === paths.length - 1 ? 0 : 1, offset: Math.min(1, start + interval) }); + dispense(points[index + 1], start + interval * .7, start + interval); + }); + } + if (transitionDuration) { + const connection = this.route(previous, points[0], true); + const length = connection.getTotalLength(); + const travelFrames = Array.from({length: 21}, (_, sample) => { + const fraction = sample / 20; + return position(connection.getPointAtLength(length * fraction), transitionDuration / this.duration * fraction); + }); + const offset = keyframe => ({ ...keyframe, offset: (transitionDuration + keyframe.offset * duration) / this.duration }); + keyframes = [...travelFrames, ...keyframes.map(offset)]; + liquidFrames.splice(0, liquidFrames.length, + { transform: "scaleY(0)", opacity: 0, offset: 0 }, ...liquidFrames.map(offset)); + } + this.animations.push(this.pipette.animate(keyframes, { duration: this.duration, easing: "linear" })); + this.animations.push(this.liquid.animate(liquidFrames, { duration: this.duration, easing: "linear" })); + } +} + +customElements.define("lab-transfer-motion", TransferMotion); diff --git a/src/lab/documents/assets/worksheet.css b/src/lab/documents/assets/worksheet.css new file mode 100644 index 0000000..2dfd1fd --- /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(5, 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..3b58832 --- /dev/null +++ b/src/lab/documents/flow.py @@ -0,0 +1,93 @@ +"""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, + *, + refill_between_destinations: bool = False, +) -> 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): + if refill_between_destinations: + phases = [(step.volume, aspiration), (step.volume, -dispense)] * len(step.destinations) + else: + 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..a86fcc4 --- /dev/null +++ b/src/lab/documents/models.py @@ -0,0 +1,136 @@ +"""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 WellVolume: + anchor: str + volume: Decimal + radius: Decimal + + +@dataclass(frozen=True) +class PlateWellView: + coordinate: str + row: int + column: int + x: int + y: int + reference: Reference | None + initial: WellVolume | None + + +@dataclass(frozen=True) +class PlateMapView: + resource: ResourceView + rows: tuple[str, ...] + columns: tuple[int, ...] + wells: tuple[PlateWellView, ...] + width: int + height: int + + +@dataclass(frozen=True) +class PreparationFrame: + number: int + description: str + source: str | None + destinations: tuple[str, ...] + active: tuple[str, ...] + volumes: tuple[WellVolume, ...] + mix_cycles: int = 0 + refill_between_destinations: bool = False + + +@dataclass(frozen=True) +class PreparationView: + plates: tuple[PlateMapView, ...] + frames: tuple[PreparationFrame, ...] + + +@dataclass(frozen=True) +class Worksheet: + protocol: RecordedProtocol + target: TargetPlan + digest: str + tip_count: int + summary: str + notes: str + resources: tuple[ResourceView, ...] + procedure: tuple[ProcedureRow, ...] + liquid_classes: tuple[LiquidClassView, ...] + preparation: PreparationView diff --git a/src/lab/documents/preparation.py b/src/lab/documents/preparation.py new file mode 100644 index 0000000..bbf9969 --- /dev/null +++ b/src/lab/documents/preparation.py @@ -0,0 +1,112 @@ +"""Plate geometry and operation snapshots from the compiler's volume accounting.""" + +from __future__ import annotations + +import lab.compiler as compiler +from lab.documents.descriptions import describe +from lab.documents.models import ( + PlateMapView, + PlateWellView, + PreparationFrame, + PreparationView, + ResourceView, + WellVolume, +) +from lab.model import Distribute, Mix, SetTemperature, Thermocycle, Transfer +from lab.samples import Location +from lab.units import number +from lab.validation import volume_trace + + +def build_preparation( + compilation: compiler.Compilation, resources: tuple[ResourceView, ...] +) -> PreparationView: + wells = {well.location: well for resource in resources for well in resource.wells} + bindings = {binding.location: binding for binding in compilation.target.bindings} + capacities = { + well.location: min(resource.resource.capacity, bindings[well.location].capacity) + for resource in resources + for well in resource.wells + } + trace = volume_trace(compilation.protocol, compilation.target.bindings) + frames = [] + for index, volumes in enumerate(trace): + source = None + destinations: tuple[str, ...] = () + active: tuple[str, ...] = () + mix_cycles = 0 + refill_between_destinations = False + description = ( + "Before the first operation. Inspect a well to review its inputs and sample details." + ) + if index: + step = compilation.protocol.steps[index - 1] + description = describe(step) + if isinstance(step, (Transfer, Distribute)): + source = wells[step.source].reference.anchor + targets = (step.destination,) if isinstance(step, Transfer) else step.destinations + destinations = tuple(wells[location].reference.anchor for location in targets) + if isinstance(step, Distribute) and compilation.target.name == "Manual": + refill_between_destinations = True + description += ( + f" For each destination, aspirate a fresh {number(step.volume)} µL " + "from the source and dispense it completely." + ) + elif isinstance(step, Mix): + active = (wells[step.location].reference.anchor,) + mix_cycles = step.cycles + elif isinstance(step, (Thermocycle, SetTemperature)): + active = tuple( + well.reference.anchor + for location, well in wells.items() + if location.resource == step.resource + ) + frames.append( + PreparationFrame( + number=index, + description=description, + source=source, + destinations=destinations, + active=active, + mix_cycles=mix_cycles, + refill_between_destinations=refill_between_destinations, + volumes=tuple( + WellVolume( + well.reference.anchor, + volumes[location], + 11 * (volumes[location] / capacities[location]).sqrt(), + ) + for location, well in wells.items() + ), + ) + ) + initial = {volume.anchor: volume for volume in frames[0].volumes} + plates = [] + for resource in resources: + cells = [] + for row in range(resource.resource.rows): + for column in range(resource.resource.columns): + coordinate = f"{chr(65 + row)}{column + 1}" + well = wells.get(Location(resource.resource.name, coordinate)) + cells.append( + PlateWellView( + coordinate=coordinate, + row=row, + column=column, + x=40 + column * 32, + y=40 + row * 32, + reference=well.reference if well else None, + initial=initial[well.reference.anchor] if well else None, + ) + ) + plates.append( + PlateMapView( + resource=resource, + rows=tuple(chr(65 + row) for row in range(resource.resource.rows)), + columns=tuple(range(1, resource.resource.columns + 1)), + wells=tuple(cells), + width=resource.resource.columns * 32 + 32, + height=resource.resource.rows * 32 + 32, + ) + ) + return PreparationView(tuple(plates), tuple(frames)) diff --git a/src/lab/documents/prepare.py b/src/lab/documents/prepare.py new file mode 100644 index 0000000..b6ad969 --- /dev/null +++ b/src/lab/documents/prepare.py @@ -0,0 +1,248 @@ +"""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.documents.preparation import build_preparation +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, refill_between_destinations=target.name == "Manual" + ) + 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, + tip_count=sum(isinstance(step, (Transfer, Distribute, Mix)) for step in p.steps), + 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() + ), + preparation=build_preparation(compilation, tuple(resources)), + ) diff --git a/src/lab/documents/render.py b/src/lab/documents/render.py new file mode 100644 index 0000000..a1ba27e --- /dev/null +++ b/src/lab/documents/render.py @@ -0,0 +1,50 @@ +"""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.model import encode +from lab.units import number + +STYLESHEETS = ( + "worksheet.css", + "inspector.css", + "flow-profile.css", + "preparation.css", + "responsive.css", + "print.css", +) +SCRIPTS = ("flow-profile.js", "transfer-motion.js", "preparation.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}", + encode=encode, +) + + +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..4155417 --- /dev/null +++ b/src/lab/documents/templates/components/detail_cards.html @@ -0,0 +1,48 @@ +{% 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" }}

    + + Initial loads + {{ well.fill.volume|number if well.fill else "0" }} µL + + {{ 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/plate_map.html b/src/lab/documents/templates/components/plate_map.html new file mode 100644 index 0000000..09e2f4b --- /dev/null +++ b/src/lab/documents/templates/components/plate_map.html @@ -0,0 +1,49 @@ +{% from "components/ui.html" import reference %} + +{% macro plate_map(plate) -%} +{% set resource = plate.resource %} +
    +
    +

    {{ reference(resource.reference) }}

    {{ resource.geometry }} +
    +
    + + + {% for column in plate.columns %}{{ column }}{% endfor %} + {% for row in plate.rows %}{{ row }}{% endfor %} + {% for well in plate.wells %} + {% if well.reference %} + + {{ well.reference.label }} · {{ well.initial.volume|number }} µL initially + + + + + {% else %} + {{ resource.reference.label }}:{{ well.coordinate }} · unused + {% endif %} + {% endfor %} + +
    + {% if resource.loaded_wells %} +
    + Reagent inputs {{ resource.loaded_wells|length }} +
    Initial materialWell volume
    +
      + {% for well in resource.loaded_wells %} +
    • + {{ reference(well.reference, well.location.well) }} + {{ reference(well.reference, well.fill.material) }} + {{ well.fill.volume|number }} µL +
    • + {% endfor %} +
    +
    + {% else %} +

    Initially empty · {{ resource.wells|length }} wells used in this protocol

    + {% endif %} +
    +{%- endmacro %} diff --git a/src/lab/documents/templates/components/transfer_motion.html b/src/lab/documents/templates/components/transfer_motion.html new file mode 100644 index 0000000..fe3ef28 --- /dev/null +++ b/src/lab/documents/templates/components/transfer_motion.html @@ -0,0 +1,17 @@ + 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/preparation.html b/src/lab/documents/templates/sections/preparation.html new file mode 100644 index 0000000..305ee35 --- /dev/null +++ b/src/lab/documents/templates/sections/preparation.html @@ -0,0 +1,42 @@ +{% from "components/ui.html" import section_heading %} +{% from "components/plate_map.html" import plate_map %} +{% if document.preparation.plates %} +
    + {{ section_heading("02", "preparation-title", "Preparation preview") }} +

    Explore the logical plate layout and follow the liquid through each operation.

    + + + +
    +

    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 new file mode 100644 index 0000000..aabf7d0 --- /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("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 %} + + {% endif %} + {% call table("Ordered procedure", "procedure", [("Step", ""), ("Action", ""), ("Volume", "quantity"), ("From / location", ""), ("To", ""), ("Liquid class", "")]) %} + + {% for step in document.procedure %} + {% if document.preparation.plates %}{{ "%02d"|format(step.number) }}{% else %}{{ "%02d"|format(step.number) }}{% endif %} + {% 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..f9659ea --- /dev/null +++ b/src/lab/documents/templates/sections/volumes.html @@ -0,0 +1,19 @@ +{% from "components/ui.html" import reference, section_heading %} +
    + {{ 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 %} + + + + + {% 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..9f67b5e --- /dev/null +++ b/src/lab/documents/templates/worksheet.html @@ -0,0 +1,44 @@ + + + + + + {{ document.protocol.name }} + + + + +
    +
    +
    Lab CompilerProtocol worksheet
    +

    {{ document.protocol.name }}

    + {% if document.summary %}

    {{ document.summary }}

    {% endif %} +
    +
    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

    +
      {% for line in document.target.setup %}
    • {{ line }}
    • {% endfor %}
    +
    + {% endif %} + {% include "sections/materials.html" %} + {% include "sections/preparation.html" %} + {% include "sections/procedure.html" %} + {% include "sections/volumes.html" %} + {% include "sections/details.html" %} +
    + {% include "components/inspector.html" %} +
    + + + 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..6fadd4d --- /dev/null +++ b/tests/test_concentration_series.py @@ -0,0 +1,560 @@ +"""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 Distribute, Mix, Transfer +from lab.samples import Location +from lab.units import units + +ROOT = Path(__file__).resolve().parents[1] +CSV = ROOT / "examples/concentration_series/layout.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) + + +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: + simulate = None + + +def test_preparation_matches_independent_doses_inventory_and_identity(): + bundle = compile(protocol(), to=None) + transfers = delivered_transfers(bundle.protocol.steps) + + conditions = layout().conditions + expected = {f"{row}{column}" for row in "ABCDEFGH" for column in range(1, 13)} + assert {row.well for row in conditions} == expected + assert configuration().wells is None + samples = {sample.id: sample for sample in bundle.manifest.samples} + for row in conditions: + moves = [s for s in transfers if s.destination == Location("cultures", row.well)] + + # Independently balance all CSV targets against the supplied working stocks. + van_stock = "A1" if row.inducer1_concentration <= 25 else "A2" + van = row.inducer1_concentration * 200 / (100 if van_stock == "A1" else 1000) + sal = row.inducer2_concentration * 200 / 100 + tmp = row.selector_concentration * 200 / 300 + culture_source = f"A{6 + (ord(row.row) - ord('A')) // 2}" + doses = { + Location("medium", "A1"): Decimal(186) - van - sal, + Location("reagents", "A5"): Decimal(4) - tmp, + Location("reagents", van_stock): van, + Location("reagents", "A3"): sal, + Location("reagents", "A4"): tmp, + Location("reagents", culture_source): Decimal(10), + } + assert {s.source: s.volume for s in moves} == { + source: dose for source, dose in doses.items() if dose + } + assert sum(s.volume for s in moves) == 200 + assert (doses[Location("reagents", "A5")] + tmp) / 200 == Decimal("0.02") + sample = samples[f"culture-{row.well}"] + assert sample.material_identity == row.plasmid + assert sample.parent_ids == (f"inoculum-{culture_source}",) + assert sample.replicate is None + + 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", "596.25"), + ("A2", "680"), + ("A3", "820"), + ("A4", "832"), + ("A5", "784"), + ("A6", "760"), + ("A7", "760"), + ("A8", "760"), + ("A9", "760"), + ): + assert abs(remaining[Location("reagents", well)] - Decimal(volume)) < Decimal("1e-12") + assert abs(remaining[Location("medium", "A1")] - Decimal("547.75")) < Decimal("1e-12") + assert len(bundle.manifest.samples) == 96 + assert {p.location.well for p in bundle.manifest.placements} == set(expected) + 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, 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 + for row in conditions + for item in ( + (Location("reagents", f"A{6 + (ord(row.row) - ord('A')) // 2}"), 50, 3), + (Location("cultures", row.well), 100, 3), + ) + ] + + +def test_additions_are_grouped_by_source_and_dose_before_inoculation(): + steps = protocol().steps + assert len(steps) == 327 + assert all( + isinstance(step, Distribute) and step.source.resource == "medium" for step in steps[:20] + ) + distributions = [step for step in steps if isinstance(step, Distribute)] + assert len(distributions) == 39 + assert len({(step.source, step.volume) for step in distributions}) == len(distributions) + assert {destination.well for step in steps[:20] for destination in step.destinations} == { + row.well for row in layout().conditions + } + first_inoculum_mix = next(index for index, step in enumerate(steps) if isinstance(step, Mix)) + assert first_inoculum_mix == 39 + assert all( + step.source.resource == "medium" or step.source.well in {"A1", "A2", "A3", "A4", "A5"} + 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): + 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, remaining, moves = None, Decimal(0), [] + for text in texts: + start, end = aspirate.search(text), dispense.search(text) + if start: + assert remaining == 0 + pending, remaining = (start[3], start[2]), Decimal(start[1]) + elif end: + assert pending is not None + destination = (end[3], end[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 delivered_transfers(bundle.protocol.steps) + ] + 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(bundle.protocol.steps) + assert tips == 327 + 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), *s.cultures[1:]), + ), + "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 = "500 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 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 + + +@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) + + # The SDK accumulates binary floats, while the compiler retains decimal doses. + assert abs(Decimal(str(resource.tracker.get_used_volume())) - volume) < Decimal("1e-8") + + +def test_explicit_subset_preserves_requested_order_and_culture_assignments(): + config = updated(configuration(), wells=("H12", "A1")) + prepared = inputs.prepare_wells(layout(), config) + assert [well.well for well in prepared] == ["H12", "A1"] + assert [well.culture.plasmid for well in prepared] == [ + "example-plasmid-4", + "example-plasmid-1", + ] + bundle = compile(recipe.protocol(prepared, config), to=None) + assert {placement.location.well for placement in bundle.manifest.placements} == {"H12", "A1"} + + +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() | {"wells"} + assert saved["wells"] is None + 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", row.well)] == 180 + for row in layout().conditions + ) + 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", row.well)] == 200 + for row in layout().conditions + ) + + +@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"] + 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"]) == 327 + assert "Previous compilation" not in (output / "protocol.html").read_text() + assert len(json.loads((output / "manifest.json").read_text())["outputs"]) == 96 + assert not (output / "protocol.py").exists() + assert (output / "notes.txt").read_text() == "Keep these notes" + + +@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, ("example-plasmid-1", "unknown-plasmid"), "no source"), + ( + ('final_volume = "200 uL"', 'wells = ["A1", "A13"]\nfinal_volume = "200 uL"'), + 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_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 "' + 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 "