dsh-labscript 0.2.8 → 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 CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ Implements the upstream request written from the field (labscript-suite workflow terms and
6
+ `runmanager.remote` limits). No output field is renamed or removed.
7
+
8
+ - `n_shots` is now documented and rendered as the authoritative shot count. The scan-axes line is
9
+ labelled a heuristic: runmanager expands list/array globals but passes tuples through, and an
10
+ expansion setting of `none` turns an array into a non-axis; neither is visible through
11
+ `runmanager.remote`. A mismatch now reads "n_shots is authoritative (a tuple global or an
12
+ expansion setting of none is not an axis)" instead of printing two numbers with no verdict. The
13
+ bridge's axis guess ignores tuples and dicts, and the mock backend follows the same rule (it used
14
+ to multiply tuples in, pinning the wrong semantics into the tests).
15
+ - Skill and tool descriptions no longer tell the agent to "read the runmanager output": compilation
16
+ errors appear only in runmanager's output tab (the compiler subprocess writes its traceback to
17
+ stderr and runmanager redirects it to an in-memory Qt widget), so the instruction is now to say
18
+ that no shot file appeared, ask the operator to paste the tab, and not guess the cause.
19
+ `labs_set` also warns that a global name must not shadow a Python builtin or a
20
+ `from labscript import *` name: runmanager accepts it and labscript fails at compile time.
21
+ - `compiled_not_executed` explains the other official causes: BLACS may keep a shot out of its
22
+ queue (the shot's connection table must be a subset of the lab connection table) or put it back
23
+ at the head after a device programming timeout; all of that is visible only in the BLACS GUI.
24
+ More shot files than expected now points at a changed panel or BLACS Repeat all / Repeat last,
25
+ which re-runs completed shots without creating new files.
26
+ - `labs_status` reports `shuffle` (from `get_shuffle`; "unknown" when that runmanager version has
27
+ no such method). With shuffle on, shot order is not axis order: read each shot's parameters from
28
+ its own h5 globals.
29
+ - runmanager host/port/timeout default to labscript's labconfig when unset, resolved by
30
+ `runmanager.remote.Client` itself (`[servers]/[ports]/[timeouts]`, port 42523, timeout 60 s);
31
+ configured values still win. The Plugins page now leaves host/port blank for that reason, and
32
+ clearing them hands the endpoint back to labconfig. `labs_doctor` prints the effective endpoint
33
+ and whether it came from config or labconfig.
34
+ - mock backend: `mock.errorInGlobals` makes `error_in_globals` true so the engagement refusal can
35
+ be tested, and `mock.shuffle` sets the reported shuffle flag.
36
+ - Docs: a terminology table maps the plugin's short names to the suite's (shot output folder,
37
+ sequence, shot file, parameter space/axes, acquired data) without renaming fields.
38
+ - Compilation logs stay out of scope: nothing about a compilation failure is written anywhere
39
+ readable (see README), so no `labs_compile_log` was added; a guessed diagnosis would be worse
40
+ than none.
41
+ - Tests: 33 python + 47 node (axis heuristic with tuples/dicts, mock error/shuffle switches,
42
+ labconfig client defaults, extra shot files, scan/shuffle rendering, endpoint clearing).
43
+
3
44
  ## 0.2.8
4
45
 
5
46
  Fixes from a real labscript-suite session (2026-11-14, 145 tool calls, no run submitted by the
package/README.md CHANGED
@@ -40,9 +40,9 @@ config). Run `labs_doctor` first if anything looks misconfigured.
40
40
  | Tool | What it does |
41
41
  |---|---|
42
42
  | labs_doctor | Read-only configuration diagnostic: python, numpy/h5py, labscript/runmanager, paths, approval, versions. |
43
- | labs_status | Read the panel: labscript, globals, n_shots, error_in_globals, run_shots, pending run progress, snapshot directory |
43
+ | labs_status | Read the panel: labscript, globals, n_shots, error_in_globals, run_shots, shuffle, pending run progress, snapshot directory |
44
44
  | labs_set | Write panel globals (or raw expressions, or the repetition counter). Snapshots the panel first. Does not run. |
45
- | labs_engage | Submit the current panel for a run; returns immediately with n_shots_at_submit and expected_n_shots |
45
+ | labs_engage | Submit the current panel as one sequence (shot files to BLACS); returns immediately with n_shots_at_submit and expected_n_shots |
46
46
  | labs_wait | Wait (blocking, with timeout) for the pending run; finalizes a pending protocol: postprocess, finish write-back, restore |
47
47
  | labs_abort | Abort the current submission |
48
48
  | labs_data | Read the run folder HDF5: first-shot globals and, with scan, a per-shot table |
@@ -84,7 +84,7 @@ Add one row to your DSH profile patch (see examples/config.example.yml):
84
84
  config:
85
85
  python: /path/to/labscript-suite/python
86
86
  backend: labscript
87
- runmanager: { host: 127.0.0.1, port: 42523, timeoutS: 10 }
87
+ # runmanager: { host: 127.0.0.1, port: 42523, timeoutS: 10 } # optional; labconfig otherwise
88
88
  protocolsDir: /path/to/protocols
89
89
  scriptsFile: /path/to/scripts.json
90
90
  hardLimitsFile: /path/to/hard_limits.json
@@ -103,6 +103,12 @@ already mapped: `labs_status`, `labs_engage`, `labs_wait`, `labs_data` and
103
103
  `labs_protocol` show local paths, while `run_folder` keeps the raw runmanager
104
104
  value next to `mapped_run_folder`.
105
105
 
106
+ `runmanager` may be omitted: host, port and timeout then come from labscript's labconfig
107
+ (`[servers] runmanager`, `[ports] runmanager` — default 42523 — and
108
+ `[timeouts] communication_timeout` — default 60 s), the same way `runmanager.remote.Client`
109
+ resolves them. Configured values override labconfig. The Plugins page leaves host/port blank
110
+ for that reason; typing them pins them.
111
+
106
112
  `engage` controls how a submitted run is watched: `timeoutS` (default 900) is the
107
113
  total wait budget of one `labs_wait`, `noShotS` (default 25) makes the wait return
108
114
  early with a diagnosis when no shot appeared, `staleS` (default 180) drops a pending
@@ -133,8 +139,8 @@ is not a physics simulation.
133
139
  ## Development
134
140
 
135
141
  npm install # only runtime dependency: yaml
136
- npm test # node --test, 46 tests (fake bridge, no python needed)
137
- python python/tests/run_tests.py # 28 tests (needs numpy + h5py)
142
+ npm test # node --test, 47 tests (fake bridge, no python needed)
143
+ python python/tests/run_tests.py # 33 tests (needs numpy + h5py)
138
144
  LABSCRIPT_PYTHON=/path/to/python3 node scripts/demo.mjs # end-to-end mock demo
139
145
 
140
146
  A fresh clone must run `npm install` before `npm test`; the CI workflow does this.
@@ -144,6 +150,20 @@ A fresh clone must run `npm install` before `npm test`; the CI workflow does thi
144
150
  - docs/README.zh.md - Chinese guide
145
151
  - docs/INSTALL.zh.md - local DSH profile install
146
152
 
153
+ ## Terminology
154
+
155
+ The plugin uses short names; the labscript suite uses its own. Both describe the same objects,
156
+ and the field names are unchanged:
157
+
158
+ | plugin | labscript suite |
159
+ |---|---|
160
+ | run folder, `run_folder` | **shot output folder** (one **sequence**'s shot files) |
161
+ | run, engage | one **sequence**: one shot file per point in the parameter space |
162
+ | shot h5, `n_h5` | **shot file** (`.h5`) |
163
+ | scan, scan axis | **parameter space**, **axis**, **expansion** (`none`/`outer`/zip group/`auto`) |
164
+ | `n_executed` | shots that **acquired data** (`images/`, `results/`) |
165
+ | postprocess | lyse **single-shot / multi-shot routine** |
166
+
147
167
  ## Boundaries
148
168
 
149
169
  - Real hardware: engage/protocol run ask for a DSH approval (one-shot) when the
@@ -154,6 +174,23 @@ A fresh clone must run `npm install` before `npm test`; the CI workflow does thi
154
174
  count as 0/1. They are advisory configuration, not a safety interlock.
155
175
  - labs_protocol show warns when a set/scan key is missing from the currently loaded
156
176
  labscript; protocol runs ask for confirmation before proceeding on real hardware.
177
+ - The panel's `n_shots` is the authoritative shot count. The reported scan axes and
178
+ `n_shots_from_arrays` are a heuristic: runmanager expands list/array globals but passes
179
+ tuples through unwrapped, and an expansion setting of `none` turns an array back into a
180
+ non-axis; neither is visible through the remote API. When the two numbers disagree,
181
+ `n_shots` wins and the difference is reported as such.
182
+ - `labs_status` reports `shuffle`. With shuffle on, shot order is not axis order, so each
183
+ shot's parameters must be read from its own h5 globals.
184
+ - Compilation errors appear only in runmanager's output tab. The compiler subprocess writes the
185
+ traceback to its stderr, which runmanager redirects to that tab (an in-memory Qt widget);
186
+ `runmanager.remote` has no interface for it and nothing is written to a log file. The plugin
187
+ therefore reports that no shot file appeared and asks for the tab to be pasted, instead of
188
+ guessing why compilation failed.
189
+ - A shot file exists as soon as a sequence starts; `images/` and `results/` are written only
190
+ after execution, which is what `n_executed` counts. BLACS can keep a shot out of its queue
191
+ (its connection table must be a subset of the lab connection table) or put it back at the head
192
+ after a device programming timeout, and BLACS Repeat all / Repeat last re-runs completed shots
193
+ without creating new files; none of that is visible to the plugin.
157
194
  - Completion is detected from the run folder on the filesystem (HDF5 count,
158
195
  stable file sizes, and a readable last shot); stock labscript has no
159
196
  run-state query.
package/docs/README.zh.md CHANGED
@@ -27,6 +27,9 @@ hard limits 全部通过配置接入。
27
27
  - 要求:DSH >= 0.2、Node ^22.19/24;Python >= 3.9。mock 模式只要 numpy+h5py;
28
28
  真机用 labscript-suite 环境。详见 docs/INSTALL.zh.md。
29
29
  - 默认 backend 是 mock-labscript;真机必须显式配置 backend: labscript。
30
+ - runmanager 的 host/port/timeout 可以不填:不填就读 labscript 的 labconfig
31
+ (`[servers]/[ports] runmanager`,端口默认 42523;`[timeouts] communication_timeout`,
32
+ 默认 60 s),和 runmanager 自己的客户端一致;填了就以配置为准。插件页留空即走 labconfig。
30
33
  - 安装后在 DSH 插件页填 backend、python、runmanager host/port、protocolsDir;
31
34
  其余高级配置(scriptsFile、hard limits、postprocess、approval、pathMap、
32
35
  engage 的超时/早退参数)写在 profile 的 cordis.patch.yml。
@@ -62,4 +65,15 @@ postprocess 是后处理:跑完后调用配置的 Python 函数(entry + pyth
62
65
  丢弃并报 dropped_stale_pending,不再永久挡住提交;RUN_PENDING 的错误里也会写清年龄和两种出路。
63
66
  - 配了 pathMap 时 pending 记录映射后的目录(原始路径保留为 remote_run_folder),所以
64
67
  labs_wait 传映射后目录或原始目录都能对上,不会再说"run folder does not exist"。
68
+ - **shot 数以面板 `n_shots` 为准**。工具报的 scan axes / n_shots_from_arrays 只是猜测:
69
+ runmanager 会把 list/array 展开成轴,但 **tuple 不展开**,而且 expansion 被设成 `none` 时
70
+ 数组就不再是轴——这两件事从 remote 接口都看不到。两个数字不一致时按 `n_shots` 报,并说明差别。
71
+ - `labs_status` 会显示 `shuffle`。shuffle 打开时 shot 顺序不等于轴顺序,每发参数要从该发 h5 的
72
+ globals 读,不要按序号推。
73
+ - 编译报错只出现在 runmanager 的 output tab:编译器子进程把回溯写到自己的 stderr,runmanager
74
+ 把它转到这个 Qt 界面上(内存里,不落盘),`runmanager.remote` 没有任何读取接口。所以插件只能报
75
+ "n 秒内没有出现 shot 文件",并请操作员贴 output tab,不猜原因。
76
+ - shot 文件在 sequence 开始时就写出来了;`images/`、`results/` 是执行后才写,`n_executed`
77
+ 数的就是这个。BLACS 可能因为连接表不是实验室连接表的子集而拒收、或设备编程超时把 shot 放回队首,
78
+ BLACS 的 Repeat all / Repeat last 会把跑完的 shot 重跑而不新增文件——这些插件都看不到。
65
79
 
@@ -19,7 +19,9 @@
19
19
  # config:
20
20
  # python: /path/to/labscript-suite/python
21
21
  # backend: labscript
22
- # runmanager: { host: 127.0.0.1, port: 42523, timeoutS: 10 }
22
+ # # runmanager 可以不写:host/port/timeout 默认读 labscript 的 labconfig
23
+ # # ([servers]/[ports]/[timeouts],端口默认 42523、超时默认 60 s),填了才覆盖。
24
+ # # runmanager: { host: 127.0.0.1, port: 42523, timeoutS: 10 }
23
25
  # protocolsDir: /path/to/your/protocols
24
26
  # scriptsFile: /path/to/scripts.json
25
27
  # hardLimitsFile: /path/to/hard_limits.json
package/lib/client.js CHANGED
@@ -78,8 +78,8 @@ window.__ModuleLoader__.load({
78
78
  const [version, setVersion] = useState('')
79
79
  const [backend, setBackend] = useState('mock-labscript')
80
80
  const [python, setPython] = useState('')
81
- const [host, setHost] = useState('127.0.0.1')
82
- const [port, setPort] = useState(42523)
81
+ const [host, setHost] = useState('')
82
+ const [port, setPort] = useState('')
83
83
  const [protocolsDir, setProtocolsDir] = useState('')
84
84
  const [busy, setBusy] = useState('')
85
85
  const [message, setMessage] = useState(null)
@@ -94,8 +94,8 @@ window.__ModuleLoader__.load({
94
94
  const current = payload.config || {}
95
95
  setBackend(current.backend || 'mock-labscript')
96
96
  setPython(current.python || '')
97
- setHost((current.runmanager && current.runmanager.host) || '127.0.0.1')
98
- setPort((current.runmanager && current.runmanager.port) || 42523)
97
+ setHost((current.runmanager && current.runmanager.host) || '')
98
+ setPort(current.runmanager && current.runmanager.port ? String(current.runmanager.port) : '')
99
99
  setProtocolsDir(current.protocolsDir || '')
100
100
  } catch (error) {
101
101
  setMessage({ kind: 'err', text: error.message })
@@ -118,7 +118,7 @@ window.__ModuleLoader__.load({
118
118
  body: JSON.stringify({
119
119
  backend,
120
120
  python,
121
- runmanager: { host, port: Number(port) },
121
+ runmanager: { host: host.trim(), port: port === '' ? '' : Number(port) },
122
122
  protocolsDir,
123
123
  }),
124
124
  })
@@ -160,8 +160,8 @@ window.__ModuleLoader__.load({
160
160
  ),
161
161
  ),
162
162
  h(Row, { label: 'python', hint: '真机填 labscript-suite 环境的 python;模拟器只需 numpy+h5py。' }, h('input', { type: 'text', value: python, onChange: (event) => setPython(event.target.value) })),
163
- h(Row, { label: 'runmanager host' }, h('input', { type: 'text', value: host, onChange: (event) => setHost(event.target.value) })),
164
- h(Row, { label: 'runmanager port' }, h('input', { type: 'number', value: port, onChange: (event) => setPort(event.target.value) })),
163
+ h(Row, { label: 'runmanager host', hint: '留空则用 labscript 的 labconfig([servers] runmanager)。' }, h('input', { type: 'text', value: host, placeholder: 'labconfig 里的地址', onChange: (event) => setHost(event.target.value) })),
164
+ h(Row, { label: 'runmanager port', hint: '留空则用 labconfig([ports] runmanager,默认 42523)。' }, h('input', { type: 'number', value: port, placeholder: 'labconfig', onChange: (event) => setPort(event.target.value) })),
165
165
  ),
166
166
  h('section', { className: 'lsc-section' },
167
167
  h('h3', null, '实验'),
package/lib/skill.js CHANGED
@@ -10,8 +10,11 @@ export const SKILL_CONTENT = [
10
10
  'not your memory, is the truth. Never guess a global value or a scan axis.',
11
11
  '',
12
12
  '## Before any write',
13
- '1. Call labs_status. Confirm the loaded labscript, n_shots, error_in_globals, run_shots',
14
- ' and the keys you are about to touch.',
13
+ '1. Call labs_status. Confirm the loaded labscript, n_shots, error_in_globals, run_shots,',
14
+ ' shuffle and the keys you are about to touch. n_shots is the authoritative shot count:',
15
+ ' the scan axes line is only a heuristic (runmanager does not expand tuples, and an',
16
+ ' expansion setting of none makes an array a non-axis), so report a difference instead',
17
+ ' of recomputing the count yourself.',
15
18
  '2. Real hardware: the user must approve this turn before panel writes, engage or abort.',
16
19
  ' The plugin asks for its own one-shot approval on engage/protocol run in the real',
17
20
  ' backend; that approval does not replace the user conversation requirement.',
@@ -21,13 +24,19 @@ export const SKILL_CONTENT = [
21
24
  '## Running',
22
25
  '- labs_set writes the panel. It does not run anything. Always pass note: one sentence',
23
26
  ' saying what this batch is for (it is written to the event log).',
24
- '- labs_engage only reports that runmanager accepted the submission; it cannot see',
25
- ' compile errors. If nothing runs, the next labs_wait says so (see Stop rules).',
27
+ '- One engage submits one sequence: runmanager writes one shot file per point into the',
28
+ ' shot output folder (called run folder in these tools) and hands each file to BLACS.',
26
29
  '- labs_engage submits a run and returns immediately with n_shots_at_submit and',
27
30
  ' expected_n_shots. Do not poll in a loop; when the user wants results, call labs_wait.',
28
31
  ' After submitting you may end the turn or do independent work.',
29
- '- n_h5 counts shot h5 files, which labscript writes before compiling each shot; only',
32
+ '- labs_engage cannot see compilation errors: runmanager shows them only in its output',
33
+ ' tab, which runmanager.remote cannot read. If nothing runs, see Stop rules.',
34
+ '- n_h5 counts shot files, which labscript writes before compiling each shot; only',
30
35
  ' n_executed (or the diagnosis) says whether shots actually acquired data.',
36
+ '- shuffle true means shot order is not axis order: read each shot parameters from its',
37
+ ' own h5 globals, never from the shot index.',
38
+ '- Never name a global like a Python builtin or a name from "from labscript import *":',
39
+ ' runmanager accepts it, labscript fails at compile time, and only the output tab shows it.',
31
40
  '- labs_protocol run submits a fixed protocol; labs_wait finalizes it (postprocess,',
32
41
  ' finish write-back, panel restore).',
33
42
  '- backend defaults to mock-labscript; real hardware is only used when the config says',
@@ -40,22 +49,28 @@ export const SKILL_CONTENT = [
40
49
  ' write touched (pass full=true for the whole panel), so concurrent edits elsewhere are kept.',
41
50
  '',
42
51
  '## Reading results',
43
- '- labs_data reads the run folder HDF5: globals of the first shot and, with scan: a',
44
- ' per-shot table. HDF5 is authoritative; the panel is only intermediate state.',
52
+ '- labs_data reads the shot files HDF5: globals of the first shot and, with scan: a',
53
+ ' per-shot table. HDF5 is authoritative; the panel is only intermediate state. images/',
54
+ ' and results/ are written only after execution.',
45
55
  '- Report facts: run folder, expected vs actual shots, postprocess numbers, warnings.',
46
56
  ' If a number is missing, say so instead of inventing one.',
47
57
  '',
48
58
  '## Stop rules',
49
59
  '- error_in_globals true: fix or ask; never engage.',
50
60
  '- labs_wait returns before the timeout with diagnosis no_shots or compile_stalled when no',
51
- ' shot appeared for config.engage.noShotS seconds (default 25). That usually means runmanager',
52
- ' aborted the compilation: read the runmanager output, then labs_abort, or labs_wait again if',
53
- ' you expect a slow compile. A submission that never produced a shot is dropped by the next',
54
- ' labs_engage once it is config.engage.staleS seconds old (default 180) and reported as',
55
- ' dropped_stale_pending.',
56
- '- diagnosis=compiled_not_executed, or timed_out with h5 files but no acquisition data: the',
57
- ' shots were compiled but BLACS may be paused/not executing. Check BLACS and the panel, then',
58
- ' abort or ask; do not resubmit blindly.',
61
+ ' shot file appeared for config.engage.noShotS seconds (default 25). Compilation errors are',
62
+ ' visible only in runmanager output tab, which this tool cannot read: say exactly that, ask',
63
+ ' the operator to paste the tab, and do not guess the cause or resubmit blindly. Call',
64
+ ' labs_wait again only if you expect a slow compile. A submission that never produced a shot',
65
+ ' is dropped by the next labs_engage once it is config.engage.staleS seconds old (default',
66
+ ' 180) and reported as dropped_stale_pending.',
67
+ '- diagnosis=compiled_not_executed, or timed_out with shot files but no acquisition data:',
68
+ ' BLACS did not run that shot. It may be paused, may have refused the shot because its',
69
+ ' connection table is not a subset of the lab connection table, or a device group may have',
70
+ ' failed to program within 5 minutes (BLACS then pauses the queue and puts the shot back at',
71
+ ' its head). None of this is visible to this tool: look at the BLACS GUI, then abort or ask.',
72
+ '- More shots finished than expected: check the panel n_shots and expansion settings first,',
73
+ ' then BLACS Repeat all / Repeat last, which re-run existing shot files.',
59
74
  '- A run is already pending: wait or abort before submitting another.',
60
75
  ].join(String.fromCharCode(10))
61
76
 
package/lib/store.mjs CHANGED
@@ -34,8 +34,13 @@ export function normalizeUiConfig(input) {
34
34
  throw new Error('backend must be one of ' + BACKENDS.join(', ') + ', got: ' + backend)
35
35
  }
36
36
  const runmanager = source.runmanager && typeof source.runmanager === 'object' ? source.runmanager : {}
37
- const port = Number(runmanager.port || 42523)
38
- if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('runmanager.port must be 1-65535')
37
+ // host/port stay optional: when they are not stored, the bridge lets runmanager.remote
38
+ // resolve them from labscript's labconfig ([servers]/[ports] runmanager), the same way
39
+ // the runmanager GUI does. Storing the old hardcoded defaults here would pin them.
40
+ const host = runmanager.host === undefined || runmanager.host === null ? '' : String(runmanager.host).trim()
41
+ const portText = runmanager.port === undefined || runmanager.port === null ? '' : String(runmanager.port).trim()
42
+ const port = portText === '' ? undefined : Number(portText)
43
+ if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new Error('runmanager.port must be 1-65535')
39
44
  const timeoutS = runmanager.timeoutS === undefined || runmanager.timeoutS === null || runmanager.timeoutS === ''
40
45
  ? undefined
41
46
  : Number(runmanager.timeoutS)
@@ -45,12 +50,11 @@ export function normalizeUiConfig(input) {
45
50
  const out = {
46
51
  backend,
47
52
  python: String(source.python || '').trim(),
48
- runmanager: {
49
- host: String(runmanager.host || '127.0.0.1').trim(),
50
- port,
51
- },
53
+ runmanager: {},
52
54
  protocolsDir: String(source.protocolsDir || '').trim(),
53
55
  }
56
+ if (host !== '') out.runmanager.host = host
57
+ if (port !== undefined) out.runmanager.port = port
54
58
  if (timeoutS !== undefined) out.runmanager.timeoutS = timeoutS
55
59
  return out
56
60
  }
@@ -75,9 +79,13 @@ export function readUiConfig(home) {
75
79
  export function writeUiConfig(input, home) {
76
80
  const previous = readUiConfig(home).config || {}
77
81
  const patch = input && typeof input === 'object' ? input : {}
78
- const config = normalizeUiConfig(Object.assign({}, previous, patch, {
79
- runmanager: Object.assign({}, previous.runmanager || {}, patch.runmanager || {}),
80
- }))
82
+ const runmanager = Object.assign({}, previous.runmanager || {}, patch.runmanager || {})
83
+ // An empty host/port means "unset, ask labconfig": drop the stored key instead of
84
+ // keeping the previous value, so a lab that moves runmanager can clear it.
85
+ for (const key of Object.keys(runmanager)) {
86
+ if (runmanager[key] === '' || runmanager[key] === null || runmanager[key] === undefined) delete runmanager[key]
87
+ }
88
+ const config = normalizeUiConfig(Object.assign({}, previous, patch, { runmanager }))
81
89
  const file = uiConfigPath(home)
82
90
  fs.mkdirSync(path.dirname(file), { recursive: true })
83
91
  const temp = file + '.' + process.pid + '.' + Date.now() + '.tmp'
package/lib/tools.js CHANGED
@@ -67,14 +67,21 @@ function globalLines(args, value) {
67
67
  return out
68
68
  }
69
69
 
70
+ // runmanager decides the shot count from the expansion settings, which runmanager.remote
71
+ // cannot read: a tuple is not an axis and an array can be switched to expansion "none".
72
+ // The panel's n_shots is therefore the only authoritative number; this line is a hint.
70
73
  function scanLine(value) {
71
74
  const axes = value.scan_axes && typeof value.scan_axes === 'object' ? value.scan_axes : {}
72
75
  const names = Object.keys(axes).sort()
73
76
  const product = value.n_shots_from_arrays
74
- const mismatch = typeof product === 'number' && typeof value.n_shots === 'number' && value.n_shots !== product
77
+ const known = typeof value.n_shots === 'number'
78
+ const mismatch = typeof product === 'number' && known && value.n_shots !== product
75
79
  if (names.length === 0 && !mismatch) return ''
76
80
  const detail = names.length ? names.map((key) => key + '=' + axes[key]).join(', ') : 'none'
77
- return 'scan axes: ' + detail + ' n_shots_from_arrays=' + product + (mismatch ? ' (panel n_shots=' + value.n_shots + ' differs)' : '')
81
+ const verdict = mismatch
82
+ ? ' product ' + product + ' != n_shots ' + value.n_shots + ': n_shots is authoritative (a tuple global or an expansion setting of none is not an axis)'
83
+ : ' product ' + product + ' n_shots ' + (known ? value.n_shots : '?') + ' (authoritative)'
84
+ return 'scan axes (heuristic): ' + detail + verdict
78
85
  }
79
86
 
80
87
  function resultsLine(value) {
@@ -248,7 +255,7 @@ export function registerTools(ctx, runtime) {
248
255
 
249
256
  ctx.tools.register({
250
257
  name: 'labs_status',
251
- description: 'Read the labscript runmanager panel: loaded labscript, globals (evaluated; raw expressions with raw=true), n_shots, error_in_globals, run_shots, shot output folder, pending run progress, and the panel snapshot directory. Read-only; call this before any write. Pass keys=[...] (leaf global names; UI group names are not globals) to print the selected panel values (240 chars each; raw=true prints the raw expression), missing keys are shown as <missing>; keys=["*"] prints every global. scan axes lists array-valued globals (their lengths multiply to n_shots_from_arrays) and a differing panel n_shots is flagged. The shot output folder is the folder the NEXT run will use; it does not exist until engage. Paths in the output are already mapped for this machine through pathMap; do not map them again.',
258
+ description: 'Read the labscript runmanager panel: loaded labscript, globals (evaluated; raw expressions with raw=true), n_shots, error_in_globals, run_shots, shuffle, shot output folder (called run folder in this tool: where the NEXT engage writes its shot files; it does not exist until engage), pending run progress, and the panel snapshot directory. Read-only; call this before any write. Pass keys=[...] (leaf global names; UI group names are not globals) to print the selected panel values (240 chars each; raw=true prints the raw expression), missing keys are shown as <missing> (the global is in an inactive group or misspelled); keys=["*"] prints every global. scan axes is a heuristic: it counts array-valued globals and their lengths multiply to n_shots_from_arrays, but runmanager does not expand tuples and an expansion setting of none turns an array into a non-axis, neither of which is visible from the remote API. n_shots is authoritative: when the two differ, report n_shots and say that at least one array-valued global is not an axis. shuffle true means shot order does not follow the axis order. Paths in the output are already mapped for this machine through pathMap; do not map them again.',
252
259
  parameters: {
253
260
  type: 'object',
254
261
  properties: {
@@ -262,7 +269,7 @@ export function registerTools(ctx, runtime) {
262
269
  'backend: ' + value.backend + ' plugin: v' + runtime.version,
263
270
  'labscript: ' + value.labscript,
264
271
  'shot output: ' + value.shot_output_folder,
265
- 'n_shots: ' + value.n_shots + ' error_in_globals: ' + value.error_in_globals + ' run_shots: ' + value.run_shots,
272
+ 'n_shots: ' + value.n_shots + ' error_in_globals: ' + value.error_in_globals + ' run_shots: ' + value.run_shots + ' shuffle: ' + (value.shuffle === undefined || value.shuffle === null ? 'unknown' : value.shuffle),
266
273
  progressLine(value.last_run),
267
274
  'panel history: ' + (value.panel_history ? value.panel_history.dir + ' (' + value.panel_history.count + ' snapshots)' : 'n/a'),
268
275
  scanLine(value),
@@ -282,7 +289,7 @@ export function registerTools(ctx, runtime) {
282
289
 
283
290
  ctx.tools.register({
284
291
  name: 'labs_set',
285
- description: 'Write runmanager panel globals. One call can do the whole step: values are JSON scalars (bool/number/string); with raw=true a string value is written as an expression (e.g. np.arange(0.5, 6.7, 0.2)) while bool/number values are still converted normally (true -> True, 2.72 -> 2.72), so scalars, an expression scan axis and reps=N can be combined. reps=N writes the configured repetition global as np.arange(N). A full panel snapshot is taken before the write. Does not run anything.',
292
+ description: 'Write runmanager panel globals. One call can do the whole step: values are JSON scalars (bool/number/string); with raw=true a string value is written as an expression (e.g. np.arange(0.5, 6.7, 0.2)) while bool/number values are still converted normally (true -> True, 2.72 -> 2.72), so scalars, an expression scan axis and reps=N can be combined. reps=N writes the configured repetition global as np.arange(N). A list global becomes a scan axis only if runmanager actually expands it (a tuple is passed through whole, and an expansion setting of none drops the axis). A global name must not shadow a Python builtin or a name from "from labscript import *": runmanager accepts the name, labscript fails at compile time, and that failure is visible only in runmanager output tab. A full panel snapshot is taken before the write. Does not run anything.',
286
293
  parameters: {
287
294
  type: 'object',
288
295
  properties: {
@@ -312,7 +319,7 @@ export function registerTools(ctx, runtime) {
312
319
 
313
320
  ctx.tools.register({
314
321
  name: 'labs_engage',
315
- description: 'Submit the current panel for a run (compiles and sends to BLACS). Returns immediately with run_folder, n_shots_at_submit and expected_n_shots; use labs_wait later to wait for completion. Refuses when error_in_globals is true or another run is still pending; a pending run that produced no shot at all for config.engage.staleS (default 180 s) is dropped and reported as dropped_stale_pending.',
322
+ description: 'Submit the current panel as one sequence: runmanager writes one shot file per point and hands it to BLACS (this is the "shot output folder", called run_folder in this tool). Returns immediately with run_folder, n_shots_at_submit and expected_n_shots; use labs_wait later to wait for completion. It cannot see compilation errors: runmanager shows those only in its output tab. Refuses when error_in_globals is true or another run is still pending; a pending run that produced no shot at all for config.engage.staleS (default 180 s) is dropped and reported as dropped_stale_pending.',
316
323
  parameters: {
317
324
  type: 'object',
318
325
  properties: {
@@ -356,7 +363,7 @@ export function registerTools(ctx, runtime) {
356
363
 
357
364
  ctx.tools.register({
358
365
  name: 'labs_wait',
359
- description: 'Wait for the pending run to finish (blocking, timeout in seconds). If the pending run came from labs_protocol run, this call also finalizes it: postprocess, finish write-back of panel and calibration history, and panel restore. Returns expected vs actual shots and, for protocols, the postprocess/finish/restore results. Returns early with diagnosis no_shots or compile_stalled when no shot h5 appears for config.engage.noShotS seconds (default 25): the compilation probably aborted, so check runmanager and call labs_wait again only if you expect a slow compile. A run folder that does not exist and is not the pending run of this session fails immediately instead of waiting.',
366
+ description: 'Wait for the pending run (sequence) to finish (blocking, timeout in seconds). If it came from labs_protocol run, this call also finalizes it: postprocess, finish write-back of panel and calibration history, and panel restore. Returns expected vs actual shot files and, for protocols, the postprocess/finish/restore results. Returns early with diagnosis no_shots or compile_stalled when no shot file appeared for config.engage.noShotS seconds (default 25): compilation errors are visible only in runmanager output tab, which this tool cannot read, so report exactly that and ask the operator to paste the tab; call labs_wait again only if you expect a slow compile. diagnosis compiled_not_executed means shot files exist but BLACS acquired no data: BLACS may be paused, may have refused the shot because its connection table is not a subset of the lab connection table, or a device group failed to program within 5 minutes (the queue then pauses and puts the shot back at its head) - all visible only in the BLACS GUI. More shot files than expected means the panel n_shots changed after submit, or BLACS Repeat all / Repeat last re-ran shots. A run folder that does not exist and is not the pending run of this session fails immediately instead of waiting.',
360
367
  parameters: {
361
368
  type: 'object',
362
369
  properties: {
@@ -483,7 +490,7 @@ export function registerTools(ctx, runtime) {
483
490
 
484
491
  ctx.tools.register({
485
492
  name: 'labs_data',
486
- description: 'Read a run folder HDF5: first-shot globals (pass keys=[...] to print selected values, ["*"] for all), the results groups with results: true, and with scan: a per-shot scan axis summary (unique points, min/max, median step) plus the numeric result columns; pass limit to print up to 20 raw rows. Read-only. run_folder defaults to the pending run, else the last run submitted through this plugin (persisted across restarts); for a historical run pass its folder explicitly. A missing folder is an error: runmanager\'s shot output is the NEXT run folder and only exists after engage.',
493
+ description: 'Read a run folder (shot output folder) HDF5: first-shot globals (pass keys=[...] to print selected values, ["*"] for all), the results groups with results: true, and with scan: a per-shot axis summary (unique points, min/max, median step) plus the numeric result columns; pass limit to print up to 20 raw rows. Each shot file holds the evaluated globals of that shot, its hardware commands and, after execution, images/ and results/ - a shot with no images/results was not executed. With shuffle on, read each shot\'s parameters from its own file instead of assuming axis order. Read-only. run_folder defaults to the pending run, else the last run submitted through this plugin (persisted across restarts); for a historical run pass its folder explicitly. A missing folder is an error: the shot output folder is the NEXT sequence\'s folder and only exists after engage.',
487
494
  parameters: {
488
495
  type: 'object',
489
496
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-labscript",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "description": "Control labscript-suite experiments from DeepSeek Harness: runmanager globals and scans, submits and waits, YAML fixed-operation protocols with postprocess write-back, panel snapshots, a built-in mock backend and a read-only doctor.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/python/bridge.py CHANGED
@@ -211,6 +211,28 @@ def eval_panel_value(raw):
211
211
  return str(raw)
212
212
 
213
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
+
214
236
  def raw_repr(value):
215
237
  if isinstance(value, bool):
216
238
  return "True" if value else "False"
@@ -297,18 +319,39 @@ class LabscriptBackend(object):
297
319
  from runmanager.remote import Client
298
320
  except Exception as exc:
299
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.
300
326
  rm = self.config.get("runmanager") or {}
301
- host = rm.get("host") or "127.0.0.1"
302
- port = int(rm.get("port") or 42523)
303
- 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))
304
338
  try:
305
- client = Client(host=host, port=port, timeout=timeout)
306
339
  client.say_hello()
307
340
  except Exception as exc:
308
- 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))
309
342
  self._client = client
310
343
  return client
311
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
+
312
355
  def version(self):
313
356
  try:
314
357
  return str(self.client().get_version())
@@ -337,6 +380,13 @@ class LabscriptBackend(object):
337
380
  except Exception:
338
381
  return None
339
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
+
340
390
  def labscript_file(self):
341
391
  try:
342
392
  return self.client().get_labscript_file()
@@ -415,7 +465,9 @@ class MockBackend(object):
415
465
  panel = self.read_panel()
416
466
  if raw:
417
467
  return dict(panel)
418
- 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())
419
471
 
420
472
  def set_globals(self, values, raw=False):
421
473
  panel = self.read_panel()
@@ -425,17 +477,21 @@ class MockBackend(object):
425
477
  return dict((k, panel[k]) for k in values)
426
478
 
427
479
  def n_shots(self):
428
- import numpy as np
429
480
  total = 1
430
481
  for value in self.get_globals(raw=False).values():
431
- arr = np.asarray(value) if hasattr(value, "__len__") and not isinstance(value, str) else None
432
- if arr is None or arr.ndim == 0:
482
+ length = axis_length(value)
483
+ if length is None or length <= 0:
433
484
  continue
434
- total *= max(1, int(arr.size))
485
+ total *= length
435
486
  return int(total)
436
487
 
437
488
  def error_in_globals(self):
438
- 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"))
439
495
 
440
496
  def run_shots(self):
441
497
  return True
@@ -452,11 +508,8 @@ class MockBackend(object):
452
508
  values = self.get_globals(raw=False)
453
509
  arrays = {}
454
510
  for key, value in values.items():
455
- if isinstance(value, str):
456
- continue
457
- arr = np.asarray(value)
458
- if arr.ndim >= 1 and arr.size > 1:
459
- arrays[key] = arr
511
+ if (axis_length(value) or 0) > 1:
512
+ arrays[key] = np.asarray(value).ravel()
460
513
  total = 1
461
514
  for arr in arrays.values():
462
515
  total *= int(arr.size)
@@ -565,6 +618,10 @@ class HttpSimBackend(object):
565
618
  def run_shots(self):
566
619
  return True
567
620
 
621
+ def shuffle(self):
622
+ snap = self.snapshot()
623
+ return bool(snap.get("shuffle")) if "shuffle" in snap else None
624
+
568
625
  def labscript_file(self):
569
626
  return self.snapshot().get("labscript_file")
570
627
 
@@ -948,13 +1005,8 @@ def cmd_status(config, args):
948
1005
  scan_axes = {}
949
1006
  n_shots_from_arrays = 1
950
1007
  for name, value in panel_values.items():
951
- if isinstance(value, str) or not hasattr(value, "__len__"):
952
- continue
953
- try:
954
- size = int(len(value))
955
- except Exception:
956
- continue
957
- if size <= 1:
1008
+ size = axis_length(value)
1009
+ if size is None or size <= 1:
958
1010
  continue
959
1011
  scan_axes[str(name)] = size
960
1012
  n_shots_from_arrays *= size
@@ -973,6 +1025,7 @@ def cmd_status(config, args):
973
1025
  "n_shots": backend.n_shots(),
974
1026
  "error_in_globals": backend.error_in_globals(),
975
1027
  "run_shots": backend.run_shots(),
1028
+ "shuffle": backend.shuffle() if hasattr(backend, "shuffle") else None,
976
1029
  "panel_history": panel_history_info(config),
977
1030
  }
978
1031
  if raw:
@@ -1123,7 +1176,12 @@ def wait_for_run(config, run_folder, expected, timeout_s):
1123
1176
  if stable_since is None:
1124
1177
  stable_since = time.time()
1125
1178
  elif time.time() - stable_since >= settle:
1126
- return result(True, False, "complete", 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)
1127
1185
  else:
1128
1186
  stable_since = None
1129
1187
  if ready and executed is False:
@@ -1132,8 +1190,11 @@ def wait_for_run(config, run_folder, expected, timeout_s):
1132
1190
  elif time.time() - compiled_since >= stall:
1133
1191
  return result(
1134
1192
  False, False, "compiled_not_executed", time.time() - start,
1135
- "shots were compiled but show no acquisition data after " + str(int(stall)) +
1136
- " 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.")
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.")
1137
1198
  else:
1138
1199
  compiled_since = None
1139
1200
  last_count = count
@@ -1160,7 +1221,9 @@ def wait_for_run(config, run_folder, expected, timeout_s):
1160
1221
  hint = "only " + str(count) + " of " + str(expected) + " shots appeared before the timeout; the run may still be compiling or blocked."
1161
1222
  elif executed is False:
1162
1223
  diagnosis = "compiled_not_executed"
1163
- 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.")
1164
1227
  else:
1165
1228
  diagnosis = "partial"
1166
1229
  hint = "shots appeared but the run was not confirmed complete before the timeout."
@@ -1512,7 +1575,7 @@ def cmd_doctor(config, args):
1512
1575
  probe = LabscriptBackend(config)
1513
1576
  client = probe.client()
1514
1577
  version = client.get_version() if hasattr(client, "get_version") else "unknown"
1515
- _add_check(checks, "runmanager", "ok", str(version))
1578
+ _add_check(checks, "runmanager", "ok", str(version) + " at " + probe.endpoint())
1516
1579
  try:
1517
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()))
1518
1581
  except Exception as exc:
@@ -9,6 +9,7 @@ import shutil
9
9
  import sys
10
10
  import tempfile
11
11
  import time
12
+ import types
12
13
 
13
14
  HERE = os.path.dirname(os.path.abspath(__file__))
14
15
  sys.path.insert(0, os.path.dirname(HERE))
@@ -278,6 +279,120 @@ def test_wait_diagnosis_compiled_not_executed():
278
279
  shutil.rmtree(tmp, ignore_errors=True)
279
280
 
280
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
+
281
396
  def write_shot(path, executed):
282
397
  """Write a shot h5 like labscript does: globals first, acquisition data later."""
283
398
  import h5py