qilisdk 0.1.4__py3-none-any.whl → 0.1.5__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 (83) hide show
  1. qilisdk/__init__.py +11 -2
  2. qilisdk/__init__.pyi +2 -3
  3. qilisdk/_logging.py +135 -0
  4. qilisdk/_optionals.py +5 -7
  5. qilisdk/analog/__init__.py +3 -18
  6. qilisdk/analog/exceptions.py +2 -4
  7. qilisdk/analog/hamiltonian.py +455 -110
  8. qilisdk/analog/linear_schedule.py +118 -0
  9. qilisdk/analog/schedule.py +272 -79
  10. qilisdk/backends/__init__.py +45 -0
  11. qilisdk/{digital/digital_algorithm.py → backends/__init__.pyi} +3 -5
  12. qilisdk/backends/backend.py +117 -0
  13. qilisdk/{extras/cuda → backends}/cuda_backend.py +152 -159
  14. qilisdk/backends/qutip_backend.py +492 -0
  15. qilisdk/common/__init__.py +48 -2
  16. qilisdk/common/algorithm.py +2 -1
  17. qilisdk/{extras/qaas/qaas_settings.py → common/exceptions.py} +12 -6
  18. qilisdk/common/model.py +1019 -1
  19. qilisdk/common/parameterizable.py +75 -0
  20. qilisdk/common/qtensor.py +666 -0
  21. qilisdk/common/result.py +2 -1
  22. qilisdk/common/variables.py +1931 -0
  23. qilisdk/{extras/cuda/cuda_analog_result.py → cost_functions/__init__.py} +3 -4
  24. qilisdk/cost_functions/cost_function.py +77 -0
  25. qilisdk/cost_functions/model_cost_function.py +145 -0
  26. qilisdk/cost_functions/observable_cost_function.py +109 -0
  27. qilisdk/digital/__init__.py +3 -22
  28. qilisdk/digital/ansatz.py +203 -160
  29. qilisdk/digital/circuit.py +81 -9
  30. qilisdk/digital/exceptions.py +12 -6
  31. qilisdk/digital/gates.py +228 -85
  32. qilisdk/{extras/qaas/qaas_analog_result.py → functionals/__init__.py} +14 -5
  33. qilisdk/functionals/functional.py +39 -0
  34. qilisdk/{extras/cuda/cuda_digital_result.py → functionals/functional_result.py} +3 -4
  35. qilisdk/functionals/sampling.py +81 -0
  36. qilisdk/functionals/sampling_result.py +92 -0
  37. qilisdk/functionals/time_evolution.py +98 -0
  38. qilisdk/functionals/time_evolution_result.py +84 -0
  39. qilisdk/functionals/variational_program.py +80 -0
  40. qilisdk/functionals/variational_program_result.py +69 -0
  41. qilisdk/logging_config.yaml +16 -0
  42. qilisdk/{common/backend.py → optimizers/__init__.py} +2 -1
  43. qilisdk/optimizers/optimizer.py +39 -0
  44. qilisdk/{common → optimizers}/optimizer_result.py +3 -12
  45. qilisdk/{common/optimizer.py → optimizers/scipy_optimizer.py} +10 -28
  46. qilisdk/settings.py +78 -0
  47. qilisdk/{extras → speqtrum}/__init__.py +7 -8
  48. qilisdk/{extras → speqtrum}/__init__.pyi +3 -3
  49. qilisdk/speqtrum/experiments/__init__.py +25 -0
  50. qilisdk/speqtrum/experiments/experiment_functional.py +124 -0
  51. qilisdk/speqtrum/experiments/experiment_result.py +231 -0
  52. qilisdk/{extras/qaas → speqtrum}/keyring.py +8 -4
  53. qilisdk/speqtrum/speqtrum.py +432 -0
  54. qilisdk/speqtrum/speqtrum_models.py +300 -0
  55. qilisdk/utils/__init__.py +0 -14
  56. qilisdk/utils/openqasm2.py +1 -1
  57. qilisdk/utils/serialization.py +1 -1
  58. qilisdk/utils/visualization/PlusJakartaSans-SemiBold.ttf +0 -0
  59. qilisdk/utils/visualization/__init__.py +24 -0
  60. qilisdk/utils/visualization/circuit_renderers.py +781 -0
  61. qilisdk/utils/visualization/schedule_renderers.py +161 -0
  62. qilisdk/utils/visualization/style.py +154 -0
  63. qilisdk/utils/visualization/themes.py +76 -0
  64. qilisdk/yaml.py +126 -0
  65. {qilisdk-0.1.4.dist-info → qilisdk-0.1.5.dist-info}/METADATA +180 -134
  66. qilisdk-0.1.5.dist-info/RECORD +69 -0
  67. qilisdk/analog/algorithms.py +0 -111
  68. qilisdk/analog/analog_backend.py +0 -43
  69. qilisdk/analog/analog_result.py +0 -114
  70. qilisdk/analog/quantum_objects.py +0 -596
  71. qilisdk/digital/digital_backend.py +0 -90
  72. qilisdk/digital/digital_result.py +0 -145
  73. qilisdk/digital/vqe.py +0 -166
  74. qilisdk/extras/cuda/__init__.py +0 -13
  75. qilisdk/extras/qaas/__init__.py +0 -13
  76. qilisdk/extras/qaas/models.py +0 -132
  77. qilisdk/extras/qaas/qaas_backend.py +0 -255
  78. qilisdk/extras/qaas/qaas_digital_result.py +0 -20
  79. qilisdk/extras/qaas/qaas_time_evolution_result.py +0 -20
  80. qilisdk/extras/qaas/qaas_vqe_result.py +0 -20
  81. qilisdk-0.1.4.dist-info/RECORD +0 -51
  82. {qilisdk-0.1.4.dist-info → qilisdk-0.1.5.dist-info}/WHEEL +0 -0
  83. {qilisdk-0.1.4.dist-info → qilisdk-0.1.5.dist-info}/licenses/LICENCE +0 -0
@@ -18,19 +18,24 @@ import re
18
18
  from abc import ABC
19
19
  from collections import defaultdict
20
20
  from functools import reduce
21
+ from itertools import product
21
22
  from typing import TYPE_CHECKING, Callable, ClassVar
22
23
 
23
24
  import numpy as np
24
- from scipy.sparse import csc_array, identity, kron, spmatrix
25
+ from scipy.sparse import csr_matrix, identity, kron, spmatrix
25
26
 
27
+ from qilisdk.common.parameterizable import Parameterizable
28
+ from qilisdk.common.qtensor import QTensor
29
+ from qilisdk.common.variables import BaseVariable, Parameter, Term
26
30
  from qilisdk.yaml import yaml
27
31
 
28
- from .exceptions import InvalidHamiltonianOperation, NotSupportedOperation
32
+ from .exceptions import InvalidHamiltonianOperation
29
33
 
30
34
  if TYPE_CHECKING:
31
35
  from collections.abc import Iterator
32
36
 
33
- Complex = int | float | complex
37
+
38
+ Number = int | float | complex
34
39
 
35
40
 
36
41
  ###############################################################################
@@ -62,20 +67,20 @@ def _get_pauli(name: str, qubit: int) -> PauliOperator:
62
67
  ###############################################################################
63
68
  # Public Factory Functions
64
69
  ###############################################################################
65
- def Z(qubit: int) -> PauliOperator:
66
- return _get_pauli("Z", qubit)
70
+ def Z(qubit: int) -> Hamiltonian:
71
+ return _get_pauli("Z", qubit).to_hamiltonian()
67
72
 
68
73
 
69
- def X(qubit: int) -> PauliOperator:
70
- return _get_pauli("X", qubit)
74
+ def X(qubit: int) -> Hamiltonian:
75
+ return _get_pauli("X", qubit).to_hamiltonian()
71
76
 
72
77
 
73
- def Y(qubit: int) -> PauliOperator:
74
- return _get_pauli("Y", qubit)
78
+ def Y(qubit: int) -> Hamiltonian:
79
+ return _get_pauli("Y", qubit).to_hamiltonian()
75
80
 
76
81
 
77
- def I(qubit: int = 0) -> PauliOperator: # noqa: E743
78
- return _get_pauli("I", qubit)
82
+ def I(qubit: int = 0) -> Hamiltonian: # noqa: E743
83
+ return _get_pauli("I", qubit).to_hamiltonian()
79
84
 
80
85
 
81
86
  ###############################################################################
@@ -83,8 +88,18 @@ def I(qubit: int = 0) -> PauliOperator: # noqa: E743
83
88
  ###############################################################################
84
89
  class PauliOperator(ABC):
85
90
  """
86
- A generic Pauli operator that acts on one qubit.
87
- Flyweight usage: do NOT instantiate directly—use X(q), Y(q), etc.
91
+ A generic abstract Pauli operator that acts on one qubit.
92
+
93
+ Example:
94
+ .. code-block:: python
95
+
96
+ from qilisdk.analog import PauliX
97
+
98
+ op = PauliX(0)
99
+
100
+ Flyweight usage: do NOT instantiate directly—use PauliX(q), PauliY(q), etc.
101
+
102
+ Note: You can also use the factory functions X(q), Y(q), Z(q), I(q) to get a Hamiltonian object.
88
103
  """
89
104
 
90
105
  _NAME: ClassVar[str]
@@ -131,33 +146,33 @@ class PauliOperator(ABC):
131
146
 
132
147
  # ----------- Arithmetic Operators ------------
133
148
 
134
- def __add__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
149
+ def __add__(self, other: Number | PauliOperator | Hamiltonian) -> Hamiltonian:
135
150
  return self.to_hamiltonian() + other
136
151
 
137
152
  __radd__ = __add__
138
153
  __iadd__ = __add__
139
154
 
140
- def __sub__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
155
+ def __sub__(self, other: Number | PauliOperator | Hamiltonian) -> Hamiltonian:
141
156
  return self.to_hamiltonian() - other
142
157
 
143
- def __rsub__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
158
+ def __rsub__(self, other: Number | PauliOperator | Hamiltonian) -> Hamiltonian:
144
159
  return other - self.to_hamiltonian()
145
160
 
146
161
  __isub__ = __sub__
147
162
 
148
- def __mul__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
163
+ def __mul__(self, other: Number | PauliOperator | Hamiltonian) -> Hamiltonian:
149
164
  return self.to_hamiltonian() * other
150
165
 
151
- def __rmul__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
166
+ def __rmul__(self, other: Number | PauliOperator | Hamiltonian) -> Hamiltonian:
152
167
  return other * self.to_hamiltonian()
153
168
 
154
169
  __imul__ = __mul__
155
170
 
156
- def __truediv__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
171
+ def __truediv__(self, other: Number | PauliOperator | Hamiltonian) -> Hamiltonian:
157
172
  return self.to_hamiltonian() / other
158
173
 
159
- def __rtruediv__(self, _: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
160
- raise NotSupportedOperation("Division by operators is not supported")
174
+ def __rtruediv__(self, _: Number | PauliOperator | Hamiltonian) -> Hamiltonian:
175
+ raise InvalidHamiltonianOperation("Division by operators is not supported")
161
176
 
162
177
  __itruediv__ = __truediv__
163
178
 
@@ -194,53 +209,174 @@ class PauliI(PauliOperator):
194
209
 
195
210
 
196
211
  @yaml.register_class
197
- class Hamiltonian:
212
+ class Hamiltonian(Parameterizable):
198
213
  """
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
- }
214
+ Represent a Hamiltonian expressed as a linear combination of Pauli operators.
215
+
216
+ Example:
217
+ .. code-block:: python
218
+
219
+ from qilisdk.analog.hamiltonian import Hamiltonian, X, Z
220
+
221
+ H = X(0) * X(1) + Z(1)
205
222
  """
206
223
 
207
224
  _EPS: float = 1e-14
208
225
  _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),
226
+ ("X", "X"): (1, PauliI),
227
+ ("X", "Y"): (1j, PauliZ),
228
+ ("X", "Z"): (-1j, PauliY),
229
+ ("Y", "X"): (-1j, PauliZ),
230
+ ("Y", "Y"): (1, PauliI),
231
+ ("Y", "Z"): (1j, PauliX),
232
+ ("Z", "X"): (1j, PauliY),
233
+ ("Z", "Y"): (-1j, PauliX),
234
+ ("Z", "Z"): (1, PauliI),
218
235
  }
219
236
 
220
237
  ZERO: int = 0
221
238
 
222
- def __init__(self, elements: dict[tuple[PauliOperator, ...], complex] | None = None) -> None:
223
- self._elements: dict[tuple[PauliOperator, ...], complex] = defaultdict(complex)
239
+ def __init__(self, elements: dict[tuple[PauliOperator, ...], complex | Term | Parameter] | None = None) -> None:
240
+ """
241
+ Build a Hamiltonian from a mapping of Pauli operator products to coefficients.
242
+
243
+ Args:
244
+ elements (dict[tuple[PauliOperator, ...], complex | Term | Parameter], optional):
245
+ Mapping from operator tuples to numerical coefficients or symbolic parameters. For example:
246
+
247
+ .. code-block:: python
248
+
249
+ {
250
+ (Z(0), Y(1)): 1.0,
251
+ (X(1),): 1j,
252
+ }
253
+
254
+ Defaults to None, which creates an empty Hamiltonian.
255
+
256
+ Raises:
257
+ ValueError: If the provided coefficients include generic variables instead of parameters.
258
+ """
259
+ self._elements: dict[tuple[PauliOperator, ...], complex | Term | Parameter] = defaultdict(complex)
260
+ self._parameters: dict[str, Parameter] = {}
224
261
  if elements:
225
262
  for key, val in elements.items():
263
+ if isinstance(val, Term):
264
+ for v in val.variables():
265
+ if isinstance(v, Parameter):
266
+ self._parameters[v.label] = v
267
+ else:
268
+ raise ValueError(
269
+ "Only Parameters are allowed to be used in hamiltonians. Generic Variables are not supported"
270
+ )
271
+ elif isinstance(val, BaseVariable):
272
+ if isinstance(val, Parameter):
273
+ self._parameters[val.label] = val
274
+
275
+ else:
276
+ raise ValueError(
277
+ "Only Parameters are allowed to be used in hamiltonians. Generic Variables are not supported"
278
+ )
226
279
  self._elements[key] += val
227
280
  self.simplify()
228
281
 
229
282
  @property
230
283
  def nqubits(self) -> int:
231
- """Number of qubits acting on the hamiltonian."""
284
+ """Number of qubits on which the Hamiltonian acts."""
232
285
  qubits = {op.qubit for key in self._elements for op in key}
233
286
 
234
287
  return max(qubits) + 1 if qubits else 0
235
288
 
236
289
  @property
237
290
  def elements(self) -> dict[tuple[PauliOperator, ...], complex]:
238
- """Returns the internal dictionary of elements (read-only)."""
239
- return self._elements
291
+ """Return the stored operator-coefficient mapping with symbolic terms evaluated."""
292
+ return {
293
+ k: (v if isinstance(v, complex) else (v.evaluate({}) if isinstance(v, Term) else v.evaluate()))
294
+ for k, v in self._elements.items()
295
+ }
296
+
297
+ @property
298
+ def nparameters(self) -> int:
299
+ """Return the number of unique symbolic parameters contained in the Hamiltonian."""
300
+ return len(self._parameters)
301
+
302
+ @property
303
+ def parameters(self) -> dict[str, Parameter]:
304
+ """Return a mapping from parameter labels to their corresponding parameter objects."""
305
+ return self._parameters
306
+
307
+ def get_parameter_values(self) -> list[float]:
308
+ """Return the current numeric values of the Hamiltonian parameters."""
309
+ return [param.value for param in self._parameters.values()]
310
+
311
+ def get_parameter_names(self) -> list[str]:
312
+ """Return the ordered list of parameter labels defined in the Hamiltonian."""
313
+ return list(self._parameters.keys())
314
+
315
+ def get_parameters(self) -> dict[str, float]:
316
+ """Return a mapping from parameter labels to their current numerical values."""
317
+ return {label: param.value for label, param in self._parameters.items()}
318
+
319
+ def set_parameter_values(self, values: list[float]) -> None:
320
+ """
321
+ Update the numerical values of the Hamiltonian parameters.
322
+
323
+ Args:
324
+ values (list[float]): New values ordered according to ``get_parameter_names()``.
325
+
326
+ Raises:
327
+ ValueError: If the number of provided values does not match ``nparameters``.
328
+ """
329
+ if len(values) != self.nparameters:
330
+ raise ValueError(f"Provided {len(values)} but Hamiltonian has {self.nparameters} parameters.")
331
+ for i, parameter in enumerate(self._parameters.values()):
332
+ parameter.set_value(values[i])
333
+
334
+ def set_parameters(self, parameter_dict: dict[str, float]) -> None:
335
+ """
336
+ Update a subset of parameters by label.
337
+
338
+ Args:
339
+ parameter_dict (dict[str, float]): Mapping from parameter labels to new numerical values.
340
+
341
+ Raises:
342
+ ValueError: If an unknown parameter label is provided.
343
+ """
344
+ for label, param in parameter_dict.items():
345
+ if label not in self._parameters:
346
+ raise ValueError(f"Parameter {label} is not defined in this hamiltonian.")
347
+ self._parameters[label].set_value(param)
348
+
349
+ def get_parameter_bounds(self) -> dict[str, tuple[float, float]]:
350
+ """Return the lower and upper bounds currently associated with each parameter."""
351
+ return {k: v.bounds for k, v in self._parameters.items()}
352
+
353
+ def set_parameter_bounds(self, ranges: dict[str, tuple[float, float]]) -> None:
354
+ """
355
+ Update parameter bounds.
356
+
357
+ Args:
358
+ ranges (dict[str, tuple[float, float]]): Mapping from parameter labels to ``(lower, upper)`` bounds.
359
+
360
+ Raises:
361
+ ValueError: If an unknown parameter label is provided.
362
+ """
363
+ for label, bound in ranges.items():
364
+ if label not in self._parameters:
365
+ raise ValueError(
366
+ f"The provided parameter label {label} is not defined in the list of parameters in this object."
367
+ )
368
+ self._parameters[label].set_bounds(bound[0], bound[1])
240
369
 
241
370
  def simplify(self) -> Hamiltonian:
371
+ """Simplify the Hamiltonian expression by removing near-zero terms and accumulating constant terms.
372
+
373
+ Returns:
374
+ Hamiltonian: Simplified Hamiltonian
375
+ """
242
376
  # 1) Remove near-zero
243
- keys_to_remove = [key for key, value in self._elements.items() if abs(value) < Hamiltonian._EPS]
377
+ keys_to_remove = [
378
+ key for key, value in self._elements.items() if isinstance(value, complex) and abs(value) < Hamiltonian._EPS
379
+ ]
244
380
  for key in keys_to_remove:
245
381
  del self._elements[key]
246
382
 
@@ -252,11 +388,11 @@ class Hamiltonian:
252
388
  ]
253
389
  for key, value in to_accumulate:
254
390
  del self._elements[key]
255
- self._elements[I(0),] += value
391
+ self._elements[PauliI(0),] += value
256
392
 
257
393
  return self
258
394
 
259
- def _apply_operator_on_qubit(self, terms: list[PauliOperator]) -> spmatrix:
395
+ def _apply_operator_on_qubit(self, terms: list[PauliOperator], padding: int = 0) -> spmatrix:
260
396
  """Get the matrix representation of a single term by taking the tensor product
261
397
  of operators acting on each qubit. For qubits with no operator in `terms`,
262
398
  the identity is used.
@@ -269,12 +405,12 @@ class Hamiltonian:
269
405
  """
270
406
  # Build a list of factors for each qubit
271
407
  factors = []
272
- for q in range(self.nqubits):
408
+ for q in range(self.nqubits + padding):
273
409
  # Look for an operator acting on qubit q
274
410
  op = next((t for t in terms if t.qubit == q), None)
275
411
  if op is not None:
276
412
  # Wrap the operator's matrix as a sparse matrix.
277
- factors.append(csc_array(np.array(op.matrix)))
413
+ factors.append(csr_matrix(np.array(op.matrix)))
278
414
  else:
279
415
  factors.append(identity(2, format="csc"))
280
416
  # Compute the tensor (Kronecker) product over all qubits.
@@ -289,13 +425,51 @@ class Hamiltonian:
289
425
  """
290
426
  dim = 2**self.nqubits
291
427
  # Initialize a zero matrix of the appropriate dimension.
292
- result = csc_array(np.zeros((dim, dim), dtype=complex))
428
+ result = csr_matrix(np.zeros((dim, dim), dtype=complex))
293
429
  for coeff, term in self:
294
430
  result += coeff * self._apply_operator_on_qubit(term)
295
431
  return result
296
432
 
433
+ def to_qtensor(self, total_nqubits: int | None = None) -> QTensor:
434
+ """Return the Hamiltonian as a ``QTensor`` built from the sparse matrix representation.
435
+
436
+ Args:
437
+ total_nqubits (int, optional): Specify the total number of qubits that this hamiltonian acts on. Defaults to None.
438
+
439
+ Returns:
440
+ QTensor: The QTensor object representation of the Hamiltonian.
441
+
442
+ Raises:
443
+ ValueError: If the total_nqubits provided is lower than the number of qubits effected by the hamiltonian.
444
+ """
445
+
446
+ nqubits = total_nqubits or self.nqubits
447
+ padding = nqubits - self.nqubits
448
+ if nqubits < self.nqubits:
449
+ raise ValueError(
450
+ f"The total number of qubits can't be less than the number of the qubits effected by this hamiltonian ({self.nqubits})"
451
+ )
452
+
453
+ dim = 2 ** (nqubits)
454
+
455
+ # Initialize a zero matrix of the appropriate dimension.
456
+ result = csr_matrix(np.zeros((dim, dim), dtype=complex))
457
+ for coeff, term in self:
458
+ result += coeff * self._apply_operator_on_qubit(term, padding=padding)
459
+ return QTensor(result)
460
+
461
+ def get_static_hamiltonian(self) -> Hamiltonian:
462
+ """Return a Hamiltonian containing only constant coefficients."""
463
+ out = Hamiltonian()
464
+ for pauli, value in self.elements.items():
465
+ aux: Hamiltonian | PauliOperator = pauli[0]
466
+ for p in list(pauli)[1:]:
467
+ aux *= p
468
+ out += aux * value
469
+ return out
470
+
297
471
  def __iter__(self) -> Iterator[tuple[complex, list[PauliOperator]]]:
298
- for key, value in self._elements.items():
472
+ for key, value in self.elements.items():
299
473
  yield value, list(key)
300
474
 
301
475
  # ------- Equality & hashing --------
@@ -304,10 +478,12 @@ class Hamiltonian:
304
478
  if other == Hamiltonian.ZERO:
305
479
  return bool(
306
480
  len(self._elements) == 0
307
- or (len(self._elements) == 1 and (I(0),) in self._elements and self._elements[I(0),] == 0)
481
+ or (len(self._elements) == 1 and (PauliI(0),) in self._elements and self._elements[PauliI(0),] == 0)
482
+ )
483
+ if isinstance(other, Number):
484
+ return bool(
485
+ len(self._elements) == 1 and (PauliI(0),) in self._elements and self._elements[PauliI(0),] == other
308
486
  )
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
487
  if isinstance(other, PauliOperator):
312
488
  other = other.to_hamiltonian()
313
489
  if not isinstance(other, Hamiltonian):
@@ -322,7 +498,7 @@ class Hamiltonian:
322
498
  return hash(items_frozen)
323
499
 
324
500
  def __copy__(self) -> Hamiltonian:
325
- return Hamiltonian(elements=self.elements.copy())
501
+ return Hamiltonian(elements=self._elements.copy())
326
502
 
327
503
  # ------- String representation --------
328
504
 
@@ -356,9 +532,9 @@ class Hamiltonian:
356
532
  return s
357
533
 
358
534
  # We want to place the single identity term (I(0),) at the front if it exists
359
- items = list(self._elements.items())
535
+ items = list(self.elements.items())
360
536
  try:
361
- i = next(idx for idx, (key, _) in enumerate(items) if len(key) == 1 and key[0] == (I(0)))
537
+ i = next(idx for idx, (key, _) in enumerate(items) if len(key) == 1 and key[0] == (PauliI(0)))
362
538
  item = items.pop(i)
363
539
  items.insert(0, item)
364
540
  except StopIteration:
@@ -397,23 +573,102 @@ class Hamiltonian:
397
573
  return " ".join(parts)
398
574
 
399
575
  @classmethod
400
- def parse(cls, ham_str: str) -> Hamiltonian:
401
- ham_str = ham_str.strip()
576
+ def from_qtensor(cls, tensor: QTensor, tol: float = 1e-10, prune: float = 1e-12) -> Hamiltonian:
577
+ """
578
+ Expand a qtensor (dense operator) on n qubits into a sum of Pauli strings,
579
+ returning a qilisdk.analog.Hamiltonian.
580
+
581
+ Args:
582
+ tol (float): Hermiticity check tolerance.
583
+ prune (float): Drop coefficients whose absolute value satisfies ``abs(c) < prune`` to reduce numerical noise.
584
+
585
+ Returns:
586
+ Hamiltonian: Sum_{P in {I,X,Y,Z}^{⊗ n}} c_P * P with c_P = Tr(qt * P) / 2^n
587
+
588
+ Raises
589
+ ValueError: If the input is not square, not a power-of-two dimension, or not Hermitian w.r.t. `tol`.
590
+ """
591
+ A = np.asarray(tensor.dense)
592
+
593
+ dim = tensor.shape[0]
594
+ n = round(np.log2(dim))
595
+ if 2**n != dim:
596
+ raise ValueError(f"Matrix dimension {dim} is not a power of two.")
597
+ if not tensor.is_hermitian():
598
+ raise ValueError("Matrix is not Hermitian within tolerance; cannot form a Hamiltonian.")
599
+
600
+ # QiliSDK Pauli constructors indexed by qubit id
601
+ pauli_for: dict[int, Callable[[int], Hamiltonian]] = {0: I, 1: X, 2: Y, 3: Z}
602
+
603
+ # Normalization from orthonormality: Tr(P_a P_b) = 2^n δ_ab
604
+ norm = 1.0 / (2**n)
605
+
606
+ # Prebuild per-qubit operator “letters” so we can construct full strings quickly
607
+
608
+ H = Hamiltonian() # start additive Hamiltonian expression
609
+ # Full Pauli basis (includes identity on any subset automatically)
610
+ for word in product((0, 1, 2, 3), repeat=n):
611
+ # Compose the word into a QiliSDK operator acting on the proper qubits
612
+ # Example word=(3,1,0) for n=3 -> Z(0)*X(1)*I(2)
613
+ op = Hamiltonian()
614
+ for q, letter in enumerate(word):
615
+ # multiply by the operator on qubit q
616
+ op = op * pauli_for[letter](q) if op != 0 else pauli_for[letter](q)
617
+
618
+ # Convert to dense once; no padding needed because it spans all n qubits
619
+ P_dense = op.to_qtensor(n).dense
620
+
621
+ # Coefficient c_P = Tr(A P) / 2^n (P is Hermitian)
622
+ c = norm * np.trace(A @ P_dense)
623
+
624
+ # Numerical safety: coefficients should be real for Hermitian A and P
625
+ if abs(c.imag) < tol:
626
+ c = c.real
627
+
628
+ if abs(c) > prune:
629
+ H += c * op
630
+
631
+ # Optional: verify round-trip (use a slightly looser atol to tolerate pruning)
632
+ if not np.allclose(H.to_qtensor(n).dense, A, atol=max(10 * prune, 1e-9)):
633
+ # If this triggers, consider lowering `prune` or raising `tol`.
634
+ raise ValueError("Pauli expansion failed round-trip check; try adjusting tolerances.")
635
+
636
+ return H
637
+
638
+ @classmethod
639
+ def parse(cls, hamiltonian_str: str) -> Hamiltonian:
640
+ hamiltonian_str = hamiltonian_str.strip()
641
+
642
+ # 1) remove *all* spaces inside any ( … ) group (coefficients or indices)
643
+ hamiltonian_str = re.sub(
644
+ r"\(\s*([0-9A-Za-z.+\-j\s]+?)\s*\)",
645
+ lambda m: "(" + re.sub(r"\s+", "", str(m.group(1))) + ")",
646
+ hamiltonian_str,
647
+ )
648
+
649
+ # 2) collapse multiple spaces down to one (outside the parens now)
650
+ hamiltonian_str = re.sub(r"\s+", " ", hamiltonian_str)
651
+
652
+ # 3) ensure a single space between a closing “)” and the next operator token like X(0)/Y(1)/etc.
653
+ hamiltonian_str = re.sub(r"\)\s*(?=[XYZI]\()", ") ", hamiltonian_str)
654
+
402
655
  # Special case: "0" => empty Hamiltonian
403
- if ham_str == "0":
656
+ if hamiltonian_str == "0":
404
657
  return cls({})
405
658
 
406
- elements: dict[tuple[PauliOperator, ...], complex] = defaultdict(complex)
659
+ elements: dict[tuple[PauliOperator, ...], complex | Term | Parameter] = defaultdict(
660
+ complex
661
+ ) # TODO (ameer): the parsing doesn't support Term and Parameters
407
662
 
408
663
  # 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
664
+ if not hamiltonian_str.startswith("+") and not hamiltonian_str.startswith("-"):
665
+ hamiltonian_str = "+ " + hamiltonian_str
411
666
 
412
667
  # Replace " - " with " + - " so each term is split on " + "
413
- ham_str = ham_str.replace(" - ", " + - ")
668
+ hamiltonian_str = hamiltonian_str.replace(" - ", " + - ")
414
669
 
415
670
  # Split on " + "
416
- tokens = ham_str.split(" + ")
671
+ tokens = hamiltonian_str.split(" + ")
417
672
  # Remove any empty tokens (can happen if the string started "+ ")
418
673
  tokens = [t.strip() for t in tokens if t.strip()]
419
674
 
@@ -475,19 +730,74 @@ class Hamiltonian:
475
730
 
476
731
  return coeff_val, ops
477
732
 
478
- for tok in tokens:
479
- coeff, op_list = parse_token(tok)
733
+ for token in tokens:
734
+ coeff, op_list = parse_token(token)
480
735
  if not op_list:
481
736
  # purely scalar => store as (I(0),)
482
- elements[I(0),] += coeff
737
+ elements[PauliI(0),] += coeff
483
738
  else:
484
739
  # Sort operators by qubit for canonical ordering
485
740
  op_list.sort(key=lambda op: op.qubit)
486
741
  elements[tuple(op_list)] += coeff
487
742
 
488
- ham = cls(elements)
489
- ham.simplify()
490
- return ham
743
+ hamiltonian = cls(elements)
744
+ hamiltonian.simplify()
745
+ return hamiltonian
746
+
747
+ def commutator(self, h: Hamiltonian) -> Hamiltonian:
748
+ """compute the commutator of the current hamiltonian with another hamiltonian (h)
749
+
750
+ Args:
751
+ h (Hamiltonian): the second hamiltonian.
752
+
753
+ Returns:
754
+ Hamiltonian: the commutator.
755
+ """
756
+ return self * h - h * self
757
+
758
+ def anticommutator(self, h: Hamiltonian) -> Hamiltonian:
759
+ """compute the anticommutator of the current hamiltonian with another hamiltonian (h)
760
+
761
+ Args:
762
+ h (Hamiltonian): the second hamiltonian.
763
+
764
+ Returns:
765
+ Hamiltonian: the anticommutator.
766
+ """
767
+ return self * h + h * self
768
+
769
+ def vector_norm(self) -> float:
770
+ """
771
+ Returns:
772
+ float: the vector norm of the hamiltonian.
773
+ """
774
+ s = 0
775
+ for coeff, _ in self:
776
+ s += np.conj(coeff) * coeff
777
+ return np.real(np.sqrt(s))
778
+
779
+ def frobenius_norm(self) -> float:
780
+ """
781
+ Returns:
782
+ float: the forbenius norm of the hamiltonian.
783
+ """
784
+ n = self.nqubits
785
+ s = 0
786
+ for coeff, _ in self:
787
+ s += np.conj(coeff) * coeff
788
+ return np.real(np.sqrt(s) * np.sqrt(2**n))
789
+
790
+ def trace(self) -> Number:
791
+ """
792
+ Returns:
793
+ float: the trace of the hamiltonian.
794
+ """
795
+ t = self._elements.get((PauliI(0),), 0)
796
+ if isinstance(t, Parameter):
797
+ return t.evaluate()
798
+ if isinstance(t, Term):
799
+ return t.evaluate({})
800
+ return t
491
801
 
492
802
  # ------- Internal multiplication helpers --------
493
803
 
@@ -520,7 +830,7 @@ class Hamiltonian:
520
830
 
521
831
  # If everything simplified to identity, we store I(0)
522
832
  if not final_ops:
523
- final_ops = [I(0)]
833
+ final_ops = [PauliI(0)]
524
834
 
525
835
  # Sort again by qubit (to keep canonical form)
526
836
  final_ops.sort(key=lambda op: op.qubit)
@@ -541,87 +851,87 @@ class Hamiltonian:
541
851
  key = (op1.name, op2.name)
542
852
  result = Hamiltonian._PAULI_PRODUCT_TABLE.get(key)
543
853
  if result is None:
544
- raise NotSupportedOperation(f"Multiplying {op1} and {op2} not supported.")
854
+ raise InvalidHamiltonianOperation(f"Multiplying {op1} and {op2} not supported.")
545
855
  phase, op_cls = result
546
856
 
547
857
  # By convention, an I operator is always I(0) in this code
548
- if op_cls is I:
549
- return phase, I(0)
858
+ if op_cls is PauliI:
859
+ return phase, PauliI(0)
550
860
  # Otherwise, keep the same qubit
551
861
  return phase, op_cls(op1.qubit)
552
862
 
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
863
  # ------- Public arithmetic operators --------
563
864
 
564
- def __add__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
865
+ def __add__(self, other: Number | PauliOperator | Hamiltonian | Term | Parameter) -> Hamiltonian:
565
866
  out = copy.copy(self)
867
+ if isinstance(other, Term) and not other.is_parameterized_term():
868
+ raise ValueError("Term provided contains generic variables that are not Parameter.")
566
869
  out._add_inplace(other)
567
870
  return out.simplify()
568
871
 
569
- def __radd__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
872
+ def __radd__(self, other: Number | PauliOperator | Hamiltonian | Term | Parameter) -> Hamiltonian:
873
+ if isinstance(other, Term) and not other.is_parameterized_term():
874
+ raise ValueError("Term provided contains generic variables that are not Parameter.")
570
875
  return self.__add__(other)
571
876
 
572
- def __sub__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
877
+ def __sub__(self, other: Number | PauliOperator | Hamiltonian | Term | Parameter) -> Hamiltonian:
878
+ if isinstance(other, Term) and not other.is_parameterized_term():
879
+ raise ValueError("Term provided contains generic variables that are not Parameter.")
573
880
  out = copy.copy(self)
574
881
  out._sub_inplace(other)
575
882
  return out.simplify()
576
883
 
577
- def __rsub__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
884
+ def __rsub__(self, other: Number | PauliOperator | Hamiltonian | Term | Parameter) -> Hamiltonian:
578
885
  # (other - self)
886
+ if isinstance(other, Term) and not other.is_parameterized_term():
887
+ raise ValueError("Term provided contains generic variables that are not Parameter.")
579
888
  out = copy.copy(other if isinstance(other, Hamiltonian) else Hamiltonian() + other)
580
889
  out._sub_inplace(self)
581
890
  return out.simplify()
582
891
 
583
- def __mul__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
892
+ def __neg__(self) -> Hamiltonian:
893
+ return -1 * self
894
+
895
+ def __mul__(self, other: Number | PauliOperator | Hamiltonian | Term | Parameter) -> Hamiltonian:
896
+ if isinstance(other, Term) and not other.is_parameterized_term():
897
+ raise ValueError("Term provided contains generic variables that are not Parameter.")
584
898
  out = copy.copy(self)
585
899
  out._mul_inplace(other)
586
900
  return out.simplify()
587
901
 
588
- def __rmul__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
902
+ def __rmul__(self, other: Number | PauliOperator | Hamiltonian | Term | Parameter) -> Hamiltonian:
903
+ if isinstance(other, Term) and not other.is_parameterized_term():
904
+ raise ValueError("Term provided contains generic variables that are not Parameter.")
589
905
  if isinstance(other, Hamiltonian):
590
906
  out = copy.copy(other)
591
907
  out._mul_inplace(self)
592
908
  return out.simplify()
593
909
  return self.__mul__(other)
594
910
 
595
- def __truediv__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
911
+ def __truediv__(self, other: Number | PauliOperator | Hamiltonian) -> Hamiltonian:
596
912
  out = copy.copy(self)
597
913
  out._div_inplace(other)
598
914
  return out.simplify()
599
915
 
600
- def __rtruediv__(self, other: Complex | PauliOperator | Hamiltonian) -> Hamiltonian:
916
+ def __rtruediv__(self, other: Number | PauliOperator | Hamiltonian) -> Hamiltonian:
601
917
  # (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
918
+ raise InvalidHamiltonianOperation("Division by operators is not supported")
611
919
 
612
920
  __iadd__ = __add__
613
921
  __isub__ = __sub__
614
922
  __imul__ = __mul__
615
923
  __itruediv__ = __truediv__
616
924
 
617
- def _add_inplace(self, other: Complex | PauliOperator | Hamiltonian) -> None:
925
+ def _add_inplace(self, other: Number | PauliOperator | Hamiltonian | Term | Parameter) -> None:
618
926
  if isinstance(other, Hamiltonian):
619
927
  # If it's empty, do nothing
620
928
  if not other.elements:
621
929
  return
622
930
  # Otherwise, add each term
623
- for key, val in other.elements.items():
931
+ for key, val in other._elements.items(): # noqa: SLF001
624
932
  self._elements[key] += val
933
+
934
+ self._parameters.update(other.parameters)
625
935
  elif isinstance(other, PauliOperator):
626
936
  # Just add 1 to that single operator key
627
937
  self._elements[other,] += 1
@@ -629,26 +939,47 @@ class Hamiltonian:
629
939
  if other == 0:
630
940
  return
631
941
  # Add the scalar to (I(0),)
632
- self._elements[I(0),] += other
942
+ self._elements[PauliI(0),] += other
943
+ elif isinstance(other, (Term, Parameter)):
944
+ if isinstance(other, Term):
945
+ if not other.is_parameterized_term():
946
+ raise ValueError(
947
+ "Only Parameters are allowed to be used in hamiltonians. Generic Variables are not supported"
948
+ )
949
+ self._parameters.update({v.label: v for v in other if isinstance(v, Parameter)})
950
+ else:
951
+ self._parameters[other.label] = other
952
+ self._elements[PauliI(0),] += other
633
953
  else:
634
954
  raise InvalidHamiltonianOperation(f"Invalid addition between Hamiltonian and {other.__class__.__name__}.")
635
955
 
636
- def _sub_inplace(self, other: Complex | PauliOperator | Hamiltonian) -> None:
956
+ def _sub_inplace(self, other: Number | PauliOperator | Hamiltonian | Term | Parameter) -> None:
637
957
  if isinstance(other, Hamiltonian):
638
- for key, val in other.elements.items():
958
+ for key, val in other._elements.items(): # noqa: SLF001
639
959
  self._elements[key] -= val
960
+ self._parameters.update(other._parameters) # noqa: SLF001
640
961
  elif isinstance(other, PauliOperator):
641
962
  self._elements[other,] -= 1
642
963
  elif isinstance(other, (int, float, complex)):
643
964
  if other == 0:
644
965
  return
645
- self._elements[I(0),] -= other
966
+ self._elements[PauliI(0),] -= other
967
+ elif isinstance(other, (Term, Parameter)):
968
+ if isinstance(other, Term):
969
+ if not other.is_parameterized_term():
970
+ raise ValueError(
971
+ "Only Parameters are allowed to be used in hamiltonians. Generic Variables are not supported"
972
+ )
973
+ self._parameters.update({v.label: v for v in other if isinstance(v, Parameter)})
974
+ else:
975
+ self._parameters[other.label] = other
976
+ self._elements[PauliI(0),] -= other
646
977
  else:
647
978
  raise InvalidHamiltonianOperation(
648
979
  f"Invalid subtraction between Hamiltonian and {other.__class__.__name__}."
649
980
  )
650
981
 
651
- def _mul_inplace(self, other: Complex | PauliOperator | Hamiltonian) -> None:
982
+ def _mul_inplace(self, other: Number | PauliOperator | Hamiltonian | Term | Parameter) -> None:
652
983
  if isinstance(other, (int, float, complex)):
653
984
  # 0 short-circuit
654
985
  if other == 0:
@@ -663,6 +994,19 @@ class Hamiltonian:
663
994
  self._elements[k] *= other
664
995
  return None
665
996
 
997
+ if isinstance(other, (Term, Parameter)):
998
+ if isinstance(other, Term):
999
+ if not other.is_parameterized_term():
1000
+ raise ValueError(
1001
+ "Only Parameters are allowed to be used in hamiltonians. Generic Variables are not supported"
1002
+ )
1003
+ self._parameters.update({v.label: v for v in other if isinstance(v, Parameter)})
1004
+ else:
1005
+ self._parameters[other.label] = other
1006
+ for k in self._elements:
1007
+ self._elements[k] *= other
1008
+ return None
1009
+
666
1010
  if isinstance(other, PauliOperator):
667
1011
  # Convert single PauliOperator -> Hamiltonian with 1 key
668
1012
  # Then do the single-key Hamiltonian path below
@@ -676,7 +1020,7 @@ class Hamiltonian:
676
1020
 
677
1021
  # Check if 'other' is purely scalar identity => short-circuit
678
1022
  if len(other.elements) == 1:
679
- ((ops2, c2),) = other.elements.items() # single item
1023
+ ((ops2, c2),) = other._elements.items() # single item # noqa: SLF001
680
1024
  if len(ops2) == 1:
681
1025
  op2 = ops2[0]
682
1026
  if op2.name == "I" and op2.qubit == 0:
@@ -684,12 +1028,13 @@ class Hamiltonian:
684
1028
  return self._mul_inplace(c2)
685
1029
 
686
1030
  # Otherwise, we do the general multiply
687
- new_dict: dict[tuple[PauliOperator, ...], complex] = defaultdict(complex)
1031
+ new_dict: dict[tuple[PauliOperator, ...], complex | Term | Parameter] = defaultdict(complex)
688
1032
  for ops1, c1 in self._elements.items():
689
- for ops2, c2 in other.elements.items():
1033
+ for ops2, c2 in other._elements.items(): # noqa: SLF001
690
1034
  phase, new_ops = self._multiply_sets(ops1, ops2)
691
1035
  new_dict[new_ops] += phase * c1 * c2
692
1036
  self._elements = new_dict
1037
+ self._parameters.update(other._parameters) # noqa: SLF001
693
1038
 
694
1039
  else:
695
1040
  raise InvalidHamiltonianOperation(
@@ -697,7 +1042,7 @@ class Hamiltonian:
697
1042
  )
698
1043
  return None
699
1044
 
700
- def _div_inplace(self, other: Complex | PauliOperator | Hamiltonian) -> None:
1045
+ def _div_inplace(self, other: Number | PauliOperator | Hamiltonian) -> None:
701
1046
  # Only valid for scalars
702
1047
  if not isinstance(other, (int, float, complex)):
703
1048
  raise InvalidHamiltonianOperation("Division by operators is not supported")