labcode 0.1.3__tar.gz → 0.1.5__tar.gz
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.
- {labcode-0.1.3 → labcode-0.1.5}/.github/workflows/ci.yml +17 -1
- labcode-0.1.5/MANIFEST.in +6 -0
- {labcode-0.1.3 → labcode-0.1.5}/PKG-INFO +1 -1
- {labcode-0.1.3 → labcode-0.1.5}/SPECIFICATIONS.md +95 -3
- labcode-0.1.5/examples/README.md +396 -0
- labcode-0.1.5/examples/outputs/sila2_plate_cycle.boundary.yaml +17 -0
- labcode-0.1.5/examples/outputs/sila2_plate_cycle.observation.yaml +199 -0
- labcode-0.1.5/examples/outputs/sila2_plate_cycle.plan.yaml +146 -0
- labcode-0.1.5/examples/outputs/sila2_plate_cycle.svg +2 -0
- labcode-0.1.5/examples/render_sila2_plate_cycle.py +114 -0
- {labcode-0.1.3 → labcode-0.1.5}/examples/run_all_sila2_examples.py +26 -14
- labcode-0.1.5/examples/run_sila2_plate_cycle.py +345 -0
- {labcode-0.1.3 → labcode-0.1.5}/examples/run_sila2_seal.py +2 -1
- labcode-0.1.5/examples/sila2_plate_cycle.boundary.yaml +22 -0
- labcode-0.1.5/examples/sila2_plate_cycle.workflow.yaml +120 -0
- labcode-0.1.5/examples/sila2_plate_cycle.wrapped.env.yaml +441 -0
- {labcode-0.1.3 → labcode-0.1.5}/examples/sila2_seal.env.yaml +7 -0
- {labcode-0.1.3 → labcode-0.1.5}/examples/sila2_seal.wrapped.env.yaml +4 -11
- {labcode-0.1.3 → labcode-0.1.5}/labcode/backend.py +53 -16
- {labcode-0.1.3 → labcode-0.1.5}/labcode/dialect.py +58 -9
- {labcode-0.1.3 → labcode-0.1.5}/labcode/extension.py +40 -0
- {labcode-0.1.3 → labcode-0.1.5}/labcode/run_cli.py +9 -0
- labcode-0.1.5/labcode/sila2.py +280 -0
- labcode-0.1.5/labcode/sila2_commands.py +104 -0
- {labcode-0.1.3 → labcode-0.1.5}/labcode.egg-info/PKG-INFO +1 -1
- {labcode-0.1.3 → labcode-0.1.5}/labcode.egg-info/SOURCES.txt +12 -0
- {labcode-0.1.3 → labcode-0.1.5}/labcode.egg-info/scm_file_list.json +51 -38
- labcode-0.1.5/labcode.egg-info/scm_version.json +8 -0
- {labcode-0.1.3 → labcode-0.1.5}/pyproject.toml +11 -1
- {labcode-0.1.3 → labcode-0.1.5}/tests/test_backend.py +73 -0
- {labcode-0.1.3 → labcode-0.1.5}/tests/test_dialect.py +79 -0
- {labcode-0.1.3 → labcode-0.1.5}/tests/test_run_cli.py +26 -0
- {labcode-0.1.3 → labcode-0.1.5}/tests/test_sila2.py +126 -13
- labcode-0.1.5/tests/test_sila2_commands.py +138 -0
- labcode-0.1.3/examples/README.md +0 -227
- labcode-0.1.3/labcode/sila2.py +0 -153
- labcode-0.1.3/labcode.egg-info/scm_version.json +0 -8
- {labcode-0.1.3 → labcode-0.1.5}/.github/workflows/publish.yml +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/.gitignore +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/LICENSE +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/README.md +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/examples/outputs/plate_line.boundary.yaml +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/examples/outputs/plate_line.observation.yaml +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/examples/outputs/plate_line.plan.yaml +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/examples/outputs/plate_line.svg +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/examples/plate_line.boundary.yaml +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/examples/plate_line.env.yaml +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/examples/plate_line.workflow.yaml +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/examples/render_plate_line.py +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/examples/sila2_seal.boundary.yaml +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/examples/sila2_seal.workflow.yaml +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/labcode/__init__.py +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/labcode/__main__.py +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/labcode/_child.py +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/labcode/cli.py +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/labcode/idgen.py +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/labcode/objectid.py +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/labcode/probe.py +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/labcode/py.typed +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/labcode/runner.py +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/labcode.egg-info/dependency_links.txt +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/labcode.egg-info/entry_points.txt +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/labcode.egg-info/requires.txt +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/labcode.egg-info/top_level.txt +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/setup.cfg +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/tests/fixtures/device_script.env.yaml +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/tests/fixtures/device_script.workflow.yaml +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/tests/fixtures/reroute_device.env.yaml +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/tests/fixtures/reroute_transporter.env.yaml +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/tests/fixtures/transport.env.yaml +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/tests/fixtures/transport.workflow.yaml +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/tests/test_cli.py +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/tests/test_objectid.py +0 -0
- {labcode-0.1.3 → labcode-0.1.5}/tests/test_probe.py +0 -0
|
@@ -31,7 +31,9 @@ jobs:
|
|
|
31
31
|
python-version: "3.12"
|
|
32
32
|
cache: pip
|
|
33
33
|
- run: pip install -e ".[dev]"
|
|
34
|
-
|
|
34
|
+
# The whole tree, so `examples/` is linted too (ruff's own defaults skip
|
|
35
|
+
# build/venv dirs and honour .gitignore).
|
|
36
|
+
- run: ruff check .
|
|
35
37
|
|
|
36
38
|
typecheck:
|
|
37
39
|
runs-on: ubuntu-latest
|
|
@@ -43,3 +45,17 @@ jobs:
|
|
|
43
45
|
cache: pip
|
|
44
46
|
- run: pip install -e ".[dev]"
|
|
45
47
|
- run: mypy
|
|
48
|
+
|
|
49
|
+
# `uv sync --extra sila2` is how a development environment gets built here, and
|
|
50
|
+
# uv.lock records what it produces. This asserts the lock still agrees with
|
|
51
|
+
# pyproject.toml, so a raised dependency floor or a new extra cannot leave the
|
|
52
|
+
# environment pinned to versions the project no longer accepts. It deliberately
|
|
53
|
+
# does not ask whether newer releases exist -- that is `uv lock --upgrade`, run
|
|
54
|
+
# when someone means to move. The jobs above stay on pip so they keep testing
|
|
55
|
+
# against the latest released siblings.
|
|
56
|
+
lock:
|
|
57
|
+
runs-on: ubuntu-latest
|
|
58
|
+
steps:
|
|
59
|
+
- uses: actions/checkout@v4
|
|
60
|
+
- uses: astral-sh/setup-uv@v7
|
|
61
|
+
- run: uv lock --check
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# setuptools-scm hands the sdist every file git tracks, and uv.lock is tracked so
|
|
2
|
+
# that the versions a developer actually gets stay reviewable. That lock describes
|
|
3
|
+
# the development environment only -- an installer resolves the wheel's dependency
|
|
4
|
+
# metadata and never reads it -- so it has no business in the distribution, where
|
|
5
|
+
# it would roughly double the sdist. Excluded here; still in the repository.
|
|
6
|
+
exclude uv.lock
|
|
@@ -49,6 +49,10 @@ still validates and schedules as plain v0. Only labcode interprets it.
|
|
|
49
49
|
commands alone and labcode supplies the clients. `raw` is the whole function body,
|
|
50
50
|
written by its author — the general escape hatch, and what a script that connects for
|
|
51
51
|
itself (or speaks something other than SiLA2) uses.
|
|
52
|
+
- `endpoints` (**transport routes only**, optional, default `false`): MUST be a boolean —
|
|
53
|
+
whether this move is also given clients for the devices at either **end** of its route,
|
|
54
|
+
not only its `transporter` (§1.6). A process mode may not declare it: a mode's machines
|
|
55
|
+
are the ones it lists.
|
|
52
56
|
|
|
53
57
|
**Unknown keys are an error** — in `x-labcode` at every position, and in the mappings it
|
|
54
58
|
holds. A key this version does not know is either a typo or a feature it does not have;
|
|
@@ -160,6 +164,15 @@ a script uses it.
|
|
|
160
164
|
- a transport script with `flavor: sila2` requires that route's `transporter` to declare
|
|
161
165
|
one.
|
|
162
166
|
|
|
167
|
+
A transport that declares `endpoints: true` is also handed the clients of the devices at
|
|
168
|
+
either **end** of its route (§1.6), but those are *not* required to declare a `connection`:
|
|
169
|
+
a route through a plain holding location is ordinary, and the end without an address is
|
|
170
|
+
simply not connected to (a **warning** when *neither* end has one, since then the request
|
|
171
|
+
does nothing). The transporter is the one that must be reachable, because it is the machine
|
|
172
|
+
that does the moving — and the one `sila2_client` names. Asking a `raw` script for endpoint
|
|
173
|
+
clients is an **error**: a raw script is handed no clients at all, so the request cannot be
|
|
174
|
+
honoured.
|
|
175
|
+
|
|
163
176
|
Declaring a `connection` on a device no script connects to is allowed — it is how an
|
|
164
177
|
environment is prepared before the scripts that use it are written.
|
|
165
178
|
|
|
@@ -258,8 +271,8 @@ of the input ports of §1.2 (or the transport locals of §1.3), the code sees:
|
|
|
258
271
|
|
|
259
272
|
| name | meaning |
|
|
260
273
|
|---|---|
|
|
261
|
-
| `sila2_clients` | the clients by **
|
|
262
|
-
| `sila2_client` | the first of them — the one name a single-machine operation needs |
|
|
274
|
+
| `sila2_clients` | the clients by **machine id**, in the order the operation holds its machines: a mode's `devices[]` order, or — for a transport — its `transporter`, followed by the devices at either **end of the route** when it declares `endpoints: true` |
|
|
275
|
+
| `sila2_client` | the first of them — for a transport always its `transporter`; the one name a single-machine operation needs |
|
|
263
276
|
|
|
264
277
|
```yaml
|
|
265
278
|
x-labcode:
|
|
@@ -275,13 +288,92 @@ x-labcode:
|
|
|
275
288
|
parameters, so an input port of the same name would be silently overwritten by a client;
|
|
276
289
|
a process that declares one is rejected at the front door (as a `_id` view field is,
|
|
277
290
|
§4.1).
|
|
278
|
-
- **
|
|
291
|
+
- **A transport may be handed all three of the machines it holds** — `endpoints: true`. A
|
|
292
|
+
transport activity occupies the source device, the destination device *and* the transporter
|
|
293
|
+
for its whole body (`ofplang-schedule` SPECIFICATIONS §4.5), so all three are its to
|
|
294
|
+
command: that is what lets the move that needs a lid open be the move that opens it, and
|
|
295
|
+
nothing else can be using either instrument meanwhile, because the scheduler has given them
|
|
296
|
+
both to this move.
|
|
297
|
+
|
|
298
|
+
It is **off unless asked for**, per route. A move that drives nothing but its transporter
|
|
299
|
+
should pay for one connection rather than three, and should not begin to fail because an
|
|
300
|
+
instrument it merely hands a plate to is switched off — while needing to open a lid is a
|
|
301
|
+
property of the move, not of the lab. A route that does not ask still *holds* both ends, so
|
|
302
|
+
reaching for one is answered with what to add rather than with silence.
|
|
303
|
+
|
|
304
|
+
```yaml
|
|
305
|
+
transports:
|
|
306
|
+
- transporter: arm
|
|
307
|
+
from: plateloc.stage
|
|
308
|
+
to: thermal_cycler.block
|
|
309
|
+
duration: 8
|
|
310
|
+
x-labcode:
|
|
311
|
+
script:
|
|
312
|
+
language: python
|
|
313
|
+
flavor: sila2
|
|
314
|
+
endpoints: true # ...so the lid can be opened before the plate arrives
|
|
315
|
+
code: |
|
|
316
|
+
from labcode.sila2_commands import settle
|
|
317
|
+
|
|
318
|
+
cycler = sila2_clients["thermal_cycler"].AutomatedThermalCyclerController
|
|
319
|
+
settle(cycler.OpenLid(), "OpenLid")
|
|
320
|
+
arm = sila2_client.TrolleyArmProvider # the transporter: still the first client
|
|
321
|
+
arm.Pick(LocationSpecifier="plateloc.stage")
|
|
322
|
+
arm.Place(LocationSpecifier="thermal-cycler.block")
|
|
323
|
+
```
|
|
324
|
+
- **Connections last one operation**, opened before the code runs and closed after it — on
|
|
279
325
|
any exit, including a `return` or an exception, and including a *later* connection
|
|
280
326
|
failing after an earlier one opened. There is no pooling and no reconnection: reaching an
|
|
281
327
|
instrument is assumed, and failing to is an ordinary operation failure naming the machine.
|
|
328
|
+
A machine that declares a `connection` is connected to whether or not the script uses it,
|
|
329
|
+
so the cost of an operation follows the machines it **holds**, not the ones it commands.
|
|
330
|
+
- **A machine held without a client explains itself.** An operation may hold a machine it
|
|
331
|
+
cannot reach — a plain holding device declares no `connection` — and that machine is
|
|
332
|
+
absent from `sila2_clients` (`in`, `.get()` and iteration all say so). *Indexing* it is
|
|
333
|
+
different: it yields a stand-in that is **falsy**, so `if sila2_clients[id]:` reads as "is
|
|
334
|
+
there a client for it", and that fails the operation with **why** there is none
|
|
335
|
+
(`sila2_not_connected`, or `sila2_endpoints_not_requested` for an end of a route that did
|
|
336
|
+
not ask for it) if the script commands it anyway. Indexing an id the operation does not
|
|
337
|
+
hold at all raises instead, naming what it does hold: that is a typo, and a falsy stand-in
|
|
338
|
+
would let it survive until something stranger happened later.
|
|
282
339
|
- **Everything else is still the script's own.** The flavor supplies connections, nothing
|
|
283
340
|
more: waiting for an observable command to finish (the standard `sila2` polling pattern)
|
|
284
341
|
belongs in the code, as it does in a `raw` script.
|
|
342
|
+
|
|
343
|
+
#### 1.6.1 `labcode.sila2_commands` — the polling loop, written once
|
|
344
|
+
|
|
345
|
+
Waiting for an observable command is the same loop in every script that issues one, so
|
|
346
|
+
labcode ships it. It is an **ordinary module**, reached by an ordinary import — nothing is
|
|
347
|
+
injected, and a script that does not import it does not have it:
|
|
348
|
+
|
|
349
|
+
```yaml
|
|
350
|
+
code: |
|
|
351
|
+
from labcode.sila2_commands import settle
|
|
352
|
+
|
|
353
|
+
feature = sila2_client.PlateLocController
|
|
354
|
+
settle(feature.StartCycle(), "StartCycle")
|
|
355
|
+
return {"cycle_count": int(feature.CycleCount.get())}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
`settle(instance, label, *, timeout=3600.0, poll=1.0)` polls `instance` until it reports
|
|
359
|
+
`done` and returns its `get_responses()`.
|
|
360
|
+
|
|
361
|
+
This is deliberately *not* part of the calling convention above. A name that appears out of
|
|
362
|
+
nowhere is worth spending only on what a script cannot obtain for itself — a live connection
|
|
363
|
+
is that, an import is not — and keeping it an import means the helper reserves no name, is
|
|
364
|
+
equally available to a `raw` script, and stays visible in the code that depends on it.
|
|
365
|
+
|
|
366
|
+
- **A timeout is not a cancel.** SiLA2 offers no way to stop a command already issued, so a
|
|
367
|
+
`settle` that times out fails the *operation* while the instrument carries on. Whatever
|
|
368
|
+
state that leaves the lab in is the operator's to restore, as for any operation that
|
|
369
|
+
failed part way. The default timeout is therefore generous rather than tight: its purpose
|
|
370
|
+
is to turn a hang into a diagnosable failure, since nothing else in the stack bounds an
|
|
371
|
+
operation's running time.
|
|
372
|
+
- **Its timeout is in real seconds**, and is unrelated to the mode's `duration` — which is
|
|
373
|
+
an *estimate*, in environment time, for scheduling. A schedule's estimate is not a
|
|
374
|
+
deadline, and `--seconds-per-tick` does not rescale the timeout.
|
|
375
|
+
- **Passing an unobservable command's response is an error** (`sila2_not_observable`): such
|
|
376
|
+
a command has already finished when its call returns, and there is nothing to settle.
|
|
285
377
|
- A `sila2` script is only interpreted where the dialect is — in an environment
|
|
286
378
|
`x-labcode`. A workflow's own `script` (v0 §22) has no `flavor`.
|
|
287
379
|
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# labcode examples
|
|
2
|
+
|
|
3
|
+
## `plate_line` — an Object-bearing line, driven by environment scripts
|
|
4
|
+
|
|
5
|
+
A `Plate` flows down a four-station line, a `Tube` is dispensed into it, and a
|
|
6
|
+
measurement is read off it:
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
(in) tube ──────────────┐
|
|
10
|
+
▼
|
|
11
|
+
load ──[move]──> dispense ──[move]──> read ──[move]──> store
|
|
12
|
+
│ │
|
|
13
|
+
(out) tube (out) od
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- [`plate_line.workflow.yaml`](plate_line.workflow.yaml) is **portable ofplang v0** — it
|
|
17
|
+
says only *what* happens. The whole workflow takes one `Tube` in and passes the *same*
|
|
18
|
+
Tube back out, alongside the Pure Data measurement `od`. `dispense` and `read` are
|
|
19
|
+
**Object-bearing**: their Objects go in and the *same* Objects come out (`objects.map` —
|
|
20
|
+
identity preserved). The Plate is created inside the workflow by `load`; the Tube enters
|
|
21
|
+
at the run boundary.
|
|
22
|
+
- [`plate_line.boundary.yaml`](plate_line.boundary.yaml) is the **run boundary** (dev-notes
|
|
23
|
+
D28). It supplies the `Tube` input — an Object, so it names a `spot`: a standalone tube
|
|
24
|
+
`rack` slot bound to no process, where the Tube starts and returns (`Tube` has no view
|
|
25
|
+
fields, so no `view`). The arm carries it to the dispenser and back. It also names the
|
|
26
|
+
outputs `od` and `tube`, whose produced values are echoed in the result boundary.
|
|
27
|
+
- [`plate_line.env.yaml`](plate_line.env.yaml) is the **labcode environment** — it says
|
|
28
|
+
*how* each step is carried out, as an `x-labcode.script` (see [`../SPECIFICATIONS.md`](../SPECIFICATIONS.md)).
|
|
29
|
+
**Every process mode and every transport route carries a script**: `load` makes the
|
|
30
|
+
Plate, `dispense` dispenses the Tube into the Plate (both Objects are carried through by
|
|
31
|
+
`objects.map`, so its script returns nothing), `read` measures `od` (the Plate is
|
|
32
|
+
carried, so the script returns only what it computes — labcode's *partial outputs*, see
|
|
33
|
+
the spec), `store` takes it; the transport scripts perform the move and may read the
|
|
34
|
+
moved Plate's `view` (its barcode).
|
|
35
|
+
|
|
36
|
+
labcode runs each script **out-of-process** on a wall clock, discovering completion by
|
|
37
|
+
polling — so a real, slow device operation never blocks the runner.
|
|
38
|
+
|
|
39
|
+
### Run it
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
lc run examples/plate_line.workflow.yaml --env examples/plate_line.env.yaml \
|
|
43
|
+
--boundary examples/plate_line.boundary.yaml --seconds-per-tick 0.2
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`--seconds-per-tick` sets the real seconds per environment time tick; it defaults to a
|
|
47
|
+
coarse value suited to real hardware, so a small value here keeps the demo quick. The run
|
|
48
|
+
completes with every activity `completed`; the produced measurement (`od = 0.42`) can be
|
|
49
|
+
written out with `--boundary-out result.yaml`.
|
|
50
|
+
|
|
51
|
+
### Produce the outputs
|
|
52
|
+
|
|
53
|
+
[`render_plate_line.py`](render_plate_line.py) drives the same run on the labcode backend
|
|
54
|
+
and writes its artifacts under [`outputs/`](outputs/):
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
python examples/render_plate_line.py
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- [`outputs/plate_line.plan.yaml`](outputs/plate_line.plan.yaml) — the **final execution
|
|
61
|
+
schedule** (the §6/§7 status document: every activity, `completed`).
|
|
62
|
+
- [`outputs/plate_line.observation.yaml`](outputs/plate_line.observation.yaml) — the
|
|
63
|
+
**observation document** (D38): each completed activity's I/O views, as `lc run
|
|
64
|
+
--observation-out` would stream it.
|
|
65
|
+
- [`outputs/plate_line.svg`](outputs/plate_line.svg) — a **Gantt chart** of that schedule
|
|
66
|
+
(device view), drawn by the scheduler's visualizer. (The ofplang toolchain renders
|
|
67
|
+
SVG/HTML; open it in a browser, or convert to PNG with any SVG rasterizer.)
|
|
68
|
+
- [`outputs/plate_line.boundary.yaml`](outputs/plate_line.boundary.yaml) — the **result
|
|
69
|
+
boundary**, echoing the produced `od` and the returned `tube` (as `--boundary-out`
|
|
70
|
+
writes it).
|
|
71
|
+
|
|
72
|
+
Because the labcode backend runs each op out-of-process on a wall clock, the exact times
|
|
73
|
+
(and makespan) may vary slightly between runs; the sequence and produced values do not.
|
|
74
|
+
|
|
75
|
+
Every Object's view carries a reserved **`_id`** — labcode's implicit, value-layer Object
|
|
76
|
+
identity (see [`../SPECIFICATIONS.md`](../SPECIFICATIONS.md) §4). In the observation you can
|
|
77
|
+
follow the *same* Plate (one `_id`) from `load` through `dispense`/`read` to `store`, and
|
|
78
|
+
the Tube's `_id` round-trips from the input boundary to the output. The ids are
|
|
79
|
+
reproducible (a seeded, provenance-keyed generator), so these outputs are stable to
|
|
80
|
+
re-generate.
|
|
81
|
+
|
|
82
|
+
> The scripts here are mocks (they just return values / reference their locals). Replace a
|
|
83
|
+
> script's body with real device calls — e.g. `robot.move(from_spot, to_spot)` in a
|
|
84
|
+
> transport, or an instrument read in `read` — to drive real hardware; the code may
|
|
85
|
+
> `import` anything the host Python can.
|
|
86
|
+
|
|
87
|
+
## `sila2_seal` — the same idea, driven by real SiLA2 servers
|
|
88
|
+
|
|
89
|
+
Where `plate_line`'s scripts are mocks that only return values, this example's scripts open
|
|
90
|
+
SiLA2 connections and issue real commands. It is the integration check for labcode's SiLA2
|
|
91
|
+
story: labcode schedules, dispatches out-of-process, a script talks SiLA2, an instrument acts,
|
|
92
|
+
and the produced value comes back through labcode's partial outputs.
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
(in) plate ──[move]──▶ seal ──[move]──▶ (out) plate
|
|
96
|
+
│
|
|
97
|
+
(out) cycle_count
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- [`sila2_seal.workflow.yaml`](sila2_seal.workflow.yaml) is portable ofplang v0: one Plate in,
|
|
101
|
+
the *same* Plate out (`objects.map`), plus the Pure Data reading `cycle_count`.
|
|
102
|
+
- [`sila2_seal.wrapped.env.yaml`](sila2_seal.wrapped.env.yaml) drives a plate sealer over SiLA2
|
|
103
|
+
and a transport arm's `Pick`/`Place`, written the **recommended** way — `flavor: sila2`.
|
|
104
|
+
`cycle_count` is **a real reading**: the script asks the instrument how many cycles it has
|
|
105
|
+
performed, and the number goes up because this run performed one.
|
|
106
|
+
- [`sila2_seal.boundary.yaml`](sila2_seal.boundary.yaml) puts the Plate in and takes it out at
|
|
107
|
+
the *same* spot, so the run is a **round trip** and can be repeated without anyone putting
|
|
108
|
+
the world back.
|
|
109
|
+
|
|
110
|
+
Each machine's address is declared **once**, on the device (or the transporter) that has it:
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
devices:
|
|
114
|
+
- id: plateloc
|
|
115
|
+
spots: [stage]
|
|
116
|
+
x-labcode:
|
|
117
|
+
connection: { kind: sila2, host: 127.0.0.1, port: 50053, insecure: true }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
…and each script is the **commands alone**. labcode opens `sila2_client` before the code runs
|
|
121
|
+
and closes it afterwards — on a `return`, on an exception, and on a later connection failing
|
|
122
|
+
after an earlier one opened:
|
|
123
|
+
|
|
124
|
+
```yaml
|
|
125
|
+
x-labcode:
|
|
126
|
+
script:
|
|
127
|
+
flavor: sila2
|
|
128
|
+
language: python
|
|
129
|
+
code: |
|
|
130
|
+
from labcode.sila2_commands import settle
|
|
131
|
+
|
|
132
|
+
feature = sila2_client.PlateLocController
|
|
133
|
+
settle(feature.StartCycle(), "StartCycle") # the wait is still the script's own
|
|
134
|
+
return {"cycle_count": int(feature.CycleCount.get())}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
What the flavor supplies is connections, nothing more. Waiting for an *observable* command to
|
|
138
|
+
finish stays in the script — but the loop itself does not have to be rewritten each time:
|
|
139
|
+
labcode ships `settle` in **`labcode.sila2_commands`** (§1.6.1), reached by the ordinary import
|
|
140
|
+
above. Nothing is injected, so a script that does not import it does not have it, and a `raw`
|
|
141
|
+
script has exactly the same access as this one. A live connection is worth a name that appears
|
|
142
|
+
out of nowhere; an import is not.
|
|
143
|
+
|
|
144
|
+
Note what `settle` is *not*. Its timeout is in real seconds, unrelated to the mode's `duration`
|
|
145
|
+
(an estimate, for scheduling) and unscaled by `--seconds-per-tick`; and it cancels nothing — a
|
|
146
|
+
SiLA2 command cannot be stopped from here, so a timeout fails the operation while the
|
|
147
|
+
instrument carries on, leaving the lab for the operator to restore.
|
|
148
|
+
|
|
149
|
+
Spot names still have to be the ones the lab declares — a name it does not know fails the move
|
|
150
|
+
at the moment of use. See [`../SPECIFICATIONS.md`](../SPECIFICATIONS.md) §1.4 and §1.6.
|
|
151
|
+
|
|
152
|
+
### If a machine stops answering
|
|
153
|
+
|
|
154
|
+
Neither environment here asks for it, but labcode can **check that a machine is reachable**
|
|
155
|
+
and schedule around the ones that are not — see
|
|
156
|
+
[`../SPECIFICATIONS.md`](../SPECIFICATIONS.md) §1.5. Adding this to a device (or a
|
|
157
|
+
transporter) that declares a `connection`:
|
|
158
|
+
|
|
159
|
+
```yaml
|
|
160
|
+
x-labcode:
|
|
161
|
+
connection: { kind: sila2, host: 127.0.0.1, port: 50053, insecure: true }
|
|
162
|
+
probe: { enabled: true, interval: 60 }
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
…makes `lc run` report the machine when its reachability changes and plan without it while
|
|
166
|
+
it is down. These two examples leave it out on purpose: each has exactly one sealer and one
|
|
167
|
+
arm, so there is nothing to route around, and what a stopped lab should produce here is the
|
|
168
|
+
instrument command failing where it was issued. `lc run --no-probe` turns probing off for a
|
|
169
|
+
run without editing the environment.
|
|
170
|
+
|
|
171
|
+
### Prerequisites
|
|
172
|
+
|
|
173
|
+
**Verified against [ofplang-sila2-backend](https://github.com/kaizu/sila2-demo) v0.3.0 (commit
|
|
174
|
+
`0c3c4c8`)** — a virtual lab of mock SiLA2 instrument servers. That lab is a *reference, not a
|
|
175
|
+
requirement*: the scripts speak plain SiLA2, so pointing them at real instruments is a matter
|
|
176
|
+
of changing the host and port in the environment. The version is recorded so a run without
|
|
177
|
+
hardware has something known to reproduce against; it is deliberately not asserted on, since
|
|
178
|
+
checking a server's name would be the one thing that stopped this working against hardware.
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
# in the reference lab
|
|
182
|
+
docker compose up -d
|
|
183
|
+
|
|
184
|
+
# here: the client library has to be importable by the interpreter that runs the scripts
|
|
185
|
+
uv sync --extra sila2
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The world must be at t=0 — one plate on `station.slot1`. The round trip puts it back, so
|
|
189
|
+
repeated runs need no intervention; a run that failed part way may not have, and restoring the
|
|
190
|
+
world is the **operator's** job, not the workflow's:
|
|
191
|
+
|
|
192
|
+
```sh
|
|
193
|
+
curl -X POST http://localhost:8001/reseed
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Run it
|
|
197
|
+
|
|
198
|
+
```sh
|
|
199
|
+
python examples/run_sila2_seal.py
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Exit code 0 means every check passed, so this is a check rather than a demo. It asserts only on
|
|
203
|
+
what a real client can see — the schedule, the produced boundary, the observation — and **never
|
|
204
|
+
contacts the lab's world-state service**: that service stands in for the physical world, which
|
|
205
|
+
exposes no such interface, so a client that read it could not be pointed at hardware.
|
|
206
|
+
|
|
207
|
+
Pass `--artifacts DIR` to keep the run's status, observation and result boundary (they are
|
|
208
|
+
otherwise written to a temporary directory, since their timings vary between runs).
|
|
209
|
+
|
|
210
|
+
### The same run, with each script connecting for itself
|
|
211
|
+
|
|
212
|
+
[`sila2_seal.env.yaml`](sila2_seal.env.yaml) drives the *same* workflow through the same
|
|
213
|
+
motions with `raw` scripts: no `connection` on the devices, each script opening its own
|
|
214
|
+
`SilaClient` (so the address appears in every script that talks to a machine, and closing it is
|
|
215
|
+
the author's `with`), and its `settle` written out by hand rather than imported — not because a
|
|
216
|
+
`raw` script may not import it, but because this file is the low-level reference and the loop
|
|
217
|
+
is worth being able to read. It is what to write when the connection is not a plain SiLA2 one,
|
|
218
|
+
or when the script needs to do something the flavor does not cover:
|
|
219
|
+
|
|
220
|
+
```sh
|
|
221
|
+
python examples/run_sila2_seal.py --env examples/sila2_seal.env.yaml
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The two environments are interchangeable today but are **not promised to stay equivalent**; the
|
|
225
|
+
`flavor: sila2` one is the example that follows the dialect.
|
|
226
|
+
|
|
227
|
+
> **A device id cannot contain a hyphen.** v0 identifiers are `[A-Za-z_][A-Za-z0-9_]*`, which
|
|
228
|
+
> rules out the reference lab's `seal-remover`, `thermal-cycler` and `trolley-arm`. This
|
|
229
|
+
> example sidesteps it by using `plateloc`; [`sila2_plate_cycle`](#sila2_plate_cycle--the-whole-circuit-four-instruments-and-one-plate)
|
|
230
|
+
> below cannot, and translates the names in its transport scripts instead. (The arm is fine
|
|
231
|
+
> either way: labcode names the transporter `arm` and never names the arm's own spot.)
|
|
232
|
+
|
|
233
|
+
## `sila2_plate_cycle` — the whole circuit: four instruments and one plate
|
|
234
|
+
|
|
235
|
+
Where `sila2_seal` drives one instrument, this drives a line. One `Plate` is unsealed,
|
|
236
|
+
resealed, thermal-cycled and spun down, carried between the four instruments by the arm, and
|
|
237
|
+
returned to the spot it started from:
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
(in) plate ──▶ peel ──▶ seal ──▶ thermal_cycle ──▶ rotate ──▶ (out) plate
|
|
241
|
+
│ │ │
|
|
242
|
+
(out) tape_left │ (out) elapsed_time
|
|
243
|
+
(out) cycle_count
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- [`sila2_plate_cycle.workflow.yaml`](sila2_plate_cycle.workflow.yaml) is portable ofplang v0:
|
|
247
|
+
one Plate in, the *same* Plate out, carried through all four steps by `objects.map`. Three
|
|
248
|
+
steps also report a reading; `rotate` reports nothing, because the centrifuge exposes no
|
|
249
|
+
counter — it is the example of a step that only carries its Object.
|
|
250
|
+
- [`sila2_plate_cycle.wrapped.env.yaml`](sila2_plate_cycle.wrapped.env.yaml) drives the four
|
|
251
|
+
instruments and the arm with `flavor: sila2`. The command arguments are taken from the
|
|
252
|
+
delivery scripts in `ScriptsForIntegrationTest` (`snip_xpeel.py`, `snip_plateloc.py`,
|
|
253
|
+
`snip_atc.py`, `snip_microplate_centrifuge.py`), so what each instrument is asked to do here
|
|
254
|
+
is what those ask of the real hardware.
|
|
255
|
+
- [`sila2_plate_cycle.boundary.yaml`](sila2_plate_cycle.boundary.yaml) puts the Plate in and
|
|
256
|
+
takes it out at `station.slot1`, so the circuit is a **round trip** like `sila2_seal`'s.
|
|
257
|
+
|
|
258
|
+
Every reading is a real one: the seal remover's remaining supply-spool tape, the sealer's cycle
|
|
259
|
+
count, and the thermal cycler's `hh:mm:ss` elapsed time — read after the run, so it is non-zero
|
|
260
|
+
exactly because a run happened. And because the Plate's `_id` survives four handovers, the
|
|
261
|
+
check can assert something a single-step run cannot: that all four instruments handled the
|
|
262
|
+
*same* plate.
|
|
263
|
+
|
|
264
|
+
This is the labcode counterpart of the reference lab's own `samples/run_roundabout.py`, with
|
|
265
|
+
one difference that is the point of it: there, a script drives the circuit directly; here,
|
|
266
|
+
labcode schedules it and dispatches each step, and each script only says what one step does.
|
|
267
|
+
|
|
268
|
+
### Two things worth reading the environment for
|
|
269
|
+
|
|
270
|
+
**Hyphens.** The lab's locations are `seal-remover.stage` and `thermal-cycler.block`, which no
|
|
271
|
+
v0 identifier can spell. The environment names those devices `seal_remover` and
|
|
272
|
+
`thermal_cycler`, and each transport script writes the lab's names out literally rather than
|
|
273
|
+
passing `from_spot` / `to_spot` through:
|
|
274
|
+
|
|
275
|
+
```yaml
|
|
276
|
+
code: |
|
|
277
|
+
# labcode's plateloc.stage -> thermal_cycler.block.
|
|
278
|
+
arm = sila2_client.TrolleyArmProvider
|
|
279
|
+
arm.Pick(LocationSpecifier="plateloc.stage")
|
|
280
|
+
arm.Place(LocationSpecifier="thermal-cycler.block")
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
A route is one fixed pair of spots, so there is nothing to compute, and the name the lab
|
|
284
|
+
actually receives is the name written in the file. Deriving it instead — `_` → `-` — would
|
|
285
|
+
read as a rule, and it is not one: it is a coincidence of this lab's naming, and a real lab may
|
|
286
|
+
have a device legitimately called `foo_bar`. The cost is that the route and the strings can
|
|
287
|
+
drift apart if one is edited without the other; the lab is what catches that, since a name it
|
|
288
|
+
does not know fails the move with `unknown_location`.
|
|
289
|
+
|
|
290
|
+
Only transport scripts face this, because only they name spots. The lab is *not* renamed to
|
|
291
|
+
suit labcode: the dependency runs one way, and the lab's world model is not labcode's to edit.
|
|
292
|
+
|
|
293
|
+
**Lids and doors.** In the lab's world model a closed lid or door makes that spot inaccessible,
|
|
294
|
+
and an item cannot be moved into or out of an inaccessible spot. So the transport that delivers
|
|
295
|
+
the plate is what opens the instrument: three of the five routes declare `endpoints: true` and
|
|
296
|
+
are handed clients for the devices at either end as well as for the arm (§1.6). That is sound
|
|
297
|
+
because the scheduler has already given the move both instruments for its whole duration —
|
|
298
|
+
nothing else can be using them meanwhile.
|
|
299
|
+
|
|
300
|
+
The convention the environment follows is that **an instrument is closed at rest**: a transport
|
|
301
|
+
opens what it must to pick and to place and closes the source it emptied, and a process closes
|
|
302
|
+
the instrument to work and leaves it closed. Delivering the plate to the thermal cycler
|
|
303
|
+
therefore reads as: the transport opens the lid and places the plate; `thermal_cycle` closes it,
|
|
304
|
+
runs, and leaves it closed; the next transport opens it, takes the plate out, and closes it
|
|
305
|
+
again.
|
|
306
|
+
|
|
307
|
+
```yaml
|
|
308
|
+
x-labcode:
|
|
309
|
+
script:
|
|
310
|
+
flavor: sila2
|
|
311
|
+
endpoints: true
|
|
312
|
+
code: |
|
|
313
|
+
from labcode.sila2_commands import settle
|
|
314
|
+
|
|
315
|
+
cycler = sila2_clients["thermal_cycler"].AutomatedThermalCyclerController
|
|
316
|
+
settle(cycler.OpenLid(), "OpenLid")
|
|
317
|
+
arm = sila2_client.TrolleyArmProvider # the transporter: still the first client
|
|
318
|
+
arm.Pick(LocationSpecifier="plateloc.stage")
|
|
319
|
+
arm.Place(LocationSpecifier="thermal-cycler.block")
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Closed-at-rest is what a real instrument does, and it is what makes this example *check*
|
|
323
|
+
something rather than merely work. The lab starts with everything open, so the first run's
|
|
324
|
+
`OpenLid` is a no-op — but every run after it starts with the cycler and the centrifuge closed
|
|
325
|
+
and gets nowhere unless the transports really can open them. (`OpenLid` / `CloseLid` set the
|
|
326
|
+
world's accessibility outright rather than toggling it, so calling one that is already true
|
|
327
|
+
costs nothing.) The thing to know is that the plate is inside a closed instrument for part of
|
|
328
|
+
the circuit, so a run that dies there leaves it there — an operator's job to retrieve, or a
|
|
329
|
+
reseed, as for any run that fails half way.
|
|
330
|
+
|
|
331
|
+
### Run it
|
|
332
|
+
|
|
333
|
+
```sh
|
|
334
|
+
python examples/run_sila2_plate_cycle.py
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Same prerequisites as `sila2_seal` (the lab up, `sila2` importable, one plate on
|
|
338
|
+
`station.slot1`), and the same conventions: exit code 0 means every check passed, and
|
|
339
|
+
`--artifacts DIR` keeps the run's status, observation and result boundary. It needs no
|
|
340
|
+
particular lid or door state to start from: the transports open what they need.
|
|
341
|
+
|
|
342
|
+
Verified against both of the reference lab's timing profiles at `--seconds-per-tick 1.0`.
|
|
343
|
+
Against `command_durations.realistic.yaml` — the profile the environment's durations are
|
|
344
|
+
measured on — the circuit takes a makespan of about 110, and each step lands within its
|
|
345
|
+
declared duration; against the default profile (which waits for nothing) every op finishes
|
|
346
|
+
early and the run still completes.
|
|
347
|
+
|
|
348
|
+
Those durations are the **measured operation times, not the instrument's**. An op also costs
|
|
349
|
+
labcode a child process (~2 s), a SiLA2 client per machine it connects to (~1 s each, since
|
|
350
|
+
building one fetches every Feature definition), and one poll interval per `settle` that
|
|
351
|
+
finishes mid-interval. Both variable parts show: `thermal_cycle` turns 10 s of instrument time
|
|
352
|
+
into 17 because all five of its commands are short, and the cycler → centrifuge move costs 17
|
|
353
|
+
for 11 s of instrument time, opening two instruments and closing one across three clients.
|
|
354
|
+
Declaring the instrument's time alone would under-run every op, and the scheduler would keep
|
|
355
|
+
trying to dispatch a successor onto a device still finishing.
|
|
356
|
+
|
|
357
|
+
### Produce the outputs
|
|
358
|
+
|
|
359
|
+
[`render_sila2_plate_cycle.py`](render_sila2_plate_cycle.py) drives the same circuit and keeps
|
|
360
|
+
its documents under [`outputs/`](outputs/), so the schedule can be read — and its Gantt chart
|
|
361
|
+
looked at — without a lab to hand:
|
|
362
|
+
|
|
363
|
+
```sh
|
|
364
|
+
python examples/render_sila2_plate_cycle.py
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
- [`outputs/sila2_plate_cycle.plan.yaml`](outputs/sila2_plate_cycle.plan.yaml) — the **final
|
|
368
|
+
execution schedule** (the §6/§7 status document): nine activities, five transports and four
|
|
369
|
+
instrument steps, every one `completed`.
|
|
370
|
+
- [`outputs/sila2_plate_cycle.observation.yaml`](outputs/sila2_plate_cycle.observation.yaml) —
|
|
371
|
+
the **observation document** (D38). Follow one `_id` through `peel` → `seal` →
|
|
372
|
+
`thermal_cycle` → `rotate` and back to the output boundary: that is the plate surviving four
|
|
373
|
+
handovers.
|
|
374
|
+
- [`outputs/sila2_plate_cycle.svg`](outputs/sila2_plate_cycle.svg) — a **Gantt chart** of that
|
|
375
|
+
schedule (device view), drawn by the scheduler's visualizer. Six rows — the four instruments,
|
|
376
|
+
the station and the arm — and the makespan marker.
|
|
377
|
+
- [`outputs/sila2_plate_cycle.boundary.yaml`](outputs/sila2_plate_cycle.boundary.yaml) — the
|
|
378
|
+
**result boundary**: the three readings and the returned plate.
|
|
379
|
+
|
|
380
|
+
It is a producer, not a check: it asserts nothing, and `run_sila2_plate_cycle.py` is what says
|
|
381
|
+
whether the example still works. Unlike `render_plate_line.py` it needs the lab, since its
|
|
382
|
+
scripts issue real commands. The committed copies were produced on the realistic profile
|
|
383
|
+
(makespan 110); every op runs out-of-process on a wall clock against real servers, so the exact
|
|
384
|
+
times vary between runs while the sequence, the identities and the produced values do not.
|
|
385
|
+
|
|
386
|
+
### Run every SiLA2 example
|
|
387
|
+
|
|
388
|
+
```sh
|
|
389
|
+
python examples/run_all_sila2_examples.py
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Runs each environment above in turn — both `sila2_seal` environments and `sila2_plate_cycle`
|
|
393
|
+
(they are round trips that put the plate back where it started, and each opens whatever it
|
|
394
|
+
needs open, so they follow one another without intervention) — prints a pass/fail summary, and
|
|
395
|
+
exits non-zero if any failed. Only the examples
|
|
396
|
+
that need the lab are included; `render_plate_line.py` needs nothing but Python.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
boundary:
|
|
2
|
+
inputs:
|
|
3
|
+
plate:
|
|
4
|
+
spot: station.slot1
|
|
5
|
+
view:
|
|
6
|
+
_id: 2fbeb516-ba67-4198-9ba6-7cd664e799c8
|
|
7
|
+
outputs:
|
|
8
|
+
tape_left:
|
|
9
|
+
view: 1186
|
|
10
|
+
cycle_count:
|
|
11
|
+
view: 22
|
|
12
|
+
elapsed_time:
|
|
13
|
+
view: 00:00:10
|
|
14
|
+
plate:
|
|
15
|
+
spot: station.slot1
|
|
16
|
+
view:
|
|
17
|
+
_id: 2fbeb516-ba67-4198-9ba6-7cd664e799c8
|