dsh-labscript 0.2.5 → 0.2.7
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 +25 -0
- package/README.md +2 -2
- package/lib/tools.js +62 -6
- package/package.json +1 -1
- package/python/bridge.py +41 -6
- package/python/tests/run_tests.py +63 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.7
|
|
4
|
+
|
|
5
|
+
- Fix: labs_wait no longer fails with "value is not lossless JSON". Tool results must be
|
|
6
|
+
pure JSON; a completed wait could carry an undefined hint field, which the DSH host
|
|
7
|
+
rejects when recording the result (the run itself was fine). labs_status pending
|
|
8
|
+
progress and the aborted-wait result are cleaned the same way.
|
|
9
|
+
- Fix: non-ASCII arguments (e.g. a Chinese note) no longer break the bridge. Python's
|
|
10
|
+
stdin on Windows uses the locale encoding (GBK); the bridge now reads stdin as UTF-8.
|
|
11
|
+
Such a note used to submit the run and then fail with UnicodeEncodeError in the event
|
|
12
|
+
log, which made a successful engage look failed.
|
|
13
|
+
- Fix: a failed event-log write can no longer turn a successful action into an error; it
|
|
14
|
+
is reported on stderr and the action result is returned as usual.
|
|
15
|
+
- Tests: Unicode note end-to-end through the bridge process, event-log failure
|
|
16
|
+
isolation, and a lossless-JSON check on tool results.
|
|
17
|
+
|
|
18
|
+
## 0.2.6
|
|
19
|
+
|
|
20
|
+
- Fix: labs_data rendered only counts. It now prints selected first-shot globals
|
|
21
|
+
(keys=[...] to choose, ["*"] for all, <missing> for absent keys), the results group
|
|
22
|
+
names when results: true, and a scan axis summary (unique points, min/max, median
|
|
23
|
+
step, numeric column count). Pass limit to print up to 20 raw scan rows; the bridge
|
|
24
|
+
only returns rows when limit is given.
|
|
25
|
+
- Tests: first-shot key selection, scan axis summary and columns, on-demand rows, and
|
|
26
|
+
the rendered output for keys/results/scan/limit.
|
|
27
|
+
|
|
3
28
|
## 0.2.5
|
|
4
29
|
|
|
5
30
|
- labs_status can list every panel global: keys=["*"] prints all of them, sorted
|
package/README.md
CHANGED
|
@@ -125,8 +125,8 @@ is not a physics simulation.
|
|
|
125
125
|
## Development
|
|
126
126
|
|
|
127
127
|
npm install # only runtime dependency: yaml
|
|
128
|
-
npm test # node --test,
|
|
129
|
-
python python/tests/run_tests.py #
|
|
128
|
+
npm test # node --test, 40 tests (fake bridge, no python needed)
|
|
129
|
+
python python/tests/run_tests.py # 22 tests (needs numpy + h5py)
|
|
130
130
|
LABSCRIPT_PYTHON=/path/to/python3 node scripts/demo.mjs # end-to-end mock demo
|
|
131
131
|
|
|
132
132
|
A fresh clone must run `npm install` before `npm test`; the CI workflow does this.
|
package/lib/tools.js
CHANGED
|
@@ -74,6 +74,54 @@ function scanLine(value) {
|
|
|
74
74
|
return 'scan axes: ' + detail + ' n_shots_from_arrays=' + product + (mismatch ? ' (panel n_shots=' + value.n_shots + ' differs)' : '')
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
function resultsLine(value) {
|
|
78
|
+
const layout = value.results_layout
|
|
79
|
+
if (!layout) return ''
|
|
80
|
+
const names = Object.keys(layout).sort()
|
|
81
|
+
if (names.length === 0) return 'results: none'
|
|
82
|
+
return 'results: ' + names.map((group) => group + ' (' + layout[group].length + ' fields)').join(', ')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function dataGlobalsLines(args, value) {
|
|
86
|
+
const globals = value.globals
|
|
87
|
+
if (!globals) return []
|
|
88
|
+
const keys = Array.isArray(args.keys) ? args.keys.map(String) : []
|
|
89
|
+
if (keys.length === 0) {
|
|
90
|
+
return ['globals: ' + Object.keys(globals).length + ' keys (pass keys=[...] or keys=["*"] to print values)']
|
|
91
|
+
}
|
|
92
|
+
const wanted = keys.length === 1 && keys[0] === '*' ? Object.keys(globals).sort() : keys
|
|
93
|
+
return ['globals:'].concat(wanted.map((key) => ' ' + key + ' = ' + (Object.prototype.hasOwnProperty.call(globals, key) ? globalText(globals[key]) : '<missing>')))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function scanSummaryLine(value) {
|
|
97
|
+
const table = value.scan_table
|
|
98
|
+
if (!table) return ''
|
|
99
|
+
const axis = table.axis || {}
|
|
100
|
+
const unique = typeof axis.points === 'number' && axis.points > 0
|
|
101
|
+
? axis.points + ' unique ' + axis.min + '..' + axis.max + (axis.step === null || axis.step === undefined ? '' : ' step ' + axis.step)
|
|
102
|
+
: 'no numeric axis values'
|
|
103
|
+
const columns = Array.isArray(table.columns) ? table.columns.length : 0
|
|
104
|
+
return 'scan ' + table.scan + ': ' + table.n_points + ' shots, ' + unique + ', ' + columns + ' numeric columns' + (Array.isArray(table.rows) ? ' (' + table.rows.length + ' raw rows below)' : '')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function scanRowLines(value) {
|
|
108
|
+
const table = value.scan_table
|
|
109
|
+
if (!table || !Array.isArray(table.rows) || table.rows.length === 0) return []
|
|
110
|
+
const lines = ['rows (first ' + table.rows.length + '):']
|
|
111
|
+
for (const row of table.rows) {
|
|
112
|
+
const parts = []
|
|
113
|
+
if (row.shot !== undefined) parts.push('shot=' + row.shot)
|
|
114
|
+
if (row[table.scan] !== undefined) parts.push(table.scan + '=' + row[table.scan])
|
|
115
|
+
for (const key of Object.keys(row).sort()) {
|
|
116
|
+
if (key === 'shot' || key === table.scan) continue
|
|
117
|
+
parts.push(key + '=' + row[key])
|
|
118
|
+
}
|
|
119
|
+
const line = parts.join(' ')
|
|
120
|
+
lines.push(' ' + (line.length > 240 ? line.slice(0, 240) + '…' : line))
|
|
121
|
+
}
|
|
122
|
+
return lines
|
|
123
|
+
}
|
|
124
|
+
|
|
77
125
|
async function pendingProgress(runtime, exec) {
|
|
78
126
|
const pending = runtime.state.pending
|
|
79
127
|
if (!pending) return null
|
|
@@ -85,6 +133,7 @@ async function pendingProgress(runtime, exec) {
|
|
|
85
133
|
note: pending.note,
|
|
86
134
|
elapsed_s: pending.submitted_at ? Math.round((Date.now() - Date.parse(pending.submitted_at)) / 1000) : undefined,
|
|
87
135
|
}
|
|
136
|
+
for (const key of Object.keys(base)) if (base[key] === undefined) delete base[key]
|
|
88
137
|
if (!pending.run_folder) return base
|
|
89
138
|
try {
|
|
90
139
|
const progress = await runtime.runBridge('progress', {
|
|
@@ -302,7 +351,7 @@ export function registerTools(ctx, runtime) {
|
|
|
302
351
|
runtime.state.waitCancelledByAbort = false
|
|
303
352
|
return {
|
|
304
353
|
run_folder: runFolder,
|
|
305
|
-
expected_n_shots: expected,
|
|
354
|
+
...(expected !== undefined ? { expected_n_shots: expected } : {}),
|
|
306
355
|
complete: false,
|
|
307
356
|
timed_out: false,
|
|
308
357
|
aborted: true,
|
|
@@ -328,6 +377,7 @@ export function registerTools(ctx, runtime) {
|
|
|
328
377
|
mapped_run_folder: waited.mapped_run_folder,
|
|
329
378
|
folder_exists: waited.folder_exists,
|
|
330
379
|
}
|
|
380
|
+
for (const key of Object.keys(out)) if (out[key] === undefined) delete out[key]
|
|
331
381
|
if (waited.complete && runtime.state.lastRun && runtime.state.lastRun.run_folder === runFolder) {
|
|
332
382
|
runtime.setLastRun(Object.assign({}, runtime.state.lastRun, {
|
|
333
383
|
completed_at: new Date().toISOString(),
|
|
@@ -372,23 +422,27 @@ export function registerTools(ctx, runtime) {
|
|
|
372
422
|
|
|
373
423
|
ctx.tools.register({
|
|
374
424
|
name: 'labs_data',
|
|
375
|
-
description: 'Read a run folder HDF5: globals
|
|
425
|
+
description: 'Read a run folder HDF5: first-shot globals (pass keys=[...] to print selected values, ["*"] for all), the results groups with results: true, and with scan: a per-shot scan axis summary (unique points, min/max, median step) plus the numeric result columns; pass limit to print up to 20 raw rows. Read-only. run_folder defaults to the pending run, else the last run submitted through this plugin (persisted across restarts); for a historical run pass its folder explicitly. A missing folder is an error: runmanager\'s shot output is the NEXT run folder and only exists after engage.',
|
|
376
426
|
parameters: {
|
|
377
427
|
type: 'object',
|
|
378
428
|
properties: {
|
|
379
429
|
run_folder: { type: 'string', description: 'Run folder; defaults to the run submitted from this DSH session (pass a historical run folder explicitly)' },
|
|
380
|
-
scan: { type: 'string', description: 'Panel global name used as the scan axis; adds
|
|
430
|
+
scan: { type: 'string', description: 'Panel global name used as the scan axis; adds an axis summary and numeric columns (pass limit for raw rows)' },
|
|
381
431
|
globals: { type: 'boolean', description: 'Include the first shot globals (default true)' },
|
|
432
|
+
keys: { type: 'array', items: { type: 'string' }, description: 'First-shot globals to print; ["*"] prints all. Missing keys show as <missing>.' },
|
|
433
|
+
limit: { type: 'number', minimum: 1, maximum: 20, description: 'How many raw scan rows to print (only with scan; default: none)' },
|
|
382
434
|
results: { type: 'boolean', description: 'Include the results layout' },
|
|
383
435
|
},
|
|
384
436
|
additionalProperties: false,
|
|
385
437
|
},
|
|
386
438
|
isConcurrencySafe: () => true,
|
|
387
|
-
output: looseOutput((
|
|
439
|
+
output: looseOutput((args, value) => text(lines([
|
|
388
440
|
'run: ' + (value.mapped_run_folder || value.run_folder),
|
|
389
441
|
'h5: ' + value.n_h5 + (value.empty ? ' (folder exists, no shots yet)' : ''),
|
|
390
|
-
value
|
|
391
|
-
|
|
442
|
+
resultsLine(value),
|
|
443
|
+
...dataGlobalsLines(args, value),
|
|
444
|
+
scanSummaryLine(value),
|
|
445
|
+
...scanRowLines(value),
|
|
392
446
|
]))),
|
|
393
447
|
async execute(args, exec) {
|
|
394
448
|
const runFolder = args.run_folder || (runtime.state.pending ? runtime.state.pending.run_folder : '') || (runtime.state.lastRun ? runtime.state.lastRun.run_folder : '')
|
|
@@ -396,6 +450,8 @@ export function registerTools(ctx, runtime) {
|
|
|
396
450
|
return runtime.runBridge('data', {
|
|
397
451
|
run_folder: runFolder,
|
|
398
452
|
scan: args.scan,
|
|
453
|
+
keys: Array.isArray(args.keys) ? args.keys : undefined,
|
|
454
|
+
limit: typeof args.limit === 'number' ? args.limit : undefined,
|
|
399
455
|
globals: args.globals !== false,
|
|
400
456
|
results: args.results === true,
|
|
401
457
|
}, { signal: exec.signal })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-labscript",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
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
|
@@ -612,10 +612,13 @@ def snapshot_panel(backend, config, tool, args, touched=None):
|
|
|
612
612
|
|
|
613
613
|
|
|
614
614
|
def append_event(config, event):
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
615
|
+
try:
|
|
616
|
+
folder = history_dir(config)
|
|
617
|
+
path = os.path.join(folder, "events.jsonl")
|
|
618
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
619
|
+
handle.write(json.dumps(jsonable(event), ensure_ascii=False) + "\n")
|
|
620
|
+
except Exception as exc:
|
|
621
|
+
sys.stderr.write("append_event failed: %s\n" % (exc,))
|
|
619
622
|
|
|
620
623
|
|
|
621
624
|
def load_snapshot(config, name):
|
|
@@ -1213,7 +1216,12 @@ def cmd_data(config, args):
|
|
|
1213
1216
|
with h5py.File(shots[0], "r", locking=False) as handle:
|
|
1214
1217
|
globals_attrs = dict(handle["globals"].attrs) if "globals" in handle else {}
|
|
1215
1218
|
if args.get("globals", True):
|
|
1216
|
-
|
|
1219
|
+
selected = jsonable(globals_attrs)
|
|
1220
|
+
keys = args.get("keys")
|
|
1221
|
+
if keys and list(map(str, keys)) != ["*"]:
|
|
1222
|
+
wanted = [str(key) for key in keys]
|
|
1223
|
+
selected = dict((key, selected[key]) for key in wanted if key in selected)
|
|
1224
|
+
out["globals"] = selected
|
|
1217
1225
|
if args.get("results"):
|
|
1218
1226
|
layout = {}
|
|
1219
1227
|
if "results" in handle:
|
|
@@ -1252,7 +1260,30 @@ def cmd_data(config, args):
|
|
|
1252
1260
|
if number is not None:
|
|
1253
1261
|
row[group_name + "." + key] = number
|
|
1254
1262
|
rows.append(row)
|
|
1255
|
-
|
|
1263
|
+
axis_values = [float(row[scan]) for row in rows if isinstance(row.get(scan), (int, float))]
|
|
1264
|
+
unique = sorted(set(axis_values))
|
|
1265
|
+
steps = [round(b - a, 9) for a, b in zip(unique, unique[1:])]
|
|
1266
|
+
columns = sorted(set(key for row in rows for key in row) - {"shot", scan})
|
|
1267
|
+
table = {
|
|
1268
|
+
"scan": scan,
|
|
1269
|
+
"n_points": len(rows),
|
|
1270
|
+
"axis": {
|
|
1271
|
+
"points": len(unique),
|
|
1272
|
+
"min": unique[0] if unique else None,
|
|
1273
|
+
"max": unique[-1] if unique else None,
|
|
1274
|
+
"step": (sorted(steps)[len(steps) // 2] if steps else None),
|
|
1275
|
+
},
|
|
1276
|
+
"columns": columns,
|
|
1277
|
+
}
|
|
1278
|
+
limit = args.get("limit")
|
|
1279
|
+
if limit:
|
|
1280
|
+
try:
|
|
1281
|
+
limit = max(1, min(20, int(limit)))
|
|
1282
|
+
except Exception:
|
|
1283
|
+
limit = 0
|
|
1284
|
+
if limit:
|
|
1285
|
+
table["rows"] = rows[:limit]
|
|
1286
|
+
out["scan_table"] = table
|
|
1256
1287
|
return out
|
|
1257
1288
|
|
|
1258
1289
|
|
|
@@ -1502,6 +1533,10 @@ HANDLERS = {
|
|
|
1502
1533
|
|
|
1503
1534
|
|
|
1504
1535
|
def main():
|
|
1536
|
+
try:
|
|
1537
|
+
sys.stdin.reconfigure(encoding="utf-8")
|
|
1538
|
+
except Exception:
|
|
1539
|
+
pass
|
|
1505
1540
|
try:
|
|
1506
1541
|
request = json.load(sys.stdin)
|
|
1507
1542
|
except Exception as exc:
|
|
@@ -232,7 +232,7 @@ def test_data_reading():
|
|
|
232
232
|
cfg = make_config(tmp)
|
|
233
233
|
started = bridge.cmd_protocol_start(cfg, {"protocol": protocol_raw()})
|
|
234
234
|
run_folder = started["engage"]["run_folder"]
|
|
235
|
-
data = bridge.cmd_data(cfg, {"run_folder": run_folder, "scan": "duration_test_us"})
|
|
235
|
+
data = bridge.cmd_data(cfg, {"run_folder": run_folder, "scan": "duration_test_us", "limit": 1})
|
|
236
236
|
assert data["n_h5"] == 6
|
|
237
237
|
assert data["scan_table"]["n_points"] == 6
|
|
238
238
|
assert data["globals"]["amplitude"] == 0.5
|
|
@@ -405,6 +405,68 @@ def test_status_scan_axes():
|
|
|
405
405
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
406
406
|
|
|
407
407
|
|
|
408
|
+
def test_data_keys_and_scan_summary():
|
|
409
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-data-keys-")
|
|
410
|
+
try:
|
|
411
|
+
cfg = make_config(tmp)
|
|
412
|
+
started = bridge.cmd_protocol_start(cfg, {"protocol": protocol_raw()})
|
|
413
|
+
run_folder = started["engage"]["run_folder"]
|
|
414
|
+
one = bridge.cmd_data(cfg, {"run_folder": run_folder, "keys": ["amplitude"]})
|
|
415
|
+
assert list(one["globals"].keys()) == ["amplitude"], one["globals"]
|
|
416
|
+
absent = bridge.cmd_data(cfg, {"run_folder": run_folder, "keys": ["nope"]})
|
|
417
|
+
assert absent["globals"] == {}, absent["globals"]
|
|
418
|
+
full = bridge.cmd_data(cfg, {"run_folder": run_folder, "keys": ["*"]})
|
|
419
|
+
assert len(full["globals"]) > 1, full["globals"]
|
|
420
|
+
summary = bridge.cmd_data(cfg, {"run_folder": run_folder, "scan": "duration_test_us"})
|
|
421
|
+
table = summary["scan_table"]
|
|
422
|
+
assert table["n_points"] == 6, table
|
|
423
|
+
assert table["axis"]["points"] == 3, table["axis"]
|
|
424
|
+
assert table["axis"]["min"] == 0.0 and table["axis"]["max"] == 2.0, table["axis"]
|
|
425
|
+
assert table["axis"]["step"] == 1.0, table["axis"]
|
|
426
|
+
assert "shot" not in table["columns"] and "duration_test_us" not in table["columns"], table["columns"]
|
|
427
|
+
assert "rows" not in table, list(table.keys())
|
|
428
|
+
limited = bridge.cmd_data(cfg, {"run_folder": run_folder, "scan": "duration_test_us", "limit": 2})
|
|
429
|
+
assert len(limited["scan_table"]["rows"]) == 2, limited["scan_table"]
|
|
430
|
+
assert limited["scan_table"]["rows"][0]["shot"], limited["scan_table"]["rows"][0]
|
|
431
|
+
finally:
|
|
432
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def test_engage_with_unicode_note():
|
|
436
|
+
import subprocess
|
|
437
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-unicode-")
|
|
438
|
+
try:
|
|
439
|
+
cfg = make_config(tmp)
|
|
440
|
+
request = {"cmd": "engage", "config": cfg, "args": {"note": "中文备注:测试"}}
|
|
441
|
+
env = dict((k, v) for k, v in os.environ.items() if k not in ("PYTHONUTF8", "PYTHONIOENCODING"))
|
|
442
|
+
proc = subprocess.run(
|
|
443
|
+
[sys.executable, os.path.join(os.path.dirname(HERE), "bridge.py")],
|
|
444
|
+
input=json.dumps(request).encode("utf-8"),
|
|
445
|
+
stdout=subprocess.PIPE,
|
|
446
|
+
stderr=subprocess.PIPE,
|
|
447
|
+
env=env,
|
|
448
|
+
)
|
|
449
|
+
payload = json.loads(proc.stdout.decode("utf-8"))
|
|
450
|
+
assert payload.get("ok") is True, payload
|
|
451
|
+
assert payload["data"]["run_folder"], payload["data"]
|
|
452
|
+
with open(os.path.join(tmp, "history", "events.jsonl"), "r", encoding="utf-8") as handle:
|
|
453
|
+
assert "中文备注" in handle.read()
|
|
454
|
+
finally:
|
|
455
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def test_append_event_never_raises():
|
|
459
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-events-")
|
|
460
|
+
try:
|
|
461
|
+
blocked = os.path.join(tmp, "blocked")
|
|
462
|
+
with open(blocked, "w", encoding="utf-8") as handle:
|
|
463
|
+
handle.write("not a directory")
|
|
464
|
+
cfg = {"panelHistoryDir": os.path.join(blocked, "history")}
|
|
465
|
+
bridge.append_event(cfg, {"tool": "x", "note": "中文"})
|
|
466
|
+
finally:
|
|
467
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
468
|
+
|
|
469
|
+
|
|
408
470
|
def main():
|
|
409
471
|
tests = [value for name, value in sorted(globals().items()) if name.startswith("test_")]
|
|
410
472
|
failures = 0
|