qilisdk 0.1.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 (47) hide show
  1. qilisdk/__init__.py +47 -0
  2. qilisdk/__init__.pyi +30 -0
  3. qilisdk/_optionals.py +105 -0
  4. qilisdk/analog/__init__.py +17 -0
  5. qilisdk/analog/algorithms.py +111 -0
  6. qilisdk/analog/analog_backend.py +43 -0
  7. qilisdk/analog/analog_result.py +114 -0
  8. qilisdk/analog/exceptions.py +19 -0
  9. qilisdk/analog/hamiltonian.py +706 -0
  10. qilisdk/analog/quantum_objects.py +486 -0
  11. qilisdk/analog/schedule.py +311 -0
  12. qilisdk/common/__init__.py +20 -0
  13. qilisdk/common/algorithm.py +17 -0
  14. qilisdk/common/backend.py +16 -0
  15. qilisdk/common/model.py +16 -0
  16. qilisdk/common/optimizer.py +136 -0
  17. qilisdk/common/optimizer_result.py +110 -0
  18. qilisdk/common/result.py +17 -0
  19. qilisdk/digital/__init__.py +66 -0
  20. qilisdk/digital/ansatz.py +143 -0
  21. qilisdk/digital/circuit.py +106 -0
  22. qilisdk/digital/digital_algorithm.py +20 -0
  23. qilisdk/digital/digital_backend.py +90 -0
  24. qilisdk/digital/digital_result.py +145 -0
  25. qilisdk/digital/exceptions.py +31 -0
  26. qilisdk/digital/gates.py +989 -0
  27. qilisdk/digital/vqe.py +165 -0
  28. qilisdk/extras/__init__.py +13 -0
  29. qilisdk/extras/cuda/__init__.py +18 -0
  30. qilisdk/extras/cuda/cuda_analog_result.py +19 -0
  31. qilisdk/extras/cuda/cuda_backend.py +398 -0
  32. qilisdk/extras/cuda/cuda_digital_result.py +19 -0
  33. qilisdk/extras/qaas/__init__.py +13 -0
  34. qilisdk/extras/qaas/keyring.py +54 -0
  35. qilisdk/extras/qaas/models.py +57 -0
  36. qilisdk/extras/qaas/qaas_backend.py +154 -0
  37. qilisdk/extras/qaas/qaas_digital_result.py +20 -0
  38. qilisdk/extras/qaas/qaas_settings.py +23 -0
  39. qilisdk/py.typed +0 -0
  40. qilisdk/utils/__init__.py +27 -0
  41. qilisdk/utils/openqasm2.py +215 -0
  42. qilisdk/utils/serialization.py +128 -0
  43. qilisdk/yaml.py +71 -0
  44. qilisdk-0.1.0.dist-info/METADATA +237 -0
  45. qilisdk-0.1.0.dist-info/RECORD +47 -0
  46. qilisdk-0.1.0.dist-info/WHEEL +4 -0
  47. qilisdk-0.1.0.dist-info/licenses/LICENCE +201 -0
@@ -0,0 +1,706 @@
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
+ from __future__ import annotations
15
+
16
+ import copy
17
+ import re
18
+ from abc import ABC
19
+ from collections import defaultdict
20
+ from functools import reduce
21
+ from typing import TYPE_CHECKING, Callable, ClassVar
22
+
23
+ import numpy as np
24
+ from scipy.sparse import csc_array, identity, kron, spmatrix
25
+
26
+ from qilisdk.yaml import yaml
27
+
28
+ from .exceptions import InvalidHamiltonianOperation, NotSupportedOperation
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Iterator
32
+
33
+ Complex = int | float | complex
34
+
35
+
36
+ ###############################################################################
37
+ # Flyweight Cache
38
+ ###############################################################################
39
+ _OPERATOR_CACHE: dict[tuple[str, int], PauliOperator] = {}
40
+
41
+
42
+ def _get_pauli(name: str, qubit: int) -> PauliOperator:
43
+ key = (name, qubit)
44
+ if key in _OPERATOR_CACHE:
45
+ return _OPERATOR_CACHE[key]
46
+
47
+ if name == "Z":
48
+ op = PauliZ(qubit)
49
+ elif name == "X":
50
+ op = PauliX(qubit) # type: ignore[assignment]
51
+ elif name == "Y":
52
+ op = PauliY(qubit) # type: ignore[assignment]
53
+ elif name == "I":
54
+ op = PauliI(qubit) # type: ignore[assignment]
55
+ else:
56
+ raise ValueError(f"Unknown Pauli operator name: {name}")
57
+
58
+ _OPERATOR_CACHE[key] = op
59
+ return op
60
+
61
+
62
+ ###############################################################################
63
+ # Public Factory Functions
64
+ ###############################################################################
65
+ def Z(qubit: int) -> PauliOperator:
66
+ return _get_pauli("Z", qubit)
67
+
68
+
69
+ def X(qubit: int) -> PauliOperator:
70
+ return _get_pauli("X", qubit)
71
+
72
+
73
+ def Y(qubit: int) -> PauliOperator:
74
+ return _get_pauli("Y", qubit)
75
+
76
+
77
+ def I(qubit: int = 0) -> PauliOperator: # noqa: E743
78
+ return _get_pauli("I", qubit)
79
+
80
+
81
+ ###############################################################################
82
+ # Abstract Base PauliOperator
83
+ ###############################################################################
84
+ class PauliOperator(ABC):
85
+ """
86
+ A generic Pauli operator that acts on one qubit.
87
+ Flyweight usage: do NOT instantiate directly—use X(q), Y(q), etc.
88
+ """
89
+
90
+ _NAME: ClassVar[str]
91
+ _MATRIX: ClassVar[np.ndarray]
92
+
93
+ # __slots__ = ("_qubit",)
94
+
95
+ def __init__(self, qubit: int) -> None:
96
+ self._qubit = qubit
97
+
98
+ @property
99
+ def qubit(self) -> int:
100
+ return self._qubit
101
+
102
+ @property
103
+ def name(self) -> str:
104
+ return self._NAME
105
+
106
+ @property
107
+ def matrix(self) -> np.ndarray:
108
+ return self._MATRIX
109
+
110
+ def to_hamiltonian(self) -> Hamiltonian:
111
+ """Convert this single operator to a Hamiltonian with one term.
112
+
113
+ Returns:
114
+ Hamiltonian: The converted Hamiltonian.
115
+ """
116
+ return Hamiltonian({(self,): 1})
117
+
118
+ def __hash__(self) -> int:
119
+ return hash((self._NAME, self._qubit))
120
+
121
+ def __eq__(self, other: object) -> bool:
122
+ if not isinstance(other, PauliOperator):
123
+ return False
124
+ return (self._NAME == other._NAME) and (self._qubit == other._qubit)
125
+
126
+ def __repr__(self) -> str:
127
+ return f"{self.name}({self.qubit})"
128
+
129
+ def __str__(self) -> str:
130
+ return f"{self.name}({self.qubit})"
131
+
132
+ # ----------- Arithmetic Operators ------------
133
+
134
+ def __add__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
135
+ return self.to_hamiltonian() + other
136
+
137
+ __radd__ = __add__
138
+ __iadd__ = __add__
139
+
140
+ def __sub__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
141
+ return self.to_hamiltonian() - other
142
+
143
+ def __rsub__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
144
+ return other - self.to_hamiltonian()
145
+
146
+ __isub__ = __sub__
147
+
148
+ def __mul__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
149
+ return self.to_hamiltonian() * other
150
+
151
+ def __rmul__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
152
+ return other * self.to_hamiltonian()
153
+
154
+ __imul__ = __mul__
155
+
156
+ def __truediv__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
157
+ return self.to_hamiltonian() / other
158
+
159
+ def __rtruediv__(self, _: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
160
+ raise NotSupportedOperation("Division by operators is not supported")
161
+
162
+ __itruediv__ = __truediv__
163
+
164
+
165
+ ###############################################################################
166
+ # Concrete Flyweight Operator Classes
167
+ ###############################################################################
168
+ @yaml.register_class
169
+ class PauliZ(PauliOperator):
170
+ # __slots__ = ()
171
+ _NAME: ClassVar[str] = "Z"
172
+ _MATRIX: ClassVar[np.ndarray] = np.array([[1, 0], [0, -1]], dtype=complex)
173
+
174
+
175
+ @yaml.register_class
176
+ class PauliX(PauliOperator):
177
+ # __slots__ = ()
178
+ _NAME: ClassVar[str] = "X"
179
+ _MATRIX: ClassVar[np.ndarray] = np.array([[0, 1], [1, 0]], dtype=complex)
180
+
181
+
182
+ @yaml.register_class
183
+ class PauliY(PauliOperator):
184
+ # __slots__ = ()
185
+ _NAME: ClassVar[str] = "Y"
186
+ _MATRIX: ClassVar[np.ndarray] = np.array([[0, -1j], [1j, 0]], dtype=complex)
187
+
188
+
189
+ @yaml.register_class
190
+ class PauliI(PauliOperator):
191
+ # __slots__ = ()
192
+ _NAME: ClassVar[str] = "I"
193
+ _MATRIX: ClassVar[np.ndarray] = np.array([[1, 0], [0, 1]], dtype=complex)
194
+
195
+
196
+ @yaml.register_class
197
+ class Hamiltonian:
198
+ """
199
+ The `elements` dictionary now maps from a tuple of PauliOperator objects
200
+ to a complex coefficient. For example:
201
+ {
202
+ (Z(0), Y(1)): 1,
203
+ (X(1),): 1j,
204
+ }
205
+ """
206
+
207
+ _EPS: float = 1e-14
208
+ _PAULI_PRODUCT_TABLE: ClassVar[dict[tuple[str, str], tuple[complex, Callable[..., PauliOperator]]]] = {
209
+ ("X", "X"): (1, I),
210
+ ("X", "Y"): (1j, Z),
211
+ ("X", "Z"): (-1j, Y),
212
+ ("Y", "X"): (-1j, Z),
213
+ ("Y", "Y"): (1, I),
214
+ ("Y", "Z"): (1j, X),
215
+ ("Z", "X"): (1j, Y),
216
+ ("Z", "Y"): (-1j, X),
217
+ ("Z", "Z"): (1, I),
218
+ }
219
+
220
+ ZERO: int = 0
221
+
222
+ def __init__(self, elements: dict[tuple[PauliOperator, ...], complex] | None = None) -> None:
223
+ self._elements: dict[tuple[PauliOperator, ...], complex] = defaultdict(complex)
224
+ if elements:
225
+ for key, val in elements.items():
226
+ self._elements[key] += val
227
+ self.simplify()
228
+
229
+ @property
230
+ def nqubits(self) -> int:
231
+ """Number of qubits acting on the hamiltonian."""
232
+ qubits = {op.qubit for key in self._elements for op in key}
233
+
234
+ return max(qubits) + 1 if qubits else 0
235
+
236
+ @property
237
+ def elements(self) -> dict[tuple[PauliOperator, ...], complex]:
238
+ """Returns the internal dictionary of elements (read-only)."""
239
+ return self._elements
240
+
241
+ def simplify(self) -> Hamiltonian:
242
+ # 1) Remove near-zero
243
+ keys_to_remove = [key for key, value in self._elements.items() if abs(value) < Hamiltonian._EPS]
244
+ for key in keys_to_remove:
245
+ del self._elements[key]
246
+
247
+ # 2) Accumulate identities that do NOT act on qubit=0 => I(0)
248
+ to_accumulate = [
249
+ (key, value)
250
+ for key, value in self._elements.items()
251
+ if len(key) == 1 and key[0].name == "I" and key[0].qubit != 0
252
+ ]
253
+ for key, value in to_accumulate:
254
+ del self._elements[key]
255
+ self._elements[I(0),] += value
256
+
257
+ return self
258
+
259
+ def _apply_operator_on_qubit(self, terms: list[PauliOperator]) -> spmatrix:
260
+ """Get the matrix representation of a single term by taking the tensor product
261
+ of operators acting on each qubit. For qubits with no operator in `terms`,
262
+ the identity is used.
263
+
264
+ Args:
265
+ terms (list[PauliOperator]): A list of Pauli operators in the term.
266
+
267
+ Returns:
268
+ spmatrix: The full matrix representation of the term.
269
+ """
270
+ # Build a list of factors for each qubit
271
+ factors = []
272
+ for q in range(self.nqubits):
273
+ # Look for an operator acting on qubit q
274
+ op = next((t for t in terms if t.qubit == q), None)
275
+ if op is not None:
276
+ # Wrap the operator's matrix as a sparse matrix.
277
+ factors.append(csc_array(np.array(op.matrix)))
278
+ else:
279
+ factors.append(identity(2, format="csc"))
280
+ # Compute the tensor (Kronecker) product over all qubits.
281
+ full_matrix = reduce(lambda A, B: kron(A, B, format="csc"), factors)
282
+ return full_matrix
283
+
284
+ def to_matrix(self) -> spmatrix:
285
+ """Return the full matrix representation of the Hamiltonian by summing over all terms.
286
+
287
+ Returns:
288
+ spmatrix: The sparse matrix representation of the Hamiltonian.
289
+ """
290
+ dim = 2**self.nqubits
291
+ # Initialize a zero matrix of the appropriate dimension.
292
+ result = csc_array(np.zeros((dim, dim), dtype=complex))
293
+ for coeff, term in self:
294
+ result += coeff * self._apply_operator_on_qubit(term)
295
+ return result
296
+
297
+ def __iter__(self) -> Iterator[tuple[complex, list[PauliOperator]]]:
298
+ for key, value in self._elements.items():
299
+ yield value, list(key)
300
+
301
+ # ------- Equality & hashing --------
302
+
303
+ def __eq__(self, other: object) -> bool:
304
+ if other == Hamiltonian.ZERO:
305
+ return bool(
306
+ len(self._elements) == 0
307
+ or (len(self._elements) == 1 and (I(0),) in self._elements and self._elements[I(0),] == 0)
308
+ )
309
+ if isinstance(other, Complex):
310
+ return bool(len(self._elements) == 1 and (I(0),) in self._elements and self._elements[I(0),] == other)
311
+ if isinstance(other, PauliOperator):
312
+ other = other.to_hamiltonian()
313
+ if not isinstance(other, Hamiltonian):
314
+ return False
315
+ return dict(self._elements) == dict(other._elements)
316
+
317
+ def __ne__(self, other: object) -> bool:
318
+ return not self.__eq__(other)
319
+
320
+ def __hash__(self) -> int:
321
+ items_frozen = frozenset(self._elements.items())
322
+ return hash(items_frozen)
323
+
324
+ def __copy__(self) -> Hamiltonian:
325
+ return Hamiltonian(elements=self.elements.copy())
326
+
327
+ # ------- String representation --------
328
+
329
+ def __repr__(self) -> str:
330
+ return str(self)
331
+
332
+ def __str__(self) -> str:
333
+ # Return "0" if there are no terms
334
+ if not self._elements:
335
+ return "0"
336
+
337
+ def _format_coeff(c: complex) -> str:
338
+ re, im = c.real, c.imag
339
+
340
+ # 1) Purely real?
341
+ if abs(im) < Hamiltonian._EPS:
342
+ re_int = np.round(re)
343
+ if abs(re - re_int) < Hamiltonian._EPS:
344
+ return str(int(re_int)) # e.g. '2' instead of '2.0'
345
+ return str(re) # e.g. '2.5'
346
+
347
+ # 2) Purely imaginary?
348
+ if abs(re) < Hamiltonian._EPS:
349
+ im_int = np.round(im)
350
+ if abs(im - im_int) < Hamiltonian._EPS:
351
+ return f"{int(im_int)}j" # e.g. 2 => '2j', -3 => '-3j'
352
+ return f"{im}j" # e.g. '2.5j'
353
+
354
+ # 3) General complex with nonzero real & imag
355
+ s = str(c) # e.g. '(3+2j)'
356
+ return s
357
+
358
+ # We want to place the single identity term (I(0),) at the front if it exists
359
+ items = list(self._elements.items())
360
+ try:
361
+ i = next(idx for idx, (key, _) in enumerate(items) if len(key) == 1 and key[0] == (I(0)))
362
+ item = items.pop(i)
363
+ items.insert(0, item)
364
+ except StopIteration:
365
+ pass
366
+
367
+ parts = []
368
+ for idx, (operator, coeff) in enumerate(items):
369
+ base_str = _format_coeff(coeff)
370
+
371
+ if idx == 0:
372
+ # first term
373
+ if len(operator) == 1 and operator[0].name == "I":
374
+ coeff_str = base_str
375
+ elif base_str == "1":
376
+ coeff_str = ""
377
+ elif base_str == "-1":
378
+ coeff_str = "-"
379
+ else:
380
+ coeff_str = base_str
381
+ elif base_str == "1":
382
+ coeff_str = "+"
383
+ elif base_str == "-1":
384
+ coeff_str = "-"
385
+ elif base_str.startswith("-"):
386
+ coeff_str = f"- {base_str[1:]}"
387
+ else:
388
+ coeff_str = f"+ {base_str}"
389
+
390
+ # Operators string
391
+ ops_str = " ".join(str(op) for op in operator if op.name != "I")
392
+ if coeff_str and ops_str:
393
+ parts.append(f"{coeff_str} {ops_str}")
394
+ else:
395
+ parts.append(coeff_str + ops_str)
396
+
397
+ return " ".join(parts)
398
+
399
+ @classmethod
400
+ def parse(cls, ham_str: str) -> Hamiltonian:
401
+ ham_str = ham_str.strip()
402
+ # Special case: "0" => empty Hamiltonian
403
+ if ham_str == "0":
404
+ return cls({})
405
+
406
+ elements: dict[tuple[PauliOperator, ...], complex] = defaultdict(complex)
407
+
408
+ # If there's no initial +/- sign, prepend '+ ' for easier splitting
409
+ if not ham_str.startswith("+") and not ham_str.startswith("-"):
410
+ ham_str = "+ " + ham_str
411
+
412
+ # Replace " - " with " + - " so each term is split on " + "
413
+ ham_str = ham_str.replace(" - ", " + - ")
414
+
415
+ # Split on " + "
416
+ tokens = ham_str.split(" + ")
417
+ # Remove any empty tokens (can happen if the string started "+ ")
418
+ tokens = [t.strip() for t in tokens if t.strip()]
419
+
420
+ # Regex to match operator tokens like "Z(0)", "X(1)", "I(0)"
421
+ operator_pattern = re.compile(r"([XYZI])\((\d+)\)")
422
+
423
+ def parse_token(token: str) -> tuple[complex, list[PauliOperator]]:
424
+ def looks_like_number(text: str) -> bool:
425
+ # If it's empty, it's not a number
426
+ if not text:
427
+ return False
428
+ # If the first char is digit, '(', '.', '+', '-', or '0',
429
+ # or if 'j' is present, assume it's numeric
430
+ first = text[0]
431
+ if first.isdigit() or first in {"(", ".", "+", "-"}:
432
+ return True
433
+ return "j" in text
434
+
435
+ sign = 1
436
+ # Check leading sign
437
+ if token.startswith("-"):
438
+ sign = -1
439
+ token = token[1:].strip()
440
+ elif token.startswith("+"):
441
+ # optional leading '+'
442
+ token = token[1:].strip()
443
+
444
+ words = token.split()
445
+ if not words:
446
+ # e.g. just "-" or "+"
447
+ # means coefficient = ±1, no operators
448
+ return complex(sign), []
449
+
450
+ # Attempt to parse the first word as a numeric coefficient
451
+ maybe_coeff = words[0]
452
+ # Decide if 'maybe_coeff' is numeric or an operator
453
+ if looks_like_number(maybe_coeff):
454
+ # parse as a complex number
455
+ coeff_str = maybe_coeff
456
+ # If it's e.g. '(2.5+3j)', remove parentheses
457
+ if coeff_str.startswith("(") and coeff_str.endswith(")"):
458
+ coeff_str = coeff_str[1:-1]
459
+ coeff_val = complex(coeff_str) * sign
460
+ words = words[1:] # consume this word
461
+ else:
462
+ # No explicit coefficient => ±1
463
+ coeff_val = complex(sign)
464
+
465
+ # Now parse the remaining words as operators
466
+ ops = []
467
+ for w in words:
468
+ match = operator_pattern.fullmatch(w)
469
+ if not match:
470
+ raise ValueError(f"Unrecognized operator format: '{w}'")
471
+ name, qubit_str = match.groups()
472
+ qubit = int(qubit_str)
473
+ op = _get_pauli(name, qubit)
474
+ ops.append(op)
475
+
476
+ return coeff_val, ops
477
+
478
+ for tok in tokens:
479
+ coeff, op_list = parse_token(tok)
480
+ if not op_list:
481
+ # purely scalar => store as (I(0),)
482
+ elements[I(0),] += coeff
483
+ else:
484
+ # Sort operators by qubit for canonical ordering
485
+ op_list.sort(key=lambda op: op.qubit)
486
+ elements[tuple(op_list)] += coeff
487
+
488
+ ham = cls(elements)
489
+ ham.simplify()
490
+ return ham
491
+
492
+ # ------- Internal multiplication helpers --------
493
+
494
+ @staticmethod
495
+ def _multiply_sets(
496
+ set1: tuple[PauliOperator, ...], set2: tuple[PauliOperator, ...]
497
+ ) -> tuple[complex, tuple[PauliOperator, ...]]:
498
+ # Combine all operators into a single list
499
+ combined = list(set1) + list(set2)
500
+
501
+ # Group by qubit
502
+ combined.sort(key=lambda op: op.qubit)
503
+ sum_dict: dict[int, list[PauliOperator]] = defaultdict(list)
504
+ for op in combined:
505
+ sum_dict[op.qubit].append(op)
506
+
507
+ accumulated_phase = complex(1)
508
+ final_ops: list[PauliOperator] = []
509
+
510
+ for qubit_ops in sum_dict.values():
511
+ op1 = qubit_ops[0]
512
+ phase = complex(1)
513
+ # Multiply together all operators on the same qubit
514
+ for op2 in qubit_ops[1:]:
515
+ aux_phase, op1 = Hamiltonian._multiply_pauli(op1, op2)
516
+ phase *= aux_phase
517
+ if op1.name != "I":
518
+ final_ops.append(op1)
519
+ accumulated_phase *= phase
520
+
521
+ # If everything simplified to identity, we store I(0)
522
+ if not final_ops:
523
+ final_ops = [I(0)]
524
+
525
+ # Sort again by qubit (to keep canonical form)
526
+ final_ops.sort(key=lambda op: op.qubit)
527
+ return accumulated_phase, tuple(final_ops)
528
+
529
+ @staticmethod
530
+ def _multiply_pauli(op1: PauliOperator, op2: PauliOperator) -> tuple[complex, PauliOperator]:
531
+ if op1.qubit != op2.qubit:
532
+ raise ValueError("Operators must act on the same qubit for multiplication.")
533
+
534
+ # If either is identity, no phase
535
+ if op1.name == "I":
536
+ return (1, op2)
537
+ if op2.name == "I":
538
+ return (1, op1)
539
+
540
+ # Look up the product in the table
541
+ key = (op1.name, op2.name)
542
+ result = Hamiltonian._PAULI_PRODUCT_TABLE.get(key)
543
+ if result is None:
544
+ raise NotSupportedOperation(f"Multiplying {op1} and {op2} not supported.")
545
+ phase, op_cls = result
546
+
547
+ # By convention, an I operator is always I(0) in this code
548
+ if op_cls is I:
549
+ return phase, I(0)
550
+ # Otherwise, keep the same qubit
551
+ return phase, op_cls(op1.qubit)
552
+
553
+ @staticmethod
554
+ def _multiply_hamiltonians(h1: Hamiltonian, h2: Hamiltonian) -> Hamiltonian:
555
+ out = Hamiltonian()
556
+ for key1, c1 in h1.elements.items():
557
+ for key2, c2 in h2.elements.items():
558
+ phase, new_key = Hamiltonian._multiply_sets(key1, key2)
559
+ out.elements[new_key] += phase * c1 * c2
560
+ return out.simplify()
561
+
562
+ # ------- Public arithmetic operators --------
563
+
564
+ def __add__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
565
+ out = copy.copy(self)
566
+ out._add_inplace(other)
567
+ return out.simplify()
568
+
569
+ def __radd__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
570
+ return self.__add__(other)
571
+
572
+ def __sub__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
573
+ out = copy.copy(self)
574
+ out._sub_inplace(other)
575
+ return out.simplify()
576
+
577
+ def __rsub__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
578
+ # (other - self)
579
+ out = copy.copy(other if isinstance(other, Hamiltonian) else Hamiltonian() + other)
580
+ out._sub_inplace(self)
581
+ return out.simplify()
582
+
583
+ def __mul__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
584
+ out = copy.copy(self)
585
+ out._mul_inplace(other)
586
+ return out.simplify()
587
+
588
+ def __rmul__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
589
+ if isinstance(other, Hamiltonian):
590
+ out = copy.copy(other)
591
+ out._mul_inplace(self)
592
+ return out.simplify()
593
+ return self.__mul__(other)
594
+
595
+ def __truediv__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
596
+ out = copy.copy(self)
597
+ out._div_inplace(other)
598
+ return out.simplify()
599
+
600
+ def __rtruediv__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
601
+ # (other / self)
602
+ if not isinstance(other, (int, float, complex)):
603
+ raise InvalidHamiltonianOperation("Division by operators is not supported")
604
+ out = copy.copy(self)
605
+ return (1 / other) * out
606
+
607
+ def __rfloordiv__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
608
+ if not isinstance(other, (int, float, complex)):
609
+ raise NotSupportedOperation("Division of operators is not supported")
610
+ return (1 / other) * self
611
+
612
+ __iadd__ = __add__
613
+ __isub__ = __sub__
614
+ __imul__ = __mul__
615
+ __itruediv__ = __truediv__
616
+
617
+ def _add_inplace(self, other: Complex | PauliOperator | Hamiltonian) -> None:
618
+ if isinstance(other, Hamiltonian):
619
+ # If it's empty, do nothing
620
+ if not other.elements:
621
+ return
622
+ # Otherwise, add each term
623
+ for key, val in other.elements.items():
624
+ self._elements[key] += val
625
+ elif isinstance(other, PauliOperator):
626
+ # Just add 1 to that single operator key
627
+ self._elements[other,] += 1
628
+ elif isinstance(other, (int, float, complex)):
629
+ if other == 0:
630
+ return
631
+ # Add the scalar to (I(0),)
632
+ self._elements[I(0),] += other
633
+ else:
634
+ raise InvalidHamiltonianOperation(f"Invalid addition between Hamiltonian and {other.__class__.__name__}.")
635
+
636
+ def _sub_inplace(self, other: Complex | PauliOperator | Hamiltonian) -> None:
637
+ if isinstance(other, Hamiltonian):
638
+ for key, val in other.elements.items():
639
+ self._elements[key] -= val
640
+ elif isinstance(other, PauliOperator):
641
+ self._elements[other,] -= 1
642
+ elif isinstance(other, (int, float, complex)):
643
+ if other == 0:
644
+ return
645
+ self._elements[I(0),] -= other
646
+ else:
647
+ raise InvalidHamiltonianOperation(
648
+ f"Invalid subtraction between Hamiltonian and {other.__class__.__name__}."
649
+ )
650
+
651
+ def _mul_inplace(self, other: Complex | PauliOperator | Hamiltonian) -> None:
652
+ if isinstance(other, (int, float, complex)):
653
+ # 0 short-circuit
654
+ if other == 0:
655
+ # everything becomes 0
656
+ self._elements.clear()
657
+ return None
658
+ # 1 short-circuit
659
+ if other == 1:
660
+ return None
661
+ # scale all coefficients
662
+ for k in self._elements:
663
+ self._elements[k] *= other
664
+ return None
665
+
666
+ if isinstance(other, PauliOperator):
667
+ # Convert single PauliOperator -> Hamiltonian with 1 key
668
+ # Then do the single-key Hamiltonian path below
669
+ other = other.to_hamiltonian()
670
+
671
+ if isinstance(other, Hamiltonian):
672
+ if not other.elements:
673
+ # Multiply by "0" Hamiltonian => 0
674
+ self._elements.clear()
675
+ return None
676
+
677
+ # Check if 'other' is purely scalar identity => short-circuit
678
+ if len(other.elements) == 1:
679
+ ((ops2, c2),) = other.elements.items() # single item
680
+ if len(ops2) == 1:
681
+ op2 = ops2[0]
682
+ if op2.name == "I" and op2.qubit == 0:
683
+ # effectively scalar c2
684
+ return self._mul_inplace(c2)
685
+
686
+ # Otherwise, we do the general multiply
687
+ new_dict: dict[tuple[PauliOperator, ...], complex] = defaultdict(complex)
688
+ for ops1, c1 in self._elements.items():
689
+ for ops2, c2 in other.elements.items():
690
+ phase, new_ops = self._multiply_sets(ops1, ops2)
691
+ new_dict[new_ops] += phase * c1 * c2
692
+ self._elements = new_dict
693
+
694
+ else:
695
+ raise InvalidHamiltonianOperation(
696
+ f"Invalid multiplication between Hamiltonian and {other.__class__.__name__}."
697
+ )
698
+ return None
699
+
700
+ def _div_inplace(self, other: Complex | PauliOperator | Hamiltonian) -> None:
701
+ # Only valid for scalars
702
+ if not isinstance(other, (int, float, complex)):
703
+ raise InvalidHamiltonianOperation("Division by operators is not supported")
704
+ if other == 0:
705
+ raise ZeroDivisionError("Cannot divide by zero.")
706
+ self._mul_inplace(1 / other)