qilisdk 0.1.6__py3-none-any.whl → 0.1.7__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 (34) hide show
  1. qilisdk/analog/__init__.py +1 -2
  2. qilisdk/analog/hamiltonian.py +1 -68
  3. qilisdk/analog/schedule.py +288 -313
  4. qilisdk/backends/backend.py +5 -1
  5. qilisdk/backends/cuda_backend.py +9 -5
  6. qilisdk/backends/qutip_backend.py +23 -12
  7. qilisdk/core/__init__.py +4 -0
  8. qilisdk/core/interpolator.py +406 -0
  9. qilisdk/core/parameterizable.py +66 -10
  10. qilisdk/core/variables.py +150 -7
  11. qilisdk/digital/circuit.py +1 -0
  12. qilisdk/digital/circuit_transpiler.py +46 -0
  13. qilisdk/digital/circuit_transpiler_passes/__init__.py +18 -0
  14. qilisdk/digital/circuit_transpiler_passes/circuit_transpiler_pass.py +36 -0
  15. qilisdk/digital/circuit_transpiler_passes/decompose_multi_controlled_gates_pass.py +216 -0
  16. qilisdk/digital/circuit_transpiler_passes/numeric_helpers.py +82 -0
  17. qilisdk/digital/gates.py +12 -2
  18. qilisdk/{speqtrum/experiments → experiments}/__init__.py +13 -2
  19. qilisdk/{speqtrum/experiments → experiments}/experiment_functional.py +90 -2
  20. qilisdk/{speqtrum/experiments → experiments}/experiment_result.py +16 -0
  21. qilisdk/functionals/sampling.py +8 -1
  22. qilisdk/functionals/time_evolution.py +6 -2
  23. qilisdk/functionals/variational_program.py +58 -0
  24. qilisdk/speqtrum/speqtrum.py +360 -130
  25. qilisdk/speqtrum/speqtrum_models.py +108 -19
  26. qilisdk/utils/openfermion/__init__.py +38 -0
  27. qilisdk/{core/algorithm.py → utils/openfermion/__init__.pyi} +2 -3
  28. qilisdk/utils/openfermion/openfermion.py +45 -0
  29. qilisdk/utils/visualization/schedule_renderers.py +16 -8
  30. {qilisdk-0.1.6.dist-info → qilisdk-0.1.7.dist-info}/METADATA +74 -24
  31. {qilisdk-0.1.6.dist-info → qilisdk-0.1.7.dist-info}/RECORD +33 -26
  32. {qilisdk-0.1.6.dist-info → qilisdk-0.1.7.dist-info}/WHEEL +1 -1
  33. qilisdk/analog/linear_schedule.py +0 -121
  34. {qilisdk-0.1.6.dist-info → qilisdk-0.1.7.dist-info}/licenses/LICENCE +0 -0
@@ -0,0 +1,216 @@
1
+ # Copyright 2025 Qilimanjaro Quantum Tech
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
+
15
+ import math
16
+ from typing import List
17
+
18
+ from qilisdk.digital import RX, RY, RZ, U1, U2, U3, Circuit, Gate, H, I, S, T, X, Y, Z
19
+ from qilisdk.digital.gates import BasicGate, Controlled
20
+
21
+ from .circuit_transpiler_pass import CircuitTranspilerPass
22
+ from .numeric_helpers import (
23
+ _unitary_sqrt_2x2,
24
+ _zyz_from_unitary,
25
+ )
26
+
27
+
28
+ class DecomposeMultiControlledGatesPass(CircuitTranspilerPass):
29
+ """Decompose multi-controlled (k >= 2) single-qubit gates.
30
+
31
+ The construction follows Lemma 7.5 from Barenco et al., *Elementary Gates for Quantum Computation*,
32
+ recursively replacing a k-controlled unitary with five layers of (k-1)-controlled operations built
33
+ from sqrt(U), its adjoint, and multi-controlled Pauli-X gates.
34
+ """
35
+
36
+ def run(self, circuit: Circuit) -> Circuit:
37
+ """Rewrite the circuit while decomposing multi-controlled gates.
38
+
39
+ Args:
40
+ circuit (Circuit): Circuit whose gates should be rewritten.
41
+ Returns:
42
+ Circuit: Newly built circuit containing only supported primitives.
43
+ """
44
+ out = Circuit(circuit.nqubits)
45
+ for g in circuit.gates:
46
+ for h in self._rewrite_gate(g):
47
+ out.add(h)
48
+
49
+ return out
50
+
51
+ def _rewrite_gate(self, gate: Gate) -> List[Gate]: # noqa: PLR6301
52
+ """Expand unsupported gates into equivalent elementary gates.
53
+
54
+ Args:
55
+ gate (Gate): Candidate gate potentially containing multiple controls.
56
+ Returns:
57
+ list[Gate]: Sequence of equivalent gates that rely on supported primitives.
58
+ """
59
+ # --- Multi-controlled gates ---
60
+ if isinstance(gate, Controlled):
61
+ base: BasicGate = gate.basic_gate
62
+ if base.nqubits != 1:
63
+ raise NotImplementedError("Controlled version of multi-qubit gates is not supported.")
64
+
65
+ return _decompose(gate)
66
+
67
+ # Everything else is untouched.
68
+ return [gate]
69
+
70
+
71
+ def _decompose(gate: Controlled) -> List[Gate]:
72
+ """Recursively decompose a multi-controlled single-qubit gate.
73
+
74
+ Args:
75
+ gate (Controlled): Controlled gate whose target operation is single-qubit.
76
+ Returns:
77
+ list[Gate]: Gate sequence computing the same unitary as `gate`.
78
+ """
79
+ if len(gate.control_qubits) == 1:
80
+ return [gate]
81
+
82
+ c_last = gate.control_qubits[-1]
83
+ rest = gate.control_qubits[:-1]
84
+
85
+ V = _sqrt_of(gate.basic_gate)
86
+ Vd = _adjoint_of(V)
87
+
88
+ seq: List[Gate] = []
89
+ seq += _decompose(Controlled(c_last, basic_gate=V))
90
+ seq += _decompose(X(c_last).controlled(*rest))
91
+ seq += _decompose(Controlled(c_last, basic_gate=Vd))
92
+ seq += _decompose(X(c_last).controlled(*rest))
93
+ seq += _decompose(Controlled(*rest, basic_gate=V))
94
+
95
+ return seq
96
+
97
+
98
+ def _sqrt_of(gate: BasicGate) -> BasicGate:
99
+ """Return a gate V whose square equals the provided gate.
100
+
101
+ Args:
102
+ gate (BasicGate): Single-qubit gate to compute the principal square root for.
103
+ Returns:
104
+ BasicGate: New primitive V that satisfies V · V ≡ gate.
105
+ """
106
+ q = gate.qubits[0]
107
+
108
+ # Identity: sqrt(I) = I
109
+ if isinstance(gate, I):
110
+ return I(q)
111
+
112
+ # Direct parametric rotations.
113
+ if isinstance(gate, RZ):
114
+ return RZ(q, phi=gate.phi / 2.0)
115
+ if isinstance(gate, RX):
116
+ return RX(q, theta=gate.theta / 2.0)
117
+ if isinstance(gate, RY):
118
+ return RY(q, theta=gate.theta / 2.0)
119
+
120
+ # Pauli gates via half-angle rotations.
121
+ if isinstance(gate, Z):
122
+ return RZ(q, phi=math.pi / 2.0)
123
+ if isinstance(gate, X):
124
+ return RX(q, theta=math.pi / 2.0)
125
+ if isinstance(gate, Y):
126
+ return RY(q, theta=math.pi / 2.0)
127
+
128
+ # Phase gate U1(phi) = diag(1, e^{iphi}), sqrt is U1(phi/2).
129
+ if isinstance(gate, U1):
130
+ return RZ(q, phi=gate.phi / 2.0)
131
+
132
+ # S and T: phase gates with known relation to RZ
133
+ # S = RZ(pi/2) ⇒ sqrt(S) = RZ(pi/4) ≡ T
134
+ if isinstance(gate, S):
135
+ return T(q)
136
+
137
+ # T = RZ(pi/4) ⇒ sqrt(T) = RZ(pi/8)
138
+ if isinstance(gate, T):
139
+ return RZ(q, phi=math.pi / 8.0)
140
+
141
+ # Build the 2x2 unitary matrix for gate
142
+ if isinstance(gate, (U2, U3, H, BasicGate)):
143
+ U = gate.matrix
144
+ else:
145
+ raise NotImplementedError(f"_sqrt_1q_gate_as_basis only supports 1-qubit gates; got {type(gate).__name__}")
146
+
147
+ # Compute a matrix square root V such that V @ V ≈ U.
148
+ Vs = _unitary_sqrt_2x2(U)
149
+
150
+ # Express V as a U3 on the same qubit. This introduces a new gate in U3 form
151
+ # for the *square root*, but leaves the original g untouched.
152
+ th, ph, lam = _zyz_from_unitary(Vs)
153
+ return U3(q, theta=th, phi=ph, gamma=lam)
154
+
155
+
156
+ def _adjoint_of(gate: BasicGate) -> BasicGate:
157
+ """Return the single-qubit adjoint (inverse) of a gate.
158
+
159
+ Args:
160
+ gate (BasicGate): Gate whose inverse should be produced.
161
+ Returns:
162
+ BasicGate: Gate that when composed with `gate` yields the identity.
163
+ """
164
+ q = gate.qubits[0]
165
+
166
+ # Identity: self-adjoint.
167
+ if isinstance(gate, I):
168
+ return I(q)
169
+
170
+ # Pauli & Hadamard: self-adjoint.
171
+ if isinstance(gate, X):
172
+ return X(q)
173
+ if isinstance(gate, Y):
174
+ return Y(q)
175
+ if isinstance(gate, Z):
176
+ return Z(q)
177
+ if isinstance(gate, H):
178
+ return H(q)
179
+
180
+ if isinstance(gate, RX):
181
+ return RX(q, theta=-gate.theta)
182
+ if isinstance(gate, RY):
183
+ return RY(q, theta=-gate.theta)
184
+ if isinstance(gate, RZ):
185
+ return RZ(q, phi=-gate.phi)
186
+
187
+ if isinstance(gate, U1):
188
+ # U1(gamma)† = U1(-gamma)
189
+ return RZ(q, phi=-gate.phi)
190
+ if isinstance(gate, U2):
191
+ # U2(phi, gamma)† = U3(pi/2, phi, gamma)† = U3(-pi/2, -phi, -gamma)
192
+ return U3(q, theta=-math.pi / 2.0, phi=-gate.gamma, gamma=-gate.phi)
193
+ if isinstance(gate, U3):
194
+ # U3(theta, phi, gamma)† = U3(-theta, -gamma, -phi)
195
+ return U3(q, theta=-gate.theta, phi=-gate.gamma, gamma=-gate.phi)
196
+
197
+ # S, T: phase gates about Z.
198
+ # S = RZ(pi/2) ⇒ S† = RZ(-pi/2)
199
+ if isinstance(gate, S):
200
+ return RZ(q, phi=-math.pi / 2.0)
201
+
202
+ # T = RZ(pi/4) ⇒ T† = RZ(-pi/4)
203
+ if isinstance(gate, T):
204
+ return RZ(q, phi=-math.pi / 4.0)
205
+
206
+ # ---------- Generic 1-qubit unitary via matrix adjoint ----------
207
+
208
+ if isinstance(gate, BasicGate) and gate.nqubits == 1:
209
+ U = gate.matrix
210
+ else:
211
+ raise NotImplementedError(f"_adjoint_1q only supports 1-qubit gates; got {type(gate).__name__}")
212
+
213
+ # Take the matrix adjoint U† and convert to ZYZ → U3.
214
+ U_dag = U.conj().T
215
+ theta, phi, gamma = _zyz_from_unitary(U_dag)
216
+ return U3(q, theta=theta, phi=phi, gamma=gamma)
@@ -0,0 +1,82 @@
1
+ # Copyright 2025 Qilimanjaro Quantum Tech
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
+ import math
15
+
16
+ import numpy as np
17
+
18
+ # ======================= numeric helpers =======================
19
+
20
+ _EPS = 1e-12
21
+ _SIG_DECIMALS = 12
22
+
23
+
24
+ def _wrap_angle(a: float) -> float:
25
+ """Wrap an angle to the (-pi, pi] range.
26
+
27
+ Args:
28
+ a (float): Angle value in radians.
29
+ Returns:
30
+ float: Angle mapped into the open-closed interval (-pi, pi].
31
+ """
32
+ a = (a + math.pi) % (2.0 * math.pi) - math.pi
33
+ if a <= -math.pi:
34
+ a = math.pi
35
+ return a
36
+
37
+
38
+ def _zyz_from_unitary(U: np.ndarray) -> tuple[float, float, float]:
39
+ """Recover ZYZ Euler angles from a 2x2 unitary.
40
+
41
+ Args:
42
+ U (np.ndarray): 2x2 unitary matrix.
43
+ Returns:
44
+ tuple[float, float, float]: Tuple containing theta, phi and gamma angles.
45
+ Raises:
46
+ ValueError: If matrix is not 2x2.
47
+ """
48
+ if U.shape != (2, 2):
49
+ raise ValueError("Expected 2x2 unitary for ZYZ decomposition.")
50
+ det = np.linalg.det(U)
51
+ if abs(det) < _EPS:
52
+ raise ValueError("Matrix is singular.")
53
+ # remove phase to a U3 rotation
54
+ phase = np.angle(U[0, 0])
55
+ U /= np.exp(1j * phase, dtype=complex)
56
+
57
+ a00, a01 = U[0, 0], U[0, 1]
58
+ a10, a11 = U[1, 0], U[1, 1]
59
+ theta = 2.0 * math.atan2(np.abs(a01), np.abs(a00))
60
+ s = math.sin(theta / 2.0)
61
+
62
+ if s < _EPS:
63
+ lam = _wrap_angle(np.angle(a11))
64
+ return (0.0, 0.0, lam)
65
+
66
+ phi = _wrap_angle(np.angle(a10))
67
+ lam = _wrap_angle(np.angle(-a01))
68
+ return (theta, phi, lam)
69
+
70
+
71
+ def _unitary_sqrt_2x2(U: np.ndarray) -> np.ndarray:
72
+ """Compute the principal square root of a 2x2 unitary.
73
+
74
+ Args:
75
+ U (np.ndarray): 2x2 unitary matrix.
76
+ Returns:
77
+ np.ndarray: Matrix V such that V · V equals U.
78
+ """
79
+ w, V = np.linalg.eig(U)
80
+ ph = np.angle(w)
81
+ sqrt_w = np.exp(0.5j * ph)
82
+ return V @ np.diag(sqrt_w) @ np.linalg.inv(V)
qilisdk/digital/gates.py CHANGED
@@ -212,13 +212,23 @@ class BasicGate(Gate):
212
212
  Represents a quantum gate that can be used in quantum circuits.
213
213
  """
214
214
 
215
- def __init__(self, target_qubits: tuple[int, ...], parameters: dict[str, Parameter] = {}) -> None:
215
+ def __init__(self, target_qubits: tuple[int, ...], parameters: dict[str, Parameter] | None = None) -> None:
216
+ """Build a basic gate.
217
+
218
+ Args:
219
+ target_qubits (tuple[int, ...]): Qubit indices the gate acts on. Duplicate indices are rejected.
220
+ parameters (dict[str, Parameter] | None): Optional parameter objects keyed by label for parameterized gates.
221
+
222
+ Raises:
223
+ ValueError: if duplicate target qubits are found.
224
+ """
216
225
  # Check for duplicate integers in target_qubits.
226
+ super(BasicGate, self).__init__()
217
227
  if len(target_qubits) != len(set(target_qubits)):
218
228
  raise ValueError("Duplicate target qubits found.")
219
229
 
220
230
  self._target_qubits: tuple[int, ...] = target_qubits
221
- self._parameters: dict[str, Parameter] = parameters
231
+ self._parameters: dict[str, Parameter] = parameters or {}
222
232
  self._matrix: np.ndarray = self._generate_matrix()
223
233
 
224
234
  @property
@@ -11,8 +11,15 @@
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
- from .experiment_functional import ExperimentFunctional, RabiExperiment, T1Experiment
15
- from .experiment_result import Dimension, ExperimentResult, RabiExperimentResult, T1ExperimentResult
14
+ from .experiment_functional import ExperimentFunctional, RabiExperiment, T1Experiment, T2Experiment, TwoTonesExperiment
15
+ from .experiment_result import (
16
+ Dimension,
17
+ ExperimentResult,
18
+ RabiExperimentResult,
19
+ T1ExperimentResult,
20
+ T2ExperimentResult,
21
+ TwoTonesExperimentResult,
22
+ )
16
23
 
17
24
  __all__ = [
18
25
  "Dimension",
@@ -22,4 +29,8 @@ __all__ = [
22
29
  "RabiExperimentResult",
23
30
  "T1Experiment",
24
31
  "T1ExperimentResult",
32
+ "T2Experiment",
33
+ "T2ExperimentResult",
34
+ "TwoTonesExperiment",
35
+ "TwoTonesExperimentResult",
25
36
  ]
@@ -16,12 +16,14 @@ from __future__ import annotations
16
16
  from abc import ABC
17
17
  from typing import TYPE_CHECKING, ClassVar, Generic, TypeVar
18
18
 
19
- from qilisdk.functionals.functional import Functional
20
- from qilisdk.speqtrum.experiments.experiment_result import (
19
+ from qilisdk.experiments.experiment_result import (
21
20
  ExperimentResult,
22
21
  RabiExperimentResult,
23
22
  T1ExperimentResult,
23
+ T2ExperimentResult,
24
+ TwoTonesExperimentResult,
24
25
  )
26
+ from qilisdk.functionals.functional import Functional
25
27
  from qilisdk.yaml import yaml
26
28
 
27
29
  if TYPE_CHECKING:
@@ -122,3 +124,89 @@ class T1Experiment(ExperimentFunctional[T1ExperimentResult]):
122
124
  np.ndarray: The set of delay durations (in nanoseconds) used in the T1 experiment.
123
125
  """
124
126
  return self._wait_duration_values
127
+
128
+
129
+ @yaml.register_class
130
+ class T2Experiment(ExperimentFunctional[T2ExperimentResult]):
131
+ """T2 dephasing experiment functional for a single qubit.
132
+
133
+ This functional defines a Ramsey/spin-echo style T2 experiment, where
134
+ the free-evolution delay between phase-sensitive pulses is swept to
135
+ extract the qubit coherence time.
136
+ """
137
+
138
+ result_type: ClassVar[type[T2ExperimentResult]] = T2ExperimentResult
139
+ """Result type returned by this functional."""
140
+
141
+ def __init__(self, qubit: int, wait_duration_values: np.ndarray) -> None:
142
+ """Initialize a T2 dephasing experiment functional.
143
+
144
+ Args:
145
+ qubit (int): The physical qubit index on which the experiment is performed.
146
+ wait_duration_values (np.ndarray): Array of free-evolution delays
147
+ (in nanoseconds) between the phase-sensitive pulses.
148
+ """
149
+ super().__init__(qubit=qubit)
150
+ self._wait_duration_values: np.ndarray = wait_duration_values
151
+
152
+ @property
153
+ def wait_duration_values(self) -> np.ndarray:
154
+ """Free-evolution delay sweep values.
155
+
156
+ Returns:
157
+ np.ndarray: The set of delay durations (in nanoseconds) used to estimate T2.
158
+ """
159
+ return self._wait_duration_values
160
+
161
+
162
+ @yaml.register_class
163
+ class TwoTonesExperiment(ExperimentFunctional[TwoTonesExperimentResult]):
164
+ """Two-tone spectroscopy functional for a single qubit.
165
+
166
+ Sweeps a drive tone frequency while monitoring the readout tone to
167
+ identify the qubit transition frequency.
168
+ """
169
+
170
+ result_type: ClassVar[type[TwoTonesExperimentResult]] = TwoTonesExperimentResult
171
+ """Result type returned by this functional."""
172
+
173
+ def __init__(self, qubit: int, frequency_start: float, frequency_stop: float, frequency_step: float) -> None:
174
+ """Initialize a two-tone spectroscopy functional.
175
+
176
+ Args:
177
+ qubit (int): The physical qubit index on which the experiment is performed.
178
+ frequency_start (float): Starting frequency of the swept drive tone (in Hz).
179
+ frequency_stop (float): Ending frequency of the swept drive tone (in Hz).
180
+ frequency_step (float): Frequency increment between sweep points (in Hz).
181
+ """
182
+ super().__init__(qubit=qubit)
183
+ self._frequency_start: float = frequency_start
184
+ self._frequency_stop: float = frequency_stop
185
+ self._frequency_step: float = frequency_step
186
+
187
+ @property
188
+ def frequency_start(self) -> float:
189
+ """Start frequency for the drive tone sweep.
190
+
191
+ Returns:
192
+ float: Starting frequency of the drive tone (in Hz).
193
+ """
194
+ return self._frequency_start
195
+
196
+ @property
197
+ def frequency_stop(self) -> float:
198
+ """Stop frequency for the drive tone sweep.
199
+
200
+ Returns:
201
+ float: Ending frequency of the drive tone (in Hz).
202
+ """
203
+ return self._frequency_stop
204
+
205
+ @property
206
+ def frequency_step(self) -> float:
207
+ """Step size for the drive tone sweep.
208
+
209
+ Returns:
210
+ float: Frequency increment between sweep points (in Hz).
211
+ """
212
+ return self._frequency_step
@@ -229,3 +229,19 @@ class T1ExperimentResult(ExperimentResult):
229
229
 
230
230
  plot_title: ClassVar[str] = "T1"
231
231
  """Default title for T1 experiment plots."""
232
+
233
+
234
+ @yaml.register_class
235
+ class T2ExperimentResult(ExperimentResult):
236
+ """Result container for T2 dephasing experiments."""
237
+
238
+ plot_title: ClassVar[str] = "T2"
239
+ """Default title for T2 experiment plots."""
240
+
241
+
242
+ @yaml.register_class
243
+ class TwoTonesExperimentResult(ExperimentResult):
244
+ """Result container for TwoTones experiments."""
245
+
246
+ plot_title: ClassVar[str] = "TwoTones"
247
+ """Default title for TwoTones experiment plots."""
@@ -13,7 +13,7 @@
13
13
  # limitations under the License.
14
14
  from typing import ClassVar
15
15
 
16
- from qilisdk.core.variables import RealNumber
16
+ from qilisdk.core.variables import ComparisonTerm, RealNumber
17
17
  from qilisdk.digital.circuit import Circuit
18
18
  from qilisdk.functionals.functional import PrimitiveFunctional
19
19
  from qilisdk.functionals.sampling_result import SamplingResult
@@ -79,3 +79,10 @@ class Sampling(PrimitiveFunctional[SamplingResult]):
79
79
  def set_parameter_bounds(self, ranges: dict[str, tuple[float, float]]) -> None:
80
80
  """Update the admissible range for selected circuit parameters."""
81
81
  self.circuit.set_parameter_bounds(ranges)
82
+
83
+ def get_constraints(self) -> list[ComparisonTerm]:
84
+ """Expose parameter constraints defined on the circuit.
85
+ Returns:
86
+ list[ComparisonTerm]: a list of constraints on the circuit parameters.
87
+ """
88
+ return self.circuit.get_constraints()
@@ -16,7 +16,7 @@ from typing import ClassVar
16
16
  from qilisdk.analog.hamiltonian import Hamiltonian, PauliOperator
17
17
  from qilisdk.analog.schedule import Schedule
18
18
  from qilisdk.core.qtensor import QTensor
19
- from qilisdk.core.variables import RealNumber
19
+ from qilisdk.core.variables import ComparisonTerm, RealNumber
20
20
  from qilisdk.functionals.functional import PrimitiveFunctional
21
21
  from qilisdk.functionals.time_evolution_result import TimeEvolutionResult
22
22
  from qilisdk.yaml import yaml
@@ -35,7 +35,7 @@ class TimeEvolution(PrimitiveFunctional[TimeEvolutionResult]):
35
35
  from qilisdk.functionals.time_evolution import TimeEvolution
36
36
 
37
37
  h0 = Z(0)
38
- schedule = Schedule(T=10.0, hamiltonians={"h0": h0})
38
+ schedule = Schedule(hamiltonians={"h0": h0}, total_time=10.0)
39
39
  functional = TimeEvolution(schedule, observables=[Z(0), X(0)], initial_state=ket(0))
40
40
  """
41
41
 
@@ -96,3 +96,7 @@ class TimeEvolution(PrimitiveFunctional[TimeEvolutionResult]):
96
96
  def set_parameter_bounds(self, ranges: dict[str, tuple[float, float]]) -> None:
97
97
  """Update bounds for selected schedule parameters."""
98
98
  self.schedule.set_parameter_bounds(ranges)
99
+
100
+ def get_constraints(self) -> list[ComparisonTerm]:
101
+ """Return the parameter constraints defined within the underlying schedule."""
102
+ return self.schedule.get_constraints()
@@ -13,14 +13,18 @@
13
13
  # limitations under the License.
14
14
  from __future__ import annotations
15
15
 
16
+ import functools
17
+ import operator
16
18
  from typing import TYPE_CHECKING, ClassVar, Generic, TypeVar
17
19
 
20
+ from qilisdk.core.variables import BaseVariable, Parameter
18
21
  from qilisdk.functionals.functional import Functional, PrimitiveFunctional
19
22
  from qilisdk.functionals.functional_result import FunctionalResult
20
23
  from qilisdk.functionals.variational_program_result import VariationalProgramResult
21
24
  from qilisdk.yaml import yaml
22
25
 
23
26
  if TYPE_CHECKING:
27
+ from qilisdk.core.variables import ComparisonTerm
24
28
  from qilisdk.cost_functions.cost_function import CostFunction
25
29
  from qilisdk.optimizers.optimizer import Optimizer
26
30
 
@@ -46,6 +50,7 @@ class VariationalProgram(Functional, Generic[TFunctional]):
46
50
  optimizer: Optimizer,
47
51
  cost_function: CostFunction,
48
52
  store_intermediate_results: bool = False,
53
+ parameter_constraints: list[ComparisonTerm] | None = None,
49
54
  ) -> None:
50
55
  """
51
56
  Args:
@@ -53,11 +58,29 @@ class VariationalProgram(Functional, Generic[TFunctional]):
53
58
  optimizer (Optimizer): Optimization routine controlling parameter updates.
54
59
  cost_function (CostFunction): Metric used to evaluate functional executions.
55
60
  store_intermediate_results (bool, optional): Persist intermediate executions if requested by the optimizer.
61
+ parameter_constraints (list[ComparisonTerm] | None): Optional constraints on parameter values that are
62
+ enforced before optimizer updates are applied.
63
+
64
+ Raises:
65
+ ValueError: if the user applies constraints on parameters that are not present in the variational program.
66
+ Or the constraints contain Objects that are not parameters.
56
67
  """
57
68
  self._functional = functional
58
69
  self._optimizer = optimizer
59
70
  self._cost_function = cost_function
60
71
  self._store_intermediate_results = store_intermediate_results
72
+ parameter_constraints = parameter_constraints or []
73
+ functional_params = self._functional.get_parameters()
74
+ for p in parameter_constraints:
75
+ if not p.lhs.is_parameterized_term() or not p.rhs.is_parameterized_term():
76
+ raise ValueError("Only parameters are allowed to be constrained.")
77
+ variables = p.variables()
78
+ for v in variables:
79
+ if v.label not in functional_params:
80
+ raise ValueError(
81
+ f"Writing a constraint on the parameter ({v}) that is not present in the variational program "
82
+ )
83
+ self._parameter_constraints = parameter_constraints
61
84
 
62
85
  @property
63
86
  def functional(self) -> TFunctional:
@@ -78,3 +101,38 @@ class VariationalProgram(Functional, Generic[TFunctional]):
78
101
  def store_intermediate_results(self) -> bool:
79
102
  """Indicate whether intermediate execution data should be stored."""
80
103
  return self._store_intermediate_results
104
+
105
+ def get_constraints(self) -> list[ComparisonTerm]:
106
+ """Return variational-program-level constraints plus those from the underlying functional."""
107
+ return self._parameter_constraints + self._functional.get_constraints()
108
+
109
+ def _check_constraints(self, parameters: dict[str, float]) -> list[bool]:
110
+ """Evaluate each constraint with a proposed parameter set.
111
+
112
+ Returns:
113
+ list[bool]: list of booleans that correspond to whether each constraint is satisfied or not.
114
+
115
+ Raises:
116
+ ValueError: if the parameter is not defined in the underlying functional.
117
+ """
118
+ params: list[BaseVariable] = functools.reduce(
119
+ operator.iadd, (con.variables() for con in self.get_constraints()), []
120
+ )
121
+ params = list(set(params))
122
+ if any(not isinstance(p, Parameter) for p in params):
123
+ raise ValueError("Only Parameters are allowed.")
124
+ params_dict = {p.label: p for p in params}
125
+ evaluate_dict: dict[BaseVariable, float] = {}
126
+ functional_params = self._functional.get_parameters()
127
+ for label, value in parameters.items():
128
+ if label not in functional_params:
129
+ raise ValueError(f"Parameter {label} is not defined in the functional.")
130
+ if label in params_dict:
131
+ evaluate_dict[params_dict[label]] = value
132
+ constraints = self.get_constraints()
133
+ return [con.evaluate(evaluate_dict) for con in constraints]
134
+
135
+ def check_parameter_constraints(self, parameters: dict[str, float]) -> int:
136
+ """Return a penalty-like score (0 if valid) indicating how many constraints are violated."""
137
+ const_list = self._check_constraints(parameters)
138
+ return sum((100 if not con else 0) for con in const_list)