dsh-labscript 0.0.0-stage → 0.2.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.
@@ -0,0 +1,1445 @@
1
+ """dsh-labscript bridge: one JSON request on stdin, one JSON envelope on stdout.
2
+
3
+ commands: status, set, engage, wait, abort, data, restore,
4
+ protocol_plan, protocol_start, protocol_finalize, progress
5
+
6
+ The node plugin spawns this script with one python interpreter configured by the
7
+ user; it must be able to import numpy and h5py (the labscript environment has
8
+ both). The labscript backend additionally imports runmanager.remote lazily.
9
+ """
10
+ import ast
11
+ import glob
12
+ import json
13
+ import os
14
+ import sys
15
+ import time
16
+ import datetime
17
+ import importlib
18
+ import traceback
19
+
20
+
21
+ class BridgeError(Exception):
22
+ def __init__(self, code, message, detail=None):
23
+ Exception.__init__(self, message)
24
+ self.code = code
25
+ self.message = message
26
+ self.detail = detail or {}
27
+
28
+
29
+ def now_iso():
30
+ return datetime.datetime.now().astimezone().isoformat(timespec="seconds")
31
+
32
+
33
+ def expand(path):
34
+ if not path:
35
+ return path
36
+ return os.path.expanduser(str(path))
37
+
38
+
39
+ def jsonable(value):
40
+ if hasattr(value, "tolist"):
41
+ return jsonable(value.tolist())
42
+ if hasattr(value, "item"):
43
+ return jsonable(value.item())
44
+ if isinstance(value, dict):
45
+ return dict((str(k), jsonable(v)) for k, v in value.items())
46
+ if isinstance(value, (list, tuple)):
47
+ return [jsonable(v) for v in value]
48
+ if isinstance(value, float):
49
+ if value != value or value in (float("inf"), float("-inf")):
50
+ return None
51
+ return value
52
+ if isinstance(value, (str, int, bool)) or value is None:
53
+ return value
54
+ return str(value)
55
+
56
+
57
+
58
+ _H5_LOCK_LOADED = False
59
+
60
+
61
+ def ensure_h5_lock():
62
+ """Import labscript_utils.h5_lock before h5py (labscript requirement)."""
63
+ global _H5_LOCK_LOADED
64
+ if _H5_LOCK_LOADED:
65
+ return True
66
+ try:
67
+ import labscript_utils.h5_lock # noqa: F401
68
+ _H5_LOCK_LOADED = True
69
+ return True
70
+ except Exception:
71
+ return False
72
+
73
+
74
+ def shot_ready(path):
75
+ """True when a shot h5 is complete enough to read (best effort, never raises)."""
76
+ ensure_h5_lock()
77
+ try:
78
+ import h5py
79
+ with h5py.File(path, "r", locking=False) as handle:
80
+ return "globals" in handle
81
+ except Exception:
82
+ return False
83
+
84
+
85
+ def shot_executed(path):
86
+ """True/False when a shot shows acquisition data; None when unknown.
87
+
88
+ labscript compiles shot h5 files before BLACS executes them. A compiled but
89
+ not executed shot has no images/results yet; this is how a stalled queue
90
+ (for example BLACS paused) can be told apart from a normal run in progress.
91
+ """
92
+ ensure_h5_lock()
93
+ try:
94
+ import h5py
95
+ with h5py.File(path, "r", locking=False) as handle:
96
+ if "images" in handle:
97
+ cameras = handle["images"]
98
+ if isinstance(cameras, h5py.Group):
99
+ for camera in cameras:
100
+ camera_group = cameras[camera]
101
+ if isinstance(camera_group, h5py.Group):
102
+ for label in camera_group:
103
+ label_group = camera_group[label]
104
+ if isinstance(label_group, h5py.Group) and len(label_group) > 0:
105
+ return True
106
+ if "results" in handle:
107
+ results = handle["results"]
108
+ if isinstance(results, h5py.Group):
109
+ for name in results:
110
+ group = results[name]
111
+ if isinstance(group, h5py.Group) and (len(group) > 0 or len(group.attrs) > 0):
112
+ return True
113
+ return False
114
+ except Exception:
115
+ return None
116
+
117
+ # ---------------------------------------------------------------- scan expr
118
+
119
+ _ALLOWED_CALLS = {"arange", "linspace", "array", "logspace"}
120
+ _ALLOWED_NODES = (
121
+ ast.Expression, ast.Call, ast.Attribute, ast.Name, ast.Load, ast.Constant,
122
+ ast.List, ast.Tuple, ast.UnaryOp, ast.USub, ast.UAdd, ast.BinOp, ast.Add,
123
+ ast.Sub, ast.Mult, ast.Div, ast.Mod, ast.Pow,
124
+ )
125
+
126
+
127
+ def _num_arg(node):
128
+ value = ast.literal_eval(node)
129
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
130
+ raise BridgeError("BAD_SCAN_EXPR", "scan expression argument is not numeric")
131
+ return value
132
+
133
+
134
+ def eval_scan_expr(expr):
135
+ """Evaluate the small scan-expression subset used by protocols (np.arange...)."""
136
+ import numpy as np
137
+ if isinstance(expr, bool):
138
+ raise BridgeError("BAD_SCAN_EXPR", "scan expression cannot be a bool")
139
+ if isinstance(expr, (int, float)):
140
+ return np.atleast_1d(np.asarray(expr, dtype=float))
141
+ if isinstance(expr, (list, tuple)):
142
+ return np.asarray([float(v) for v in expr], dtype=float)
143
+ text = str(expr).strip()
144
+ if text == "":
145
+ raise BridgeError("BAD_SCAN_EXPR", "scan expression is empty")
146
+ try:
147
+ tree = ast.parse(text, mode="eval")
148
+ except SyntaxError as exc:
149
+ raise BridgeError("BAD_SCAN_EXPR", "cannot parse scan expression: " + str(exc))
150
+ for node in ast.walk(tree):
151
+ if not isinstance(node, _ALLOWED_NODES):
152
+ raise BridgeError("BAD_SCAN_EXPR", "scan expression node not allowed: " + type(node).__name__)
153
+ if isinstance(node, ast.Call):
154
+ if not isinstance(node.func, ast.Attribute):
155
+ raise BridgeError("BAD_SCAN_EXPR", "only np.* calls are allowed")
156
+ if not isinstance(node.func.value, ast.Name) or node.func.value.id != "np":
157
+ raise BridgeError("BAD_SCAN_EXPR", "only np.* calls are allowed")
158
+ if node.func.attr not in _ALLOWED_CALLS:
159
+ raise BridgeError("BAD_SCAN_EXPR", "np." + node.func.attr + " is not allowed")
160
+ for arg in node.args:
161
+ if isinstance(arg, ast.Constant):
162
+ _num_arg(arg)
163
+ elif isinstance(arg, (ast.List, ast.Tuple)):
164
+ for item in arg.elts:
165
+ if isinstance(item, ast.Constant):
166
+ _num_arg(item)
167
+ elif not isinstance(item, (ast.UnaryOp, ast.BinOp)):
168
+ raise BridgeError("BAD_SCAN_EXPR", "unsupported scan expression argument")
169
+ elif not isinstance(arg, (ast.UnaryOp, ast.BinOp)):
170
+ raise BridgeError("BAD_SCAN_EXPR", "unsupported scan expression argument")
171
+ env = {"np": np, "__builtins__": {}}
172
+ try:
173
+ value = eval(compile(tree, "<scan>", "eval"), env)
174
+ except Exception as exc:
175
+ raise BridgeError("BAD_SCAN_EXPR", "scan expression evaluation failed: " + str(exc))
176
+ return np.atleast_1d(np.asarray(value, dtype=float))
177
+
178
+
179
+ def eval_panel_value(raw):
180
+ """Evaluate a raw panel string the way runmanager does (best effort)."""
181
+ import numpy as np
182
+ text = str(raw).strip()
183
+ if text == "":
184
+ return ""
185
+ if "np." in text or text.startswith("["):
186
+ try:
187
+ arr = eval_scan_expr(text)
188
+ return arr if arr.size > 1 else arr[0]
189
+ except BridgeError:
190
+ pass
191
+ try:
192
+ return ast.literal_eval(text)
193
+ except Exception:
194
+ return str(raw)
195
+
196
+
197
+ def raw_repr(value):
198
+ if isinstance(value, bool):
199
+ return "True" if value else "False"
200
+ if isinstance(value, (int, float)):
201
+ return repr(value)
202
+ if isinstance(value, str):
203
+ return repr(value)
204
+ if isinstance(value, (list, tuple)):
205
+ return repr(list(value))
206
+ return repr(value)
207
+
208
+
209
+ def values_to_raw(values):
210
+ """Convert JSON values to runmanager raw expressions (arrays included)."""
211
+ out = {}
212
+ for key, value in values.items():
213
+ if isinstance(value, (list, tuple)):
214
+ out[str(key)] = scan_expr_from_value(list(value))
215
+ else:
216
+ out[str(key)] = raw_repr(value)
217
+ return out
218
+
219
+
220
+ def has_array_value(values):
221
+ return any(isinstance(value, (list, tuple)) for value in values.values())
222
+
223
+
224
+ def to_raw_values(values):
225
+ """Stringify evaluated globals into runmanager raw expressions."""
226
+ out = {}
227
+ for key, value in values.items():
228
+ if isinstance(value, str):
229
+ out[str(key)] = value
230
+ elif isinstance(value, (list, tuple)):
231
+ out[str(key)] = "np.array([" + ",".join(repr(float(item)) for item in value) + "])"
232
+ else:
233
+ out[str(key)] = raw_repr(value)
234
+ return out
235
+
236
+
237
+ def as_number(value):
238
+ if isinstance(value, bool):
239
+ return None
240
+ if isinstance(value, (int, float)):
241
+ return float(value)
242
+ return None
243
+
244
+
245
+ # ---------------------------------------------------------------- backends
246
+
247
+
248
+ def map_path(config, path):
249
+ if not path:
250
+ return path
251
+ text = str(path)
252
+ for rule in config.get("pathMap") or []:
253
+ remote = rule.get("remotePrefix") or ""
254
+ local = rule.get("localPrefix") or ""
255
+ if remote and local and text.startswith(remote):
256
+ return local + text[len(remote):]
257
+ return text
258
+
259
+
260
+ class LabscriptBackend(object):
261
+ def __init__(self, config):
262
+ self.config = config
263
+ self._client = None
264
+
265
+ def client(self):
266
+ if self._client is not None:
267
+ return self._client
268
+ try:
269
+ from labscript_utils import h5_lock # noqa: F401 (must precede h5py)
270
+ except Exception:
271
+ pass
272
+ try:
273
+ from runmanager.remote import Client
274
+ except Exception as exc:
275
+ raise BridgeError("BACKEND_UNAVAILABLE", "labscript python environment not importable: " + str(exc))
276
+ rm = self.config.get("runmanager") or {}
277
+ host = rm.get("host") or "127.0.0.1"
278
+ port = int(rm.get("port") or 42523)
279
+ timeout = float(rm.get("timeoutS") or 10)
280
+ try:
281
+ client = Client(host=host, port=port, timeout=timeout)
282
+ client.say_hello()
283
+ except Exception as exc:
284
+ raise BridgeError("BACKEND_UNAVAILABLE", "runmanager not reachable at " + host + ":" + str(port) + ": " + str(exc))
285
+ self._client = client
286
+ return client
287
+
288
+ def version(self):
289
+ try:
290
+ return str(self.client().get_version())
291
+ except Exception:
292
+ return None
293
+
294
+ def get_globals(self, raw=False):
295
+ return dict(self.client().get_globals(raw=raw))
296
+
297
+ def set_globals(self, values, raw=False):
298
+ return dict(self.client().set_globals(values, raw=raw))
299
+
300
+ def n_shots(self):
301
+ return int(self.client().n_shots())
302
+
303
+ def error_in_globals(self):
304
+ return bool(self.client().error_in_globals())
305
+
306
+ def run_shots(self):
307
+ try:
308
+ return bool(self.client().get_run_shots())
309
+ except Exception:
310
+ return None
311
+
312
+ def labscript_file(self):
313
+ try:
314
+ return self.client().get_labscript_file()
315
+ except Exception:
316
+ return None
317
+
318
+ def shot_output_folder(self):
319
+ try:
320
+ return self.client().get_shot_output_folder()
321
+ except Exception:
322
+ return None
323
+
324
+ def engage(self):
325
+ # runmanager advances the sequence index on engage: capture the folder first.
326
+ run_folder = self.shot_output_folder()
327
+ n_shots = self.n_shots()
328
+ try:
329
+ self.client().engage()
330
+ except Exception as exc:
331
+ raise BridgeError("ENGAGE_REJECTED", "runmanager engage failed: " + type(exc).__name__ + ": " + str(exc))
332
+ return {"accepted": True, "n_shots": n_shots, "run_folder": run_folder, "mode": "runmanager"}
333
+
334
+ def abort(self):
335
+ return self.client().abort()
336
+
337
+
338
+ MOCK_DEFAULT_PANEL = {
339
+ "do_acquire": True,
340
+ "duration_test_us": 1.0,
341
+ "amplitude": 0.5,
342
+ "dummy_iterator": 0,
343
+ }
344
+
345
+
346
+ class MockBackend(object):
347
+ """In-memory simulator used for tests, examples and hardware-free development."""
348
+
349
+ def __init__(self, config):
350
+ self.config = config
351
+ mock = config.get("mock") or {}
352
+ self.state_dir = os.path.abspath(expand(mock.get("stateDir") or "~/.dsh-labscript/mock"))
353
+ os.makedirs(self.state_dir, exist_ok=True)
354
+ self.panel_path = os.path.join(self.state_dir, "panel.json")
355
+ self.state_path = os.path.join(self.state_dir, "state.json")
356
+ self.runs_dir = os.path.join(self.state_dir, "runs")
357
+ os.makedirs(self.runs_dir, exist_ok=True)
358
+
359
+ # ---- state helpers
360
+ def read_panel(self):
361
+ if os.path.isfile(self.panel_path):
362
+ with open(self.panel_path, "r", encoding="utf-8") as handle:
363
+ data = json.load(handle)
364
+ else:
365
+ data = dict((k, raw_repr(v)) for k, v in MOCK_DEFAULT_PANEL.items())
366
+ return data
367
+
368
+ def write_panel(self, raw_panel):
369
+ with open(self.panel_path, "w", encoding="utf-8") as handle:
370
+ json.dump(raw_panel, handle, indent=1)
371
+
372
+ def next_run_folder(self):
373
+ state = {}
374
+ if os.path.isfile(self.state_path):
375
+ with open(self.state_path, "r", encoding="utf-8") as handle:
376
+ state = json.load(handle)
377
+ index = int(state.get("last_run") or 0) + 1
378
+ state["last_run"] = index
379
+ with open(self.state_path, "w", encoding="utf-8") as handle:
380
+ json.dump(state, handle)
381
+ folder = os.path.join(self.runs_dir, "run_" + ("%06d" % index))
382
+ os.makedirs(folder, exist_ok=True)
383
+ return folder
384
+
385
+ # ---- backend interface
386
+ def get_globals(self, raw=False):
387
+ panel = self.read_panel()
388
+ if raw:
389
+ return dict(panel)
390
+ return dict((k, jsonable(eval_panel_value(v))) for k, v in panel.items())
391
+
392
+ def set_globals(self, values, raw=False):
393
+ panel = self.read_panel()
394
+ for key, value in values.items():
395
+ panel[str(key)] = str(value) if raw else raw_repr(value)
396
+ self.write_panel(panel)
397
+ return dict((k, panel[k]) for k in values)
398
+
399
+ def n_shots(self):
400
+ import numpy as np
401
+ total = 1
402
+ for value in self.get_globals(raw=False).values():
403
+ arr = np.asarray(value) if hasattr(value, "__len__") and not isinstance(value, str) else None
404
+ if arr is None or arr.ndim == 0:
405
+ continue
406
+ total *= max(1, int(arr.size))
407
+ return int(total)
408
+
409
+ def error_in_globals(self):
410
+ return False
411
+
412
+ def run_shots(self):
413
+ return True
414
+
415
+ def labscript_file(self):
416
+ return "mock_sequence.py"
417
+
418
+ def shot_output_folder(self):
419
+ return self.runs_dir
420
+
421
+ def engage(self):
422
+ import h5py
423
+ import numpy as np
424
+ values = self.get_globals(raw=False)
425
+ arrays = {}
426
+ for key, value in values.items():
427
+ if isinstance(value, str):
428
+ continue
429
+ arr = np.asarray(value)
430
+ if arr.ndim >= 1 and arr.size > 1:
431
+ arrays[key] = arr
432
+ total = 1
433
+ for arr in arrays.values():
434
+ total *= int(arr.size)
435
+ folder = self.next_run_folder()
436
+ for index in range(total):
437
+ attrs = {}
438
+ for key, value in values.items():
439
+ if key in arrays:
440
+ arr = arrays[key]
441
+ attrs[key] = arr.flat[index % arr.size]
442
+ elif isinstance(value, str):
443
+ attrs[key] = value
444
+ else:
445
+ attrs[key] = value
446
+ shot_path = os.path.join(folder, "shot_" + ("%04d" % index) + ".h5")
447
+ with h5py.File(shot_path, "w") as handle:
448
+ group = handle.create_group("globals")
449
+ for key, value in attrs.items():
450
+ group.attrs[key] = value
451
+ results = handle.create_group("results/demo")
452
+ results.attrs["value"] = float(index)
453
+ results.create_dataset("mean", data=float(index))
454
+ return {"accepted": True, "n_shots": total, "run_folder": folder, "mode": "mock-labscript"}
455
+
456
+ def abort(self):
457
+ return {"aborted": True, "mode": "mock-labscript"}
458
+
459
+
460
+ class HttpSimBackend(object):
461
+ """HTTP client for an optional local simulator service (development backend).
462
+
463
+ The simulator must speak the documented JSON/HTTP contract on a loopback address.
464
+ It is a development tool, not a physics backend for publication.
465
+ """
466
+
467
+ def __init__(self, config):
468
+ simulator = config.get("simulator") or {}
469
+ url = str(simulator.get("url") or "http://127.0.0.1:18761").rstrip("/")
470
+ if not url.startswith("http://127.0.0.1:"):
471
+ raise BridgeError("BAD_CONFIG", "simulator.url must be http://127.0.0.1:<port>, got: " + url)
472
+ self.base = url
473
+ self.timeout = float(simulator.get("timeoutS") or 10)
474
+ self._opener = None
475
+
476
+ def opener(self):
477
+ import urllib.request
478
+ if self._opener is not None:
479
+ return self._opener
480
+
481
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
482
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
483
+ return None
484
+
485
+ self._opener = urllib.request.build_opener(NoRedirect, urllib.request.ProxyHandler({}))
486
+ return self._opener
487
+
488
+ def request(self, method, path, body=None):
489
+ import urllib.request
490
+ import urllib.error
491
+ data = None
492
+ headers = {}
493
+ if body is not None:
494
+ data = json.dumps(jsonable(body)).encode("utf-8")
495
+ headers["Content-Type"] = "application/json"
496
+ req = urllib.request.Request(self.base + path, data=data, headers=headers, method=method)
497
+ try:
498
+ with self.opener().open(req, timeout=self.timeout) as response:
499
+ raw = response.read().decode("utf-8")
500
+ except urllib.error.HTTPError as exc:
501
+ try:
502
+ payload = json.loads(exc.read().decode("utf-8"))
503
+ except Exception:
504
+ raise BridgeError("BACKEND_UNAVAILABLE", "http-sim HTTP " + str(exc.code) + " at " + path)
505
+ error = payload.get("error") or {}
506
+ raise BridgeError(str(error.get("code") or "BACKEND_UNAVAILABLE"), str(error.get("message") or "http-sim error"))
507
+ except Exception as exc:
508
+ raise BridgeError("BACKEND_UNAVAILABLE", "http-sim not reachable at " + self.base + ": " + str(exc))
509
+ try:
510
+ envelope = json.loads(raw)
511
+ except Exception:
512
+ raise BridgeError("BACKEND_UNAVAILABLE", "http-sim response is not JSON")
513
+ if envelope.get("ok") is not True:
514
+ error = envelope.get("error") or {}
515
+ raise BridgeError(str(error.get("code") or "BACKEND_UNAVAILABLE"), str(error.get("message") or "http-sim error"))
516
+ return envelope.get("data") or {}
517
+
518
+ def snapshot(self):
519
+ return self.request("GET", "/v1/rm/snapshot")
520
+
521
+ def get_globals(self, raw=False):
522
+ snap = self.snapshot()
523
+ key = "raw_globals" if raw else "globals"
524
+ return dict(snap.get(key) or {})
525
+
526
+ def set_globals(self, values, raw=False):
527
+ body = dict((k, v.tolist() if hasattr(v, "tolist") else v) for k, v in values.items())
528
+ data = self.request("POST", "/v1/rm/set_globals", {"values": body, "raw": bool(raw)})
529
+ return dict(data.get("written") or {})
530
+
531
+ def n_shots(self):
532
+ return int(self.snapshot().get("n_shots") or 0)
533
+
534
+ def error_in_globals(self):
535
+ return bool(self.snapshot().get("error_in_globals"))
536
+
537
+ def run_shots(self):
538
+ return True
539
+
540
+ def labscript_file(self):
541
+ return self.snapshot().get("labscript_file")
542
+
543
+ def shot_output_folder(self):
544
+ return self.snapshot().get("shot_output_folder")
545
+
546
+ def engage(self):
547
+ data = self.request("POST", "/v1/rm/engage", {})
548
+ if data.get("accepted") is not True:
549
+ raise BridgeError("ENGAGE_REJECTED", "http-sim did not accept engage: " + str(data.get("detail") or ""))
550
+ return {"accepted": True, "n_shots": data.get("n_shots"), "run_folder": data.get("run_folder"), "mode": "http-sim"}
551
+
552
+ def abort(self):
553
+ data = self.request("POST", "/v1/blacs/stop", {})
554
+ return {"aborted": True, "via": "http-sim/blacs/stop", "result": data}
555
+
556
+
557
+ def make_backend(config):
558
+ backend = (config.get("backend") or "mock-labscript").lower()
559
+ if backend == "mock-labscript":
560
+ return MockBackend(config)
561
+ if backend == "http-sim":
562
+ return HttpSimBackend(config)
563
+ if backend == "labscript":
564
+ return LabscriptBackend(config)
565
+ raise BridgeError("BAD_CONFIG", "unknown backend: " + backend)
566
+
567
+
568
+ # ---------------------------------------------------------------- snapshots / logs
569
+
570
+
571
+ def history_dir(config):
572
+ mock = config.get("mock") or {}
573
+ base = config.get("panelHistoryDir") or os.path.join(expand("~/.dsh-labscript"), "panel_history")
574
+ if (config.get("backend") or "").lower() == "mock-labscript" and mock.get("historyDir"):
575
+ base = mock["historyDir"]
576
+ path = os.path.abspath(expand(base))
577
+ os.makedirs(path, exist_ok=True)
578
+ return path
579
+
580
+
581
+ def snapshot_panel(backend, config, tool, args, touched=None):
582
+ panel = backend.get_globals(raw=True)
583
+ folder = history_dir(config)
584
+ stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
585
+ name = stamp + "-" + tool + "-" + os.urandom(3).hex() + ".json"
586
+ path = os.path.join(folder, name)
587
+ with open(path, "w", encoding="utf-8") as handle:
588
+ json.dump({
589
+ "ts": now_iso(),
590
+ "tool": tool,
591
+ "args": jsonable(args),
592
+ "backend": config.get("backend"),
593
+ "raw_globals": jsonable(panel),
594
+ "globals": jsonable(backend.get_globals(raw=False)),
595
+ "touched": sorted(str(key) for key in touched) if touched is not None else None,
596
+ }, handle, indent=1)
597
+ prune_keep = 200
598
+ files = sorted(glob.glob(os.path.join(folder, "*.json")))
599
+ for old in files[:-prune_keep]:
600
+ try:
601
+ os.remove(old)
602
+ except OSError:
603
+ pass
604
+ return {"file": path, "name": name, "dir": folder}
605
+
606
+
607
+ def append_event(config, event):
608
+ folder = history_dir(config)
609
+ path = os.path.join(folder, "events.jsonl")
610
+ with open(path, "a", encoding="utf-8") as handle:
611
+ handle.write(json.dumps(jsonable(event), ensure_ascii=False) + "\n")
612
+
613
+
614
+ def load_snapshot(config, name):
615
+ folder = history_dir(config)
616
+ if not name or name == "latest":
617
+ files = sorted(glob.glob(os.path.join(folder, "*.json")))
618
+ if not files:
619
+ raise BridgeError("NO_SNAPSHOT", "no panel snapshot found in " + folder)
620
+ path = files[-1]
621
+ else:
622
+ path = name if os.path.isabs(name) else os.path.join(folder, name)
623
+ if not os.path.isfile(path):
624
+ raise BridgeError("NO_SNAPSHOT", "snapshot not found: " + str(path))
625
+ with open(path, "r", encoding="utf-8") as handle:
626
+ data = json.load(handle)
627
+ return path, data
628
+
629
+
630
+ # ---------------------------------------------------------------- hard limits
631
+
632
+
633
+ def load_limits(config):
634
+ path = config.get("hardLimitsFile")
635
+ if not path:
636
+ return None
637
+ path = os.path.abspath(expand(path))
638
+ if not os.path.isfile(path):
639
+ raise BridgeError("HARD_LIMITS_UNREADABLE", "hardLimitsFile does not exist: " + path)
640
+ try:
641
+ with open(path, "r", encoding="utf-8") as handle:
642
+ doc = json.load(handle)
643
+ except Exception as exc:
644
+ raise BridgeError("HARD_LIMITS_UNREADABLE", "cannot read hardLimitsFile: " + str(exc))
645
+ limits = {}
646
+ for name, meta in (doc.get("limits") or {}).items():
647
+ if isinstance(meta, dict):
648
+ limits[str(name)] = dict(meta)
649
+ for channel, meta in (doc.get("channels") or {}).items():
650
+ if not isinstance(meta, dict):
651
+ continue
652
+ expanded = dict((k, v) for k, v in meta.items() if k != "globals")
653
+ expanded["channel"] = channel
654
+ for name in meta.get("globals") or []:
655
+ limits[str(name)] = dict(expanded)
656
+ return {"doc": doc, "limits": limits}
657
+
658
+
659
+ def check_limits(config, set_values, scan_values, expected_n_shots):
660
+ loaded = load_limits(config)
661
+ if loaded is None:
662
+ return {"checked": False, "warning": "hardLimitsFile not configured"}
663
+ findings = []
664
+ limits = loaded["limits"]
665
+ for key, raw in (set_values or {}).items():
666
+ meta = limits.get(key)
667
+ number = as_number(raw)
668
+ if meta is None or number is None:
669
+ continue
670
+ maximum = as_number(meta.get("max"))
671
+ if maximum is not None and number > maximum:
672
+ findings.append({"kind": "over_max", "key": key, "value": number, "max": maximum})
673
+ for key, expr in (scan_values or {}).items():
674
+ meta = limits.get(key)
675
+ if meta is None:
676
+ continue
677
+ maximum = as_number(meta.get("max"))
678
+ if maximum is None:
679
+ continue
680
+ arr = eval_scan_expr(expr)
681
+ if arr.size and float(arr.max()) > maximum:
682
+ findings.append({"kind": "over_max_scan", "key": key, "max_in_scan": float(arr.max()), "max": maximum})
683
+ doc = loaded["doc"]
684
+ max_shots = None
685
+ run_limits = doc.get("run_limits") or {}
686
+ if isinstance(run_limits, dict):
687
+ max_shots = run_limits.get("maxTotalShots") or run_limits.get("max_total_shots")
688
+ if isinstance(max_shots, int) and expected_n_shots is not None and expected_n_shots > max_shots:
689
+ findings.append({"kind": "over_max_total_shots", "expected": expected_n_shots, "max": max_shots})
690
+ if findings:
691
+ raise BridgeError("HARD_LIMIT_EXCEEDED", "hard limit exceeded", {"findings": findings})
692
+ return {"checked": True, "findings": []}
693
+
694
+
695
+ # ---------------------------------------------------------------- postprocess
696
+
697
+
698
+ def run_postprocess(config, plan, run_folder, scan_global, reps):
699
+ name = plan.get("postprocess")
700
+ if not name:
701
+ return None
702
+ spec = config.get("postprocess") or {}
703
+ entry = spec.get("entry")
704
+ if not entry:
705
+ raise BridgeError("POSTPROCESS_NOT_CONFIGURED", "protocol asks for postprocess but config.postprocess.entry is not set")
706
+ for extra in spec.get("pythonPaths") or []:
707
+ path = os.path.abspath(expand(extra))
708
+ if path not in sys.path:
709
+ sys.path.insert(0, path)
710
+ if ":" in entry:
711
+ module_name, func_name = entry.split(":", 1)
712
+ else:
713
+ module_name, func_name = entry.rsplit(".", 1)
714
+ try:
715
+ module = importlib.import_module(module_name)
716
+ func = getattr(module, func_name)
717
+ except Exception as exc:
718
+ raise BridgeError("POSTPROCESS_IMPORT_FAILED", "cannot import postprocess " + entry + ": " + str(exc))
719
+ try:
720
+ result = func(run_folder=run_folder, scan_global=scan_global, reps=reps, name=name)
721
+ except TypeError:
722
+ try:
723
+ result = func(run_folder, scan_global, reps, name)
724
+ except Exception as exc:
725
+ raise BridgeError("POSTPROCESS_FAILED", "postprocess raised: " + str(exc))
726
+ except Exception as exc:
727
+ raise BridgeError("POSTPROCESS_FAILED", "postprocess raised: " + str(exc))
728
+ if not isinstance(result, dict):
729
+ raise BridgeError("POSTPROCESS_BAD_RESULT", "postprocess must return a dict, got " + type(result).__name__)
730
+ return jsonable(result)
731
+
732
+
733
+ def resolve_dotted(obj, dotted):
734
+ current = obj
735
+ for part in str(dotted).split("."):
736
+ if not isinstance(current, dict):
737
+ return None
738
+ current = current.get(part)
739
+ return current
740
+
741
+
742
+ def apply_finish(backend, config, plan, result, run_folder):
743
+ out = {}
744
+ finish = plan.get("finish") or {}
745
+ panel = finish.get("panel") or {}
746
+ writes = {}
747
+ for key, dotted in panel.items():
748
+ value = as_number(resolve_dotted(result, dotted))
749
+ if value is not None:
750
+ writes[str(key)] = value
751
+ if writes:
752
+ out["panel"] = jsonable(backend.set_globals(writes, raw=False))
753
+ history = finish.get("history") or {}
754
+ rows = []
755
+ log_path = config.get("calibrationLog")
756
+ if history and log_path:
757
+ log_path = os.path.abspath(expand(log_path))
758
+ os.makedirs(os.path.dirname(log_path), exist_ok=True)
759
+ with open(log_path, "a", encoding="utf-8") as handle:
760
+ for key, dotted in history.items():
761
+ value = as_number(resolve_dotted(result, dotted))
762
+ if value is None:
763
+ continue
764
+ row = {
765
+ "ts": now_iso(),
766
+ "protocol": plan.get("name") or plan.get("labscript"),
767
+ "run_folder": run_folder,
768
+ "key": str(key),
769
+ "value": value,
770
+ "postprocess": plan.get("postprocess"),
771
+ }
772
+ handle.write(json.dumps(row, ensure_ascii=False) + "\n")
773
+ rows.append(row)
774
+ if rows:
775
+ out["history"] = rows
776
+ return out
777
+
778
+
779
+ def restore_panel(backend, config, snapshot_file, protected, only_touched=True):
780
+ _, data = load_snapshot(config, snapshot_file) if snapshot_file else load_snapshot(config, "latest")
781
+ protected = protected or set()
782
+ touched = data.get("touched")
783
+ use_raw = isinstance(backend, LabscriptBackend)
784
+ if use_raw:
785
+ values = to_raw_values(data.get("raw_globals") or {})
786
+ else:
787
+ values = data.get("globals")
788
+ if not values:
789
+ values = to_raw_values(data.get("raw_globals") or {})
790
+ use_raw = True
791
+ if not values:
792
+ return {"restored": False, "reason": "snapshot has no values"}
793
+ if only_touched and touched:
794
+ wanted = set(str(key) for key in touched)
795
+ values = dict((k, v) for k, v in values.items() if k in wanted)
796
+ if protected:
797
+ values = dict((k, v) for k, v in values.items() if k not in protected)
798
+ if not values:
799
+ return {"restored": False, "reason": "nothing to restore after filtering touched/protected keys"}
800
+ written = backend.set_globals(values, raw=use_raw)
801
+ readback = backend.get_globals(raw=True)
802
+ return {
803
+ "restored": True,
804
+ "selective": bool(only_touched and touched),
805
+ "keys": sorted(str(key) for key in values),
806
+ "from": data.get("ts"),
807
+ "written": jsonable(written),
808
+ "readback": jsonable(readback),
809
+ }
810
+
811
+
812
+ # ---------------------------------------------------------------- protocol plan
813
+
814
+
815
+ def normalize_plan(raw):
816
+ if not isinstance(raw, dict):
817
+ raise BridgeError("BAD_PROTOCOL", "protocol must be a mapping")
818
+ plan = {}
819
+ plan["name"] = raw.get("name") or raw.get("protocol") or ""
820
+ plan["labscript"] = raw.get("labscript")
821
+ plan["joint_script"] = raw.get("joint_script")
822
+ plan["set"] = dict(raw.get("set") or {})
823
+ plan["scan"] = dict(raw.get("scan") or {})
824
+ plan["reps"] = raw.get("reps", 1)
825
+ plan["engage"] = bool(raw.get("engage", True))
826
+ plan["postprocess"] = raw.get("postprocess") or raw.get("analysis")
827
+ finish = raw.get("finish") or {}
828
+ plan["finish"] = {
829
+ "restore": bool(finish.get("restore", True)),
830
+ "panel": dict(finish.get("panel") or {}),
831
+ "history": dict(finish.get("history") or {}),
832
+ }
833
+ if not plan["labscript"] and not plan["joint_script"]:
834
+ raise BridgeError("BAD_PROTOCOL", "protocol needs labscript or joint_script")
835
+ reps = plan["reps"]
836
+ if isinstance(reps, bool) or not isinstance(reps, int) or reps <= 0:
837
+ raise BridgeError("BAD_PROTOCOL", "reps must be a positive integer")
838
+ return plan
839
+
840
+
841
+ def scan_expr_from_value(value):
842
+ """Protocol YAML scan value -> runmanager raw expression string."""
843
+ import numpy as np
844
+ if isinstance(value, bool):
845
+ raise BridgeError("BAD_PROTOCOL", "scan value cannot be a bool")
846
+ if isinstance(value, (int, float)):
847
+ return repr(float(value))
848
+ if isinstance(value, str):
849
+ arr = eval_scan_expr(value)
850
+ if arr.size < 1:
851
+ raise BridgeError("BAD_PROTOCOL", "scan expression is empty: " + value)
852
+ return value
853
+ if isinstance(value, (list, tuple)):
854
+ if not value:
855
+ raise BridgeError("BAD_PROTOCOL", "scan list is empty")
856
+ numbers = [float(item) for item in value]
857
+ if len(numbers) == 1:
858
+ return repr(numbers[0])
859
+ steps = [numbers[index + 1] - numbers[index] for index in range(len(numbers) - 1)]
860
+ mean = sum(steps) / len(steps)
861
+ tolerance = 1e-9 * max(1.0, abs(mean))
862
+ if all(abs(step - mean) <= tolerance for step in steps):
863
+ return "np.linspace(" + repr(numbers[0]) + ", " + repr(numbers[-1]) + ", " + str(len(numbers)) + ")"
864
+ return "np.array([" + ",".join(repr(number) for number in numbers) + "])"
865
+ raise BridgeError("BAD_PROTOCOL", "unsupported scan value: " + repr(value))
866
+
867
+
868
+ def resolve_plan(config, plan):
869
+ resolved = dict(plan)
870
+ if not resolved.get("labscript") and resolved.get("joint_script"):
871
+ path = config.get("scriptsFile")
872
+ if not path:
873
+ raise BridgeError("UNKNOWN_SCRIPT", "protocol uses joint_script but config.scriptsFile is not set")
874
+ path = os.path.abspath(expand(path))
875
+ if not os.path.isfile(path):
876
+ raise BridgeError("UNKNOWN_SCRIPT", "scriptsFile not found: " + path)
877
+ with open(path, "r", encoding="utf-8") as handle:
878
+ registry = json.load(handle)
879
+ entry = registry.get(resolved["joint_script"])
880
+ if not entry:
881
+ raise BridgeError("UNKNOWN_SCRIPT", "joint_script not registered: " + str(resolved["joint_script"]))
882
+ resolved["labscript"] = entry.get("labscript")
883
+ scan_exprs = {}
884
+ scan_raw = {}
885
+ n_points = 1
886
+ for key, value in resolved["scan"].items():
887
+ expr = scan_expr_from_value(value)
888
+ arr = eval_scan_expr(expr)
889
+ if arr.size < 1:
890
+ raise BridgeError("BAD_PROTOCOL", "scan axis is empty: " + key)
891
+ scan_exprs[key] = arr
892
+ scan_raw[key] = expr
893
+ n_points *= int(arr.size)
894
+ resolved["scan_exprs"] = scan_exprs
895
+ resolved["scan_raw"] = scan_raw
896
+ resolved["expected_n_shots"] = int(resolved["reps"]) * n_points
897
+ return resolved
898
+
899
+
900
+ # ---------------------------------------------------------------- commands
901
+
902
+
903
+ def cmd_status(config, args):
904
+ backend = make_backend(config)
905
+ keys = args.get("keys")
906
+ raw = bool(args.get("raw"))
907
+ values = backend.get_globals(raw=False)
908
+ raw_values = backend.get_globals(raw=True)
909
+ if keys:
910
+ values = dict((k, values.get(k)) for k in keys)
911
+ raw_values = dict((k, raw_values.get(k)) for k in keys)
912
+ out = {
913
+ "backend": config.get("backend"),
914
+ "version": backend.version() if hasattr(backend, "version") else None,
915
+ "labscript": backend.labscript_file(),
916
+ "shot_output_folder": backend.shot_output_folder(),
917
+ "globals": jsonable(values),
918
+ "n_shots": backend.n_shots(),
919
+ "error_in_globals": backend.error_in_globals(),
920
+ "run_shots": backend.run_shots(),
921
+ "panel_history": panel_history_info(config),
922
+ }
923
+ if raw:
924
+ out["raw_globals"] = jsonable(raw_values)
925
+ return out
926
+
927
+
928
+ def panel_history_info(config):
929
+ folder = history_dir(config)
930
+ files = sorted(glob.glob(os.path.join(folder, "*.json")))
931
+ latest = os.path.basename(files[-1]) if files else None
932
+ return {"dir": folder, "latest": latest, "count": len(files)}
933
+
934
+
935
+ def cmd_set(config, args):
936
+ backend = make_backend(config)
937
+ values = dict(args.get("values") or {})
938
+ reps = args.get("reps")
939
+ raw = bool(args.get("raw"))
940
+ if reps is not None:
941
+ if isinstance(reps, bool) or not isinstance(reps, int) or reps <= 0:
942
+ raise BridgeError("BAD_ARGS", "reps must be a positive integer")
943
+ reps_global = ((config.get("reps") or {}).get("global") or "dummy_iterator")
944
+ values[reps_global] = "np.arange(" + str(reps) + ")" if raw else list(range(reps))
945
+ raw = True
946
+ if not values:
947
+ raise BridgeError("BAD_ARGS", "values must not be empty")
948
+ if raw:
949
+ values = dict((k, v) if isinstance(v, str) else (k, values_to_raw({k: v})[k]) for k, v in values.items()) if False else dict((k, v if isinstance(v, str) else values_to_raw({k: v})[k]) for k, v in values.items())
950
+ elif has_array_value(values):
951
+ values = values_to_raw(values)
952
+ raw = True
953
+ check_limits(config, dict((k, v) for k, v in values.items() if not raw), dict((k, v) for k, v in values.items()) if raw else {}, None)
954
+ snap = snapshot_panel(backend, config, "set", {"keys": sorted(values.keys()), "raw": raw, "note": args.get("note")}, touched=values.keys())
955
+ try:
956
+ written = backend.set_globals(values, raw=raw)
957
+ except BridgeError as exc:
958
+ detail = dict(exc.detail or {})
959
+ detail["snapshot"] = snap
960
+ detail["hint"] = "set_globals failed after the panel snapshot; the panel may be partially written. Use labs_restore to undo."
961
+ raise BridgeError(exc.code, exc.message, detail)
962
+ except Exception as exc:
963
+ raise BridgeError("SET_FAILED", "set_globals failed: " + type(exc).__name__ + ": " + str(exc), {
964
+ "snapshot": snap,
965
+ "hint": "the panel may be partially written; use labs_restore to undo",
966
+ })
967
+ readback = backend.get_globals(raw=False)
968
+ raw_readback = backend.get_globals(raw=True)
969
+ missing = [k for k in values if k not in readback]
970
+ out = {
971
+ "written": jsonable(written),
972
+ "readback": dict((k, jsonable(readback.get(k))) for k in values),
973
+ "raw_readback": dict((k, jsonable(raw_readback.get(k))) for k in values),
974
+ "missing": missing,
975
+ "n_shots": backend.n_shots(),
976
+ "error_in_globals": backend.error_in_globals(),
977
+ "snapshot": snap,
978
+ }
979
+ append_event(config, {"ts": now_iso(), "tool": "set", "backend": config.get("backend"), "note": args.get("note"), "keys": sorted(values.keys()), "snapshot": snap["name"]})
980
+ return out
981
+
982
+
983
+ def cmd_engage(config, args):
984
+ backend = make_backend(config)
985
+ if backend.error_in_globals():
986
+ raise BridgeError("GLOBALS_ERROR", "panel globals are in an error state; refusing to engage")
987
+ run_shots = backend.run_shots()
988
+ if run_shots is False:
989
+ raise BridgeError("RUN_SHOTS_OFF", "the runmanager 'Run shot(s)' checkbox is off; engage would only compile and never execute")
990
+ n_shots = backend.n_shots()
991
+ result = backend.engage()
992
+ run_folder = None
993
+ if isinstance(result, dict):
994
+ run_folder = result.get("run_folder")
995
+ else:
996
+ run_folder = getattr(result, "run_folder", None)
997
+ out = {
998
+ "accepted": True,
999
+ "run_folder": run_folder,
1000
+ "mapped_run_folder": map_path(config, run_folder),
1001
+ "n_shots_at_submit": n_shots,
1002
+ "expected_n_shots": int(args.get("expected_n_shots") or n_shots),
1003
+ "labscript": backend.labscript_file(),
1004
+ "submitted_at": now_iso(),
1005
+ }
1006
+ append_event(config, {"ts": out["submitted_at"], "tool": "engage", "backend": config.get("backend"), "note": args.get("note"), "run_folder": run_folder, "expected_n_shots": out["expected_n_shots"]})
1007
+ return out
1008
+
1009
+
1010
+ def wait_for_run(config, run_folder, expected, timeout_s):
1011
+ if not run_folder:
1012
+ raise BridgeError("BAD_ARGS", "run_folder is required")
1013
+ folder = map_path(config, run_folder)
1014
+ engage_cfg = config.get("engage") or {}
1015
+ timeout = float(timeout_s or engage_cfg.get("timeoutS") or 900)
1016
+ poll = float(engage_cfg.get("pollS") or 1.0)
1017
+ settle = float(engage_cfg.get("settleS") or 2.0)
1018
+ stall = float(engage_cfg.get("stallS") or 20.0)
1019
+ start = time.time()
1020
+ stable_since = None
1021
+ compiled_since = None
1022
+ last_count = -1
1023
+ last_size = -1
1024
+ last_executed = None
1025
+
1026
+ def result(complete, timed_out, diagnosis, count, executed, elapsed, hint=None):
1027
+ payload = {
1028
+ "run_folder": run_folder,
1029
+ "mapped_run_folder": folder,
1030
+ "folder_exists": os.path.isdir(folder),
1031
+ "expected_n_shots": expected,
1032
+ "n_h5": count,
1033
+ "complete": complete,
1034
+ "timed_out": timed_out,
1035
+ "diagnosis": diagnosis,
1036
+ "executed": executed,
1037
+ "elapsed": round(elapsed, 1),
1038
+ }
1039
+ if hint:
1040
+ payload["hint"] = hint
1041
+ return payload
1042
+
1043
+ while True:
1044
+ shots = sorted(glob.glob(os.path.join(folder, "**", "*.h5"), recursive=True))
1045
+ count = len(shots)
1046
+ total = sum(os.path.getsize(p) for p in shots) if shots else 0
1047
+ ready = False
1048
+ if count >= (expected or 0) and count == last_count and total == last_size and shots:
1049
+ ready = shot_ready(shots[-1])
1050
+ executed = None
1051
+ if ready:
1052
+ executed = shot_executed(shots[-1])
1053
+ if ready and executed is True:
1054
+ compiled_since = None
1055
+ if stable_since is None:
1056
+ stable_since = time.time()
1057
+ elif time.time() - stable_since >= settle:
1058
+ return result(True, False, "complete", count, True, time.time() - start)
1059
+ else:
1060
+ stable_since = None
1061
+ if ready and executed is False:
1062
+ if compiled_since is None:
1063
+ compiled_since = time.time()
1064
+ elif time.time() - compiled_since >= stall:
1065
+ return result(
1066
+ False, False, "compiled_not_executed", count, False, time.time() - start,
1067
+ "shots were compiled but show no acquisition data after " + str(int(stall)) +
1068
+ " 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.")
1069
+ else:
1070
+ compiled_since = None
1071
+ last_count = count
1072
+ last_size = total
1073
+ last_executed = executed
1074
+ if time.time() - start >= timeout:
1075
+ if count == 0:
1076
+ diagnosis = "no_shots"
1077
+ hint = "no shot h5 appeared: check that the run was accepted, that BLACS is running, and that pathMap points at the run folder."
1078
+ elif count < (expected or 0):
1079
+ diagnosis = "compiling"
1080
+ hint = "only " + str(count) + " of " + str(expected) + " shots appeared before the timeout; the run may still be compiling or blocked."
1081
+ elif executed is False:
1082
+ diagnosis = "compiled_not_executed"
1083
+ hint = "shots were compiled but no acquisition data was found: BLACS may be paused or not executing the queue."
1084
+ else:
1085
+ diagnosis = "partial"
1086
+ hint = "shots appeared but the run was not confirmed complete before the timeout."
1087
+ return result(False, True, diagnosis, count, executed, time.time() - start, hint)
1088
+ time.sleep(poll)
1089
+
1090
+
1091
+ def cmd_wait(config, args):
1092
+ return wait_for_run(config, args.get("run_folder"), int(args.get("expected_n_shots") or 0), args.get("timeout_s"))
1093
+
1094
+
1095
+ def cmd_abort(config, args):
1096
+ backend = make_backend(config)
1097
+ backend_name = (config.get("backend") or "").lower()
1098
+ if backend_name == "labscript":
1099
+ note = ("labscript abort stops compiling/submitting new shots. Shots that are already compiled and queued "
1100
+ "in BLACS, or currently running, are not stopped by it. This is not a hardware interlock.")
1101
+ elif backend_name == "http-sim":
1102
+ note = "http-sim abort stops the simulated queue (pause + clear)."
1103
+ else:
1104
+ note = "mock backend abort is a no-op."
1105
+ result = backend.abort()
1106
+ append_event(config, {"ts": now_iso(), "tool": "abort", "backend": config.get("backend"), "note": note, "result": jsonable(result)})
1107
+ return {"aborted": True, "note": note, "result": jsonable(result)}
1108
+
1109
+
1110
+ def cmd_progress(config, args):
1111
+ run_folder = args.get("run_folder")
1112
+ expected = int(args.get("expected_n_shots") or 0)
1113
+ folder = map_path(config, run_folder)
1114
+ shots = sorted(glob.glob(os.path.join(folder, "**", "*.h5"), recursive=True)) if folder else []
1115
+ return {"run_folder": run_folder, "n_h5": len(shots), "expected_n_shots": expected, "complete": bool(expected and len(shots) >= expected)}
1116
+
1117
+
1118
+ def cmd_data(config, args):
1119
+ ensure_h5_lock()
1120
+ import h5py
1121
+ run_folder = args.get("run_folder")
1122
+ if not run_folder:
1123
+ raise BridgeError("BAD_ARGS", "run_folder is required")
1124
+ folder = map_path(config, run_folder)
1125
+ shots = sorted(glob.glob(os.path.join(folder, "**", "*.h5"), recursive=True))
1126
+ out = {"run_folder": run_folder, "mapped_run_folder": folder, "n_h5": len(shots)}
1127
+ if not shots:
1128
+ return out
1129
+ scan = args.get("scan")
1130
+ with h5py.File(shots[0], "r", locking=False) as handle:
1131
+ globals_attrs = dict(handle["globals"].attrs) if "globals" in handle else {}
1132
+ if args.get("globals", True):
1133
+ out["globals"] = jsonable(globals_attrs)
1134
+ if args.get("results"):
1135
+ layout = {}
1136
+ if "results" in handle:
1137
+ for group_name in handle["results"]:
1138
+ group = handle["results"][group_name]
1139
+ if isinstance(group, h5py.Group):
1140
+ layout[group_name] = sorted(list(group.keys()) + list(group.attrs.keys()))
1141
+ out["results_layout"] = layout
1142
+ if scan:
1143
+ rows = []
1144
+ for path in shots:
1145
+ with h5py.File(path, "r", locking=False) as handle:
1146
+ attrs = handle["globals"].attrs if "globals" in handle else {}
1147
+ row = {"shot": os.path.basename(path)}
1148
+ if scan in attrs:
1149
+ row[scan] = jsonable(attrs[scan])
1150
+ if "results" in handle:
1151
+ for group_name in handle["results"]:
1152
+ group = handle["results"][group_name]
1153
+ if not isinstance(group, h5py.Group):
1154
+ continue
1155
+ for key in list(group.attrs.keys()):
1156
+ value = as_number(group.attrs[key])
1157
+ if value is not None:
1158
+ row[group_name + "." + key] = value
1159
+ for key in group.keys():
1160
+ item = group[key]
1161
+ if isinstance(item, h5py.Dataset) and item.size <= 16:
1162
+ try:
1163
+ value = item[()]
1164
+ if hasattr(value, "item"):
1165
+ value = value.item()
1166
+ except Exception:
1167
+ continue
1168
+ number = as_number(value)
1169
+ if number is not None:
1170
+ row[group_name + "." + key] = number
1171
+ rows.append(row)
1172
+ out["scan_table"] = {"scan": scan, "n_points": len(rows), "rows": rows}
1173
+ return out
1174
+
1175
+
1176
+ def cmd_restore(config, args):
1177
+ backend = make_backend(config)
1178
+ snap = args.get("snapshot")
1179
+ full = bool(args.get("full"))
1180
+ path, data = load_snapshot(config, snap)
1181
+ touched = data.get("touched")
1182
+ use_raw = isinstance(backend, LabscriptBackend)
1183
+ if use_raw:
1184
+ values = to_raw_values(data.get("raw_globals") or {})
1185
+ else:
1186
+ values = data.get("globals") or to_raw_values(data.get("raw_globals") or {})
1187
+ if not data.get("globals"):
1188
+ use_raw = True
1189
+ if not full and touched:
1190
+ wanted = set(str(key) for key in touched)
1191
+ values = dict((k, v) for k, v in values.items() if k in wanted)
1192
+ written = backend.set_globals(values, raw=use_raw)
1193
+ readback = backend.get_globals(raw=True)
1194
+ append_event(config, {"ts": now_iso(), "tool": "restore", "backend": config.get("backend"), "snapshot": os.path.basename(path)})
1195
+ return {
1196
+ "restored_from": {"file": path, "ts": data.get("ts"), "tool": data.get("tool")},
1197
+ "selective": bool(not full and touched),
1198
+ "keys": sorted(str(key) for key in values),
1199
+ "written": jsonable(written),
1200
+ "readback": jsonable(readback),
1201
+ }
1202
+
1203
+
1204
+ def cmd_protocol_plan(config, args):
1205
+ plan = resolve_plan(config, normalize_plan(args.get("protocol") or {}))
1206
+ limits = check_limits(config, plan["set"], dict((k, v) for k, v in plan["scan_raw"].items()), plan["expected_n_shots"])
1207
+ public = {
1208
+ "name": plan.get("name"),
1209
+ "labscript": plan.get("labscript"),
1210
+ "joint_script": plan.get("joint_script"),
1211
+ "set": jsonable(plan["set"]),
1212
+ "scan": dict((k, {"expr": plan["scan_raw"][k], "n": int(plan["scan_exprs"][k].size)}) for k in plan["scan"]),
1213
+ "reps": plan["reps"],
1214
+ "engage": plan["engage"],
1215
+ "postprocess": plan.get("postprocess"),
1216
+ "finish": jsonable(plan["finish"]),
1217
+ "expected_n_shots": plan["expected_n_shots"],
1218
+ }
1219
+ warnings = []
1220
+ if not plan.get("postprocess"):
1221
+ warnings.append("protocol has no postprocess")
1222
+ return {"plan": public, "hard_limits": limits, "warnings": warnings}
1223
+
1224
+
1225
+ def cmd_protocol_start(config, args):
1226
+ plan = resolve_plan(config, normalize_plan(args.get("protocol") or {}))
1227
+ backend = make_backend(config)
1228
+ check_limits(config, plan["set"], dict((k, v) for k, v in plan["scan_raw"].items()), plan["expected_n_shots"])
1229
+ reps_global = ((config.get("reps") or {}).get("global") or "dummy_iterator")
1230
+ touched = set(plan["set"]) | set(plan["scan"]) | {reps_global}
1231
+ snap = snapshot_panel(backend, config, "protocol", {"name": plan.get("name"), "labscript": plan.get("labscript"), "note": args.get("note")}, touched=touched)
1232
+ steps = {}
1233
+ try:
1234
+ if plan["set"]:
1235
+ steps["set"] = jsonable(backend.set_globals(plan["set"], raw=False))
1236
+ scan_values = dict((k, expr) for k, expr in plan["scan_raw"].items())
1237
+ scan_values[reps_global] = "np.arange(" + str(plan["reps"]) + ")"
1238
+ steps["scan"] = jsonable(backend.set_globals(scan_values, raw=True))
1239
+ except BridgeError as exc:
1240
+ detail = dict(exc.detail or {})
1241
+ detail["snapshot"] = snap
1242
+ detail["hint"] = "protocol panel write failed; the panel may be partially written. Use labs_restore to undo."
1243
+ raise BridgeError(exc.code, exc.message, detail)
1244
+ except Exception as exc:
1245
+ raise BridgeError("PROTOCOL_WRITE_FAILED", "protocol panel write failed: " + type(exc).__name__ + ": " + str(exc), {
1246
+ "snapshot": snap,
1247
+ "hint": "the panel may be partially written; use labs_restore to undo",
1248
+ })
1249
+ panel_n_shots = backend.n_shots()
1250
+ if panel_n_shots != plan["expected_n_shots"]:
1251
+ raise BridgeError("N_SHOTS_MISMATCH", "panel n_shots does not match protocol plan", {"panel_n_shots": panel_n_shots, "expected": plan["expected_n_shots"]})
1252
+ if not plan["engage"]:
1253
+ return {"plan": public_plan(plan), "snapshot": snap, "steps": steps, "engage": None, "n_shots_at_submit": panel_n_shots}
1254
+ result = backend.engage()
1255
+ run_folder = result.get("run_folder") if isinstance(result, dict) else getattr(result, "run_folder", None)
1256
+ out = {
1257
+ "plan": public_plan(plan),
1258
+ "snapshot": snap,
1259
+ "steps": steps,
1260
+ "engage": {"accepted": True, "run_folder": run_folder, "mapped_run_folder": map_path(config, run_folder), "n_shots_at_submit": panel_n_shots},
1261
+ "n_shots_at_submit": panel_n_shots,
1262
+ "expected_n_shots": plan["expected_n_shots"],
1263
+ "submitted_at": now_iso(),
1264
+ }
1265
+ append_event(config, {"ts": out["submitted_at"], "tool": "protocol", "backend": config.get("backend"), "note": args.get("note"), "protocol": plan.get("name"), "run_folder": run_folder, "expected_n_shots": plan["expected_n_shots"]})
1266
+ return out
1267
+
1268
+
1269
+ def public_plan(plan):
1270
+ return {
1271
+ "name": plan.get("name"),
1272
+ "labscript": plan.get("labscript"),
1273
+ "joint_script": plan.get("joint_script"),
1274
+ "set": jsonable(plan["set"]),
1275
+ "scan": dict((k, {"expr": plan["scan"][k], "n": int(plan["scan_exprs"][k].size)}) for k in plan["scan"]),
1276
+ "reps": plan["reps"],
1277
+ "engage": plan["engage"],
1278
+ "postprocess": plan.get("postprocess"),
1279
+ "finish": jsonable(plan["finish"]),
1280
+ "expected_n_shots": plan["expected_n_shots"],
1281
+ }
1282
+
1283
+
1284
+ def cmd_protocol_finalize(config, args):
1285
+ plan = resolve_plan(config, normalize_plan(args.get("protocol") or {}))
1286
+ run_folder = args.get("run_folder")
1287
+ if not run_folder:
1288
+ raise BridgeError("BAD_ARGS", "run_folder is required")
1289
+ backend = make_backend(config)
1290
+ out = {"run_folder": run_folder}
1291
+ result = None
1292
+ if plan.get("postprocess"):
1293
+ scan_global = next(iter(plan["scan"]), None) if plan["scan"] else None
1294
+ result = run_postprocess(config, plan, map_path(config, run_folder), scan_global, plan["reps"])
1295
+ out["postprocess"] = result
1296
+ if plan.get("finish"):
1297
+ out["finish"] = apply_finish(backend, config, plan, result or {}, run_folder)
1298
+ if plan["finish"].get("restore"):
1299
+ protected = set((plan["finish"].get("panel") or {}).keys())
1300
+ out["restore"] = restore_panel(backend, config, (args.get("snapshot") or {}).get("file"), protected)
1301
+ append_event(config, {"ts": now_iso(), "tool": "protocol_finalize", "backend": config.get("backend"), "note": args.get("note"), "protocol": plan.get("name"), "run_folder": run_folder, "postprocess": bool(result), "restore": (out.get("restore") or {}).get("restored")})
1302
+ return out
1303
+
1304
+
1305
+ def _add_check(checks, name, status, detail, hint=""):
1306
+ checks.append({"name": name, "status": status, "detail": str(detail), "hint": hint})
1307
+
1308
+
1309
+ def cmd_doctor(config, args):
1310
+ """Read-only environment/config diagnostic (no panel writes, no engage)."""
1311
+ checks = []
1312
+ _add_check(checks, "python", "ok", sys.version.split()[0])
1313
+ try:
1314
+ import numpy as np
1315
+ _add_check(checks, "numpy", "ok", np.__version__)
1316
+ except Exception as exc:
1317
+ _add_check(checks, "numpy", "fail", str(exc), "install numpy (the labscript-suite environment has it)")
1318
+ try:
1319
+ import h5py
1320
+ _add_check(checks, "h5py", "ok", h5py.__version__)
1321
+ except Exception as exc:
1322
+ _add_check(checks, "h5py", "fail", str(exc), "install h5py")
1323
+
1324
+ backend = (config.get("backend") or "mock-labscript").lower()
1325
+ _add_check(checks, "backend", "ok", backend)
1326
+ if backend == "labscript":
1327
+ missing = []
1328
+ for module in ("labscript", "runmanager", "labscript_utils", "zprocess"):
1329
+ try:
1330
+ importlib.import_module(module)
1331
+ except Exception as exc:
1332
+ missing.append(module + ": " + str(exc))
1333
+ if missing:
1334
+ _add_check(checks, "labscript-suite", "fail", "; ".join(missing), "use the labscript-suite python environment")
1335
+ else:
1336
+ _add_check(checks, "labscript-suite", "ok", "labscript / runmanager / labscript_utils / zprocess importable")
1337
+ try:
1338
+ probe = LabscriptBackend(config)
1339
+ client = probe.client()
1340
+ version = client.get_version() if hasattr(client, "get_version") else "unknown"
1341
+ _add_check(checks, "runmanager", "ok", str(version))
1342
+ try:
1343
+ _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()))
1344
+ except Exception as exc:
1345
+ _add_check(checks, "panel", "warn", str(exc), "runmanager connected but the panel read failed")
1346
+ try:
1347
+ _add_check(checks, "shot-output", "ok", str(probe.shot_output_folder()))
1348
+ except Exception as exc:
1349
+ _add_check(checks, "shot-output", "warn", str(exc), "cannot resolve the shot output folder")
1350
+ except Exception as exc:
1351
+ _add_check(checks, "runmanager", "fail", str(exc), "check runmanager host/port and the zprocess shared secret in labconfig")
1352
+ else:
1353
+ try:
1354
+ probe = make_backend(config)
1355
+ status = cmd_status(config, {})
1356
+ _add_check(checks, "backend-status", "ok", "n_shots=" + str(status.get("n_shots")) + ", labscript=" + str(status.get("labscript")))
1357
+ except Exception as exc:
1358
+ _add_check(checks, "backend-status", "fail", str(exc), "check the mock/simulator configuration")
1359
+
1360
+ protocols_dir = config.get("protocolsDir")
1361
+ if protocols_dir:
1362
+ path = os.path.abspath(expand(protocols_dir))
1363
+ _add_check(checks, "protocols-dir", "ok" if os.path.isdir(path) else "warn", path, "" if os.path.isdir(path) else "directory does not exist")
1364
+ scripts_file = config.get("scriptsFile")
1365
+ if scripts_file and os.path.isfile(os.path.abspath(expand(scripts_file))):
1366
+ try:
1367
+ with open(os.path.abspath(expand(scripts_file)), "r", encoding="utf-8") as handle:
1368
+ json.load(handle)
1369
+ _add_check(checks, "scripts-file", "ok", scripts_file)
1370
+ except Exception as exc:
1371
+ _add_check(checks, "scripts-file", "fail", str(exc), "scriptsFile must be JSON")
1372
+ hard_limits = config.get("hardLimitsFile")
1373
+ if hard_limits:
1374
+ try:
1375
+ with open(os.path.abspath(expand(hard_limits)), "r", encoding="utf-8") as handle:
1376
+ json.load(handle)
1377
+ _add_check(checks, "hard-limits-file", "ok", hard_limits)
1378
+ except Exception as exc:
1379
+ _add_check(checks, "hard-limits-file", "warn", str(exc), "hard limits are not enforced if this file cannot be read")
1380
+ try:
1381
+ base = history_dir(config)
1382
+ _add_check(checks, "state-dir", "ok", base)
1383
+ except Exception as exc:
1384
+ _add_check(checks, "state-dir", "fail", str(exc), "cannot create the panel history directory")
1385
+
1386
+ spec = config.get("postprocess") or {}
1387
+ entry = spec.get("entry")
1388
+ if entry:
1389
+ for extra in spec.get("pythonPaths") or []:
1390
+ extra_path = os.path.abspath(expand(extra))
1391
+ if extra_path not in sys.path:
1392
+ sys.path.insert(0, extra_path)
1393
+ try:
1394
+ module_name, _, function_name = entry.partition(":")
1395
+ module = importlib.import_module(module_name)
1396
+ if not hasattr(module, function_name):
1397
+ raise AttributeError("module has no attribute " + function_name)
1398
+ _add_check(checks, "postprocess", "ok", entry)
1399
+ except Exception as exc:
1400
+ _add_check(checks, "postprocess", "fail", str(exc), "postprocess.entry must be module:function and importable from pythonPaths")
1401
+ return {"checks": checks, "backend": backend, "python": sys.version.split()[0]}
1402
+
1403
+
1404
+ HANDLERS = {
1405
+ "status": cmd_status,
1406
+ "set": cmd_set,
1407
+ "engage": cmd_engage,
1408
+ "wait": cmd_wait,
1409
+ "abort": cmd_abort,
1410
+ "progress": cmd_progress,
1411
+ "data": cmd_data,
1412
+ "restore": cmd_restore,
1413
+ "protocol_plan": cmd_protocol_plan,
1414
+ "protocol_start": cmd_protocol_start,
1415
+ "protocol_finalize": cmd_protocol_finalize,
1416
+ "doctor": cmd_doctor,
1417
+ }
1418
+
1419
+
1420
+ def main():
1421
+ try:
1422
+ request = json.load(sys.stdin)
1423
+ except Exception as exc:
1424
+ sys.stdout.write(json.dumps({"ok": False, "error": {"code": "BAD_REQUEST", "message": "invalid JSON on stdin: " + str(exc)}}))
1425
+ return 0
1426
+ command = request.get("cmd") or ""
1427
+ handler = HANDLERS.get(command)
1428
+ if handler is None:
1429
+ sys.stdout.write(json.dumps({"ok": False, "error": {"code": "UNKNOWN_COMMAND", "message": "unknown bridge command: " + str(command)}}))
1430
+ return 0
1431
+ try:
1432
+ data = handler(request.get("config") or {}, request.get("args") or {})
1433
+ sys.stdout.write(json.dumps({"ok": True, "data": jsonable(data)}, ensure_ascii=True))
1434
+ return 0
1435
+ except BridgeError as exc:
1436
+ sys.stdout.write(json.dumps({"ok": False, "error": {"code": exc.code, "message": exc.message, "detail": jsonable(exc.detail)}}, ensure_ascii=True))
1437
+ return 0
1438
+ except Exception as exc:
1439
+ traceback.print_exc(file=sys.stderr)
1440
+ sys.stdout.write(json.dumps({"ok": False, "error": {"code": "INTERNAL", "message": type(exc).__name__ + ": " + str(exc)}}, ensure_ascii=True))
1441
+ return 1
1442
+
1443
+
1444
+ if __name__ == "__main__":
1445
+ sys.exit(main())