labcode 0.1.7__tar.gz → 0.2.0__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 (90) hide show
  1. {labcode-0.1.7 → labcode-0.2.0}/.gitignore +3 -0
  2. {labcode-0.1.7 → labcode-0.2.0}/PKG-INFO +76 -9
  3. {labcode-0.1.7 → labcode-0.2.0}/README.md +70 -6
  4. {labcode-0.1.7 → labcode-0.2.0}/examples/README.md +116 -8
  5. {labcode-0.1.7 → labcode-0.2.0}/examples/plate_line.env.yaml +0 -3
  6. labcode-0.2.0/examples/preflight_sila2_env.py +369 -0
  7. {labcode-0.1.7 → labcode-0.2.0}/examples/run_all_sila2_examples.py +8 -4
  8. labcode-0.2.0/examples/run_sila2_plate_cycle_no_atc.py +398 -0
  9. {labcode-0.1.7 → labcode-0.2.0}/examples/sila2_plate_cycle.wrapped.env.yaml +0 -3
  10. labcode-0.2.0/examples/sila2_plate_cycle_no_atc.boundary.yaml +22 -0
  11. labcode-0.2.0/examples/sila2_plate_cycle_no_atc.workflow.yaml +115 -0
  12. labcode-0.2.0/examples/sila2_plate_cycle_no_atc.wrapped.env.yaml +389 -0
  13. {labcode-0.1.7 → labcode-0.2.0}/examples/sila2_seal.env.yaml +0 -3
  14. {labcode-0.1.7 → labcode-0.2.0}/examples/sila2_seal.wrapped.env.yaml +0 -3
  15. {labcode-0.1.7 → labcode-0.2.0}/labcode/_child.py +11 -4
  16. {labcode-0.1.7 → labcode-0.2.0}/labcode/backend.py +69 -0
  17. {labcode-0.1.7 → labcode-0.2.0}/labcode/dialect.py +62 -2
  18. {labcode-0.1.7 → labcode-0.2.0}/labcode/extension.py +12 -0
  19. {labcode-0.1.7 → labcode-0.2.0}/labcode/otel.py +8 -4
  20. labcode-0.2.0/labcode/otel_sila2.py +182 -0
  21. {labcode-0.1.7 → labcode-0.2.0}/labcode/probe.py +4 -1
  22. {labcode-0.1.7 → labcode-0.2.0}/labcode/record.py +5 -0
  23. {labcode-0.1.7 → labcode-0.2.0}/labcode/run_cli.py +15 -0
  24. {labcode-0.1.7 → labcode-0.2.0}/labcode/runner.py +4 -0
  25. {labcode-0.1.7 → labcode-0.2.0}/labcode/sila2_instrument.py +51 -3
  26. {labcode-0.1.7 → labcode-0.2.0}/labcode.egg-info/PKG-INFO +76 -9
  27. {labcode-0.1.7 → labcode-0.2.0}/labcode.egg-info/SOURCES.txt +7 -0
  28. {labcode-0.1.7 → labcode-0.2.0}/labcode.egg-info/requires.txt +5 -2
  29. {labcode-0.1.7 → labcode-0.2.0}/labcode.egg-info/scm_file_list.json +7 -0
  30. labcode-0.2.0/labcode.egg-info/scm_version.json +8 -0
  31. {labcode-0.1.7 → labcode-0.2.0}/pyproject.toml +42 -8
  32. {labcode-0.1.7 → labcode-0.2.0}/tests/fixtures/device_script.env.yaml +0 -3
  33. labcode-0.2.0/tests/fixtures/replenishment.env.yaml +51 -0
  34. {labcode-0.1.7 → labcode-0.2.0}/tests/fixtures/reroute_device.env.yaml +0 -3
  35. {labcode-0.1.7 → labcode-0.2.0}/tests/fixtures/reroute_transporter.env.yaml +0 -3
  36. {labcode-0.1.7 → labcode-0.2.0}/tests/fixtures/transport.env.yaml +0 -3
  37. {labcode-0.1.7 → labcode-0.2.0}/tests/test_backend.py +87 -4
  38. {labcode-0.1.7 → labcode-0.2.0}/tests/test_dialect.py +93 -0
  39. {labcode-0.1.7 → labcode-0.2.0}/tests/test_otel_child.py +40 -0
  40. labcode-0.2.0/tests/test_otel_grpc.py +153 -0
  41. {labcode-0.1.7 → labcode-0.2.0}/tests/test_recording.py +0 -1
  42. {labcode-0.1.7 → labcode-0.2.0}/tests/test_run_cli.py +40 -2
  43. {labcode-0.1.7 → labcode-0.2.0}/tests/test_sila2_instrument.py +52 -2
  44. labcode-0.1.7/labcode/otel_sila2.py +0 -68
  45. labcode-0.1.7/labcode.egg-info/scm_version.json +0 -8
  46. {labcode-0.1.7 → labcode-0.2.0}/.github/workflows/ci.yml +0 -0
  47. {labcode-0.1.7 → labcode-0.2.0}/.github/workflows/publish.yml +0 -0
  48. {labcode-0.1.7 → labcode-0.2.0}/LICENSE +0 -0
  49. {labcode-0.1.7 → labcode-0.2.0}/MANIFEST.in +0 -0
  50. {labcode-0.1.7 → labcode-0.2.0}/SPECIFICATIONS.md +0 -0
  51. {labcode-0.1.7 → labcode-0.2.0}/examples/outputs/plate_line.boundary.yaml +0 -0
  52. {labcode-0.1.7 → labcode-0.2.0}/examples/outputs/plate_line.observation.yaml +0 -0
  53. {labcode-0.1.7 → labcode-0.2.0}/examples/outputs/plate_line.plan.yaml +0 -0
  54. {labcode-0.1.7 → labcode-0.2.0}/examples/outputs/plate_line.svg +0 -0
  55. {labcode-0.1.7 → labcode-0.2.0}/examples/outputs/sila2_plate_cycle.boundary.yaml +0 -0
  56. {labcode-0.1.7 → labcode-0.2.0}/examples/outputs/sila2_plate_cycle.observation.yaml +0 -0
  57. {labcode-0.1.7 → labcode-0.2.0}/examples/outputs/sila2_plate_cycle.plan.yaml +0 -0
  58. {labcode-0.1.7 → labcode-0.2.0}/examples/outputs/sila2_plate_cycle.svg +0 -0
  59. {labcode-0.1.7 → labcode-0.2.0}/examples/plate_line.boundary.yaml +0 -0
  60. {labcode-0.1.7 → labcode-0.2.0}/examples/plate_line.workflow.yaml +0 -0
  61. {labcode-0.1.7 → labcode-0.2.0}/examples/render_plate_line.py +0 -0
  62. {labcode-0.1.7 → labcode-0.2.0}/examples/render_sila2_plate_cycle.py +0 -0
  63. {labcode-0.1.7 → labcode-0.2.0}/examples/run_sila2_plate_cycle.py +0 -0
  64. {labcode-0.1.7 → labcode-0.2.0}/examples/run_sila2_seal.py +0 -0
  65. {labcode-0.1.7 → labcode-0.2.0}/examples/sila2_plate_cycle.boundary.yaml +0 -0
  66. {labcode-0.1.7 → labcode-0.2.0}/examples/sila2_plate_cycle.workflow.yaml +0 -0
  67. {labcode-0.1.7 → labcode-0.2.0}/examples/sila2_seal.boundary.yaml +0 -0
  68. {labcode-0.1.7 → labcode-0.2.0}/examples/sila2_seal.workflow.yaml +0 -0
  69. {labcode-0.1.7 → labcode-0.2.0}/labcode/__init__.py +0 -0
  70. {labcode-0.1.7 → labcode-0.2.0}/labcode/__main__.py +0 -0
  71. {labcode-0.1.7 → labcode-0.2.0}/labcode/cli.py +0 -0
  72. {labcode-0.1.7 → labcode-0.2.0}/labcode/idgen.py +0 -0
  73. {labcode-0.1.7 → labcode-0.2.0}/labcode/objectid.py +0 -0
  74. {labcode-0.1.7 → labcode-0.2.0}/labcode/py.typed +0 -0
  75. {labcode-0.1.7 → labcode-0.2.0}/labcode/sila2.py +0 -0
  76. {labcode-0.1.7 → labcode-0.2.0}/labcode/sila2_commands.py +0 -0
  77. {labcode-0.1.7 → labcode-0.2.0}/labcode.egg-info/dependency_links.txt +0 -0
  78. {labcode-0.1.7 → labcode-0.2.0}/labcode.egg-info/entry_points.txt +0 -0
  79. {labcode-0.1.7 → labcode-0.2.0}/labcode.egg-info/top_level.txt +0 -0
  80. {labcode-0.1.7 → labcode-0.2.0}/setup.cfg +0 -0
  81. {labcode-0.1.7 → labcode-0.2.0}/tests/fixtures/device_script.workflow.yaml +0 -0
  82. {labcode-0.1.7 → labcode-0.2.0}/tests/fixtures/transport.workflow.yaml +0 -0
  83. {labcode-0.1.7 → labcode-0.2.0}/tests/test_cli.py +0 -0
  84. {labcode-0.1.7 → labcode-0.2.0}/tests/test_objectid.py +0 -0
  85. {labcode-0.1.7 → labcode-0.2.0}/tests/test_otel.py +0 -0
  86. {labcode-0.1.7 → labcode-0.2.0}/tests/test_otel_sila2.py +0 -0
  87. {labcode-0.1.7 → labcode-0.2.0}/tests/test_probe.py +0 -0
  88. {labcode-0.1.7 → labcode-0.2.0}/tests/test_record.py +0 -0
  89. {labcode-0.1.7 → labcode-0.2.0}/tests/test_sila2.py +0 -0
  90. {labcode-0.1.7 → labcode-0.2.0}/tests/test_sila2_commands.py +0 -0
@@ -12,3 +12,6 @@ venv/
12
12
  # Local working notes, not part of the published project (until explicitly committed).
13
13
  CLAUDE.md
14
14
  dev-notes/
15
+
16
+ # The bench-specific environments name real hosts and ports; this repository is public.
17
+ *.remote.env.yaml
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labcode
3
- Version: 0.1.7
3
+ Version: 0.2.0
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
@@ -21,22 +21,25 @@ Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: ofplang-validate>=0.1.3
24
- Requires-Dist: ofplang-schedule>=0.1.3
25
- Requires-Dist: ofplang-run>=0.1.13
24
+ Requires-Dist: ofplang-schedule>=0.2.3
25
+ Requires-Dist: ofplang-run>=0.3.0
26
26
  Provides-Extra: test
27
27
  Requires-Dist: pytest>=7.0; extra == "test"
28
28
  Requires-Dist: opentelemetry-sdk>=1.20; extra == "test"
29
+ Requires-Dist: opentelemetry-instrumentation-grpc[instruments]>=0.41b0; extra == "test"
29
30
  Provides-Extra: dev
30
31
  Requires-Dist: pytest>=7.0; extra == "dev"
31
32
  Requires-Dist: ruff>=0.16; extra == "dev"
32
33
  Requires-Dist: mypy>=1.11; extra == "dev"
33
34
  Requires-Dist: opentelemetry-sdk>=1.20; extra == "dev"
35
+ Requires-Dist: opentelemetry-instrumentation-grpc[instruments]>=0.41b0; extra == "dev"
34
36
  Provides-Extra: sila2
35
37
  Requires-Dist: sila2>=0.14; extra == "sila2"
36
38
  Provides-Extra: otel
37
39
  Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
38
40
  Requires-Dist: opentelemetry-sdk>=1.20; extra == "otel"
39
41
  Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20; extra == "otel"
42
+ Requires-Dist: opentelemetry-instrumentation-grpc>=0.41b0; extra == "otel"
40
43
  Dynamic: license-file
41
44
 
42
45
  # labcode
@@ -110,9 +113,9 @@ What `lc run` brings of its own, beyond dispatching:
110
113
  (§1.6). The client library itself is the `sila2` extra: `pip install labcode[sila2]`,
111
114
  installed into whichever interpreter runs the scripts.
112
115
  - **recording a run** — with `--trace`, what the run did is recorded as OpenTelemetry
113
- traces: one trace per run, a span per operation, and a span per SiLA2 connection and
114
- command measured inside the process that issued it. Off by default; the extra is
115
- `pip install labcode[otel]`.
116
+ traces: one trace per run, a span per operation, and measured inside the process that
117
+ issued them a span per SiLA2 connection, per command, and per gRPC call each of those
118
+ made. Off by default; the extra is `pip install labcode[otel]`.
116
119
 
117
120
  ## Usage
118
121
 
@@ -136,7 +139,7 @@ described below.
136
139
  lc run <workflow> --env <env>
137
140
  [--boundary DOC] [-o OUT] [--boundary-out FILE] [--observation-out FILE]
138
141
  [--seconds-per-tick S] [--op-timeout S | --no-op-timeout] [--no-probe]
139
- [--trace] [--mission-id ID] [--object-ids seeded|real]
142
+ [--ignore-resources] [--trace] [--mission-id ID] [--object-ids seeded|real]
140
143
  ```
141
144
 
142
145
  - `<workflow>` — the portable v0 workflow: *what* happens.
@@ -146,7 +149,12 @@ lc run <workflow> --env <env>
146
149
  `{spot, view}` descriptor per entry input / final output port. `spot` says where a
147
150
  boundary Object sits; `view` supplies an input's value. A workflow with Object-bearing
148
151
  entry inputs needs one, since each must be placed on a spot and only the operator knows
149
- where the labware is.
152
+ where the labware is. Where a device declares a consumable and some mode draws on it,
153
+ an `inventories: {levels: ...}` section says what each stock holds **at the start of
154
+ the run** — the level later on is never stated, it is worked out from that and what the
155
+ run has done since. It is not echoed into `--boundary-out`, because that document is
156
+ written to be fed back and the next run would take this run's opening stock for its
157
+ own.
150
158
  - `-o OUT` — write the final execution status (spec §6/§7) here; the default is stdout.
151
159
  - `--boundary-out FILE` — write the result boundary: the same schema as `--boundary`, with
152
160
  each produced output's `view` filled in, including the `_id` its Object was minted with
@@ -164,6 +172,9 @@ lc run <workflow> --env <env>
164
172
  else 7200 real seconds. The two forms exclude each other.
165
173
  - `--no-probe` — ignore the environment's `x-labcode.probe` policies and treat every
166
174
  machine as reachable (§1.5). The documents are still validated.
175
+ - `--ignore-resources` — switch the consumable model off. The environment's resource
176
+ declarations are still checked for shape but none is applied, so a bench whose devices
177
+ declare stocks nobody is tracking runs without the boundary saying what they held.
167
178
  - `--trace` — record what the run did (see below). Off by default.
168
179
  - `--mission-id ID` — the campaign this run belongs to. Recorded with the run and given no
169
180
  meaning by labcode: several runs may share one, and nothing here reads it back.
@@ -187,6 +198,49 @@ The remaining options tune the replan loop rather than describe the run —
187
198
  `--poll-interval`, `--margin`, `--seed`, `--speed`, `--max-ticks`, `--no-validate` — and
188
199
  are covered by `lc run --help`.
189
200
 
201
+ ### Refilling a stock
202
+
203
+ Where a device declares a consumable and the environment says a replenisher can reach it,
204
+ a stock that would run out is **topped up instead of ending the run**. The procedure goes
205
+ on the `replenishments[]` route — the pair is what has a procedure, while the machine has
206
+ only an address, the same division `transporters` and `transports` have:
207
+
208
+ ```yaml
209
+ replenishers:
210
+ - id: dispenser
211
+ x-labcode:
212
+ connection: { kind: sila2, host: 10.0.0.9, port: 50055, insecure: true }
213
+
214
+ replenishments:
215
+ - replenisher: dispenser
216
+ device: reader
217
+ duration: 4 # ticks: the scheduler's estimate of the visit
218
+ x-labcode:
219
+ script:
220
+ language: python
221
+ code: |
222
+ import time
223
+ time.sleep(80) # real seconds: what the visit actually takes
224
+ ```
225
+
226
+ The script is handed `replenisher`, `device` and the `amounts` the scheduler derived, and
227
+ is expected to put that in. It is **not** handed the duration: a real refill takes as long
228
+ as it takes, so a stand-in says so in its own code — which is why the two numbers above
229
+ are written separately. Like a transport script it returns nothing; it acts.
230
+
231
+ A route with no script runs as a timed visit: both machines are held for the declared
232
+ duration and nothing is commanded. That is a real thing to write (an operator tops the
233
+ stock up while the schedule waits for them) and an easy one to write by accident, so it is
234
+ warned about.
235
+
236
+ `flavor: sila2` is **refused on a refill route** for now: a sila2 script is handed clients,
237
+ and which machine's clients a refill should receive — the replenisher's, or both ends' as a
238
+ transport may ask for — is not settled. Use `python`.
239
+
240
+ A refill holds the device it fills *and* the replenisher filling it, so it never overlaps
241
+ the work it feeds. It is recorded (`--trace`) as a `replenishment` span naming both
242
+ machines.
243
+
190
244
  ### Recording a run
191
245
 
192
246
  `--trace` records what the run did as OpenTelemetry traces. It needs the extra, in the
@@ -198,14 +252,17 @@ pip install 'labcode[otel]'
198
252
  lc run <workflow> --env <env> --trace --mission-id M-2026-001
199
253
  ```
200
254
 
201
- One run is one trace, and the id it can be found by is printed to stderr as the run starts:
255
+ One run is one trace, and the id it can be found by is printed to stderr as the run
256
+ starts (`lc run: recording this run as trace …`). What it holds:
202
257
 
203
258
  ```text
204
259
  run mission.id, and the failure if it stopped on one
205
260
  ├─ process Seal which node, process and mode; the plan's interval;
206
261
  │ │ which Objects it handled
207
262
  │ ├─ sila2.connect the address, measured in the process that connected
263
+ │ │ └─ /…/SiLAService/GetFeatureDefinition one per feature, × however many
208
264
  │ └─ sila2 SealerControl.Seal the command, from its start to its real completion
265
+ │ └─ /…/SealerControl/Seal the round trip that started it
209
266
  └─ transport the route and the transporter
210
267
  ```
211
268
 
@@ -215,6 +272,16 @@ in the command spans, which are measured where the commands are issued. `ofp.obj
215
272
  lists the `_id`s an operation handled, including one it created — which is what makes
216
273
  "everything that happened to this plate" a single query.
217
274
 
275
+ The innermost layer is the gRPC calls themselves, each under the connection or the command
276
+ that issued it — so what a connection spends is broken down into the feature definitions it
277
+ had to fetch, and a command's span separates its round trip from the time the instrument
278
+ then took. It needs `grpcio`, which arrives with the `sila2` extra; without it the record is
279
+ the same minus that layer. Two things it does not do: an observable command's
280
+ execution-info subscription is **not** recorded (it is read on a thread of `sila2`'s own,
281
+ where it would land in a trace of its own, and its duration is the command's anyway), and
282
+ each recorded call **sends the trace context to the instrument** in its gRPC metadata, which
283
+ a SiLA2 server ignores as it does any key that is not SiLA Client Metadata.
284
+
218
285
  Where the record goes is configured by the **standard `OTEL_*` environment variables**
219
286
  (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_SERVICE_NAME`, …), so
220
287
  labcode adds no settings of its own; `service.name` falls back to `labcode` if nothing sets
@@ -69,9 +69,9 @@ What `lc run` brings of its own, beyond dispatching:
69
69
  (§1.6). The client library itself is the `sila2` extra: `pip install labcode[sila2]`,
70
70
  installed into whichever interpreter runs the scripts.
71
71
  - **recording a run** — with `--trace`, what the run did is recorded as OpenTelemetry
72
- traces: one trace per run, a span per operation, and a span per SiLA2 connection and
73
- command measured inside the process that issued it. Off by default; the extra is
74
- `pip install labcode[otel]`.
72
+ traces: one trace per run, a span per operation, and measured inside the process that
73
+ issued them a span per SiLA2 connection, per command, and per gRPC call each of those
74
+ made. Off by default; the extra is `pip install labcode[otel]`.
75
75
 
76
76
  ## Usage
77
77
 
@@ -95,7 +95,7 @@ described below.
95
95
  lc run <workflow> --env <env>
96
96
  [--boundary DOC] [-o OUT] [--boundary-out FILE] [--observation-out FILE]
97
97
  [--seconds-per-tick S] [--op-timeout S | --no-op-timeout] [--no-probe]
98
- [--trace] [--mission-id ID] [--object-ids seeded|real]
98
+ [--ignore-resources] [--trace] [--mission-id ID] [--object-ids seeded|real]
99
99
  ```
100
100
 
101
101
  - `<workflow>` — the portable v0 workflow: *what* happens.
@@ -105,7 +105,12 @@ lc run <workflow> --env <env>
105
105
  `{spot, view}` descriptor per entry input / final output port. `spot` says where a
106
106
  boundary Object sits; `view` supplies an input's value. A workflow with Object-bearing
107
107
  entry inputs needs one, since each must be placed on a spot and only the operator knows
108
- where the labware is.
108
+ where the labware is. Where a device declares a consumable and some mode draws on it,
109
+ an `inventories: {levels: ...}` section says what each stock holds **at the start of
110
+ the run** — the level later on is never stated, it is worked out from that and what the
111
+ run has done since. It is not echoed into `--boundary-out`, because that document is
112
+ written to be fed back and the next run would take this run's opening stock for its
113
+ own.
109
114
  - `-o OUT` — write the final execution status (spec §6/§7) here; the default is stdout.
110
115
  - `--boundary-out FILE` — write the result boundary: the same schema as `--boundary`, with
111
116
  each produced output's `view` filled in, including the `_id` its Object was minted with
@@ -123,6 +128,9 @@ lc run <workflow> --env <env>
123
128
  else 7200 real seconds. The two forms exclude each other.
124
129
  - `--no-probe` — ignore the environment's `x-labcode.probe` policies and treat every
125
130
  machine as reachable (§1.5). The documents are still validated.
131
+ - `--ignore-resources` — switch the consumable model off. The environment's resource
132
+ declarations are still checked for shape but none is applied, so a bench whose devices
133
+ declare stocks nobody is tracking runs without the boundary saying what they held.
126
134
  - `--trace` — record what the run did (see below). Off by default.
127
135
  - `--mission-id ID` — the campaign this run belongs to. Recorded with the run and given no
128
136
  meaning by labcode: several runs may share one, and nothing here reads it back.
@@ -146,6 +154,49 @@ The remaining options tune the replan loop rather than describe the run —
146
154
  `--poll-interval`, `--margin`, `--seed`, `--speed`, `--max-ticks`, `--no-validate` — and
147
155
  are covered by `lc run --help`.
148
156
 
157
+ ### Refilling a stock
158
+
159
+ Where a device declares a consumable and the environment says a replenisher can reach it,
160
+ a stock that would run out is **topped up instead of ending the run**. The procedure goes
161
+ on the `replenishments[]` route — the pair is what has a procedure, while the machine has
162
+ only an address, the same division `transporters` and `transports` have:
163
+
164
+ ```yaml
165
+ replenishers:
166
+ - id: dispenser
167
+ x-labcode:
168
+ connection: { kind: sila2, host: 10.0.0.9, port: 50055, insecure: true }
169
+
170
+ replenishments:
171
+ - replenisher: dispenser
172
+ device: reader
173
+ duration: 4 # ticks: the scheduler's estimate of the visit
174
+ x-labcode:
175
+ script:
176
+ language: python
177
+ code: |
178
+ import time
179
+ time.sleep(80) # real seconds: what the visit actually takes
180
+ ```
181
+
182
+ The script is handed `replenisher`, `device` and the `amounts` the scheduler derived, and
183
+ is expected to put that in. It is **not** handed the duration: a real refill takes as long
184
+ as it takes, so a stand-in says so in its own code — which is why the two numbers above
185
+ are written separately. Like a transport script it returns nothing; it acts.
186
+
187
+ A route with no script runs as a timed visit: both machines are held for the declared
188
+ duration and nothing is commanded. That is a real thing to write (an operator tops the
189
+ stock up while the schedule waits for them) and an easy one to write by accident, so it is
190
+ warned about.
191
+
192
+ `flavor: sila2` is **refused on a refill route** for now: a sila2 script is handed clients,
193
+ and which machine's clients a refill should receive — the replenisher's, or both ends' as a
194
+ transport may ask for — is not settled. Use `python`.
195
+
196
+ A refill holds the device it fills *and* the replenisher filling it, so it never overlaps
197
+ the work it feeds. It is recorded (`--trace`) as a `replenishment` span naming both
198
+ machines.
199
+
149
200
  ### Recording a run
150
201
 
151
202
  `--trace` records what the run did as OpenTelemetry traces. It needs the extra, in the
@@ -157,14 +208,17 @@ pip install 'labcode[otel]'
157
208
  lc run <workflow> --env <env> --trace --mission-id M-2026-001
158
209
  ```
159
210
 
160
- One run is one trace, and the id it can be found by is printed to stderr as the run starts:
211
+ One run is one trace, and the id it can be found by is printed to stderr as the run
212
+ starts (`lc run: recording this run as trace …`). What it holds:
161
213
 
162
214
  ```text
163
215
  run mission.id, and the failure if it stopped on one
164
216
  ├─ process Seal which node, process and mode; the plan's interval;
165
217
  │ │ which Objects it handled
166
218
  │ ├─ sila2.connect the address, measured in the process that connected
219
+ │ │ └─ /…/SiLAService/GetFeatureDefinition one per feature, × however many
167
220
  │ └─ sila2 SealerControl.Seal the command, from its start to its real completion
221
+ │ └─ /…/SealerControl/Seal the round trip that started it
168
222
  └─ transport the route and the transporter
169
223
  ```
170
224
 
@@ -174,6 +228,16 @@ in the command spans, which are measured where the commands are issued. `ofp.obj
174
228
  lists the `_id`s an operation handled, including one it created — which is what makes
175
229
  "everything that happened to this plate" a single query.
176
230
 
231
+ The innermost layer is the gRPC calls themselves, each under the connection or the command
232
+ that issued it — so what a connection spends is broken down into the feature definitions it
233
+ had to fetch, and a command's span separates its round trip from the time the instrument
234
+ then took. It needs `grpcio`, which arrives with the `sila2` extra; without it the record is
235
+ the same minus that layer. Two things it does not do: an observable command's
236
+ execution-info subscription is **not** recorded (it is read on a thread of `sila2`'s own,
237
+ where it would land in a trace of its own, and its duration is the command's anyway), and
238
+ each recorded call **sends the trace context to the instrument** in its gRPC metadata, which
239
+ a SiLA2 server ignores as it does any key that is not SiLA Client Metadata.
240
+
177
241
  Where the record goes is configured by the **standard `OTEL_*` environment variables**
178
242
  (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_SERVICE_NAME`, …), so
179
243
  labcode adds no settings of its own; `service.name` falls back to `labcode` if nothing sets
@@ -185,11 +185,11 @@ polls forever. A script that knows its own commands should still bound them itse
185
185
 
186
186
  ### Prerequisites
187
187
 
188
- **Verified against [ofplang-sila2-backend](https://github.com/kaizu/sila2-demo) branch `ardea`
189
- (commit `de3c4fd`)** — a virtual lab of mock SiLA2 instrument servers. That branch is where its
188
+ **Verified against [ofplang-sila2-backend](https://github.com/ofplang/sila2-backend) at commit
189
+ `de3c4fd`** — a virtual lab of mock SiLA2 instrument servers. That commit is where its
190
190
  transporter became a mock of **Ardea**, a machine that exists, serving the real one's nine
191
- Feature definitions unchanged; it is not merged, so a checkout of `main` will not run these
192
- examples. That lab is a *reference, not a
191
+ Feature definitions unchanged; it reached `main` in the `ardea` merge, so a checkout of `main`
192
+ runs these examples. That lab is a *reference, not a
193
193
  requirement*: the scripts speak plain SiLA2, so pointing them at real instruments is a matter
194
194
  of changing the host and port in the environment. The version is recorded so a run without
195
195
  hardware has something known to reproduce against; it is deliberately not asserted on, since
@@ -417,14 +417,122 @@ scripts issue real commands. The committed copies were produced on the realistic
417
417
  times vary between runs — by tens of seconds, now that five 30 s transfers dominate — while the
418
418
  sequence, the identities and the produced values do not.
419
419
 
420
+ ## `sila2_plate_cycle_no_atc` — the same circuit, on a bench with no thermal cycler
421
+
422
+ The full circuit above needs an automated thermal cycler. When there is not one to use, the
423
+ workflow that goes to the bench is this one — `sila2_plate_cycle` with that step removed and the
424
+ rest in the order a bench with an **unsealed** plate can run them:
425
+
426
+ ```
427
+ (in) plate ──▶ seal ──▶ rotate ──▶ peel ──▶ (out) plate
428
+ │ │
429
+ (out) cycle_count (out) tape_left
430
+ ```
431
+
432
+ Seal, spin, peel — so the plate ends the circuit in the state it began it, and the run repeats
433
+ without anyone reconditioning a plate in between. That is the same property the round trip has
434
+ for the plate's *location*, applied to its condition; peeling first would need a sealed plate to
435
+ start from and hand back a sealed one.
436
+
437
+ - [`sila2_plate_cycle_no_atc.workflow.yaml`](sila2_plate_cycle_no_atc.workflow.yaml) is the
438
+ circuit minus one node, with the remaining three in the new order: the plate is sealed, spun
439
+ down and unsealed, and the *same* plate comes back to `station.slot1`.
440
+ - [`sila2_plate_cycle_no_atc.wrapped.env.yaml`](sila2_plate_cycle_no_atc.wrapped.env.yaml)
441
+ drops the `thermal_cycler` device, its routes and the `thermal_cycle` mode, and joins four
442
+ different pairs of places: to the sealer, to the centrifuge, to the peeler, home. Only the
443
+ centrifuge has a door, so only the two routes at its ends open anything. Every instrument
444
+ script and every duration is the full circuit's, unchanged.
445
+ - [`sila2_plate_cycle_no_atc.boundary.yaml`](sila2_plate_cycle_no_atc.boundary.yaml) is the
446
+ full circuit's boundary without `elapsed_time` — that reading was the cycler's.
447
+
448
+ Nothing connects to the cycler, so the run does not care whether that server is up: the check
449
+ asserts it was never scheduled, which is what makes a pass here evidence for the bench it was
450
+ written for rather than for the full lab.
451
+
452
+ ```sh
453
+ python examples/run_sila2_plate_cycle_no_atc.py
454
+ ```
455
+
456
+ Same prerequisites and conventions as `sila2_plate_cycle` (the lab up, `sila2` importable, one
457
+ plate on `station.slot1`, exit code 0 means every check passed, `--artifacts DIR` keeps the
458
+ documents). It checks the same things minus the cycler's reading: every activity completed,
459
+ four transports and three instrument steps, both readings real, the plate home at
460
+ `station.slot1`, and all three instruments handling the plate with the same `_id`.
461
+
462
+ Verified against the reference lab on both timing profiles at `--seconds-per-tick 1.0`. On
463
+ `command_durations.realistic.yaml` the circuit is a makespan of about 190–220 (two ops fewer
464
+ than the full one's 260–290); on the default profile every op finishes early and the run still
465
+ completes. Running it twice in a row is the more interesting pass: the second run starts with
466
+ the centrifuge closed by the first, so it only gets anywhere if the transport really can open
467
+ the door.
468
+
469
+ `--trace` records the run (`labcode.record`) and prints its trace id, so the SiLA2 commands, the
470
+ client builds and the poll waits *inside* each operation are separable — which is what to look
471
+ at when a duration turns out to be wrong. The exporter is configured by OpenTelemetry's own
472
+ environment variables:
473
+
474
+ ```sh
475
+ OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 \
476
+ python examples/run_sila2_plate_cycle_no_atc.py --trace --artifacts run1
477
+ ```
478
+
479
+ One thing to get right first: **point the exporter at `127.0.0.1`, not `localhost`.** Where the
480
+ collector binds IPv4 only, `localhost` can resolve to `::1` first, and the exporter's retry
481
+ backoff then stalls whichever operation was exporting. Measured on the reference lab at
482
+ `--seconds-per-tick 1.0`: makespan 216 untraced, **219** traced via `127.0.0.1` (so recording
483
+ itself costs next to nothing), **279** traced via `localhost` — one 39 s move became 95 s, and
484
+ the run reported a 54 s poll cycle. Both traced runs recorded the same 114 spans, so the trace
485
+ was not the poorer for it; only the schedule was.
486
+
487
+ Even so, take a duration from an *untraced* run and use the trace to explain it. Recording puts
488
+ an exporter in every operation's child process, and an operation's measured time is exactly what
489
+ these numbers are for.
490
+
420
491
  ### Run every SiLA2 example
421
492
 
422
493
  ```sh
423
494
  python examples/run_all_sila2_examples.py
424
495
  ```
425
496
 
426
- Runs each environment above in turn — both `sila2_seal` environments and `sila2_plate_cycle`
427
- (they are round trips that put the plate back where it started, and each opens whatever it
428
- needs open, so they follow one another without intervention) — prints a pass/fail summary, and
429
- exits non-zero if any failed. Only the examples
497
+ Runs each environment above in turn — both `sila2_seal` environments, `sila2_plate_cycle` and
498
+ `sila2_plate_cycle_no_atc` (they are round trips that put the plate back where it started, and
499
+ each opens whatever it needs open, so they follow one another without intervention) — prints a
500
+ pass/fail summary, and exits non-zero if any failed. Only the examples
430
501
  that need the lab are included; `render_plate_line.py` needs nothing but Python.
502
+
503
+ ## Taking one of these to a bench
504
+
505
+ The examples above point at the reference lab because it is what CI and a laptop can run. An
506
+ environment that speaks plain SiLA2 goes to real instruments by **changing hosts and ports and
507
+ nothing else** — so a bench environment is best written as a copy of the mock one with the
508
+ addresses replaced, and nothing else touched. Then `diff` says what is bench-specific.
509
+
510
+ `*.remote.env.yaml` is **git-ignored**: this repository is public, and a bench's host/port
511
+ inventory is not something to publish by accident. Keep the copy local, or commit it
512
+ deliberately.
513
+
514
+ ### Check the bench before commanding anything
515
+
516
+ [`preflight_sila2_env.py`](preflight_sila2_env.py) takes any labcode environment and checks it
517
+ against the machines it names, **issuing no SiLA2 command**:
518
+
519
+ ```sh
520
+ python examples/preflight_sila2_env.py --env examples/<your>.remote.env.yaml
521
+ ```
522
+
523
+ Everything it does is a read — building labcode's own client (which fetches every Feature
524
+ definition, and is where TLS, a wrong port and a half-open server show up), the `SiLAService`
525
+ properties every server serves, and the transporter's non-observable
526
+ `CarriageService.StationNames`. Nothing moves, nothing opens, nothing starts. It reports
527
+
528
+ - each machine's identity and the Features it serves;
529
+ - whether every Feature the environment's **scripts** name is actually served — a script
530
+ reaching for a Feature the server does not implement otherwise fails mid-run, with a plate
531
+ somewhere;
532
+ - whether every station name the transport scripts use is one the transporter **knows** — an
533
+ unknown one fails with `InvalidStation`.
534
+
535
+ What it cannot check is the one thing the hardware will not catch either: whether a station
536
+ name the machine *does* know is the place the workflow means. `Base4` that is the sealer on one
537
+ bench and the cycler on another does not fail — it puts a plate somewhere else. That mapping
538
+ has to be confirmed against the bench, and it is the thing to confirm before a first run.
@@ -141,6 +141,3 @@ processes:
141
141
  code: |
142
142
  # `plate` is the incoming Plate's view; storing is side-effect only.
143
143
  return {}
144
-
145
- objective:
146
- kind: makespan