quelware-client 0.7.2__tar.gz → 0.7.4__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 (78) hide show
  1. {quelware_client-0.7.2 → quelware_client-0.7.4}/CHANGELOG.md +22 -0
  2. {quelware_client-0.7.2 → quelware_client-0.7.4}/PKG-INFO +1 -1
  3. quelware_client-0.7.4/examples/fixed_timeline.py +173 -0
  4. {quelware_client-0.7.2 → quelware_client-0.7.4}/pyproject.toml +1 -1
  5. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/_session.py +229 -19
  6. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/exceptions/__init__.py +51 -0
  7. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/instrument_driver/__init__.py +35 -4
  8. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/interfaces/instrument_agent/__init__.py +7 -1
  9. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/infra/instrument_agent_grpc/__init__.py +16 -1
  10. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/testing/instrument_agent_mock/__init__.py +7 -1
  11. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/core/instrument_driver/test_instrument_driver.py +8 -0
  12. quelware_client-0.7.4/tests/core/session/test_abort_runs.py +104 -0
  13. quelware_client-0.7.4/tests/core/session/test_not_triggered.py +286 -0
  14. quelware_client-0.7.4/tests/core/session/test_unit_busy.py +241 -0
  15. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/core/session/test_wait_for_results.py +18 -6
  16. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/infra/instrument_agent_grpc/test_retry_on_connection_lost.py +2 -1
  17. {quelware_client-0.7.2 → quelware_client-0.7.4}/uv.lock +1 -1
  18. {quelware_client-0.7.2 → quelware_client-0.7.4}/.gitignore +0 -0
  19. {quelware_client-0.7.2 → quelware_client-0.7.4}/LICENSE +0 -0
  20. {quelware_client-0.7.2 → quelware_client-0.7.4}/Makefile +0 -0
  21. {quelware_client-0.7.2 → quelware_client-0.7.4}/README.md +0 -0
  22. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/__init__.py +0 -0
  23. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/client/__init__.py +0 -0
  24. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/client/_grpc.py +0 -0
  25. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/client/_standalone_grpc.py +0 -0
  26. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/client/helpers/__init__.py +0 -0
  27. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/client/helpers/instrument_resolver/__init__.py +0 -0
  28. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/client/helpers/sequencer/__init__.py +0 -0
  29. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/__init__.py +0 -0
  30. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/_agent_container.py +0 -0
  31. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/_client.py +0 -0
  32. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/_utils.py +0 -0
  33. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/interfaces/__init__.py +0 -0
  34. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/interfaces/diagnostics_agent/__init__.py +0 -0
  35. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/interfaces/health_agent/__init__.py +0 -0
  36. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/interfaces/pat_provider/__init__.py +0 -0
  37. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/interfaces/resource_agent/__init__.py +0 -0
  38. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/interfaces/session_agent/__init__.py +0 -0
  39. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/interfaces/system_configuration_agent/__init__.py +0 -0
  40. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/interfaces/trigger_agent/__init__.py +0 -0
  41. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/interfaces/worker_agent/__init__.py +0 -0
  42. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/trigger_count_proposer/__init__.py +0 -0
  43. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/core/unit_control/__init__.py +0 -0
  44. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/infra/__init__.py +0 -0
  45. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/infra/_grpc_retry.py +0 -0
  46. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/infra/_timeouts.py +0 -0
  47. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/infra/diagnostics_agent_grpc/__init__.py +0 -0
  48. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/infra/health_agent_grpc/__init__.py +0 -0
  49. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/infra/pat_provider_file/__init__.py +0 -0
  50. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/infra/resource_agent_grpc/__init__.py +0 -0
  51. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/infra/session_agent_grpc/__init__.py +0 -0
  52. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/infra/system_configuration_agent_grpc/__init__.py +0 -0
  53. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/infra/trigger_agent_grpc/__init__.py +0 -0
  54. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/infra/worker_agent_grpc/__init__.py +0 -0
  55. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/py.typed +0 -0
  56. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/testing/__init__.py +0 -0
  57. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/testing/mock_client.py +0 -0
  58. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/testing/resource_agent_mock/__init__.py +0 -0
  59. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/testing/session_agent_mock/__init__.py +0 -0
  60. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/testing/system_configuration_agent_mock/__init__.py +0 -0
  61. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/testing/trigger_agent_mock/__init__.py +0 -0
  62. {quelware_client-0.7.2 → quelware_client-0.7.4}/src/quelware_client/testing/worker_agent_mock/__init__.py +0 -0
  63. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/__init__.py +0 -0
  64. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/core/__init__.py +0 -0
  65. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/core/client/__init__.py +0 -0
  66. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/core/client/helpers/__init__.py +0 -0
  67. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/core/client/helpers/test_instrument_resolver.py +0 -0
  68. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/core/client/helpers/test_sequencer.py +0 -0
  69. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/core/client/test_client.py +0 -0
  70. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/core/instrument_driver/__init__.py +0 -0
  71. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/core/session/__init__.py +0 -0
  72. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/core/session/test_session.py +0 -0
  73. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/core/session/test_trigger.py +0 -0
  74. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/infra/__init__.py +0 -0
  75. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/infra/health_agent_grpc/__init__.py +0 -0
  76. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/infra/health_agent_grpc/test_health_check.py +0 -0
  77. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/infra/instrument_agent_grpc/__init__.py +0 -0
  78. {quelware_client-0.7.2 → quelware_client-0.7.4}/tests/infra/instrument_agent_grpc/test_wait_for_result.py +0 -0
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.4] - 2026-11-02
6
+
7
+ ### Added
8
+
9
+ - `Session.abort_runs()`, which stops the session's runs on every unit, before the trigger time or part way through. A run stops as a whole, because its instruments share the unit's waveform generators and captures, so it takes no instruments. Waiting for an instrument of a run raises the server's abort error. `AbortFailedError` is raised, once every unit has been asked, when some units failed; `failures` holds the error of each, by unit.
10
+ - `UnitBusyError` is raised also by `Session.abort_runs` while a trigger or initialize of the session is running, and by a trigger or initialize while `abort_runs` is.
11
+
12
+ ## [0.7.3] - 2026-10-01
13
+
14
+ ### Added
15
+
16
+ - `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.
17
+ - `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.
18
+ - `UnitBusyError`: `Session.trigger` and `Session.initialize` raise it immediately if a trigger or initialize of the same session is already running on the unit.
19
+ - `InitializeFailedError`: `Session.initialize` raises it, once every unit has finished, when some units failed; `failures` holds the error of each, by unit.
20
+ - `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.
21
+
22
+ ### Changed
23
+
24
+ - `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.
25
+ - `InstrumentAgent.apply` is renamed `arm`, because it arms the instruments (`InstrumentDriver.apply` configures an instrument). It still calls the `Apply` RPC.
26
+
5
27
  ## [0.7.2] - 2026-09-29
6
28
 
7
29
  ### 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.4
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.4"
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,14 @@ 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
+ AbortFailedError,
21
+ InitializeFailedError,
22
+ NotTriggeredError,
23
+ RunFailedError,
24
+ ServiceUnavailableError,
25
+ UnitBusyError,
26
+ )
19
27
  from quelware_client.core.trigger_count_proposer import (
20
28
  FixedOffsetTriggerCountProposer,
21
29
  TriggerCountProposer,
@@ -45,6 +53,13 @@ class Session:
45
53
  await session.trigger(instrument_ids)
46
54
  results = await session.wait_for_results(instrument_ids)
47
55
  ```
56
+
57
+ Within a session, each unit has at most one run. A new trigger on a unit
58
+ replaces its run, and initializing an instrument on the unit clears it.
59
+ Other sessions on the same unit have their own runs. The session remembers
60
+ which instruments were in the last trigger on each unit, and waits only for
61
+ those. Another `Session` object built with the same token does not share
62
+ this record.
48
63
  """
49
64
 
50
65
  def __init__( # noqa: PLR0913
@@ -90,6 +105,10 @@ class Session:
90
105
  self._unit_to_ids.setdefault(ul, []).append(rid)
91
106
 
92
107
  self._check_lock = not skip_lock_check
108
+ # instruments in the last trigger on each unit of this session
109
+ self._triggered: set[ResourceId] = set()
110
+ # units where a trigger, initialize or abort_runs of this session is running now
111
+ self._busy_units: set[UnitLabel] = set()
93
112
 
94
113
  async def open(self):
95
114
  """Open the session, locking its resources and obtaining a token.
@@ -262,33 +281,53 @@ class Session:
262
281
  instrument_ids: Collection[ResourceId],
263
282
  wait_ms: int | None = None,
264
283
  ) -> int:
265
- """Apply pending configuration and trigger the given instruments.
284
+ """Arm the given instruments, then trigger them.
266
285
 
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.
286
+ Arming puts the configuration of each instrument on its unit's device,
287
+ as the run of the session there. Then the server schedules a trigger,
288
+ at which every unit starts. If the server's trigger service is
289
+ unavailable, the client triggers the units itself: a self-timed trigger
290
+ for a single unit, or a clock-synchronized trigger across several.
271
291
 
272
292
  Args:
273
293
  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.
294
+ wait_ms: Delay, in milliseconds, from now to when the run starts,
295
+ giving every unit time to start on it. ``None`` leaves it to
296
+ the server, which also raises a wait shorter than it allows.
297
+ When the client triggers the units itself, it waits at least
298
+ 500 ms.
279
299
 
280
300
  Returns:
281
301
  The clock count at which the trigger was scheduled.
302
+
303
+ Raises:
304
+ UnitBusyError: Immediately, if a trigger, initialize or abort_runs of this
305
+ session is already running on one of the units.
282
306
  """
283
307
  unit_to_ids = create_unit_to_ids_map(instrument_ids)
308
+ with self._claiming(unit_to_ids):
309
+ # arming replaces the session's run on the unit, so forget the old
310
+ # one now, even if this trigger fails
311
+ self._forget_runs_on(unit_to_ids)
312
+ scheduled = await self._arm_and_trigger(
313
+ instrument_ids, unit_to_ids, wait_ms
314
+ )
315
+ self._triggered.update(instrument_ids)
316
+ return scheduled
284
317
 
285
- logger.info(f"starting application (token= {self.token} )")
286
- apply_coros = [
287
- self._agent.instrument(unit_label).apply(self.token, ids)
318
+ async def _arm_and_trigger(
319
+ self,
320
+ instrument_ids: Collection[ResourceId],
321
+ unit_to_ids: dict[UnitLabel, list[ResourceId]],
322
+ wait_ms: int | None,
323
+ ) -> int:
324
+ logger.info(f"starting arming (token= {self.token} )")
325
+ arm_coros = [
326
+ self._agent.instrument(unit_label).arm(self.token, ids)
288
327
  for unit_label, ids in unit_to_ids.items()
289
328
  ]
290
- await asyncio.gather(*apply_coros)
291
- logger.info(f"finished application (token= {self.token} )")
329
+ await asyncio.gather(*arm_coros)
330
+ logger.info(f"finished arming (token= {self.token} )")
292
331
 
293
332
  try:
294
333
  scheduled = await self._agent.trigger.trigger(
@@ -314,8 +353,10 @@ class Session:
314
353
  ) -> dict[ResourceId, ResultContainer]:
315
354
  """Wait for the result of every one of the instruments of a run.
316
355
 
317
- Pass the instruments given to `trigger()`: an instrument without a
318
- capture window is waited for as well, and its result is empty.
356
+ Pass the instruments given to `trigger()`. An instrument without a
357
+ capture window is waited for too, and its result is empty. Only the
358
+ instruments in the last trigger on each unit can be waited for, and not
359
+ after an instrument on that unit is initialized.
319
360
 
320
361
  Args:
321
362
  instrument_ids: Instruments to wait for.
@@ -325,11 +366,14 @@ class Session:
325
366
  Each instrument's result.
326
367
 
327
368
  Raises:
369
+ NotTriggeredError: Immediately, if an instrument was not in the last
370
+ trigger on its unit.
328
371
  RunFailedError: If any instrument failed, once all of them have
329
372
  finished, with the error of each that failed and the results of
330
373
  the others.
331
374
  TimeoutError: If some are still running when ``timeout_sec`` runs out.
332
375
  """
376
+ self._ensure_triggered(instrument_ids)
333
377
  tasks = {
334
378
  asyncio.ensure_future(
335
379
  self._agent.instrument(extract_unit_label(rid)).wait_for_result(
@@ -361,6 +405,172 @@ class Session:
361
405
  raise RunFailedError(failures, results)
362
406
  return results
363
407
 
408
+ async def initialize(
409
+ self, instrument_ids: Collection[ResourceId], parallel: bool = True
410
+ ) -> None:
411
+ """Initialize the given instruments.
412
+
413
+ An initialized instrument is empty: armed, it plays and captures
414
+ nothing until it is given a timeline again. This also clears the
415
+ session's run on each of their units: no instrument on those units can
416
+ be waited for until the next trigger there.
417
+
418
+ Args:
419
+ instrument_ids: Instruments to initialize.
420
+ parallel: Initialize the units at the same time, the default; when
421
+ False, one unit after another. Either way every unit is
422
+ initialized, also after one fails, and the instruments of one
423
+ unit are always initialized in one call.
424
+
425
+ Raises:
426
+ UnitBusyError: Immediately, if a trigger, initialize or abort_runs of this
427
+ session is already running on one of the units.
428
+ InitializeFailedError: If some units failed, once every unit has
429
+ finished, with the error of each unit that failed.
430
+ """
431
+ unit_to_ids = create_unit_to_ids_map(instrument_ids)
432
+ with self._claiming(unit_to_ids):
433
+ self._forget_runs_on(unit_to_ids)
434
+ if parallel:
435
+ # wait for every unit, so that none is still initializing once
436
+ # the units are let go
437
+ outcomes = await asyncio.gather(
438
+ *(
439
+ self._initialize_unit(unit_label, ids)
440
+ for unit_label, ids in unit_to_ids.items()
441
+ )
442
+ )
443
+ else:
444
+ outcomes = [
445
+ await self._initialize_unit(unit_label, ids)
446
+ for unit_label, ids in unit_to_ids.items()
447
+ ]
448
+ failures = {
449
+ unit_label: outcome
450
+ for unit_label, outcome in zip(unit_to_ids, outcomes, strict=True)
451
+ if outcome is not None
452
+ }
453
+ if failures:
454
+ raise InitializeFailedError(failures, len(unit_to_ids))
455
+
456
+ async def _initialize_unit(
457
+ self, unit_label: UnitLabel, instrument_ids: list[ResourceId]
458
+ ) -> Exception | None:
459
+ """Initialize the instruments of a unit, and return its error, if any."""
460
+ try:
461
+ await self._agent.instrument(unit_label).initialize(
462
+ self.token, instrument_ids
463
+ )
464
+ except Exception as e:
465
+ return e
466
+ return None
467
+
468
+ async def abort_runs(self) -> None:
469
+ """Stop the session's runs, on every unit.
470
+
471
+ The run on a unit is the session's last trigger there, and it stops as
472
+ a whole: its instruments share the unit's waveform generators and
473
+ captures, so one of them cannot stop without the others. Every
474
+ instrument of the runs is aborted, and waiting for one raises the
475
+ server's abort error. A unit stops its run before the trigger time
476
+ comes, or part way through, and leaves a run that has already finished
477
+ as it is. The instruments keep their configuration, so the same runs
478
+ can be triggered again.
479
+
480
+ Raises:
481
+ UnitBusyError: Immediately, if a trigger, initialize or abort_runs of this
482
+ session is running on any unit, since the run it makes is not
483
+ known yet.
484
+ AbortFailedError: If some units failed, once every unit has been
485
+ asked, with the error of each unit that failed.
486
+ """
487
+ runs = {
488
+ unit_label: sorted(ids)
489
+ for unit_label, ids in create_unit_to_ids_map(self._triggered).items()
490
+ }
491
+ with self._claiming(set(runs) | self._busy_units):
492
+ outcomes = await asyncio.gather(
493
+ *(
494
+ self._agent.instrument(unit_label).abort(self.token, ids)
495
+ for unit_label, ids in runs.items()
496
+ ),
497
+ return_exceptions=True,
498
+ )
499
+ failures = {
500
+ unit_label: outcome
501
+ for unit_label, outcome in zip(runs, outcomes, strict=True)
502
+ if isinstance(outcome, BaseException)
503
+ }
504
+ if failures:
505
+ raise AbortFailedError(failures, len(runs))
506
+
507
+ async def fetch_result(self, instrument_id: ResourceId) -> ResultContainer:
508
+ """Fetch the result of one instrument, with a single request.
509
+
510
+ Unlike `wait_for_result()`, this does not retry when the request times
511
+ out.
512
+
513
+ Raises:
514
+ NotTriggeredError: Immediately, if the instrument was not in the last
515
+ trigger on its unit.
516
+ """
517
+ self._ensure_triggered([instrument_id])
518
+ agent = self._agent.instrument(extract_unit_label(instrument_id))
519
+ return await agent.fetch_result(self.token, instrument_id)
520
+
521
+ async def wait_for_result(
522
+ self, instrument_id: ResourceId, timeout_sec: float | None = None
523
+ ) -> ResultContainer:
524
+ """Wait for the result of one instrument.
525
+
526
+ Unlike `wait_for_results()`, this raises the instrument's own error if
527
+ it failed.
528
+
529
+ Args:
530
+ instrument_id: Instrument to wait for.
531
+ timeout_sec: Maximum time to wait; ``None`` waits without limit.
532
+
533
+ Raises:
534
+ NotTriggeredError: Immediately, if the instrument was not in the last
535
+ trigger on its unit.
536
+ """
537
+ self._ensure_triggered([instrument_id])
538
+ agent = self._agent.instrument(extract_unit_label(instrument_id))
539
+ return await agent.wait_for_result(self.token, instrument_id, timeout_sec)
540
+
541
+ @contextmanager
542
+ def _claiming(self, unit_labels: Collection[UnitLabel]) -> Iterator[None]:
543
+ """Mark the units as busy during a trigger, initialize or abort_runs.
544
+
545
+ Two of them at the same time on one unit would make the record of
546
+ triggered instruments wrong.
547
+ """
548
+ units = set(unit_labels)
549
+ if busy := sorted(units & self._busy_units):
550
+ raise UnitBusyError().with_unit_labels(busy)
551
+ self._busy_units |= units
552
+ try:
553
+ yield
554
+ finally:
555
+ self._busy_units -= units
556
+
557
+ def _forget_runs_on(self, unit_labels: Collection[UnitLabel]) -> None:
558
+ """Forget the triggered instruments on the units."""
559
+ units = set(unit_labels)
560
+ self._triggered = {
561
+ rid for rid in self._triggered if extract_unit_label(rid) not in units
562
+ }
563
+
564
+ def _ensure_triggered(self, instrument_ids: Collection[ResourceId]) -> None:
565
+ """Raise if an instrument was not in the last trigger on its unit.
566
+
567
+ Raises:
568
+ NotTriggeredError: With the ids of those instruments.
569
+ """
570
+ missing = sorted(set(instrument_ids) - self._triggered)
571
+ if missing:
572
+ raise NotTriggeredError().with_resource_ids(missing)
573
+
364
574
  async def _client_side_trigger_fallback(
365
575
  self,
366
576
  unit_to_ids: dict[UnitLabel, list[ResourceId]],
@@ -101,3 +101,54 @@ 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 AbortFailedError(QuelwareClientError):
122
+ """Raised when abort_runs failed on some units.
123
+
124
+ ``failures`` holds the error of each unit that failed, by its label. The
125
+ other units were aborted.
126
+ """
127
+
128
+ def __init__(self, failures: Mapping[UnitLabel, BaseException], units: int):
129
+ self.failures = dict(failures)
130
+ detail = "; ".join(f"{u}: {e}" for u, e in sorted(self.failures.items()))
131
+ super().__init__(
132
+ f"abort_runs failed on {len(self.failures)} of {units} units: {detail}"
133
+ )
134
+
135
+
136
+ class NotTriggeredError(_ErrorWithResourceIdsMixin, QuelwareClientError):
137
+ """Raised when waiting for an instrument that was not triggered.
138
+
139
+ Within a session, each unit has at most one run. A new trigger on the unit
140
+ replaces it, and initializing an instrument on the unit clears it. So only
141
+ the instruments in the last trigger on each unit can be waited for.
142
+ """
143
+
144
+ DEFAULT_MESSAGE = "The instruments were not in the last trigger on their unit."
145
+
146
+
147
+ class UnitBusyError(_ErrorWithUnitLabelsMixin, QuelwareClientError):
148
+ """Raised when a trigger, initialize or abort_runs of the same session is already
149
+ running on the unit."""
150
+
151
+ DEFAULT_MESSAGE = (
152
+ "A trigger, initialize or abort_runs of this session is already running "
153
+ "on the unit."
154
+ )