dsh-labscript 0.2.4 → 0.2.6

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,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.6
4
+
5
+ - Fix: labs_data rendered only counts. It now prints selected first-shot globals
6
+ (keys=[...] to choose, ["*"] for all, <missing> for absent keys), the results group
7
+ names when results: true, and a scan axis summary (unique points, min/max, median
8
+ step, numeric column count). Pass limit to print up to 20 raw scan rows; the bridge
9
+ only returns rows when limit is given.
10
+ - Tests: first-shot key selection, scan axis summary and columns, on-demand rows, and
11
+ the rendered output for keys/results/scan/limit.
12
+
13
+ ## 0.2.5
14
+
15
+ - labs_status can list every panel global: keys=["*"] prints all of them, sorted
16
+ (raw=true prints the raw expressions). This makes leftover array globals visible,
17
+ which is what makes labs_protocol refuse to run with N_SHOTS_MISMATCH.
18
+ - labs_status reports scan axes: the array-valued globals with their lengths and
19
+ n_shots_from_arrays (their product), and flags when the panel n_shots differs from
20
+ that product.
21
+ - labs_wait fails immediately when an explicit run folder does not exist and is not
22
+ this session's pending run (it used to wait for the full timeout), and the result
23
+ shows whether the folder exists.
24
+ - labs_data description: run_folder defaults to the pending run, else the last run
25
+ submitted through this plugin (persisted across restarts).
26
+ - cmd_progress returns folder_exists.
27
+ - Tests: scan axes, all-globals rendering, wait fast-fail, progress folder_exists.
28
+
3
29
  ## 0.2.4
4
30
 
5
31
  - Fix: labs_data no longer answers h5: 0 for a run folder that does not exist. A missing
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, 36 tests (fake bridge, no python needed)
129
- python python/tests/run_tests.py # 18 tests (needs numpy + h5py)
128
+ npm test # node --test, 39 tests (fake bridge, no python needed)
129
+ python python/tests/run_tests.py # 20 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
@@ -46,11 +46,16 @@ function globalText(value, limit = 240) {
46
46
  function globalLines(args, value) {
47
47
  const globals = value.globals || {}
48
48
  const keys = Array.isArray(args.keys) ? args.keys : []
49
+ const raw = args.raw === true && value.raw_globals ? value.raw_globals : null
50
+ if (keys.length === 1 && keys[0] === '*') {
51
+ const shown = raw || globals
52
+ const names = Object.keys(shown).sort()
53
+ return ['globals: all ' + names.length].concat(names.map((key) => ' ' + key + ' = ' + globalText(shown[key])))
54
+ }
49
55
  if (keys.length === 0) {
50
56
  const count = Object.keys(globals).length
51
- return [count > 0 ? 'globals: ' + count + ' keys (pass keys=[...] to read values)' : 'globals: none']
57
+ return [count > 0 ? 'globals: ' + count + ' keys (pass keys=[...] or keys=["*"] to read values)' : 'globals: none']
52
58
  }
53
- const raw = args.raw === true && value.raw_globals ? value.raw_globals : null
54
59
  const out = ['globals:']
55
60
  for (const key of keys) {
56
61
  const shown = raw ? raw[key] : globals[key]
@@ -59,6 +64,64 @@ function globalLines(args, value) {
59
64
  return out
60
65
  }
61
66
 
67
+ function scanLine(value) {
68
+ const axes = value.scan_axes && typeof value.scan_axes === 'object' ? value.scan_axes : {}
69
+ const names = Object.keys(axes).sort()
70
+ const product = value.n_shots_from_arrays
71
+ const mismatch = typeof product === 'number' && typeof value.n_shots === 'number' && value.n_shots !== product
72
+ if (names.length === 0 && !mismatch) return ''
73
+ const detail = names.length ? names.map((key) => key + '=' + axes[key]).join(', ') : 'none'
74
+ return 'scan axes: ' + detail + ' n_shots_from_arrays=' + product + (mismatch ? ' (panel n_shots=' + value.n_shots + ' differs)' : '')
75
+ }
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
+
62
125
  async function pendingProgress(runtime, exec) {
63
126
  const pending = runtime.state.pending
64
127
  if (!pending) return null
@@ -129,11 +192,11 @@ export function registerTools(ctx, runtime) {
129
192
 
130
193
  ctx.tools.register({
131
194
  name: 'labs_status',
132
- description: 'Read the labscript runmanager panel: loaded labscript, globals (evaluated; raw expressions with raw=true), n_shots, error_in_globals, run_shots, shot output folder, pending run progress, and the panel snapshot directory. Read-only; call this before any write. Pass keys=[...] (leaf global names; UI group names are not globals) to print the selected panel values (240 chars each; raw=true prints the raw expression), missing keys are shown as <missing>. The shot output folder is the folder the NEXT run will use; it does not exist until engage. Paths in the output are already mapped for this machine through pathMap; do not map them again.',
195
+ description: 'Read the labscript runmanager panel: loaded labscript, globals (evaluated; raw expressions with raw=true), n_shots, error_in_globals, run_shots, shot output folder, pending run progress, and the panel snapshot directory. Read-only; call this before any write. Pass keys=[...] (leaf global names; UI group names are not globals) to print the selected panel values (240 chars each; raw=true prints the raw expression), missing keys are shown as <missing>; keys=["*"] prints every global. scan axes lists array-valued globals (their lengths multiply to n_shots_from_arrays) and a differing panel n_shots is flagged. The shot output folder is the folder the NEXT run will use; it does not exist until engage. Paths in the output are already mapped for this machine through pathMap; do not map them again.',
133
196
  parameters: {
134
197
  type: 'object',
135
198
  properties: {
136
- keys: { type: 'array', items: { type: 'string' }, description: 'Only return these panel globals' },
199
+ keys: { type: 'array', items: { type: 'string' }, description: 'Only return these panel globals; ["*"] returns every global' },
137
200
  raw: { type: 'boolean', description: 'Also return raw (expression) values' },
138
201
  },
139
202
  additionalProperties: false,
@@ -146,11 +209,14 @@ export function registerTools(ctx, runtime) {
146
209
  'n_shots: ' + value.n_shots + ' error_in_globals: ' + value.error_in_globals + ' run_shots: ' + value.run_shots,
147
210
  progressLine(value.last_run),
148
211
  'panel history: ' + (value.panel_history ? value.panel_history.dir + ' (' + value.panel_history.count + ' snapshots)' : 'n/a'),
212
+ scanLine(value),
149
213
  ...globalLines(args, value),
150
214
  ]))),
151
215
  async execute(args, exec) {
216
+ const keys = Array.isArray(args.keys) ? args.keys : []
217
+ const all = keys.length === 1 && keys[0] === '*'
152
218
  const data = await runtime.runBridge('status', {
153
- keys: Array.isArray(args.keys) ? args.keys : undefined,
219
+ keys: all || keys.length === 0 ? undefined : keys,
154
220
  raw: args.raw === true,
155
221
  }, { signal: exec.signal })
156
222
  if (runtime.state.pending) data.last_run = await pendingProgress(runtime, exec)
@@ -230,7 +296,7 @@ export function registerTools(ctx, runtime) {
230
296
 
231
297
  ctx.tools.register({
232
298
  name: 'labs_wait',
233
- 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.',
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.',
234
300
  parameters: {
235
301
  type: 'object',
236
302
  properties: {
@@ -244,6 +310,7 @@ export function registerTools(ctx, runtime) {
244
310
  output: looseOutput((_args, value) => text(lines([
245
311
  'run: ' + (value.mapped_run_folder || value.run_folder),
246
312
  value.aborted ? 'wait cancelled by labs_abort (run aborted)' : '',
313
+ value.folder_exists === false ? 'folder: missing' : '',
247
314
  'shots: ' + value.n_h5 + '/' + (value.expected_n_shots || '?') + ' complete: ' + value.complete + (value.timed_out ? ' TIMED OUT' : '') + (value.diagnosis ? ' diagnosis: ' + value.diagnosis : ''),
248
315
  value.hint ? 'hint: ' + value.hint : '',
249
316
  value.finalize && value.finalize.postprocess ? 'postprocess: ' + JSON.stringify(value.finalize.postprocess).slice(0, 400) : '',
@@ -256,6 +323,20 @@ export function registerTools(ctx, runtime) {
256
323
  const runFolder = args.run_folder || (pending ? pending.run_folder : '')
257
324
  if (!runFolder) throw new LabError('NO_PENDING_RUN', 'no run folder: submit with labs_engage or labs_protocol run first, or pass run_folder')
258
325
  const expected = pending && pending.run_folder === runFolder ? pending.expected_n_shots : undefined
326
+ if (!(pending && pending.run_folder === runFolder)) {
327
+ let probe = null
328
+ try {
329
+ probe = await runtime.runBridge('progress', {
330
+ run_folder: runFolder,
331
+ expected_n_shots: expected,
332
+ }, { signal: exec.signal })
333
+ } catch (error) {
334
+ probe = null
335
+ }
336
+ if (probe && probe.folder_exists === false) {
337
+ throw new LabError('RUN_FOLDER_MISSING', 'run folder does not exist: ' + (probe.mapped_run_folder || runFolder) + '. Pass the folder of a run that exists (labs_engage / labs_protocol return one), or check pathMap.')
338
+ }
339
+ }
259
340
  const timeoutS = Number(args.timeout_s || runtime.config.engage.timeoutS || 900)
260
341
  let waited
261
342
  try {
@@ -339,23 +420,27 @@ export function registerTools(ctx, runtime) {
339
420
 
340
421
  ctx.tools.register({
341
422
  name: 'labs_data',
342
- 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 run this DSH session submitted; 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.',
423
+ 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.',
343
424
  parameters: {
344
425
  type: 'object',
345
426
  properties: {
346
427
  run_folder: { type: 'string', description: 'Run folder; defaults to the run submitted from this DSH session (pass a historical run folder explicitly)' },
347
- scan: { type: 'string', description: 'Panel global name used as the scan axis; adds a per-shot table' },
428
+ scan: { type: 'string', description: 'Panel global name used as the scan axis; adds an axis summary and numeric columns (pass limit for raw rows)' },
348
429
  globals: { type: 'boolean', description: 'Include the first shot globals (default true)' },
430
+ keys: { type: 'array', items: { type: 'string' }, description: 'First-shot globals to print; ["*"] prints all. Missing keys show as <missing>.' },
431
+ limit: { type: 'number', minimum: 1, maximum: 20, description: 'How many raw scan rows to print (only with scan; default: none)' },
349
432
  results: { type: 'boolean', description: 'Include the results layout' },
350
433
  },
351
434
  additionalProperties: false,
352
435
  },
353
436
  isConcurrencySafe: () => true,
354
- output: looseOutput((_args, value) => text(lines([
437
+ output: looseOutput((args, value) => text(lines([
355
438
  'run: ' + (value.mapped_run_folder || value.run_folder),
356
439
  'h5: ' + value.n_h5 + (value.empty ? ' (folder exists, no shots yet)' : ''),
357
- value.scan_table ? 'scan ' + value.scan_table.scan + ': ' + value.scan_table.n_points + ' shots' : '',
358
- value.globals ? 'globals: ' + Object.keys(value.globals).length + ' keys' : '',
440
+ resultsLine(value),
441
+ ...dataGlobalsLines(args, value),
442
+ scanSummaryLine(value),
443
+ ...scanRowLines(value),
359
444
  ]))),
360
445
  async execute(args, exec) {
361
446
  const runFolder = args.run_folder || (runtime.state.pending ? runtime.state.pending.run_folder : '') || (runtime.state.lastRun ? runtime.state.lastRun.run_folder : '')
@@ -363,6 +448,8 @@ export function registerTools(ctx, runtime) {
363
448
  return runtime.runBridge('data', {
364
449
  run_folder: runFolder,
365
450
  scan: args.scan,
451
+ keys: Array.isArray(args.keys) ? args.keys : undefined,
452
+ limit: typeof args.limit === 'number' ? args.limit : undefined,
366
453
  globals: args.globals !== false,
367
454
  results: args.results === true,
368
455
  }, { signal: exec.signal })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-labscript",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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
@@ -919,10 +919,24 @@ def cmd_status(config, args):
919
919
  backend = make_backend(config)
920
920
  keys = args.get("keys")
921
921
  raw = bool(args.get("raw"))
922
- values = backend.get_globals(raw=False)
922
+ panel_values = backend.get_globals(raw=False)
923
923
  raw_values = backend.get_globals(raw=True)
924
+ scan_axes = {}
925
+ n_shots_from_arrays = 1
926
+ for name, value in panel_values.items():
927
+ if isinstance(value, str) or not hasattr(value, "__len__"):
928
+ continue
929
+ try:
930
+ size = int(len(value))
931
+ except Exception:
932
+ continue
933
+ if size <= 1:
934
+ continue
935
+ scan_axes[str(name)] = size
936
+ n_shots_from_arrays *= size
937
+ values = dict(panel_values)
924
938
  if keys:
925
- values = dict((k, values[k]) for k in keys if k in values)
939
+ values = dict((k, panel_values[k]) for k in keys if k in panel_values)
926
940
  raw_values = dict((k, raw_values[k]) for k in keys if k in raw_values)
927
941
  out = {
928
942
  "backend": config.get("backend"),
@@ -930,6 +944,8 @@ def cmd_status(config, args):
930
944
  "labscript": map_path(config, backend.labscript_file()),
931
945
  "shot_output_folder": map_path(config, backend.shot_output_folder()),
932
946
  "globals": jsonable(values),
947
+ "scan_axes": scan_axes,
948
+ "n_shots_from_arrays": n_shots_from_arrays,
933
949
  "n_shots": backend.n_shots(),
934
950
  "error_in_globals": backend.error_in_globals(),
935
951
  "run_shots": backend.run_shots(),
@@ -1127,7 +1143,7 @@ def cmd_progress(config, args):
1127
1143
  expected = int(args.get("expected_n_shots") or 0)
1128
1144
  folder = map_path(config, run_folder)
1129
1145
  shots = sorted(glob.glob(os.path.join(folder, "**", "*.h5"), recursive=True)) if folder else []
1130
- return {"run_folder": run_folder, "mapped_run_folder": folder, "n_h5": len(shots), "expected_n_shots": expected, "complete": bool(expected and len(shots) >= expected)}
1146
+ return {"run_folder": run_folder, "mapped_run_folder": folder, "folder_exists": bool(folder) and os.path.isdir(folder), "n_h5": len(shots), "expected_n_shots": expected, "complete": bool(expected and len(shots) >= expected)}
1131
1147
 
1132
1148
 
1133
1149
 
@@ -1197,7 +1213,12 @@ def cmd_data(config, args):
1197
1213
  with h5py.File(shots[0], "r", locking=False) as handle:
1198
1214
  globals_attrs = dict(handle["globals"].attrs) if "globals" in handle else {}
1199
1215
  if args.get("globals", True):
1200
- out["globals"] = jsonable(globals_attrs)
1216
+ selected = jsonable(globals_attrs)
1217
+ keys = args.get("keys")
1218
+ if keys and list(map(str, keys)) != ["*"]:
1219
+ wanted = [str(key) for key in keys]
1220
+ selected = dict((key, selected[key]) for key in wanted if key in selected)
1221
+ out["globals"] = selected
1201
1222
  if args.get("results"):
1202
1223
  layout = {}
1203
1224
  if "results" in handle:
@@ -1236,7 +1257,30 @@ def cmd_data(config, args):
1236
1257
  if number is not None:
1237
1258
  row[group_name + "." + key] = number
1238
1259
  rows.append(row)
1239
- out["scan_table"] = {"scan": scan, "n_points": len(rows), "rows": rows}
1260
+ axis_values = [float(row[scan]) for row in rows if isinstance(row.get(scan), (int, float))]
1261
+ unique = sorted(set(axis_values))
1262
+ steps = [round(b - a, 9) for a, b in zip(unique, unique[1:])]
1263
+ columns = sorted(set(key for row in rows for key in row) - {"shot", scan})
1264
+ table = {
1265
+ "scan": scan,
1266
+ "n_points": len(rows),
1267
+ "axis": {
1268
+ "points": len(unique),
1269
+ "min": unique[0] if unique else None,
1270
+ "max": unique[-1] if unique else None,
1271
+ "step": (sorted(steps)[len(steps) // 2] if steps else None),
1272
+ },
1273
+ "columns": columns,
1274
+ }
1275
+ limit = args.get("limit")
1276
+ if limit:
1277
+ try:
1278
+ limit = max(1, min(20, int(limit)))
1279
+ except Exception:
1280
+ limit = 0
1281
+ if limit:
1282
+ table["rows"] = rows[:limit]
1283
+ out["scan_table"] = table
1240
1284
  return out
1241
1285
 
1242
1286
 
@@ -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
@@ -344,6 +344,7 @@ def test_progress_reports_mapped_run_folder():
344
344
  out = bridge.cmd_progress(cfg, {"run_folder": "E:\\Experiments\\nope", "expected_n_shots": 1})
345
345
  assert out["run_folder"] == "E:\\Experiments\\nope", out
346
346
  assert out["mapped_run_folder"] == "\\\\lab-pc\\Experiments\\nope", out
347
+ assert out["folder_exists"] is False, out
347
348
 
348
349
 
349
350
  def test_data_missing_run_folder():
@@ -388,6 +389,49 @@ def test_status_drops_missing_keys():
388
389
  shutil.rmtree(tmp, ignore_errors=True)
389
390
 
390
391
 
392
+ def test_status_scan_axes():
393
+ tmp = tempfile.mkdtemp(prefix="dsh-labscript-scan-axes-")
394
+ try:
395
+ cfg = make_config(tmp)
396
+ bridge.cmd_set(cfg, {"values": {"duration_test_us": [0.0, 0.5, 1.0]}})
397
+ out = bridge.cmd_status(cfg, {})
398
+ assert out["scan_axes"].get("duration_test_us") == 3, out["scan_axes"]
399
+ assert out["n_shots_from_arrays"] == 3, out["n_shots_from_arrays"]
400
+ assert out["n_shots"] == 3, out["n_shots"]
401
+ partial = bridge.cmd_status(cfg, {"keys": ["amplitude"]})
402
+ assert "duration_test_us" not in partial["globals"], partial["globals"]
403
+ assert partial["scan_axes"].get("duration_test_us") == 3, partial["scan_axes"]
404
+ finally:
405
+ shutil.rmtree(tmp, ignore_errors=True)
406
+
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
+
391
435
  def main():
392
436
  tests = [value for name, value in sorted(globals().items()) if name.startswith("test_")]
393
437
  failures = 0