steadyhand 0.26.1__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.
steadyhand/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """steadyhand: turn a written lab protocol into a robot program and rehearse it before it touches a real bench."""
2
+
3
+ __version__ = "0.26.1"
4
+
5
+ from . import _gl # noqa: F401 (must come before anything imports mujoco)
6
+ from .record import RecordingBackend, Step
7
+ from .replay import Collision, Rehearsal, TipProfile, rehearse, volumes
8
+ from .scene import Box, collect_boxes, export_scene
9
+
10
+ __all__ = [
11
+ "Box",
12
+ "Collision",
13
+ "RecordingBackend",
14
+ "Rehearsal",
15
+ "Step",
16
+ "TipProfile",
17
+ "collect_boxes",
18
+ "export_scene",
19
+ "rehearse",
20
+ "volumes",
21
+ ]
steadyhand/_gl.py ADDED
@@ -0,0 +1,31 @@
1
+ """Pick an offscreen OpenGL backend on a Linux machine with no display.
2
+
3
+ MuJoCo reads MUJOCO_GL once, when it is first imported, so this has to run before that.
4
+ A value the user has set is never changed.
5
+
6
+ Only a backend whose library is on the machine is chosen. Naming one that is missing makes
7
+ `import mujoco` itself fail, and nothing here needs OpenGL except the two pictures `rehearse`
8
+ draws: with no backend they are skipped and everything else runs.
9
+ """
10
+
11
+ import ctypes.util
12
+ import os
13
+ import sys
14
+
15
+
16
+ def choose() -> str:
17
+ """The backend to ask MuJoCo for, or an empty string to leave its default alone."""
18
+ if not sys.platform.startswith("linux") or "MUJOCO_GL" in os.environ:
19
+ return ""
20
+ if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
21
+ return ""
22
+ if ctypes.util.find_library("EGL"):
23
+ return "egl"
24
+ if ctypes.util.find_library("OSMesa"):
25
+ return "osmesa"
26
+ return ""
27
+
28
+
29
+ _backend = choose()
30
+ if _backend:
31
+ os.environ["MUJOCO_GL"] = _backend
steadyhand/cli.py ADDED
@@ -0,0 +1,414 @@
1
+ """Command line for steadyhand."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Callable, List, Optional, Tuple
11
+
12
+ from . import __version__
13
+ from .demo import FAULTS, run_demo
14
+ from .replay import Rehearsal, rehearse
15
+
16
+
17
+ def _keeper() -> Tuple[dict, Callable]:
18
+ """Remember the moment the channels reach the bottom of the first aspirate, for a close-up."""
19
+ kept: dict = {}
20
+
21
+ def keep(model, data, steps, phase):
22
+ if phase == "descend" and steps[0].kind == "aspirate" and kept.get("command", steps[0].command) == steps[0].command:
23
+ kept.update(
24
+ command=steps[0].command, model=model, qpos=data.qpos.copy(), rgba=model.geom_rgba.copy(), steps=steps
25
+ )
26
+
27
+ return kept, keep
28
+
29
+
30
+ def _write(result: Rehearsal, out: Path, kept: dict, render: bool, title: str = "") -> None:
31
+ out.mkdir(parents=True, exist_ok=True)
32
+ (out / "scene.xml").write_text(result.scene_xml)
33
+ (out / "report.json").write_text(json.dumps(result.to_dict(), indent=2) + "\n")
34
+ (out / "report.txt").write_text(result.report() + "\n")
35
+ (out / "replay.html").write_text(result.replay_html(title), encoding="utf-8")
36
+ print(f"wrote {out / 'scene.xml'}, {out / 'report.json'}, {out / 'report.txt'}")
37
+ print(f"open {out / 'replay.html'} in a browser to watch the run")
38
+ if render:
39
+ import mujoco
40
+
41
+ from .render import close_up, render_model, render_png
42
+
43
+ try:
44
+ print(f"wrote {render_png(result.scene_xml, out / 'scene.png')}")
45
+ if kept:
46
+ model = kept["model"]
47
+ model.geom_rgba[:] = kept["rgba"]
48
+ data = mujoco.MjData(model)
49
+ data.qpos[:] = kept["qpos"]
50
+ ends = [s.tip_end for s in kept["steps"]]
51
+ middle = [sum(e[i] for e in ends) / len(ends) for i in range(3)]
52
+ middle[2] += 25.0
53
+ print(f"wrote {render_model(model, data, out / 'aspirate.png', camera=close_up(middle))}")
54
+ except Exception as error: # no OpenGL on this machine, or no way to open a context
55
+ print(f"no picture: {' '.join(str(error).split())[:200]}")
56
+ print(f"to look around the scene: python -m mujoco.viewer --mjcf {out / 'scene.xml'}")
57
+
58
+
59
+ def _demo(args: argparse.Namespace) -> int:
60
+ deck, backend = asyncio.run(run_demo(args.fault))
61
+ kept, keep = _keeper()
62
+ result = rehearse(deck, backend.steps, on_sample=None if args.no_render else keep)
63
+ print(f"demo: {FAULTS[args.fault]}")
64
+ print(result.report())
65
+ _write(result, Path(args.out), kept, not args.no_render, f"steadyhand demo: {FAULTS[args.fault]}")
66
+ return 0 if result.ok else 1
67
+
68
+
69
+ def _faults(args: argparse.Namespace) -> int:
70
+ print("planting mistakes in a short dilution; this takes about half a minute")
71
+ if args.robot and args.robot != "hamilton-starlet":
72
+ from .opentrons_faults import run_suite as run_on
73
+ from .opentrons_robots import PROFILES, opentrons_installed
74
+
75
+ if not opentrons_installed():
76
+ print("steadyhand faults: this needs Opentrons' labware definitions: pip install 'steadyhand[opentrons]'")
77
+ return 1
78
+ report = run_on(PROFILES[args.robot])
79
+ else:
80
+ from .faults import run_suite
81
+
82
+ report = run_suite()
83
+ print(report.text())
84
+ if args.output:
85
+ Path(args.output).write_text(report.markdown(), encoding="utf-8")
86
+ print(f"wrote {args.output}")
87
+ return 0 if not report.clean.caught else 1
88
+
89
+
90
+ def _load_checked(path: str):
91
+ """Read and check a protocol file. Prints what is wrong and returns None when it cannot be read."""
92
+ from .protocol import ProtocolError, check, load
93
+
94
+ try:
95
+ protocol = load(path)
96
+ except FileNotFoundError:
97
+ print(f"steadyhand: cannot find {path}")
98
+ return None
99
+ except ProtocolError as error:
100
+ print(f"steadyhand check: FAIL {path} cannot be read as a protocol")
101
+ print(f" problems: {len(error.problems)}")
102
+ for problem in error.problems:
103
+ print(f" - {problem}")
104
+ return None
105
+ return check(protocol)
106
+
107
+
108
+ def _check(args: argparse.Namespace) -> int:
109
+ result = _load_checked(args.file)
110
+ if result is None:
111
+ return 1
112
+ print(result.report())
113
+ if args.moves:
114
+ print(" moves, in order:")
115
+ for move in result.moves:
116
+ if move.kind == "transfer":
117
+ print(f" step {move.step} tip {move.tip:<3} {move.volume:>7g} uL {move.source} -> {move.destination}")
118
+ elif move.kind == "mix":
119
+ print(f" step {move.step} tip {move.tip:<3} mix {move.times} x {move.volume:g} uL in {move.source}")
120
+ else:
121
+ print(f" step {move.step} wait {move.seconds / 60:g} min")
122
+ return 0 if result.ok else 1
123
+
124
+
125
+ def _planned(path: str, choices_path: Optional[str] = None, robot_name: Optional[str] = None):
126
+ """Check and plan a protocol file for a robot. Prints both reports.
127
+
128
+ Returns the robot and its plan, or None when the check or the plan fails. The robot is the one
129
+ asked for, else the one the file names, else the Hamilton STARlet.
130
+ """
131
+ from .planner import load_choices
132
+ from .protocol import ProtocolError
133
+ from .robots import choose
134
+
135
+ checked = _load_checked(path)
136
+ if checked is None:
137
+ return None
138
+ print(checked.report())
139
+ if not checked.ok:
140
+ return None
141
+ robot = choose(robot_name, checked.protocol.robot)
142
+ choices = None
143
+ if choices_path and not robot.takes_plan_file:
144
+ print(f"steadyhand plan: a plan file cannot replace the planner's choices for the {robot.title} yet; leave --plan out")
145
+ return None
146
+ if choices_path:
147
+ try:
148
+ choices = load_choices(choices_path)
149
+ except FileNotFoundError:
150
+ print(f"steadyhand: cannot find {choices_path}")
151
+ return None
152
+ except ProtocolError as error:
153
+ print(f"steadyhand plan: FAIL {choices_path} cannot be read as a plan file")
154
+ print(f" problems: {len(error.problems)}")
155
+ for problem in error.problems:
156
+ print(f" - {problem}")
157
+ return None
158
+ planned = robot.plan(checked, choices)
159
+ print(planned.report())
160
+ if choices_path:
161
+ print(f" using choices from {choices_path}")
162
+ return (robot, planned) if planned.ok else None
163
+
164
+
165
+ def _plan(args: argparse.Namespace) -> int:
166
+ from .planner import dump_choices
167
+
168
+ found = _planned(args.file, args.plan, args.robot)
169
+ if found is None:
170
+ return 1
171
+ robot, planned = found
172
+ if args.output and not robot.takes_plan_file:
173
+ print(f"steadyhand plan: no plan file was written; the choices for the {robot.title} cannot be edited yet")
174
+ return 1
175
+ if args.output:
176
+ Path(args.output).write_text(dump_choices(planned, source=args.file), encoding="utf-8")
177
+ print(f"wrote {args.output}; edit it and pass it back with --plan")
178
+ return 0
179
+
180
+
181
+ def _rehearse_opentrons(planned, args: argparse.Namespace) -> int:
182
+ """Replay an Opentrons plan in the physics scene, then run its protocol through Opentrons' own simulator.
183
+
184
+ Both have to pass. The replay checks the plan against the deck's geometry and the liquid;
185
+ the simulator checks that the protocol file is one Opentrons' software accepts.
186
+ """
187
+ from .opentrons_robots import emit, opentrons_installed, simulate
188
+
189
+ out = Path(args.out)
190
+ script = emit(planned, source=args.file)
191
+ physics = None
192
+ if opentrons_installed():
193
+ from .opentrons_scene import Refused, rehearse_plan
194
+
195
+ kept, keep = _keeper()
196
+ try:
197
+ physics = asyncio.run(rehearse_plan(planned, on_sample=None if args.no_render else keep))
198
+ except Refused as refused:
199
+ print("steadyhand rehearsal: FAIL")
200
+ print(f" {refused}")
201
+ print(" Nothing was replayed: the run stops at the first action PyLabRobot's tracking will not accept.")
202
+ return 1
203
+ print(physics.report())
204
+ _write(physics, out, kept, not args.no_render, planned.protocol.name)
205
+ else:
206
+ print("steadyhand rehearsal: NOT RUN")
207
+ print(" The physics replay needs Opentrons' labware definitions. Install them with: pip install 'steadyhand[opentrons]'")
208
+ result = simulate(script, planned.profile)
209
+ print(result.report())
210
+ out.mkdir(parents=True, exist_ok=True)
211
+ (out / "protocol.py").write_text(script)
212
+ (out / "simulator.json").write_text(json.dumps(result.to_dict(), indent=2) + "\n")
213
+ (out / "run_log.txt").write_text("\n".join(result.log) + "\n")
214
+ with (out / "report.txt").open("a") as report:
215
+ report.write(result.report() + "\n")
216
+ print(f"wrote {out / 'simulator.json'}, {out / 'run_log.txt'}")
217
+ passed = physics is not None and physics.ok and result.ok
218
+ if passed:
219
+ print(f"wrote {out / 'protocol.py'}")
220
+ elif physics is not None and physics.ok and not result.ran:
221
+ print(f"wrote {out / 'protocol.py'}; Opentrons' simulator has not run it yet")
222
+ else:
223
+ print(f"wrote {out / 'protocol.py'} so you can see what was planned; do not run it")
224
+ return 0 if passed else 1
225
+
226
+
227
+ def _rehearse(args: argparse.Namespace) -> int:
228
+ from .planner import RunRefused, emit, rehearse_plan
229
+
230
+ found = _planned(args.file, args.plan, args.robot)
231
+ if found is None:
232
+ return 1
233
+ robot, planned = found
234
+ if not robot.takes_plan_file: # an Opentrons robot: its own deck, and Opentrons' simulator as well
235
+ return _rehearse_opentrons(planned, args)
236
+ kept, keep = _keeper()
237
+ try:
238
+ result = asyncio.run(rehearse_plan(planned, on_sample=None if args.no_render else keep))
239
+ except RunRefused as refused:
240
+ print("steadyhand rehearsal: FAIL")
241
+ print(f" {refused}")
242
+ print(" Nothing was replayed: the run stops at the first command PyLabRobot will not accept.")
243
+ return 1
244
+ print(result.report())
245
+ out = Path(args.out)
246
+ _write(result, out, kept, not args.no_render, planned.protocol.name)
247
+ if result.ok:
248
+ (out / "protocol.py").write_text(emit(planned, source=args.file))
249
+ print(f"wrote {out / 'protocol.py'}")
250
+ return 0 if result.ok else 1
251
+
252
+
253
+ def _robots(args: argparse.Namespace) -> int:
254
+ from .robots import DEFAULT, ROBOTS
255
+
256
+ for robot in ROBOTS.values():
257
+ print(f"{robot.name}{' (used when none is named)' if robot.name == DEFAULT else ''}")
258
+ print(f" {robot.title}, driven through {robot.through}; emit writes {robot.program}")
259
+ print(" rehearse checks:")
260
+ for line in robot.rehearsal:
261
+ print(f" - {line}")
262
+ return 0
263
+
264
+
265
+ def _emit(args: argparse.Namespace) -> int:
266
+ found = _planned(args.file, args.plan, args.robot)
267
+ if found is None:
268
+ return 1
269
+ robot, planned = found
270
+ script = robot.emit(planned, source=args.file)
271
+ if args.output:
272
+ Path(args.output).write_text(script)
273
+ print(f"wrote {args.output}")
274
+ else:
275
+ print(script)
276
+ return 0
277
+
278
+
279
+ def _draft(args: argparse.Namespace) -> int:
280
+ from .intake import ModelError, SourceError, anthropic_model, command_model, draft, openai_model, read_source
281
+
282
+ try:
283
+ source = sys.stdin.read() if args.source == "-" else read_source(Path(args.source), args.pages)
284
+ except OSError as error:
285
+ print(f"steadyhand draft: cannot read {args.source}: {error}")
286
+ return 1
287
+ except SourceError as error:
288
+ print(f"steadyhand draft: {error}")
289
+ return 1
290
+ if args.source.lower().endswith(".pdf"):
291
+ print(f"steadyhand draft: read {len(source):,} characters from {args.source}" + (f", pages {args.pages}" if args.pages else ""))
292
+ try:
293
+ if args.model_command:
294
+ model = command_model(args.model_command)
295
+ elif args.provider and args.model:
296
+ model = (anthropic_model if args.provider == "anthropic" else openai_model)(args.model)
297
+ else:
298
+ print("steadyhand draft: say which model to use, with --model-command CMD or --provider NAME --model NAME")
299
+ return 1
300
+ samples = Path(args.samples) if args.samples else None
301
+ if samples is not None and not samples.is_file():
302
+ print(f"steadyhand draft: cannot find {samples}")
303
+ return 1
304
+ if samples is not None and samples.resolve().parent != Path(args.output).resolve().parent:
305
+ print(f"steadyhand draft: the protocol names its samples file by name, so write it next to {samples}")
306
+ print(f" for example: -o {samples.parent / Path(args.output).name}")
307
+ return 1
308
+ result = draft(
309
+ source, model, samples=samples, max_rounds=args.rounds, robot=args.robot or "", rehearse=not args.no_rehearse
310
+ )
311
+ except ModelError as error:
312
+ print(f"steadyhand draft: {error}")
313
+ return 1
314
+
315
+ Path(args.output).write_text(result.text)
316
+ asked = "once" if result.rounds == 1 else f"{result.rounds} times"
317
+ print(f"steadyhand draft: {'PASS' if result.ok else 'FAIL'} the model was asked {asked}")
318
+ if result.checked is not None:
319
+ print(result.checked.report())
320
+ if result.problems and (result.checked is None or result.checked.ok):
321
+ # the file could not be read, or it passes the check and the plan or the rehearsal stops it
322
+ print(f" problems: {len(result.problems)}")
323
+ for problem in result.problems:
324
+ print(f" - {problem}")
325
+ if result.ok and result.rehearsed:
326
+ print(" planned and rehearsed: nothing stopped it")
327
+ print(f"wrote {args.output}")
328
+ if result.ok:
329
+ print("A model wrote this file. Read it against your source before you rehearse or run it.")
330
+ elif result.left_for_a_person:
331
+ print("The model kept the file as it is: fixing these problems means changing what your text says,")
332
+ print("or supplying something it does not have. Decide what to change, then run steadyhand check.")
333
+ else:
334
+ print("The file still has problems after the last try. Fix it by hand, then run steadyhand check.")
335
+ return 0 if result.ok else 1
336
+
337
+
338
+ def main(argv: Optional[List[str]] = None) -> int:
339
+ from .robots import ROBOTS
340
+
341
+ parser = argparse.ArgumentParser(
342
+ prog="steadyhand", description="Turn a written lab protocol into a robot program and rehearse it without the robot."
343
+ )
344
+ parser.add_argument("--version", action="version", version=f"steadyhand {__version__}")
345
+ commands = parser.add_subparsers(dest="command", required=True)
346
+
347
+ draft = commands.add_parser("draft", help="have a language model write a protocol file from plain text")
348
+ draft.add_argument("source", help="a text file or a PDF with notes, a kit manual or a description; '-' reads stdin")
349
+ draft.add_argument("--pages", help="for a PDF, the pages to read, such as 4-6 or 3,5-7")
350
+ draft.add_argument("-o", "--output", required=True, help="where to write the protocol file")
351
+ draft.add_argument("--provider", choices=("anthropic", "openai"), help="an API to call; needs --model")
352
+ draft.add_argument("--model", help="the model's name at that provider")
353
+ draft.add_argument("--model-command", help="a shell command that reads a prompt on stdin and prints a reply")
354
+ draft.add_argument("--samples", help="a CSV of per-sample concentrations, for normalizing; kept next to the output")
355
+ draft.add_argument("--rounds", type=int, default=3, help="how many times the model may be asked (default 3)")
356
+ draft.add_argument(
357
+ "--no-rehearse", action="store_true", help="only check each file; do not plan and rehearse it before accepting it"
358
+ )
359
+ draft.add_argument(
360
+ "--robot", choices=sorted(ROBOTS), help="the robot to draft for: it decides which labware the model is offered"
361
+ )
362
+ draft.set_defaults(run=_draft)
363
+
364
+ check = commands.add_parser("check", help="read a protocol file and follow its liquid through every move")
365
+ check.add_argument("file", help="a steadyhand protocol file (YAML)")
366
+ check.add_argument("--moves", action="store_true", help="also list every move the steps expand into")
367
+ check.set_defaults(run=_check)
368
+
369
+ plan = commands.add_parser("plan", help="check a protocol, then choose tips, channels and a deck layout")
370
+ plan.add_argument("file", help="a steadyhand protocol file (YAML)")
371
+ plan.add_argument("-o", "--output", help="write the tips, deck and pipetting height to a plan file you can edit")
372
+ plan.add_argument("--plan", help="a plan file whose choices replace the planner's own")
373
+ plan.set_defaults(run=_plan)
374
+
375
+ robots = commands.add_parser("robots", help="list the robots a protocol can be planned for")
376
+ robots.set_defaults(run=_robots)
377
+
378
+ rehearse_ = commands.add_parser(
379
+ "rehearse", help="check and plan a protocol, then run the plan through everything that can check it off the robot"
380
+ )
381
+ rehearse_.add_argument("file", help="a steadyhand protocol file (YAML)")
382
+ rehearse_.add_argument("--out", default="steadyhand-out", help="folder for the scene, the report and the script")
383
+ rehearse_.add_argument("--no-render", action="store_true", help="skip the PNG pictures")
384
+ rehearse_.add_argument("--plan", help="a plan file whose choices replace the planner's own")
385
+ rehearse_.set_defaults(run=_rehearse)
386
+
387
+ emit = commands.add_parser("emit", help="check and plan a protocol, then write the program for the robot")
388
+ emit.add_argument("file", help="a steadyhand protocol file (YAML)")
389
+ emit.add_argument("-o", "--output", help="where to write the script; printed when left out")
390
+ emit.add_argument("--plan", help="a plan file whose choices replace the planner's own")
391
+ emit.set_defaults(run=_emit)
392
+
393
+ demo = commands.add_parser("demo", help="rehearse a built-in transfer on a Hamilton STARlet deck")
394
+ demo.add_argument("--fault", choices=sorted(FAULTS), default="none", help="a mistake to plant in the protocol")
395
+ demo.add_argument("--out", default="steadyhand-out", help="folder for the scene and the report")
396
+ demo.add_argument("--no-render", action="store_true", help="skip the PNG picture")
397
+ demo.set_defaults(run=_demo)
398
+
399
+ faults = commands.add_parser("faults", help="plant known mistakes in a working protocol and list which are stopped")
400
+ faults.add_argument("-o", "--output", help="also write the table as Markdown")
401
+ faults.set_defaults(run=_faults)
402
+
403
+ for command in (plan, rehearse_, emit):
404
+ command.add_argument(
405
+ "--robot", choices=sorted(ROBOTS), help="the robot to plan for; default: what the file says, else hamilton-starlet"
406
+ )
407
+ faults.add_argument("--robot", choices=sorted(ROBOTS), help="the robot to plant the mistakes on; default: hamilton-starlet")
408
+
409
+ args = parser.parse_args(argv)
410
+ return args.run(args)
411
+
412
+
413
+ if __name__ == "__main__":
414
+ sys.exit(main())
steadyhand/demo.py ADDED
@@ -0,0 +1,80 @@
1
+ """A small transfer on a Hamilton STARlet deck, used by `steadyhand demo` and by the tests.
2
+
3
+ Eight channels pick up tips, take 100 uL from column 1 of a source plate, put it in column 1
4
+ of a destination plate, and return the tips. Two deliberate mistakes can be switched on to
5
+ show what a rehearsal catches.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import warnings
11
+ from typing import Tuple
12
+
13
+ from pylabrobot.liquid_handling import LiquidHandler
14
+ from pylabrobot.resources import Coordinate, set_tip_tracking, set_volume_tracking
15
+ from pylabrobot.resources.corning import Cor_96_wellplate_360ul_Fb
16
+ from pylabrobot.resources.hamilton import (
17
+ PLT_CAR_L5AC_A00,
18
+ TIP_CAR_480_A00,
19
+ STARLetDeck,
20
+ hamilton_96_tiprack_300uL_filter,
21
+ )
22
+
23
+ from .record import RecordingBackend
24
+
25
+ FAULTS = {
26
+ "none": "the protocol as intended",
27
+ "offset": "aspirate 4.5 mm to the right of each well's centre, onto the wall between wells",
28
+ "depth": "aspirate 3 mm below each well's floor",
29
+ }
30
+
31
+
32
+ def build_deck() -> STARLetDeck:
33
+ """A STARlet deck with one tip rack and two 96-well plates."""
34
+ deck = STARLetDeck()
35
+
36
+ tip_carrier = TIP_CAR_480_A00(name="tip_carrier")
37
+ tip_carrier[0] = hamilton_96_tiprack_300uL_filter(name="tips")
38
+ deck.assign_child_resource(tip_carrier, rails=1)
39
+
40
+ plate_carrier = PLT_CAR_L5AC_A00(name="plate_carrier")
41
+ plate_carrier[0] = Cor_96_wellplate_360ul_Fb(name="source")
42
+ plate_carrier[1] = Cor_96_wellplate_360ul_Fb(name="destination")
43
+ deck.assign_child_resource(plate_carrier, rails=7)
44
+ return deck
45
+
46
+
47
+ async def run_demo(fault: str = "none") -> Tuple[STARLetDeck, RecordingBackend]:
48
+ """Run the transfer against a recording backend. Returns the deck and the backend."""
49
+ if fault not in FAULTS:
50
+ raise ValueError(f"unknown fault {fault!r}; choose from {sorted(FAULTS)}")
51
+
52
+ set_volume_tracking(True)
53
+ set_tip_tracking(True)
54
+
55
+ deck = build_deck()
56
+ backend = RecordingBackend(num_channels=8)
57
+ with warnings.catch_warnings():
58
+ warnings.simplefilter("ignore")
59
+ handler = LiquidHandler(backend=backend, deck=deck)
60
+ await handler.setup()
61
+
62
+ tips = deck.get_resource("tips")
63
+ source = deck.get_resource("source")
64
+ destination = deck.get_resource("destination")
65
+ for well in source["A1:H1"]:
66
+ well.tracker.set_liquids([(None, 200.0)])
67
+
68
+ offsets = None
69
+ if fault == "offset":
70
+ offsets = [Coordinate(4.5, 0, 0)] * 8
71
+ elif fault == "depth":
72
+ offsets = [Coordinate(0, 0, -3.0)] * 8
73
+
74
+ await handler.pick_up_tips(tips["A1:H1"])
75
+ await handler.aspirate(source["A1:H1"], vols=[100.0] * 8, offsets=offsets)
76
+ await handler.dispense(destination["A1:H1"], vols=[100.0] * 8)
77
+ await handler.return_tips()
78
+ await handler.stop()
79
+
80
+ return deck, backend