quelware-client 0.7.2__tar.gz → 0.7.3__tar.gz

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.
Files changed (77) hide show
  1. {quelware_client-0.7.2 → quelware_client-0.7.3}/CHANGELOG.md +15 -0
  2. {quelware_client-0.7.2 → quelware_client-0.7.3}/PKG-INFO +1 -1
  3. quelware_client-0.7.3/examples/fixed_timeline.py +173 -0
  4. {quelware_client-0.7.2 → quelware_client-0.7.3}/pyproject.toml +1 -1
  5. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/_session.py +189 -19
  6. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/exceptions/__init__.py +35 -0
  7. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/instrument_driver/__init__.py +35 -4
  8. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/interfaces/instrument_agent/__init__.py +1 -1
  9. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/infra/instrument_agent_grpc/__init__.py +2 -1
  10. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/testing/instrument_agent_mock/__init__.py +1 -1
  11. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/core/instrument_driver/test_instrument_driver.py +8 -0
  12. quelware_client-0.7.3/tests/core/session/test_not_triggered.py +286 -0
  13. quelware_client-0.7.3/tests/core/session/test_unit_busy.py +218 -0
  14. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/core/session/test_wait_for_results.py +18 -6
  15. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/infra/instrument_agent_grpc/test_retry_on_connection_lost.py +1 -1
  16. {quelware_client-0.7.2 → quelware_client-0.7.3}/uv.lock +1 -1
  17. {quelware_client-0.7.2 → quelware_client-0.7.3}/.gitignore +0 -0
  18. {quelware_client-0.7.2 → quelware_client-0.7.3}/LICENSE +0 -0
  19. {quelware_client-0.7.2 → quelware_client-0.7.3}/Makefile +0 -0
  20. {quelware_client-0.7.2 → quelware_client-0.7.3}/README.md +0 -0
  21. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/__init__.py +0 -0
  22. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/client/__init__.py +0 -0
  23. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/client/_grpc.py +0 -0
  24. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/client/_standalone_grpc.py +0 -0
  25. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/client/helpers/__init__.py +0 -0
  26. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/client/helpers/instrument_resolver/__init__.py +0 -0
  27. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/client/helpers/sequencer/__init__.py +0 -0
  28. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/__init__.py +0 -0
  29. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/_agent_container.py +0 -0
  30. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/_client.py +0 -0
  31. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/_utils.py +0 -0
  32. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/interfaces/__init__.py +0 -0
  33. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/interfaces/diagnostics_agent/__init__.py +0 -0
  34. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/interfaces/health_agent/__init__.py +0 -0
  35. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/interfaces/pat_provider/__init__.py +0 -0
  36. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/interfaces/resource_agent/__init__.py +0 -0
  37. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/interfaces/session_agent/__init__.py +0 -0
  38. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/interfaces/system_configuration_agent/__init__.py +0 -0
  39. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/interfaces/trigger_agent/__init__.py +0 -0
  40. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/interfaces/worker_agent/__init__.py +0 -0
  41. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/trigger_count_proposer/__init__.py +0 -0
  42. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/core/unit_control/__init__.py +0 -0
  43. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/infra/__init__.py +0 -0
  44. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/infra/_grpc_retry.py +0 -0
  45. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/infra/_timeouts.py +0 -0
  46. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/infra/diagnostics_agent_grpc/__init__.py +0 -0
  47. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/infra/health_agent_grpc/__init__.py +0 -0
  48. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/infra/pat_provider_file/__init__.py +0 -0
  49. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/infra/resource_agent_grpc/__init__.py +0 -0
  50. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/infra/session_agent_grpc/__init__.py +0 -0
  51. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/infra/system_configuration_agent_grpc/__init__.py +0 -0
  52. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/infra/trigger_agent_grpc/__init__.py +0 -0
  53. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/infra/worker_agent_grpc/__init__.py +0 -0
  54. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/py.typed +0 -0
  55. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/testing/__init__.py +0 -0
  56. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/testing/mock_client.py +0 -0
  57. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/testing/resource_agent_mock/__init__.py +0 -0
  58. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/testing/session_agent_mock/__init__.py +0 -0
  59. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/testing/system_configuration_agent_mock/__init__.py +0 -0
  60. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/testing/trigger_agent_mock/__init__.py +0 -0
  61. {quelware_client-0.7.2 → quelware_client-0.7.3}/src/quelware_client/testing/worker_agent_mock/__init__.py +0 -0
  62. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/__init__.py +0 -0
  63. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/core/__init__.py +0 -0
  64. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/core/client/__init__.py +0 -0
  65. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/core/client/helpers/__init__.py +0 -0
  66. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/core/client/helpers/test_instrument_resolver.py +0 -0
  67. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/core/client/helpers/test_sequencer.py +0 -0
  68. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/core/client/test_client.py +0 -0
  69. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/core/instrument_driver/__init__.py +0 -0
  70. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/core/session/__init__.py +0 -0
  71. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/core/session/test_session.py +0 -0
  72. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/core/session/test_trigger.py +0 -0
  73. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/infra/__init__.py +0 -0
  74. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/infra/health_agent_grpc/__init__.py +0 -0
  75. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/infra/health_agent_grpc/test_health_check.py +0 -0
  76. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/infra/instrument_agent_grpc/__init__.py +0 -0
  77. {quelware_client-0.7.2 → quelware_client-0.7.3}/tests/infra/instrument_agent_grpc/test_wait_for_result.py +0 -0
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.3] - 2026-10-01
6
+
7
+ ### Added
8
+
9
+ - `examples/fixed_timeline.py`, the code of the fixed-timeline tutorial as a script you can run against a system. The docs quote it, and the lint and the type check cover it, so it keeps up with the API.
10
+ - `NotTriggeredError`: `Session.wait_for_results`, `Session.wait_for_result`, `Session.fetch_result` and the same methods of `InstrumentDriver` raise it immediately if an instrument was not in the last trigger on its unit. Before, the request went to the unit, which could hang (armed but not triggered), return an empty result, or fail with `INTERNAL`. Within a session, each unit has at most one run: a new trigger on the unit replaces it, and initializing an instrument on the unit clears it. Other sessions on the unit have their own runs.
11
+ - `UnitBusyError`: `Session.trigger` and `Session.initialize` raise it immediately if a trigger or initialize of the same session is already running on the unit.
12
+ - `InitializeFailedError`: `Session.initialize` raises it, once every unit has finished, when some units failed; `failures` holds the error of each, by unit.
13
+ - `Session.initialize(instrument_ids, parallel=True)`, which initializes the units at the same time, or one after another when not `parallel`, and every unit also after one fails, `Session.fetch_result(instrument_id)` and `Session.wait_for_result(instrument_id, timeout_sec=None)`. The `InstrumentDriver` methods of the same names now call them, so the session knows which instruments can be waited for.
14
+
15
+ ### Changed
16
+
17
+ - `InstrumentDriver` requires the `session` it belongs to. `create_instrument_driver_fixed_timeline` passes it, so only code that builds a driver directly needs a change.
18
+ - `InstrumentAgent.apply` is renamed `arm`, because it arms the instruments (`InstrumentDriver.apply` configures an instrument). It still calls the `Apply` RPC.
19
+
5
20
  ## [0.7.2] - 2026-09-29
6
21
 
7
22
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quelware-client
3
- Version: 0.7.2
3
+ Version: 0.7.3
4
4
  Project-URL: Repository, https://github.com/quel-inc/quelware-client
5
5
  Project-URL: Documentation, https://quel-inc.github.io/quelware-client/
6
6
  Project-URL: Changelog, https://github.com/quel-inc/quelware-client/blob/main/quelware-client/CHANGELOG.md
@@ -0,0 +1,173 @@
1
+ """A drive pulse and a readout on fixed-timeline instruments.
2
+
3
+ The code of the fixed-timeline tutorial, which the docs quote from here. Run it
4
+ against your system:
5
+
6
+ python fixed_timeline.py <host> --unit <label>
7
+ """
8
+
9
+ # --8<-- [start:imports]
10
+ import argparse
11
+ import asyncio
12
+
13
+ import numpy as np
14
+ from quelware_core.entities import directives
15
+ from quelware_core.entities.instrument import (
16
+ FixedTimelineProfile,
17
+ InstrumentDefinition,
18
+ InstrumentInfo,
19
+ InstrumentMode,
20
+ InstrumentRole,
21
+ )
22
+
23
+ from quelware_client import create_quelware_client
24
+ from quelware_client.client.helpers.sequencer import Sequencer
25
+ from quelware_client.core import QuelwareClient, Session
26
+ from quelware_client.core.exceptions import RunFailedError
27
+ from quelware_client.core.instrument_driver import (
28
+ create_instrument_driver_fixed_timeline,
29
+ )
30
+
31
+ # --8<-- [end:imports]
32
+
33
+
34
+ # --8<-- [start:deploy]
35
+ def definition(alias: str, role: InstrumentRole, center_hz: float):
36
+ return InstrumentDefinition(
37
+ alias=alias,
38
+ mode=InstrumentMode.FIXED_TIMELINE,
39
+ role=role,
40
+ profile=FixedTimelineProfile(
41
+ frequency_range_min=center_hz - 2.5e6,
42
+ frequency_range_max=center_hz + 2.5e6,
43
+ ),
44
+ )
45
+
46
+
47
+ async def deploy(
48
+ qc: QuelwareClient, drive_port: str, readout_port: str
49
+ ) -> tuple[InstrumentInfo, InstrumentInfo]:
50
+ async with qc.create_session([drive_port, readout_port]) as session:
51
+ (drive,) = await session.deploy_instruments(
52
+ drive_port, [definition("drive", InstrumentRole.TRANSMITTER, 5.0e9)]
53
+ )
54
+ (readout,) = await session.deploy_instruments(
55
+ readout_port,
56
+ [definition("readout", InstrumentRole.TRANSCEIVER_LOOPBACK, 6.0e9)],
57
+ )
58
+ return drive, readout
59
+
60
+
61
+ # --8<-- [end:deploy]
62
+
63
+
64
+ # --8<-- [start:timeline]
65
+ def build_timeline(
66
+ inst_info: InstrumentInfo, pulse_start_ns: float, capture: bool
67
+ ) -> Sequencer:
68
+ alias = inst_info.definition.alias
69
+ sampling_period_ns = inst_info.config.sampling_period_fs * 1e-6
70
+
71
+ seq = Sequencer(default_sampling_period_ns=sampling_period_ns)
72
+ seq.bind(
73
+ alias,
74
+ sampling_period_fs=inst_info.config.sampling_period_fs,
75
+ step_samples=inst_info.config.timeline_step_samples,
76
+ )
77
+
78
+ n = int(200.0 / sampling_period_ns) # a 200 ns rectangular pulse
79
+ seq.register_waveform("pulse", np.ones(n, dtype=complex))
80
+ seq.add_event(alias, "pulse", start_offset_ns=pulse_start_ns)
81
+ if capture:
82
+ seq.add_capture_window(
83
+ alias, "capture", start_offset_ns=pulse_start_ns, length_ns=1000.0
84
+ )
85
+ seq.extend_length_ns(100_000.0) # gap before the next shot
86
+ seq.set_iterations(1000) # average over 1000 shots
87
+ return seq
88
+
89
+
90
+ # --8<-- [end:timeline]
91
+
92
+
93
+ # --8<-- [start:trigger]
94
+ async def configure(
95
+ session: Session, inst_info: InstrumentInfo, hz: float, seq: Sequencer
96
+ ) -> None:
97
+ driver = create_instrument_driver_fixed_timeline(session, inst_info)
98
+ alias = inst_info.definition.alias
99
+ settings: list[directives.FixedTimelineDirective] = [directives.SetFrequency(hz=hz)]
100
+ if inst_info.definition.role is not InstrumentRole.TRANSMITTER:
101
+ settings.append(
102
+ directives.SetCaptureMode(mode=directives.CaptureMode.AVERAGED_WAVEFORM)
103
+ )
104
+ settings.append(seq.export_set_fixed_timeline_directive(alias))
105
+ await driver.initialize()
106
+ await driver.apply(settings)
107
+
108
+
109
+ async def trigger(
110
+ session: Session, drive: InstrumentInfo, readout: InstrumentInfo
111
+ ) -> None:
112
+ await configure(session, drive, 5.0e9, build_timeline(drive, 0.0, capture=False))
113
+ await configure(
114
+ session, readout, 6.0e9, build_timeline(readout, 300.0, capture=True)
115
+ )
116
+ await session.trigger([drive.id, readout.id])
117
+
118
+
119
+ # --8<-- [end:trigger]
120
+
121
+
122
+ # --8<-- [start:wait]
123
+ async def wait(
124
+ session: Session, drive: InstrumentInfo, readout: InstrumentInfo
125
+ ) -> np.ndarray:
126
+ try:
127
+ results = await session.wait_for_results(
128
+ [drive.id, readout.id], timeout_sec=10.0
129
+ )
130
+ except RunFailedError as e:
131
+ for instrument_id, error in e.failures.items():
132
+ print(f"{instrument_id} failed: {error}")
133
+ raise
134
+ return results[readout.id].iq_waveform_result["capture"][0].iq_array
135
+
136
+
137
+ # --8<-- [end:wait]
138
+
139
+
140
+ # --8<-- [start:main]
141
+ async def main(host: str, port: int, drive_port: str, readout_port: str) -> None:
142
+ qc = create_quelware_client(host, port)
143
+ async with qc:
144
+ drive, readout = await deploy(qc, drive_port, readout_port)
145
+ async with qc.create_session([drive.id, readout.id], ttl_ms=10_000) as session:
146
+ await trigger(session, drive, readout)
147
+ iq = await wait(session, drive, readout)
148
+ print(f"captured {len(iq)} samples")
149
+
150
+
151
+ # --8<-- [end:main]
152
+
153
+
154
+ if __name__ == "__main__":
155
+ parser = argparse.ArgumentParser(
156
+ description="Run a drive pulse and a readout on fixed-timeline instruments."
157
+ )
158
+ parser.add_argument("host", help="server host")
159
+ parser.add_argument("--port", type=int, default=50051, help="server port")
160
+ parser.add_argument("--unit", required=True, help="unit label")
161
+ parser.add_argument("--drive-port", default="tx_p02", help="a transmitter port")
162
+ parser.add_argument(
163
+ "--readout-port", default="trx_p00p01", help="a transceiver port"
164
+ )
165
+ args = parser.parse_args()
166
+ asyncio.run(
167
+ main(
168
+ args.host,
169
+ args.port,
170
+ f"{args.unit}:{args.drive_port}",
171
+ f"{args.unit}:{args.readout_port}",
172
+ )
173
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quelware-client"
3
- version = "0.7.2"
3
+ version = "0.7.3"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,7 +1,8 @@
1
1
  import asyncio
2
2
  import logging
3
3
  import math
4
- from collections.abc import Collection, Mapping
4
+ from collections.abc import Collection, Iterator, Mapping
5
+ from contextlib import contextmanager
5
6
  from types import TracebackType
6
7
  from typing import cast
7
8
 
@@ -15,7 +16,13 @@ from quelware_core.entities.session import SessionToken
15
16
  from quelware_core.entities.unit import UnitLabel
16
17
 
17
18
  from quelware_client.core import AgentContainer
18
- from quelware_client.core.exceptions import RunFailedError, ServiceUnavailableError
19
+ from quelware_client.core.exceptions import (
20
+ InitializeFailedError,
21
+ NotTriggeredError,
22
+ RunFailedError,
23
+ ServiceUnavailableError,
24
+ UnitBusyError,
25
+ )
19
26
  from quelware_client.core.trigger_count_proposer import (
20
27
  FixedOffsetTriggerCountProposer,
21
28
  TriggerCountProposer,
@@ -45,6 +52,13 @@ class Session:
45
52
  await session.trigger(instrument_ids)
46
53
  results = await session.wait_for_results(instrument_ids)
47
54
  ```
55
+
56
+ Within a session, each unit has at most one run. A new trigger on a unit
57
+ replaces its run, and initializing an instrument on the unit clears it.
58
+ Other sessions on the same unit have their own runs. The session remembers
59
+ which instruments were in the last trigger on each unit, and waits only for
60
+ those. Another `Session` object built with the same token does not share
61
+ this record.
48
62
  """
49
63
 
50
64
  def __init__( # noqa: PLR0913
@@ -90,6 +104,10 @@ class Session:
90
104
  self._unit_to_ids.setdefault(ul, []).append(rid)
91
105
 
92
106
  self._check_lock = not skip_lock_check
107
+ # instruments in the last trigger on each unit of this session
108
+ self._triggered: set[ResourceId] = set()
109
+ # units where a trigger or initialize of this session is running now
110
+ self._busy_units: set[UnitLabel] = set()
93
111
 
94
112
  async def open(self):
95
113
  """Open the session, locking its resources and obtaining a token.
@@ -262,33 +280,53 @@ class Session:
262
280
  instrument_ids: Collection[ResourceId],
263
281
  wait_ms: int | None = None,
264
282
  ) -> int:
265
- """Apply pending configuration and trigger the given instruments.
283
+ """Arm the given instruments, then trigger them.
266
284
 
267
- The instruments' configuration is applied first, then a trigger is
268
- scheduled. If the manager-side trigger service is unavailable, the
269
- client falls back to a client-side trigger: a self-timed trigger for a
270
- single unit, or a clock-synchronized trigger across multiple units.
285
+ Arming puts the configuration of each instrument on its unit's device,
286
+ as the run of the session there. Then the server schedules a trigger,
287
+ at which every unit starts. If the server's trigger service is
288
+ unavailable, the client triggers the units itself: a self-timed trigger
289
+ for a single unit, or a clock-synchronized trigger across several.
271
290
 
272
291
  Args:
273
292
  instrument_ids: Instruments to trigger.
274
- wait_ms: Delay, in milliseconds, from now to when the trigger
275
- fires, giving every unit time to start on it. ``None`` leaves
276
- it to the server (600 ms across units, 200 ms for one); the
277
- server raises a shorter one to 150 ms, and the client-side
278
- fallback path to 500 ms.
293
+ wait_ms: Delay, in milliseconds, from now to when the run starts,
294
+ giving every unit time to start on it. ``None`` leaves it to
295
+ the server, which also raises a wait shorter than it allows.
296
+ When the client triggers the units itself, it waits at least
297
+ 500 ms.
279
298
 
280
299
  Returns:
281
300
  The clock count at which the trigger was scheduled.
301
+
302
+ Raises:
303
+ UnitBusyError: Immediately, if a trigger or initialize of this
304
+ session is already running on one of the units.
282
305
  """
283
306
  unit_to_ids = create_unit_to_ids_map(instrument_ids)
307
+ with self._claiming(unit_to_ids):
308
+ # arming replaces the session's run on the unit, so forget the old
309
+ # one now, even if this trigger fails
310
+ self._forget_runs_on(unit_to_ids)
311
+ scheduled = await self._arm_and_trigger(
312
+ instrument_ids, unit_to_ids, wait_ms
313
+ )
314
+ self._triggered.update(instrument_ids)
315
+ return scheduled
284
316
 
285
- logger.info(f"starting application (token= {self.token} )")
286
- apply_coros = [
287
- self._agent.instrument(unit_label).apply(self.token, ids)
317
+ async def _arm_and_trigger(
318
+ self,
319
+ instrument_ids: Collection[ResourceId],
320
+ unit_to_ids: dict[UnitLabel, list[ResourceId]],
321
+ wait_ms: int | None,
322
+ ) -> int:
323
+ logger.info(f"starting arming (token= {self.token} )")
324
+ arm_coros = [
325
+ self._agent.instrument(unit_label).arm(self.token, ids)
288
326
  for unit_label, ids in unit_to_ids.items()
289
327
  ]
290
- await asyncio.gather(*apply_coros)
291
- logger.info(f"finished application (token= {self.token} )")
328
+ await asyncio.gather(*arm_coros)
329
+ logger.info(f"finished arming (token= {self.token} )")
292
330
 
293
331
  try:
294
332
  scheduled = await self._agent.trigger.trigger(
@@ -314,8 +352,10 @@ class Session:
314
352
  ) -> dict[ResourceId, ResultContainer]:
315
353
  """Wait for the result of every one of the instruments of a run.
316
354
 
317
- Pass the instruments given to `trigger()`: an instrument without a
318
- capture window is waited for as well, and its result is empty.
355
+ Pass the instruments given to `trigger()`. An instrument without a
356
+ capture window is waited for too, and its result is empty. Only the
357
+ instruments in the last trigger on each unit can be waited for, and not
358
+ after an instrument on that unit is initialized.
319
359
 
320
360
  Args:
321
361
  instrument_ids: Instruments to wait for.
@@ -325,11 +365,14 @@ class Session:
325
365
  Each instrument's result.
326
366
 
327
367
  Raises:
368
+ NotTriggeredError: Immediately, if an instrument was not in the last
369
+ trigger on its unit.
328
370
  RunFailedError: If any instrument failed, once all of them have
329
371
  finished, with the error of each that failed and the results of
330
372
  the others.
331
373
  TimeoutError: If some are still running when ``timeout_sec`` runs out.
332
374
  """
375
+ self._ensure_triggered(instrument_ids)
333
376
  tasks = {
334
377
  asyncio.ensure_future(
335
378
  self._agent.instrument(extract_unit_label(rid)).wait_for_result(
@@ -361,6 +404,133 @@ class Session:
361
404
  raise RunFailedError(failures, results)
362
405
  return results
363
406
 
407
+ async def initialize(
408
+ self, instrument_ids: Collection[ResourceId], parallel: bool = True
409
+ ) -> None:
410
+ """Initialize the given instruments.
411
+
412
+ An initialized instrument is empty: armed, it plays and captures
413
+ nothing until it is given a timeline again. This also clears the
414
+ session's run on each of their units: no instrument on those units can
415
+ be waited for until the next trigger there.
416
+
417
+ Args:
418
+ instrument_ids: Instruments to initialize.
419
+ parallel: Initialize the units at the same time, the default; when
420
+ False, one unit after another. Either way every unit is
421
+ initialized, also after one fails, and the instruments of one
422
+ unit are always initialized in one call.
423
+
424
+ Raises:
425
+ UnitBusyError: Immediately, if a trigger or initialize of this
426
+ session is already running on one of the units.
427
+ InitializeFailedError: If some units failed, once every unit has
428
+ finished, with the error of each unit that failed.
429
+ """
430
+ unit_to_ids = create_unit_to_ids_map(instrument_ids)
431
+ with self._claiming(unit_to_ids):
432
+ self._forget_runs_on(unit_to_ids)
433
+ if parallel:
434
+ # wait for every unit, so that none is still initializing once
435
+ # the units are let go
436
+ outcomes = await asyncio.gather(
437
+ *(
438
+ self._initialize_unit(unit_label, ids)
439
+ for unit_label, ids in unit_to_ids.items()
440
+ )
441
+ )
442
+ else:
443
+ outcomes = [
444
+ await self._initialize_unit(unit_label, ids)
445
+ for unit_label, ids in unit_to_ids.items()
446
+ ]
447
+ failures = {
448
+ unit_label: outcome
449
+ for unit_label, outcome in zip(unit_to_ids, outcomes, strict=True)
450
+ if outcome is not None
451
+ }
452
+ if failures:
453
+ raise InitializeFailedError(failures, len(unit_to_ids))
454
+
455
+ async def _initialize_unit(
456
+ self, unit_label: UnitLabel, instrument_ids: list[ResourceId]
457
+ ) -> Exception | None:
458
+ """Initialize the instruments of a unit, and return its error, if any."""
459
+ try:
460
+ await self._agent.instrument(unit_label).initialize(
461
+ self.token, instrument_ids
462
+ )
463
+ except Exception as e:
464
+ return e
465
+ return None
466
+
467
+ async def fetch_result(self, instrument_id: ResourceId) -> ResultContainer:
468
+ """Fetch the result of one instrument, with a single request.
469
+
470
+ Unlike `wait_for_result()`, this does not retry when the request times
471
+ out.
472
+
473
+ Raises:
474
+ NotTriggeredError: Immediately, if the instrument was not in the last
475
+ trigger on its unit.
476
+ """
477
+ self._ensure_triggered([instrument_id])
478
+ agent = self._agent.instrument(extract_unit_label(instrument_id))
479
+ return await agent.fetch_result(self.token, instrument_id)
480
+
481
+ async def wait_for_result(
482
+ self, instrument_id: ResourceId, timeout_sec: float | None = None
483
+ ) -> ResultContainer:
484
+ """Wait for the result of one instrument.
485
+
486
+ Unlike `wait_for_results()`, this raises the instrument's own error if
487
+ it failed.
488
+
489
+ Args:
490
+ instrument_id: Instrument to wait for.
491
+ timeout_sec: Maximum time to wait; ``None`` waits without limit.
492
+
493
+ Raises:
494
+ NotTriggeredError: Immediately, if the instrument was not in the last
495
+ trigger on its unit.
496
+ """
497
+ self._ensure_triggered([instrument_id])
498
+ agent = self._agent.instrument(extract_unit_label(instrument_id))
499
+ return await agent.wait_for_result(self.token, instrument_id, timeout_sec)
500
+
501
+ @contextmanager
502
+ def _claiming(self, unit_labels: Collection[UnitLabel]) -> Iterator[None]:
503
+ """Mark the units as busy during a trigger or initialize.
504
+
505
+ Two of them at the same time on one unit would make the record of
506
+ triggered instruments wrong.
507
+ """
508
+ units = set(unit_labels)
509
+ if busy := sorted(units & self._busy_units):
510
+ raise UnitBusyError().with_unit_labels(busy)
511
+ self._busy_units |= units
512
+ try:
513
+ yield
514
+ finally:
515
+ self._busy_units -= units
516
+
517
+ def _forget_runs_on(self, unit_labels: Collection[UnitLabel]) -> None:
518
+ """Forget the triggered instruments on the units."""
519
+ units = set(unit_labels)
520
+ self._triggered = {
521
+ rid for rid in self._triggered if extract_unit_label(rid) not in units
522
+ }
523
+
524
+ def _ensure_triggered(self, instrument_ids: Collection[ResourceId]) -> None:
525
+ """Raise if an instrument was not in the last trigger on its unit.
526
+
527
+ Raises:
528
+ NotTriggeredError: With the ids of those instruments.
529
+ """
530
+ missing = sorted(set(instrument_ids) - self._triggered)
531
+ if missing:
532
+ raise NotTriggeredError().with_resource_ids(missing)
533
+
364
534
  async def _client_side_trigger_fallback(
365
535
  self,
366
536
  unit_to_ids: dict[UnitLabel, list[ResourceId]],
@@ -101,3 +101,38 @@ class RunFailedError(QuelwareClientError):
101
101
  f"{len(self.failures)} of {len(self.failures) + len(self.results)} "
102
102
  f"instruments failed: {detail}"
103
103
  )
104
+
105
+
106
+ class InitializeFailedError(QuelwareClientError):
107
+ """Raised when the initialize of some units failed.
108
+
109
+ ``failures`` holds the error of each unit that failed, by its label. The
110
+ other units were initialized.
111
+ """
112
+
113
+ def __init__(self, failures: Mapping[UnitLabel, BaseException], units: int):
114
+ self.failures = dict(failures)
115
+ detail = "; ".join(f"{u}: {e}" for u, e in sorted(self.failures.items()))
116
+ super().__init__(
117
+ f"initialize failed on {len(self.failures)} of {units} units: {detail}"
118
+ )
119
+
120
+
121
+ class NotTriggeredError(_ErrorWithResourceIdsMixin, QuelwareClientError):
122
+ """Raised when waiting for an instrument that was not triggered.
123
+
124
+ Within a session, each unit has at most one run. A new trigger on the unit
125
+ replaces it, and initializing an instrument on the unit clears it. So only
126
+ the instruments in the last trigger on each unit can be waited for.
127
+ """
128
+
129
+ DEFAULT_MESSAGE = "The instruments were not in the last trigger on their unit."
130
+
131
+
132
+ class UnitBusyError(_ErrorWithUnitLabelsMixin, QuelwareClientError):
133
+ """Raised when a trigger or initialize of the same session is already
134
+ running on the unit."""
135
+
136
+ DEFAULT_MESSAGE = (
137
+ "A trigger or initialize of this session is already running on the unit."
138
+ )
@@ -29,6 +29,12 @@ P = TypeVar("P", bound="ProfileVariant")
29
29
 
30
30
 
31
31
  class InstrumentDriver(Generic[D, C, P]):
32
+ """Configure one deployed instrument, within the session it belongs to.
33
+
34
+ Build one with `create_instrument_driver_fixed_timeline()`. Its settings
35
+ take effect when the session triggers it; see `Session.trigger()`.
36
+ """
37
+
32
38
  def __init__( # noqa: PLR0913
33
39
  self,
34
40
  session_token: SessionToken,
@@ -37,6 +43,7 @@ class InstrumentDriver(Generic[D, C, P]):
37
43
  definition: InstrumentDefinition[P],
38
44
  config: C,
39
45
  instrument_agent: InstrumentAgent,
46
+ session: Session,
40
47
  ):
41
48
  self._token = session_token
42
49
  self._id = instrument_id
@@ -44,6 +51,8 @@ class InstrumentDriver(Generic[D, C, P]):
44
51
  self._definition = definition
45
52
  self._config = config
46
53
  self._agent = instrument_agent
54
+ # the session knows which instruments can be waited for
55
+ self._session = session
47
56
 
48
57
  @overload
49
58
  async def apply(self, directive: D) -> bool: ...
@@ -52,21 +61,30 @@ class InstrumentDriver(Generic[D, C, P]):
52
61
  async def apply(self, directive: Sequence[D]) -> bool: ...
53
62
 
54
63
  async def apply(self, directive) -> bool:
64
+ """Send one directive, or a sequence of them, to the instrument.
65
+
66
+ A directive sets the instrument's frequency, its timeline and the like.
67
+ The unit keeps them, and puts them on the device at the next trigger of
68
+ the instrument. Applying after a run means the run's result can no
69
+ longer be fetched. Returns True once the unit has taken them.
70
+ """
55
71
  if isinstance(directive, Sequence):
56
72
  return await self._agent.configure(self._token, self._id, directive)
57
73
  else:
58
74
  return await self._agent.configure(self._token, self._id, [directive])
59
75
 
60
76
  async def initialize(self):
61
- await self._agent.initialize(self._token, [self._id])
77
+ """Initialize the instrument. See `Session.initialize`."""
78
+ await self._session.initialize([self._id])
62
79
 
63
80
  @property
64
81
  def instrument_config(self) -> C:
82
+ """The hardware timing of the instrument, such as its sampling period."""
65
83
  return self._config
66
84
 
67
85
  async def fetch_result(self) -> ResultContainer:
68
- res = await self._agent.fetch_result(self._token, self._id)
69
- return res
86
+ """Fetch the result of the instrument. See `Session.fetch_result`."""
87
+ return await self._session.fetch_result(self._id)
70
88
 
71
89
  async def wait_for_result(
72
90
  self, timeout_sec: float | None = None
@@ -74,8 +92,9 @@ class InstrumentDriver(Generic[D, C, P]):
74
92
  """Block until the result is ready, retrying transient fetch timeouts.
75
93
 
76
94
  ``timeout_sec`` caps the total client-side wait; ``None`` is unlimited.
95
+ See `Session.wait_for_result`.
77
96
  """
78
- return await self._agent.wait_for_result(self._token, self._id, timeout_sec)
97
+ return await self._session.wait_for_result(self._id, timeout_sec)
79
98
 
80
99
 
81
100
  FixedTimelineInstrumentDriver: TypeAlias = InstrumentDriver[
@@ -88,6 +107,17 @@ FixedTimelineInstrumentDriver: TypeAlias = InstrumentDriver[
88
107
  def create_instrument_driver_fixed_timeline(
89
108
  session: Session, instrument_info: InstrumentInfo
90
109
  ) -> FixedTimelineInstrumentDriver:
110
+ """Create a driver for a fixed-timeline instrument of the session.
111
+
112
+ Args:
113
+ session: An open session that holds the instrument.
114
+ instrument_info: The instrument, as `Session.deploy_instruments()` or
115
+ the instrument resolver returns it.
116
+
117
+ Raises:
118
+ ValueError: If the instrument is not in fixed-timeline mode, or not
119
+ held by the session.
120
+ """
91
121
  if instrument_info.definition.mode is not InstrumentMode.FIXED_TIMELINE:
92
122
  raise ValueError(
93
123
  f"Instrument mode is mismatched: '{instrument_info.definition.mode}' "
@@ -106,6 +136,7 @@ def create_instrument_driver_fixed_timeline(
106
136
  instrument_info.definition,
107
137
  instrument_info.config,
108
138
  session.agent_container.instrument(extract_unit_label(instrument_info.id)),
139
+ session,
109
140
  )
110
141
 
111
142
 
@@ -28,7 +28,7 @@ class InstrumentAgent(Protocol):
28
28
  directives: Sequence[Directive],
29
29
  ) -> bool: ...
30
30
 
31
- async def apply(
31
+ async def arm(
32
32
  self,
33
33
  token: SessionToken,
34
34
  resource_ids: Collection[ResourceId],
@@ -78,11 +78,12 @@ class InstrumentAgentGrpc(InstrumentAgent):
78
78
  return True
79
79
 
80
80
  @override
81
- async def apply(
81
+ async def arm(
82
82
  self,
83
83
  token: SessionToken,
84
84
  resource_ids: Collection[ResourceId],
85
85
  ) -> bool:
86
+ # the Apply RPC arms the instruments; it will be renamed Arm
86
87
  req = pb_inst.ApplyRequest(resource_ids=list(resource_ids))
87
88
  metadata = dict(self._service.metadata or {})
88
89
  metadata["x-session-token"] = str(token)
@@ -33,7 +33,7 @@ class InstrumentAgentMock(InstrumentAgent):
33
33
  ) -> bool:
34
34
  return True
35
35
 
36
- async def apply(
36
+ async def arm(
37
37
  self,
38
38
  token: SessionToken,
39
39
  resource_ids: Collection[ResourceId],