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.
Files changed (74) hide show
  1. {labcode-0.1.3 → labcode-0.1.5}/.github/workflows/ci.yml +17 -1
  2. labcode-0.1.5/MANIFEST.in +6 -0
  3. {labcode-0.1.3 → labcode-0.1.5}/PKG-INFO +1 -1
  4. {labcode-0.1.3 → labcode-0.1.5}/SPECIFICATIONS.md +95 -3
  5. labcode-0.1.5/examples/README.md +396 -0
  6. labcode-0.1.5/examples/outputs/sila2_plate_cycle.boundary.yaml +17 -0
  7. labcode-0.1.5/examples/outputs/sila2_plate_cycle.observation.yaml +199 -0
  8. labcode-0.1.5/examples/outputs/sila2_plate_cycle.plan.yaml +146 -0
  9. labcode-0.1.5/examples/outputs/sila2_plate_cycle.svg +2 -0
  10. labcode-0.1.5/examples/render_sila2_plate_cycle.py +114 -0
  11. {labcode-0.1.3 → labcode-0.1.5}/examples/run_all_sila2_examples.py +26 -14
  12. labcode-0.1.5/examples/run_sila2_plate_cycle.py +345 -0
  13. {labcode-0.1.3 → labcode-0.1.5}/examples/run_sila2_seal.py +2 -1
  14. labcode-0.1.5/examples/sila2_plate_cycle.boundary.yaml +22 -0
  15. labcode-0.1.5/examples/sila2_plate_cycle.workflow.yaml +120 -0
  16. labcode-0.1.5/examples/sila2_plate_cycle.wrapped.env.yaml +441 -0
  17. {labcode-0.1.3 → labcode-0.1.5}/examples/sila2_seal.env.yaml +7 -0
  18. {labcode-0.1.3 → labcode-0.1.5}/examples/sila2_seal.wrapped.env.yaml +4 -11
  19. {labcode-0.1.3 → labcode-0.1.5}/labcode/backend.py +53 -16
  20. {labcode-0.1.3 → labcode-0.1.5}/labcode/dialect.py +58 -9
  21. {labcode-0.1.3 → labcode-0.1.5}/labcode/extension.py +40 -0
  22. {labcode-0.1.3 → labcode-0.1.5}/labcode/run_cli.py +9 -0
  23. labcode-0.1.5/labcode/sila2.py +280 -0
  24. labcode-0.1.5/labcode/sila2_commands.py +104 -0
  25. {labcode-0.1.3 → labcode-0.1.5}/labcode.egg-info/PKG-INFO +1 -1
  26. {labcode-0.1.3 → labcode-0.1.5}/labcode.egg-info/SOURCES.txt +12 -0
  27. {labcode-0.1.3 → labcode-0.1.5}/labcode.egg-info/scm_file_list.json +51 -38
  28. labcode-0.1.5/labcode.egg-info/scm_version.json +8 -0
  29. {labcode-0.1.3 → labcode-0.1.5}/pyproject.toml +11 -1
  30. {labcode-0.1.3 → labcode-0.1.5}/tests/test_backend.py +73 -0
  31. {labcode-0.1.3 → labcode-0.1.5}/tests/test_dialect.py +79 -0
  32. {labcode-0.1.3 → labcode-0.1.5}/tests/test_run_cli.py +26 -0
  33. {labcode-0.1.3 → labcode-0.1.5}/tests/test_sila2.py +126 -13
  34. labcode-0.1.5/tests/test_sila2_commands.py +138 -0
  35. labcode-0.1.3/examples/README.md +0 -227
  36. labcode-0.1.3/labcode/sila2.py +0 -153
  37. labcode-0.1.3/labcode.egg-info/scm_version.json +0 -8
  38. {labcode-0.1.3 → labcode-0.1.5}/.github/workflows/publish.yml +0 -0
  39. {labcode-0.1.3 → labcode-0.1.5}/.gitignore +0 -0
  40. {labcode-0.1.3 → labcode-0.1.5}/LICENSE +0 -0
  41. {labcode-0.1.3 → labcode-0.1.5}/README.md +0 -0
  42. {labcode-0.1.3 → labcode-0.1.5}/examples/outputs/plate_line.boundary.yaml +0 -0
  43. {labcode-0.1.3 → labcode-0.1.5}/examples/outputs/plate_line.observation.yaml +0 -0
  44. {labcode-0.1.3 → labcode-0.1.5}/examples/outputs/plate_line.plan.yaml +0 -0
  45. {labcode-0.1.3 → labcode-0.1.5}/examples/outputs/plate_line.svg +0 -0
  46. {labcode-0.1.3 → labcode-0.1.5}/examples/plate_line.boundary.yaml +0 -0
  47. {labcode-0.1.3 → labcode-0.1.5}/examples/plate_line.env.yaml +0 -0
  48. {labcode-0.1.3 → labcode-0.1.5}/examples/plate_line.workflow.yaml +0 -0
  49. {labcode-0.1.3 → labcode-0.1.5}/examples/render_plate_line.py +0 -0
  50. {labcode-0.1.3 → labcode-0.1.5}/examples/sila2_seal.boundary.yaml +0 -0
  51. {labcode-0.1.3 → labcode-0.1.5}/examples/sila2_seal.workflow.yaml +0 -0
  52. {labcode-0.1.3 → labcode-0.1.5}/labcode/__init__.py +0 -0
  53. {labcode-0.1.3 → labcode-0.1.5}/labcode/__main__.py +0 -0
  54. {labcode-0.1.3 → labcode-0.1.5}/labcode/_child.py +0 -0
  55. {labcode-0.1.3 → labcode-0.1.5}/labcode/cli.py +0 -0
  56. {labcode-0.1.3 → labcode-0.1.5}/labcode/idgen.py +0 -0
  57. {labcode-0.1.3 → labcode-0.1.5}/labcode/objectid.py +0 -0
  58. {labcode-0.1.3 → labcode-0.1.5}/labcode/probe.py +0 -0
  59. {labcode-0.1.3 → labcode-0.1.5}/labcode/py.typed +0 -0
  60. {labcode-0.1.3 → labcode-0.1.5}/labcode/runner.py +0 -0
  61. {labcode-0.1.3 → labcode-0.1.5}/labcode.egg-info/dependency_links.txt +0 -0
  62. {labcode-0.1.3 → labcode-0.1.5}/labcode.egg-info/entry_points.txt +0 -0
  63. {labcode-0.1.3 → labcode-0.1.5}/labcode.egg-info/requires.txt +0 -0
  64. {labcode-0.1.3 → labcode-0.1.5}/labcode.egg-info/top_level.txt +0 -0
  65. {labcode-0.1.3 → labcode-0.1.5}/setup.cfg +0 -0
  66. {labcode-0.1.3 → labcode-0.1.5}/tests/fixtures/device_script.env.yaml +0 -0
  67. {labcode-0.1.3 → labcode-0.1.5}/tests/fixtures/device_script.workflow.yaml +0 -0
  68. {labcode-0.1.3 → labcode-0.1.5}/tests/fixtures/reroute_device.env.yaml +0 -0
  69. {labcode-0.1.3 → labcode-0.1.5}/tests/fixtures/reroute_transporter.env.yaml +0 -0
  70. {labcode-0.1.3 → labcode-0.1.5}/tests/fixtures/transport.env.yaml +0 -0
  71. {labcode-0.1.3 → labcode-0.1.5}/tests/fixtures/transport.workflow.yaml +0 -0
  72. {labcode-0.1.3 → labcode-0.1.5}/tests/test_cli.py +0 -0
  73. {labcode-0.1.3 → labcode-0.1.5}/tests/test_objectid.py +0 -0
  74. {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
- - run: ruff check labcode tests
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labcode
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: labcode -- a dialect wrapper over the Object-Flow Programming Language toolchain
5
5
  Author-email: Kazunari Kaizu <kwaizu@gmail.com>
6
6
  License-Expression: MIT
@@ -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 **device id** (`transporter id` for a transport), in `devices[]` order, holding **only** the machines that declared a `connection` |
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
- - **One connection per operation**, opened before the code runs and closed after it — on
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