boulder-opal-scale-up-sdk 1.0.0__py3-none-any.whl

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 (50) hide show
  1. boulder_opal_scale_up_sdk-1.0.0.dist-info/METADATA +38 -0
  2. boulder_opal_scale_up_sdk-1.0.0.dist-info/RECORD +50 -0
  3. boulder_opal_scale_up_sdk-1.0.0.dist-info/WHEEL +4 -0
  4. boulderopalscaleupsdk/__init__.py +14 -0
  5. boulderopalscaleupsdk/agent/__init__.py +29 -0
  6. boulderopalscaleupsdk/agent/worker.py +244 -0
  7. boulderopalscaleupsdk/common/__init__.py +12 -0
  8. boulderopalscaleupsdk/common/dtypes.py +353 -0
  9. boulderopalscaleupsdk/common/typeclasses.py +85 -0
  10. boulderopalscaleupsdk/device/__init__.py +16 -0
  11. boulderopalscaleupsdk/device/common.py +58 -0
  12. boulderopalscaleupsdk/device/config_loader.py +88 -0
  13. boulderopalscaleupsdk/device/controller/__init__.py +32 -0
  14. boulderopalscaleupsdk/device/controller/base.py +18 -0
  15. boulderopalscaleupsdk/device/controller/qblox.py +664 -0
  16. boulderopalscaleupsdk/device/controller/quantum_machines.py +139 -0
  17. boulderopalscaleupsdk/device/device.py +35 -0
  18. boulderopalscaleupsdk/device/processor/__init__.py +23 -0
  19. boulderopalscaleupsdk/device/processor/common.py +148 -0
  20. boulderopalscaleupsdk/device/processor/superconducting_processor.py +291 -0
  21. boulderopalscaleupsdk/experiments/__init__.py +44 -0
  22. boulderopalscaleupsdk/experiments/common.py +96 -0
  23. boulderopalscaleupsdk/experiments/power_rabi.py +60 -0
  24. boulderopalscaleupsdk/experiments/ramsey.py +55 -0
  25. boulderopalscaleupsdk/experiments/resonator_spectroscopy.py +64 -0
  26. boulderopalscaleupsdk/experiments/resonator_spectroscopy_by_bias.py +76 -0
  27. boulderopalscaleupsdk/experiments/resonator_spectroscopy_by_power.py +64 -0
  28. boulderopalscaleupsdk/grpc_interceptors/__init__.py +16 -0
  29. boulderopalscaleupsdk/grpc_interceptors/auth.py +101 -0
  30. boulderopalscaleupsdk/plotting/__init__.py +24 -0
  31. boulderopalscaleupsdk/plotting/dtypes.py +221 -0
  32. boulderopalscaleupsdk/protobuf/v1/agent_pb2.py +48 -0
  33. boulderopalscaleupsdk/protobuf/v1/agent_pb2.pyi +53 -0
  34. boulderopalscaleupsdk/protobuf/v1/agent_pb2_grpc.py +138 -0
  35. boulderopalscaleupsdk/protobuf/v1/device_pb2.py +71 -0
  36. boulderopalscaleupsdk/protobuf/v1/device_pb2.pyi +110 -0
  37. boulderopalscaleupsdk/protobuf/v1/device_pb2_grpc.py +274 -0
  38. boulderopalscaleupsdk/protobuf/v1/task_pb2.py +53 -0
  39. boulderopalscaleupsdk/protobuf/v1/task_pb2.pyi +118 -0
  40. boulderopalscaleupsdk/protobuf/v1/task_pb2_grpc.py +119 -0
  41. boulderopalscaleupsdk/py.typed +0 -0
  42. boulderopalscaleupsdk/routines/__init__.py +9 -0
  43. boulderopalscaleupsdk/routines/common.py +10 -0
  44. boulderopalscaleupsdk/routines/resonator_mapping.py +13 -0
  45. boulderopalscaleupsdk/third_party/__init__.py +14 -0
  46. boulderopalscaleupsdk/third_party/quantum_machines/__init__.py +51 -0
  47. boulderopalscaleupsdk/third_party/quantum_machines/config.py +597 -0
  48. boulderopalscaleupsdk/third_party/quantum_machines/constants.py +20 -0
  49. boulderopalscaleupsdk/utils/__init__.py +12 -0
  50. boulderopalscaleupsdk/utils/serial_utils.py +62 -0
@@ -0,0 +1,664 @@
1
+ # Copyright 2025 Q-CTRL. All rights reserved.
2
+ #
3
+ # Licensed under the Q-CTRL Terms of service (the "License"). Unauthorized
4
+ # copying or use of this file, via any medium, is strictly prohibited.
5
+ # Proprietary and confidential. You may not use this file except in compliance
6
+ # with the License. You may obtain a copy of the License at
7
+ #
8
+ # https://q-ctrl.com/terms
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS. See the
12
+ # License for the specific language.
13
+
14
+ """
15
+ QBLOX Quantum Control Stack
16
+ """
17
+
18
+ __all__ = (
19
+ "DEFAULT_MODULE_CONSTRAINTS",
20
+ "AcquisitionConfig",
21
+ "ChannelType",
22
+ "ComplexChannel",
23
+ "IndexedData",
24
+ "ModuleAddr",
25
+ "ModuleAddrType",
26
+ "ModuleConstraints",
27
+ "ModuleType",
28
+ "OutputAcquisition",
29
+ "OutputBinnedAcquisition",
30
+ "OutputBinnedAcquisitionIntegrationData",
31
+ "OutputIndexedAcquisition",
32
+ "OutputScopedAcquisition",
33
+ "OutputScopedAcquisitionData",
34
+ "OutputSequencerAcquisitions",
35
+ "PortAddr",
36
+ "PortAddrType",
37
+ "PreparedProgram",
38
+ "PreparedSequenceProgram",
39
+ "RealChannel",
40
+ "SequenceProgram",
41
+ "SequencerAddr",
42
+ "SequencerAddrType",
43
+ "validate_channel",
44
+ )
45
+
46
+ import dataclasses
47
+ import enum
48
+ import re
49
+ from dataclasses import dataclass
50
+ from typing import Annotated, Any, Literal, TypeVar
51
+
52
+ from pydantic import BaseModel, BeforeValidator, Field, PlainSerializer, model_validator
53
+
54
+ from boulderopalscaleupsdk.common.dtypes import Self
55
+
56
+ # ==================================================================================================
57
+ # Addressing
58
+ # ==================================================================================================
59
+ _RE_SEQUENCER_ADDR = re.compile(r"^(?P<cluster>[^:]+):(?P<slot>\d+):s(?P<num>\d+)$")
60
+ _RE_MODULE_ADDR = re.compile(r"^(?P<cluster>[^:]+):(?P<slot>\d+)$")
61
+ _RE_PORT_ADDR = re.compile(
62
+ r"^(?P<cluster>[^:]+):(?P<slot>\d+):p(?P<dir>(O|I))(?P<num>\d+)$",
63
+ )
64
+
65
+
66
+ class ModuleType(str, enum.Enum):
67
+ """Enumeration of QBLOX modules."""
68
+
69
+ QCM = "QCM"
70
+ QRM = "QRM"
71
+ QCM_RF = "QCM_RF"
72
+ QRM_RF = "QRM_RF"
73
+ QTM = "QTM"
74
+ QDM = "QDM"
75
+ EOM = "EOM"
76
+ LINQ = "LINQ"
77
+ QRC = "QRC"
78
+
79
+
80
+ @dataclass(frozen=True, eq=True)
81
+ class ModuleAddr:
82
+ """Address to a module in a QBLOX control stack."""
83
+
84
+ cluster: str
85
+ slot: int
86
+
87
+ def __str__(self) -> str:
88
+ """Address as a string.
89
+
90
+ This is used for serialization/deserialization and must match the Regex pattern defined in
91
+ this module. See `_RE_MODULE_ADDR`
92
+ """
93
+ return f"{self.cluster}:{self.slot}"
94
+
95
+ @classmethod
96
+ def parse(cls, data: str) -> Self:
97
+ mch = _RE_MODULE_ADDR.match(data)
98
+ if mch is None:
99
+ raise ValueError("could not parse module address")
100
+ return cls(cluster=mch.group("cluster"), slot=int(mch.group("slot")))
101
+
102
+
103
+ @dataclass(frozen=True, eq=True)
104
+ class SequencerAddr:
105
+ """Address to a sequencer (located on a specific module) in a QBLOX control stack."""
106
+
107
+ cluster: str
108
+ slot: int
109
+ number: int
110
+
111
+ @property
112
+ def module(self) -> ModuleAddr:
113
+ return ModuleAddr(self.cluster, self.slot)
114
+
115
+ def __str__(self) -> str:
116
+ """Address as a string.
117
+
118
+ This is used for serialization/deserialization and must match the Regex pattern defined in
119
+ this module. See `_RE_SEQUENCER_ADDR`
120
+ """
121
+ return f"{self.cluster}:{self.slot}:s{self.number}"
122
+
123
+ @classmethod
124
+ def parse(cls, data: str) -> Self:
125
+ mch = _RE_SEQUENCER_ADDR.match(data)
126
+ if mch is None:
127
+ raise ValueError("could not parse sequencer address")
128
+ return cls(
129
+ cluster=mch.group("cluster"),
130
+ slot=int(mch.group("slot")),
131
+ number=int(mch.group("num")),
132
+ )
133
+
134
+
135
+ @dataclass(frozen=True, eq=True)
136
+ class PortAddr:
137
+ """Address to a hardware port (located on a specific module) in a QBLOX control stack."""
138
+
139
+ cluster: str
140
+ slot: int
141
+ direction: Literal["out", "in"]
142
+ number: int
143
+
144
+ @property
145
+ def module(self) -> ModuleAddr:
146
+ return ModuleAddr(self.cluster, self.slot)
147
+
148
+ def __str__(self) -> str:
149
+ """Address as a string.
150
+
151
+ This is used for serialization/deserialization and must match the Regex pattern defined in
152
+ this module. See `_RE_PORT_ADDR`
153
+ """
154
+ direction = "O" if self.direction == "out" else "I"
155
+ return f"{self.cluster}:{self.slot}:p{direction}{self.number}"
156
+
157
+ @classmethod
158
+ def parse(cls, data: str) -> Self:
159
+ mch = _RE_PORT_ADDR.match(data)
160
+ if mch is None:
161
+ raise ValueError("could not parse port address")
162
+ direction: Literal["out", "in"] = "out" if mch.group("dir") == "O" else "in"
163
+ return cls(
164
+ cluster=mch.group("cluster"),
165
+ slot=int(mch.group("slot")),
166
+ direction=direction,
167
+ number=int(mch.group("num")),
168
+ )
169
+
170
+
171
+ T = TypeVar("T", bound=ModuleAddr | SequencerAddr | PortAddr)
172
+
173
+
174
+ def _addr_validator(dtype: type[T]) -> BeforeValidator:
175
+ """Return a Pydantic BeforeValidator to adapt address type with Pydantic."""
176
+
177
+ def _validator(obj: object):
178
+ if isinstance(obj, dtype): # Allow instantiation with Python object
179
+ return obj
180
+ if isinstance(obj, str): # Parse from JSON
181
+ return dtype.parse(obj)
182
+ raise TypeError(f"Invalid type {type(obj).__name__} for {type(dtype).__name__}")
183
+
184
+ return BeforeValidator(_validator)
185
+
186
+
187
+ # Annotated types with Pydantic validator and serializer.
188
+ ModuleAddrType = Annotated[ModuleAddr, _addr_validator(ModuleAddr), PlainSerializer(str)]
189
+ SequencerAddrType = Annotated[SequencerAddr, _addr_validator(SequencerAddr), PlainSerializer(str)]
190
+ PortAddrType = Annotated[PortAddr, _addr_validator(PortAddr), PlainSerializer(str)]
191
+
192
+
193
+ # ==================================================================================================
194
+ # Signalling Channels
195
+ # ==================================================================================================
196
+ class RealChannel(BaseModel):
197
+ """A real channel targeting a single hardware port on a QBLOX module."""
198
+
199
+ mode: Literal["real"] = "real"
200
+ port: PortAddrType
201
+
202
+ @property
203
+ def module(self) -> ModuleAddr:
204
+ return ModuleAddr(self.port.cluster, self.port.slot)
205
+
206
+ @property
207
+ def direction(self) -> Literal["out", "in"]:
208
+ return self.port.direction
209
+
210
+ def __str__(self) -> str:
211
+ return f"{self.port!s}[R]"
212
+
213
+
214
+ class ComplexChannel(BaseModel):
215
+ """A Complex channel targeting a single hardware port on a QBLOX module."""
216
+
217
+ mode: Literal["complex"] = "complex"
218
+ i_port: PortAddrType
219
+ q_port: PortAddrType
220
+
221
+ @property
222
+ def module(self) -> ModuleAddr:
223
+ return ModuleAddr(self.i_port.cluster, self.i_port.slot)
224
+
225
+ @property
226
+ def direction(self) -> Literal["out", "in"]:
227
+ return self.i_port.direction
228
+
229
+ def __str__(self) -> str:
230
+ return f"{self.i_port!s}_{self.q_port.number}[Z]"
231
+
232
+ @model_validator(mode="after")
233
+ def validate_i_q_ports(self) -> "ComplexChannel":
234
+ ii = self.i_port
235
+ qq = self.q_port
236
+ if ii.cluster != qq.cluster or ii.slot != qq.slot:
237
+ raise ValueError("I and Q ports must be on the same cluster+module")
238
+ if ii.direction != qq.direction:
239
+ raise ValueError("I and Q ports must be the same direction")
240
+ return self
241
+
242
+
243
+ ChannelType = RealChannel | ComplexChannel
244
+
245
+
246
+ # ==================================================================================================
247
+ # Controller information
248
+ # ==================================================================================================
249
+ class ElementConnection(BaseModel): # pragma: no cover
250
+ """
251
+ The connections involved for a control element.
252
+
253
+ Attributes
254
+ ----------
255
+ ch_out: ChannelType
256
+ The output channel that will signal towards the control element.
257
+ ch_in: ChannelType, optional
258
+ The input channel from which signals will be acquired from the element. This is optional, as
259
+ not all modules support acquisitions. If an input channel is specified, it must be located
260
+ on the same module as the output channel.
261
+
262
+ Notes
263
+ -----
264
+ The direction of channels is referenced against the QBLOX control stack. I.e. the "out"
265
+ direction is outwards from the control stack. The following diagram depicts a simple setup with
266
+ the arrows indicating a control channel.
267
+
268
+ ┌────────┐ ┌────────────┐
269
+ │ │─── out ──►│Element: xy1│
270
+ │ QBLOX │ └────────────┘
271
+ │ Stack │ ┌────────────┐
272
+ │ │─── out ──►│Element: ro1│
273
+ │ │◄── in ────│ │
274
+ └────────┘ └────────────┘
275
+ """
276
+
277
+ ch_out: ChannelType
278
+ ch_in: ChannelType | None = None
279
+
280
+ @model_validator(mode="after")
281
+ def validate_channels(self) -> "ElementConnection":
282
+ if self.ch_in is not None and self.ch_in.module != self.ch_out.module:
283
+ raise ValueError("I/O channels for an element must be on the same module.")
284
+ return self
285
+
286
+
287
+ class ControllerInfo(BaseModel): # pragma: no cover
288
+ """
289
+ Controller information needed for program compilation and control.
290
+
291
+ Attributes
292
+ ----------
293
+ modules: dict[ModuleAddrType, ModuleType]
294
+ The modules connected to the QBLOX stack.
295
+ elements: dict[str, ElementConnection]
296
+ The addressable control elements for the stack.
297
+ """
298
+
299
+ modules: dict[ModuleAddrType, ModuleType]
300
+ elements: dict[str, ElementConnection]
301
+
302
+
303
+ # ==================================================================================================
304
+ # Instrument management
305
+ # ==================================================================================================
306
+ class SequencerParams(BaseModel):
307
+ nco_freq: float | None = Field(default=None, gt=0)
308
+ gain_awg_path0: float | None = Field(default=None, ge=-1.0, le=1.0)
309
+ offset_awg_path0: float | None = Field(default=None, ge=-1.0, le=1.0)
310
+ gain_awg_path1: float | None = Field(default=None, ge=-1.0, le=1.0)
311
+ offset_awg_path1: float | None = Field(default=None, ge=-1.0, le=1.0)
312
+ marker_ovr_en: bool | None = Field(default=None)
313
+ marker_ovr_value: int | None = Field(default=None)
314
+ mod_en_awg: bool | None = Field(default=None)
315
+ demod_en_acq: bool | None = Field(default=None)
316
+ sync_en: bool | None = Field(default=True)
317
+ nco_prop_delay_comp_en: bool | None = Field(default=True)
318
+ integration_length_acq: int | None = Field(default=None)
319
+
320
+
321
+ class ModuleParams(BaseModel):
322
+ out0_in0_lo_freq: float | None = Field(default=None, gt=0)
323
+ out0_in0_lo_en: bool | None = Field(default=None)
324
+ out0_lo_freq: float | None = Field(default=None, gt=0)
325
+ out0_lo_en: bool | None = Field(default=None)
326
+
327
+
328
+ # ==================================================================================================
329
+ # Programs
330
+ # ==================================================================================================
331
+ class IndexedData(BaseModel):
332
+ """Used for sequence waveforms and weights."""
333
+
334
+ data: list[float]
335
+ index: int
336
+
337
+ def data_equal(self, samples: list[float]) -> bool:
338
+ """Whether the samples provided match the data in this object."""
339
+ if len(samples) != len(self.data):
340
+ return False
341
+ return all(
342
+ sample_1 == sample_2 for sample_1, sample_2 in zip(samples, self.data, strict=False)
343
+ )
344
+
345
+
346
+ class AcquisitionConfig(BaseModel):
347
+ """Acquisition configuration for Q1ASM programs."""
348
+
349
+ num_bins: int
350
+ index: int
351
+
352
+
353
+ class SequenceProgram(BaseModel):
354
+ """A Q1 Sequence Program."""
355
+
356
+ program: str
357
+ waveforms: dict[str, IndexedData] = {}
358
+ weights: dict[str, IndexedData] = {}
359
+ acquisitions: dict[str, AcquisitionConfig] = {}
360
+ acquisition_scopes: list[str] = []
361
+
362
+ def sequence_data(self) -> dict[str, Any]:
363
+ return self.model_dump(include={"program", "waveforms", "weights", "acquisitions"})
364
+
365
+ def dump(self) -> bytes:
366
+ return self.model_dump_json().encode("utf-8")
367
+
368
+
369
+ class PreparedSequenceProgram(BaseModel): # pragma: no cover
370
+ """A sequence program that is mapped to a specific module & sequencer."""
371
+
372
+ sequence_program: SequenceProgram
373
+ sequencer_number: int
374
+ ch_out: ChannelType
375
+ ch_in: ChannelType | None = None
376
+ sequencer_params: SequencerParams = SequencerParams()
377
+
378
+ @property
379
+ def sequencer_addr(self) -> SequencerAddr:
380
+ mod_addr = self.ch_out.module
381
+ return SequencerAddr(
382
+ cluster=mod_addr.cluster,
383
+ slot=mod_addr.slot,
384
+ number=self.sequencer_number,
385
+ )
386
+
387
+
388
+ class PreparedModule(BaseModel):
389
+ params: ModuleParams = ModuleParams()
390
+
391
+
392
+ class PreparedProgram(BaseModel):
393
+ """A program representing a multi-element circuit."""
394
+
395
+ modules: dict[ModuleAddrType, PreparedModule] # The set of modules this program will target.
396
+ sequence_programs: dict[str, PreparedSequenceProgram] # The individual element programs.
397
+
398
+ def dump(self) -> bytes:
399
+ return self.model_dump_json().encode("utf-8")
400
+
401
+
402
+ # ==================================================================================================
403
+ # Results
404
+ # ==================================================================================================
405
+ class OutputScopedAcquisitionData(BaseModel): # pragma: no cover
406
+ """
407
+ Scoped acquisition data for a single path in `OutputScopedAcquisition`.
408
+
409
+ This schema is defined by QBLOX.
410
+ """
411
+
412
+ data: list[float]
413
+ out_of_range: bool = Field(validation_alias="out-of-range")
414
+ avg_cnt: int
415
+
416
+
417
+ class OutputScopedAcquisition(BaseModel): # pragma: no cover
418
+ """
419
+ Scoped acquisition data for a single acquisition index in the SequenceProgram.
420
+
421
+ This schema is defined by QBLOX.
422
+ """
423
+
424
+ path0: OutputScopedAcquisitionData
425
+ path1: OutputScopedAcquisitionData
426
+
427
+
428
+ class OutputBinnedAcquisitionIntegrationData(BaseModel): # pragma: no cover
429
+ """
430
+ Binned values in `OutputBinnedAcquisition`.
431
+
432
+ This schema is defined by QBLOX.
433
+ """
434
+
435
+ path0: list[float]
436
+ path1: list[float]
437
+
438
+
439
+ class OutputBinnedAcquisition(BaseModel): # pragma: no cover
440
+ """
441
+ Binned acquisition data for a single acquisition index in the SequenceProgram.
442
+
443
+ This schema is defined by QBLOX.
444
+ """
445
+
446
+ integration: OutputBinnedAcquisitionIntegrationData
447
+ threshold: list[float]
448
+ avg_cnt: list[int]
449
+
450
+
451
+ class OutputAcquisition(BaseModel): # pragma: no cover
452
+ """
453
+ Acquisition data for a single acquisition index in the SequenceProgram.
454
+
455
+ Note, this type is wrapped by `OutputIndexedAcquisition`.
456
+
457
+ This schema is defined by QBLOX.
458
+ """
459
+
460
+ scope: OutputScopedAcquisition
461
+ bins: OutputBinnedAcquisition
462
+
463
+
464
+ class OutputIndexedAcquisition(BaseModel): # pragma: no cover
465
+ """
466
+ Acquisition data (wrapper) for a single acquisition index in the SequenceProgram.
467
+
468
+ This type simply wraps `OutputAcquisition` with an additional `index` attribute. The index in
469
+ `SequenceProgram.acquisitions[...].index` will correspond to `OutputIndexedAcquisition.index`.
470
+
471
+ Note, this type is used as the values in the `OutputSequencerAcquisitions` dict-type; the keys
472
+ will correspond to the acquisition name.
473
+
474
+ This schema is defined by QBLOX.
475
+ """
476
+
477
+ index: int
478
+ acquisition: OutputAcquisition
479
+
480
+
481
+ # Results returned by a single sequencer.
482
+ # This schema is defined by QBLOX.
483
+ # Example result in JSON (redacted for brevity):
484
+ #
485
+ # // {
486
+ # // 'weighted': {
487
+ # // 'index': 0
488
+ # // 'acquisition': {
489
+ # // 'scope': {
490
+ # // 'path0': {
491
+ # // 'out_of_range': False,
492
+ # // 'avg_cnt': 0,
493
+ # // 'data': [...]
494
+ # // },
495
+ # // 'path1': {
496
+ # // 'out_of_range': False,
497
+ # // 'avg_cnt': 0,
498
+ # // 'data': [...]
499
+ # // }
500
+ # // },
501
+ # // 'bins': {
502
+ # // 'integration': {
503
+ # // 'path0': [10.0],
504
+ # // 'path1': [10.0],
505
+ # // },
506
+ # // 'threshold': [1.0],
507
+ # // 'avg_cnt': [1],
508
+ # // }
509
+ # // }
510
+ # // }
511
+ # // }
512
+ #
513
+ # This results must come from a SequenceProgram that defines
514
+ #
515
+ # // acquisitions = {
516
+ # // {'weighed': {'num_bins': 1, 'index': 0}}
517
+ # // }
518
+ OutputSequencerAcquisitions = dict[str, OutputIndexedAcquisition] # pragma: no cover
519
+
520
+
521
+ # ==================================================================================================
522
+ # Utilities
523
+ # ==================================================================================================
524
+ @dataclasses.dataclass
525
+ class ModuleConstraints:
526
+ """Physical constraints of a module."""
527
+
528
+ n_sequencers: int
529
+ n_markers: int = 0
530
+ n_ch_out: int = 0
531
+ n_ch_in: int = 0
532
+ n_digital_io: int = 0
533
+
534
+ # TODO: Confirm if ordering is important.
535
+ ch_out_iq_pairs: list[set[int]] = dataclasses.field(default_factory=list)
536
+ ch_in_iq_pairs: list[set[int]] = dataclasses.field(default_factory=list)
537
+
538
+
539
+ # Default module constraints by module type.
540
+ DEFAULT_MODULE_CONSTRAINTS: dict[ModuleType, ModuleConstraints] = {
541
+ ModuleType.QCM: ModuleConstraints(
542
+ n_sequencers=6,
543
+ n_markers=4,
544
+ n_ch_out=4,
545
+ n_ch_in=0,
546
+ ch_out_iq_pairs=[{0, 1}, {2, 3}],
547
+ ),
548
+ ModuleType.QCM_RF: ModuleConstraints(
549
+ n_sequencers=6,
550
+ n_markers=2,
551
+ n_ch_out=2,
552
+ n_ch_in=0,
553
+ ),
554
+ ModuleType.QRM: ModuleConstraints(
555
+ n_sequencers=6,
556
+ n_markers=4,
557
+ n_ch_out=2,
558
+ n_ch_in=2,
559
+ ch_out_iq_pairs=[{0, 1}],
560
+ ch_in_iq_pairs=[{2, 3}],
561
+ ),
562
+ ModuleType.QRM_RF: ModuleConstraints(
563
+ n_sequencers=6,
564
+ n_markers=2,
565
+ n_ch_out=1,
566
+ n_ch_in=1,
567
+ ),
568
+ ModuleType.QRC: ModuleConstraints(
569
+ n_sequencers=12,
570
+ n_ch_out=6,
571
+ n_ch_in=2,
572
+ ),
573
+ ModuleType.QTM: ModuleConstraints(
574
+ n_sequencers=8,
575
+ n_digital_io=8,
576
+ ),
577
+ ModuleType.QDM: ModuleConstraints(n_sequencers=0),
578
+ ModuleType.EOM: ModuleConstraints(n_sequencers=0),
579
+ ModuleType.LINQ: ModuleConstraints(n_sequencers=0),
580
+ }
581
+
582
+
583
+ def validate_channel(ch: ChannelType, constraint: ModuleConstraints) -> list[str]:
584
+ """Validates a channel against a module constraint.
585
+
586
+ Parameters
587
+ ----------
588
+ ch: ChannelType
589
+ The channel to validate
590
+ constraint: ModuleConstraints
591
+ The module's physical constraints
592
+
593
+ Returns
594
+ -------
595
+ list[str]
596
+ A list of issue descriptions
597
+
598
+ Notes
599
+ -----
600
+ Possible issues reported:
601
+
602
+ - "module has no <input/output> ports."
603
+ - "<input/output> port number # out-of-bounds for module, must be between [#, #)."
604
+ - "module does not support complex <input/output> channels."
605
+ - "invalid <input/output> IQ pair {#, #}, module only supports pairs [{#, #}, ...]."
606
+ """
607
+ # TODO: Consider simplifying these separate input/output private validators into a single func
608
+ # since they share the same overall logic, and simply require different error messaging
609
+ # and attribute access...
610
+ if ch.direction == "out":
611
+ return _validate_output_channel(ch, constraint)
612
+ return _validate_input_channel(ch, constraint)
613
+
614
+
615
+ def _validate_output_channel(ch_out: ChannelType, constraint: ModuleConstraints) -> list[str]:
616
+ issues = []
617
+ if isinstance(ch_out, RealChannel):
618
+ po_out = ch_out.port
619
+ if constraint.n_ch_out == 0:
620
+ issues.append("module has no output ports.")
621
+ elif po_out.number < 0 or po_out.number >= constraint.n_ch_out:
622
+ issues.append(
623
+ f"output port number {po_out.number} out-of-bounds for module, "
624
+ f"must be between [0, {constraint.n_ch_out}).",
625
+ )
626
+ else:
627
+ valid_pairs = constraint.ch_out_iq_pairs
628
+ if not valid_pairs:
629
+ issues.append("module does not support complex output channels.")
630
+ else:
631
+ po_out_i = ch_out.i_port
632
+ po_out_q = ch_out.q_port
633
+ if {po_out_i.number, po_out_q.number} not in valid_pairs:
634
+ issues.append(
635
+ f"invalid output IQ pair {{{po_out_i.number}, {po_out_q.number}}}, module only "
636
+ f"supports pairs {valid_pairs}.",
637
+ )
638
+ return issues
639
+
640
+
641
+ def _validate_input_channel(ch_in: ChannelType, constraint: ModuleConstraints) -> list[str]:
642
+ issues = []
643
+ if isinstance(ch_in, RealChannel):
644
+ po_in = ch_in.port
645
+ if constraint.n_ch_in == 0:
646
+ issues.append("module has no input ports.")
647
+ elif po_in.number < 0 or po_in.number >= constraint.n_ch_in:
648
+ issues.append(
649
+ f"input port number {po_in.number} out-of-bounds for module, "
650
+ f"must be between [0, {constraint.n_ch_in}).",
651
+ )
652
+ else:
653
+ valid_pairs = constraint.ch_in_iq_pairs
654
+ if not valid_pairs:
655
+ issues.append("module does not support complex input channels.")
656
+ else:
657
+ po_in_i = ch_in.i_port
658
+ po_in_q = ch_in.q_port
659
+ if {po_in_i.number, po_in_q.number} not in valid_pairs:
660
+ issues.append(
661
+ f"invalid input IQ pair {{{po_in_i.number}, {po_in_q.number}}}, module only "
662
+ f"supports pairs {valid_pairs}.",
663
+ )
664
+ return issues