iqm-station-control-client 11.3.1__py3-none-any.whl → 12.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 (44) hide show
  1. iqm/station_control/client/authentication.py +239 -0
  2. iqm/station_control/client/iqm_server/error.py +0 -30
  3. iqm/station_control/client/iqm_server/grpc_utils.py +0 -156
  4. iqm/station_control/client/iqm_server/iqm_server_client.py +0 -489
  5. iqm/station_control/client/list_models.py +16 -11
  6. iqm/station_control/client/qon.py +1 -1
  7. iqm/station_control/client/serializers/run_serializers.py +5 -4
  8. iqm/station_control/client/serializers/struct_serializer.py +1 -1
  9. iqm/station_control/client/station_control.py +140 -154
  10. iqm/station_control/client/utils.py +4 -42
  11. iqm/station_control/interface/models/__init__.py +21 -2
  12. iqm/station_control/interface/models/circuit.py +348 -0
  13. iqm/station_control/interface/models/dynamic_quantum_architecture.py +61 -3
  14. iqm/station_control/interface/models/jobs.py +41 -12
  15. iqm/station_control/interface/models/observation_set.py +28 -4
  16. iqm/station_control/interface/models/run.py +8 -8
  17. iqm/station_control/interface/models/sweep.py +7 -1
  18. iqm/station_control/interface/models/type_aliases.py +1 -2
  19. iqm/station_control/interface/station_control.py +1 -1
  20. {iqm_station_control_client-11.3.1.dist-info → iqm_station_control_client-12.0.0.dist-info}/METADATA +3 -3
  21. iqm_station_control_client-12.0.0.dist-info/RECORD +42 -0
  22. iqm/station_control/client/iqm_server/__init__.py +0 -14
  23. iqm/station_control/client/iqm_server/proto/__init__.py +0 -43
  24. iqm/station_control/client/iqm_server/proto/calibration_pb2.py +0 -48
  25. iqm/station_control/client/iqm_server/proto/calibration_pb2.pyi +0 -45
  26. iqm/station_control/client/iqm_server/proto/calibration_pb2_grpc.py +0 -152
  27. iqm/station_control/client/iqm_server/proto/common_pb2.py +0 -43
  28. iqm/station_control/client/iqm_server/proto/common_pb2.pyi +0 -32
  29. iqm/station_control/client/iqm_server/proto/common_pb2_grpc.py +0 -17
  30. iqm/station_control/client/iqm_server/proto/job_pb2.py +0 -57
  31. iqm/station_control/client/iqm_server/proto/job_pb2.pyi +0 -107
  32. iqm/station_control/client/iqm_server/proto/job_pb2_grpc.py +0 -436
  33. iqm/station_control/client/iqm_server/proto/qc_pb2.py +0 -51
  34. iqm/station_control/client/iqm_server/proto/qc_pb2.pyi +0 -57
  35. iqm/station_control/client/iqm_server/proto/qc_pb2_grpc.py +0 -163
  36. iqm/station_control/client/iqm_server/proto/uuid_pb2.py +0 -39
  37. iqm/station_control/client/iqm_server/proto/uuid_pb2.pyi +0 -26
  38. iqm/station_control/client/iqm_server/proto/uuid_pb2_grpc.py +0 -17
  39. iqm/station_control/client/iqm_server/testing/__init__.py +0 -13
  40. iqm/station_control/client/iqm_server/testing/iqm_server_mock.py +0 -102
  41. iqm_station_control_client-11.3.1.dist-info/RECORD +0 -59
  42. {iqm_station_control_client-11.3.1.dist-info → iqm_station_control_client-12.0.0.dist-info}/LICENSE.txt +0 -0
  43. {iqm_station_control_client-11.3.1.dist-info → iqm_station_control_client-12.0.0.dist-info}/WHEEL +0 -0
  44. {iqm_station_control_client-11.3.1.dist-info → iqm_station_control_client-12.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,348 @@
1
+ # Copyright 2025 IQM
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Models related to quantum circuit execution."""
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from enum import StrEnum
20
+ from typing import TYPE_CHECKING, Annotated, Any, TypeAlias
21
+ from uuid import UUID
22
+
23
+ from pydantic import AliasChoices, BeforeValidator, Field, PlainSerializer, WithJsonSchema, computed_field
24
+
25
+ from exa.common.helpers.deprecation import format_deprecated
26
+ from iqm.pulse import Circuit
27
+ from iqm.station_control.interface.pydantic_base import PydanticBase
28
+
29
+ if TYPE_CHECKING:
30
+ from iqm.pulse import Circuit, CircuitOperation
31
+
32
+
33
+ PRXSequence: TypeAlias = list[tuple[float, float]]
34
+ """Sequence of PRX gates. A generic PRX gate is defined by rotation angle and phase angle, theta and phi,
35
+ respectively."""
36
+
37
+ QIRCode: TypeAlias = str
38
+ """QIR program code in string representation."""
39
+
40
+
41
+ # TODO: remove when CUDA-Q supports the new circuit format
42
+ @dataclass
43
+ class _Instruction:
44
+ """An instruction in a quantum circuit. Old format."""
45
+
46
+ name: str
47
+ implementation: str | None = None
48
+ qubits: tuple[str, ...] = field(default_factory=tuple)
49
+ args: dict[str, Any] = field(default_factory=dict)
50
+
51
+ def to_cpc_type(self) -> CircuitOperation:
52
+ """Convert the model to a dataclass."""
53
+ return CircuitOperation(
54
+ name=self.name,
55
+ implementation=self.implementation,
56
+ locus=self.qubits,
57
+ args=self.args,
58
+ )
59
+
60
+
61
+ # TODO: remove when CUDA-Q supports the new circuit format
62
+ @dataclass
63
+ class _Circuit:
64
+ """Quantum circuit to be executed. Old format."""
65
+
66
+ name: str
67
+ instructions: tuple[_Instruction, ...] = field(default_factory=tuple)
68
+ metadata: dict[str, Any] | None = None
69
+
70
+ def to_cpc_type(self) -> Circuit:
71
+ """Convert the model to a dataclass."""
72
+ return Circuit(
73
+ name=self.name,
74
+ instructions=tuple(instruction.to_cpc_type() for instruction in self.instructions),
75
+ )
76
+
77
+
78
+ CircuitBatch: TypeAlias = list[Circuit | _Circuit | QIRCode]
79
+ """Sequence of quantum circuits to be executed together in a single batch."""
80
+
81
+
82
+ CircuitMeasurementResults: TypeAlias = dict[str, list[list[int]]]
83
+ """Measurement results from a single circuit.
84
+
85
+ For each measurement operation in the circuit, maps the measurement key to the corresponding results.
86
+ ``results[key][shot][qubit_index]`` is the result of measuring the
87
+ ``qubit_index``'th qubit in measurement operation ``key`` in the shot ``shot``.
88
+ The results are non-negative integers representing the computational basis state (for qubits, 0 or 1)
89
+ that was the measurement outcome.
90
+ """
91
+
92
+ CircuitMeasurementResultsBatch: TypeAlias = list[CircuitMeasurementResults]
93
+ """Type that represents measurement results for a batch of circuits."""
94
+
95
+
96
+ class HeraldingMode(StrEnum):
97
+ """Heralding mode for circuit execution.
98
+
99
+ Heralding is the practice of generating data about the state of qubits prior to execution of a circuit.
100
+ This can be achieved by measuring the qubits immediately before executing each shot for a circuit.
101
+ """
102
+
103
+ NONE = "none"
104
+ """Do not do any heralding."""
105
+ ZEROS = "zeros"
106
+ """For each circuit, perform a heralding measurement after the initial reset on all the QPU components
107
+ used in the circuit that have the "measure" operation available in the calset.
108
+ Only retain shots where all the components are measured to be in the zero state.
109
+
110
+ Note: in this mode, the number of shots returned after execution will be <= the requested amount
111
+ due to the post-selection based on heralding data.
112
+ If zero shots would be returned, the job will have the FAILED status."""
113
+
114
+
115
+ class MoveGateValidationMode(StrEnum):
116
+ """MOVE gate validation mode for circuit compilation. This options is meant for advanced users."""
117
+
118
+ STRICT = "strict"
119
+ """Perform standard MOVE gate validation: MOVE(qubit, resonator) gates must only
120
+ appear in sandwiches (pairs). Inside a sandwich there must be no gates acting on the
121
+ MOVE qubit, and no other MOVE gates acting on the resonator."""
122
+ ALLOW_PRX = "allow_prx"
123
+ """Allow PRX gates on the MOVE qubit inside MOVE sandwiches during validation."""
124
+ NONE = "none"
125
+ """Do not perform any MOVE gate validation."""
126
+
127
+
128
+ class MoveGateFrameTrackingMode(StrEnum):
129
+ """MOVE gate reference frame tracking mode for circuit compilation. This option is meant for advanced users."""
130
+
131
+ FULL = "full"
132
+ """Apply both explicit z rotations on the resonator, and a dynamic phase correction
133
+ due to qubit-resonator detuning, to the qubit at the end of a MOVE sandwich."""
134
+ NO_DETUNING_CORRECTION = "no_detuning_correction"
135
+ """Only apply explicit z rotations on the resonator to the qubit at the end of the sandwich.
136
+ Do not apply a detuning correction, the user is expected to do this manually."""
137
+ NONE = "none"
138
+ """Do not perform any MOVE gate frame tracking. The user is expected to do this manually."""
139
+
140
+
141
+ class DDMode(StrEnum):
142
+ """Dynamical Decoupling (DD) mode for circuit execution."""
143
+
144
+ DISABLED = "disabled"
145
+ """Do not apply dynamical decoupling."""
146
+ ENABLED = "enabled"
147
+ """Apply dynamical decoupling."""
148
+
149
+
150
+ class DDStrategy(PydanticBase):
151
+ """Describes a particular dynamical decoupling strategy.
152
+
153
+ The current standard DD stategy can be found in :attr:`~iqm.cpc.compiler.dd.STANDARD_DD_STRATEGY`,
154
+ but users can use this class to provide their own dynamical decoupling strategies.
155
+
156
+ See Ezzell et al., Phys. Rev. Appl. 20, 064027 (2022) for information on DD sequences.
157
+ """
158
+
159
+ # TODO station-control-client docs need to support bibtex citations.
160
+ # TODO :cite:`Ezzell_2022`
161
+
162
+ merge_contiguous_waits: bool = Field(default=True)
163
+ """Merge contiguous ``Wait`` instructions into one if they are separated only by ``Block`` instructions."""
164
+
165
+ target_qubits: frozenset[str] | None = Field(default=None)
166
+ """Qubits on which dynamical decoupling should be applied. If ``None``, all qubits are targeted."""
167
+
168
+ skip_leading_wait: bool = Field(default=True)
169
+ """Skip processing leading ``Wait`` instructions."""
170
+
171
+ skip_trailing_wait: bool = Field(default=True)
172
+ """Skip processing trailing ``Wait`` instructions."""
173
+
174
+ gate_sequences: list[tuple[int, str | PRXSequence, str]] = Field(default_factory=list)
175
+ """Available decoupling gate sequences to choose from in this strategy.
176
+
177
+ Each sequence is defined by a tuple of ``(ratio, gate pattern, align)``:
178
+
179
+ * ratio: Minimal duration for the sequence (in PRX gate durations).
180
+
181
+ * gate pattern: Gate pattern can be defined in two ways. It can be a string containing "X" and "Y" characters,
182
+ encoding a PRX gate sequence. For example, "YXYX" corresponds to the
183
+ XY4 sequence, "XYXYYXYX" to the EDD sequence, etc. If more flexibility is needed, a gate pattern can be
184
+ defined as a sequence of PRX gate argument tuples (that contain a rotation angle and a phase angle). For
185
+ example, sequence "YX" could be written as ``[(math.pi, math.pi / 2), (math.pi, 0)]``.
186
+
187
+ * align: Controls the alignment of the sequence within the time window it is inserted in. Supported values:
188
+
189
+ - "asap": Corresponds to a ASAP-aligned sequence with no waiting time before the first pulse.
190
+ - "center": Corresponds to a symmetric sequence.
191
+ - "alap": Corresponds to a ALAP-aligned sequence.
192
+
193
+ The Dynamical Decoupling algorithm uses the best fitting gate sequence by first sorting them
194
+ by ``ratio`` in descending order. Then the longest fitting pattern is determined by comparing ``ratio``
195
+ with the duration of the time window divided by the PRX gate duration.
196
+ """
197
+
198
+
199
+ def _parse_legacy_qubit_mapping(value: Any) -> dict[str, str] | None:
200
+ if value is None:
201
+ return None
202
+ if isinstance(value, dict):
203
+ return {str(key): str(value) for key, value in value.items()}
204
+ # Deprecated since 2025-11-03.
205
+ # `qubit_mapping` currently uses the legacy list format on the wire,
206
+ # but it should eventually be replaced with the new `QubitMapping` (dict-based) format.
207
+ # The switch can only be made once all legacy clients have been updated,
208
+ # as they still depend on receiving the old response format.
209
+ return {item["logical_name"]: item["physical_name"] for item in value}
210
+
211
+
212
+ def _serialize_as_legacy_qubit_mapping(mapping: dict[str, str] | None) -> list[dict[str, str]] | None:
213
+ if mapping is None:
214
+ return None
215
+ # Deprecated since 2025-11-03.
216
+ # `qubit_mapping` currently uses the legacy list format on the wire,
217
+ # but it should eventually be replaced with the new `QubitMapping` (dict-based) format.
218
+ # The switch can only be made once all legacy clients have been updated,
219
+ # as they still depend on receiving the old response format.
220
+ return [{"logical_name": k, "physical_name": v} for k, v in mapping.items()]
221
+
222
+
223
+ LEGACY_QUBIT_MAPPING_SCHEMA = {
224
+ "anyOf": [
225
+ {
226
+ "type": "array",
227
+ "items": {
228
+ "type": "object",
229
+ "required": ["logical_name", "physical_name"],
230
+ "properties": {
231
+ "logical_name": {"type": "string"},
232
+ "physical_name": {"type": "string"},
233
+ },
234
+ "additionalProperties": False,
235
+ },
236
+ },
237
+ {"type": "null"},
238
+ ]
239
+ }
240
+
241
+
242
+ QubitMapping = Annotated[
243
+ dict[str, str],
244
+ BeforeValidator(_parse_legacy_qubit_mapping),
245
+ PlainSerializer(_serialize_as_legacy_qubit_mapping),
246
+ WithJsonSchema(LEGACY_QUBIT_MAPPING_SCHEMA),
247
+ ]
248
+ """Mapping from logical qubit names to physical qubit names."""
249
+
250
+
251
+ # ATTENTION: Do **not** rename RunRequest model!
252
+ # IQM Server implements circuit validation by loading the StationControl OpenAPI specification
253
+ # and using this (JSON) schema for the validation. OpenAPI specification contains schemas for
254
+ # all station control models so the name of the schema (= name of this dataclass) is used to
255
+ # select the correct schema. If you intend to rename or move this model, consult IQM Server
256
+ # team to ensure that nothing gets broken!! Sub-schemas (e.g. Circuit) can be renamed freely
257
+ # - FastAPI uses local schema references so the renamed references are resolved correctly,
258
+ # as long as all the referenced schemas are added to the OpenAPI specification
259
+ # (which is done automatically by FastAPI/Pydantic,
260
+ # unless there are some really weird stuff in the dataclas definition).
261
+ #
262
+ # In addition to the schema name, IQM Server depends on the following features:
263
+ #
264
+ # * RunRequest has "shots" integer property
265
+ # * RunRequest has "circuits" array property
266
+ #
267
+ # If you change those properties, coordinate the changes with IQM Server team!
268
+ class PostJobsRequest(PydanticBase):
269
+ """Request to Station Control run a job that executes a batch of quantum circuits."""
270
+
271
+ circuits: CircuitBatch = Field(...)
272
+ """Batch of quantum circuit(s) to execute."""
273
+ calibration_set_id: UUID | None = Field(None)
274
+ """ID of the calibration set to use, or None to use the current default calibration set."""
275
+ qubit_mapping: QubitMapping | None = Field(None)
276
+ """Mapping from logical qubit names to physical qubit names, or None if ``circuits`` use physical qubit names."""
277
+ shots: int = Field(1, gt=0)
278
+ """How many times to execute each circuit in the batch, must be greater than zero."""
279
+ max_circuit_duration_over_t2: float | None = Field(None)
280
+ """Circuits are disqualified on the server if they are longer than this fraction
281
+ of the T2 time of the worst qubit used.
282
+ If set to 0.0, no circuits are disqualified. If set to None the server default value is used."""
283
+ heralding_mode: HeraldingMode = Field(HeraldingMode.NONE)
284
+ """Which heralding mode to use during the execution of circuits in this request."""
285
+ move_gate_validation: MoveGateValidationMode = Field(
286
+ MoveGateValidationMode.STRICT,
287
+ validation_alias=AliasChoices("move_gate_validation", "move_validation_mode"),
288
+ )
289
+ """Which method of MOVE gate validation to use in circuit compilation."""
290
+ move_gate_frame_tracking: MoveGateFrameTrackingMode = Field(
291
+ MoveGateFrameTrackingMode.FULL,
292
+ validation_alias=AliasChoices("move_gate_frame_tracking", "move_gate_frame_tracking_mode"),
293
+ )
294
+ """Which method of MOVE gate frame tracking to use for circuit compilation."""
295
+ active_reset_cycles: int | None = Field(None)
296
+ """Number of active ``reset`` operations inserted at the beginning of each circuit for each active qubit.
297
+ ``None`` means active reset is not used but instead reset is done by waiting (relaxation). Integer values smaller
298
+ than 1 result in neither active nor reset by wait being used, in which case any reset operations must be explicitly
299
+ added in the circuit."""
300
+ dd_mode: DDMode = Field(DDMode.DISABLED)
301
+ """Whether dynamical decoupling is enabled or disabled during the execution."""
302
+ dd_strategy: DDStrategy | None = Field(None)
303
+ """Dynamical decoupling strategy to be used during the execution, if DD is enabled.
304
+ If None, use the server default strategy."""
305
+
306
+ @computed_field(
307
+ json_schema_extra={
308
+ "deprecated": True,
309
+ "description": format_deprecated(
310
+ old="`move_validation_mode`", new="`move_gate_validation`", since="2025-10-17"
311
+ ),
312
+ },
313
+ )
314
+ def move_validation_mode(self) -> MoveGateValidationMode:
315
+ return self.move_gate_validation
316
+
317
+ @computed_field(
318
+ json_schema_extra={
319
+ "deprecated": True,
320
+ "description": format_deprecated(
321
+ old="`move_gate_frame_tracking_mode`", new="`move_gate_frame_tracking`", since="2025-10-17"
322
+ ),
323
+ },
324
+ )
325
+ def move_gate_frame_tracking_mode(self) -> MoveGateFrameTrackingMode:
326
+ return self.move_gate_frame_tracking
327
+
328
+
329
+ RunRequest: TypeAlias = PostJobsRequest
330
+
331
+
332
+ class CircuitMeasurementCounts(PydanticBase):
333
+ """Circuit measurement counts in histogram representation."""
334
+
335
+ measurement_keys: list[str]
336
+ """Measurement keys in the order they are concatenated to form the state bitstrings in :attr:`counts`.
337
+
338
+ For example, if :attr:`measurement_keys` is ``['mk_1', 'mk2']`` and ``'mk_1'`` measures ``QB1``
339
+ and ``'mk_2'`` measures ``QB3`` and ``QB5``, then :attr:`counts` could contains keys such as ``'010'`` representing
340
+ shots where ``QB1`, ``QB3`` and ``QB5`` were observed to be in the state :math:`|010\rangle`.
341
+ """
342
+ counts: dict[str, int]
343
+ """Mapping from computational basis states, represented as bitstrings, to the number of times they were observed
344
+ when executing the circuit."""
345
+
346
+
347
+ CircuitMeasurementCountsBatch: TypeAlias = list[CircuitMeasurementCounts]
348
+ """Measurement results in histogram representation for each circuit in the batch."""
@@ -13,14 +13,16 @@
13
13
  # limitations under the License.
14
14
  """Dynamic quantum architecture (DQA) related interface models."""
15
15
 
16
- from typing import Any
16
+ from functools import cached_property
17
+ import re
18
+ from typing import Any, TypeAlias
17
19
  from uuid import UUID
18
20
 
19
- from pydantic import Field, StrictStr, field_validator
21
+ from pydantic import Field, field_validator
20
22
 
21
23
  from iqm.station_control.interface.pydantic_base import PydanticBase
22
24
 
23
- Locus = tuple[StrictStr, ...]
25
+ Locus: TypeAlias = tuple[str, ...]
24
26
  """Names of the QPU components (typically qubits) a quantum operation instance is acting on, e.g. `("QB1", "QB2")`."""
25
27
 
26
28
 
@@ -80,6 +82,42 @@ class GateInfo(PydanticBase):
80
82
  return new_value
81
83
  raise ValueError("'override_default_implementation' must be a dict.")
82
84
 
85
+ @cached_property
86
+ def loci(self) -> tuple[Locus, ...]:
87
+ """Returns all loci which are available for at least one of the implementations.
88
+
89
+ The loci are sorted first based on the first locus component, then the second, etc.
90
+ The sorting of individual locus components is first done alphabetically based on their
91
+ non-numeric part, and then components with the same non-numeric part are sorted numerically.
92
+ An example of loci sorted this way would be:
93
+
94
+ ('QB1', 'QB2'),
95
+ ('QB2', 'COMPR1'),
96
+ ('QB2', 'QB3'),
97
+ ('QB3', 'COMPR1'),
98
+ ('QB3', 'COMPR2'),
99
+ ('QB3', 'QB1'),
100
+ ('QB10', 'QB2'),
101
+
102
+ """
103
+ loci_set = {locus for impl in self.implementations.values() for locus in impl.loci}
104
+ loci_sorted = sorted(loci_set, key=lambda locus: tuple(map(_component_sort_key, locus)))
105
+ return tuple(loci_sorted)
106
+
107
+ def get_default_implementation(self, locus: Locus) -> str:
108
+ """Default implementation of this gate for the given locus.
109
+
110
+ Args:
111
+ locus: gate locus
112
+
113
+ Returns:
114
+ Name of the default implementation of this gate for ``locus``.
115
+
116
+ """
117
+ if (impl := self.override_default_implementation.get(locus)) is not None:
118
+ return impl
119
+ return self.default_implementation
120
+
83
121
 
84
122
  class DynamicQuantumArchitecture(PydanticBase):
85
123
  """The dynamic quantum architecture (DQA).
@@ -117,3 +155,23 @@ class DynamicQuantumArchitecture(PydanticBase):
117
155
  ],
118
156
  )
119
157
  """Mapping of gate names to information about the gates."""
158
+
159
+ @cached_property
160
+ def components(self) -> tuple[str, ...]:
161
+ """All locus components (qubits and computational resonators) sorted.
162
+
163
+ The components are first sorted alphabetically based on their non-numeric part, and then
164
+ components with the same non-numeric part are sorted numerically. An example of components
165
+ sorted this way would be: ('COMPR1', 'COMPR2', 'QB1', 'QB2', 'QB3', 'QB10', 'QB11', 'QB20').
166
+ """
167
+ return tuple(sorted(self.qubits + self.computational_resonators, key=_component_sort_key))
168
+
169
+
170
+ def _component_sort_key(component_name: str) -> tuple[str, int, str]:
171
+ """Sorting key for QPU component names."""
172
+
173
+ def get_numeric_id(name: str) -> int:
174
+ match = re.search(r"(\d+)", name)
175
+ return int(match.group(1)) if match else 0
176
+
177
+ return re.sub(r"[^a-zA-Z]", "", component_name), get_numeric_id(component_name), component_name
@@ -11,60 +11,89 @@
11
11
  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
- """Job executor artifact and state models."""
14
+ """Job-related models."""
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ from collections.abc import Callable
18
19
  from datetime import datetime
19
20
  from enum import Enum
20
21
  import functools
22
+ from typing import TypeAlias
21
23
  from uuid import UUID
22
24
 
23
25
  from iqm.station_control.interface.pydantic_base import PydanticBase
24
26
 
27
+ _Progress: TypeAlias = tuple[str, int, int]
28
+ """Describes the progress of an arbitrary task: (label, value, max_value)."""
29
+
30
+ ProgressCallback: TypeAlias = Callable[[list[_Progress]], None]
31
+ """Callback function for reporting progress on a list of tasks."""
32
+
33
+ Statuses: TypeAlias = list[_Progress]
34
+ """Progress of the parallel sweeps of a job.
35
+ Used in Station Control. Deprecated, should not be used anywhere else."""
36
+
25
37
 
26
38
  class TimelineEntry(PydanticBase):
27
- """Status and timestamp pair as described in a job timeline."""
39
+ """Status and timestamp pair for a job timeline."""
28
40
 
29
41
  status: JobExecutorStatus
42
+ """Job status that was reached."""
30
43
  timestamp: datetime
44
+ """Time at which ``status`` was reached."""
31
45
 
32
46
 
33
47
  class JobResult(PydanticBase):
34
- """Progress information about a running job."""
48
+ """Progress information for the JobExecutorStatus.EXECUTION_STARTED stage of a job."""
49
+
50
+ # TODO redesign, should not be called JobResult since it's a progress indicator
35
51
 
36
52
  job_id: UUID
37
- parallel_sweep_progress: list[tuple[str, int, int]]
53
+ """ID of the job."""
54
+ parallel_sweep_progress: list[_Progress]
55
+ """Progress of the sweeps if we are in the JobExecutorStatus.EXECUTION_STARTED stage, otherwise empty."""
38
56
  interrupted: bool
57
+ """True iff the job was canceled."""
39
58
 
40
59
 
41
60
  class JobError(PydanticBase):
42
- """Error log for a job."""
61
+ """Error message for a job."""
43
62
 
44
63
  full_error_log: str
64
+ """Full error message for logging."""
45
65
  user_error_message: str
66
+ """Short, human-readable error message for users."""
46
67
 
47
68
 
48
69
  class JobData(PydanticBase):
49
70
  """Status, artifacts and metadata of a job."""
50
71
 
72
+ # TODO redesign
73
+
51
74
  job_id: UUID
52
75
  """Unique ID of the job."""
53
76
  job_status: JobExecutorStatus
54
77
  """Current job status."""
55
- job_result: JobResult
56
- """Progress information for the job.""" # FIXME why is it called JobResult? can it be None?
57
- # job_result: The output of a progressing or a successful job. This includes progress indicators.
78
+ job_result: JobResult # TODO should not be called JobResult, it's progress info for the execution stage
79
+ """Progress information for the JobExecutorStatus.EXECUTION_STARTED stage of a job."""
58
80
  job_error: JobError | None
59
- """Error message(s) for a failed job."""
81
+ """Error message(s) for a failed job, otherwise None."""
60
82
  position: int | None
61
- """If the job is not completed, its position in the current queue.
62
- 1 means this task will be executed next. In other cases the value is 0."""
83
+ """Number of jobs ahead of this job in its current queue.
84
+ None means the job has reached a terminal status.
85
+ """
63
86
 
64
87
 
88
+ # NOTE: Keep JobExecutorStatus inheriting from Enum (not StrEnum).
89
+ # Our tests do ordering like: str(JobExecutorStatus.X) > JobExecutorStatus.Y
90
+ # With Enum, the right operand isn’t a str, so Python dispatches to the Enum’s comparison methods,
91
+ # where we implement definition-order (<, >) logic.
92
+ # With StrEnum, members are str subclasses; when a plain str is on the left, Python uses str.__gt__
93
+ # (lexicographic) and never calls our enum’s ordering, so string↔enum ordering fails.
65
94
  @functools.total_ordering
66
95
  class JobExecutorStatus(Enum):
67
- """Different states a job can be in.
96
+ """Different statuses a job can be in.
68
97
 
69
98
  The ordering of these statuses is important, and execution logic relies on it.
70
99
  Thus, if a new status is added, ensure that it is slotted
@@ -17,8 +17,9 @@ from datetime import datetime
17
17
  import enum
18
18
  import uuid
19
19
 
20
- from pydantic import ConfigDict, Field
20
+ from pydantic import ConfigDict, Field, computed_field
21
21
 
22
+ from exa.common.helpers.deprecation import format_deprecated
22
23
  from iqm.station_control.interface.models.observation import ObservationLite
23
24
  from iqm.station_control.interface.pydantic_base import PydanticBase
24
25
 
@@ -41,8 +42,6 @@ class ObservationSetBase(PydanticBase):
41
42
 
42
43
  observation_set_type: ObservationSetType
43
44
  """Indicates the type (i.e. purpose) of the observation set."""
44
- observation_ids: list[int]
45
- """Database IDs of the observations belonging to the observation set."""
46
45
  describes_id: uuid.UUID | None = Field(default=None)
47
46
  """Unique identifier of the observation set this observation set describes."""
48
47
  invalid: bool = Field(default=False)
@@ -52,6 +51,9 @@ class ObservationSetBase(PydanticBase):
52
51
  class ObservationSetDefinition(ObservationSetBase):
53
52
  """The content of the observation set object when creating it."""
54
53
 
54
+ observation_ids: list[int]
55
+ """Database IDs of the observations belonging to the observation set."""
56
+
55
57
  model_config = ConfigDict(
56
58
  extra="forbid", # Forbid any extra attributes
57
59
  )
@@ -60,6 +62,8 @@ class ObservationSetDefinition(ObservationSetBase):
60
62
  class ObservationSetData(ObservationSetBase):
61
63
  """The content of the observation set stored in the database."""
62
64
 
65
+ observation_ids: list[int]
66
+ """Database IDs of the observations belonging to the observation set."""
63
67
  dut_label: str | None
64
68
  """String representation of the DUT the observation set is associated with. Can only be None for generic sets."""
65
69
  observation_set_id: uuid.UUID
@@ -90,12 +94,32 @@ class ObservationSetUpdate(PydanticBase):
90
94
  """Flag indicating if the set is invalid. Automated systems must not use invalid sets."""
91
95
 
92
96
 
93
- class ObservationSetWithObservations(ObservationSetData):
97
+ class ObservationSetWithObservations(ObservationSetBase):
94
98
  """The content of the observation set stored in the database, with a list of observations."""
95
99
 
100
+ dut_label: str | None
101
+ """String representation of the DUT the observation set is associated with. Can only be None for generic sets."""
102
+ observation_set_id: uuid.UUID
103
+ """Unique identifier of the observation set."""
104
+ created_timestamp: datetime
105
+ """Time when the object was created in the database."""
106
+ end_timestamp: datetime | None
107
+ """Time when the observation set was finalized. If ``None``, the set is not finalized yet."""
96
108
  observations: list[ObservationLite]
97
109
  """Observations belonging to the observation set."""
98
110
 
111
+ @computed_field(
112
+ return_type=list[int],
113
+ json_schema_extra={
114
+ "deprecated": True,
115
+ "description": format_deprecated(old="`observation_ids`", new="`observations`", since="2025-10-06"),
116
+ },
117
+ )
118
+ def observation_ids(self) -> list[int]:
119
+ """Database IDs of the observations belonging to the observation set."""
120
+ # "observation_ids" is deprecated to unify the format with IQM Server which uses "observations"
121
+ return [observation.observation_id for observation in self.observations]
122
+
99
123
 
100
124
  class QualityMetrics(ObservationSetWithObservations):
101
125
  """The content of the quality metric set stored in the database, with a list of observations and calibration set."""
@@ -13,7 +13,7 @@
13
13
  # limitations under the License.
14
14
  """Run related station control interface models."""
15
15
 
16
- from dataclasses import dataclass
16
+ from dataclasses import dataclass, field
17
17
  from datetime import datetime
18
18
  from typing import Any
19
19
  import uuid
@@ -34,9 +34,9 @@ class RunBase:
34
34
  """Identifier of the Experiment (:attr:`.Experiment.name`)."""
35
35
  experiment_label: str
36
36
  """Freeform label of the Experiment. As opposed to `experiment_name`, no core logic relies on this value."""
37
- options: dict[str, Any] | None
37
+ options: dict[str, Any] = field(default_factory=dict)
38
38
  """Experiment-specific options or toggles that generated the run."""
39
- software_version_set_id: int | None
39
+ software_version_set_id: int = 0
40
40
  """Unique identifier of the software version set of the current Python runtime."""
41
41
 
42
42
 
@@ -44,9 +44,9 @@ class RunBase:
44
44
  class RunConfigurationBase:
45
45
  """Abstract base class of the run configuration data."""
46
46
 
47
- additional_run_properties: dict[str, Any] | None
47
+ additional_run_properties: dict[str, Any] = field(default_factory=dict)
48
48
  """A free-form dictionary of data, used to store information that does not fall into other categories."""
49
- hard_sweeps: dict[str, NdSweep] | None
49
+ hard_sweeps: dict[str, NdSweep] = field(default_factory=dict)
50
50
  """Maps :attr:`.SweepBase.return_parameters` to "hardware sweep specification" which specifies
51
51
  how the data measured at each spot should be interpreted and shaped.
52
52
  The hard sweep specification is in the same format as :attr:`.SweepBase.sweeps`,
@@ -54,12 +54,12 @@ class RunConfigurationBase:
54
54
  An empty list is interpreted such that the return parameter is a scalar.
55
55
  The hard sweep specification can also be `None`,
56
56
  in which case the shape will be whatever the instrument returns."""
57
- components: list[str]
57
+ components: list[str] = field(default_factory=list)
58
58
  """Components that participate in this run."""
59
- default_data_parameters: list[str]
59
+ default_data_parameters: list[str] = field(default_factory=list)
60
60
  """The subset of :attr:`.SweepBase.return_parameters` that were added by default, not by the user.
61
61
  Used to select which data to analyze and plot."""
62
- default_sweep_parameters: list[str]
62
+ default_sweep_parameters: list[str] = field(default_factory=list)
63
63
  """The subset of :attr:`.SweepBase.sweeps` parameters were added by default, not by the user.
64
64
  Used to select which data to analyze and plot."""
65
65
 
@@ -52,7 +52,13 @@ class SweepDefinition(SweepBase):
52
52
 
53
53
  @dataclass(kw_only=True)
54
54
  class SweepData(SweepBase):
55
- """The content of the sweep stored in the database."""
55
+ """The content of the sweep stored in the database.
56
+
57
+ The raw data for each spot in the sweep is saved as NumPy arrays,
58
+ and the complete data for the whole sweep is saved as an ``xarray.Dataset``
59
+ which has ``SweepBase.sweeps`` as coordinates and
60
+ ``SweepBase.return_parameters`` data as ``xarray.DataArray`` s.
61
+ """
56
62
 
57
63
  created_timestamp: datetime
58
64
  """Time when the object was created in the database."""