lab-compiler 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lab/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """Build and compile laboratory protocols using ordinary Python."""
2
+
3
+ from lab._version import __version__
4
+ from lab.compiler import Compilation, compile
5
+ from lab.protocol import Plate, Protocol, Well
6
+ from lab.units import Quantity, celsius, minutes, mL, seconds, uL
7
+ from lab.validation import CompileError
8
+
9
+ __all__ = [
10
+ "Compilation",
11
+ "CompileError",
12
+ "Plate",
13
+ "Protocol",
14
+ "Quantity",
15
+ "Well",
16
+ "__version__",
17
+ "celsius",
18
+ "compile",
19
+ "mL",
20
+ "minutes",
21
+ "seconds",
22
+ "uL",
23
+ ]
lab/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Package version shared by the public API and compiler."""
2
+
3
+ __version__ = "0.1.0"
lab/compiler.py ADDED
@@ -0,0 +1,133 @@
1
+ """Snapshot, validate, prepare a target, and emit a self-contained bundle."""
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import dataclass, replace
6
+ from decimal import Decimal
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from typing import Protocol as Interface
10
+
11
+ import lab.documents as documents
12
+ from lab._version import __version__
13
+ from lab.deck import Deck
14
+ from lab.model import Distribute, Location, Mix, RecordedProtocol, TargetPlan, Transfer, encode
15
+ from lab.protocol import Protocol
16
+ from lab.targets.liquid_handler import LiquidHandler
17
+ from lab.targets.lower import lower_deck
18
+ from lab.validation import CompileError, logical_bindings, validate
19
+
20
+
21
+ class Target(Interface):
22
+ def prepare(self, protocol: RecordedProtocol) -> TargetPlan: ...
23
+
24
+
25
+ def canonical_json(value: Any) -> str:
26
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2, allow_nan=False) + "\n"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Compilation:
31
+ protocol: RecordedProtocol
32
+ target: TargetPlan
33
+ final_volumes: tuple[tuple[Location, Decimal], ...]
34
+
35
+ @property
36
+ def plan_json(self) -> str:
37
+ return canonical_json(
38
+ {
39
+ "format": "lab.plan.v1",
40
+ "compiler_version": __version__,
41
+ "units": {"volume": "microliter", "duration": "second", "temperature": "celsius"},
42
+ "protocol": encode(self.protocol),
43
+ "target": {
44
+ "name": self.target.name,
45
+ "configuration": json.loads(self.target.configuration_json),
46
+ "bindings": encode(self.target.bindings),
47
+ "setup": list(self.target.setup),
48
+ },
49
+ "final_volumes": [
50
+ {"location": encode(location), "volume": encode(volume)}
51
+ for location, volume in self.final_volumes
52
+ ],
53
+ "source_sha256": (
54
+ hashlib.sha256(self.target.source.encode()).hexdigest()
55
+ if self.target.source is not None
56
+ else None
57
+ ),
58
+ }
59
+ )
60
+
61
+ @property
62
+ def digest(self) -> str:
63
+ return hashlib.sha256(self.plan_json.encode()).hexdigest()
64
+
65
+ @property
66
+ def files(self) -> dict[str, str]:
67
+ result = {"plan.json": self.plan_json, "protocol.html": documents.render(self)}
68
+ if self.target.source is not None:
69
+ result["protocol.py"] = self.target.source
70
+ return result
71
+
72
+ def write(self, directory: str | Path) -> Path:
73
+ """Write a bundle. Refuse to replace any different existing artifact."""
74
+ directory = Path(directory)
75
+ files = self.files
76
+ for name, text in files.items():
77
+ path = directory / name
78
+ if path.exists() and path.read_text() != text:
79
+ raise FileExistsError(f"{path} already contains a different artifact")
80
+ directory.mkdir(parents=True, exist_ok=True)
81
+ for name, text in files.items():
82
+ (directory / name).write_text(text, encoding="utf-8")
83
+ return directory
84
+
85
+
86
+ def compile(
87
+ protocol: Protocol, hardware: Target | Deck, *, liquid_handler: LiquidHandler | None = None
88
+ ) -> Compilation:
89
+ """Compile offline for one piece of hardware.
90
+
91
+ A ``Deck`` contains shared requirements and optional Lab-owned layouts. The
92
+ selected backend validates and translates its layout or supported preset.
93
+ Concrete backend targets are also accepted for low-level integrations.
94
+ A document target such as ``Manual()`` has no robot, so ``liquid_handler`` is omitted.
95
+ """
96
+ authored_deck = hardware if isinstance(hardware, Deck) else None
97
+ if isinstance(hardware, Deck):
98
+ if not isinstance(liquid_handler, LiquidHandler):
99
+ raise TypeError(
100
+ "Pass liquid_handler=LiquidHandler.OT2, LiquidHandler.FLEX, or LiquidHandler.STAR."
101
+ )
102
+ liquid = tuple(
103
+ step.volume for step in protocol.steps if isinstance(step, (Transfer, Mix, Distribute))
104
+ )
105
+ requirements = {container.id: container.labware for container in hardware.containers}
106
+ for resource in protocol.snapshot().resources:
107
+ spec = requirements.get(resource.name)
108
+ if spec is None or (spec.rows, spec.columns) != (resource.rows, resource.columns):
109
+ raise CompileError(
110
+ f"Deck requirements must match the protocol geometry for {resource.name}."
111
+ )
112
+ if resource.capacity > spec.capacity_ul:
113
+ raise CompileError(f"Protocol capacity exceeds the deck limit for {resource.name}.")
114
+ hardware = lower_deck(hardware, liquid_handler, liquid)
115
+ declared = getattr(hardware, "liquid_handler", None)
116
+ if isinstance(declared, LiquidHandler) and liquid_handler != declared:
117
+ raise TypeError(
118
+ f"This hardware is LiquidHandler.{declared.name}. "
119
+ f"Pass liquid_handler=LiquidHandler.{declared.name}."
120
+ )
121
+ if liquid_handler is not None and not isinstance(declared, LiquidHandler):
122
+ raise TypeError("This hardware does not name a LiquidHandler.")
123
+ if liquid_handler is not None and not isinstance(liquid_handler, LiquidHandler):
124
+ raise TypeError("Pass LiquidHandler.OT2, LiquidHandler.FLEX, or LiquidHandler.STAR.")
125
+ recorded = protocol.snapshot()
126
+ validate(recorded, logical_bindings(recorded))
127
+ prepared = hardware.prepare(recorded)
128
+ if authored_deck is not None:
129
+ configuration = json.loads(prepared.configuration_json)
130
+ configuration["lab_deck"] = encode(authored_deck)
131
+ prepared = replace(prepared, configuration_json=json.dumps(configuration, sort_keys=True))
132
+ volumes = validate(recorded, prepared.bindings)
133
+ return Compilation(recorded, prepared, tuple(volumes.items()))
lab/deck.py ADDED
@@ -0,0 +1,311 @@
1
+ """Logical containers and Lab-owned physical layouts for liquid handlers."""
2
+
3
+ from dataclasses import dataclass
4
+ from decimal import Decimal
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+ from lab.equipment import (
9
+ CARRIER_SITE_COUNTS,
10
+ MODULE_SITE_COUNTS,
11
+ CarrierModel,
12
+ LabwareModel,
13
+ LiquidHandler,
14
+ ModuleModel,
15
+ Mount,
16
+ PipetteModel,
17
+ TipRackModel,
18
+ )
19
+ from lab.labware import ContainerSpec, LabwareKind
20
+
21
+
22
+ class DeckSite(Enum):
23
+ """Logical placement groups used by built-in layout presets."""
24
+
25
+ TEMPERATURE_MODULE = "temperature_module"
26
+ THERMOCYCLER = "thermocycler"
27
+ PLATES = "plates"
28
+ MORE_PLATES = "more_plates"
29
+ TUBE_RACK = "tube_rack"
30
+ RESERVOIR = "reservoir"
31
+
32
+
33
+ _COMPATIBLE_KINDS = {
34
+ DeckSite.TEMPERATURE_MODULE: frozenset({LabwareKind.COLD_BLOCK}),
35
+ DeckSite.THERMOCYCLER: frozenset({LabwareKind.PCR_PLATE, LabwareKind.CULTURE_PLATE}),
36
+ DeckSite.PLATES: frozenset({LabwareKind.PLATE, LabwareKind.PCR_PLATE}),
37
+ DeckSite.MORE_PLATES: frozenset({LabwareKind.PLATE, LabwareKind.PCR_PLATE}),
38
+ DeckSite.TUBE_RACK: frozenset({LabwareKind.TUBE_RACK}),
39
+ DeckSite.RESERVOIR: frozenset({LabwareKind.CONICAL_RACK}),
40
+ }
41
+
42
+
43
+ @dataclass(frozen=True, slots=True, kw_only=True)
44
+ class Container(ContainerSpec):
45
+ """A named container assigned to a logical placement group.
46
+
47
+ ``DeckSite.PLATES`` and ``DeckSite.MORE_PLATES`` are the two open-deck plate
48
+ runs. ``DeckSite.TUBE_RACK`` and ``DeckSite.RESERVOIR`` are the holder and the
49
+ large-volume rack. Explicit layouts specify the actual holders and devices.
50
+ """
51
+
52
+ site: DeckSite
53
+
54
+ def __post_init__(self) -> None:
55
+ ContainerSpec.__post_init__(self)
56
+ if not isinstance(self.site, DeckSite):
57
+ raise TypeError("Container site must be a DeckSite member.")
58
+ if self.labware.kind not in _COMPATIBLE_KINDS[self.site]:
59
+ raise ValueError(f"A {self.labware.kind.value} cannot sit on {self.site.value}.")
60
+
61
+
62
+ def _name(value: str) -> None:
63
+ if not isinstance(value, str) or not value.strip():
64
+ raise ValueError("Equipment and container identifiers must be nonempty text.")
65
+
66
+
67
+ def _tuple(values: tuple[Any, ...], member: type, label: str) -> None:
68
+ if not isinstance(values, tuple) or any(not isinstance(value, member) for value in values):
69
+ raise TypeError(f"{label} must be a tuple of {member.__name__} objects.")
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class Slot:
74
+ name: str
75
+
76
+ def __post_init__(self) -> None:
77
+ _name(self.name)
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class Rail:
82
+ index: int
83
+
84
+ def __post_init__(self) -> None:
85
+ if type(self.index) is not int or self.index < 1:
86
+ raise ValueError("Rail index must be a positive integer.")
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class HolderSite:
91
+ holder: str
92
+ index: int = 0
93
+
94
+ def __post_init__(self) -> None:
95
+ _name(self.holder)
96
+ if type(self.index) is not int or self.index < 0:
97
+ raise ValueError("Holder site index must be a nonnegative integer.")
98
+
99
+
100
+ Position = Slot | Rail | HolderSite
101
+
102
+
103
+ @dataclass(frozen=True, slots=True, kw_only=True)
104
+ class Carrier:
105
+ id: str
106
+ model: CarrierModel
107
+ location: Position
108
+
109
+
110
+ @dataclass(frozen=True, slots=True, kw_only=True)
111
+ class Module:
112
+ id: str
113
+ model: ModuleModel
114
+ location: Position
115
+
116
+
117
+ @dataclass(frozen=True, slots=True, kw_only=True)
118
+ class TipRack:
119
+ id: str
120
+ model: TipRackModel
121
+ location: Position
122
+
123
+
124
+ @dataclass(frozen=True, slots=True, kw_only=True)
125
+ class Placement:
126
+ """Bind a logical container to a physical model and location in a layout.
127
+
128
+ ``wells`` optionally lists physical well names in logical row-major order.
129
+ """
130
+
131
+ container: str
132
+ model: LabwareModel
133
+ location: Position
134
+ wells: tuple[str, ...] = ()
135
+
136
+
137
+ @dataclass(frozen=True, slots=True, kw_only=True)
138
+ class Pipette:
139
+ model: PipetteModel
140
+ mount: Mount
141
+ tip_racks: tuple[str, ...]
142
+
143
+
144
+ @dataclass(frozen=True, slots=True, kw_only=True)
145
+ class Channel:
146
+ """An independently addressable channel; limits are in microliters."""
147
+
148
+ index: int
149
+ min_volume_ul: Decimal
150
+ max_volume_ul: Decimal
151
+ tip_racks: tuple[str, ...]
152
+
153
+
154
+ @dataclass(frozen=True, slots=True, kw_only=True)
155
+ class DeckLayout:
156
+ """A physical configuration expressed entirely in Lab types.
157
+
158
+ Targets validate supported equipment, positions, footprints, and capabilities.
159
+ External thermal resources explicitly require a runtime device handoff.
160
+ """
161
+
162
+ liquid_handler: LiquidHandler
163
+ placements: tuple[Placement, ...]
164
+ carriers: tuple[Carrier, ...] = ()
165
+ modules: tuple[Module, ...] = ()
166
+ tip_racks: tuple[TipRack, ...] = ()
167
+ pipettes: tuple[Pipette, ...] = ()
168
+ channels: tuple[Channel, ...] = ()
169
+ external_thermal_resources: tuple[str, ...] = ()
170
+
171
+ def __post_init__(self) -> None:
172
+ if not isinstance(self.liquid_handler, LiquidHandler):
173
+ raise TypeError("A layout requires a LiquidHandler member.")
174
+ for values, member in (
175
+ (self.placements, Placement),
176
+ (self.carriers, Carrier),
177
+ (self.modules, Module),
178
+ (self.tip_racks, TipRack),
179
+ (self.pipettes, Pipette),
180
+ (self.channels, Channel),
181
+ ):
182
+ _tuple(values, member, member.__name__)
183
+ equipment: tuple[Carrier | Module | TipRack, ...] = (
184
+ *self.carriers,
185
+ *self.modules,
186
+ *self.tip_racks,
187
+ )
188
+ ids = [item.id for item in equipment]
189
+ for identifier in ids:
190
+ _name(identifier)
191
+ if len(set(ids)) != len(ids):
192
+ raise ValueError("Layout equipment ids must be unique.")
193
+ containers = [item.container for item in self.placements]
194
+ for identifier in containers:
195
+ _name(identifier)
196
+ if len(set(containers)) != len(containers):
197
+ raise ValueError("Each container needs exactly one placement in a layout.")
198
+ if set(ids) & set(containers):
199
+ raise ValueError("Equipment and container ids must be distinct.")
200
+ for values, model_type in (
201
+ (self.carriers, CarrierModel),
202
+ (self.modules, ModuleModel),
203
+ (self.tip_racks, TipRackModel),
204
+ (self.placements, LabwareModel),
205
+ ):
206
+ for model_item in values:
207
+ if not isinstance(model_item.model, model_type):
208
+ raise TypeError(f"Use a {model_type.__name__} member.")
209
+ holder_items: tuple[Carrier | Module, ...] = (*self.carriers, *self.modules)
210
+ holders = {item.id: item for item in holder_items}
211
+ occupied: set[Position] = set()
212
+ positioned: tuple[Carrier | Module | TipRack | Placement, ...] = (
213
+ *equipment,
214
+ *self.placements,
215
+ )
216
+ for item in positioned:
217
+ position = item.location
218
+ if not isinstance(position, (Slot, Rail, HolderSite)):
219
+ raise TypeError("A location must be a Slot, Rail, or HolderSite.")
220
+ if position in occupied:
221
+ raise ValueError(f"Layout location {position!r} is occupied more than once.")
222
+ occupied.add(position)
223
+ if isinstance(position, HolderSite):
224
+ if position.holder not in holders:
225
+ raise ValueError(f"Unknown holder {position.holder!r}.")
226
+ holder = holders[position.holder]
227
+ limit = (
228
+ CARRIER_SITE_COUNTS[holder.model]
229
+ if isinstance(holder, Carrier)
230
+ else MODULE_SITE_COUNTS[holder.model]
231
+ )
232
+ if position.index >= limit:
233
+ raise ValueError(f"Holder {position.holder!r} has {limit} sites.")
234
+ # Reject cycles even when holders were declared in a different order.
235
+ for holder in holders.values():
236
+ seen = {holder.id}
237
+ position = holder.location
238
+ while isinstance(position, HolderSite):
239
+ if position.holder in seen:
240
+ raise ValueError("Layout holder relationships must not contain a cycle.")
241
+ seen.add(position.holder)
242
+ position = holders[position.holder].location
243
+ for placement in self.placements:
244
+ _tuple(placement.wells, str, "Physical wells")
245
+ if len(set(placement.wells)) != len(placement.wells):
246
+ raise ValueError("Physical well mappings must be unique.")
247
+ for well in placement.wells:
248
+ _name(well)
249
+ tips = {rack.id for rack in self.tip_racks}
250
+ assigned: set[str] = set()
251
+ heads: tuple[Pipette | Channel, ...] = (*self.pipettes, *self.channels)
252
+ for head in heads:
253
+ _tuple(head.tip_racks, str, "Head tip racks")
254
+ if not head.tip_racks or any(rack not in tips for rack in head.tip_racks):
255
+ raise ValueError("Every pipette or channel needs declared tip racks.")
256
+ if len(set(head.tip_racks)) != len(head.tip_racks):
257
+ raise ValueError("A head's tip rack list must not contain duplicates.")
258
+ assigned.update(head.tip_racks)
259
+ if assigned != tips:
260
+ raise ValueError("Every tip rack must be assigned to a pipette or channel.")
261
+ for pipette in self.pipettes:
262
+ if not isinstance(pipette.model, PipetteModel) or not isinstance(pipette.mount, Mount):
263
+ raise TypeError("Use PipetteModel and Mount members.")
264
+ if len({pipette.mount for pipette in self.pipettes}) != len(self.pipettes):
265
+ raise ValueError("Pipette mounts must be unique.")
266
+ for channel in self.channels:
267
+ if type(channel.index) is not int or channel.index < 0:
268
+ raise ValueError("Channel index must be a nonnegative integer.")
269
+ limits = (channel.min_volume_ul, channel.max_volume_ul)
270
+ if any(not isinstance(v, Decimal) or not v.is_finite() or v <= 0 for v in limits):
271
+ raise ValueError("Channel volume limits must be positive finite Decimals.")
272
+ if channel.min_volume_ul > channel.max_volume_ul:
273
+ raise ValueError("Channel minimum volume exceeds maximum volume.")
274
+ if len({channel.index for channel in self.channels}) != len(self.channels):
275
+ raise ValueError("Channel indices must be unique.")
276
+ _tuple(self.external_thermal_resources, str, "External thermal resources")
277
+ if not set(self.external_thermal_resources) <= set(containers):
278
+ raise ValueError("External thermal resources must name placed containers.")
279
+
280
+
281
+ @dataclass(frozen=True, slots=True, kw_only=True)
282
+ class Deck:
283
+ """Shared container requirements with optional physical layouts for each handler."""
284
+
285
+ containers: tuple[ContainerSpec, ...]
286
+ layouts: tuple[DeckLayout, ...] = ()
287
+
288
+ def __post_init__(self) -> None:
289
+ _tuple(self.containers, ContainerSpec, "Deck containers")
290
+ _tuple(self.layouts, DeckLayout, "Deck layouts")
291
+ if not self.containers:
292
+ raise ValueError("A deck needs at least one container.")
293
+ ids = [container.id for container in self.containers]
294
+ if len(ids) != len(set(ids)):
295
+ raise ValueError("Deck container ids must be unique.")
296
+ handlers = [layout.liquid_handler for layout in self.layouts]
297
+ if len(set(handlers)) != len(handlers):
298
+ raise ValueError("A deck can have only one layout per liquid handler.")
299
+ specs = {container.id: container.labware for container in self.containers}
300
+ for layout in self.layouts:
301
+ if {placement.container for placement in layout.placements} != set(ids):
302
+ raise ValueError("Each layout must place exactly the deck's containers.")
303
+ for placement in layout.placements:
304
+ spec = specs[placement.container]
305
+ if placement.wells and len(placement.wells) != spec.rows * spec.columns:
306
+ raise ValueError("A physical well mapping must cover every logical well.")
307
+
308
+ def layout_for(self, liquid_handler: LiquidHandler) -> DeckLayout | None:
309
+ return next(
310
+ (layout for layout in self.layouts if layout.liquid_handler == liquid_handler), None
311
+ )
lab/documents.py ADDED
@@ -0,0 +1,129 @@
1
+ """A printable document rendered from the same frozen data as robot code."""
2
+
3
+ from html import escape
4
+ from typing import Any
5
+
6
+ from lab.model import (
7
+ Distribute,
8
+ ManualInstruction,
9
+ Mix,
10
+ SetTemperature,
11
+ Step,
12
+ Thermocycle,
13
+ Transfer,
14
+ Wait,
15
+ )
16
+ from lab.units import number
17
+
18
+
19
+ def describe(step: Step) -> str:
20
+ match step:
21
+ case Transfer(source, destination, volume, _):
22
+ return f"Transfer {number(volume)} µL from {source} to {destination}."
23
+ case Distribute(source, destinations, volume, air_gap, _):
24
+ wells = ", ".join(str(destination) for destination in destinations)
25
+ gap = f" Air gap {number(air_gap)} µL." if air_gap is not None else ""
26
+ return f"Distribute {number(volume)} µL from {source} to {wells}.{gap}"
27
+ case Mix(location, volume, cycles, _):
28
+ return f"Mix {location}: {cycles} cycles of {number(volume)} µL."
29
+ case Wait(seconds, _):
30
+ unit = "second" if seconds == 1 else "seconds"
31
+ return f"Wait {number(seconds)} {unit}."
32
+ case Thermocycle(resource, profile, cycles, lid, _, block_volume):
33
+ holds = "; ".join(
34
+ f"{number(hold.celsius)} °C for {number(hold.seconds)} s" for hold in profile
35
+ )
36
+ lid_text = "unheated lid" if lid is None else f"lid at {number(lid)} °C"
37
+ block = (
38
+ f" Reported block volume {number(block_volume)} µL."
39
+ if block_volume is not None
40
+ else ""
41
+ )
42
+ return (
43
+ f"Thermocycle all of {resource}: {holds}; {cycles} cycle(s); {lid_text}. "
44
+ "Wait for completion, turn off temperature control, and release the plate."
45
+ f"{block}"
46
+ )
47
+ case SetTemperature(resource, celsius, _):
48
+ return f"Hold {resource} at {number(celsius)} °C."
49
+ case ManualInstruction(text, _):
50
+ return f"Operator: {text}"
51
+ case _:
52
+ raise TypeError(f"Unsupported step: {type(step).__name__}")
53
+
54
+
55
+ def render(compilation: Any) -> str:
56
+ p, target = compilation.protocol, compilation.target
57
+ bindings = {binding.location: binding for binding in target.bindings}
58
+ resources = []
59
+ for resource in p.resources:
60
+ fills = (
61
+ "; ".join(
62
+ f"{fill.well}: {fill.material}, {number(fill.volume)} µL" for fill in resource.fills
63
+ )
64
+ or "Initially empty"
65
+ )
66
+ locations = [
67
+ binding.physical
68
+ for location, binding in bindings.items()
69
+ if location.resource == resource.name
70
+ ]
71
+ physical = (
72
+ ", ".join(locations)
73
+ if len(locations) <= 4
74
+ else f"{locations[0]} … {locations[-1]} ({len(locations)} wells; see plan.json)"
75
+ )
76
+ resources.append(
77
+ f"<tr><th>{escape(resource.name)}</th><td>{escape(fills)}</td>"
78
+ f"<td class='locations'>{escape(physical)}</td></tr>"
79
+ )
80
+ steps = "".join(f"<li><p>{escape(describe(step))}</p></li>" for step in p.steps)
81
+ setup = (
82
+ "<h2>Equipment setup</h2><ul>"
83
+ + "".join(f"<li>{escape(line)}</li>" for line in target.setup)
84
+ + "</ul>"
85
+ if target.setup
86
+ else ""
87
+ )
88
+ totals = "".join(
89
+ f"<tr><th>{escape(str(location))}</th><td>{number(volume)} µL</td></tr>"
90
+ for location, volume in compilation.final_volumes
91
+ if volume
92
+ )
93
+ return f"""<!doctype html>
94
+ <html lang="en"><head><meta charset="utf-8">
95
+ <meta name="viewport" content="width=device-width, initial-scale=1">
96
+ <title>{escape(p.name)}</title>
97
+ <style>
98
+ :root {{ color-scheme: light; font-family: system-ui, sans-serif; color: #18312f; }}
99
+ body {{ max-width: 920px; padding: 56px 28px; margin: auto; line-height: 1.6; }}
100
+ header {{ border-top: 5px solid #25756a; padding-top: 24px; margin-bottom: 36px; }}
101
+ h1 {{ font-size: clamp(2rem, 5vw, 3rem); letter-spacing: -.035em; margin: 6px 0; }}
102
+ h2 {{ margin-top: 36px; font-size: 1.2rem; }}
103
+ .eyebrow, small {{ color: #60736e; }}
104
+ .eyebrow {{ text-transform: uppercase; letter-spacing: .1em; }}
105
+ table {{ width: 100%; border-collapse: collapse; font-size: .9rem; }}
106
+ th, td {{ text-align: left; vertical-align: top; border-bottom: 1px solid #dbe5e1; padding: 12px; }}
107
+ .locations {{ overflow-wrap: anywhere; }} ol {{ padding-left: 28px; }}
108
+ li {{ padding: 8px 0 16px 12px; border-bottom: 1px solid #e3ebe7; break-inside: avoid; }}
109
+ li p {{ margin: 0; }} small, code {{ overflow-wrap: anywhere; }}
110
+ .note {{ padding: 16px; background: #f0f5f2; border-radius: 8px; }}
111
+ footer {{ margin-top: 40px; font-size: .8rem; color: #60736e; }}
112
+ @media (max-width: 600px) {{ body {{ padding: 24px 16px; }} th, td {{ padding: 8px 4px; }} }}
113
+ @media print {{ body {{ padding: 0; font-size: 10pt; }} header {{ margin-bottom: 16px; }}
114
+ small {{ display: none; }} h2 {{ break-after: avoid; }} tr {{ break-inside: avoid; }} }}
115
+ </style></head><body>
116
+ <header><div class="eyebrow">Lab / Compiled protocol</div>
117
+ <h1>{escape(p.name)}</h1><p>{escape(p.description)}</p>
118
+ <p>Target: <strong>{escape(target.name)}</strong></p></header>
119
+ <p class="note">Planned quantities. Use a fresh tip for each transfer or mix.</p>
120
+ {setup}
121
+ <h2>Materials and placement</h2><table><thead><tr>
122
+ <th>Resource</th><th>Declared initial contents</th>
123
+ <th>Physical locations</th></tr></thead><tbody>{"".join(resources)}</tbody></table>
124
+ <h2>Procedure</h2><ol>{steps}</ol>
125
+ <h2>Calculated final contents</h2><table>{totals}</table>
126
+ <footer>Plan {compilation.digest}<br>Quantities are in microlitres unless stated otherwise.
127
+ Inspect plan.json for complete bindings and target configuration.</footer>
128
+ </body></html>
129
+ """
lab/equipment.py ADDED
@@ -0,0 +1,64 @@
1
+ """Lab-owned equipment identifiers. Backends resolve these to SDK definitions."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class LiquidHandler(Enum):
7
+ OT2 = "ot2"
8
+ FLEX = "flex"
9
+ STAR = "star"
10
+
11
+
12
+ class LabwareModel(Enum):
13
+ CORNING_96_360_UL = "corning_96_360_ul"
14
+ NEST_12_RESERVOIR_15_ML = "nest_12_reservoir_15_ml"
15
+ NEST_96_PCR_100_UL = "nest_96_pcr_100_ul"
16
+ OPENTRONS_96_PCR_200_UL = "opentrons_96_pcr_200_ul"
17
+ BIORAD_96_PCR_200_UL = "biorad_96_pcr_200_ul"
18
+ AZENTA_96_PCR_200_UL = "azenta_96_pcr_200_ul"
19
+ OPENTRONS_24_COLD_BLOCK = "opentrons_24_cold_block"
20
+ OPENTRONS_24_TUBE_RACK = "opentrons_24_tube_rack"
21
+ OPENTRONS_15_CONICAL_RACK = "opentrons_15_conical_rack"
22
+
23
+
24
+ class CarrierModel(Enum):
25
+ HAMILTON_PLATE_5 = "hamilton_plate_5"
26
+ HAMILTON_TIP_5 = "hamilton_tip_5"
27
+
28
+
29
+ class ModuleModel(Enum):
30
+ TEMPERATURE_GEN1 = "temperature_gen1"
31
+ TEMPERATURE_GEN2 = "temperature_gen2"
32
+ THERMOCYCLER_GEN1 = "thermocycler_gen1"
33
+ THERMOCYCLER_GEN2 = "thermocycler_gen2"
34
+
35
+
36
+ class TipRackModel(Enum):
37
+ OPENTRONS_20_UL = "opentrons_20_ul"
38
+ OPENTRONS_300_UL = "opentrons_300_ul"
39
+ FLEX_200_UL = "flex_200_ul"
40
+ HAMILTON_50_UL = "hamilton_50_ul"
41
+ HAMILTON_300_UL = "hamilton_300_ul"
42
+
43
+
44
+ class PipetteModel(Enum):
45
+ P20_SINGLE_GEN2 = "p20_single_gen2"
46
+ P300_SINGLE_GEN2 = "p300_single_gen2"
47
+ FLEX_1CHANNEL_1000 = "flex_1channel_1000"
48
+
49
+
50
+ class Mount(Enum):
51
+ LEFT = "left"
52
+ RIGHT = "right"
53
+
54
+
55
+ CARRIER_SITE_COUNTS = {
56
+ CarrierModel.HAMILTON_PLATE_5: 5,
57
+ CarrierModel.HAMILTON_TIP_5: 5,
58
+ }
59
+ MODULE_SITE_COUNTS = {
60
+ ModuleModel.TEMPERATURE_GEN1: 1,
61
+ ModuleModel.TEMPERATURE_GEN2: 1,
62
+ ModuleModel.THERMOCYCLER_GEN1: 1,
63
+ ModuleModel.THERMOCYCLER_GEN2: 1,
64
+ }
@@ -0,0 +1,7 @@
1
+ """Experiment families built on the protocol operations.
2
+
3
+ A family is a package of ordinary functions. Each stage lays out wells, then
4
+ appends steps to a caller-owned protocol. Cloning is :mod:`lab.experiments.cloning`.
5
+ Add another kind of experiment as a sibling package, and add another stage of
6
+ cloning as a module in :mod:`lab.experiments.cloning.stages`. Stages are not registered.
7
+ """