dsh-labscript 0.2.7 → 0.2.8
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.
- package/CHANGELOG.md +31 -0
- package/README.md +23 -5
- package/docs/README.zh.md +13 -5
- package/lib/skill.js +13 -2
- package/lib/tools.js +94 -32
- package/package.json +1 -1
- package/python/bridge.py +81 -22
- package/python/tests/run_tests.py +154 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.8
|
|
4
|
+
|
|
5
|
+
Fixes from a real labscript-suite session (2026-11-14, 145 tool calls, no run submitted by the
|
|
6
|
+
plugin). Each defect was reproduced against the stock runmanager source before the fix.
|
|
7
|
+
|
|
8
|
+
- Fix: labs_set failed with "TypeError: 'NoneType' object is not iterable" after the write had
|
|
9
|
+
already landed. runmanager's set_globals RPC is a side-effect call whose handler returns None,
|
|
10
|
+
and the bridge fed that reply to dict(). It now reports the requested values and lets the
|
|
11
|
+
readback decide success. When a write really fails, the error carries `landed` (what the panel
|
|
12
|
+
holds now) and the hint says to read the keys back instead of claiming the panel may be half
|
|
13
|
+
written and pointing at labs_restore.
|
|
14
|
+
- Fix: labs_wait blocked for the whole timeout when compilation aborted. It returns after
|
|
15
|
+
engage.noShotS seconds (default 25) with diagnosis no_shots (no shot file appeared at all) or
|
|
16
|
+
compile_stalled (files stopped appearing), so the runmanager compile error can be read instead
|
|
17
|
+
of waiting 5-15 minutes. The hint says to call labs_wait again if a slow compile is expected.
|
|
18
|
+
- Fix: a pending run that never produced a shot blocked labs_engage forever. A pending submission
|
|
19
|
+
with zero shots and older than engage.staleS (default 180 s) is dropped and reported as
|
|
20
|
+
dropped_stale_pending; the RUN_PENDING error now states the run's age and both ways out.
|
|
21
|
+
- Fix: with pathMap configured, labs_wait(run_folder=<the folder labs_engage printed>) failed with
|
|
22
|
+
RUN_FOLDER_MISSING: the pending run kept the unmapped path and the wait compared strings. Pending
|
|
23
|
+
state now stores the mapped folder (raw kept as remote_run_folder) and both forms match.
|
|
24
|
+
- Fix: shot h5 files that only hold compiled globals were counted as progress, so h5=1/64 looked
|
|
25
|
+
like "a shot ran". Progress reports n_h5 (files) and n_executed (shots with acquisition data,
|
|
26
|
+
located by binary search over the file order), completion requires acquisition data in the last
|
|
27
|
+
shot, and labs_status renders executed=.
|
|
28
|
+
- labs_protocol show prints the closest key names for plan keys missing from the loaded labscript
|
|
29
|
+
(previously only the run path did), so stale protocols can be renamed without guessing.
|
|
30
|
+
- labs_set description: one call can mix scalars, an expression scan axis and reps=N.
|
|
31
|
+
- Tests: 28 python + 46 node, adding a client whose set_globals returns None, a stalled
|
|
32
|
+
compilation, a stale zero-shot pending run, mapped/raw pending folders and the show suggestions.
|
|
33
|
+
|
|
3
34
|
## 0.2.7
|
|
4
35
|
|
|
5
36
|
- Fix: labs_wait no longer fails with "value is not lossless JSON". Tool results must be
|
package/README.md
CHANGED
|
@@ -103,6 +103,14 @@ already mapped: `labs_status`, `labs_engage`, `labs_wait`, `labs_data` and
|
|
|
103
103
|
`labs_protocol` show local paths, while `run_folder` keeps the raw runmanager
|
|
104
104
|
value next to `mapped_run_folder`.
|
|
105
105
|
|
|
106
|
+
`engage` controls how a submitted run is watched: `timeoutS` (default 900) is the
|
|
107
|
+
total wait budget of one `labs_wait`, `noShotS` (default 25) makes the wait return
|
|
108
|
+
early with a diagnosis when no shot appeared, `staleS` (default 180) drops a pending
|
|
109
|
+
submission that never produced a single shot, `pollS`, `settleS` and `stallS` tune the
|
|
110
|
+
polling and the compiled-not-executed detection.
|
|
111
|
+
|
|
112
|
+
engage: { timeoutS: 900, noShotS: 25, staleS: 180, stallS: 20 }
|
|
113
|
+
|
|
106
114
|
The python interpreter must be able to import numpy and h5py; the real backend
|
|
107
115
|
also needs runmanager (labscript-suite). The plugin never stores credentials:
|
|
108
116
|
zprocess authentication is read by labscript itself from labconfig.
|
|
@@ -125,8 +133,8 @@ is not a physics simulation.
|
|
|
125
133
|
## Development
|
|
126
134
|
|
|
127
135
|
npm install # only runtime dependency: yaml
|
|
128
|
-
npm test # node --test,
|
|
129
|
-
python python/tests/run_tests.py #
|
|
136
|
+
npm test # node --test, 46 tests (fake bridge, no python needed)
|
|
137
|
+
python python/tests/run_tests.py # 28 tests (needs numpy + h5py)
|
|
130
138
|
LABSCRIPT_PYTHON=/path/to/python3 node scripts/demo.mjs # end-to-end mock demo
|
|
131
139
|
|
|
132
140
|
A fresh clone must run `npm install` before `npm test`; the CI workflow does this.
|
|
@@ -153,9 +161,19 @@ A fresh clone must run `npm install` before `npm test`; the CI workflow does thi
|
|
|
153
161
|
explicitly when something must stop; labs_abort also cancels an in-flight labs_wait.
|
|
154
162
|
labscript abort stops compiling/submitting new shots; shots already queued in BLACS
|
|
155
163
|
or running are not stopped.
|
|
156
|
-
- labs_wait reports a diagnosis (complete, no_shots,
|
|
157
|
-
partial).
|
|
158
|
-
|
|
164
|
+
- labs_wait reports a diagnosis (complete, no_shots, compile_stalled, compiling,
|
|
165
|
+
compiled_not_executed, partial). no_shots and compile_stalled come back before the
|
|
166
|
+
timeout when nothing new appeared for engage.noShotS seconds: runmanager probably
|
|
167
|
+
aborted the compilation, so read its output. compiled_not_executed means shots were
|
|
168
|
+
compiled but show no acquisition data yet; BLACS may be paused or not executing the queue.
|
|
169
|
+
- labs_engage only reports that runmanager accepted the submission; compilation errors are
|
|
170
|
+
invisible to it. A pending submission with no shots at all is dropped after
|
|
171
|
+
engage.staleS seconds and reported as dropped_stale_pending, so a dead run does not block
|
|
172
|
+
the next one forever.
|
|
173
|
+
- Shot h5 files exist before they are executed: progress reports n_h5 (files) and
|
|
174
|
+
n_executed (shots with acquisition data), and completion requires acquisition data.
|
|
175
|
+
- The pending run is stored under the pathMap-mapped folder (the raw runmanager path is kept
|
|
176
|
+
as remote_run_folder), so labs_wait accepts whichever of the two an agent quotes back.
|
|
159
177
|
- Real runmanager engage does not return a run folder; the bridge captures the
|
|
160
178
|
output folder before engage and reports it as the run folder. That shot output
|
|
161
179
|
folder is the NEXT run's folder and does not exist until engage, so labs_data
|
package/docs/README.zh.md
CHANGED
|
@@ -11,7 +11,7 @@ hard limits 全部通过配置接入。
|
|
|
11
11
|
|---|---|
|
|
12
12
|
| labs_doctor | 只读诊断:python、numpy/h5py、labscript/runmanager、路径、审批、版本 |
|
|
13
13
|
| labs_status | 读面板:labscript、globals、n_shots、error_in_globals、run_shots、pending 进度 |
|
|
14
|
-
| labs_set | 写面板(标量 / raw 表达式 / reps
|
|
14
|
+
| labs_set | 写面板(标量 / raw 表达式 / reps 可一次混用),写前全量快照;不运行 |
|
|
15
15
|
| labs_engage | 提交当前面板,立即返回 n_shots_at_submit 与 expected_n_shots |
|
|
16
16
|
| labs_wait | 等待 pending run(可超时);若是协议则收尾:postprocess → finish 写回 → restore |
|
|
17
17
|
| labs_abort | 中止当前提交(不排队,不会被 labs_wait 堵住) |
|
|
@@ -28,8 +28,8 @@ hard limits 全部通过配置接入。
|
|
|
28
28
|
真机用 labscript-suite 环境。详见 docs/INSTALL.zh.md。
|
|
29
29
|
- 默认 backend 是 mock-labscript;真机必须显式配置 backend: labscript。
|
|
30
30
|
- 安装后在 DSH 插件页填 backend、python、runmanager host/port、protocolsDir;
|
|
31
|
-
其余高级配置(scriptsFile、hard limits、postprocess、approval、pathMap
|
|
32
|
-
profile 的 cordis.patch.yml。
|
|
31
|
+
其余高级配置(scriptsFile、hard limits、postprocess、approval、pathMap、
|
|
32
|
+
engage 的超时/早退参数)写在 profile 的 cordis.patch.yml。
|
|
33
33
|
|
|
34
34
|
## 协议 YAML
|
|
35
35
|
|
|
@@ -52,6 +52,14 @@ postprocess 是后处理:跑完后调用配置的 Python 函数(entry + pyth
|
|
|
52
52
|
- 真机 engage / protocol run 会请求 DSH 审批(一次),abort 和面板写入不请求;
|
|
53
53
|
插件不是硬件联锁。
|
|
54
54
|
- labscript abort 只停止编译/提交新的 shot,已进 BLACS 队列或正在跑的 shot 不会被停。
|
|
55
|
-
- 完成判定基于 HDF5
|
|
56
|
-
|
|
55
|
+
- 完成判定基于 HDF5 数量、大小稳定和最后一发可读。shot 的 h5 文件在编译阶段就写了出来,
|
|
56
|
+
所以 labs_status 分别报 h5(文件数)和 executed(真正采到数据的枪数),完成要求最后一发
|
|
57
|
+
有采集数据;BLACS paused 会报 compiled_not_executed 诊断。
|
|
58
|
+
- 编译 abort 时 labs_wait 不再等满 timeout:距今没有任何新 shot 超过 engage.noShotS
|
|
59
|
+
(默认 25 s)就返回 no_shots / compile_stalled 诊断,去 runmanager 看编译错误;如果只是
|
|
60
|
+
编译慢,再调一次 labs_wait 继续等。
|
|
61
|
+
- 一条一枪都没产生的 pending 提交,超过 engage.staleS(默认 180 s)会被下一次 labs_engage
|
|
62
|
+
丢弃并报 dropped_stale_pending,不再永久挡住提交;RUN_PENDING 的错误里也会写清年龄和两种出路。
|
|
63
|
+
- 配了 pathMap 时 pending 记录映射后的目录(原始路径保留为 remote_run_folder),所以
|
|
64
|
+
labs_wait 传映射后目录或原始目录都能对上,不会再说"run folder does not exist"。
|
|
57
65
|
|
package/lib/skill.js
CHANGED
|
@@ -21,9 +21,13 @@ export const SKILL_CONTENT = [
|
|
|
21
21
|
'## Running',
|
|
22
22
|
'- labs_set writes the panel. It does not run anything. Always pass note: one sentence',
|
|
23
23
|
' saying what this batch is for (it is written to the event log).',
|
|
24
|
+
'- labs_engage only reports that runmanager accepted the submission; it cannot see',
|
|
25
|
+
' compile errors. If nothing runs, the next labs_wait says so (see Stop rules).',
|
|
24
26
|
'- labs_engage submits a run and returns immediately with n_shots_at_submit and',
|
|
25
27
|
' expected_n_shots. Do not poll in a loop; when the user wants results, call labs_wait.',
|
|
26
28
|
' After submitting you may end the turn or do independent work.',
|
|
29
|
+
'- n_h5 counts shot h5 files, which labscript writes before compiling each shot; only',
|
|
30
|
+
' n_executed (or the diagnosis) says whether shots actually acquired data.',
|
|
27
31
|
'- labs_protocol run submits a fixed protocol; labs_wait finalizes it (postprocess,',
|
|
28
32
|
' finish write-back, panel restore).',
|
|
29
33
|
'- backend defaults to mock-labscript; real hardware is only used when the config says',
|
|
@@ -43,8 +47,15 @@ export const SKILL_CONTENT = [
|
|
|
43
47
|
'',
|
|
44
48
|
'## Stop rules',
|
|
45
49
|
'- error_in_globals true: fix or ask; never engage.',
|
|
46
|
-
'-
|
|
47
|
-
'
|
|
50
|
+
'- labs_wait returns before the timeout with diagnosis no_shots or compile_stalled when no',
|
|
51
|
+
' shot appeared for config.engage.noShotS seconds (default 25). That usually means runmanager',
|
|
52
|
+
' aborted the compilation: read the runmanager output, then labs_abort, or labs_wait again if',
|
|
53
|
+
' you expect a slow compile. A submission that never produced a shot is dropped by the next',
|
|
54
|
+
' labs_engage once it is config.engage.staleS seconds old (default 180) and reported as',
|
|
55
|
+
' dropped_stale_pending.',
|
|
56
|
+
'- diagnosis=compiled_not_executed, or timed_out with h5 files but no acquisition data: the',
|
|
57
|
+
' shots were compiled but BLACS may be paused/not executing. Check BLACS and the panel, then',
|
|
58
|
+
' abort or ask; do not resubmit blindly.',
|
|
48
59
|
'- A run is already pending: wait or abort before submitting another.',
|
|
49
60
|
].join(String.fromCharCode(10))
|
|
50
61
|
|
package/lib/tools.js
CHANGED
|
@@ -30,6 +30,9 @@ function progressLine(run) {
|
|
|
30
30
|
if (run.kind) parts.push('kind=' + run.kind)
|
|
31
31
|
if (run.n_h5 !== undefined) parts.push('h5=' + run.n_h5 + '/' + (run.expected_n_shots || '?'))
|
|
32
32
|
else if (run.expected_n_shots) parts.push('expected=' + run.expected_n_shots)
|
|
33
|
+
// h5 counts files; labscript writes each file before compiling it, so a compiled
|
|
34
|
+
// but not yet executed run shows h5>0 until the first acquisition data appears.
|
|
35
|
+
if (run.n_executed !== undefined) parts.push('executed=' + run.n_executed)
|
|
33
36
|
if (run.complete !== undefined) parts.push('complete=' + run.complete)
|
|
34
37
|
if (run.elapsed_s !== undefined) parts.push('elapsed=' + run.elapsed_s + 's')
|
|
35
38
|
return parts.join(' ')
|
|
@@ -82,6 +85,24 @@ function resultsLine(value) {
|
|
|
82
85
|
return 'results: ' + names.map((group) => group + ' (' + layout[group].length + ' fields)').join(', ')
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
// Protocol keys that the loaded labscript does not define, with the closest names it
|
|
89
|
+
// does define: a renamed panel global is the most common reason a protocol is stale.
|
|
90
|
+
function nameCheckDetail(check) {
|
|
91
|
+
const missing = check && Array.isArray(check.missing) ? check.missing : []
|
|
92
|
+
return missing.map((key) => {
|
|
93
|
+
const suggestions = (check.suggestions || {})[key] || []
|
|
94
|
+
return key + (suggestions.length ? ' (closest: ' + suggestions.join(', ') + ')' : '')
|
|
95
|
+
}).join(', ')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function nameCheckLine(value) {
|
|
99
|
+
const check = value.name_check
|
|
100
|
+
if (!check) return ''
|
|
101
|
+
if (check.checked === false) return 'script key check: not checked (' + (check.reason || 'no script') + ')'
|
|
102
|
+
const detail = nameCheckDetail(check)
|
|
103
|
+
return detail ? 'script key check: MISSING ' + detail : ''
|
|
104
|
+
}
|
|
105
|
+
|
|
85
106
|
function dataGlobalsLines(args, value) {
|
|
86
107
|
const globals = value.globals
|
|
87
108
|
if (!globals) return []
|
|
@@ -166,23 +187,57 @@ async function requireApproval(runtime, exec, toolName, reason, approvalKey) {
|
|
|
166
187
|
}
|
|
167
188
|
}
|
|
168
189
|
|
|
190
|
+
// The pending run may be stored under either path form: labs_engage returns the
|
|
191
|
+
// mapped folder, the bridge also reports the raw runmanager folder, and an agent may
|
|
192
|
+
// quote either one back.
|
|
193
|
+
function pendingFolders(pending) {
|
|
194
|
+
if (!pending) return []
|
|
195
|
+
return [pending.run_folder, pending.mapped_run_folder, pending.remote_run_folder].filter((folder) => Boolean(folder))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isPendingRun(pending, folder) {
|
|
199
|
+
return pendingFolders(pending).includes(folder)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Returns the dropped run description when a dead submission was cleared.
|
|
169
203
|
async function clearStalePending(runtime, exec) {
|
|
170
204
|
const pending = runtime.state.pending
|
|
171
|
-
if (!pending) return
|
|
205
|
+
if (!pending) return null
|
|
172
206
|
if (pending.run_folder) {
|
|
207
|
+
let progress = null
|
|
173
208
|
try {
|
|
174
|
-
|
|
209
|
+
progress = await runtime.runBridge('progress', {
|
|
175
210
|
run_folder: pending.run_folder,
|
|
176
211
|
expected_n_shots: pending.expected_n_shots,
|
|
177
212
|
}, { signal: exec.signal })
|
|
178
|
-
|
|
179
|
-
|
|
213
|
+
} catch {
|
|
214
|
+
progress = null
|
|
215
|
+
}
|
|
216
|
+
if (progress && progress.complete !== true) {
|
|
217
|
+
const ageS = pending.submitted_at ? Math.round((Date.now() - Date.parse(pending.submitted_at)) / 1000) : 0
|
|
218
|
+
const staleS = Number((runtime.config.engage || {}).staleS || 180)
|
|
219
|
+
const nH5 = Number(progress.n_h5 || 0)
|
|
220
|
+
if (progress.folder_exists && nH5 === 0 && ageS >= staleS) {
|
|
221
|
+
// Nothing was ever written for this submission and it is minutes old: the
|
|
222
|
+
// compilation aborted, so the run folder stays empty forever. Drop it and
|
|
223
|
+
// report the drop instead of refusing to submit again.
|
|
224
|
+
const dropped = {
|
|
225
|
+
run_folder: progress.mapped_run_folder || pending.run_folder,
|
|
226
|
+
age_s: ageS,
|
|
227
|
+
expected_n_shots: pending.expected_n_shots,
|
|
228
|
+
}
|
|
229
|
+
runtime.clearPending()
|
|
230
|
+
return dropped
|
|
180
231
|
}
|
|
181
|
-
|
|
182
|
-
|
|
232
|
+
const next = nH5 === 0
|
|
233
|
+
? ' A submission with no shots is dropped automatically after ' + staleS + ' s, or call labs_abort now.'
|
|
234
|
+
: ' Call labs_wait to inspect it, or labs_abort to abandon it.'
|
|
235
|
+
throw new LabError('RUN_PENDING', 'a run is still in progress (' + nH5 + '/' + (pending.expected_n_shots || '?') +
|
|
236
|
+
' shots, ' + ageS + ' s old):' + next)
|
|
183
237
|
}
|
|
184
238
|
}
|
|
185
239
|
runtime.clearPending()
|
|
240
|
+
return null
|
|
186
241
|
}
|
|
187
242
|
|
|
188
243
|
function mockApprovalSkipped(runtime) {
|
|
@@ -227,13 +282,13 @@ export function registerTools(ctx, runtime) {
|
|
|
227
282
|
|
|
228
283
|
ctx.tools.register({
|
|
229
284
|
name: 'labs_set',
|
|
230
|
-
description: 'Write runmanager panel globals. values are JSON scalars (bool/number/string);
|
|
285
|
+
description: 'Write runmanager panel globals. One call can do the whole step: values are JSON scalars (bool/number/string); with raw=true a string value is written as an expression (e.g. np.arange(0.5, 6.7, 0.2)) while bool/number values are still converted normally (true -> True, 2.72 -> 2.72), so scalars, an expression scan axis and reps=N can be combined. reps=N writes the configured repetition global as np.arange(N). A full panel snapshot is taken before the write. Does not run anything.',
|
|
231
286
|
parameters: {
|
|
232
287
|
type: 'object',
|
|
233
288
|
properties: {
|
|
234
289
|
values: { type: 'object', additionalProperties: true, description: 'Panel globals to write: key -> scalar' },
|
|
235
|
-
raw: { type: 'boolean', description: 'values are
|
|
236
|
-
reps: { type: 'integer', minimum: 1, description: '
|
|
290
|
+
raw: { type: 'boolean', description: 'String values are written as expressions; bool/number values are still converted (mix them freely with reps=N)' },
|
|
291
|
+
reps: { type: 'integer', minimum: 1, description: 'Also write the configured repetition global as np.arange(reps)' },
|
|
237
292
|
note: { type: 'string', description: 'One sentence: what this batch is for (written to the event log)' },
|
|
238
293
|
},
|
|
239
294
|
required: ['values'],
|
|
@@ -257,7 +312,7 @@ export function registerTools(ctx, runtime) {
|
|
|
257
312
|
|
|
258
313
|
ctx.tools.register({
|
|
259
314
|
name: 'labs_engage',
|
|
260
|
-
description: 'Submit the current panel for a run (compiles and sends to BLACS). Returns immediately with run_folder, n_shots_at_submit and expected_n_shots; use labs_wait later to wait for completion. Refuses when error_in_globals is true or another run is still pending.',
|
|
315
|
+
description: 'Submit the current panel for a run (compiles and sends to BLACS). Returns immediately with run_folder, n_shots_at_submit and expected_n_shots; use labs_wait later to wait for completion. Refuses when error_in_globals is true or another run is still pending; a pending run that produced no shot at all for config.engage.staleS (default 180 s) is dropped and reported as dropped_stale_pending.',
|
|
261
316
|
parameters: {
|
|
262
317
|
type: 'object',
|
|
263
318
|
properties: {
|
|
@@ -267,29 +322,33 @@ export function registerTools(ctx, runtime) {
|
|
|
267
322
|
},
|
|
268
323
|
isConcurrencySafe: () => false,
|
|
269
324
|
output: looseOutput((_args, value) => text(lines([
|
|
325
|
+
value.dropped_stale_pending ? 'dropped stale pending run: ' + value.dropped_stale_pending.run_folder + ' (' + value.dropped_stale_pending.age_s + ' s old, no shots ever appeared)' : '',
|
|
270
326
|
'submitted run: ' + (value.mapped_run_folder || value.run_folder || '<run folder pending>'),
|
|
271
327
|
'n_shots_at_submit: ' + value.n_shots_at_submit + ' expected: ' + value.expected_n_shots,
|
|
272
328
|
'call labs_wait when results are needed.',
|
|
273
329
|
]))),
|
|
274
330
|
async execute(args, exec) {
|
|
275
331
|
return runtime.serial(async () => {
|
|
276
|
-
await clearStalePending(runtime, exec)
|
|
332
|
+
const dropped = await clearStalePending(runtime, exec)
|
|
277
333
|
const before = await runtime.runBridge('status', {}, { signal: exec.signal })
|
|
278
334
|
await requireApproval(runtime, exec, 'labs_engage', 'Submit a labscript run: ' + (before.labscript || '<script>') + ', n_shots=' + before.n_shots + ', backend=' + before.backend, 'engage')
|
|
279
335
|
const data = await runtime.runBridge('engage', { note: args.note }, { signal: exec.signal, timeoutMs: 180000 })
|
|
280
336
|
runtime.setLastRun({
|
|
281
|
-
run_folder: data.run_folder,
|
|
337
|
+
run_folder: data.mapped_run_folder || data.run_folder,
|
|
338
|
+
remote_run_folder: data.run_folder,
|
|
282
339
|
expected_n_shots: data.expected_n_shots,
|
|
283
340
|
submitted_at: data.submitted_at,
|
|
284
341
|
})
|
|
285
342
|
runtime.setPending({
|
|
286
343
|
kind: 'engage',
|
|
287
|
-
run_folder: data.run_folder,
|
|
344
|
+
run_folder: data.mapped_run_folder || data.run_folder,
|
|
345
|
+
remote_run_folder: data.run_folder,
|
|
288
346
|
expected_n_shots: data.expected_n_shots,
|
|
289
347
|
submitted_at: data.submitted_at,
|
|
290
348
|
note: args.note,
|
|
291
349
|
snapshot: null,
|
|
292
350
|
})
|
|
351
|
+
if (dropped) data.dropped_stale_pending = dropped
|
|
293
352
|
return data
|
|
294
353
|
})
|
|
295
354
|
},
|
|
@@ -297,7 +356,7 @@ export function registerTools(ctx, runtime) {
|
|
|
297
356
|
|
|
298
357
|
ctx.tools.register({
|
|
299
358
|
name: 'labs_wait',
|
|
300
|
-
description: 'Wait for the pending run to finish (blocking, timeout in seconds). If the pending run came from labs_protocol run, this call also finalizes it: postprocess, finish write-back of panel and calibration history, and panel restore. Returns expected vs actual shots and, for protocols, the postprocess/finish/restore results. A run folder that does not exist and is not the pending run of this session fails immediately instead of waiting.',
|
|
359
|
+
description: 'Wait for the pending run to finish (blocking, timeout in seconds). If the pending run came from labs_protocol run, this call also finalizes it: postprocess, finish write-back of panel and calibration history, and panel restore. Returns expected vs actual shots and, for protocols, the postprocess/finish/restore results. Returns early with diagnosis no_shots or compile_stalled when no shot h5 appears for config.engage.noShotS seconds (default 25): the compilation probably aborted, so check runmanager and call labs_wait again only if you expect a slow compile. A run folder that does not exist and is not the pending run of this session fails immediately instead of waiting.',
|
|
301
360
|
parameters: {
|
|
302
361
|
type: 'object',
|
|
303
362
|
properties: {
|
|
@@ -312,7 +371,7 @@ export function registerTools(ctx, runtime) {
|
|
|
312
371
|
'run: ' + (value.mapped_run_folder || value.run_folder),
|
|
313
372
|
value.aborted ? 'wait cancelled by labs_abort (run aborted)' : '',
|
|
314
373
|
value.folder_exists === false ? 'folder: missing' : '',
|
|
315
|
-
'shots: ' + value.n_h5 + '/' + (value.expected_n_shots || '?') + ' complete: ' + value.complete + (value.timed_out ? ' TIMED OUT' : '') + (value.diagnosis ? ' diagnosis: ' + value.diagnosis : ''),
|
|
374
|
+
'shots: ' + value.n_h5 + '/' + (value.expected_n_shots || '?') + ' h5 files' + (value.n_executed !== undefined ? ', ' + value.n_executed + ' with acquisition data' : '') + ' complete: ' + value.complete + (value.timed_out ? ' TIMED OUT' : '') + (value.diagnosis ? ' diagnosis: ' + value.diagnosis : ''),
|
|
316
375
|
value.hint ? 'hint: ' + value.hint : '',
|
|
317
376
|
value.finalize && value.finalize.postprocess ? 'postprocess: ' + JSON.stringify(value.finalize.postprocess).slice(0, 400) : '',
|
|
318
377
|
value.finalize && value.finalize.finish ? 'finish: ' + JSON.stringify(value.finalize.finish).slice(0, 300) : '',
|
|
@@ -323,8 +382,9 @@ export function registerTools(ctx, runtime) {
|
|
|
323
382
|
const pending = runtime.state.pending
|
|
324
383
|
const runFolder = args.run_folder || (pending ? pending.run_folder : '')
|
|
325
384
|
if (!runFolder) throw new LabError('NO_PENDING_RUN', 'no run folder: submit with labs_engage or labs_protocol run first, or pass run_folder')
|
|
326
|
-
const
|
|
327
|
-
|
|
385
|
+
const pendingRun = isPendingRun(pending, runFolder)
|
|
386
|
+
const expected = pendingRun ? pending.expected_n_shots : undefined
|
|
387
|
+
if (!pendingRun) {
|
|
328
388
|
let probe = null
|
|
329
389
|
try {
|
|
330
390
|
probe = await runtime.runBridge('progress', {
|
|
@@ -368,6 +428,7 @@ export function registerTools(ctx, runtime) {
|
|
|
368
428
|
run_folder: waited.run_folder,
|
|
369
429
|
expected_n_shots: waited.expected_n_shots,
|
|
370
430
|
n_h5: waited.n_h5,
|
|
431
|
+
n_executed: waited.n_executed,
|
|
371
432
|
complete: waited.complete,
|
|
372
433
|
timed_out: waited.timed_out,
|
|
373
434
|
elapsed: waited.elapsed,
|
|
@@ -378,13 +439,13 @@ export function registerTools(ctx, runtime) {
|
|
|
378
439
|
folder_exists: waited.folder_exists,
|
|
379
440
|
}
|
|
380
441
|
for (const key of Object.keys(out)) if (out[key] === undefined) delete out[key]
|
|
381
|
-
if (waited.complete && runtime.state.lastRun && runtime.state.lastRun
|
|
442
|
+
if (waited.complete && runtime.state.lastRun && isPendingRun(runtime.state.lastRun, runFolder)) {
|
|
382
443
|
runtime.setLastRun(Object.assign({}, runtime.state.lastRun, {
|
|
383
444
|
completed_at: new Date().toISOString(),
|
|
384
445
|
n_h5: waited.n_h5,
|
|
385
446
|
}))
|
|
386
447
|
}
|
|
387
|
-
if (waited.complete && pending && pending.kind === 'protocol' &&
|
|
448
|
+
if (waited.complete && pending && pending.kind === 'protocol' && pendingRun) {
|
|
388
449
|
const finalize = await runtime.runBridge('protocol_finalize', {
|
|
389
450
|
protocol: pending.plan,
|
|
390
451
|
run_folder: runFolder,
|
|
@@ -393,7 +454,7 @@ export function registerTools(ctx, runtime) {
|
|
|
393
454
|
}, { signal: exec.signal, timeoutMs: 180000 })
|
|
394
455
|
out.finalize = finalize
|
|
395
456
|
runtime.clearPending()
|
|
396
|
-
} else if (waited.complete && pending &&
|
|
457
|
+
} else if (waited.complete && pending && pendingRun) {
|
|
397
458
|
runtime.clearPending()
|
|
398
459
|
}
|
|
399
460
|
return out
|
|
@@ -479,8 +540,8 @@ export function registerTools(ctx, runtime) {
|
|
|
479
540
|
value.plan && value.plan.scan ? 'scan: ' + Object.keys(value.plan.scan).join(', ') : '',
|
|
480
541
|
value.hard_limits ? 'hard limits: ' + JSON.stringify(value.hard_limits).slice(0, 240) : '',
|
|
481
542
|
value.warnings && value.warnings.length ? 'warnings: ' + value.warnings.join('; ') : '',
|
|
482
|
-
value
|
|
483
|
-
value.
|
|
543
|
+
nameCheckLine(value),
|
|
544
|
+
value.dropped_stale_pending ? 'dropped stale pending run: ' + value.dropped_stale_pending.run_folder + ' (' + value.dropped_stale_pending.age_s + ' s old, no shots ever appeared)' : '',
|
|
484
545
|
value.engage ? 'submitted: ' + (value.engage.mapped_run_folder || value.engage.run_folder || '<run folder pending>') + ' n_shots_at_submit: ' + value.n_shots_at_submit : '',
|
|
485
546
|
value.engage ? 'call labs_wait to finalize this protocol.' : '',
|
|
486
547
|
]))),
|
|
@@ -496,38 +557,39 @@ export function registerTools(ctx, runtime) {
|
|
|
496
557
|
return { name: loaded.name, file: loaded.file, warnings: loaded.warnings.concat(planned.warnings || []), plan: planned.plan, hard_limits: planned.hard_limits, name_check: planned.name_check }
|
|
497
558
|
}
|
|
498
559
|
return runtime.serial(async () => {
|
|
499
|
-
await clearStalePending(runtime, exec)
|
|
560
|
+
const dropped = await clearStalePending(runtime, exec)
|
|
500
561
|
const planned = await runtime.runBridge('protocol_plan', { protocol: loaded.plan }, { signal: exec.signal })
|
|
501
562
|
if (planned.hard_limits && planned.hard_limits.checked) {
|
|
502
563
|
// plan command already fails on violations; nothing to do
|
|
503
564
|
}
|
|
504
565
|
const nameCheck = planned.name_check
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
return key + (suggestions.length ? ' (closest: ' + suggestions.join(', ') + ')' : '')
|
|
509
|
-
}).join(', ')
|
|
510
|
-
await requireApproval(runtime, exec, 'labs_protocol', 'Protocol ' + loaded.name + ' uses key(s) that are not in the currently loaded labscript: ' + detail + '. Continue anyway?', 'protocolNames')
|
|
566
|
+
const missingDetail = nameCheckDetail(nameCheck)
|
|
567
|
+
if (missingDetail) {
|
|
568
|
+
await requireApproval(runtime, exec, 'labs_protocol', 'Protocol ' + loaded.name + ' uses key(s) that are not in the currently loaded labscript: ' + missingDetail + '. Continue anyway?', 'protocolNames')
|
|
511
569
|
}
|
|
512
570
|
await requireApproval(runtime, exec, 'labs_protocol', 'Run protocol ' + loaded.name + ': expected ' + planned.plan.expected_n_shots + ' shots, labscript ' + (loaded.plan.labscript || loaded.plan.joint_script) + ', backend ' + runtime.config.backend, 'protocolRun')
|
|
513
571
|
const started = await runtime.runBridge('protocol_start', { protocol: loaded.plan, note: args.note }, { signal: exec.signal, timeoutMs: 180000 })
|
|
514
572
|
if (started.engage) {
|
|
515
573
|
runtime.setLastRun({
|
|
516
|
-
run_folder: started.engage.run_folder,
|
|
574
|
+
run_folder: started.engage.mapped_run_folder || started.engage.run_folder,
|
|
575
|
+
remote_run_folder: started.engage.run_folder,
|
|
517
576
|
expected_n_shots: started.expected_n_shots,
|
|
518
577
|
submitted_at: started.submitted_at,
|
|
519
578
|
})
|
|
520
579
|
runtime.setPending({
|
|
521
580
|
kind: 'protocol',
|
|
522
581
|
plan: loaded.plan,
|
|
523
|
-
run_folder: started.engage.run_folder,
|
|
582
|
+
run_folder: started.engage.mapped_run_folder || started.engage.run_folder,
|
|
583
|
+
remote_run_folder: started.engage.run_folder,
|
|
524
584
|
expected_n_shots: started.expected_n_shots,
|
|
525
585
|
snapshot: started.snapshot,
|
|
526
586
|
submitted_at: started.submitted_at,
|
|
527
587
|
note: args.note,
|
|
528
588
|
})
|
|
529
589
|
}
|
|
530
|
-
|
|
590
|
+
const out = Object.assign({ name: loaded.name, file: loaded.file, warnings: loaded.warnings.concat(planned.warnings || []), name_check: nameCheck }, started)
|
|
591
|
+
if (dropped) out.dropped_stale_pending = dropped
|
|
592
|
+
return out
|
|
531
593
|
})
|
|
532
594
|
},
|
|
533
595
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-labscript",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "Control labscript-suite experiments from DeepSeek Harness: runmanager globals and scans, submits and waits, YAML fixed-operation protocols with postprocess write-back, panel snapshots, a built-in mock backend and a read-only doctor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
package/python/bridge.py
CHANGED
|
@@ -114,6 +114,23 @@ def shot_executed(path):
|
|
|
114
114
|
except Exception:
|
|
115
115
|
return None
|
|
116
116
|
|
|
117
|
+
|
|
118
|
+
def executed_prefix(shots):
|
|
119
|
+
"""How many shot h5 files already hold acquisition data (binary search).
|
|
120
|
+
|
|
121
|
+
labscript compiles and submits shots in file order, so executed shots are a prefix
|
|
122
|
+
of the sorted file list. Opening a handful of files keeps this cheap even when the
|
|
123
|
+
run folder is a network share.
|
|
124
|
+
"""
|
|
125
|
+
low, high = 0, len(shots)
|
|
126
|
+
while low < high:
|
|
127
|
+
middle = (low + high) // 2
|
|
128
|
+
if shot_executed(shots[middle]) is True:
|
|
129
|
+
low = middle + 1
|
|
130
|
+
else:
|
|
131
|
+
high = middle
|
|
132
|
+
return low
|
|
133
|
+
|
|
117
134
|
# ---------------------------------------------------------------- scan expr
|
|
118
135
|
|
|
119
136
|
_ALLOWED_CALLS = {"arange", "linspace", "array", "logspace"}
|
|
@@ -302,7 +319,11 @@ class LabscriptBackend(object):
|
|
|
302
319
|
return dict(self.client().get_globals(raw=raw))
|
|
303
320
|
|
|
304
321
|
def set_globals(self, values, raw=False):
|
|
305
|
-
|
|
322
|
+
# runmanager's set_globals is a side-effect RPC: its handler returns None, so
|
|
323
|
+
# never feed the reply to dict(). Report what was requested; the caller reads
|
|
324
|
+
# the panel back to verify.
|
|
325
|
+
written = self.client().set_globals(values, raw=raw)
|
|
326
|
+
return dict(written) if isinstance(written, dict) else dict(values)
|
|
306
327
|
|
|
307
328
|
def n_shots(self):
|
|
308
329
|
return int(self.client().n_shots())
|
|
@@ -988,21 +1009,25 @@ def cmd_set(config, args):
|
|
|
988
1009
|
snap = snapshot_panel(backend, config, "set", {"keys": sorted(values.keys()), "raw": raw, "note": args.get("note")}, touched=values.keys())
|
|
989
1010
|
try:
|
|
990
1011
|
written = backend.set_globals(values, raw=raw)
|
|
991
|
-
except BridgeError as exc:
|
|
992
|
-
detail = dict(exc.detail or {})
|
|
993
|
-
detail["snapshot"] = snap
|
|
994
|
-
detail["hint"] = "set_globals failed after the panel snapshot; the panel may be partially written. Use labs_restore to undo."
|
|
995
|
-
raise BridgeError(exc.code, exc.message, detail)
|
|
996
1012
|
except Exception as exc:
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1013
|
+
detail = dict(getattr(exc, "detail", None) or {})
|
|
1014
|
+
detail["snapshot"] = snap
|
|
1015
|
+
# A failed write can still have landed (runmanager writes each key in turn),
|
|
1016
|
+
# so report what the panel holds now instead of guessing in the hint.
|
|
1017
|
+
try:
|
|
1018
|
+
now = backend.get_globals(raw=True)
|
|
1019
|
+
detail["landed"] = dict((k, jsonable(now.get(k))) for k in values)
|
|
1020
|
+
except Exception:
|
|
1021
|
+
pass
|
|
1022
|
+
detail["hint"] = "the write may have landed partially or not at all: read the keys back with labs_status before retrying, and only labs_restore if they are wrong"
|
|
1023
|
+
raise BridgeError(getattr(exc, "code", None) or "SET_FAILED", "set_globals failed: " + type(exc).__name__ + ": " + str(exc), detail)
|
|
1001
1024
|
readback = backend.get_globals(raw=False)
|
|
1002
1025
|
raw_readback = backend.get_globals(raw=True)
|
|
1003
1026
|
missing = [k for k in values if k not in readback]
|
|
1004
1027
|
out = {
|
|
1005
|
-
|
|
1028
|
+
# Report what was asked for even if a backend returns something else (runmanager's
|
|
1029
|
+
# RPC returns nothing); the readback below carries the truth from the panel.
|
|
1030
|
+
"written": jsonable(written) if isinstance(written, dict) else dict((k, jsonable(v)) for k, v in values.items()),
|
|
1006
1031
|
"readback": dict((k, jsonable(readback.get(k))) for k in values),
|
|
1007
1032
|
"raw_readback": dict((k, jsonable(raw_readback.get(k))) for k in values),
|
|
1008
1033
|
"missing": missing,
|
|
@@ -1050,24 +1075,31 @@ def wait_for_run(config, run_folder, expected, timeout_s):
|
|
|
1050
1075
|
poll = float(engage_cfg.get("pollS") or 1.0)
|
|
1051
1076
|
settle = float(engage_cfg.get("settleS") or 2.0)
|
|
1052
1077
|
stall = float(engage_cfg.get("stallS") or 20.0)
|
|
1078
|
+
# labscript writes each shot h5 before compiling it, so a healthy compilation keeps
|
|
1079
|
+
# adding files. When nothing has appeared for this long the compile almost certainly
|
|
1080
|
+
# aborted; returning a diagnosis beats blocking for the full timeout_s.
|
|
1081
|
+
no_shot = float(engage_cfg.get("noShotS") or 25.0)
|
|
1082
|
+
want = int(expected or 0)
|
|
1053
1083
|
start = time.time()
|
|
1084
|
+
last_progress = start
|
|
1054
1085
|
stable_since = None
|
|
1055
1086
|
compiled_since = None
|
|
1056
1087
|
last_count = -1
|
|
1057
1088
|
last_size = -1
|
|
1058
|
-
|
|
1089
|
+
shots = []
|
|
1059
1090
|
|
|
1060
|
-
def result(complete, timed_out, diagnosis,
|
|
1091
|
+
def result(complete, timed_out, diagnosis, elapsed, hint=None):
|
|
1061
1092
|
payload = {
|
|
1062
1093
|
"run_folder": run_folder,
|
|
1063
1094
|
"mapped_run_folder": folder,
|
|
1064
1095
|
"folder_exists": os.path.isdir(folder),
|
|
1065
1096
|
"expected_n_shots": expected,
|
|
1066
|
-
"n_h5":
|
|
1097
|
+
"n_h5": len(shots),
|
|
1098
|
+
"n_executed": executed_prefix(shots),
|
|
1067
1099
|
"complete": complete,
|
|
1068
1100
|
"timed_out": timed_out,
|
|
1069
1101
|
"diagnosis": diagnosis,
|
|
1070
|
-
"executed":
|
|
1102
|
+
"executed": shot_executed(shots[-1]) if shots else None,
|
|
1071
1103
|
"elapsed": round(elapsed, 1),
|
|
1072
1104
|
}
|
|
1073
1105
|
if hint:
|
|
@@ -1078,8 +1110,10 @@ def wait_for_run(config, run_folder, expected, timeout_s):
|
|
|
1078
1110
|
shots = sorted(glob.glob(os.path.join(folder, "**", "*.h5"), recursive=True))
|
|
1079
1111
|
count = len(shots)
|
|
1080
1112
|
total = sum(os.path.getsize(p) for p in shots) if shots else 0
|
|
1113
|
+
if count != last_count or total != last_size:
|
|
1114
|
+
last_progress = time.time()
|
|
1081
1115
|
ready = False
|
|
1082
|
-
if count >= (
|
|
1116
|
+
if count >= (want or 0) and count == last_count and total == last_size and shots:
|
|
1083
1117
|
ready = shot_ready(shots[-1])
|
|
1084
1118
|
executed = None
|
|
1085
1119
|
if ready:
|
|
@@ -1089,7 +1123,7 @@ def wait_for_run(config, run_folder, expected, timeout_s):
|
|
|
1089
1123
|
if stable_since is None:
|
|
1090
1124
|
stable_since = time.time()
|
|
1091
1125
|
elif time.time() - stable_since >= settle:
|
|
1092
|
-
return result(True, False, "complete",
|
|
1126
|
+
return result(True, False, "complete", time.time() - start)
|
|
1093
1127
|
else:
|
|
1094
1128
|
stable_since = None
|
|
1095
1129
|
if ready and executed is False:
|
|
@@ -1097,19 +1131,31 @@ def wait_for_run(config, run_folder, expected, timeout_s):
|
|
|
1097
1131
|
compiled_since = time.time()
|
|
1098
1132
|
elif time.time() - compiled_since >= stall:
|
|
1099
1133
|
return result(
|
|
1100
|
-
False, False, "compiled_not_executed",
|
|
1134
|
+
False, False, "compiled_not_executed", time.time() - start,
|
|
1101
1135
|
"shots were compiled but show no acquisition data after " + str(int(stall)) +
|
|
1102
1136
|
" s: BLACS may be paused / the queue may not be running, or the run is still being programmed. Check BLACS and the panel, then labs_abort if needed.")
|
|
1103
1137
|
else:
|
|
1104
1138
|
compiled_since = None
|
|
1105
1139
|
last_count = count
|
|
1106
1140
|
last_size = total
|
|
1107
|
-
|
|
1141
|
+
quiet = time.time() - last_progress
|
|
1142
|
+
if want and count < want and quiet >= no_shot:
|
|
1143
|
+
if count == 0:
|
|
1144
|
+
diagnosis = "no_shots"
|
|
1145
|
+
hint = ("no shot h5 appeared in " + str(int(no_shot)) + " s: the compilation probably aborted "
|
|
1146
|
+
"(runmanager shows the error) or BLACS is not running. Check runmanager, call labs_wait "
|
|
1147
|
+
"again to keep waiting, or labs_abort.")
|
|
1148
|
+
else:
|
|
1149
|
+
diagnosis = "compile_stalled"
|
|
1150
|
+
hint = ("compilation stopped at " + str(count) + " of " + str(want) + " shot h5 files and "
|
|
1151
|
+
"nothing changed for " + str(int(no_shot)) + " s: it probably aborted (runmanager shows "
|
|
1152
|
+
"the error). Check runmanager, call labs_wait again to keep waiting, or labs_abort.")
|
|
1153
|
+
return result(False, False, diagnosis, time.time() - start, hint)
|
|
1108
1154
|
if time.time() - start >= timeout:
|
|
1109
1155
|
if count == 0:
|
|
1110
1156
|
diagnosis = "no_shots"
|
|
1111
1157
|
hint = "no shot h5 appeared: check that the run was accepted, that BLACS is running, and that pathMap points at the run folder."
|
|
1112
|
-
elif count <
|
|
1158
|
+
elif count < want:
|
|
1113
1159
|
diagnosis = "compiling"
|
|
1114
1160
|
hint = "only " + str(count) + " of " + str(expected) + " shots appeared before the timeout; the run may still be compiling or blocked."
|
|
1115
1161
|
elif executed is False:
|
|
@@ -1118,7 +1164,7 @@ def wait_for_run(config, run_folder, expected, timeout_s):
|
|
|
1118
1164
|
else:
|
|
1119
1165
|
diagnosis = "partial"
|
|
1120
1166
|
hint = "shots appeared but the run was not confirmed complete before the timeout."
|
|
1121
|
-
return result(False, True, diagnosis,
|
|
1167
|
+
return result(False, True, diagnosis, time.time() - start, hint)
|
|
1122
1168
|
time.sleep(poll)
|
|
1123
1169
|
|
|
1124
1170
|
|
|
@@ -1146,7 +1192,20 @@ def cmd_progress(config, args):
|
|
|
1146
1192
|
expected = int(args.get("expected_n_shots") or 0)
|
|
1147
1193
|
folder = map_path(config, run_folder)
|
|
1148
1194
|
shots = sorted(glob.glob(os.path.join(folder, "**", "*.h5"), recursive=True)) if folder else []
|
|
1149
|
-
|
|
1195
|
+
# n_h5 counts files, which labscript writes before compiling each shot; n_executed
|
|
1196
|
+
# counts the shots that actually acquired data. A run is only complete when the
|
|
1197
|
+
# last shot has acquisition data, not merely when the file count matches.
|
|
1198
|
+
executed = executed_prefix(shots)
|
|
1199
|
+
complete = bool(expected and len(shots) >= expected and executed == len(shots))
|
|
1200
|
+
return {
|
|
1201
|
+
"run_folder": run_folder,
|
|
1202
|
+
"mapped_run_folder": folder,
|
|
1203
|
+
"folder_exists": bool(folder) and os.path.isdir(folder),
|
|
1204
|
+
"n_h5": len(shots),
|
|
1205
|
+
"n_executed": executed,
|
|
1206
|
+
"expected_n_shots": expected,
|
|
1207
|
+
"complete": complete,
|
|
1208
|
+
}
|
|
1150
1209
|
|
|
1151
1210
|
|
|
1152
1211
|
|
|
@@ -8,6 +8,7 @@ import os
|
|
|
8
8
|
import shutil
|
|
9
9
|
import sys
|
|
10
10
|
import tempfile
|
|
11
|
+
import time
|
|
11
12
|
|
|
12
13
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
13
14
|
sys.path.insert(0, os.path.dirname(HERE))
|
|
@@ -277,6 +278,159 @@ def test_wait_diagnosis_compiled_not_executed():
|
|
|
277
278
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
278
279
|
|
|
279
280
|
|
|
281
|
+
def write_shot(path, executed):
|
|
282
|
+
"""Write a shot h5 like labscript does: globals first, acquisition data later."""
|
|
283
|
+
import h5py
|
|
284
|
+
with h5py.File(path, "w") as handle:
|
|
285
|
+
handle.create_group("globals")
|
|
286
|
+
if executed:
|
|
287
|
+
label = handle.create_group("images/camera/label")
|
|
288
|
+
label.create_dataset("frame", data=[1.0, 2.0])
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_wait_reports_stalled_compilation_early():
|
|
292
|
+
"""A compile that dies after one shot h5 must not block for the whole timeout."""
|
|
293
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-stalled-")
|
|
294
|
+
try:
|
|
295
|
+
cfg = make_config(tmp)
|
|
296
|
+
cfg["engage"] = {"timeoutS": 10, "pollS": 0.05, "settleS": 0.1, "noShotS": 0.3}
|
|
297
|
+
run_dir = os.path.join(tmp, "stalled_run")
|
|
298
|
+
os.makedirs(run_dir, exist_ok=True)
|
|
299
|
+
write_shot(os.path.join(run_dir, "shot_0000.h5"), executed=False)
|
|
300
|
+
started = time.time()
|
|
301
|
+
result = bridge.wait_for_run(cfg, run_dir, 64, 10)
|
|
302
|
+
elapsed = time.time() - started
|
|
303
|
+
assert result["diagnosis"] == "compile_stalled", result
|
|
304
|
+
assert result["timed_out"] is False, result
|
|
305
|
+
assert result["n_h5"] == 1, result
|
|
306
|
+
assert result["n_executed"] == 0, result
|
|
307
|
+
assert elapsed < 3.0, elapsed
|
|
308
|
+
assert "aborted" in result["hint"], result
|
|
309
|
+
finally:
|
|
310
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_wait_reports_no_shots_early():
|
|
314
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-noshots-")
|
|
315
|
+
try:
|
|
316
|
+
cfg = make_config(tmp)
|
|
317
|
+
cfg["engage"] = {"timeoutS": 10, "pollS": 0.05, "settleS": 0.1, "noShotS": 0.3}
|
|
318
|
+
run_dir = os.path.join(tmp, "empty_run")
|
|
319
|
+
os.makedirs(run_dir, exist_ok=True)
|
|
320
|
+
started = time.time()
|
|
321
|
+
result = bridge.wait_for_run(cfg, run_dir, 64, 10)
|
|
322
|
+
elapsed = time.time() - started
|
|
323
|
+
assert result["diagnosis"] == "no_shots", result
|
|
324
|
+
assert result["timed_out"] is False, result
|
|
325
|
+
assert result["n_h5"] == 0 and result["n_executed"] == 0, result
|
|
326
|
+
assert elapsed < 3.0, elapsed
|
|
327
|
+
finally:
|
|
328
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_progress_separates_files_from_executed_shots():
|
|
332
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-progress-exec-")
|
|
333
|
+
try:
|
|
334
|
+
cfg = make_config(tmp)
|
|
335
|
+
compiled = os.path.join(tmp, "compiled_run")
|
|
336
|
+
os.makedirs(compiled, exist_ok=True)
|
|
337
|
+
write_shot(os.path.join(compiled, "shot_0000.h5"), executed=True)
|
|
338
|
+
write_shot(os.path.join(compiled, "shot_0001.h5"), executed=False)
|
|
339
|
+
write_shot(os.path.join(compiled, "shot_0002.h5"), executed=False)
|
|
340
|
+
out = bridge.cmd_progress(cfg, {"run_folder": compiled, "expected_n_shots": 3})
|
|
341
|
+
assert out["n_h5"] == 3, out
|
|
342
|
+
assert out["n_executed"] == 1, out
|
|
343
|
+
assert out["complete"] is False, out
|
|
344
|
+
done = os.path.join(tmp, "done_run")
|
|
345
|
+
os.makedirs(done, exist_ok=True)
|
|
346
|
+
for index in range(2):
|
|
347
|
+
write_shot(os.path.join(done, "shot_000%d.h5" % index), executed=True)
|
|
348
|
+
finished = bridge.cmd_progress(cfg, {"run_folder": done, "expected_n_shots": 2})
|
|
349
|
+
assert finished["complete"] is True, finished
|
|
350
|
+
assert finished["n_executed"] == 2, finished
|
|
351
|
+
finally:
|
|
352
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_real_backend_set_globals_survives_none_reply():
|
|
356
|
+
"""runmanager's set_globals returns None; the write must still be reported."""
|
|
357
|
+
class FakeClient(object):
|
|
358
|
+
def __init__(self):
|
|
359
|
+
self.calls = []
|
|
360
|
+
|
|
361
|
+
def set_globals(self, values, raw=False):
|
|
362
|
+
self.calls.append((dict(values), raw))
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
client = FakeClient()
|
|
366
|
+
backend = bridge.LabscriptBackend({})
|
|
367
|
+
backend._client = client
|
|
368
|
+
written = backend.set_globals({"do_acquire": True, "amplitude": 0.5})
|
|
369
|
+
assert written == {"do_acquire": True, "amplitude": 0.5}, written
|
|
370
|
+
assert client.calls == [({"do_acquire": True, "amplitude": 0.5}, False)], client.calls
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_cmd_set_accepts_backend_that_returns_none():
|
|
374
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-set-none-")
|
|
375
|
+
try:
|
|
376
|
+
cfg = make_config(tmp)
|
|
377
|
+
original = bridge.make_backend
|
|
378
|
+
|
|
379
|
+
def make(config):
|
|
380
|
+
backend = bridge.MockBackend(config)
|
|
381
|
+
|
|
382
|
+
def set_globals(values, raw=False):
|
|
383
|
+
bridge.MockBackend.set_globals(backend, values, raw=raw)
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
backend.set_globals = set_globals
|
|
387
|
+
return backend
|
|
388
|
+
|
|
389
|
+
bridge.make_backend = make
|
|
390
|
+
try:
|
|
391
|
+
out = bridge.cmd_set(cfg, {"values": {"amplitude": 0.71}, "note": "None-returning client"})
|
|
392
|
+
finally:
|
|
393
|
+
bridge.make_backend = original
|
|
394
|
+
assert out["missing"] == [], out
|
|
395
|
+
assert out["readback"]["amplitude"] == 0.71, out
|
|
396
|
+
assert out["written"] == {"amplitude": 0.71}, out
|
|
397
|
+
finally:
|
|
398
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def test_cmd_set_failure_reports_what_landed():
|
|
402
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-set-partial-")
|
|
403
|
+
try:
|
|
404
|
+
cfg = make_config(tmp)
|
|
405
|
+
original = bridge.make_backend
|
|
406
|
+
|
|
407
|
+
def make(config):
|
|
408
|
+
backend = bridge.MockBackend(config)
|
|
409
|
+
|
|
410
|
+
def set_globals(values, raw=False):
|
|
411
|
+
if "amplitude" in values:
|
|
412
|
+
bridge.MockBackend.set_globals(backend, {"amplitude": values["amplitude"]}, raw=raw)
|
|
413
|
+
raise ValueError("Global nope not found in any active group")
|
|
414
|
+
|
|
415
|
+
backend.set_globals = set_globals
|
|
416
|
+
return backend
|
|
417
|
+
|
|
418
|
+
bridge.make_backend = make
|
|
419
|
+
try:
|
|
420
|
+
bridge.cmd_set(cfg, {"values": {"amplitude": 0.71, "nope": 1}})
|
|
421
|
+
except bridge.BridgeError as exc:
|
|
422
|
+
assert exc.code == "SET_FAILED", exc.code
|
|
423
|
+
assert "not found" in exc.message, exc.message
|
|
424
|
+
assert exc.detail["snapshot"]["name"].endswith(".json"), exc.detail
|
|
425
|
+
assert exc.detail["landed"]["amplitude"] == "0.71", exc.detail
|
|
426
|
+
assert "labs_status" in exc.detail["hint"], exc.detail
|
|
427
|
+
else:
|
|
428
|
+
raise AssertionError("expected SET_FAILED")
|
|
429
|
+
finally:
|
|
430
|
+
bridge.make_backend = original
|
|
431
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
432
|
+
|
|
433
|
+
|
|
280
434
|
def test_doctor_mock():
|
|
281
435
|
tmp = tempfile.mkdtemp(prefix="dsh-labscript-doctor-")
|
|
282
436
|
try:
|