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 +21 -0
- steadyhand/_gl.py +31 -0
- steadyhand/cli.py +414 -0
- steadyhand/demo.py +80 -0
- steadyhand/faults.py +368 -0
- steadyhand/intake.py +634 -0
- steadyhand/opentrons_faults.py +220 -0
- steadyhand/opentrons_robots.py +1030 -0
- steadyhand/opentrons_scene.py +320 -0
- steadyhand/planner.py +1111 -0
- steadyhand/protocol.py +854 -0
- steadyhand/record.py +183 -0
- steadyhand/render.py +75 -0
- steadyhand/replay.py +621 -0
- steadyhand/robots.py +94 -0
- steadyhand/scene.py +359 -0
- steadyhand/viewer.py +543 -0
- steadyhand-0.26.1.dist-info/METADATA +480 -0
- steadyhand-0.26.1.dist-info/RECORD +22 -0
- steadyhand-0.26.1.dist-info/WHEEL +4 -0
- steadyhand-0.26.1.dist-info/entry_points.txt +2 -0
- steadyhand-0.26.1.dist-info/licenses/LICENSE +176 -0
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
|