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 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, 38 tests (fake bridge, no python needed)
129
- python python/tests/run_tests.py # 19 tests (needs numpy + h5py)
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 of the first shot (execution facts) and, with scan: a per-shot table of scan values and numeric results. 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.',
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 a per-shot table' },
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((_args, value) => text(lines([
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.scan_table ? 'scan ' + value.scan_table.scan + ': ' + value.scan_table.n_points + ' shots' : '',
391
- value.globals ? 'globals: ' + Object.keys(value.globals).length + ' keys' : '',
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.5",
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
- folder = history_dir(config)
616
- path = os.path.join(folder, "events.jsonl")
617
- with open(path, "a", encoding="utf-8") as handle:
618
- handle.write(json.dumps(jsonable(event), ensure_ascii=False) + "\n")
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
- out["globals"] = jsonable(globals_attrs)
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
- out["scan_table"] = {"scan": scan, "n_points": len(rows), "rows": rows}
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