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/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
- host = rm.get("host") or "127.0.0.1"
285
- port = int(rm.get("port") or 42523)
286
- timeout = float(rm.get("timeoutS") or 10)
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
- return dict(self.client().set_globals(values, raw=raw))
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
- return dict((k, jsonable(eval_panel_value(v))) for k, v in panel.items())
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
- arr = np.asarray(value) if hasattr(value, "__len__") and not isinstance(value, str) else None
411
- if arr is None or arr.ndim == 0:
482
+ length = axis_length(value)
483
+ if length is None or length <= 0:
412
484
  continue
413
- total *= max(1, int(arr.size))
485
+ total *= length
414
486
  return int(total)
415
487
 
416
488
  def error_in_globals(self):
417
- return False
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 isinstance(value, str):
435
- continue
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
- if isinstance(value, str) or not hasattr(value, "__len__"):
931
- continue
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
- raise BridgeError("SET_FAILED", "set_globals failed: " + type(exc).__name__ + ": " + str(exc), {
998
- "snapshot": snap,
999
- "hint": "the panel may be partially written; use labs_restore to undo",
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
- "written": jsonable(written),
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
- last_executed = None
1142
+ shots = []
1059
1143
 
1060
- def result(complete, timed_out, diagnosis, count, executed, elapsed, hint=None):
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": count,
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": 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 >= (expected or 0) and count == last_count and total == last_size and shots:
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
- return result(True, False, "complete", count, True, time.time() - start)
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", count, False, time.time() - start,
1101
- "shots were compiled but show no acquisition data after " + str(int(stall)) +
1102
- " s: BLACS may be paused / the queue may not be running, or the run is still being programmed. Check BLACS and the panel, then labs_abort if needed.")
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
- last_executed = executed
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 < (expected or 0):
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 = "shots were compiled but no acquisition data was found: BLACS may be paused or not executing the queue."
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, count, executed, time.time() - start, hint)
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
- 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)}
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: