dsh-labscript 0.2.7 → 0.3.0
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 +72 -0
- package/README.md +63 -8
- package/docs/README.zh.md +27 -5
- package/examples/config.example.yml +3 -1
- package/lib/client.js +7 -7
- package/lib/skill.js +32 -6
- package/lib/store.mjs +17 -9
- package/lib/tools.js +106 -37
- package/package.json +1 -1
- package/python/bridge.py +171 -49
- package/python/tests/run_tests.py +269 -0
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"}
|
|
@@ -194,6 +211,28 @@ def eval_panel_value(raw):
|
|
|
194
211
|
return str(raw)
|
|
195
212
|
|
|
196
213
|
|
|
214
|
+
def axis_length(value):
|
|
215
|
+
"""Length of the axis runmanager expands from this global, or None.
|
|
216
|
+
|
|
217
|
+
Heuristic: runmanager's automatic expansion turns list/array globals into axes
|
|
218
|
+
("outer") and passes tuples through whole, so a tuple is not an axis. Expansion
|
|
219
|
+
settings (none / zip group) are not visible from runmanager.remote, so a list that
|
|
220
|
+
was switched to "none" still looks like an axis here. The panel's n_shots is the
|
|
221
|
+
authoritative shot count; this is only a hint.
|
|
222
|
+
"""
|
|
223
|
+
if isinstance(value, (str, bytes, dict, tuple)):
|
|
224
|
+
return None
|
|
225
|
+
if not hasattr(value, "__len__"):
|
|
226
|
+
return None
|
|
227
|
+
ndim = getattr(value, "ndim", None)
|
|
228
|
+
if ndim is not None:
|
|
229
|
+
return int(value.size) if ndim >= 1 else None
|
|
230
|
+
try:
|
|
231
|
+
return int(len(value))
|
|
232
|
+
except Exception:
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
197
236
|
def raw_repr(value):
|
|
198
237
|
if isinstance(value, bool):
|
|
199
238
|
return "True" if value else "False"
|
|
@@ -280,18 +319,39 @@ class LabscriptBackend(object):
|
|
|
280
319
|
from runmanager.remote import Client
|
|
281
320
|
except Exception as exc:
|
|
282
321
|
raise BridgeError("BACKEND_UNAVAILABLE", "labscript python environment not importable: " + str(exc))
|
|
322
|
+
# Anything left unset is resolved by runmanager.remote.Client from labscript's
|
|
323
|
+
# labconfig ([servers] runmanager, [ports] runmanager, [timeouts]
|
|
324
|
+
# communication_timeout), the same way the runmanager GUI does it. Explicit
|
|
325
|
+
# config always wins.
|
|
283
326
|
rm = self.config.get("runmanager") or {}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
327
|
+
kwargs = {}
|
|
328
|
+
if rm.get("host"):
|
|
329
|
+
kwargs["host"] = rm["host"]
|
|
330
|
+
if rm.get("port"):
|
|
331
|
+
kwargs["port"] = int(rm["port"])
|
|
332
|
+
if rm.get("timeoutS"):
|
|
333
|
+
kwargs["timeout"] = float(rm["timeoutS"])
|
|
334
|
+
try:
|
|
335
|
+
client = Client(**kwargs)
|
|
336
|
+
except Exception as exc:
|
|
337
|
+
raise BridgeError("BACKEND_UNAVAILABLE", "cannot create a runmanager client: " + type(exc).__name__ + ": " + str(exc))
|
|
287
338
|
try:
|
|
288
|
-
client = Client(host=host, port=port, timeout=timeout)
|
|
289
339
|
client.say_hello()
|
|
290
340
|
except Exception as exc:
|
|
291
|
-
raise BridgeError("BACKEND_UNAVAILABLE", "runmanager not reachable at " + host + ":" + str(port) + ": " + str(exc))
|
|
341
|
+
raise BridgeError("BACKEND_UNAVAILABLE", "runmanager not reachable at " + str(getattr(client, "host", "?")) + ":" + str(getattr(client, "port", "?")) + ": " + str(exc))
|
|
292
342
|
self._client = client
|
|
293
343
|
return client
|
|
294
344
|
|
|
345
|
+
def endpoint(self):
|
|
346
|
+
"""Where this client points, and whether the values came from config or labconfig."""
|
|
347
|
+
rm = self.config.get("runmanager") or {}
|
|
348
|
+
source = "config" if (rm.get("host") or rm.get("port")) else "labconfig"
|
|
349
|
+
try:
|
|
350
|
+
client = self.client()
|
|
351
|
+
return str(client.host) + ":" + str(client.port) + " (" + source + ", timeout " + str(client.timeout) + "s)"
|
|
352
|
+
except Exception as exc:
|
|
353
|
+
return "<unknown> (" + source + "): " + str(exc)
|
|
354
|
+
|
|
295
355
|
def version(self):
|
|
296
356
|
try:
|
|
297
357
|
return str(self.client().get_version())
|
|
@@ -302,7 +362,11 @@ class LabscriptBackend(object):
|
|
|
302
362
|
return dict(self.client().get_globals(raw=raw))
|
|
303
363
|
|
|
304
364
|
def set_globals(self, values, raw=False):
|
|
305
|
-
|
|
365
|
+
# runmanager's set_globals is a side-effect RPC: its handler returns None, so
|
|
366
|
+
# never feed the reply to dict(). Report what was requested; the caller reads
|
|
367
|
+
# the panel back to verify.
|
|
368
|
+
written = self.client().set_globals(values, raw=raw)
|
|
369
|
+
return dict(written) if isinstance(written, dict) else dict(values)
|
|
306
370
|
|
|
307
371
|
def n_shots(self):
|
|
308
372
|
return int(self.client().n_shots())
|
|
@@ -316,6 +380,13 @@ class LabscriptBackend(object):
|
|
|
316
380
|
except Exception:
|
|
317
381
|
return None
|
|
318
382
|
|
|
383
|
+
def shuffle(self):
|
|
384
|
+
# Older runmanager versions have no get_shuffle; report unknown instead of failing.
|
|
385
|
+
try:
|
|
386
|
+
return bool(self.client().get_shuffle())
|
|
387
|
+
except Exception:
|
|
388
|
+
return None
|
|
389
|
+
|
|
319
390
|
def labscript_file(self):
|
|
320
391
|
try:
|
|
321
392
|
return self.client().get_labscript_file()
|
|
@@ -394,7 +465,9 @@ class MockBackend(object):
|
|
|
394
465
|
panel = self.read_panel()
|
|
395
466
|
if raw:
|
|
396
467
|
return dict(panel)
|
|
397
|
-
|
|
468
|
+
# Keep Python types (tuple vs list matters to the expansion rules); callers
|
|
469
|
+
# jsonable() values before they leave the bridge.
|
|
470
|
+
return dict((k, eval_panel_value(v)) for k, v in panel.items())
|
|
398
471
|
|
|
399
472
|
def set_globals(self, values, raw=False):
|
|
400
473
|
panel = self.read_panel()
|
|
@@ -404,17 +477,21 @@ class MockBackend(object):
|
|
|
404
477
|
return dict((k, panel[k]) for k in values)
|
|
405
478
|
|
|
406
479
|
def n_shots(self):
|
|
407
|
-
import numpy as np
|
|
408
480
|
total = 1
|
|
409
481
|
for value in self.get_globals(raw=False).values():
|
|
410
|
-
|
|
411
|
-
if
|
|
482
|
+
length = axis_length(value)
|
|
483
|
+
if length is None or length <= 0:
|
|
412
484
|
continue
|
|
413
|
-
total *=
|
|
485
|
+
total *= length
|
|
414
486
|
return int(total)
|
|
415
487
|
|
|
416
488
|
def error_in_globals(self):
|
|
417
|
-
|
|
489
|
+
# Simulator switch: mock.errorInGlobals makes the mock panel report an evaluation
|
|
490
|
+
# error, so the GLOBALS_ERROR refusal path can be tested without real hardware.
|
|
491
|
+
return bool((self.config.get("mock") or {}).get("errorInGlobals"))
|
|
492
|
+
|
|
493
|
+
def shuffle(self):
|
|
494
|
+
return bool((self.config.get("mock") or {}).get("shuffle"))
|
|
418
495
|
|
|
419
496
|
def run_shots(self):
|
|
420
497
|
return True
|
|
@@ -431,11 +508,8 @@ class MockBackend(object):
|
|
|
431
508
|
values = self.get_globals(raw=False)
|
|
432
509
|
arrays = {}
|
|
433
510
|
for key, value in values.items():
|
|
434
|
-
if
|
|
435
|
-
|
|
436
|
-
arr = np.asarray(value)
|
|
437
|
-
if arr.ndim >= 1 and arr.size > 1:
|
|
438
|
-
arrays[key] = arr
|
|
511
|
+
if (axis_length(value) or 0) > 1:
|
|
512
|
+
arrays[key] = np.asarray(value).ravel()
|
|
439
513
|
total = 1
|
|
440
514
|
for arr in arrays.values():
|
|
441
515
|
total *= int(arr.size)
|
|
@@ -544,6 +618,10 @@ class HttpSimBackend(object):
|
|
|
544
618
|
def run_shots(self):
|
|
545
619
|
return True
|
|
546
620
|
|
|
621
|
+
def shuffle(self):
|
|
622
|
+
snap = self.snapshot()
|
|
623
|
+
return bool(snap.get("shuffle")) if "shuffle" in snap else None
|
|
624
|
+
|
|
547
625
|
def labscript_file(self):
|
|
548
626
|
return self.snapshot().get("labscript_file")
|
|
549
627
|
|
|
@@ -927,13 +1005,8 @@ def cmd_status(config, args):
|
|
|
927
1005
|
scan_axes = {}
|
|
928
1006
|
n_shots_from_arrays = 1
|
|
929
1007
|
for name, value in panel_values.items():
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
try:
|
|
933
|
-
size = int(len(value))
|
|
934
|
-
except Exception:
|
|
935
|
-
continue
|
|
936
|
-
if size <= 1:
|
|
1008
|
+
size = axis_length(value)
|
|
1009
|
+
if size is None or size <= 1:
|
|
937
1010
|
continue
|
|
938
1011
|
scan_axes[str(name)] = size
|
|
939
1012
|
n_shots_from_arrays *= size
|
|
@@ -952,6 +1025,7 @@ def cmd_status(config, args):
|
|
|
952
1025
|
"n_shots": backend.n_shots(),
|
|
953
1026
|
"error_in_globals": backend.error_in_globals(),
|
|
954
1027
|
"run_shots": backend.run_shots(),
|
|
1028
|
+
"shuffle": backend.shuffle() if hasattr(backend, "shuffle") else None,
|
|
955
1029
|
"panel_history": panel_history_info(config),
|
|
956
1030
|
}
|
|
957
1031
|
if raw:
|
|
@@ -988,21 +1062,25 @@ def cmd_set(config, args):
|
|
|
988
1062
|
snap = snapshot_panel(backend, config, "set", {"keys": sorted(values.keys()), "raw": raw, "note": args.get("note")}, touched=values.keys())
|
|
989
1063
|
try:
|
|
990
1064
|
written = backend.set_globals(values, raw=raw)
|
|
991
|
-
except BridgeError as exc:
|
|
992
|
-
detail = dict(exc.detail or {})
|
|
993
|
-
detail["snapshot"] = snap
|
|
994
|
-
detail["hint"] = "set_globals failed after the panel snapshot; the panel may be partially written. Use labs_restore to undo."
|
|
995
|
-
raise BridgeError(exc.code, exc.message, detail)
|
|
996
1065
|
except Exception as exc:
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1066
|
+
detail = dict(getattr(exc, "detail", None) or {})
|
|
1067
|
+
detail["snapshot"] = snap
|
|
1068
|
+
# A failed write can still have landed (runmanager writes each key in turn),
|
|
1069
|
+
# so report what the panel holds now instead of guessing in the hint.
|
|
1070
|
+
try:
|
|
1071
|
+
now = backend.get_globals(raw=True)
|
|
1072
|
+
detail["landed"] = dict((k, jsonable(now.get(k))) for k in values)
|
|
1073
|
+
except Exception:
|
|
1074
|
+
pass
|
|
1075
|
+
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"
|
|
1076
|
+
raise BridgeError(getattr(exc, "code", None) or "SET_FAILED", "set_globals failed: " + type(exc).__name__ + ": " + str(exc), detail)
|
|
1001
1077
|
readback = backend.get_globals(raw=False)
|
|
1002
1078
|
raw_readback = backend.get_globals(raw=True)
|
|
1003
1079
|
missing = [k for k in values if k not in readback]
|
|
1004
1080
|
out = {
|
|
1005
|
-
|
|
1081
|
+
# Report what was asked for even if a backend returns something else (runmanager's
|
|
1082
|
+
# RPC returns nothing); the readback below carries the truth from the panel.
|
|
1083
|
+
"written": jsonable(written) if isinstance(written, dict) else dict((k, jsonable(v)) for k, v in values.items()),
|
|
1006
1084
|
"readback": dict((k, jsonable(readback.get(k))) for k in values),
|
|
1007
1085
|
"raw_readback": dict((k, jsonable(raw_readback.get(k))) for k in values),
|
|
1008
1086
|
"missing": missing,
|
|
@@ -1050,24 +1128,31 @@ def wait_for_run(config, run_folder, expected, timeout_s):
|
|
|
1050
1128
|
poll = float(engage_cfg.get("pollS") or 1.0)
|
|
1051
1129
|
settle = float(engage_cfg.get("settleS") or 2.0)
|
|
1052
1130
|
stall = float(engage_cfg.get("stallS") or 20.0)
|
|
1131
|
+
# labscript writes each shot h5 before compiling it, so a healthy compilation keeps
|
|
1132
|
+
# adding files. When nothing has appeared for this long the compile almost certainly
|
|
1133
|
+
# aborted; returning a diagnosis beats blocking for the full timeout_s.
|
|
1134
|
+
no_shot = float(engage_cfg.get("noShotS") or 25.0)
|
|
1135
|
+
want = int(expected or 0)
|
|
1053
1136
|
start = time.time()
|
|
1137
|
+
last_progress = start
|
|
1054
1138
|
stable_since = None
|
|
1055
1139
|
compiled_since = None
|
|
1056
1140
|
last_count = -1
|
|
1057
1141
|
last_size = -1
|
|
1058
|
-
|
|
1142
|
+
shots = []
|
|
1059
1143
|
|
|
1060
|
-
def result(complete, timed_out, diagnosis,
|
|
1144
|
+
def result(complete, timed_out, diagnosis, elapsed, hint=None):
|
|
1061
1145
|
payload = {
|
|
1062
1146
|
"run_folder": run_folder,
|
|
1063
1147
|
"mapped_run_folder": folder,
|
|
1064
1148
|
"folder_exists": os.path.isdir(folder),
|
|
1065
1149
|
"expected_n_shots": expected,
|
|
1066
|
-
"n_h5":
|
|
1150
|
+
"n_h5": len(shots),
|
|
1151
|
+
"n_executed": executed_prefix(shots),
|
|
1067
1152
|
"complete": complete,
|
|
1068
1153
|
"timed_out": timed_out,
|
|
1069
1154
|
"diagnosis": diagnosis,
|
|
1070
|
-
"executed":
|
|
1155
|
+
"executed": shot_executed(shots[-1]) if shots else None,
|
|
1071
1156
|
"elapsed": round(elapsed, 1),
|
|
1072
1157
|
}
|
|
1073
1158
|
if hint:
|
|
@@ -1078,8 +1163,10 @@ def wait_for_run(config, run_folder, expected, timeout_s):
|
|
|
1078
1163
|
shots = sorted(glob.glob(os.path.join(folder, "**", "*.h5"), recursive=True))
|
|
1079
1164
|
count = len(shots)
|
|
1080
1165
|
total = sum(os.path.getsize(p) for p in shots) if shots else 0
|
|
1166
|
+
if count != last_count or total != last_size:
|
|
1167
|
+
last_progress = time.time()
|
|
1081
1168
|
ready = False
|
|
1082
|
-
if count >= (
|
|
1169
|
+
if count >= (want or 0) and count == last_count and total == last_size and shots:
|
|
1083
1170
|
ready = shot_ready(shots[-1])
|
|
1084
1171
|
executed = None
|
|
1085
1172
|
if ready:
|
|
@@ -1089,7 +1176,12 @@ def wait_for_run(config, run_folder, expected, timeout_s):
|
|
|
1089
1176
|
if stable_since is None:
|
|
1090
1177
|
stable_since = time.time()
|
|
1091
1178
|
elif time.time() - stable_since >= settle:
|
|
1092
|
-
|
|
1179
|
+
hint = None
|
|
1180
|
+
if want and len(shots) != want:
|
|
1181
|
+
hint = ("finished with " + str(len(shots)) + " shot files for expected_n_shots=" + str(want) +
|
|
1182
|
+
": the panel's n_shots changed after submit, or shots were re-run (BLACS Repeat all / "
|
|
1183
|
+
"Repeat last re-executes existing shot files and does not create new ones).")
|
|
1184
|
+
return result(True, False, "complete", time.time() - start, hint)
|
|
1093
1185
|
else:
|
|
1094
1186
|
stable_since = None
|
|
1095
1187
|
if ready and executed is False:
|
|
@@ -1097,28 +1189,45 @@ def wait_for_run(config, run_folder, expected, timeout_s):
|
|
|
1097
1189
|
compiled_since = time.time()
|
|
1098
1190
|
elif time.time() - compiled_since >= stall:
|
|
1099
1191
|
return result(
|
|
1100
|
-
False, False, "compiled_not_executed",
|
|
1101
|
-
"
|
|
1102
|
-
"
|
|
1192
|
+
False, False, "compiled_not_executed", time.time() - start,
|
|
1193
|
+
"shot files exist but show no acquisition data after " + str(int(stall)) + " s: BLACS may be "
|
|
1194
|
+
"paused, or it kept the shot out of the queue (its connection table must be a subset of the "
|
|
1195
|
+
"lab connection table) or a device group failed to program within 5 minutes (the queue then "
|
|
1196
|
+
"pauses and puts that shot back at its head), or the run is still being programmed. Check the "
|
|
1197
|
+
"BLACS GUI and the panel, then labs_abort if needed.")
|
|
1103
1198
|
else:
|
|
1104
1199
|
compiled_since = None
|
|
1105
1200
|
last_count = count
|
|
1106
1201
|
last_size = total
|
|
1107
|
-
|
|
1202
|
+
quiet = time.time() - last_progress
|
|
1203
|
+
if want and count < want and quiet >= no_shot:
|
|
1204
|
+
if count == 0:
|
|
1205
|
+
diagnosis = "no_shots"
|
|
1206
|
+
hint = ("no shot h5 appeared in " + str(int(no_shot)) + " s: the compilation probably aborted "
|
|
1207
|
+
"(runmanager shows the error) or BLACS is not running. Check runmanager, call labs_wait "
|
|
1208
|
+
"again to keep waiting, or labs_abort.")
|
|
1209
|
+
else:
|
|
1210
|
+
diagnosis = "compile_stalled"
|
|
1211
|
+
hint = ("compilation stopped at " + str(count) + " of " + str(want) + " shot h5 files and "
|
|
1212
|
+
"nothing changed for " + str(int(no_shot)) + " s: it probably aborted (runmanager shows "
|
|
1213
|
+
"the error). Check runmanager, call labs_wait again to keep waiting, or labs_abort.")
|
|
1214
|
+
return result(False, False, diagnosis, time.time() - start, hint)
|
|
1108
1215
|
if time.time() - start >= timeout:
|
|
1109
1216
|
if count == 0:
|
|
1110
1217
|
diagnosis = "no_shots"
|
|
1111
1218
|
hint = "no shot h5 appeared: check that the run was accepted, that BLACS is running, and that pathMap points at the run folder."
|
|
1112
|
-
elif count <
|
|
1219
|
+
elif count < want:
|
|
1113
1220
|
diagnosis = "compiling"
|
|
1114
1221
|
hint = "only " + str(count) + " of " + str(expected) + " shots appeared before the timeout; the run may still be compiling or blocked."
|
|
1115
1222
|
elif executed is False:
|
|
1116
1223
|
diagnosis = "compiled_not_executed"
|
|
1117
|
-
hint = "
|
|
1224
|
+
hint = ("shot files exist but no acquisition data was found: BLACS may be paused, may have refused the "
|
|
1225
|
+
"shot (connection table not a subset of the lab connection table) or re-queued it after a device "
|
|
1226
|
+
"programming timeout. Check the BLACS GUI.")
|
|
1118
1227
|
else:
|
|
1119
1228
|
diagnosis = "partial"
|
|
1120
1229
|
hint = "shots appeared but the run was not confirmed complete before the timeout."
|
|
1121
|
-
return result(False, True, diagnosis,
|
|
1230
|
+
return result(False, True, diagnosis, time.time() - start, hint)
|
|
1122
1231
|
time.sleep(poll)
|
|
1123
1232
|
|
|
1124
1233
|
|
|
@@ -1146,7 +1255,20 @@ def cmd_progress(config, args):
|
|
|
1146
1255
|
expected = int(args.get("expected_n_shots") or 0)
|
|
1147
1256
|
folder = map_path(config, run_folder)
|
|
1148
1257
|
shots = sorted(glob.glob(os.path.join(folder, "**", "*.h5"), recursive=True)) if folder else []
|
|
1149
|
-
|
|
1258
|
+
# n_h5 counts files, which labscript writes before compiling each shot; n_executed
|
|
1259
|
+
# counts the shots that actually acquired data. A run is only complete when the
|
|
1260
|
+
# last shot has acquisition data, not merely when the file count matches.
|
|
1261
|
+
executed = executed_prefix(shots)
|
|
1262
|
+
complete = bool(expected and len(shots) >= expected and executed == len(shots))
|
|
1263
|
+
return {
|
|
1264
|
+
"run_folder": run_folder,
|
|
1265
|
+
"mapped_run_folder": folder,
|
|
1266
|
+
"folder_exists": bool(folder) and os.path.isdir(folder),
|
|
1267
|
+
"n_h5": len(shots),
|
|
1268
|
+
"n_executed": executed,
|
|
1269
|
+
"expected_n_shots": expected,
|
|
1270
|
+
"complete": complete,
|
|
1271
|
+
}
|
|
1150
1272
|
|
|
1151
1273
|
|
|
1152
1274
|
|
|
@@ -1453,7 +1575,7 @@ def cmd_doctor(config, args):
|
|
|
1453
1575
|
probe = LabscriptBackend(config)
|
|
1454
1576
|
client = probe.client()
|
|
1455
1577
|
version = client.get_version() if hasattr(client, "get_version") else "unknown"
|
|
1456
|
-
_add_check(checks, "runmanager", "ok", str(version))
|
|
1578
|
+
_add_check(checks, "runmanager", "ok", str(version) + " at " + probe.endpoint())
|
|
1457
1579
|
try:
|
|
1458
1580
|
_add_check(checks, "panel", "ok", "n_shots=" + str(probe.n_shots()) + ", run_shots=" + str(probe.run_shots()) + ", error_in_globals=" + str(probe.error_in_globals()))
|
|
1459
1581
|
except Exception as exc:
|
|
@@ -8,6 +8,8 @@ import os
|
|
|
8
8
|
import shutil
|
|
9
9
|
import sys
|
|
10
10
|
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
import types
|
|
11
13
|
|
|
12
14
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
13
15
|
sys.path.insert(0, os.path.dirname(HERE))
|
|
@@ -277,6 +279,273 @@ def test_wait_diagnosis_compiled_not_executed():
|
|
|
277
279
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
278
280
|
|
|
279
281
|
|
|
282
|
+
def test_axis_heuristic_ignores_tuples_and_dicts():
|
|
283
|
+
"""runmanager does not expand tuples, so the scan-axes hint must not count them."""
|
|
284
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-axis-")
|
|
285
|
+
try:
|
|
286
|
+
cfg = make_config(tmp)
|
|
287
|
+
out = bridge.cmd_set(cfg, {"values": {"pulse_shape": "(0.1, 0.2, 0.3)"}, "raw": True})
|
|
288
|
+
assert out["missing"] == [], out
|
|
289
|
+
status = bridge.cmd_status(cfg, {})
|
|
290
|
+
assert "pulse_shape" not in status["scan_axes"], status["scan_axes"]
|
|
291
|
+
assert status["n_shots"] == 1, status["n_shots"]
|
|
292
|
+
assert status["n_shots_from_arrays"] == 1, status["n_shots_from_arrays"]
|
|
293
|
+
# a list/array global is an axis, a dict is not
|
|
294
|
+
bridge.cmd_set(cfg, {"values": {"freq": "np.arange(0.5, 6.7, 0.2)", "shape": "{'a': 1, 'b': 2}"}, "raw": True})
|
|
295
|
+
status = bridge.cmd_status(cfg, {})
|
|
296
|
+
assert status["scan_axes"].get("freq") == 31, status["scan_axes"]
|
|
297
|
+
assert "shape" not in status["scan_axes"], status["scan_axes"]
|
|
298
|
+
assert status["n_shots"] == 31, status["n_shots"]
|
|
299
|
+
assert status["n_shots_from_arrays"] == 31, status["n_shots_from_arrays"]
|
|
300
|
+
finally:
|
|
301
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_mock_error_in_globals_blocks_engage():
|
|
305
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-mock-error-")
|
|
306
|
+
try:
|
|
307
|
+
cfg = make_config(tmp)
|
|
308
|
+
assert bridge.cmd_status(cfg, {})["error_in_globals"] is False
|
|
309
|
+
cfg["mock"]["errorInGlobals"] = True
|
|
310
|
+
assert bridge.cmd_status(cfg, {})["error_in_globals"] is True
|
|
311
|
+
try:
|
|
312
|
+
bridge.cmd_engage(cfg, {})
|
|
313
|
+
except bridge.BridgeError as exc:
|
|
314
|
+
assert exc.code == "GLOBALS_ERROR", exc.code
|
|
315
|
+
else:
|
|
316
|
+
raise AssertionError("expected GLOBALS_ERROR")
|
|
317
|
+
finally:
|
|
318
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test_mock_reports_shuffle():
|
|
322
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-mock-shuffle-")
|
|
323
|
+
try:
|
|
324
|
+
cfg = make_config(tmp)
|
|
325
|
+
assert bridge.cmd_status(cfg, {})["shuffle"] is False
|
|
326
|
+
cfg["mock"]["shuffle"] = True
|
|
327
|
+
assert bridge.cmd_status(cfg, {})["shuffle"] is True
|
|
328
|
+
finally:
|
|
329
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_real_client_uses_labconfig_defaults():
|
|
333
|
+
"""Unset host/port/timeout must be resolved by runmanager.remote from labconfig."""
|
|
334
|
+
class FakeClient(object):
|
|
335
|
+
last = None
|
|
336
|
+
|
|
337
|
+
def __init__(self, **kwargs):
|
|
338
|
+
self.kwargs = kwargs
|
|
339
|
+
self.host = kwargs.get("host") or "localhost"
|
|
340
|
+
self.port = kwargs.get("port") or 42523
|
|
341
|
+
self.timeout = kwargs.get("timeout") or 60
|
|
342
|
+
FakeClient.last = self
|
|
343
|
+
|
|
344
|
+
def say_hello(self):
|
|
345
|
+
return "hello"
|
|
346
|
+
|
|
347
|
+
def get_shuffle(self):
|
|
348
|
+
return True
|
|
349
|
+
|
|
350
|
+
class NoShuffleClient(FakeClient):
|
|
351
|
+
def get_shuffle(self):
|
|
352
|
+
raise AttributeError("get_shuffle")
|
|
353
|
+
|
|
354
|
+
fake_remote = types.ModuleType("runmanager.remote")
|
|
355
|
+
fake_remote.Client = FakeClient
|
|
356
|
+
fake_package = types.ModuleType("runmanager")
|
|
357
|
+
fake_package.remote = fake_remote
|
|
358
|
+
saved = dict((name, sys.modules.get(name)) for name in ("runmanager", "runmanager.remote"))
|
|
359
|
+
sys.modules["runmanager"] = fake_package
|
|
360
|
+
sys.modules["runmanager.remote"] = fake_remote
|
|
361
|
+
try:
|
|
362
|
+
bare = bridge.LabscriptBackend({})
|
|
363
|
+
client = bare.client()
|
|
364
|
+
assert client.kwargs == {}, client.kwargs
|
|
365
|
+
assert bare.shuffle() is True
|
|
366
|
+
explicit = bridge.LabscriptBackend({"runmanager": {"host": "10.0.0.5", "port": 43000, "timeoutS": 25}})
|
|
367
|
+
assert explicit.client().kwargs == {"host": "10.0.0.5", "port": 43000, "timeout": 25.0}
|
|
368
|
+
assert "10.0.0.5" in explicit.endpoint() and "config" in explicit.endpoint()
|
|
369
|
+
fake_remote.Client = NoShuffleClient
|
|
370
|
+
assert bridge.LabscriptBackend({}).shuffle() is None
|
|
371
|
+
finally:
|
|
372
|
+
for name, module in saved.items():
|
|
373
|
+
if module is None:
|
|
374
|
+
sys.modules.pop(name, None)
|
|
375
|
+
else:
|
|
376
|
+
sys.modules[name] = module
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def test_wait_reports_extra_shot_files():
|
|
380
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-extra-shots-")
|
|
381
|
+
try:
|
|
382
|
+
cfg = make_config(tmp)
|
|
383
|
+
cfg["engage"] = {"timeoutS": 5, "pollS": 0.05, "settleS": 0.1}
|
|
384
|
+
run_dir = os.path.join(tmp, "extra_run")
|
|
385
|
+
os.makedirs(run_dir, exist_ok=True)
|
|
386
|
+
for index in range(4):
|
|
387
|
+
write_shot(os.path.join(run_dir, "shot_000%d.h5" % index), executed=True)
|
|
388
|
+
result = bridge.wait_for_run(cfg, run_dir, 2, 4)
|
|
389
|
+
assert result["complete"] is True, result
|
|
390
|
+
assert result["n_h5"] == 4, result
|
|
391
|
+
assert "Repeat all" in result["hint"], result
|
|
392
|
+
finally:
|
|
393
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def write_shot(path, executed):
|
|
397
|
+
"""Write a shot h5 like labscript does: globals first, acquisition data later."""
|
|
398
|
+
import h5py
|
|
399
|
+
with h5py.File(path, "w") as handle:
|
|
400
|
+
handle.create_group("globals")
|
|
401
|
+
if executed:
|
|
402
|
+
label = handle.create_group("images/camera/label")
|
|
403
|
+
label.create_dataset("frame", data=[1.0, 2.0])
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def test_wait_reports_stalled_compilation_early():
|
|
407
|
+
"""A compile that dies after one shot h5 must not block for the whole timeout."""
|
|
408
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-stalled-")
|
|
409
|
+
try:
|
|
410
|
+
cfg = make_config(tmp)
|
|
411
|
+
cfg["engage"] = {"timeoutS": 10, "pollS": 0.05, "settleS": 0.1, "noShotS": 0.3}
|
|
412
|
+
run_dir = os.path.join(tmp, "stalled_run")
|
|
413
|
+
os.makedirs(run_dir, exist_ok=True)
|
|
414
|
+
write_shot(os.path.join(run_dir, "shot_0000.h5"), executed=False)
|
|
415
|
+
started = time.time()
|
|
416
|
+
result = bridge.wait_for_run(cfg, run_dir, 64, 10)
|
|
417
|
+
elapsed = time.time() - started
|
|
418
|
+
assert result["diagnosis"] == "compile_stalled", result
|
|
419
|
+
assert result["timed_out"] is False, result
|
|
420
|
+
assert result["n_h5"] == 1, result
|
|
421
|
+
assert result["n_executed"] == 0, result
|
|
422
|
+
assert elapsed < 3.0, elapsed
|
|
423
|
+
assert "aborted" in result["hint"], result
|
|
424
|
+
finally:
|
|
425
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def test_wait_reports_no_shots_early():
|
|
429
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-noshots-")
|
|
430
|
+
try:
|
|
431
|
+
cfg = make_config(tmp)
|
|
432
|
+
cfg["engage"] = {"timeoutS": 10, "pollS": 0.05, "settleS": 0.1, "noShotS": 0.3}
|
|
433
|
+
run_dir = os.path.join(tmp, "empty_run")
|
|
434
|
+
os.makedirs(run_dir, exist_ok=True)
|
|
435
|
+
started = time.time()
|
|
436
|
+
result = bridge.wait_for_run(cfg, run_dir, 64, 10)
|
|
437
|
+
elapsed = time.time() - started
|
|
438
|
+
assert result["diagnosis"] == "no_shots", result
|
|
439
|
+
assert result["timed_out"] is False, result
|
|
440
|
+
assert result["n_h5"] == 0 and result["n_executed"] == 0, result
|
|
441
|
+
assert elapsed < 3.0, elapsed
|
|
442
|
+
finally:
|
|
443
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def test_progress_separates_files_from_executed_shots():
|
|
447
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-progress-exec-")
|
|
448
|
+
try:
|
|
449
|
+
cfg = make_config(tmp)
|
|
450
|
+
compiled = os.path.join(tmp, "compiled_run")
|
|
451
|
+
os.makedirs(compiled, exist_ok=True)
|
|
452
|
+
write_shot(os.path.join(compiled, "shot_0000.h5"), executed=True)
|
|
453
|
+
write_shot(os.path.join(compiled, "shot_0001.h5"), executed=False)
|
|
454
|
+
write_shot(os.path.join(compiled, "shot_0002.h5"), executed=False)
|
|
455
|
+
out = bridge.cmd_progress(cfg, {"run_folder": compiled, "expected_n_shots": 3})
|
|
456
|
+
assert out["n_h5"] == 3, out
|
|
457
|
+
assert out["n_executed"] == 1, out
|
|
458
|
+
assert out["complete"] is False, out
|
|
459
|
+
done = os.path.join(tmp, "done_run")
|
|
460
|
+
os.makedirs(done, exist_ok=True)
|
|
461
|
+
for index in range(2):
|
|
462
|
+
write_shot(os.path.join(done, "shot_000%d.h5" % index), executed=True)
|
|
463
|
+
finished = bridge.cmd_progress(cfg, {"run_folder": done, "expected_n_shots": 2})
|
|
464
|
+
assert finished["complete"] is True, finished
|
|
465
|
+
assert finished["n_executed"] == 2, finished
|
|
466
|
+
finally:
|
|
467
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def test_real_backend_set_globals_survives_none_reply():
|
|
471
|
+
"""runmanager's set_globals returns None; the write must still be reported."""
|
|
472
|
+
class FakeClient(object):
|
|
473
|
+
def __init__(self):
|
|
474
|
+
self.calls = []
|
|
475
|
+
|
|
476
|
+
def set_globals(self, values, raw=False):
|
|
477
|
+
self.calls.append((dict(values), raw))
|
|
478
|
+
return None
|
|
479
|
+
|
|
480
|
+
client = FakeClient()
|
|
481
|
+
backend = bridge.LabscriptBackend({})
|
|
482
|
+
backend._client = client
|
|
483
|
+
written = backend.set_globals({"do_acquire": True, "amplitude": 0.5})
|
|
484
|
+
assert written == {"do_acquire": True, "amplitude": 0.5}, written
|
|
485
|
+
assert client.calls == [({"do_acquire": True, "amplitude": 0.5}, False)], client.calls
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def test_cmd_set_accepts_backend_that_returns_none():
|
|
489
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-set-none-")
|
|
490
|
+
try:
|
|
491
|
+
cfg = make_config(tmp)
|
|
492
|
+
original = bridge.make_backend
|
|
493
|
+
|
|
494
|
+
def make(config):
|
|
495
|
+
backend = bridge.MockBackend(config)
|
|
496
|
+
|
|
497
|
+
def set_globals(values, raw=False):
|
|
498
|
+
bridge.MockBackend.set_globals(backend, values, raw=raw)
|
|
499
|
+
return None
|
|
500
|
+
|
|
501
|
+
backend.set_globals = set_globals
|
|
502
|
+
return backend
|
|
503
|
+
|
|
504
|
+
bridge.make_backend = make
|
|
505
|
+
try:
|
|
506
|
+
out = bridge.cmd_set(cfg, {"values": {"amplitude": 0.71}, "note": "None-returning client"})
|
|
507
|
+
finally:
|
|
508
|
+
bridge.make_backend = original
|
|
509
|
+
assert out["missing"] == [], out
|
|
510
|
+
assert out["readback"]["amplitude"] == 0.71, out
|
|
511
|
+
assert out["written"] == {"amplitude": 0.71}, out
|
|
512
|
+
finally:
|
|
513
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def test_cmd_set_failure_reports_what_landed():
|
|
517
|
+
tmp = tempfile.mkdtemp(prefix="dsh-labscript-set-partial-")
|
|
518
|
+
try:
|
|
519
|
+
cfg = make_config(tmp)
|
|
520
|
+
original = bridge.make_backend
|
|
521
|
+
|
|
522
|
+
def make(config):
|
|
523
|
+
backend = bridge.MockBackend(config)
|
|
524
|
+
|
|
525
|
+
def set_globals(values, raw=False):
|
|
526
|
+
if "amplitude" in values:
|
|
527
|
+
bridge.MockBackend.set_globals(backend, {"amplitude": values["amplitude"]}, raw=raw)
|
|
528
|
+
raise ValueError("Global nope not found in any active group")
|
|
529
|
+
|
|
530
|
+
backend.set_globals = set_globals
|
|
531
|
+
return backend
|
|
532
|
+
|
|
533
|
+
bridge.make_backend = make
|
|
534
|
+
try:
|
|
535
|
+
bridge.cmd_set(cfg, {"values": {"amplitude": 0.71, "nope": 1}})
|
|
536
|
+
except bridge.BridgeError as exc:
|
|
537
|
+
assert exc.code == "SET_FAILED", exc.code
|
|
538
|
+
assert "not found" in exc.message, exc.message
|
|
539
|
+
assert exc.detail["snapshot"]["name"].endswith(".json"), exc.detail
|
|
540
|
+
assert exc.detail["landed"]["amplitude"] == "0.71", exc.detail
|
|
541
|
+
assert "labs_status" in exc.detail["hint"], exc.detail
|
|
542
|
+
else:
|
|
543
|
+
raise AssertionError("expected SET_FAILED")
|
|
544
|
+
finally:
|
|
545
|
+
bridge.make_backend = original
|
|
546
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
547
|
+
|
|
548
|
+
|
|
280
549
|
def test_doctor_mock():
|
|
281
550
|
tmp = tempfile.mkdtemp(prefix="dsh-labscript-doctor-")
|
|
282
551
|
try:
|