dsh-labscript 0.2.6 → 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 +46 -0
- package/README.md +23 -5
- package/docs/README.zh.md +13 -5
- package/lib/skill.js +13 -2
- package/lib/tools.js +97 -33
- package/package.json +1 -1
- package/python/bridge.py +92 -26
- package/python/tests/run_tests.py +189 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
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
|
+
|
|
34
|
+
## 0.2.7
|
|
35
|
+
|
|
36
|
+
- Fix: labs_wait no longer fails with "value is not lossless JSON". Tool results must be
|
|
37
|
+
pure JSON; a completed wait could carry an undefined hint field, which the DSH host
|
|
38
|
+
rejects when recording the result (the run itself was fine). labs_status pending
|
|
39
|
+
progress and the aborted-wait result are cleaned the same way.
|
|
40
|
+
- Fix: non-ASCII arguments (e.g. a Chinese note) no longer break the bridge. Python's
|
|
41
|
+
stdin on Windows uses the locale encoding (GBK); the bridge now reads stdin as UTF-8.
|
|
42
|
+
Such a note used to submit the run and then fail with UnicodeEncodeError in the event
|
|
43
|
+
log, which made a successful engage look failed.
|
|
44
|
+
- Fix: a failed event-log write can no longer turn a successful action into an error; it
|
|
45
|
+
is reported on stderr and the action result is returned as usual.
|
|
46
|
+
- Tests: Unicode note end-to-end through the bridge process, event-log failure
|
|
47
|
+
isolation, and a lossless-JSON check on tool results.
|
|
48
|
+
|
|
3
49
|
## 0.2.6
|
|
4
50
|
|
|
5
51
|
- Fix: labs_data rendered only counts. It now prints selected first-shot globals
|
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 []
|
|
@@ -133,6 +154,7 @@ async function pendingProgress(runtime, exec) {
|
|
|
133
154
|
note: pending.note,
|
|
134
155
|
elapsed_s: pending.submitted_at ? Math.round((Date.now() - Date.parse(pending.submitted_at)) / 1000) : undefined,
|
|
135
156
|
}
|
|
157
|
+
for (const key of Object.keys(base)) if (base[key] === undefined) delete base[key]
|
|
136
158
|
if (!pending.run_folder) return base
|
|
137
159
|
try {
|
|
138
160
|
const progress = await runtime.runBridge('progress', {
|
|
@@ -165,23 +187,57 @@ async function requireApproval(runtime, exec, toolName, reason, approvalKey) {
|
|
|
165
187
|
}
|
|
166
188
|
}
|
|
167
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.
|
|
168
203
|
async function clearStalePending(runtime, exec) {
|
|
169
204
|
const pending = runtime.state.pending
|
|
170
|
-
if (!pending) return
|
|
205
|
+
if (!pending) return null
|
|
171
206
|
if (pending.run_folder) {
|
|
207
|
+
let progress = null
|
|
172
208
|
try {
|
|
173
|
-
|
|
209
|
+
progress = await runtime.runBridge('progress', {
|
|
174
210
|
run_folder: pending.run_folder,
|
|
175
211
|
expected_n_shots: pending.expected_n_shots,
|
|
176
212
|
}, { signal: exec.signal })
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
179
231
|
}
|
|
180
|
-
|
|
181
|
-
|
|
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)
|
|
182
237
|
}
|
|
183
238
|
}
|
|
184
239
|
runtime.clearPending()
|
|
240
|
+
return null
|
|
185
241
|
}
|
|
186
242
|
|
|
187
243
|
function mockApprovalSkipped(runtime) {
|
|
@@ -226,13 +282,13 @@ export function registerTools(ctx, runtime) {
|
|
|
226
282
|
|
|
227
283
|
ctx.tools.register({
|
|
228
284
|
name: 'labs_set',
|
|
229
|
-
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.',
|
|
230
286
|
parameters: {
|
|
231
287
|
type: 'object',
|
|
232
288
|
properties: {
|
|
233
289
|
values: { type: 'object', additionalProperties: true, description: 'Panel globals to write: key -> scalar' },
|
|
234
|
-
raw: { type: 'boolean', description: 'values are
|
|
235
|
-
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)' },
|
|
236
292
|
note: { type: 'string', description: 'One sentence: what this batch is for (written to the event log)' },
|
|
237
293
|
},
|
|
238
294
|
required: ['values'],
|
|
@@ -256,7 +312,7 @@ export function registerTools(ctx, runtime) {
|
|
|
256
312
|
|
|
257
313
|
ctx.tools.register({
|
|
258
314
|
name: 'labs_engage',
|
|
259
|
-
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.',
|
|
260
316
|
parameters: {
|
|
261
317
|
type: 'object',
|
|
262
318
|
properties: {
|
|
@@ -266,29 +322,33 @@ export function registerTools(ctx, runtime) {
|
|
|
266
322
|
},
|
|
267
323
|
isConcurrencySafe: () => false,
|
|
268
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)' : '',
|
|
269
326
|
'submitted run: ' + (value.mapped_run_folder || value.run_folder || '<run folder pending>'),
|
|
270
327
|
'n_shots_at_submit: ' + value.n_shots_at_submit + ' expected: ' + value.expected_n_shots,
|
|
271
328
|
'call labs_wait when results are needed.',
|
|
272
329
|
]))),
|
|
273
330
|
async execute(args, exec) {
|
|
274
331
|
return runtime.serial(async () => {
|
|
275
|
-
await clearStalePending(runtime, exec)
|
|
332
|
+
const dropped = await clearStalePending(runtime, exec)
|
|
276
333
|
const before = await runtime.runBridge('status', {}, { signal: exec.signal })
|
|
277
334
|
await requireApproval(runtime, exec, 'labs_engage', 'Submit a labscript run: ' + (before.labscript || '<script>') + ', n_shots=' + before.n_shots + ', backend=' + before.backend, 'engage')
|
|
278
335
|
const data = await runtime.runBridge('engage', { note: args.note }, { signal: exec.signal, timeoutMs: 180000 })
|
|
279
336
|
runtime.setLastRun({
|
|
280
|
-
run_folder: data.run_folder,
|
|
337
|
+
run_folder: data.mapped_run_folder || data.run_folder,
|
|
338
|
+
remote_run_folder: data.run_folder,
|
|
281
339
|
expected_n_shots: data.expected_n_shots,
|
|
282
340
|
submitted_at: data.submitted_at,
|
|
283
341
|
})
|
|
284
342
|
runtime.setPending({
|
|
285
343
|
kind: 'engage',
|
|
286
|
-
run_folder: data.run_folder,
|
|
344
|
+
run_folder: data.mapped_run_folder || data.run_folder,
|
|
345
|
+
remote_run_folder: data.run_folder,
|
|
287
346
|
expected_n_shots: data.expected_n_shots,
|
|
288
347
|
submitted_at: data.submitted_at,
|
|
289
348
|
note: args.note,
|
|
290
349
|
snapshot: null,
|
|
291
350
|
})
|
|
351
|
+
if (dropped) data.dropped_stale_pending = dropped
|
|
292
352
|
return data
|
|
293
353
|
})
|
|
294
354
|
},
|
|
@@ -296,7 +356,7 @@ export function registerTools(ctx, runtime) {
|
|
|
296
356
|
|
|
297
357
|
ctx.tools.register({
|
|
298
358
|
name: 'labs_wait',
|
|
299
|
-
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.',
|
|
300
360
|
parameters: {
|
|
301
361
|
type: 'object',
|
|
302
362
|
properties: {
|
|
@@ -311,7 +371,7 @@ export function registerTools(ctx, runtime) {
|
|
|
311
371
|
'run: ' + (value.mapped_run_folder || value.run_folder),
|
|
312
372
|
value.aborted ? 'wait cancelled by labs_abort (run aborted)' : '',
|
|
313
373
|
value.folder_exists === false ? 'folder: missing' : '',
|
|
314
|
-
'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 : ''),
|
|
315
375
|
value.hint ? 'hint: ' + value.hint : '',
|
|
316
376
|
value.finalize && value.finalize.postprocess ? 'postprocess: ' + JSON.stringify(value.finalize.postprocess).slice(0, 400) : '',
|
|
317
377
|
value.finalize && value.finalize.finish ? 'finish: ' + JSON.stringify(value.finalize.finish).slice(0, 300) : '',
|
|
@@ -322,8 +382,9 @@ export function registerTools(ctx, runtime) {
|
|
|
322
382
|
const pending = runtime.state.pending
|
|
323
383
|
const runFolder = args.run_folder || (pending ? pending.run_folder : '')
|
|
324
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')
|
|
325
|
-
const
|
|
326
|
-
|
|
385
|
+
const pendingRun = isPendingRun(pending, runFolder)
|
|
386
|
+
const expected = pendingRun ? pending.expected_n_shots : undefined
|
|
387
|
+
if (!pendingRun) {
|
|
327
388
|
let probe = null
|
|
328
389
|
try {
|
|
329
390
|
probe = await runtime.runBridge('progress', {
|
|
@@ -350,7 +411,7 @@ export function registerTools(ctx, runtime) {
|
|
|
350
411
|
runtime.state.waitCancelledByAbort = false
|
|
351
412
|
return {
|
|
352
413
|
run_folder: runFolder,
|
|
353
|
-
expected_n_shots: expected,
|
|
414
|
+
...(expected !== undefined ? { expected_n_shots: expected } : {}),
|
|
354
415
|
complete: false,
|
|
355
416
|
timed_out: false,
|
|
356
417
|
aborted: true,
|
|
@@ -367,6 +428,7 @@ export function registerTools(ctx, runtime) {
|
|
|
367
428
|
run_folder: waited.run_folder,
|
|
368
429
|
expected_n_shots: waited.expected_n_shots,
|
|
369
430
|
n_h5: waited.n_h5,
|
|
431
|
+
n_executed: waited.n_executed,
|
|
370
432
|
complete: waited.complete,
|
|
371
433
|
timed_out: waited.timed_out,
|
|
372
434
|
elapsed: waited.elapsed,
|
|
@@ -376,13 +438,14 @@ export function registerTools(ctx, runtime) {
|
|
|
376
438
|
mapped_run_folder: waited.mapped_run_folder,
|
|
377
439
|
folder_exists: waited.folder_exists,
|
|
378
440
|
}
|
|
379
|
-
|
|
441
|
+
for (const key of Object.keys(out)) if (out[key] === undefined) delete out[key]
|
|
442
|
+
if (waited.complete && runtime.state.lastRun && isPendingRun(runtime.state.lastRun, runFolder)) {
|
|
380
443
|
runtime.setLastRun(Object.assign({}, runtime.state.lastRun, {
|
|
381
444
|
completed_at: new Date().toISOString(),
|
|
382
445
|
n_h5: waited.n_h5,
|
|
383
446
|
}))
|
|
384
447
|
}
|
|
385
|
-
if (waited.complete && pending && pending.kind === 'protocol' &&
|
|
448
|
+
if (waited.complete && pending && pending.kind === 'protocol' && pendingRun) {
|
|
386
449
|
const finalize = await runtime.runBridge('protocol_finalize', {
|
|
387
450
|
protocol: pending.plan,
|
|
388
451
|
run_folder: runFolder,
|
|
@@ -391,7 +454,7 @@ export function registerTools(ctx, runtime) {
|
|
|
391
454
|
}, { signal: exec.signal, timeoutMs: 180000 })
|
|
392
455
|
out.finalize = finalize
|
|
393
456
|
runtime.clearPending()
|
|
394
|
-
} else if (waited.complete && pending &&
|
|
457
|
+
} else if (waited.complete && pending && pendingRun) {
|
|
395
458
|
runtime.clearPending()
|
|
396
459
|
}
|
|
397
460
|
return out
|
|
@@ -477,8 +540,8 @@ export function registerTools(ctx, runtime) {
|
|
|
477
540
|
value.plan && value.plan.scan ? 'scan: ' + Object.keys(value.plan.scan).join(', ') : '',
|
|
478
541
|
value.hard_limits ? 'hard limits: ' + JSON.stringify(value.hard_limits).slice(0, 240) : '',
|
|
479
542
|
value.warnings && value.warnings.length ? 'warnings: ' + value.warnings.join('; ') : '',
|
|
480
|
-
value
|
|
481
|
-
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)' : '',
|
|
482
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 : '',
|
|
483
546
|
value.engage ? 'call labs_wait to finalize this protocol.' : '',
|
|
484
547
|
]))),
|
|
@@ -494,38 +557,39 @@ export function registerTools(ctx, runtime) {
|
|
|
494
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 }
|
|
495
558
|
}
|
|
496
559
|
return runtime.serial(async () => {
|
|
497
|
-
await clearStalePending(runtime, exec)
|
|
560
|
+
const dropped = await clearStalePending(runtime, exec)
|
|
498
561
|
const planned = await runtime.runBridge('protocol_plan', { protocol: loaded.plan }, { signal: exec.signal })
|
|
499
562
|
if (planned.hard_limits && planned.hard_limits.checked) {
|
|
500
563
|
// plan command already fails on violations; nothing to do
|
|
501
564
|
}
|
|
502
565
|
const nameCheck = planned.name_check
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
return key + (suggestions.length ? ' (closest: ' + suggestions.join(', ') + ')' : '')
|
|
507
|
-
}).join(', ')
|
|
508
|
-
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')
|
|
509
569
|
}
|
|
510
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')
|
|
511
571
|
const started = await runtime.runBridge('protocol_start', { protocol: loaded.plan, note: args.note }, { signal: exec.signal, timeoutMs: 180000 })
|
|
512
572
|
if (started.engage) {
|
|
513
573
|
runtime.setLastRun({
|
|
514
|
-
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,
|
|
515
576
|
expected_n_shots: started.expected_n_shots,
|
|
516
577
|
submitted_at: started.submitted_at,
|
|
517
578
|
})
|
|
518
579
|
runtime.setPending({
|
|
519
580
|
kind: 'protocol',
|
|
520
581
|
plan: loaded.plan,
|
|
521
|
-
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,
|
|
522
584
|
expected_n_shots: started.expected_n_shots,
|
|
523
585
|
snapshot: started.snapshot,
|
|
524
586
|
submitted_at: started.submitted_at,
|
|
525
587
|
note: args.note,
|
|
526
588
|
})
|
|
527
589
|
}
|
|
528
|
-
|
|
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
|
|
529
593
|
})
|
|
530
594
|
},
|
|
531
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())
|
|
@@ -612,10 +633,13 @@ def snapshot_panel(backend, config, tool, args, touched=None):
|
|
|
612
633
|
|
|
613
634
|
|
|
614
635
|
def append_event(config, event):
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
636
|
+
try:
|
|
637
|
+
folder = history_dir(config)
|
|
638
|
+
path = os.path.join(folder, "events.jsonl")
|
|
639
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
640
|
+
handle.write(json.dumps(jsonable(event), ensure_ascii=False) + "\n")
|
|
641
|
+
except Exception as exc:
|
|
642
|
+
sys.stderr.write("append_event failed: %s\n" % (exc,))
|
|
619
643
|
|
|
620
644
|
|
|
621
645
|
def load_snapshot(config, name):
|
|
@@ -985,21 +1009,25 @@ def cmd_set(config, args):
|
|
|
985
1009
|
snap = snapshot_panel(backend, config, "set", {"keys": sorted(values.keys()), "raw": raw, "note": args.get("note")}, touched=values.keys())
|
|
986
1010
|
try:
|
|
987
1011
|
written = backend.set_globals(values, raw=raw)
|
|
988
|
-
except BridgeError as exc:
|
|
989
|
-
detail = dict(exc.detail or {})
|
|
990
|
-
detail["snapshot"] = snap
|
|
991
|
-
detail["hint"] = "set_globals failed after the panel snapshot; the panel may be partially written. Use labs_restore to undo."
|
|
992
|
-
raise BridgeError(exc.code, exc.message, detail)
|
|
993
1012
|
except Exception as exc:
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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)
|
|
998
1024
|
readback = backend.get_globals(raw=False)
|
|
999
1025
|
raw_readback = backend.get_globals(raw=True)
|
|
1000
1026
|
missing = [k for k in values if k not in readback]
|
|
1001
1027
|
out = {
|
|
1002
|
-
|
|
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()),
|
|
1003
1031
|
"readback": dict((k, jsonable(readback.get(k))) for k in values),
|
|
1004
1032
|
"raw_readback": dict((k, jsonable(raw_readback.get(k))) for k in values),
|
|
1005
1033
|
"missing": missing,
|
|
@@ -1047,24 +1075,31 @@ def wait_for_run(config, run_folder, expected, timeout_s):
|
|
|
1047
1075
|
poll = float(engage_cfg.get("pollS") or 1.0)
|
|
1048
1076
|
settle = float(engage_cfg.get("settleS") or 2.0)
|
|
1049
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)
|
|
1050
1083
|
start = time.time()
|
|
1084
|
+
last_progress = start
|
|
1051
1085
|
stable_since = None
|
|
1052
1086
|
compiled_since = None
|
|
1053
1087
|
last_count = -1
|
|
1054
1088
|
last_size = -1
|
|
1055
|
-
|
|
1089
|
+
shots = []
|
|
1056
1090
|
|
|
1057
|
-
def result(complete, timed_out, diagnosis,
|
|
1091
|
+
def result(complete, timed_out, diagnosis, elapsed, hint=None):
|
|
1058
1092
|
payload = {
|
|
1059
1093
|
"run_folder": run_folder,
|
|
1060
1094
|
"mapped_run_folder": folder,
|
|
1061
1095
|
"folder_exists": os.path.isdir(folder),
|
|
1062
1096
|
"expected_n_shots": expected,
|
|
1063
|
-
"n_h5":
|
|
1097
|
+
"n_h5": len(shots),
|
|
1098
|
+
"n_executed": executed_prefix(shots),
|
|
1064
1099
|
"complete": complete,
|
|
1065
1100
|
"timed_out": timed_out,
|
|
1066
1101
|
"diagnosis": diagnosis,
|
|
1067
|
-
"executed":
|
|
1102
|
+
"executed": shot_executed(shots[-1]) if shots else None,
|
|
1068
1103
|
"elapsed": round(elapsed, 1),
|
|
1069
1104
|
}
|
|
1070
1105
|
if hint:
|
|
@@ -1075,8 +1110,10 @@ def wait_for_run(config, run_folder, expected, timeout_s):
|
|
|
1075
1110
|
shots = sorted(glob.glob(os.path.join(folder, "**", "*.h5"), recursive=True))
|
|
1076
1111
|
count = len(shots)
|
|
1077
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()
|
|
1078
1115
|
ready = False
|
|
1079
|
-
if count >= (
|
|
1116
|
+
if count >= (want or 0) and count == last_count and total == last_size and shots:
|
|
1080
1117
|
ready = shot_ready(shots[-1])
|
|
1081
1118
|
executed = None
|
|
1082
1119
|
if ready:
|
|
@@ -1086,7 +1123,7 @@ def wait_for_run(config, run_folder, expected, timeout_s):
|
|
|
1086
1123
|
if stable_since is None:
|
|
1087
1124
|
stable_since = time.time()
|
|
1088
1125
|
elif time.time() - stable_since >= settle:
|
|
1089
|
-
return result(True, False, "complete",
|
|
1126
|
+
return result(True, False, "complete", time.time() - start)
|
|
1090
1127
|
else:
|
|
1091
1128
|
stable_since = None
|
|
1092
1129
|
if ready and executed is False:
|
|
@@ -1094,19 +1131,31 @@ def wait_for_run(config, run_folder, expected, timeout_s):
|
|
|
1094
1131
|
compiled_since = time.time()
|
|
1095
1132
|
elif time.time() - compiled_since >= stall:
|
|
1096
1133
|
return result(
|
|
1097
|
-
False, False, "compiled_not_executed",
|
|
1134
|
+
False, False, "compiled_not_executed", time.time() - start,
|
|
1098
1135
|
"shots were compiled but show no acquisition data after " + str(int(stall)) +
|
|
1099
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.")
|
|
1100
1137
|
else:
|
|
1101
1138
|
compiled_since = None
|
|
1102
1139
|
last_count = count
|
|
1103
1140
|
last_size = total
|
|
1104
|
-
|
|
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)
|
|
1105
1154
|
if time.time() - start >= timeout:
|
|
1106
1155
|
if count == 0:
|
|
1107
1156
|
diagnosis = "no_shots"
|
|
1108
1157
|
hint = "no shot h5 appeared: check that the run was accepted, that BLACS is running, and that pathMap points at the run folder."
|
|
1109
|
-
elif count <
|
|
1158
|
+
elif count < want:
|
|
1110
1159
|
diagnosis = "compiling"
|
|
1111
1160
|
hint = "only " + str(count) + " of " + str(expected) + " shots appeared before the timeout; the run may still be compiling or blocked."
|
|
1112
1161
|
elif executed is False:
|
|
@@ -1115,7 +1164,7 @@ def wait_for_run(config, run_folder, expected, timeout_s):
|
|
|
1115
1164
|
else:
|
|
1116
1165
|
diagnosis = "partial"
|
|
1117
1166
|
hint = "shots appeared but the run was not confirmed complete before the timeout."
|
|
1118
|
-
return result(False, True, diagnosis,
|
|
1167
|
+
return result(False, True, diagnosis, time.time() - start, hint)
|
|
1119
1168
|
time.sleep(poll)
|
|
1120
1169
|
|
|
1121
1170
|
|
|
@@ -1143,7 +1192,20 @@ def cmd_progress(config, args):
|
|
|
1143
1192
|
expected = int(args.get("expected_n_shots") or 0)
|
|
1144
1193
|
folder = map_path(config, run_folder)
|
|
1145
1194
|
shots = sorted(glob.glob(os.path.join(folder, "**", "*.h5"), recursive=True)) if folder else []
|
|
1146
|
-
|
|
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
|
+
}
|
|
1147
1209
|
|
|
1148
1210
|
|
|
1149
1211
|
|
|
@@ -1530,6 +1592,10 @@ HANDLERS = {
|
|
|
1530
1592
|
|
|
1531
1593
|
|
|
1532
1594
|
def main():
|
|
1595
|
+
try:
|
|
1596
|
+
sys.stdin.reconfigure(encoding="utf-8")
|
|
1597
|
+
except Exception:
|
|
1598
|
+
pass
|
|
1533
1599
|
try:
|
|
1534
1600
|
request = json.load(sys.stdin)
|
|
1535
1601
|
except Exception as exc:
|
|
@@ -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:
|
|
@@ -432,6 +586,41 @@ def test_data_keys_and_scan_summary():
|
|
|
432
586
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
433
587
|
|
|
434
588
|
|
|
589
|
+
def test_engage_with_unicode_note():
|
|
590
|
+
import subprocess
|
|
591
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-unicode-")
|
|
592
|
+
try:
|
|
593
|
+
cfg = make_config(tmp)
|
|
594
|
+
request = {"cmd": "engage", "config": cfg, "args": {"note": "中文备注:测试"}}
|
|
595
|
+
env = dict((k, v) for k, v in os.environ.items() if k not in ("PYTHONUTF8", "PYTHONIOENCODING"))
|
|
596
|
+
proc = subprocess.run(
|
|
597
|
+
[sys.executable, os.path.join(os.path.dirname(HERE), "bridge.py")],
|
|
598
|
+
input=json.dumps(request).encode("utf-8"),
|
|
599
|
+
stdout=subprocess.PIPE,
|
|
600
|
+
stderr=subprocess.PIPE,
|
|
601
|
+
env=env,
|
|
602
|
+
)
|
|
603
|
+
payload = json.loads(proc.stdout.decode("utf-8"))
|
|
604
|
+
assert payload.get("ok") is True, payload
|
|
605
|
+
assert payload["data"]["run_folder"], payload["data"]
|
|
606
|
+
with open(os.path.join(tmp, "history", "events.jsonl"), "r", encoding="utf-8") as handle:
|
|
607
|
+
assert "中文备注" in handle.read()
|
|
608
|
+
finally:
|
|
609
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def test_append_event_never_raises():
|
|
613
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-events-")
|
|
614
|
+
try:
|
|
615
|
+
blocked = os.path.join(tmp, "blocked")
|
|
616
|
+
with open(blocked, "w", encoding="utf-8") as handle:
|
|
617
|
+
handle.write("not a directory")
|
|
618
|
+
cfg = {"panelHistoryDir": os.path.join(blocked, "history")}
|
|
619
|
+
bridge.append_event(cfg, {"tool": "x", "note": "中文"})
|
|
620
|
+
finally:
|
|
621
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
622
|
+
|
|
623
|
+
|
|
435
624
|
def main():
|
|
436
625
|
tests = [value for name, value in sorted(globals().items()) if name.startswith("test_")]
|
|
437
626
|
failures = 0
|